Introduction to C++
This courses uses C++ as the language in which we explore fundamental concepts in programming. Here, we provide a basic overview of C++ for students who are familiar with other languages such as Python or Java.
A Simple Program
Let’s start with a simple program that prints Hello world!
to the
screen:
#include <iostream>
int main() {
std::cout << "Hello world!" << std::endl;
return 0;
}
The entry point of a C++ program is the main()
function, which is
a top-level (global) function that has a signature of the form int
main()
or int main(int argc, char *argv[])
. A function
signature consists of a return type, the name of the function, and a
list of parameters. The parameters are what the function takes as
input, and the return type is the kind of value that the function
returns. For main()
, it can either have no parameters, or it can
have parameters that allow it to take command-line arguments, which
are specified when the program is run. For simplicity, we start with a
main()
that does not have parameters.
Following the signature of the function, we have the function body,
which is the code that gets executed when the function is called. The
body itself is comprised of statements, which are executed in order
from top to bottom. In our program above, the body of main()
has
two statements: one that prints to the screen, and a second that exits
the function with a return value of 0.
Examining the first statement more closely, we see that it is composed
of expressions and operators. An expression is a fragment of code
that evaluates to some value. The simplest expressions are
literals, which hardcode a specific value in the program. For
instance, "Hello world!"
is a string literal that denotes the
sequence of characters H
, e
, and so on. A name is also an
expression, and what it evaluates to depends on what the name is bound
to in the environment in which the name is evaluated. In the program
above, std::cout
is bound to an object representing the standard
output stream (stdout), which allows printing to the console (also
referred to as shell or terminal) in which the program is run.
Similarly, std::endl
is bound to an object that causes a newline
to be printed when inserted to an output stream. Finally, these
expressions are connected via the binary <<
operator; in this
context, we refer to this as the stream-insertion operator. The code
is inserting the string "Hello world!"
into the standard output
stream, followed by inserting std::endl
to obtain a newline.
Both std::cout
and std::endl
are defined in the iostream
library header. The first line of the program (#include
<iostream>
) instructs the C++ compiler to include the contents of
the iostream
library header, which makes std::cout
and
std::endl
available for us to use.
A name such as std::cout
is called a qualified name – it
consists of the std
qualifier, followed by the ::
scope-resolution operator, followed by the cout
name. The
std
qualifier refers to the std
namespace, which is a scope
in which most standard-library entities are defined. Thus, the
qualified name std::cout
refers to the cout
that is defined
within the std
namespace, as opposed to some other cout
that
might be defined in a different scope. Often, we place a directive in
our program to be able to use an entity such as cout
without
qualifying it with std::
. The following does so for both cout
and endl
:
using std::cout; // we can now use cout without qualification
using std::endl; // we can now use endl without qualification
The following does so for every entity defined in the std
namespace:
using namespace std; // we can now use anything in std without qualification
Use this with caution, however – there are lots of names defined in
the std
namespace, so this makes it much more likely for our names
to conflict with those from the standard library. (In particular, a
using namespace
directive should never be used in a header file.)
The last statement in our program is return 0;
. This returns the
value 0 from the main()
function, which conventionally indicates
that the program completed successfully. We would use a nonzero value
instead to indicate an error if something went wrong.
In the specific case of returning from main()
, a return statement
is actually optional. (This is not the case for returning from other
functions, unless they have a void
return type.) If we don’t have
a return statement in main()
, the compiler implicitly adds a
return 0;
for us. Thus, the program above could be written
equivalently as:
#include <iostream>
using std::cout; // we can now use cout without qualification
using std::endl; // we can now use endl without qualification
int main() {
cout << "Hello world!" << endl;
} // implicit return 0; added by the compiler
Now that we have examined all the pieces of our simple program, let’s
compile and run it in a shell. Assuming the code is in the file
hello.cpp
, we can compile and run it as follows:
$ g++ -std=c++17 -o hello.exe hello.cpp
$ ./hello.exe
Hello world!
Here, we use the $
symbol to denote the shell prompt, which is
displayed by the shell to indicate that it is waiting for our
commands. (On most machines, the prompt is more complicated, but we
simplify it to just a dollar sign.) We then type the compilation
command g++ -std=c++17 -o hello.exe hello.cpp
, which invokes the
g++
compiler. The -std=c++17
tells the compiler to use the
C++17 version of the language. The -o hello.exe
tells the compiler
to use hello.exe
as the name of the resulting executable file.
Finally, hello.cpp
is the filename of our C++ program. When the
compiler has finished, we type ./hello.exe
to run the resulting
executable, and we see the message Hello world!
printed to the
screen.
In this course, we encourage using both the shell as well as an integrated development environment (IDE). Refer to this tutorial for how to set up and become familiar with using a shell and IDE on your machine.
Static Typing
We saw above that the signature of the main()
function includes
the return type, which is int
in the case of main()
. The
reason the return type is included is that C++ is a statically typed
language, meaning that every value’s type must be known at compile
time. To do so, the compiler generally requires us to specify the type
of a variable, as well as the parameter types and return type of a
function. For instance, the following introduces a local variable
x
with type int
and initial value 3:
int x = 3;
The syntax for defining a variable begins with the type of the
variable, followed by the name, followed by an optional
initialization. (If no initialization is provided, the variable
undergoes default initialization. For primitive types such as
int
, that means the initial value of the variable is undefined.)
The int
type is a primitive type, which is a category consisting
of the simplest types provided by a language. Common primitive types
in C++ include:
int
: signed (positive, negative, or zero) integer values in some finite range, typically \([-2^{31}, 2^{31}-1] = [-2147483648, 2147483647]\) on most machinesstd::size_t
: unsigned (positive or zero only) integer values in some finite range, typically \([0, 2^{64}-1]\)double
: double-precision floating-point values, typically using a 64-bit representationbool
: a boolean value, either true or falsechar
: a character value, generally representing at least the 128 values in the ASCII standardvoid
: used as the return type of a function to indicate that it does not return a value
As another example, let’s define a square()
function that computes
the square of a number. We’ll write it to take a double
value as
input and return a double
value as the result:
double square(double x) {
double result = x * x;
return result;
}
As with main()
, the signature for square()
starts with the
return type, which is double
. Then we have the name of the
function, followed by the parameter list. We take in a single
double
value as input, so we have a single parameter, which we are
calling x
. In the body of the function, we define a new variable
result
that is initialized with the value of x * x
, and we
then return the value of the variable.
Since we only use the variable once, we can simplify our function by
eliminating it and using the expression x * x
directly where we
need it:
double square(double x) {
return x * x;
}
Since an expression evaluates to a value, it has a type corresponding
to that value. In the case of x * x
, its type is double
since
it is the product of two double
values. Thus, the return statement
returns a double
value, matching the declared return type of the
function.
We can now invoke square()
as follows:
#include <iostream>
double square(double x) {
return x * x;
}
int main() {
std::cout << square(3.14) << std::endl;
double x = square(4);
std::cout << square(x) << std::endl;
}
The square()
function must be declared above where we use it in
main()
– in C++, the scope of a global function or variable is
from the point of its declaration until the end of the file. (The
scope of an entity is the region of code where it may be used.)
We can then invoke the function in main()
.
Observe that in the definition
double x = square(4);
we invoke square()
on the literal 4
, which actually has type
int
rather than the declared double
type of the parameter of
the square()
function. In most cases, C++ automatically converts
between int
and double
, so that we can use a value of one of
these types where the other is expected. On the other hand, a call
such as square(std::cout)
as erroneous, since there is no
conversion between the type of std::cout
(which happens to be
std::ostream
) and the required double
type.
Compound Data
In addition to primitive types, C++ has data types that represent more
complex objects. For instance, the std::ostream
type (defined in
the iostream
header) represents an output stream, and it is the
type of std::cout
. The std::string
type is another common data
type that represents string objects, and it is defined in the
string
header:
#include <iostream>
#include <string>
int main() {
std::string s = "Hello world!";
std::cout << s << std::endl;
}
Often, we want to keep track of a collection of objects of the same
type, such as an arbitrary number of integer values. We can use a
std::vector
to do so, which is an example of a template that is
parameterized by some other type. For instance, std::vector<int>
is a collection of int
values, while std::vector<std::string>
is a collection of std::string
values. The following demonstrates
how to use a vector:
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores = { 84, 91, 77, 95, 83 };
for (std::size_t i = 0; i < scores.size(); ++i) {
std::cout << "Score " << i << " = " << scores[i] << std::endl;
}
}
In this code, we define a scores
variable of type
std::vector<int>
, and we provide an initial set of values as a
comma-separated list enclosed by curly braces. We then iterate over
the indices of the vector, which start at 0 and end at one less than
the size of the vector, which we obtain via the expression
scores.size()
. We access an element of the vector using square
brackets, with the vector object on the left-hand side and the index
between the brackets (scores[i]
). Using this syntax, we have to be
careful not to go past the end of the vector, which would result in
undefined behavior, meaning that anything could happen – e.g.
crashing the program, overwriting some other piece of data, stealing
your files, or even nothing at all. Alternatively, we can do
scores.at(i)
, which checks whether the index is in range and
guarantees an error if it is not – the program crashes, but we get
some useful information, and it definitely won’t steal our files.
We can also add elements to a vector after it has been created:
scores.push_back(42);
This appends the element 42 at the end of the vector. Similarly, the
expression scores.pop_back()
removes the element at the end of the
scores
vector (assuming there is at least one element in the
vector; otherwise we get the dreaded undefined behavior).
Vectors are useful for ordered collections of data that all have the
same type. However, sometimes we want to keep track of multiple pieces
of data that have individual meanings, and that might even have
different types. For instance, a complex number has a real part and an
imaginary part; while both might be represented as double
s, we
want to keep track of which part is which through a name rather than
maintaining an ordering between them. A struct [1] gives us this
ability to introduce a compound object, comprised of multiple pieces
each with individual names. The following is an example of defining a
new Complex
type:
struct Complex {
double real;
double imaginary;
};
We start with the struct
keyword, followed by the name of the type
we are defining, followed by an open curly brace. We then specify the
components of the struct, using syntax similar to variable
declarations. These components are called member variables in C++
[2]. The struct definition is terminated by a closing curly brace and
a semicolon. Once we have this definition, we can write a function to
print out a Complex
value:
Other languages uses the terms fields or attributes to refer to these components.
void Complex_print(Complex number) {
std::cout << number.real;
if (number.imaginary >= 0) {
std::cout << '+';
}
std::cout << number.imaginary << std::endl;
}
As shown above, we can access a member variable using the dot
operator. The expression number.real
accesses the real
component of the number
object, and number.imaginary
refers to
the imaginary
component. The function prints the +
character
to separate the two components if the imaginary part is nonnegative –
otherwise, the imaginary part would have a minus sign, so we wouldn’t
want a +
between the two components.
We can create objects of Complex
type and print them as follows:
Complex c1 = { 3.14, -1.7 };
Complex c2;
c2.real = 2.72;
c2.imaginary = -4;
Complex_print(c1);
Complex_print(c2);
As the code demonstrates, we can use curly braces to initialize a
Complex
variable, which initializes the components in order:
c1.real
gets initialized to 3.14
, and c1.imaginary
gets
initialized to -1.7
. Alternatively, we can rely on default
initialization as is the case for c2
. However, this ends up
initializing the two components to undefined values, so we need to
replace their values before we can proceed to use them.
As another example, we define a struct to keep track of a person’s exam score. We need to keep track of the person’s name, as well as the score value itself:
struct Grade {
std::string name;
int score;
};
We can now use the Grade
type as follows:
Grade g1 = { "Sofia", 99 };
Grade g2;
g2.name = "Amir";
g2.score = 23;
std::cout << g1.name << " earned a " << g1.score << std::endl;
std::cout << g2.name << " earned a " << g2.score << std::endl;
Value Semantics
One of the distinguishing features of a programming language is the relationship between variables and objects, which are pieces of data in memory. In some languages, a variable is directly associated with an object, so that using the variable always accesses the same object (as long as the variable is in scope). We say that the language has value semantics if this is the case. In other languages, a variable is an indirect reference to an object, and the variable may be modified to refer to a different object. This scheme is known as reference semantics.
C++ has value semantics, unlike other languages such as Java [3] or
Python that primarily have reference semantics. We can illustrate the
difference between these semantic choices using the Complex
type
we defined previously. Consider the following code:
Java actually has value semantics for primitive types and reference semantics for class types.
#include <iostream>
struct Complex {
double real;
double imaginary;
};
void Complex_print(Complex number) {
std::cout << number.real;
if (number.imaginary >= 0) {
std::cout << '+';
}
std::cout << number.imaginary << std::endl;
}
int main() {
Complex c1 = { 3.14, -1.7 };
Complex c2 = c1;
c2.real = 2.72;
Complex_print(c1);
Complex_print(c2);
}
In this code, we define a variable c1
of type Complex
and give
it an initial value. We then copy it to a c2
variable. If we
modify c2
, the change does not affect c1
, since the two
variables each have their own object with which they are associated.
In memory, this looks something like the picture in
Figure 89.
Compiling and running the program produces:
$ g++ -std=c++17 -o complex.exe complex.cpp
$ ./complex.exe
3.14-1.7
2.72-1.7
We see that as expected, the modification to c2
does not affect
c1
. Compare this to a similar Python program:
class Complex:
def __init__(self, real, imaginary):
self.real = real
self.imaginary = imaginary
def Complex_print(number):
print(number.real, end='')
if number.imaginary >= 0:
print('+', end='')
print(number.imaginary)
c1 = Complex(3.14, -1.7)
c2 = c1
c2.real = 2.72
Complex_print(c1)
Complex_print(c2)
In memory, the variables look like the diagram in Figure 90.
Running the program produces the following:
$ python3 complex.py
2.72-1.7
2.72-1.7
This confirms that c1
and c2
refer to the same object, since
the modification to c2.real
also affected c1
.
We can also observe C++’s value semantics when we call a function.
Suppose we want a function that modifies a Complex
object to be
its conjugate, flipping the sign of the imaginary component of the
complex number. The following is an attempt to write this function:
void Complex_conjugate(Complex number) {
number.imaginary = -number.imaginary;
}
Suppose we insert the following statement prior to the print statements in our program above:
Complex_conjugate(c1);
Compiling and running the code, we get:
$ g++ -std=c++17 -o complex.exe complex.cpp
$ ./complex.exe
3.14-1.7
2.72-1.7
Nothing changes! This is because the number
parameter of the
Complex_conjugate()
function is its own object, and it receives a
copy of c1
‘s value. The diagram in
Figure 91 illustrates this in memory at the
end of the Complex_conjugate()
call.
The copy within the Complex_conjugate()
function does have its
imaginary
component modified, but not the object within the
main()
function. This behavior is called pass by value, since
the value of c1
(the argument of the function call) is copied
into the number
parameter object.
C++ also supports another mechanism for passing arguments: pass by
reference. In this scheme, the parameter is an alias of the object
passed in as an argument rather than being its own object. We specify
that a parameter should use pass by reference by placing the ampersand
(&
) symbol to the left of the parameter name:
void Complex_conjugate(Complex &number) {
number.imaginary = -number.imaginary;
}
Compiling and running the code with this modified definition, gives us:
$ g++ -std=c++17 -o complex.exe complex.cpp
$ ./complex.exe
3.14+1.7
2.72-1.7
We see that c1
has indeed been conjugated. The memory diagram in
Figure 92 depicts what the code looks
like in memory at the end of the call to Complex_conjugate()
. In
the figure, we denote an alias with a dashed line, and we do not
include a box next to the number
name to reflect the fact that it
is not associated with a new object.
Example: Stickman
Having seen the basics of C++, let us proceed to write a larger C++
program that implements a stickman game (also called hangman), which involves
guessing the letters in a word or phrase. First, let’s sketch out how
we want the game to run. If the puzzle is the word hello
, an
unsuccessful game may play out like the following:
$ ./stickman.exe hello
_ _ _ _ _
Enter a lowercase letter to guess:
a
o
_ _ _ _ _
Enter a lowercase letter to guess:
b
o
|
_ _ _ _ _
Enter a lowercase letter to guess:
c
o
/|
_ _ _ _ _
Enter a lowercase letter to guess:
d
o
/|\
_ _ _ _ _
Enter a lowercase letter to guess:
e
o
/|\
_ e _ _ _
Enter a lowercase letter to guess:
f
o
/|\
/ _ e _ _ _
Enter a lowercase letter to guess:
g
o
/|\
/ \ _ e _ _ _
Better luck next time!
We want to print out underscores corresponding to the letters in the puzzle, with a space between each of them. We prompt the player for a guess, reading from the standard input stream corresponding to input from the console. If the player guesses a letter that is not in the puzzle, we reveal a portion of a stick figure. If the player guesses a letter that is in the puzzle, we reveal all occurrences of that letter and do not add to the stick figure. If the player guesses incorrectly six times, the full stick figure is revealed, and the player loses.
The following is a successful run of the game (loosely following the frequency distribution of English letters):
$ ./stickman.exe hello
_ _ _ _ _
Enter a lowercase letter to guess:
e
_ e _ _ _
Enter a lowercase letter to guess:
t
o
_ e _ _ _
Enter a lowercase letter to guess:
s
o
|
_ e _ _ _
Enter a lowercase letter to guess:
a
o
/|
_ e _ _ _
Enter a lowercase letter to guess:
i
o
/|\
_ e _ _ _
Enter a lowercase letter to guess:
o
o
/|\
_ e _ _ o
Enter a lowercase letter to guess:
h
o
/|\
h e _ _ o
Enter a lowercase letter to guess:
r
o
/|\
/ h e _ _ o
Enter a lowercase letter to guess:
l
o
/|\
/ h e l l o
Congratulations!
Program Constants
Now that we have a general sense of how the program should behave, we can go ahead and start on our design for its structure. First, let’s define some constants corresponding to the stick figure. The full figure is as follows:
o
/|\
/ \
This spans three lines. Unfortunately, a string literal in C++ cannot
directly include a newline (line break) character. However, we can use
the escape sequence \n
to tell the compiler we want a newline
character. For instance, the following prints Hello
on one line,
followed by world!
on the next line [4]:
std::cout << "Hello\nworld!" << std::endl;
Note that std::endl
is not the same as the newline
character. It has the effect of printing the newline character
to the target stream, but it also flushes the stream, which
immediately transfers the inserted data into the underlying
output. In the absence of a flush, the stream is allowed to
buffer the data in memory, which generally allows for better
performance than writing characters immediately.
Escape sequences in general start with a backslash in C++. If we
actually want a backslash character itself, it too needs to be
escaped: \\
. Thus, the stick figure can be represented with the
following string:
" o\n/|\\\n/ \\"
The string starts with a space to center the head of the stick figure,
then the newline escape sequence \n
to end the line, then the
right arm and torso, followed by the \\
escape sequence for a
backslash to represent the left arm, followed by another \n
newline, then the right leg, a space, and finally the escaped left
leg.
We can now work our way backwards to obtain partially revealed stick figures. There are two things we need to ensure so that the figures line up in the output:
Each figure has exactly two newline characters.
Each figure has exactly three characters on the third line.
We can use a std::vector<std::string>
to store the figures in
order:
const std::vector<std::string> STICK_FIGURES = {
"\n\n ",
" o\n\n ",
" o\n |\n ",
" o\n/|\n ",
" o\n/|\\\n ",
" o\n/|\\\n/ ",
" o\n/|\\\n/ \\"
};
We prefix the type with the const
qualifier to denote that this is
a constant, and the compiler will prevent us from modifying it. By
convention, we name the constant using all capital letters, with
underscores between words. We’ll also use a constant for the maximum
number of wrong guesses:
const int MAX_MISSES = 6;
The Game
Struct
Next, we consider what data we need to represent the state of a game. We have to keep track of both the answer to the puzzle, as well as what pieces have been guessed by the player and what pieces remain. We also need to track the number of missed guesses. We will additionally keep a count of the number of letters remaining – this isn’t strictly necessary, as we can recompute it when we need it, but it will make our job easier to keep track of it separately.
Before we define a struct, we also need to determine what underlying
data types are appropriate to represent each member variable. The
counts can be represented by the int
type, and the answer by the
std::string
type. We can also represent what the player has and
hasn’t guessed with a std::string
– letters that have not been
guessed will be replaced with an underscore, while those that have
been revealed will have the same values as in the answer. We can now
define the struct:
// A struct to represent the state of a game of stickman.
struct Game {
// The number of wrong guesses made so far.
int miss_count;
// The number of remaining unguessed positions in the puzzle.
int remaining_letters;
// The current state of the puzzle, with underscores representing
// unguessed letters.
std::string puzzle;
// The answer to the puzzle.
std::string answer;
};
Here, we have documented each component with a comment above it that describes its purpose.
Task Decomposition
Let’s now think about the functions we need in our program. We should break down each discrete task in the program to its own function, so that each function has a small job, and so we can test each piece individually. The following are some tasks that our program needs to do:
Construct a
Game
object from astd::string
answer, with the appropriate initial values for each member variable.Print the a
Game
object, showing the player the current state of the game.Obtain a letter guess from the player, checking whether or not the guess is a valid letter.
Update the
Game
based on a guessed letter.Perform the top-level game loop until the game ends.
Let’s write function signatures corresponding to each of these tasks. We will write them in the form of a declaration [5], which excludes the body of a function, replacing it with a semicolon.
Technically, a definition is also a declaration. A declaration that is not also a definition is more precisely called an incomplete or forward declaration.
The function that constructs a
Game
takes astd::string
as an argument and returns aGame
:Game make_game(std::string answer);
The function that prints a
Game
takes aGame
object and does not return anything.void print_game(Game game);
We actually don’t need a copy of the
Game
object, so we can specify pass by reference instead to obtain an alias to the existing object:void print_game(Game &game);
The function that obtains a guess from the player doesn’t take any arguments, and it returns a character value:
char get_guess();
The function that updates the game must take the
Game
object via pass by reference, so that we don’t get a copy of theGame
. It also takes in the guessed letter:void update_game(Game &game, char guess);
The top-level function takes the answer as a string and does not return anything:
void play_game(std::string answer);
Implementation
We can now proceed to implement these functions, starting with
make_game()
. The function needs to define a Game
object and
set its member variables:
Game game = { 0, 0, answer, answer };
We’ve initialized the puzzle
member variable to be a copy of the
answer
parameter to start, but we need to replace each letter with
an underscore. Let’s assume that we only hide lowercase letters, and
that other characters are shown to the player without needing to be
guessed. This allows our puzzle to contain spaces and other
punctuation. We can write a separate function to determine whether or
not a character is a lowercase letter:
bool is_lowercase(char value) {
return value >= 'a' && value <= 'z';
}
This logic takes advantage of the fact that in the ASCII standard (and
most other character standards), the lowercase letters are adjacent
and in order. Thus, a lowercase letter must be both greater than or
equal to the character 'a'
and no more than the character 'z'
.
We can now loop over the puzzle
member variable, replacing each
lowercase character with an underscore:
for (std::size_t i = 0; i < game.puzzle.size(); ++i) {
if (is_lowercase(game.puzzle[i])) {
game.puzzle[i] = '_';
++game.remaining_letters;
}
}
Iterating over a string works the same way as iterating over a vector.
We use a for
loop from 0 up to the size of the string, and we use
square brackets to index into the string to obtain the character at
that position. Unlike in some other languages, C++ strings are
mutable, meaning that they can be modified, and we do so here by
replacing each lowercase letter with an underscore character. We also
increment the number of remaining letters each time we encounter a
lowercase letter.
Putting this all together, the following defines our make_game()
function:
Game make_game(std::string answer) {
Game game = { 0, 0, answer, answer };
for (std::size_t i = 0; i < game.puzzle.size(); ++i) {
if (is_lowercase(game.puzzle[i])) {
game.puzzle[i] = '_';
++game.remaining_letters;
}
}
return game;
}
To print a game, we print out the stick figure corresponding to the current number of incorrect guesses:
std::cout << STICK_FIGURES[game.miss_count] << " ";
We add a bit of space between the figure and the puzzle. We then print out each of the characters in the puzzle, separated by spaces:
for (std::size_t i = 0; i < game.puzzle.size(); ++i) {
std::cout << " " << game.puzzle[i];
}
In addition, we add extra newlines to visually separate the turns from each other. The full function definition is as follows:
void print_game(Game &game) {
std::cout << endl;
std::cout << STICK_FIGURES[game.miss_count] << " ";
for (std::size_t i = 0; i < game.puzzle.size(); ++i) {
std::cout << " " << game.puzzle[i];
}
std::cout << "\n" << endl;
}
Next, we implement the get_guess()
function to obtain a guess from
the player. At a high level, this function needs to do the following:
Obtain a string from the standard input stream.
Check whether the string is a valid guess – it must be a single letter, and the letter must be lowercase.
If the guess is invalid, repeat the process.
If the guess is valid, return the guessed letter.
There’s one more case we need to handle – what happens if we reach the end of the stream, when no more input is available? (On most systems, a user can manually end the standard input stream with the Ctrl-d keyboard input. We will also see later that input can be redirected from a file, in which case the input stream ends when it reaches the end of the file.) In such a case, we will return immediately with an error value that is different from any valid guess:
const char ERROR_CHAR = '\0'; // special "null" character
The following is a definition of get_guess()
:
char get_guess() {
std::cout << "Enter a lowercase letter to guess:" << std::endl;
std::string input;
while (std::cin >> input) {
if (input.size() != 1) {
cout << "Error: guess must be exactly one letter" << std::endl;
} else if (!is_lowercase(input[0])) {
cout << "Error: guess must be between a and z" << std::endl;
} else {
return input[0];
}
}
return ERROR_CHAR;
}
The std::cin
object represents the standard input stream, similar
to how std::cout
is the standard output stream. We extract from
an input stream using the >>
operator, which is called the
stream-extraction operator in this context. To extract a string, we
use a std::string
object as the right-hand side of the operator.
It is common practice to perform this extraction in the condition of a
loop. If the extraction succeeds, the condition has a true value, and
the body of the loop runs. If the extraction fails (e.g. in the case
of the end of the stream), the loop condition is false, and the loop
exits. In this function, we return ERROR_CHAR
when the extraction
fails.
The function starts by prompting the player to enter a guess. It then reads input in a loop, checking whether or not the input is a valid guess. If the guess is invalid, a message describing the problem is printed, and the loop moves on to the next iteration, reading another input. If the guess is valid, the lone character in the input string is returned.
We now go ahead and implement the update_game()
function. The
function needs to iterate over the characters in the answer to check
if any are the same as the guess. If so, we additionally need to check
whether the letter has already been guessed. If it has not been
guessed, the corresponding position in the puzzle
string has an
underscore. In this case, we replace the underscore with the actual
letter and decrement the count of remaining letters:
for (std::size_t i = 0; i < game.answer.size(); ++i) {
if (game.answer[i] == guess && game.puzzle[i] == '_') {
game.puzzle[i] = guess; // replace the _ with the actual letter
--game.remaining_letters;
}
}
The function also needs to update the miss_count
member variable
depending on whether the guess was a correct one or not. We can’t know
this until we have traversed the entire puzzle, and we use a separate
boolean to track this. The full function definition below demonstrates
this logic:
void update_game(Game &game, char guess) {
bool correct_guess = false;
for (std::size_t i = 0; i < game.answer.size(); ++i) {
if (game.answer[i] == guess && game.puzzle[i] == '_') {
game.puzzle[i] = guess; // replace the _ with the actual letter
--game.remaining_letters;
correct_guess = true;
}
}
if (!correct_guess) {
++game.miss_count;
}
}
Lastly, we can write the top-level function that plays a game.
It starts by creating a Game
object via make_game()
.
It then has a loop that:
Prints the game using
print_game()
.Obtains a guess from the player via
get_guess()
. IfERROR_CHAR
is returned, the game immediately exits.Updates the game using
update_game()
.
Other than the ERROR_CHAR
condition, the game terminates either
when all letters have been guessed, or the player has made the maximum
number of incorrect guesses. Thus, the following is the main loop:
while (game.remaining_letters > 0 && game.miss_count < MAX_MISSES) {
print_game(game);
char letter = get_guess();
if (letter == ERROR_CHAR) {
cout << "Quitting." << endl;
return; // quit the game
}
update_game(game, letter);
}
After the game is over, we print the game once more to show its final state. We then print a message to the player depending on whether or not they won, as shown in the full function definition below:
void play_game(std::string answer) {
Game game = make_game(answer);
while (game.remaining_letters > 0 && game.miss_count < MAX_MISSES) {
print_game(game);
char letter = get_guess();
if (letter == ERROR_CHAR) {
std::cout << "Quitting." << std::endl;
return; // quit the game
}
update_game(game, letter);
}
print_game(game);
if (game.remaining_letters == 0) {
std::cout << "Congratulations!" << std::endl;
} else {
std::cout << "Better luck next time!" << std::endl;
}
}
Testing and File Organization
Before we write a main()
function that plays the game, we might
want to write some code that tests individual functions in our
program. For example, the following code does a basic test of
the make_game()
function:
#include <cassert>
#include <string>
void test_make_game() {
std::string answer = "hello world!";
Game game = make_game(answer);
assert(game.miss_count == 0);
assert(game.remaining_letters == 10);
assert(game.puzzle == "_____ _____!");
assert(game.answer == answer);
}
int main() {
test_make_game();
}
The test_make_game()
function creates a Game
object from the
"hello world!"
answer string. It then asserts that each of the
member variables of the Game
object has the expected value. The
assert()
construct is defined in the cassert
library header,
which we have included at the top.
Observe that the test code has its own main()
function, so it
needs to be in a separate .cpp
source file than the main()
function that actually plays a game of stickman. Let’s assume that the
function definitions above are placed in stickman.cpp
, and our test
code is in test.cpp
. The compiler will process these two files
individually, even if they are both provided in a single compilation
command. How can we make the compiler aware of the functions defined
in stickman.cpp
when it is compiling test.cpp
?
The compiler actually only needs access to the declarations of the
functions we use, not the definitions. It does need access to the
definition of the Game
struct, since it needs to know what member
variables the struct has. Thus, common practice is to place struct
definitions, function declarations, and constants in a separate
header file, conventionally with a file extension such as .hpp
.
The following code can be placed in stickman.hpp
:
#include <string>
#include <vector>
// Stick figures corresponding to each possible number of misses.
const std::vector<std::string> STICK_FIGURES = {
"\n\n ",
" o\n\n ",
" o\n |\n ",
" o\n/|\n ",
" o\n/|\\\n ",
" o\n/|\\\n/ ",
" o\n/|\\\n/ \\"
};
// Maximum number of missed guesses.
const int MAX_MISSES = 6;
// Used to indicate an error when reading user input.
const char ERROR_CHAR = '\0'; // special "null" character
// A struct to represent the state of a game of stickman.
struct Game {
// The number of wrong guesses made so far.
int miss_count;
// The number of remaining unguessed positions in the puzzle.
int remaining_letters;
// The current state of the puzzle, with underscores representing
// unguessed letters.
std::string puzzle;
// The answer to the puzzle.
std::string answer;
};
// EFFECTS: Returns a properly initialized Game object corresponding
// to the given answer phrase.
Game make_game(std::string answer);
// REQUIRES: game.miss_count <= MAX_MISSES
// MODIFIES: cout
// EFFECTS: Prints the state of the game to standard output.
void print_game(Game &game);
// MODIFIES: cout, cin
// EFFECTS: Repeatedly prompts the user for a guess consisting of a
// single lowercase letter until a valid guess is provided,
// or the end of stream is reached. Returns the guess in
// the first case, or ERROR_CHAR in the second.
char get_guess();
// MODIFIES: game
// EFFECTS: Updates the game's puzzle and miss count according to
// whether the guess is a letter in the answer and has not
// been previously guessed.
void update_game(Game &game, char guess);
// MODIFIES: cout, cin
// EFFECTS: Plays a game of stickman with the given answer, reading
// guesses from standard in and writing game details to
// standard out. The game ends if the end of standard input
// is reached, the player exhausts the maximum number of
// incorrect guesses, or the player correctly guesses all
// letters.
void play_game(std::string answer);
We start by including the string
and vector
standard
libraries, since the code in stickman.hpp
uses both strings and
vectors. We then have our constant definitions, followed by the
definition of the Game
struct. Lastly, we have function
definitions for each task in the game. We have included documentation
in the form of RMEs (requires, modifies, and effects):
The requires clause specifies what is required to be true prior to calling the function. These are also called preconditions. All bets are off if these conditions are violated: we get the dreaded undefined behavior. The function’s implementation is allowed to assume that these conditions are met – it is not required to check them in any way.
The modifies clause lists the objects outside the function that might modified by a call to the function. We generally include
cout
if the standard output stream is written to, andcin
if input is read from the standard input stream. Pass-by-reference parameters are included if their contents might be modified.The effects clause tells us what the function actually does, i.e. what the return value means and how any objects in the modifies clause are actually modified. The effects are sometimes also called postconditions. This clause only specifies the “what”, not the “how”; we will come back to this later.
Now that we have this code in stickman.hpp
, we can include it in
both test.cpp
and stickman.cpp
. The contents of test.cpp
are as follows:
#include <cassert>
#include "stickman.hpp"
void test_make_game() {
std::string answer = "hello world!";
Game game = make_game(answer);
assert(game.miss_count == 0);
assert(game.remaining_letters == 10);
assert(game.puzzle == "_____ _____!");
assert(game.answer == answer);
}
int main() {
test_make_game();
}
The #include "stickman.hpp"
directive pulls in the code from
stickman.hpp
into the current file. We use quotes around the
filename rather than the angle brackets we use with a library header.
Observe that we no longer need the #include <string>
directive,
since stickman.hpp
already contains that.
The full contents of stickman.cpp
are as follows:
#include <iostream>
#include "stickman.hpp"
using std::cin;
using std::cout;
using std::endl;
using std::size_t;
using std::string;
static bool is_lowercase(char value) {
return value >= 'a' && value <= 'z';
}
Game make_game(string answer) {
Game game = { 0, 0, answer, answer };
for (size_t i = 0; i < game.puzzle.size(); ++i) {
if (is_lowercase(game.puzzle[i])) {
game.puzzle[i] = '_';
++game.remaining_letters;
}
}
return game;
}
void print_game(Game &game) {
cout << endl;
cout << STICK_FIGURES[game.miss_count] << " ";
for (size_t i = 0; i < game.puzzle.size(); ++i) {
cout << " " << game.puzzle[i];
}
cout << "\n" << endl;
}
char get_guess() {
cout << "Enter a lowercase letter to guess:" << endl;
string input;
while (cin >> input) {
if (input.size() != 1) {
cout << "Error: guess must be exactly one letter" << endl;
} else if (!is_lowercase(input[0])) {
cout << "Error: guest must be between a and z" << endl;
} else {
return input[0];
}
}
return ERROR_CHAR;
}
void update_game(Game &game, char guess) {
bool correct_guess = false;
for (size_t i = 0; i < game.answer.size(); ++i) {
if (game.answer[i] == guess && game.puzzle[i] == '_') {
game.puzzle[i] = guess; // replace the _ with the actual letter
--game.remaining_letters;
correct_guess = true;
}
}
if (!correct_guess) {
++game.miss_count;
}
}
void play_game(string answer) {
Game game = make_game(answer);
while (game.remaining_letters > 0 && game.miss_count < MAX_MISSES) {
print_game(game);
char letter = get_guess();
if (letter == ERROR_CHAR) {
cout << "Quitting." << endl;
return; // quit the game
}
update_game(game, letter);
}
print_game(game);
if (game.remaining_letters == 0) {
cout << "Congratulations!" << endl;
} else {
cout << "Better luck next time!" << endl;
}
}
We include iostream
, since that is not included by
stickman.hpp
. We don’t have struct or constant definitions, since
those are defined in the stickman.hpp
header. Instead, we just have
the definitions for each function.
We made a few minor changes from before:
We added
using
directives so that we can use specific standard-library entities without thestd::
qualification.We preceded the definition of
is_lowercase()
with thestatic
keyword. This is common practice for a helper function, and it prevents the function from conflicting with a function of the same name defined in some other source file.
We can now compile and run the test code. We provide both .cpp
source files to the compilation command, but not the .hpp
file
– its contents are pulled directly into the .cpp
files via the
#include "stickman.hpp"
directive:
$ g++ -std=c++17 -o test.exe test.cpp stickman.cpp
$ ./test.exe
The assertions all succeed, so we don’t see any output.
Lastly, we can write a separate play.cpp
file that has a main()
function to play the game. The following are the contents of the file:
#include <iostream>
#include "stickman.hpp"
int main(int argc, char *argv[]) {
if (argc < 2) {
std::cout << "Usage: " << argv[0] << " <answer> " << std::endl;
return 1;
}
play_game(argv[1]);
}
We use an alternate signature for main()
that gives us access to
the command-line arguments given to the program. The first argument
is always the name of the program executable. We take the game answer
as the second argument, so we start by checking whether there are at
least two arguments. If not, we print an error message and return with
a nonzero value. If an answer is provided, we invoke the top-level
play_game()
function with the answer.
We compile and run the program as follows:
$ g++ -std=c++17 -o play.exe play.cpp stickman.cpp
$ ./play.exe world!
_ _ _ _ _ !
Enter a lowercase letter to guess:
e
o
_ _ _ _ _ !
Enter a lowercase letter to guess:
t
o
|
_ _ _ _ _ !
Enter a lowercase letter to guess:
s
o
/|
_ _ _ _ _ !
Enter a lowercase letter to guess:
o
o
/|
_ o _ _ _ !
Enter a lowercase letter to guess:
r
o
/|
_ o r _ _ !
Enter a lowercase letter to guess:
a
o
/|\
_ o r _ _ !
Enter a lowercase letter to guess:
l
o
/|\
_ o r l _ !
Enter a lowercase letter to guess:
d
o
/|\
_ o r l d !
Enter a lowercase letter to guess:
w
o
/|\
w o r l d !
Congratulations!