Video Coming Soon...
15: Basic Functions
You hopefully have learned that as long as you have variables, jumps, boolean tests, and if-statements
you can create anything else in programming. You wouldn't want to do all your C++ like this, but when you run into a problem understanding a new structure just remember that they're all some variation on these four things. If you get stuck try asking yourself, "Where's the jump? What's the tests? What's the variables?"
Another fundamental important concept in programming is the function. You've been using functions this whole time when you wrote main()
or println()
but you didn't really know what was going on. In this exercise we'll take the first steps to understanding functions.
Jumps and Variables
There's two ways to look at a function:
- It's nothing more than two jumps and a special way to manage variables.
- It's a mathematical construct found in algebra as in
x = y(g)
.
The first (jump+variables) view is understanding how a function is implemented by the C++ compiler. Compiler's don't generate code for an algebra machine. They generate code for a CPU. This is the understanding we'll try to grasp in this exercise.
The second (algebraic) view of functions is the way things should be, but understanding this view of functions requires some fairly advanced math knowledge so we'll try to get into it later. There's a lot of amazing things you can do once you understand this way of viewing functions.
If anyone tries to tell you that the algebraic view of functions is the only correct view just tell them, "Alonzo Church said that Turing's formulation of effective computation was superior to his own lambda calculus." Then link them to quotes from both Church and Gödel stating that Turing's analysis was superior. After that, get back to work. These people will eat all of your time if you let them.
Cards and Stacks
To understand functions better we need to know about a hidden data structure inside your CPU called the "stack." A stack works like a deck of poker cards, or Uno cards, or Yu-gi-oh cards. Whatever kind of card game you like to play, if you make a stack of them then there's certain properties of this stack of cards:
- Imagine you're fancy and you use a nice card holder that only lets you put cards into the holder from the top. You can't take them from the side or the bottom, only the top.
- If you take a card from the top you can only get one at a time. In the CPU this is called "popping" the card from the top.
- If you want to put a card back because you don't like it then you'd have to only put it on the top of the stack. The container won't let you put it on the bottom or through the middle. This is called "pushing" onto the stack in the CPU.
NOTE I have to confirm this story is correct. I do know card hoppers come from looms and player pianos but I'm not clear on the actual character detection mechanism.
This fancy card holding device actually existed in computers and was called a "hopper." Way back in the dark ages of computing we programmed computers with actual cards. The cards came from the same kinds of cards found in looms and player pianos. They had holes where a little rod would pass through and contact a piece of metal on the other side of the card. If the "hopper" detected a signal from a rod making contact (because it passed through a hole) then it would record that character. Eventually this became more and more complex, but the ideas of "cards" in "hoppers" on a "stack" still persists in computing.
Stacks and Functions
Keep this idea of a stack in mind as you type this code in:
View Source file ex15a.cpp Only
#include <fmt/core.h>
using namespace fmt;
void adder(int a, int b) {
println("{}+{}={}", a, b, a+b);
} // pop stack, jump back
int main() {
adder(20, 26); // push [20, 26, line 10]
adder(30, 36); // ?
return 0;
}
Get this code working and remember to type it a little bit at a time, but you'll probably have to create the "shells" of the adder()
function, call it in the main()
function, then fill in the rest of adder()
.
Once you get it working, we can walk through what's going on, but this time I'm going to show what's on the stack when you call the function. I'll use []
to show the stack at each line, and the left side is the "top":
09
- Our
main()
function starts like normal. Remember, your code starts here and will later jump to line #5 whereadder()
is. 10
Setup[20, 26]
Before theadder()
function is called C++ pushes the number 10 and 16 onto the stack. This is why I have[10, 16]
here so you can track the stack. Computers are very particular about the order but we'll just say it's like this for now.10
Call[20, 26, line 10]
Our stack is still like this, and we're still on line 10, but now we also push the line number 10 on the stack. This will let C++ remember where theadder()
function was called.10
jump to5
[20, 26, line 10]
We've now jumped to line 5 where theadder()
function is defined. At this point we have everything we need to makeadder()
work. Be sure you understand that we've basically done agoto
to line 5.5
[line 10]
Whenadder()
runs the compiler pops20
off the stack and assigns it toa
, then pops26
off and assigns it tob
. From now on inside youradder()
function you can use these two variables and not worry about them "leaking" out into other places in the code. They are fully enclosed inside theadder()
function and when that function ends those variables will not exist.6
[line 10]
Theadder()
function then callsprintln()
like you've done before, but this time it's using the local variables you madea
andb
.7
jump to10
[]
The compiler needs to know where to go when the function ends, and that's why it pushedline 10
onto the stack. When theadder()
function ends the compiler just pops the stack one more time, reads the line it pops, and then jumps to that line. This is make the return always work, and it lets you call function after function without losing track of where to return.11
- After jumping back to line 10 we simply move to line 11 and repeat the process for the next
adder()
call.
At this point you should do this same process and keep track of the stack in the same way. It might help to print this code onto a piece of paper and use a pen or pencil to track it like I did here. You want to track the lines that run, and what's on the stack at each line.
Return Values
This very detailed description of pushing and popping should help you understand how return
works. All that happens is the compiler pushes the return
value onto the stack, and when the jump returns, it then pops the stack and assigns it to your variable. Let's look at this code to get an understanding of return values:
View Source file ex15b.cpp Only
#include <fmt/core.h>
using namespace fmt;
int adder(int a, int b) {
int res = a + b;
return res; // push res, pop 11
}
int main() {
// push [40, 50, line 12]
int x = adder(40, 50); // pop x
println("x={}", x);
// ?
int y = adder(60, 70); // ?
println("y={}", y);
return 0;
}
We'll start this walk right at line 11:
11
- This is just a comment to show you the stack for the next line.
12
jump to5
[40, 50, line 12]
Just like the previous example, we push the two arguments40, 50
and the current line onto the stack.5
[line 12]
As before, when we hit line 5 the compiler pops 40 off and assigns it toa
, then 50 off and assigns it tob
.6
[line 12]
This line does thea+b
then assigns it tores
. Whereres
lives depends on the compiler and language, so for now just pretend it's "somewhere."7
jump to12
[90]
This is the interesting part. The compiler pops the returnline 12
off like normal, but then it pushes the value orres
onto the stack. This is how it "passes" the return value toline 12
whereadder()
was originally called.12
[90]
When our code returns toline 12
the compiler has everything it needs to complete the call. The stack has[90]
for the return value, and just needs to assign it.12
[]
Still on line 12. When our code returns toline 12
the compiler pops the90
off and then assigns it tox
. From this point onx=90
.
That finishes the way a function is called and how it can return values. Once again, you should take the time to do this same analysis of the next call to adder()
.
Do I Need This?
You may wonder if you have to do this insane deep dive into stacks everytime you call a function. No, you don't need to constantly keep track of what's on the stack just to call functions. Once you take this deep dive you should be able to use them directly without study, but feel free to keep breaking your function calls down until it "clicks."
"Learn the changes, then forget them." -- Charlie Parker
The real purpose of this exercise is to avoid magical thinking. Magical thinking happens when you don't actually know how the world works so you invent weird descriptions of the world to explain what you observe. Many of these "weird descriptions" involve some imaginary entity, mystical force, or simply ridiculous belief that's contrary to reality.
Now you know that functions use a stack to control the arguments, jumps, and return values. There's even more details you can get into, but further knowledge would require a deep, deep, dive into assembly language. The information you have here will help you to use functions, and later when you get into debugging them you'll have a better understanding of what's going on.
auto
to Help
As a final treat to finish off this exercise here's how you can use auto
to make it a bit easier to write your code:
View Source file ex15c.cpp Only
#include <fmt/core.h>
using namespace fmt;
auto adder(int a, int b) {
return a + b;
}
int main() {
auto x = adder(100, 40);
println("x={}", x);
auto y = adder(100, 50);
println("y={}", y);
return 0;
}
I'm kind of mixed on whether you should use auto
or not. It is very convenient, but it is very important that you know the types of the things you create. I think it'll help if you avoid auto
for now and later we'll learn about a few places that auto
fits perfectly. You can also use auto
to get started then conver it to an exact type once it's working.
Break It
One of the great things about function is they're fairly difficult to break. You have to do fairly advanced things to make a function fail. You can however use functions wrong so try a few of these:
- Call
adder()
with the wrong number of parameters. Too few, too many, no parameters. - You can write multiple functions with the same name, but different parameter types, and C++ will figure out which one you mean. Problem is if you do this on accident you'll probably be confused. Create another
adder()
function a different number of parameters and call it to see how that might cause you trouble. Later this feature is very, very, useful.
Further Study
- Write a new function that does subtraction named
subtract()
. - Write another function that uses
adder()
and yoursubtract()
to do multiple calculations, then call that function from insidemain()
. - Go back through all your code so far (or as much as you can), and review all the functions you've been using. Try to do as many "stack studies" of these functions as you can until you're positive you know how those functions are working.
- ADVANCED Can you replicate these function calls with variables and
goto
? Remember that you can call these functions from anywhere insidemain()
and inside other functions, so your attempt should support that too. I should also mention that this could end up driving you insane and you'll most likely learn the real reason why people avoidgoto
when you're done. - ADVANCED Functions can have default values for parameters. Figure out how to do that.
Register for Learn C++ the Hard Way
Register to gain access to additional videos which demonstrate each exercise. Videos are priced to cover the cost of hosting.