Argument Passing
When functions have parameters without default values, they must be passed arguments. In C++, passing arguments into functions can be done in three ways: (1) pass-by-value, (2) pass-by-reference, and (3) pass-by-address.
Pass-by-value
To illustrate these differences, let's consider a canonical function in
programming, swap()
. Before we write the function, let's recall how the
function works. Say we have an int a = 0
and an int b = 1
. To swap the
values assigned to the variables, we create a temporary variable, called
temp
. Then, we assign to temp
the variable a.
Thus, temp = a = 0.
Then, we assign to a
the variable b.
Thus, a = b = 1.
Finally, we
assign to b
the variable temp
: b = temp = 0
. The end result: a = 1
and b = 0
. Putting it all together:
#include <iostream>
using namespace std;
int main() {
int a = 0;
int b = 1;
cout << "Before the swap: " << endl;
cout << "x = " << a << endl;
cout << "y = " << b << endl;
// The swap
int temp;
temp = a;
a = b;
b = temp;
cout << "After the swap: " << endl;
cout << "x = " << a << endl;
cout << "y = " << b << endl;
return 0;
}
Before the swap:
x = 0
y = 1
After the swap:
x = 1
y = 0
Great, it works. Swapping variables, however, is a very common operation. Accordingly, we want to place this computation in a function, to be used whenever we'd like:
#include <iostream>;
using namespace std;
void swap(int a, int b) { int temp; temp = a; a = b; b = temp; }
int main() { int a = 0; int b = 1;
cout << "Before the swap: " << endl;
cout << "x = " << a << endl;
cout << "y = " << b << endl;
swap(a, b);
cout << "After the swap: " << endl;
cout << "x = " << a << endl;
cout << "y = " << b << endl;
return 0;
}
Before the swap:
x = 0
y = 1
After the swap:
x = 0
y = 1
Strange. It didn't perform the swap. Why? Because calling functions the way
we did above, swap(a, b)
, is by default pass-by-value. In
pass-by-value, the function only receives a copy of the values passed.
They are not pass an original. To understand what this implies, recall what
happens in main memory when we execute a function.
When main()
is loaded, a stack in main memory is allocated for it. Let's
call it stack main. Inside stack main, memory is allocated for
int a = 0
and int b = 1
. Going down the code, we encounter
swap(a, b)
. This is a new function, so a new stack is created to
accomodate its variables. Call it stack swap. Inside stack swap, memory
is allocated for int a
, int b
, and temp
. Now, arguments were passed
into swap()
, so the memory allocated is for int a = 0
, int b = 1
, and
temp
.
Inside swap stack, the body of swap()
works as expected. The variables
do in fact swap. We can see that's true by running the function without
arguments:
#include <iostream>;
using namespace std;
void swap(int a = 0, int b = 1) {
cout << "Before swap: " << endl;
cout << "\t a = " << a << endl; cout << "\t b = " << b << endl;
// The swap
int temp;
temp = a;
a = b;
b = temp;
cout << "After swap: " << endl;
cout << "\t a = " << a << endl;
cout << "\t b = " << b << endl;
}
int main() { swap(); return 0; }
Before swap:
a = 0
b = 1
After swap:
a = 1
b = 0
So what does this all mean? It means that the swap never actually occurs in
the main()
function. It only occurs in swap()
, and once swap()
is
done, stack swap is destroyed. There was no effect whatsoever on the
int a = 0
and int b = 1
in stack main.
This embodies the concept of pass-by-value. When we simply pass regular variables into a function, we only pass copies of the values into the function. We never pass the originals.
Call-by-value is what we use when we want the function to simply return a result based on the arguments passed. In fact, most of the functions we write are computations based on arguments. We generally do not want the original values changed — we want a new result. For example, suppose we had a function that computes acceleration:
#include <iostream>;
using namespace std;
double acceleration(double velocity1, double velocity2, double time) {
return (velocity2 - velocity1) / time;
}
int main() { double v1 = 12.4; double v2 = 14.6; double t = 3.48;
cout << "Before function call:" << endl;
cout << "v1 = " << v1 << endl;
cout << "v2 = " << v2 << endl;
cout << "t = " << t << endl;
double a = acceleration(v1, v2, t);
cout << "After function call:" << endl;
cout << "v1 = " << v1 << endl;
cout << "v2 = " << v2 << endl;
cout << "t = " << t << endl;
cout << "a = " << a << endl;
return 0;
}
Before function call:
v1 = 12.4
v2 = 14.6
t = 3.48
After function call:
v1 = 12.4
v2 = 14.6
t = 3.48
a = 0.632184
We obtained a value, but we did not modify the actual arguments, v1
,
v2
, and t
. In programming, we want to minimize mutation as much as
possible. Pass-by-value is one means of ensuring that. But what if we do
want mutation? I.e., what if we truly want to modify the actual arguments,
like how we intended in swap()
?
Pass-by-address
One way to do so is with pass-by-address. Pass-by-address is exactly what it sounds like. Instead of sending copies of the values to a function, we send the address of the values:
#include <iostream>
using namespace std;
void swap(int *a, int *b) { int temp; temp = *a; *a = *b; *b = temp; }
int main() { int a = 0; int b = 1;
cout << "Before the swap: " << endl;
cout << "x = " << a << endl;
cout << "y = " << b << endl;
swap(&a, &b);
cout << "After the swap: " << endl;
cout << "x = " << a << endl;
cout << "y = " << b << endl;
return 0;
}
Before the swap:
x = 0
y = 1
After the swap:
x = 1
y = 0
Now it works. But how? Notice that with the function call swap()
, we
passed as arguments &a
and &b
. These are the addresses of a
and b
in main()
. Then, inside swap()
, the parameters are *a
and *b
. These
are pointers. Why are they pointers? Because if we want to store memory
addresses, we must use pointers. Accordingly, inside the body of swap()
,
we use pointers to perform the swap. Those pointers always refer to the
addresses of a
and b
in main()
.
Pass-by-reference
Recall that we can think of a reference as a nickname, or pseudonym, for a variable. That nickname, or pseudonym, can be passed as an argument to a function. Passing references as arguments to a function is called pass-by-reference. For example, using our code above:
#include <iostream>
using namespace std;
void swap(int &a, int &b) { int temp; temp = a; a = b; b = temp; }
int main() { int a = 0; int b = 1;
cout << "Before the swap: " << endl;
cout << "x = " << a << endl;
cout << "y = " << b << endl;
swap(a, b);
cout << "After the swap: " << endl;
cout << "x = " << a << endl;
cout << "y = " << b << endl;
return 0;
}
Before the swap:
x = 0
y = 1
After the swap:
x = 1
y = 0
The same result from passing by address is obtained. This is because we
employed pass-by-reference. When main()
is memory is allocated for
its variables, as usual. However, the distinction is when the loader
encounters swap(a, b)
. The formal parameters of a
and b
are &a
and
&b
.
What happens when these parameters are used? The entire definition for
swap()
is “copied-and-pasted” into the body of main()
. In
other words, swap()
isn't loaded separately from main()
. It is loaded
as a part of main()
. And because swap()
is a part of main()
, swap()
can effectively “see”, or has access, to the preceding
variables inside main()
. In practice, pass-by-reference is used far more
often than pass-by-address. Why? Because it's simpler to write and easier
to read.
Pass-by-reference v. Pass-by-value
To repeat, the default approach for passing arguments into functions in C++ is pass-by-value. A fair question is when should we avoid the default approach; i.e., pass-by-reference. Answer: It depends.
When we pass-by-value, we pass a copy of the value. This has the benefit of avoiding inadvertent mutability. It also leads to code that's easier to prove or reason about. The cost, however, is inefficiency. If a function takes a large data structure as an argument, it's worth considering whether we actually want to pass that argument by value. With pass-by-value, an entire copy of that data structure must be made, and this takes up both memory and processing power.
If the data structure is particularly large, then it's like that case that we want to pass-by-reference. This avoids the computational expense of generating a copy of that data structure.
Of course, we should also consider whether we should be passing the entire data structure in the first place. If only a part of that data structure is needed by a function, we should be passing only part of that data structure as an argument. If that part is particularly small, then we should err on the side of passing by value.
In general, pass-by-reference is a convenient feature in C++, but it can be easily abused. From a big-picture perspective, references should only be used when there's some value in the program that every other module in the program depends on. Because of that dependancy, it's necessary to change that value for everyone else, because those changes impact what everyone else does.