Video Coming Soon...

Created by Zed A. Shaw Updated 2024-10-28 08:02:26

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:

  1. It's nothing more than two jumps and a special way to manage variables.
  2. 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:

  1. 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.
  2. 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.
  3. 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 where adder() is.
10 Setup
[20, 26] Before the adder() 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 the adder() function was called.
10 jump to 5
[20, 26, line 10] We've now jumped to line 5 where the adder() function is defined. At this point we have everything we need to make adder() work. Be sure you understand that we've basically done a goto to line 5.
5
[line 10] When adder() runs the compiler pops 20 off the stack and assigns it to a, then pops 26 off and assigns it to b. From now on inside your adder() 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 the adder() function and when that function ends those variables will not exist.
6
[line 10] The adder() function then calls println() like you've done before, but this time it's using the local variables you made a and b.
7 jump to 10
[] The compiler needs to know where to go when the function ends, and that's why it pushed line 10 onto the stack. When the adder() 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 to 5
[40, 50, line 12] Just like the previous example, we push the two arguments 40, 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 to a, then 50 off and assigns it to b.
6
[line 12] This line does the a+b then assigns it to res. Where res lives depends on the compiler and language, so for now just pretend it's "somewhere."
7 jump to 12
[90] This is the interesting part. The compiler pops the return line 12 off like normal, but then it pushes the value or res onto the stack. This is how it "passes" the return value to line 12 where adder() was originally called.
12
[90] When our code returns to line 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 to line 12 the compiler pops the 90 off and then assigns it to x. From this point on x=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:

  1. Call adder() with the wrong number of parameters. Too few, too many, no parameters.
  2. 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

Previous Lesson Next Lesson

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.