ICS 45C Fall 2021
Notes and Examples: More About Functions


Pass-by-value parameters

We've seen previously that functions in C++ can accept parameters, which are information that is passed into them from callers, as a way of configuring what the called function will do. From a terminology perspective, we say that the called function accepts parameters, and that the calling function passes arguments. Arguments are matched up to parameters by the order in which they're specified — the first argument matches the first parameter, the second argument matches the second parameter, and so on — and a function call is legal whenever the types of all arguments are compatible with the types of all parameters. (What it means for them to be "compatible" is a concept that's deeper than it sounds, but we won't focus on that for the time being; we'll come back to that issue a little later.)

Unless you specify otherwise, all parameters in C++ are pass-by-value parameters. The term pass-by-value indicates that what is passed into a function are the values of the arguments specified by the caller, which implies that these values are copied into the function; the function only has access to the copies, so any modifications made to those copies have no effect on the originals.

Consider this short C++ program as an example:

void foo(int x)
{
    x++;
    std::cout << x << std::endl;
}

int main()
{
    int i = 3;
    foo(i);
    std::cout << i << std::endl;
    return 0;
}

First, a copy of i's value is passed into foo(), so foo()'s x parameter has the value 3. Within the function, x is incremented and then printed to the standard output, so we'll see the value 4 printed first. After that, foo() returns. As with any other function, when foo() ends, its parameters are destroyed, so x ceases to exist. We pick up execution back in main() and print the value of i, and this is the important part of the example. Did the value of i change when foo() changed its parameter x? The answer is no, because foo()'s x was a copy of the value of i; modifying x had no effect on i, so 3 is printed at this point, and then the program ends.

How pass-by-value parameters are implemented

We've seen previously that an activation record is stored on the run-time stack whenever a function is called, and that function's parameters are part of what's stored in that activation record. Pass-by-value parameters have their values copied into the appropriate locations within the activation record during the process of calling the function. Since each parameter has a type, it also has a known size at compile time, which allows the compiler to determine precisely where within the activation record each parameter will be stored. Wherever the function needs to access those parameters, the compiler emits code that finds their values in the appropriate offset into the activation record (i.e., at a known distance before or after where the frame pointer points).

This may seem like a fairly unimportant detail, but there are three things that are important to understand about it.

First of all, pass-by-value parameters allow their corresponding arguments to be either lvalues or rvalues. For example, in the example above, if we replaced the call to foo() in the main() function with this:

foo(3);

or with this instead:

int j = 4;
foo(i + j);

the program would still compile and run correctly. In either case, the argument can be evaluated to an int value, and that int value is copied into the parameter x.

Secondly, learning C++ implies that we should concern ourselves with performance, in the sense that we should understand the performance impacts of the code that we're writing. The act of copying a parameter has a cost; its value has to be copied into the activation record during the process of calling the function. For a small value like an int, this is no burden at all — and even passing things more indirectly will be no faster. But all parameters are pass-by-value parameters unless you say otherwise, and the cost of copying them might be a lot higher than the cost of copying an int. For example, if the argument is a std::string with 100,000 characters, that entire string will need to be copied into a pass-by-value parameter, which is a much more substantial cost to bear.

Thirdly, while they can potentially be expensive, particularly for values that are large or complex, pass-by-value parameters do offer a substantial upside. You can feel free to call a function and pass a variable to it by value, without worrying that the function can do anything to affect the value of that variable; no matter what the function does, your original copy of the value will be exactly as it was before the call was made. This is a useful assumption to be able to make about your code, and it can also be a useful assumption for a compiler to make from an optimization perspective. But you have to weigh that benefit against the cost and decide which is more important to you.

What other kinds of parameter passing are there?

If you don't say anything special, all parameters in C++ are pass-by-value parameters. However, there are alternative forms that you can ask for, which have a different meaning, have different performance characteristics, and offer different abilities. We'll need to learn a little more C++, though, in order to be able to use them.


References

In C++, when you name a storage location — a variable, a parameter, etc. — you give that location a type, in addition to its name. That location establishes a few things about it: its size (i.e., how much memory is required to store it), what you are and aren't allowed to do with it, and how it behaves when you do those things. So far, we've seen that types, too, have names, such as int and std::string. But there's more to the story of types in C++ than that, and it's time we learned some more about it.

In C++, a type can be specified as a reference, as opposed to a value. (If you've learned another programming language like Python or Java previously, you may well have seen a feature called references before, but I should caution you that they're somewhat different in C++ than they are in a lot of other languages in which I've seen them.)

A reference in C++ is said to be "an alternative name for a value." A reference is said to refer to a value, and it then acts in every way just like the value it refers to; all you've done, in effect, is establish a synonym for that value.

A reference is declared by adding an & character to its type. So, for example, the type int& is a reference that is required to refer to an int value. When you see more complex type names like these, it's often useful to read them from right-to-left; that, much more often than not, reveals their meaning. So I would read int& as "reference (&) to an integer."

There are two important rules about references:

  1. References must be initialized explicitly when they're defined, so that they refer to something of the appropriate type.
  2. Once a reference is initialized, it cannot be changed to refer to something else; a reference will refer to the same location for its entire lifetime.

Once you have a reference to a value, it can be used as though it was the original value, meaning that accessing the reference gives the identical value as accessing the original value, and that changing the reference is identical to changing the original value.

An example illustrates the general idea:

int i = 3;
int& r = i;                     // r now refers to i
std::cout << r << std::endl;    // writes 3
r = 4;                          // a change to r is also a change to i
std::cout << i << std::endl;    // writes 4
i = 5;                          // a change to i is also a change to r
std::cout << r << std::endl;    // writes 5

To be clear, the kinds of references we're talking about here actually have a longer name in the C++ Standard: lvalue references. This is because they always refer, behind the scenes, to a location in memory, so they can only ever refer to an lvalue. For an illustration of why, what would this block of code mean?

int& x = 4;                     // How does x refer to the location of the constant 4?
x = 3;                          // What would it mean to change the value of the constant 4?

References may seem awfully limited when you first encounter them, and it may be a little hard to see what good they are. But they actually play a vital role in C++; they do a particular job very well.


Passing references as parameters

If we're permitted to append a & to a type, who's to say that we can't do that with a parameter's type? And, in fact, this is not only legal, but potentially quite useful. Let's consider what that use might be, using a slightly modified version of our previous example.

void foo(int& x)
{
    x++;
    std::cout << x << std::endl;
}

int main()
{
    int i = 3;
    foo(i);
    std::cout << i << std::endl;
    return 0;
}

We've seen that there are a couple of rules that references have to follow; are we following those rules here?

Now consider what happens when this program runs. In particular, when foo() is called, the parameter x is a reference that is made to refer to the local variable i within main(). This means that any attempt to use x in foo() will result in the value of i from main(), and that any change to x in foo() will change the value of i from main(). So when foo() updates x by incrementing it, i is also incremented.

Ultimately, then, the key difference in this version of the program is that the second line of output will be 4 instead of 3, because the change to x in foo() has an effect on i in main().

We sometimes say that parameters like these are pass-by-reference parameters, and they have three important differences from their pass-by-value counterparts:

For a somewhat less contrived example of where pass-by-reference parameters can help, consider the following function that swaps two integers:

void swap(int& a, int& b)
{
    int temp = a;
    a = b;
    b = temp;
}

It's not especially difficult to swap two integers, but that's still an error-prone three-line pattern that would read more clearly if it could just be written as swap(i, j);. The function above makes that possible. While the function returns no value, it takes its parameters by reference and then modifies those parameters. (Sadly, if we wanted the same function for swapping doubles, floats, std::strings, and so on, we would need separate functions, though we'll learn how to write an infinite set of possible swap functions, all at once, later this quarter.)


Type conversions between basic types

When you attempt to assign a value into a variable, the types are required to be compatible. The easiest way for two types to be compatible is for the types to be the same. For example:

int i = 3;
int j = 4;
i = j;

There's no question that assigning the value of j into i is valid, because both i and j have the type int.

However, it is possible for types to be compatible even though they aren't the same. C++ freely (and implicitly) converts between values of some types when there is a known conversion between them. For example, C++ will implicitly convert values of all the built-in numeric types, even in cases when information loss is possible.

int i1 = 4;
double d1 = i1;    // legal

double d2 = 3.5;
int i2 = d2;       // legal

The assignment of i1 into d1 is legal, and it's actually quite safe on most architectures. For example, on the ICS 45C VM, int values are 32 bits, while double values have more than 32 bits of precision, so this assignment will lose no information.

The assignment of d2 into i2 is also legal, though it does cause information loss (i.e., the fractional part will be lost, and i2's value will be 3). Some compilers can be configured to provide warnings in cases like these, where information may be lost, but they are technically legal.

This affects our understanding of functions, because the same rules apply when passing arguments to functions:


Function overloading

We've discussed before that C++ does not allow two definitions for the same name in the same scope. For example, you can't have two variables called x in the same function. It turns out, however, that two functions can have the same name in C++ — even in the same scope — so long as they are distinguishable by their parameters' types and/or their return types. The compiler can use the types of arguments and/or the way that the return value is used to unambiguously decide which version of a function you're intending to call.

So you could imagine having multiple versions of the square() function we've written previously.

long square(long n);
double square(double n);

Using the same name for functions that operate on different types is called function overloading, which C++ explicitly allows. However, it has to be clear which version of the function you're calling in any given case, and that can be trickier than it sounds. Let's see an example of calling our two square() functions.

int main()
{
    long l1 = 3;
    std::cout << square(l1) << std::endl;   // calls square(long)

    double d1 = 3.5;
    std::cout << square(d1) << std::endl;   // calls square(double)

    int i1 = 3;
    std::cout << square(i1) << std::endl;   // ???

    return 0;
}

In the first two calls to square(), there is an obvious answer to the question of which version we're calling. The first call passes a long argument, so we're clearly trying to call the version that accepts a long parameter; the second call passes a double, which matches the version that accepts a double.

But the third call is problematic. We're passing an argument of type int, which doesn't match either of the versions of square() we've declared. And since C++ will freely and implicitly convert from int to long or from int to double, the compiler can't be sure what our intent was; either conversion would be legal. So, in this case, the compiler simply gives us an error message, specifying that there is an ambiguity; in general, the compiler never guesses our intent (which risks guessing wrong, as well as risking that two different compilers might guess differently), but instead expects us to be clear.

The complete rules for resolving overloaded functions in C++ are surprisingly complex, but, in general, there are a few things to know about them:

There are more rules than these, but this is a good set of rules to bear in mind for this course; we won't write overloaded functions that lead to situations more complicated than these.


Default arguments

Some functions take a set of parameters, but some of them will quite often have the same value, or there is a sensible default to be used when the caller doesn't know what to pass. This is common in large, complex libraries, for example.

To help in a situation like this, it's possible to declare a function with default arguments, so that parameters that have common values can be substituted by defaults if they're not specified in calls to that function. For example, if we wanted to write a function that calculates the length of a vector (in the mathematical sense), but the vector might be specified in either two or three dimensions, we might do it this way.

double vectorLength(double x, double y, double z = 0.0)
{
    return std::sqrt(x * x + y * y + z * z);
}

(The std::sqrt function is function in the C++ Standard Library that returns the square root of its argument.)

When we call this function, we can call it with either two or three arguments; whenever we leave out the third argument, its default value is substituted for us automatically. So all of the following calls are legal, and the last two are equivalent:

std::cout << vectorLength(3, 4, 5) << std::endl;
std::cout << vectorLength(3, 4, 0) << std::endl;
std::cout << vectorLength(3, 4) << std::endl;

There is an important rule you need to know, which is that the parameters with default arguments must be listed at the end of the parameter list. There can be as many defaults as you want, but no parameters without defaults can appear after any that have defaults. This restriction is not arbitrary; it actually makes good sense. Since C++ matches arguments to parameters in the order specified — the first argument is passed to the first parameter, the second to the second, and so on — the compiler would never know which parameters you meant to pass unless only the last one or more could be missing.