ICS 45C Spring 2022
Notes and Examples: C++ Basics
What is a C++ program?
A C++ program is a collection of functions. C++ functions, like their counterparts in other programming languages (variously referred to as functions, procedures, subroutines, methods, and so on), encapsulate the code necessary to perform a single task. Functions are called to perform their task, and then return a result when they're finished. Each function has a name, accepts a set of parameters, does some kind of job when called, and returns a value (or, sometimes, no value at all).
Executing a C++ program is generally done by calling a function with the name main. The C++ Standard requires main to return an integer value, though it does not need to take parameters. (The main function can take parameters, as we'll see later in the course, but this is a feature we'll use only rarely.) The program begins with the call to main and ends when main returns.
So the simplest C++ program you could write is the one below, which contains only a main function that does nothing except return the integer value 0.
int main() { return 0; }
There are a few things worth noting about this simplest of examples.
The first line of the function is called its signature. The signature specifies how to call the function, consisting of three parts:
After the function's signature, we see the function's body, which specifies what the function actually does when it's called. The body is written as a sequence of statements, surrounded by funny-looking characters often called curly braces. The only thing our main() function does is return the value 0.
Unlike in some languages, there are few specific layout rules (e.g., spacing, indention). We could have written that same function all on a single line:
int main() { return 0; }
However, in general, I'll use spacing and indention to highlight the syntactic structure of our functions, and we'll expect you to do the same, and for good reason: Tidy-looking code is easier to read and understand (particularly for people who didn't write it, or for the author months or years later after the details have been forgotten).
Where does main()'s return value go?
Ordinarily, C++ functions are called by other C++ functions. As in many programming languages, when the called function returns a value, the calling function can then make use of that value in whatever (reasonable and legal) way it would like.
But this brings up an interesting question: If the main() function is the entry point of the entire program, why does it return a value, and to whom is the value returned? Every program running on operating systems like Windows, Mac OS X, and Linux returns an exit code back to the operating system when it's done. This makes it possible to orchestrate the execution of many programs (e.g., using shell scripts on Linux) and have that orchestration be done differently depending on the result of each program. Most commonly, an exit code of 0 is used to indicate success, while other exit codes are used to indicate various kinds of failures; checking for an exit code of 0 from the first program might tell you which program you should run next, or whether to run a second one at all. This kind of thing is out of the scope of our work this quarter, so we'll almost always return 0 from main().
Compilation, types, and static type checking
When you use some programming languages — Python being a notable example — each iteration of writing a program involves two steps: editing your code, then running the program (or a part of the program) to analyze whether it's behaving as you expect. Provided that the program is syntactically correct (i.e., it has the proper structure throughout) when you're done editing it, the program runs, and most non-syntax mistakes manifest themselves as run-time errors.
The process you follow when writing a C++ program is decidedly different. In C++, each iteration is actually a three-step process:
One of the key ways that C++ feels different from a language like Python, particularly when you're first getting acquainted with it, is that C++ takes the idea of types — what kind of values are permitted to be stored in a variable, returned from a function, and so on — very seriously. In fairness, languages like Python take types seriously, as well; if you try to misuse types, such as subtracting a string from a string in Python, you'll get an error, just as you would in C++. But C++ feels very different in a couple of ways:
Overall, this process is called static type checking. The word static, in this context, can be taken to mean "Done before the program runs" (i.e., by the compiler). Note that the incorporation of static type checking is in stark contrast to some other languages like Python, Ruby, and JavaScript, where there is only dynamic type checking (i.e., issues related to types are checked while the program is running, but not before). It is quite common for languages with dynamic type checking to have a simpler syntax, particularly in the sense that they don't generally require you to specify types of variables, parameters, or return values; since these things aren't checked until the program runs, the tools necessary to convey type information to the compiler aren't required. Dynamically-typed languages often have more flexible semantics, because your uses of types aren't limited by what your compiler will allow you to say, though some statically-typed languages provide more flexibility than others. (C++, in this sense, is actually very flexible, but at the cost of having a fair amount of syntactic complexity; we'll see that much later in the quarter.)
Depending on what language(s) you've learned previously, static type checking may be exactly what you expected or it may seem out-of-the-ordinary, but it's our reality in C++ and we'll need to embrace it.
A slightly more full-featured example
Let's add some code to our main() function so that it has a visible effect when we run it. In particular, let's write a program that writes something to the standard output (so, for example, if we run the program from a shell prompt in Linux, we'll see the output in the shell).
#include <iostream> int main() { std::cout << "Hello Boo!" << std::endl; return 0; }
There are a few notable things going on in this example:
With those things in mind, we can see that executing this program would cause Hello Boo! to be written to the program's standard output, and then the program's exit code would be 0.
Basic built-in data types
C++ has a set of basic, built-in data types that are available in all programs without doing anything special. They're not part of a library, but are instead intrinsic to the language. A non-exhaustive list of those built-in types follows.
Expressions and statements
The bodies of functions are made up of statements, which can, in turn, contain expressions. While you've almost certainly written programs in a language that draws a distinction between expressions and statements, you may never have actually had that distinction called out before, and you might not be aware of what these terms mean. But they form an important part of understanding how C++ works, so we need to be sure we're clear on it.
An expression evaluates to a value, meaning that it also has a type. Expressions are quite often built by combining smaller expressions. The type of an expression is determined — at compile time! — based on the types of its subexpressions and the operators or other syntactic constructs used to combine them. When the program runs, the value of the expression is determined by combining the values of the subexpressions.
An example of an expression with subexpressions would be a + b, which appears to be the addition of the variables a and b. a and b are themselves subexpressions, and their types are governed by the types of the corresponding variables. They're combined using the operator +, whose meaning actually depends on the types of a and b. For example, if a and b are integers, this means that we want to add the two integers together and the value of the expression is their sum; if a and b are strings instead, then we want to concatenate the two strings together and the value of the expression is their concatenation. Either way, since the compiler will always definitively know the types of a and b, the expression will have a definitive meaning before the program runs.
Statements perform a job, but do not themselves have a value (or a type). Their primary role is to have a side effect, which is to say that executing them causes something to change. The value of a variable may be changed, control flow in the program may be affected, input may be read from an input source, output may be written to an output source, a file may be created, a socket may be opened, or whatever. Statements quite often contain expressions within them, and the values and types of those expressions affect what they do.
Expression statements, compound statements, and control structures
C++ provides the ability to write many kinds of statements that do a variety of things, which we'll see as we move forward. The simplest kind of statement is an expression statement, which is simply an expression terminated with a semicolon:
a + b;
An expression statement simply evaluates its expression and then discards the value afterward. That may seem silly — and, in many cases, it is! — but can be useful when an expression has a side effect, such as the assignment of a value into a variable:
a = 3;
Assignment is an expression in C++, and its value is (more or less) the value that was assigned (in this case, 3), but you'll most often see it appear as a stand-alone expression statement, because the side effect of changing the variable's value is the only effect that is desired.
A compound statement, also called a block statement or a block, is a sequence of statements surrounded by curly braces. When a compound statement executes, the statements inside of it are executed in order, one after another. Compound statements can appear anywhere, though we most often use them as the body of control structures.
Control structures are statements that affect the control flow of a program (i.e., to affect what happens next, as opposed to the default behavior of one statement flowing straight through to the one after it). C++ has a number of control structures, many of which are likely to look fairly familiar to you from whichever language(s) you've learned previously.
In this example, we'll either write out of range or in range! to the standard output, depending on whether it's true that a is less than 3 and b is greater than 9. The conditional expression is the one written in parentheses just after the if, and there are a couple of things you should know about it that might surprise you:if (a < 3 && b > 9) { std::cout << "out of range" << std::endl; } else { std::cout << "in range!" << std::endl; }
As with the conditional expression in an if statement, the controlling expression here must appear in parentheses, and it must be integral. The actual meaning of this switch statement is not quite as it appears:switch (a) { case 0: std::cout << "zero" << std::endl; break; case 1: std::cout << "one" << std::endl; break; default: std::cout << "other" << std::endl; break; }
These three kinds of loops are similar, but subtly different, so it's worth understanding the details.int a = 3; // this is a variable declaration; more about this below while (a < 10) { std::cout << a << std::endl; a++; // adds 1 to a; more details later } int a = 3; do { std::cout << a << std::endl; a++; } while (a < 10); for (int a = 3; a < 10; a++) { std::cout << a << std::endl; }
Declarations
A declaration introduces a name into a C++ program. The declaration associates that name with a type, which specifies how it can be used properly, and which gives the compiler the ability to check those uses at compile time to ensure that they're proper. The goal of a declaration is to state that something exists and to give it a type, though it does not necessarily provide all of the other relevant details.
A few examples of declarations follow:
int a; double d = 3.0; int square(int n);
The first of these is the declaration of a variable. Variables name a block of memory in which a value can be stored. Note that variable declarations include not only a name, but also a type; for example, given the declaration of the variable a as an int, the compiler will ensure that only an integer value will ever be stored in a.
The second of these is also the declaration of a variable, but it also initializes the variable's value at the same time. (One important thing to note about variables: they are not initialized to anything in particular unless you assign a value into them, so after the declaration of a above, the value of a will be undefined, in the sense that there are no guarantees about what that value will be. A subsequent use of that variable before assigning a value to it would be what is called undefined behavior, which means that we can't know what will happen next; we'll use tools later this quarter for detecting these kinds of mistakes.)
The third is the declaration of a function. Note that it looks just like the signature of a function, but is terminated with a semicolon instead of being followed by a body. The declaration of a function establishes that function's existence, but does not specify what the function does, which brings up the interesting question of why you would want to be able to do that. Read on.
Why do we need declarations?
Declarations establish how a name can be used legally in a C++ program. Since C++ performs static type checking, it needs to be aware of what the appropriate types will be; declarations serve this purpose. Names in C++ must be declared before use (i.e., the declaration of a name must appear in a program before the first use of that name). This is a bit different from many other programming languages, which cast a much less scrutinizing eye on the order in which you say things.
As a result of the rule requiring declaration before use, this function would be illegal:
int main() { a = 3; std::cout << a << std::endl; int a; return 0; }
because the variable a is not declared until after it's been used in a couple of ways. Similarly, this function would be illegal, too:
int main() { a = 3; std::cout << a << std::endl; return 0; }
because a hasn't been declared at all.
Definitions
A definition gives a declared name a unique, specific meaning; it "gives that name life," so to speak. For example, the following code is a definition of a function boo(), because it not only specifies that the function exists, but also specifies what the function does.
int boo() { return 9999; }
Because it includes the function's signature, this definition would also serve as a declaration if the name boo had not yet been declared.
Some declarations are also definitions. Variable declarations not only specify that a variable exists and give it a type, but they also cause memory to be allocated in which to store the variable's value. In a sense, all there is to know about a variable, in order to "give it life," is the type, because the type dictates the amount of memory required to store it. Note that variables can be initialized to a value when declared, as we've seen, but a variable declaration is also a definition whether you initialize its value or not; variables live even when they haven't had a value explicitly assigned to them.
Not all declarations are definitions. A notable example is a function declaration, which specifies how a function can be called, without stating anything about what the function does. (It should be noted that function declarations serve a bigger purpose than you might think, not only because names must be declared before use, but especially when we soon start writing programs that consist of more than one source file.)
Assignments, lvalues, and rvalues
One very fundamental task in C++, as in many other programming languages, is the ability to store a value in a variable. As we've seen above, assignment is a kind of expression that does just that. The expression a = 3 indicates that the value 3 should be stored into the variable a, subject to successful static type checking (i.e., a must be able to store an integral value, so it must be a type such as int). In this case, a and 3 are subexpressions, and the = operator is used to connect the two subexpressions together.
Embedded within this seemingly simple assignment is an important concept in C++, which is the distinction between lvalues and rvalues. Expressions can be thought of as evaluating to either an lvalue or an rvalue, and it's important to understand the difference.
Originally, the terms "lvalue" and "rvalue" took their names from the distinction between what is legal to put on the left- or right-hand side of an assignment expression, where an "lvalue" refers to an expression that would be legal on the left-hand side, while an "rvalue" refers to an expression that would only be legal on the right-hand side. The following examples embody the concept at work here. (Let's assume that the variables a, b, and c that appear below all have the type int.)
a = 3 // legal a = 3 + 4 // legal a = b // legal a = b + c // legal 3 = a // not legal b + c = a // not legal
While there is more nuance to these terms than we're seeing here, the assignments above provide the basic mental framework for understanding them. An lvalue is a value that lives beyond the expression in which it is originally evaluated; variables certainly fit into that category, since their values can be used again later. (A simple way to think about that is to think about whether an expression can be used to represent a location in memory; if so, it's an lvalue.) An rvalue, on the other hand, is one that is temporary, i.e., that dies when the expression that generated it is finished being evaluated. That's certainly true of the value of a constant, or the value of expressions like 3 + 4 or b + c, which calculate a result, but don't themselves store it anywhere.
C++ goes to the trouble of naming these concepts partly because they're not as simple as they sound. As we'll see, not all lvalues are variable names. For example, the result of a function call can be a location in memory, meaning that it can be used as an lvalue, leading to the following strange-looking but perfectly legal line of code:
v.at(2) = 3; // what?!
Why we need to take the time to learn this distinction now is because terms like lvalue and rvalue show up in error messages that emanate from C++ compilers, and because they foreshadow concepts that will be very important from a performance perspective as we consider the use of memory more carefully going forward.
(It should be noted that things have become even more nuanced since C++11 introduced new features such as move, so there are more kinds of values nowadays than just lvalues and rvalues, but we'll stick with the simpler conceptual framework for now.)
More about calling and declaring functions
As we've seen, a function is a sequence of statements that can be called, accepting a sequence of parameters, and returning a result. Particularly because of the importance of types — and the fact that they're statically checked — functions must be declared before they're called; the declaration specifies not only the existence of the function, but also the types of parameters and the type of its return value.
So, given this idea, we could write the definition of a function square() that takes an integer value and squares it.
int square(int n) { return n * n; }
Since function definitions include a signature, they also act as declarations, which would make the following program legal:
#include <iostream> int square(int n) { return n * n; } int main() { std::cout << square(4) << std::endl; return 0; }
The call to square() in main() is legal, because I've passed one argument that is an integer. The arguments listed when a function is called, in general, are matched up with the function's parameters in the order listed, with the type of each argument required to be compatible wtih the corresponding parameters.
Note, too, that order is important here: Since square() is defined before main(), it was legal to call square() from within main(). But the reverse order would be problematic:
#include <iostream> int main() { std::cout << square(4) << std::endl; return 0; } int square(int n) { return n * n; }
The call to square() in main() would be deemed illegal by the compiler, because it precedes any declaration of the function.
But remember that functions can be declared without being defined, so that opens up a third possibility that would be legal:
#include <iostream> int square(int n); int main() { std::cout << square(4) << std::endl; return 0; } int square(int n) { return n * n; }
While this style — declaring a function earlier and defining it later in the same source file — isn't particularly better than the first, it's important to understand that either of these is legal, because when we start writing programs that span multiple source files, this technique will become vital. (First, you have to understand how things work; then you can focus on how to use these techniques to good end.)
More about function return values, and the importance of heeding warnings
Most C++ functions return a single value of some type. While the idea that only one value can be returned sounds restrictive, it's important to realize that there are plenty of data types more complex than int, and that we'll be able to define our own data types. So this restriction is quite workable in practice.
When you write a function that is said to return a value, you'll want to be absolutely sure that every path through the function's statements reaches a return statement. While it is technically legal in C++ to write functions that don't explicitly return a value when they should, a well-configured compiler will at least generate what's called a warning, which will alert you to the very likely possibility that you have a bug. Warnings, generally, are messages emanating from your compiler that indicate the presence of a potential problem in code that is otherwise technically legal. For the most part, when you get a warning, it's indicating an issue you should be paying attention to; it's rare (though not unheard of) that warnings can safely be ignored.
For example, consider this function:
int absoluteValue(int n) { if (n < 0) { return -n; } }
At a glance, this function appears to do the important thing, which is to negate a negative input so that it becomes positive. However, consider what the function does when given a non-negative input. The conditional expression in the if statement will evaluate to false, but since there is no else branch, the function will not reach a return statement. So what does the function return in this case?
Interestingly, the above function will actually compile successfully — on a typical compiler, you'll receive a warning about not every path through the function leading to a return statement, but since this is not technically illegal in C++, you won't necessarily get an error. But its behavior will be undefined if you call it with anything except a negative input. For fun, I wrote this short main() function to exercise this scenario:
int main() { std::cout << absoluteValue(-3) << std::endl; std::cout << absoluteValue(3) << std::endl; return 0; }
When I ran this program on the ICS 45C VM, it first displayed 3 on the standard output (because absoluteValue(-3) returned 3) and then crashed. On another environment, the outcome might have been different — a seemingly random "garbage" value being printed, for example — but, importantly, it would never have worked consistently.
Your programs in this course will be expected to compile both without errors and without warnings, because, in our exprience, compiler warnings are almost always an indication of a program bug, or, at least, something that could be written more clearly. And once you begin to allow compiler warnings to creep into a project, to the point where you see many of them every time you compile your program, you'll no longer notice when new warnings show up that you didn't see the last time, so you'll begin to miss what they're trying to tell you. To enforce this, the compiler on the ICS 45C VM has been configured to treat all warnings as though they were errors, so that no program can be compiled successfully and run if it causes any warnings to be emitted.
Of course, the fix in this particular case is quite simple: Make sure that every path through the function leads to a return statement that returns a value of type int. One way to write that, using techniques we've seen, is below.
int absoluteValue(int n) { if (n < 0) { return -n; } else { return n; } }
Functions that do not return a value
Note, too, that it is possible to write functions that do not return a value at all. You can do this by writing a function whose return type is listed as void. Such functions are generally useful for providing some kind of side effect (such as writing output):
void printSquare(int n) { std::cout << n * n << std::endl; }
In these functions, you can write a return statement with no value, but it is not required; reaching the end of the function is sufficient to end its execution cleanly, so I only write return statements in void-returning functions when I need to bail out of them early.