Syllabus info
What class is this? CSci 133 (123 is a prereq.)
Who am I? Andrew Clifton, “Andy” or “Mr. Clifton” or “Prof. Clifton” are all fine.
What is this class about? If 123 gave you the tools to get started, 133 is about the tools themselves: how do they work, how were they built, how can we build new ones ourselves? So some of the things that you just used in 123, we’re actually going to build from scratch ourselves, to help understand how they work.
Grading
This course uses specifications grading a point-less (ha!) grading system where your final grade is based on proving to me that you’ve mastered the material in the course. Fortunately for you, this is fairly simple: this class is divided into 8 “modules”. Each module has an assignment and a section on the midterms. To pass an assignment, you must pass its assignment and section. (Note that later midterms include all the sections from previous ones, so if you fail a section on a midterm, you can just try again on the next one. There is no penalty for failing a section as long as you later pass it.) There are three midterms and the final, but the final is basically just the fourth midterm, so each test effectively adds the two most recent modules.
For a C you have to pass 5 of the eight modules.
For a B you have to pass 6 of the eight modules.
For an A you have to pass all 8 modules, except that you only have to do 7 assignments. However, on one of your assignments, you have to do the Personal Software Process (more on that in a bit).
Depending on how things turn out, I might apply an adjustment at the end of the semester to keep things fair (e.g., drop the requirement for an A to 7/8 if no one gets all 8) but you shouldn’t count on that.
I’ll be posting your grades to Canvas, but it will basically just be 0/1 for each module. 1 indicating that you’ve passed it at some point.
Assignments are graded pass/fail, but you can resubmit failed assignments as many times as you like. Because each assignment includes automated tests telling you whether or not it passes, there’s really no point in submitting an assignment that you know will fail.
I probably shouldn’t point this out, but note that if you’re only aiming for a C, you could technically stop coming to class after you’ve completed your 5 modules and your grade won’t suffer at all.
The Personal Software Process
The PSP is a software development “exercise” (think pushups) to help you get some sick gainz in your software development skills. Essentially, it requires you to make estimates, before you begin coding, as to how many lines of code, how much time, how many bugs, etc. you think you will produce. Then, while you are programming you keep detailed records for all those categories. When you’re done, you compare your estimates with your real-world performance.
I wouldn’t suggest doing the PSP on the first assignment, as you’re likely to mess something up and have to resubmit. The upside is that the PSP forces you to pay attention to details, so you’re more likely to catch your own mistakes.
Note that the PSP is something you have to do at the same time as the assignment. You can’t do the assignment and then fill it out afterwards; you’ll get bad data because you’ll have forgotten about all the little bugs you fixed, and how much time you spent fixing them.
Logging into the server
What you need:
Hostname:
fccsci.fullcoll.edu
Port: 5150
Username: Usually your first initial followed by your last name. If that doesn’t work, let me know and I’ll look it up for you.
Password: defaults to your student ID, without the
@
at the beginning.A SSH client. PuTTY on Windows, built-in on Mac OS X and Linux (via the terminal)
On Mac/Linux:
ssh username@fccsci.fullcoll.edu -p 5150
Replace username with your username. It will prompt you for your password.
On PuTTY, fill in the host and port and then click Connect. It will prompt you for your username and password. You can also “save” your connection details from the connect screen so you don’t have to type them in every time.
Moving around
cd subdirectory
to move downcd ..
to move upcd ../practice
you can mix and match (move up and then down).cd practice/..
what does this do?cd ~
to get back to your homels
list things in the current directoryls -l
detailed listingtree
List things in the current directory and all subdirectories.cp source destination
Copy the file at source to destinationmv source destination
Move (rename) the file at source to destination
Editing and Compiling
There are four editors on the server: Micro, Nano, Emacs, and VIM. All have their advantages and disadvantages:
Micro: works like you expect (Ctrl-S to save, can use mouse to select). It’s still in development, so some parts are a little buggy.
Nano: easy to get started with, not a lot of fancy features.
Emacs: tons of cool features, hard to get started with. (How do I quit? How do I save?)
Vim: Cool features, very weird non-intuitive way of working. (Why doesn’t anything show up when I type?)
Compiling using GCC:
g++ -c source1.cpp
g++ -c source2.cpp
...
g++ -o program source1.o source2.o ...
or for short
g++ -o program source1.cpp source2.cpp ...
You can list multiple source/object files in the second form.
But I’ve also built a shortcut for you:
compile source1.cpp source2.cpp ...
This will compile-and-link all the listed source files, creating an executable
named source1
. (I.e., named after the first source file.) Even better,
compile
handles figuring out the right order in which to list source files!
(Remember that if you use G++ you have to list source files in a particular
order, based one which files use which other files.)
If you want to compile-and-link-and-run you can do
compile source1.cpp source2.cpp ... && ./source1
The program will only run if the compile was successful.
Using compile
also has the advantage that it will colorize the error
messages printed by G++.
You don’t have to do your development on the server; if you want to work in Visual Studio or whatever on your own machine, that’s fine. However, the server is where I will collect your submissions, and compile and test your code, so you must make sure that your source code is on the server before the due date, and that it compiles and runs on the server. I don’t accept email submissions, and “it works on my machine” isn’t good enough. (If you write correct standard C++ it will work everywhere; a program that works with one compiler and not with another is a program that has something wrong with it.)
You should also make sure that whatever compiler/IDE you use understands C++11.
You might have to change an option to enable C++11 mode, otherwise you’ll get
error messages when you try to write code using the ranged-for loops or
nullptr
.
To move files to/from the server you can use FileZilla (on Windows/Linux/Mac) or WinSCP (on Windows), or SCP from the command-line (Linux/Mac). You still need all the above information. To use SCP from the command-line you can do
scp -P 5150 path/localfile.cpp username@fccsci.fullcoll.edu:path/remotefile.cpp
to copy the local (on your computer) file path/localfile.cpp
into the
remote (on the server) file path/remotefile.cpp
. Just as on the server, you
can use ~
in either the local or remote path to refer to either your local
or remote home directory.
To use FileZilla or WinSCP, you need the above information (host, port, username, password); for FileZilla set the protocol to “SFTP”. Once you connect, you should be able to drag-and-drop files between your computer and the server.
I’ve heard that you can install FUSE for Mac OS
and MacFusion which will give you the ability to
permanently “mount” the server like a folder in Finder, but you’re on your own
if you want to do that. (On Linux it’s possible to use sshfs
to mount the
server as a directory.)
Finally, you can run Emacs on your personal computer and use TRAMP to both save/load files on the server and even to do your compilation on the server.
Review Example 1: The bag
data structure
Although most of you just had 123 last semester, I thought it would be good to review C++, just to make sure we’re all on the same page, especially since not all of you had 123 from me.
We’re going to begin our review by building a basic data structure called a “bag”. The idea of a bag is its something that supports the following operations:
You can construct a bag with a given capacity (maximum size), but initially every bag is empty.
Bags can only contain integers (
int
) for right now.You can construct more than one bag, each with a different capacity and contents, and they are all independent of each other (changes to one bag should not affect any other bag).
You can ask for the
size()
of the bag (the number of things in it), which will always be ≤ the capacity set when the bag was constructed.You can check to see if the bag is
empty()
(i.e.,size() == 0
) orfull()
(size() ==
capacity). These returntrue/false
.By using
at(n)
, you can access at each of thesize()
elements in the bag, wheren
is an integer in the range 0 tosize() - 1
. E.g., you could print out the contents of a bagb
usingfor(int i = 0; i < b.size(); ++i) cout << b.at(i) << endl;
The order in which elements occur is unspecified, and might change as elements are added/removed from the bag.
If you try to access
at(n)
withn < 0
orn >= size()
then we throw anout_of_range
exception.You can
insert(e)
a new elemente
into the bag, as long as it is notfull()
. (If the bag isfull()
, theninsert(e)
should do nothing.) Inserting new elements might change the order of the existing elements. I.e., if you print out a bag, insert something, and then print it again, potentially all the elements might be in a different order.The same element can be
insert
ed more than once, and all the copies will show up somewhere in the contents of the bag (but again, not in any particular order).You can
remove(i)
the i-th element, shrinking the size by 1. Note that i is not the value of the element to be removed, but its index. Again, this might change the order of the other elements. Ifi < 0
ori >= size()
thenremove(i)
should do nothing.You can remove all the elements from a bag using
clear()
, effectively setting the size to 0.And, of course, a bag should behave in the way that all other C++ data structures do: it should not leak memory, cause crashes (segfaults) when used correctly, etc.
An example of using a bag might look something like this:
bag b(10); // Capacity (max. size) = 10
b.insert(1);
b.insert(2);
b.insert(3);
cout << b.size() << endl; // Prints 3
cout << b.empty() << endl; // Prints 0 (false)
cout << b.full() << endl; // Prints 0 (false)
// This should print 1,2,3, but not necessarily in that order.
for(int i = 0; i < b.size(); ++i)
cout << b.at(i) << endl;
b.remove(2);
cout << b.size() << endl; // Prints 2
// This should print 1,3, but not necessarily in that order.
for(int i = 0; i < b.size(); ++i)
cout << b.at(i) << endl;
Because we can construct multiple bags, and because bags are required to be independent, we will pretty must have to implement bags as a class. The space used by the bag will have to be stored as a data member inside the class, so that each instance of the class gets its own space for its own elements.
class bag {
public:
private:
};
We could use a std::vector
to store the contents of each bag, but I’m going
to go a bit lower level and use a pointer to dynamically-allocated (i.e., heap)
memory. We will have to manage this memory using new
and delete
ourselves.
Since bags store ints
, each bag will have an int*
member, pointing to the
space it is using:
class bag {
public:
private:
int* data;
};
(If you remember 123, this means we will need to implement a destructor,
copy constructor, and copy-assignment operator in order for bag
to function
correctly.)
To consider how to create this storage (in the constructor), we have to think about how we will use it:
What happens when we
insert
an element?What happens when we
remove
an element?
Those are the only two operations that change the size
of a bag (clear
can be thought of as just while(!b.empty()) b.remove(0);
). There are two
basic ways we can implement these on top of a dynamically-allocated array:
Every
insert
, we could create an entirely new array, one element larger than the old one, copy all the old elements over, and then delete the old array. Everyremove
we would do the opposite process: create a new smaller array, copy everything over, and then delete the old array. This works, but its slow and it doesn’t take advantage of the relaxed requirements a bag has: a bag doesn’t need to keep its elements in order all the time, and every bag has a maximum size, beyond whichinsert
does nothing.Because we know a bag’s maximum size at the point of its construction, we can preallocate that much space in advance, and then just keep track, in a separate data member, how much of that space we are actually using. I.e., we will have two additional data members,
cap
will store the maximum size set during construction (and never changed afterwards), whilesz
will store the current size, which changes with everyinsert
andremove
. Using this method,insert
andremove
don’t need to do any big copies.
The second approach is probably a better fit. (It’s also the method that the first assignment requires you to use.)
How do we insert
a new element (assuming the bag is not full
)? Because
insert
is not required to place the element in any particular position, the
easiest place to put it is at the end:
void insert(int e)
{
if(!full()) {
data[sz] = e;
++sz;
}
}
This could be shortened to just data[sz++] = e;
if you’re feeling clever.
How do we remove an element? If the element is at the end, it’s easy to “remove”:
just decrement size
and its effectively gone (it’s still there in memory,
of course, but it will be overwritten by a later insert
). What if the element
is not at the end? Again, remove
is not required to preserve the order of the
other elements, so we can just swap the to-be-removed element with the last
element, reducing the case of remove(i)
to remove(size()-1)
:
void remove(int i)
{
if(i >= 0 && i < size()) {
std::swap(data[i], data[size() - 1]);
--sz;
}
}
Those are the most complex methods. The rest of the class looks like:
class bag {
public:
bag(int c)
{
cap = c;
sz = 0;
data = new int[cap];
}
int size() { return sz; }
bool empty() { return size() == 0; }
bool full() { return size() == cap; }
int at(int i)
{
if(i >= 0 && i < size())
return data[i];
else
throw std::out_of_range("at(i) out of range!");
}
void insert(int e)
{
if(!full)
data[size++] = e;
}
void remove(int i)
{
if(!empty()) {
std::swap(data[i], data[size()-1]);
--sz;
}
}
void clear()
{
sz = 0;
}
private:
int cap, sz;
int* data;
};
This implementation will work, however it has two problems:
It leaks memory: the memory we create in the constructor is never freed.
If we try to copy a bag, we end up with two bags sharing the same memory, which will lead to all sorts of problems.
To fix this, we must add the “big three”: destructor, copy constructor, and overloaded assignment operator:
// Destructor
~bag()
{
delete[] data;
}
// Copy constructor:
// bag b2 = b1;
bag(const bag& other)
{
sz = other.sz;
cap = other.cap;
data = new int[cap];
// Copy bag contents:
for(int i = 0; i < sz; ++i)
data[i] = other.data[i];
}
// Overloaded assignment operator:
// b2 = b1;
bag& operator= (const bag& rhs)
{
// "Copy and swap";
bag copy = rhs;
// Swap *this with copy
std::swap(sz, copy.sz);
std::swap(cap, copy.cap);
std::swap(data, copy.data);
return *this;
}
The easiest way to write the overloaded assignment operator is to combine the steps of the destructor and copy constructor:
delete[] data
Create a
new
data of therhs.cap
capacity.Copy all elements from
rhs.data
into the newly-createddata
.
However, this has two problems: one, it involves writing the same code twice,
and two, if anything goes wrong, notice that the bag is left in a messed-up
state. If something happens between delete
and new
, we have a bag whose
data
points to already-delete
d memory. The “copy-and-swap” idiom reverses
these steps, and reuses the code we’ve already written in the copy constructor
and destructor: it first makes a copy, and then swaps the copy with *this
.
(When the copy goes out of scope, it will be destroyed, but it contains our
original data.)
The disadvantage to the copy-and-swap idiom is that it requires twice as
much memory, because both the copy and the original must exist together, for
a short period of time. This is the only way to get true safety, unfortunately,
so that if anything goes wrong, we still have a valid bag
.
C++11 coolness
With C++11 there are a couple of additional constructors/overloads we can add, bringing the big-three up to the big-five. Unlike the copy constructor and overloaded assignment operator, these are not required for correct operation: your code will work just fine without them. These make your programs faster in certain circumstances.
To see why these are needed, let’s add a method to bag
remove_duplicates
which returns a new bag, the same as the existing one except that there’s only
one copy of each unique element:
bag remove_duplicates()
{
bag b(cap);
for(int i = 0; i < size(); ++i) {
bool found = false;
for(int j = 0; j < b.size(); ++j)
if(at(i) == b.at(j)) {
found = true;
break;
}
if(!found)
b.insert(at(i));
}
return b;
}
Consider what happens if we do something like this:
bag b1(10);
// Insert stuff into b1
bag b2 = b1.remove_duplicates();
In the last line, the following steps are performed:
remove_duplicates
constructs, populates, and returns a new bag. This bag is a temporary object: it lives until the;
at the end of the declaration.The copy constructor is called to copy the data from the temporary bag into the newly-created
b2
bag.The temporary bag’s destructor is called.
It seems a little wasteful to create a bag, copy from it, and then immediately
destroy it. Why not just steal from the temporary, and then tell the temporary
somehow that it doesn’t need to bother delete
-ing data
in its destructor.
This way, the new bag built by remove_duplicates
is effectively constructed
in-place, inside of b2
, instead of being built separately and then copied.
This process is referred to as a move, and it can only occur in particular circumstances: We can only move from a temporary object, and object that will soon be destroyed. This is because moves are implemented as “stealing resources”; if we stole resources from a long-lived object, it would cause all kinds of problems. Stealing from a temporary is fine, though, because they were going to die soon anyway.
The move constructor looks like this:
bag(bag&& other)
{
// Steal from other
data = other.data;
sz = other.sz;
cap = other.cap;
// Make sure other doesn't destroy *our* data
other.data = nullptr;
}
bag&&
is a special kind of reference called an rvalue-reference. “Rvalue”
is the technical name for “temporary object”. bag&&
can only bind to
temporary objects, never to “real” bags. (Also note that unlike the normal
copy constructor, we do not pass other
as const
, because we intend to modify
other
, by stealing from it.) At the end of the move-constructor, we set
other.data = nullptr
, so that when other
is destroyed, it won’t do anything
(delete[] nullptr
always does nothing).
The move assignment operator is similar, except that we destroy our own data
first:
bag& operator= (bag&& rhs)
{
delete[] data;
data = rhs.data;
sz = other.sz;
cap = other.cap;
other.data = nullptr;
return *this;
}
Additional operations
There are a few additional operations we can add to our bag to make it more useful:
We can write a method
find(x)
which returns the index ofx
if it is in the bag, or -1 if it is not.We can write a method
count(x)
which returns how many copies of ofx
are in the bag.We can write a method
exists(x)
which returns true if valuex
is in the bag, and false if it is not. (Note thatexists
can usefind
orcount
.)
find
looks like this:
int find(int x)
{
for(int i = 0; i < size(); ++i)
if(at(i) == x)
return i;
return -1; // Not found
}
while count
looks like this:
int count(int x)
{
int c = 0;
for(int i = 0; i < size(); ++i)
if(at(i) == x)
++c;
return c;
}
(You might notice that I use the size()
method instead of sz
and the at
method instead of accessing the array directly. This is to help “future proof”
my class. If I later decide I want to change how the class is implemented, I
only have to update size
, at
insert
and remove
; find
and count
don’t need to change.)
Given these two, we have a choice as to how to write exists
:
bool exists(int x) { return find(x) != -1; }
bool exists(int x) { return count(x) > 0; }
Which is better? This brings us to the question of algorithmic efficiency.
Both find
and count
contain a loop up to size()
, so they could both
potentially have to scan through the entire array. However, there is a crucial
difference: find
, because it is only looking for the first copy, and not
all copies, can exit the loop early. It returns as soon as it finds its target,
which means that, on average, find
will be faster than count
. Hence, we
should write exists
in terms of find
.
Const-correctness
C++ allows us to declare variables as const, indicating that we are not allowed to modify them:
const int x = 12;
++x; // ERROR!
A const bag
is not particularly useful, because we can’t insert
anything
into it, but still, for completeness, we will make the bag
class const-correct.
To do so, we look at each method and ask whether or not it modifies the
data members of the bag. If it does not, we label it as const
, like this:
bool exists(int x) const
{
return find(x) != -1;
}
Note that a const
method cannot call a non-const
method, so this forces
find
to be const
also. In the end, only insert
and remove
need to be
non-const
.
We can create a const
reference to a non-const
object, so we can do things
like this:
bag b;
b.insert(1);
b.insert(2);
b.insert(3);
const bag& br = b;
cout << br.at(0) << endl; // Fine
br.insert(12); // ERROR
Template classes
Currently, our bag only stores int
s. We can make it more flexible, so that it
can store any kind of value, by making it into a template class:
template<typename T>
class bag {
...
};
Within the class definition, wherever we used int
for the type of the elements
of the elements of the bag, we replace it with T
.
template<typename T>
class bag {
public:
bag(int c) ...
void insert(T x) ...
private:
T* data;
int sz, cap;
};
Note that we do not replace every int
: some int
s represent the size or
capacity of the bag, or indexes within the bag. Only those that represent
values within the bag become T
s.
After making this change, we can build a bag of strings via
bag<string> bs;
bs.insert("Hello");
Review Example 2: Text processing
Suppose we want to read a line of input from the user, split it into an array of “words”, and then filter out certain “noise” words.
We need to do several things:
Read a line of input from the user.
Split it into words.
Filter out the noise words
Extract the verb and nouns that are left (easy)
Representing strings of text: some of you may have come from a class where you
used char*
as strings. Here we’re going to use the built-in string
class
which comes with “batteries included” and doesn’t require you to worry about
allocation or length or anything:
#include <string>
using std::string;
...
string line;
getline(cin, line); // Read a line of text from cin into `line`
Now we need to split the line into words, separated by spaces.
How are we going to store our list of words? We could use an array, but then we’d
have to decide on a maximum size, and keep track of how many words we had actually
read in. All of our current commands have relatively few words, so this isn’t
too bad, but we’d prefer something simpler. We’re going to use the built-in
vector
class.
#include <vector>
using std::vector;
A vector
is like an array: you can access elements at a specific numeric
index via
v.at(n)
v[n]
The first is “safe” in that if \(n \lt 0\) or \(n \gt \) the size of the vector it will throw an exception. The second is unsafe (it doesn’t do any “bounds-checking”) but it’s just as fast as a plain array access.
The nice thing about vectors is that we can add new elements onto the end, without having to worry about how big the vector is; the vector will resize itself when necessary:
vector<string> words;
words.push_back("word"); // Add another word
Vectors should normally be preferred to “raw” arrays in almost every situation
(the exception is when you are implementing low-level data structures; e.g.,
when we sit down to write our own vector
class, you’ll have to use arrays to
build it). Vectors are just as fast as arrays, and safer and more flexible to
boot.
To accomplish the splitting, we have two methods we can use:
We can write a loop to do it ourselves. We’ll have to keep track of whether we are “inside” a word, and if so, collect characters into a string. When we transition from non-word to word, we have to start a new word, and when we go from word to non-word, we have to add the word to the list of words.
Note that with a string, we don’t need to pick a “maximum size”; we can add a new character to the end by just doing
s.append(c);
and the string will be expanded to hold it.
To use this method, we need to figure out all the things that can “happen” while reading the next character: it can be a word character (i.e., not a space) or a space character. There’s a third option, however, that is easy to overlook: the next character might not exist, if we’ve reached the end of the string. So we have two “states” and three possible transitions for each state, which leads us to a maximum of six cases we need to consider:
In word?\next char Space Word End-of-string WORD Finish word Continue word Finish word, end SPACE Ignore Start word End “Finish word” means to add the word that is being constructed to the vector of words, clear the current word, and set the curren state to SPACE. “Start word” means to add the current character to the word (which should be initially empty, because we cleared it in “Finish”), and change the state to WORD. “Continue word” means to add the character to the end of the current word. “End” means we’re done; return the vector of words. “Ignore” means we basically don’t do anything with the current character, just continue to the next one.
We can also draw a graphical representation of this as a state machine diagram (sometimes called a statechart diagram). We’ll see an example of this later.
The one last thing we have to think about is what state to start in. If we look at the states, if we start in WORD and the first character is a space, then we will try to “finish” a word that hasn’t been started yet! On the other hand, if we start in SPACE and see a space, it’s ignored; if we see a non-space, we’ll start a new word. Both of those are fine, so our “start state” should be SPACE.
We can let the standard library do the work for us, and use a
stringstream
. This is basically an input stream, likecin
, except that it gets its input from an existing string, rather than from the user. This is useful to us because if we dostring w; cin >> w;
then
w
will contain the next word (it will skip over any leading spaces, and stop reading when it encounters a space at the end).
Using a vector to hold the words, and a stringstream
to get them we have
something like this:
// same comment
vector<string> split_words(string input) {
vector<string> words;
string current_word;
std::stringstream in(input);
while(in >> current_word)
words.push_back(current_word);
return words;
}
(Note that a stringstream can also be used for output, like cout
, so that
anything you write to it gets “printed” into the string. That’s not useful
for what we’re doing here, but you might find it useful in other problems.)
One thing you should notice is that I have no problems with you using the standard library. It’s there to make your life easier, so why not use it? (The exception is, of course, if I ask to you to rewrite something provided by the library; then I expect you to actually write it yourself.) When possible, you should prefer high-level methods (which is what the library provides) to lower-level ones (like doing everything yourself).
Coding standards
Some things to note about the coding standard that I use:
Four space indent (not tabs). The exception are the access qualifiers in classes; those should be indented two spaces. (Sometimes I may forget and use a two-space indent everywhere.)
Every function (and class) starts with a block comment giving the function signature (minus types), a short description of what it does, a description of what each parameter is for, and a description of what the return value is.
Functions which make assumptions about their parameters, or about global variables will have an
Assumes
section describing what these assumptions are, and likewise anEnsures
section describing what will be true after the function completes. Functions that throw exceptions should have aThrows
section describing what exceptions might be thrown.Here, I’ve put the comment before the function definition. For functions exported via a header file, put the comment in the
.h
file; you don’t need to write anything in the.cc
file (because that is part of the implementation).Note that this comment is the template for your comments. I want to see this format, not just any block comment before a function.
Function, variable, and class names are all lowercase, words separated by underscore. The only uppercase letters used are for compile-time constants (declared via
const
, never via#define
) and template type parameters.Variable names should be descriptive, although short variable names are fine when the context makes their meaning clear (e.g., loop variables). Function names should usually be verb-phrases, describing what the function does; functions that return
bool
should have question-like names (is_upper_case
orhas_potato
).Class names (and the names of types in general) do not start with “c” or anything like that. Just name them based on what they represent. In our case, we might have a
location
class, aplayer
class, agame
class, anobject
class, acommand
class, etc.(My view is that the user of a type shouldn’t need to know or care about whether it is implemented as a class, a struct, an union, a typedef, or whatever. If they need that information, then your abstraction is leaking and you should fix it.)
Opening curly braces for functions, classes, etc. go on the same line as the function signature, class name, etc. But note that the curly braces are optional for loops,
if
statements, etc. if the body is just a single statement (hence thewhile
loop with no curly braces).You might be used to just putting
using namespace std;
at the top of all your programs. I prefer to be a bit more precise. We can putusing std::string;
because we’re going to be usingstring
a lot; likewise forusing std::vector;
. For things that you don’t use a lot, just suck it up and writestd::
every time you use it, like I did withstringstream
. Note that you can putusing
declarations inside classes and functions, so if you have a particular class or function that uses something a lot, you can justusing
it in that class or function.
Other C++ features
Templates
I won’t expect you to write templates, but I may occasionally use them in class or ask questions involving them on tests, so you should at least know what a template is and how they work.
Suppose we want to write a function that checks whether a vector contains a
particular element. If we know that we have a vector of int
s we could write
this:
bool has_element(vector<int>& data, int target) {
for(int e : data)
if(e == target)
return true;
return false;
}
Thanks to function overloading we could write versions (with the same name)
for vector<string>
, etc., but every single version would be identical,
except for the element type. What we really want is a way for C++ to generate
the various versions for different types, as they are needed. This is what
templates give us: a way to leave out the types and have C++ fill them in
later, when we use the function:
template<typename T>
bool has_element(vector<T>& data, T target) {
for(T e : data)
if(e == target)
return true;
return false;
}
The leading template<typename T>
says that this is not a normal function
definition but a template definition. A template definition doesn’t actually
define the function, the function itself is defined only when we use the
template function, providing an actual type for T
:
vector<string> names;
if(has_element(names, "John"))
...
Here, I’ve let C++ figure out that T
should be string
. I could also tell
it explicitly:
has_element<string>(names, "John")
Either way, the compiler basically makes a copy of the template definition,
replacing T
with string
, and the proceeds from there.
We can do the same thing with a class:
template<typename T>
class myvector {
public:
...
T at(int index);
private:
T* data;
};
Once again, when we tell C++ what type to use (via myvector<int>
) it will
make a copy, replacing T
with int
and use that.
All you really need to remember is that, inside a template definition,
T
(or whatever type variable(s) are declared in the template
header) can
be treated just like any other type (int
, string
, etc.), with the exception
that we don’t know anything about it yet.
To solve the problem above (finding the smallest element in either a
checked_array
or a array_view
), we can write a function like this:
template<typename T>
int smallest(T array) {
int s = array.at(0);
for(int i = 0; i < array.size(); ++i)
if(array.at(i) < s)
s = array.at(i);
return s;
}
Note that this function will work for any type T
which has methods .at
and
.size
. In fact, it will even work for the vector<int>
type!
Inheritance
The other way to make the two classes compatible is inheritance. We can make both classes inherit from a common abstract base class, which specifies what methods they have. The abstract base class looks like this:
class array_base {
public:
virtual int& at(int i) = 0;
virtual int size() = 0;
};
This definition says that any class which inherits from array_base
must
supply methods .at
and .size
in order to be complete. We can then make
both our classes inherit from this base class:
class checked_array : public array_base { ... };
class array_view : public array_base { ... };
To make use of this base class, we write a function which takes an array_base
,
either by reference or as a pointer:
int smallest(array_base& array) {
int s = array.at(0);
for(int i = 0; i < array.size(); ++i)
if(array.at(i) < s)
s = array.at(i);
return s;
}
This version of the function will only work with subclasses of array_base
. It
won’t work with unrelated classes like vector<int>
.
The advantage to using templates is that the resulting executable code is
customized to the class used: it’s as fast as if you’d written the function
specifically to work with array_view
or checked_array
or vector<int>
. The
disadvantage is that it has to generate a completely different version of the
function for each type you use it on, so the size of your program can get bigger.
The advantage to inheritance is that there’s only one version of the function, so your program is smaller. The disadvantage is that it has to “figure out” what type of array it’s dealing with while the program is running, which slows it down a bit.
Implementing string splitting using strings and vectors
The string
class
#include <string>
using std::string;
string s = "Andy";
If you declare a string variable without initializing it, it defaults to the empty string:
string e; // e is the empty string
You can initialize a string object from a char*
literal. The basic string
operations are:
Operation | Description |
---|---|
s.at(i) , s[i] |
Access individual characters by index (char ) |
s.front() , s.back() |
Access first/last characters |
s.length() , s.size() |
Length of string |
s.empty() |
True if length is 0 |
s.substr(i) |
Extract substring starting at i , to the end |
s.substr(i,l) |
Extract substring, starting at i , of length l |
s1 + s2 |
Concatenate two strings to form a new one |
s1 < s2 , etc. |
Compare strings alphabetically |
s.c_str() |
Get a char* representation of a string |
s.find(c) , s.find(s2) |
Get position of the first occurrence of char c or string s2 in s |
s.rfind(c) , s.rfind(s2) |
Get position of last occurrence |
s.push_back(c); |
Add a character c to the end of the string |
s.pop_back(); |
Delete the last character of the string |
s.clear(); |
Delete all characters from string (len. = 0) |
s1 += s2; , s1.append(s2); |
Append s2 to the end of s1 , modifying s1 |
s1.insert(s2,i); |
Insert s2 into s1 , before position i |
s.erase(i,l); |
Erase characters starting at i , of length l |
s1.replace(s2,i,l); |
Replace l characters of s1 , starting at i , with s2 |
s1 = s2; |
Copy all of s2 into s1 |
find
and rfind
will return the special value string::npos
if the thing
you are searching for cannot be found at all.
Note that string
s do not have a nul character at the end! This means that
the length of a string
is strictly the number of characters in it, and the
last character (s.at(s.length() - 1)
) is not necessarily nul. (In fact,
string
s can have nul characters anywhere inside them!)
To read a single word from cin
, you can use
string w;
cin >> w;
This reads a word because the normal behavior of >>
is to stop at the first
space character it sees.
To read an entire line, use
string l;
getline(cin, l);
You can, of course, print strings normally:
string name = "Andy";
cout << "Hello, " << name << endl;
For our purpose, we’re going to read in an entire line (using getline
) and then
process it one character at a time. We can do the latter using a loop:
for(unsigned i = 0; i < s.length(); ++i)
// Use s[i] ...
Or we can use the fancy ranged-for-loop:
for(char c : s)
// Use c ...
Vectors
You can think of a vector as a version of a string that stores any kind of element, not just characters. The operations supported by vectors are somewhat more limited than those supported by strings, just because vectors cannot make any assumptions about what kind of data they are holding.
vector<int> vi; // empty vector of ints
vector<char> vc1(10); // Vector of 10 chars
vector<char> vc2(10, 'x'); // Vector of 10 'x' chars
vector<string> names = {"Bruce", "Richard", "Alfred"}; // vector of three strings
Vectors manage their own storage, so you don’t need to delete
them.
To access the elements of a vector by index, use either .at(i)
or [i]
:
v.at(i)
returns the element at positioni
(starting at 0 for the first position). Ifi
is less than 0, or larger than the last position, then anout_of_range
exception is thrown. (I.e., this is like our checked array.)v[i]
returns the element at positioni
. Ifi
is out of range, then the behavior is undefined (same as for accessing out-of-range on a normal array). (Some compilers will still check[]
vector accesses when compiling a project with debugging enabled, but you shouldn’t rely on this.)
The shortcut methods v.front()
and v.back()
provide easy access to the
first and last elements. These methods are “checked”: if the vector is empty,
they will throw an exception.
Other vector operations:
Operation | Description |
---|---|
v.size() |
Size of the vector (number of elements) |
v.empty() |
True if size is 0 |
v.clear() |
Reset size to 0, deleting all elements |
v1 == v2 |
True if v1 and v2 are identical |
v.push_back(e); |
Add a new element to the end of the vector |
v.pop_back(); |
Erase the last element of the vector |
v1 = v2; |
Copy all elements from v2 into v1 |
Note that unlike arrays, vectors support copying, via assignment. This means
that you can pass vectors as parameters to functions, and return from functions
as well. This makes vectors much easier to work with than arrays, especially
since you don’t need to worry about dynamically allocating them. The fact that
vectors can grow, via push_back
makes them even more useful; e.g., if you
want to read some number of ints in from the user, and you don’t know how many,
you can just do
vector<int> data;
int x;
while(cin >> x)
data.push_back(x);
It’s possible to insert/erase elements in the middle of a vector, but doing so requires the use of an iterator. Here’s an example to get you started:
vector<int> vs = {1, 2, 3, 4};
vs.insert(vs.begin() + 2, 100); // vs = { 1, 2, 100, 3, 4 }
vs.erase(vs.begin() + 1); // vs = { 1, 100, 3, 4 }
You can also insert the contents of one vector in the middle of another.
Splitting strings
Our table for states/inputs looks like this:
Letter | Space | |
---|---|---|
WORD | Add to cur. word Stay in WORD | Finish word Switch to SPACE |
SPACE | Start new word Switch to WORD | Ignore Stay in SPACE |
Implementing this as a function, it will take a string
as its input,
and return a vector<string>
as its output:
vector<string> split(string input) {
string word; // Current word
vector<string> output; // List of words
const int SPACE = 0;
const int WORD = 1;
int state = SPACE;
for(char c : input) {
}
return output;
}
All of our work needs to be done inside the for
loop. The current character
is c
, and the current state is state
. Since we have four table entries,
we’ll have an if-else
for each of the four possibilities:
for(char c : input) {
if(state == WORD && c != ' ') {
}
else if(state == WORD && c == ' ') {
}
else if(state == SPACE && c != ' ') {
}
else { // state == SPACE && c == ' '
}
}
Taking the first case, what do we need to do to add the character
c
to the current word?word.push_back(c);
To “stay in WORD” we don’t need to do anything at all; if we don’t change the value of
state
then we will stay in the same state.In the second case, we want to “finish the word”. This requires us to do two things: add the word to the vector of words, and then clear the word, resetting it to empty, so that when we start the next word, there won’t be anything in it.
output.push_back(word); word.clear(); state = SPACE;
Third, we want to “start a new word”. Because we cleared the word in the previous case, this just means adding the current character to it.
word.push_back(c); state = WORD;
Finally, “ignoring” a character means doing literally nothing, so the
else
case is actually empty!
There’s one last thing we have to think about, and that’s what happens at the end of the input string:
If
state == SPACE
then there is no current word, so we can just return the vector of words.If
state == WORD
however, then there is still a current word in-progress, and because we never saw a space, we haven’t yet “finished” it. We can handle this in two ways:We can add a space to the end of the input string, at the very beginning of the function:
input.push_back(' ');
We can check the current state at the end, and add the word to the vector if it is still WORD:
if(state == WORD) output.push_back(word); return output;
Which method you use is up to you: some people think its bad manners to modify the parameter values to a function, but doing so makes the end of the function simpler.
Trace through this on the input string "get the rock"
. Run example.
References and pointers
A reference is just another name for an existing variable or location:
int x = 1; // x is a variable, OK
int& y = x; // y is another name for x
Both x
and y
refer to the same thing. It’s impossible to distinguish them,
because they are just different names for the same object. Any changes to x
will be reflected in y
, and vice versa.
Because a reference is an “alias” for something, you must initialize a reference variable with another variable/location (or another reference):
int& z = 1; // ERROR
int& q; // ERROR
(The C++ name for things that you can get a reference to is “lvalue”, as in, values that can be on the left side of an assignment. Temporary objects are rvalues.)
You can, however, get a reference to an element of an array or vector (or string):
int arr[] = {1, 2, 3};
vector<int> vec = {5, 6, 7};
int& y = arr[1]; // OK, another name for arr[1]
int& z = vec[2]; // Also OK
With vectors, this can be a little dangerous, because the size of a vector can change, and thus there’s the possibility that the thing the reference is referring to might disappear. But as long as you are careful, everything is OK.
Functions can take references as parameters, in which case the formal parameter becomes “another name” for whatever argument is used when the function is called. Similarly, functions can return references (as long as the thing referred to still exists after the function exits!)
References can cause some weird behavior: Look at this function; can you see any way in which it might print out a 2, instead of a 1?
void f(int& a, int& b) {
a = 1;
b = 2;
cout << a << endl;
}
References have some limitations:
There’s no way to change a reference, to make it refer to something else. Anything you do to the reference is interpreted as an action on the thing it refers to.
Because of this, there’s (almost) no way to “compare” references to see if they refer to the same thing or not.
Because you can’t change references, there’s no point in having a reference to nothing, because it would always be useless!
Pointers
Pointers remove these limitations, but add some complexity in doing so. A pointer is like a reference that can change what it refers to. However, the simplicity of references comes from the fact that they cannot change; hence, there are no operations on the reference. Anything you do to a reference variable is transparently done to the the on the other end of the reference. Because pointers can change, this means that we now need two different syntaxes:
We need a way of referring to the thing on the other end of the pointer.
We need a way of referring to the pointer itself.
In other words, while references have no “identity”, the don’t exist on their own and are just aliases for other things, pointers are objects in their own right, and thus we need some way of manipulating them, as opposed to the thing they point to. All of the complexity of pointers springs from this duality: we always have to be clear what we are doing, are we manipulating the pointer or the object it points to?
Semantically, pointers work by storing addresses. Every (lvalue) object in our program exists somewhere in the computer’s memory; every location in the computer’s memory has an address, a number. A pointer stores an address of another thing. “Dereferencing” the pointer means going to the address it contains.
Pointer syntax:
Type | Description |
---|---|
T* |
“Pointer to T” |
T& |
“Reference to T” |
Expression | Description |
---|---|
*p |
Get object pointed to by p (look at the addr. in p ) |
&v |
Get pointer to object v (get the addr. of v ) |
The connection between the two is
If
p
has typeT*
then*p
has typeT
If
v
has typeT
then&v
has typeT*
Thus, for expressions, *
and &
are kind of like opposites. &
adds a layer
of “pointer to-ness” while *
removes a layer.
Although pointers are often introduced with dynamic memory, you don’t actually have to do that. Just like with a reference, you can get a pointer to any lvalue:
int x = 1;
int* y = &x; // y points to x
x++; // Increments x
(*y)++; // Also increments x
(Note that ++
has higher precedence than *
, so the parentheses are necessary!)
As with references, you can get a pointer to an element of an array, vector, or string:
int arr[] = {1, 2, 3};
vector<int> vec = {2, 3, 4};
string str = "Hello";
int* ap = &arr[1];
int* vp = &vec.front(); // Same as vec.at(0)
char* cp = &str[3];
Once again, with strings and vectors, because elements can be removed, you have to be careful to make sure that the pointers still point to something that exists! A pointer that points to a non-existent object is called dangling and using it results in undefined behavior!
If we do ap == vp
will this be true or false? What are we asking? We are
asking if ap
and vp
point to the same thing. We are not asking anything
about the values pointed to by ap
and vp
. Similarly, if I do, ap = vp;
what happens? Do any of the values in the array or vector change? No: only the
pointer ap
changes what it is pointing to. The most important thing about
pointers is to get yourself clear on when we are talking about the pointers
themselves, and when we are talking about the things pointed to. If you have
some pointer variables, and there aren’t any *
s in the expression, then you
are talking about the pointers. If there are *
s then you are probably talking
about the objects pointed to.
Pointers to array/vector elements
If we have a pointer into an array, vector, or string, we can do some interesting things with it:
vector<int> vec = {1, 2, 3, 1};
int* p1 = &vec[0];
int* p2 = &vec[3];
Consider the following:
p1 == p2 // True or false?
*p1 == *p2 // True or false?
p1 < p2 // True or false?
p1 + 3 // What does this mean?
*(p1 + 3) // What about this?
p1[3] // This?
p1+3 == p2 // True or false?
p1++; // What happens if we do this?
Pointers into an array can have arithmetic done on them (adding/subtracting integers) and it will cause them to move around within the array. Adding 1 moves the pointer one element forward within the array. Comparisons between pointers effectively compare the the indexes within the array that they point to. Note that none of the above operations change the elements in the array!
Pointers to structure and class instances
There are a few extra things we can do with pointers if we have a struct or a class. Here’s an example struct:
struct thing {
int a;
char* b;
string s;
};
char c = '?';
thing t1 = {1, &c, "Hello"};
thing* tp = &t1;
How do I refer to each of the members of t2
?
(*tp).a // == 1
(*tp).b // == pointer to c
(*tp).c // == "Hello"
This is a bit cumbersome to type out, so there’s a shortcut:
tp->a
tp->b
tp->c
What if I want to access the actual char
pointed to by tp->b
?
*((*tp).b) // == '?'
*(tp->b) // == '?'
We can also get a pointer to a member of the structure:
int* ip = &(t1.a);
= &(tp->a); // Same thing
Finally, nothing stops us from having multiple pointers to the same structure:
thing* tp2 = tp;
tp == tp2 // True
tp->a = 2; // Also changes tp2->a
All of this applies equally to classes, and to methods. If we have a class
class thing {
public:
void run() {
cout << "WHATUP" << endl;
}
};
and then we have a pointer to an instance of that class
thing* t = ... ;
Then we can use either (*t).run()
or t->run()
to dereference t
and
call the run
method:
(*t).run();
t->run(); // Same thing
In this semester, we’ll use a lot of pointers, but they will usually be pointers
to structs or classes, so we’ll use ->
a lot. We’ll almost never use &
,
because we generally won’t need to get pointers to existing objects. We’ll
get our pointers by creating objects dynamically, via new
. (Remember that
new T
returns a T*
, a pointer to a T
.
The null pointer
nullptr
is the value you should use for the null pointer (the pointer that
points to nothing at all), not NULL
. The null pointer is unique: no other,
non-null pointer is ==
to it, and it is ==
only to other null pointers. Thus,
you can use nullptr
to signal that a pointer doesn’t point to anything at all.
This gives pointers a bit of power that normal “values” don’t have. E.g., if we
have an int
variable, there is no way to say that it contains “nothing”; it
always contains some int
value. But if we have an int
pointer then there
are two possibilities:
It is non-null, so then it points to some
int
(and hence has a value)It is null, and thus has no
int
value at all.
Dynamic memory allocation
I’ve intentionally separated pointers from dynamic memory because often students
assume that whenever you have pointers to must also have dynamic allocation
somewhere, but this is not the case. You can get a pointer to anything that
“hangs around”. Dynamically allocated objects are particularly convenient for
this, because they hang around until we delete
them, but its not required to
use them. Dynamic allocation is useful when we don’t how what or how many
objects we will need (or how long they need to live), until our program is running.
Stack vs. heap. Activation records. Heap allocation. Lifetime of objects.
new
finds space on the heap, big enough for an object of the given type,
and then gives you a pointer to that space. (The array-new
finds enough
space for multiple objects of the same type.) Because the object is allocated
on the heap, it will remain “alive” (taking up space) until we delete
it.
A common mistake (coming from the assumption that pointers have to be used with dynamic allocation) is to write something like this:
int x = 12;
...
// Now we want a pointer to x
int* p = new int();
p = &x;
// carry on with p
What’s wrong with this? We create a new (dynamic) int
, but then immediately
discard the pointer to it, replacing it with a pointer to x
. This means that
we have no way to refer to the heap memory we just reserved, and hence
no way to delete it. That memory is lost to our program, until our program
exists; we have created a memory leak. The correct way to do this is
int* p = &x;
// carry on with p
If the thing you want to get a pointer to already exists, then just initialize
your pointer to that; there’s no need to allocate any space. Only use new
when you want to reserve some additional space on the heap.
Similarly, just because we are done with p
does not mean we need to delete
it. In this case, because the thing pointed to by p
was not created via new
,
it should not be destroyed via delete
.
An object can only be delete
-d once; we saw last time that trying to delete
something more than once crashes your program with a “double-free” error. This
means that if we allocate something, we need to decide what part of our program
“owns” it, and that part is responsible for delete
-ing it. A pointer that
points to an object that it will eventually delete is called an “owning” pointer.
It “owns” the object on the other end, and somewhere, that object will be
deleted via that pointer. There should only ever be one owning pointer to an
object; as more than one would mean that the object would be deleted more than
once. There can, of course, be any number of non-owning pointers to an
object, because those won’t delete
it when they are done.
Assignment 1
The first assignment is a kind of C++/CSci 123 review, but also hopefully gets you thinking about the kind of issues that will be important to us. The assignment is to implement an ordered array. A lot of the operations will be similar to those on the bag structure we implemented earlier, but a few are different.
Like a bag, an ordered array has a maximum size (capacity), set when it is created and then never changed after that. (The
ordered_array
class adds a method which returns the capacity.)Like a bag, we can ask for the current
size()
of an ordered array, which will always be ≤ its capacity.Like a bag, we can use
at(i)
to access the i-th element of the array. Unlike a bag, ifi < 0
ori > size()
thenat(i)
shouldthrow std::out_of_range("...");
with a suitable message.at
return a reference, but you don’t have to do anything special to return a reference, so don’t worry about it.The combination of
at
andsize
means that we can loop over the elements of an ordered arrayarr
with:for(int i = 0; i < arr.size(); ++i) cout << arr.at(i) << endl;
The key property of an ordered array is that a loop like this will always process the elements of the array in ascending order. In other words,
arr.at(i) <= arr.at(j)
wheneveri <= j
. As elements are added/removed to/from the array, the sorted order of the elements must be maintained.New elements can be added to the array with
insert(e)
. If the array is full,insert
should do nothing.Existing elements can be removed with
remove(e)
. Note that unlike a bag,remove
takes an element’s value and not its index. If the element does not exist, nothing happens.We can check whether an element exist with
exist(e)
which returns true if it does, and false otherwise.
Because an ordered array has a size, a capacity, and its elements, you will probably need three data members: size, capacity, and a dynamic array of elements (you could also use a vector).
The description of how the ordered array works says that the special value
-2147483648 cannot be stored in the array. E.g., if you try to insert
it,
nothing happens, exists(-2147483648)
always returns false, etc. This seems
arbitrary, but it’s done so that if you want, you can use -2147483648 as a
“special” value internally. E.g., if you store the contents of the ordered
array in a (dynamic) array, you could implement remove(e)
by finding the
location of e
and then simply putting a -2147483648 there, to mark it as
“deleted”. This method will require you to implement every other method to
ignore -2147483648 entries. If you use this method, you don’t need to
store the size
: the size of the array is just the number of entries that
are != -2147483648
.
Alternatively, you could implement it the way we implemented the bag, by
using the front portion of the array, starting at index 0, for the “used”
elements, and the second half for unused elements. Using this method, you must
store the size of the array, and remove
must shift array elements around
(we cannot simply swap to the end, because that would un-sort the array!).
The assignment asks you to think about how much time the various operations will take, relative to the size of the array. E.g., as the array grows, which operations will get slower? Which will take the same amount of time? We’ll investigate these ideas further in the next few lectures.