How Functions Return Result Values
So far, we've learned that when we write code like
int x = (a+b)+c;
std::cout << sin(3.14);
std::cout << myString.substr(3,7);
what's really happening behind the scenes is equivalent to
{
int _temp1 = a+b;
int x = _temp1+c;
}
{
double _temp2 = sin(3.14);
std::cout << _temp2;
}
{
std::string _temp3 = myString.substr(3,7)
std::cout << _temp3;
}
In other words, at most one function call happens at a time, and intermediate results are stored in hidden temporary variables.
Thus, we've learned something interesting: every function call that returns a value is actually initializing a new object in memory. (It's either running a constructor to make an instance of a class, or copy initializing a primitive type.)
Thus, for a function call that returns a value,
- The caller will have allocated space somewhere in its stack frame to store the result.
- Behind the scenes, the caller tells the called function (a.k.a. “the callee”) where that space is.
- The called function then initializes that space (usually when it
return
s). - The caller is responsible for destroying the result and eventually deallocating it.
The called function does not know what name, if any, the caller has for the memory it is initializing. It also doesn't know if it's an unnamed temporary location or a named variable. When it makes sense to think of the called function needing a name for the destination memory location in our diagrams, we'll give it the name _retval
.
One cool thing about the fact that return
is actually initializing an object in the caller is that, although we can say return val;
, which copy initializes the object, we can also write return {};
to cause the object to be default initialized or return {arg1, arg2, arg3};
to call a specific constructor!
An Example
The code below shows some of these principles in action:
Cow makeCow(const std::string& name, int cowYears) {
// the first cow year is equal to 14 human years; each subsequent cow year
// equals 4 human years
double humanYears;
if (cowYears < 14) {
humanYears = cowYears / 14.0;
} else {
humanYears = 1.0 + (cowYears - 14) / 4.0;
}
cout << ">> name = " << name << ", at location " << &name << endl;
cout << ">> cowYears = " << cowYears << ", at location " << &cowYears << endl;
cout << ">> humanYears = " << humanYears << ", at location " << &humanYears << endl;
return {name, humanYears}; // Equivalent to: return Cow{name, humanYars};
}
int main() {
int theAge = 17;
string theName{"Bessie"};
cout << "> theAge = " << theAge << ", at location " << &theAge << endl;
cout << "> theName = '" << theName << "', at location " << &theName << endl;
cout << makeCow(theName, theAge).age_ << endl;
cout << "> (the temporary Cow is gone, but main isn't quite done)" << endl;
return 0;
}
(We've skipped code to include the Cow
class, and also some code that causes functions to print a message on entry and exit.)
If we run this code, we see the following output:
In body of main:
> theAge = 17, at location s1
> theName = 'Bessie', at location s2
In body of makeCow:
>> name = Bessie, at location s2
>> cowYears = 17, at location s4
>> humanYears = 1.75, at location s5
+ Created Cow Bessie (via parameterized constructor), at location s3
Leaving makeCow.
1.75
- Destroyed Cow Bessie, at location s3
> (the temporary Cow is gone, but main isn't quite done)
Leaving main.
We can observe a few things here:
- In
makeCow
, thename
variable is just another name for thetheName
variable inmain
, because thename
parameter is passed as a reference. - In
makeCow
, the function constructs its resultingCow
value ins3
, which belongs tomain
. (makeCow
's first stack slot iss4
.) - The temporary object for
makeCow
's result only lasts for the lifetime of the line of code where it appears.
Hay! How did you get a trace with
s1
,s2
, and so on? Did you hand trace the whole thing with a memory diagram?I could have, but it would have taken several minutes to “run” all this code by hand. Instead, I just compiled and ran the C++ program. On a real machine, it outputs the actual pointer values as hexadecimal addresses, so I converted those numbers into symbols in the style of our memory diagrams.
I know you well enough to know you didn't do it by hand! You wrote a Python script to do the conversion!
*Pretends not to hear.*
So we can see that makeCow
really is making a temporary object in main
's stack frame.
Drawing a Quick Memory Diagram
The trace above has enough information for us to draw a memory diagram of the code at a specific moment in time. Use the trace above to draw a memory diagram at the point just after we've created the Cow
called "Bessie"
, before we leave makeCow
.
When you draw the diagram, also label s3
with the name _retval
(an internal name makeCow
could use to refer to this location) and temp1_
(an internal name main
could use).
This video (approximately two minutes long) follows the execution of the code and shows the construction of the diagram. (It actually continues to the point where the makeCow
function return
s.)
Variation #1
If we change makeCow to take cowYears
by constant reference,
Cow makeCow(const std::string& name, const int& cowYears)
we get the following output:
In body of main:
> theAge = 17, at location s1
> theName = 'Bessie', at location s2
In body of makeCow:
>> name = Bessie, at location s2
>> cowYears = 17, at location s1
>> humanYears = 1.75, at location s4
+ Created Cow Bessie (via parameterized constructor), at location s3
Leaving makeCow.
1.75
- Destroyed Cow Bessie, at location s3
> (the temporary Cow is gone, but main isn't quite done)
Leaving main.
Notice that now
cowYears
is just another name fortheAge
.humanYears
has moved up froms5
tos4
.
Variation #2
Now let's revert that change, and change main
to
int main() {
int theAge = 17;
string theName{"Bessie"};
cout << "> theAge = " << theAge << ", at location " << &theAge << endl;
cout << "> theName = '" << theName << "', at location " << &theName << endl;
Cow theCow = makeCow(theName, theAge);
cout << "> theCow exists now, at location " << &theCow << endl;
cout << makeCow("Mabel", theAge+2).age_ << endl;
Cow theOtherCow = theCow;
return 0;
}
This version produces the output
In body of main:
> theAge = 17, at location s1
> theName = 'Bessie', at location s2
In body of makeCow:
>> name = Bessie, at location s2
>> cowYears = 17, at location s7
>> humanYears = 1.75, at location s8
+ Created Cow Bessie (via parameterized constructor), at location s3
Leaving makeCow.
> theCow exists now, at location s3
In body of makeCow:
>> name = Mabel, at location s5
>> cowYears = 19, at location s7
>> humanYears = 2.25, at location s8
+ Created Cow Mabel (via parameterized constructor), at location s4
Leaving makeCow.
2.25
- Destroyed Cow Mabel, at location s4
+ Created Cow Copy of Bessie (copying Cow Bessie from s3), at location s6
- Destroyed Cow Copy of Bessie, at location s6
- Destroyed Cow Bessie, at location s3
Leaving main.
Hang on, aren't both these lines—
Cow theCow = makeCow(theName, theAge); Cow theOtherCow = theCow;
—calls to the copy constructor? Why do we only see one copy being made?
It's totally fine to think there should be two copies. Because that is what our code says.
But C++ has some rules for avoiding pointless, redundant copying. Putting the result of
makeCow
into a temporary variable and then copying that temporary variable intotheCow
would be pointless, redundant copying, so C++ makes it so thatmakeCow
writes its result into the memory fortheCow
.How much of this do we need to actually care about?
Okay, let's summarize the key takeaways.
Takeaways
When a function returns a value, it actually constructs that value in the calling function. If you're drawing a diagram, you can refer to the space where you'll write the result using the name _retval
.
So, if we have a function that returns an
Elephant
, it has to deal with all the work of making an elephant.Hay! I see it now, this is just like passing-by-value. We're copying data.
Exactly.
So if we can pass-by-reference can we return by reference?
Good question!
(When logged in, completion status appears here.)