Chapter 8

Functions

As our programs grow, something uncomfortable starts to happen. The same few lines of code turn up in three different places. A single block of logic becomes so long that you can't see the top and the bottom of it at the same time. You change one line and something unrelated three pages away breaks. This is what code looks like before functions, and it's not sustainable for anything bigger than a toy.

Functions are how we carve a program up into named, reusable pieces. They let us give a chunk of code a clear name, a defined job, and a clean boundary between "what it does" and "how it does it." In this chapter we'll learn how to write them, how to pass data into them, how to get data back, and how to organize our code around them. By the end, your programs will be significantly more powerful than anything you could write without them.

In this chapter, we will:

Let's start with the question the rest of the chapter depends on.

What Is a Function?

A function is a named block of code that does a specific job. You write it once, give it a name, and then any time you want that job done, you just say its name. C++ goes and runs the code for you and, if the job produces a result, hands it back.

You have actually been writing and calling functions since Chapter 1. Every program in this book so far has started with this line:

int main()

That's a function. It's called main, and it's special because it's the one C++ automatically calls when the program starts. You've also been calling someone else's functions — every std::cout, every SDL call — but no one has made it obvious until now. Functions are everywhere, and you've been using them all along.

The problem functions solve is duplication. Imagine you're building a game and, in four different places, you need to check whether a player's position is off the edge of the screen. Without functions, you'd write the same check four times. Get one copy wrong and your game has a bug only some of the time. Decide to change how the check works and you have to find and fix every copy. Multiply that by every repeated chunk of logic in a real game and the problem is obvious.

A function replaces all four copies with a single named thing. Write the logic once. Call it by name four times. Fix it once when it changes. That's the deal.

The Anatomy of a Function

Every C++ function has four parts. Here is one:

int addTwoNumbers(int a, int b) {
    int result = a + b;
    return result;
}

The four parts are:

The body of this function takes the two numbers it was given, adds them, and then uses the return keyword to hand the answer back. We'll unpack every one of those pieces over the next few sections.

Declaring and Defining Functions

C++ is a bit old-fashioned about one thing: by the time the compiler reaches a call to a function, it needs to already know that the function exists. It needs to know the function's name, what arguments it takes, and what type of value it returns. If you try to call a function the compiler hasn't seen yet, compilation fails.

There are two ways to tell the compiler about a function, and they have slightly different names.

A function definition is the full thing — the complete function with its body:

int addTwoNumbers(int a, int b) {
    int result = a + b;
    return result;
}

A function declaration (sometimes called a forward declaration, or a function prototype) tells the compiler a function exists without providing the body. It ends with a semicolon where the body would be:

int addTwoNumbers(int a, int b);

In the preceding code, we've told the compiler: "There's a function called addTwoNumbers. It takes two ints and returns an int. You'll find the actual body further down — or in another file." That's enough information for the compiler to check calls to the function for correctness.

Here's why this matters. Consider this small program:

#include <iostream>

int main() {
    int sum = addTwoNumbers(3, 4);   // Error! Compiler hasn't met this function yet
    std::cout << sum << std::endl;
    return 0;
}

int addTwoNumbers(int a, int b) {
    return a + b;
}

In the preceding code, we call addTwoNumbers inside main, but the compiler reads top to bottom and doesn't meet addTwoNumbers until after main is already finished. It complains.

There are two ways to fix this. First, move the definition of addTwoNumbers above main:

#include <iostream>

int addTwoNumbers(int a, int b) {
    return a + b;
}

int main() {
    int sum = addTwoNumbers(3, 4);
    std::cout << sum << std::endl;
    return 0;
}

Second — and this is the approach real projects use — keep the definition where it was and add a declaration at the top:

#include <iostream>

int addTwoNumbers(int a, int b);   // Declaration — just tells the compiler this exists

int main() {
    int sum = addTwoNumbers(3, 4);
    std::cout << sum << std::endl;
    return 0;
}

int addTwoNumbers(int a, int b) {   // Definition — the actual code
    return a + b;
}

In the preceding code, the declaration at the top is enough to satisfy the compiler when it reaches main. The full definition comes later. This is the pattern you'll see in real C++ code, usually with the declarations tucked away in a separate header file. We'll meet header files properly in the OOP chapters.

Parameters and Arguments

When you write a function, the variables listed inside the parentheses after its name are called parameters. When you call a function, the values you pass in are called arguments. Parameters are the slots. Arguments are what you put in them.

int addTwoNumbers(int a, int b) {   // a and b are parameters
    return a + b;
}

int main() {
    int result = addTwoNumbers(7, 12);   // 7 and 12 are arguments
    return 0;
}

In the preceding code, a and b are the parameters of addTwoNumbers. They're placeholders that get filled in when the function is called. The call addTwoNumbers(7, 12) passes the arguments 7 and 12, which become the values of a and b inside the function.

Functions can have as many parameters as you like, of any types you like:

void spawnEnemy(int x, int y, int health, float speed, bool isBoss) {
    // Use x, y, health, speed, and isBoss to spawn an enemy
}

They can also have none:

void printGameTitle() {
    std::cout << "My Awesome Game" << std::endl;
}

When you call a function with no parameters, you still need the parentheses: printGameTitle();. The parentheses are part of the call, not part of the parameter list.

Arguments are matched to parameters by position, not by name. The first argument goes into the first parameter, the second into the second, and so on. If you pass them in the wrong order, the compiler won't catch it — as long as the types match, it'll happily hand you garbage results.

spawnEnemy(100, 5, 200, 2.5f, false);   // x=100, y=5, health=200, speed=2.5, isBoss=false

Getting the order right is on you. Good parameter names and, when needed, a well-placed comment go a long way.

Return Values

A function can hand a value back to whoever called it. This is its return value. The return keyword does two things at once: it ends the function immediately and sends a value back.

The return type declared at the start of the function has to match what you actually return:

int squareIt(int n) {
    return n * n;
}

In the preceding code, the return type is int, and n * n is an int, so everything lines up. Try to return a float or a bool here and the compiler will either warn you, convert the value quietly, or flat-out refuse — depending on the mismatch.

The return value of a function can be used wherever any value of that type can be used. You can put it in a variable, use it directly in an expression, or pass it straight into another function:

int score = squareIt(7);               // Stored in a variable
std::cout << squareIt(7) << std::endl;   // Used directly
int doubled = squareIt(7) * 2;         // Part of an expression

Once a return runs, the function is over. Any code after it in the same block is dead — it will never execute. This is sometimes exactly what you want. An early return can make code much cleaner:

int dividePoints(int totalPoints, int numberOfPlayers) {
    if (numberOfPlayers == 0) {
        return 0;                      // Bail out early — can't divide by zero
    }

    return totalPoints / numberOfPlayers;
}

In the preceding code, if there are no players, we return 0 straight away. We never reach the second return. If there are players, we skip the if and fall through to the division. Early returns are a clean way to handle edge cases without burying the real work inside nested if statements.

void Functions

Not every function needs to return something. Sometimes you just want a function to do something — print a message, update a variable, draw a sprite — without handing any value back. For these, the return type is void:

void printScore(int score) {
    std::cout << "Score: " << score << std::endl;
}

The word void literally means "nothing." This function returns nothing. Inside a void function, you can still use return; on its own to exit early, but you can't give it a value:

void greetPlayer(bool isLoggedIn) {
    if (!isLoggedIn) {
        return;                        // Early exit, no value
    }
    std::cout << "Welcome back!" << std::endl;
}

In the preceding code, if the player isn't logged in, we return immediately. The greeting is skipped. If they are logged in, we fall through and print the welcome.

Pass by Value vs Pass by Reference

This is one of those topics that trips up beginners because something quiet and invisible is happening, and once you see it, it's obvious. Before you see it, it's a mystery.

When you pass an argument to a function by the normal method you've seen so far, C++ makes a copy of that argument and hands the copy to the function. The function works on its copy. The original, back in the calling code, is untouched. This is called pass by value.

Here it is in action:

void tryToDouble(int n) {
    n = n * 2;   // Modifies the copy
}

int main() {
    int score = 10;
    tryToDouble(score);
    std::cout << score << std::endl;   // Still 10!
    return 0;
}

In the preceding code, tryToDouble appears to double its argument. But when we print score afterwards, it's still 10. Why? Because tryToDouble was given a copy of score. It doubled that copy to 20. The copy was thrown away when the function ended. The original score in main never changed.

For small, simple types like int and float, this copying is cheap and usually exactly what we want. It keeps functions nicely contained — a function can't accidentally reach out and scribble on variables it doesn't own.

But sometimes we actually want the function to modify the caller's variable. Or we have a very large object that would be wasteful to copy every call. For both of these cases, C++ gives us pass by reference.

To pass by reference, you add an & after the parameter's type:

void actuallyDouble(int& n) {   // Note the &
    n = n * 2;
}

int main() {
    int score = 10;
    actuallyDouble(score);
    std::cout << score << std::endl;   // 20!
    return 0;
}

In the preceding code, the parameter is now int& n instead of int n. That tiny & changes everything. The function isn't handed a copy anymore — it's handed a direct reference to the caller's score. When it modifies n, it's modifying score itself. The call from main sees the change.

Think of it like this. Pass by value is giving someone a photocopy of a document. They can scribble all over it; your original is safe. Pass by reference is giving someone your original document. What they do to it, you'll see when they hand it back.

const References

Pass by reference is also useful for a completely different reason: avoiding the cost of a copy. For a type as small as an int, the copy is basically free. But for a big object — a thousand-element array, a long string, a complex game entity — making a copy every time you call a function would be wasteful.

There's a snag. If you pass a big object by reference just to save the copy, the function could also modify it, and sometimes you don't want that. You want the function to be able to read it cheaply, but not change it. For that, we add const:

void printName(const std::string& name) {
    std::cout << name << std::endl;
    // name = "something else";   // This would be a compiler error!
}

In the preceding code, const std::string& means "a reference to a string that can't be modified." No copy is made — fast. And the function can't accidentally change the string — safe. This pattern, const reference, is the standard way to pass anything non-trivial into a function when the function only needs to read it. You'll see it constantly in real C++ code.

The rough rule of thumb:

Function Overloading

C++ lets you have two or more functions with the same name, as long as their parameter lists are different. This is called function overloading. Which version of the function gets called depends on which set of arguments you pass.

void printValue(int n) {
    std::cout << "Integer: " << n << std::endl;
}

void printValue(float f) {
    std::cout << "Float: " << f << std::endl;
}

void printValue(const std::string& s) {
    std::cout << "String: " << s << std::endl;
}

Now we can call printValue with all three different types:

printValue(42);              // Calls the int version
printValue(3.14f);           // Calls the float version
printValue("Hello");         // Calls the string version

In the preceding code, the compiler works out which version to call based on the type of the argument you passed. This is entirely a compile-time decision — there's no runtime cost.

Overloading is most useful when you want a single conceptual operation — "print a value," "spawn an enemy," "render a shape" — to work with several different kinds of input. The alternative would be ugly function names like printInt, printFloat, printString, and the reader would have to remember which to call for which type.

The risk with overloading is that it can become confusing if overused. If you write six overloads of the same function, nobody — including future you — can easily tell which one a particular call will hit. Use overloading when the different versions are genuinely doing the same conceptual thing with different inputs. Don't use it to pack unrelated work under one name.

Scope

Every variable in C++ exists inside a scope — a region of the program where the variable is visible and usable. Outside that region, the variable might as well not exist. Scope is one of those quietly important ideas that stops your code turning into chaos.

The basic rule is simple: a variable declared inside a pair of curly braces { } is visible only inside those braces, and disappears the moment they close.

void doSomething() {
    int local = 5;
    std::cout << local << std::endl;   // Fine — we're inside the braces
}

// std::cout << local << std::endl;    // Error! local doesn't exist out here

In the preceding code, local is a local variable. It's born when the function is called, lives for as long as the function is running, and is gone the moment the function returns. If another function tries to use local, the compiler will stop it — as far as that other function is concerned, local doesn't exist.

This also applies to blocks inside a function. Remember the for loop from Chapter 6? The counter declared inside the loop only lives inside the loop:

for (int i = 0; i < 5; i++) {
    std::cout << i << std::endl;
}
// std::cout << i << std::endl;   // Error! i is gone

Scope is what makes it safe to reuse common names like i, x, or result in different functions without them interfering with each other. Each function has its own universe of variables, sealed off from the others.

Global Variables (And Why to Avoid Them)

C++ also lets you declare variables outside any function. These are global variables, and they're visible everywhere in the program:

int globalScore = 0;   // Declared outside any function

void addPoints(int amount) {
    globalScore = globalScore + amount;
}

void resetScore() {
    globalScore = 0;
}

In the preceding code, both addPoints and resetScore can see and modify globalScore. That seems convenient. It's also a trap.

The trouble with global variables is that any code can change them at any time, for any reason. If your game has a weird bug where the score suddenly resets to zero halfway through a level, and the score is a global, the bug could be anywhere in your entire program. With local variables and parameters, the range of possible culprits is tiny — just the function you're in. With globals, everything is suspect.

The general advice is simple: avoid globals where you can. Pass data into functions as parameters, and return data out as return values. When we get to object-oriented programming, we'll see even better ways to organize shared data without resorting to globals.

Default Parameter Values

C++ lets you give a function's parameters default values, which are used automatically if the caller doesn't provide an argument for them. This can make functions with lots of parameters much friendlier to call.

void spawnEnemy(int x, int y, int health = 100, float speed = 1.0f) {
    // Spawn the enemy
}

In the preceding code, health defaults to 100 and speed defaults to 1.0. Now the function can be called in any of these ways:

spawnEnemy(50, 75);                    // health=100, speed=1.0
spawnEnemy(50, 75, 200);               // health=200, speed=1.0
spawnEnemy(50, 75, 200, 3.5f);         // health=200, speed=3.5

There's one rule to remember: default values have to go on the parameters at the end of the list. You can't have a parameter with a default followed by one without a default. This wouldn't work:

// Wrong! Compiler error.
void spawnEnemy(int x, int y = 75, int health, float speed) {
    // ...
}

The reason is that arguments are matched to parameters by position. If you left out the middle one, C++ would have no way to know which of the later arguments you meant.

Default values are particularly useful for functions that are mostly called the same way but occasionally need a tweak. They keep the common call short while leaving the full flexibility available.

Inline Functions

You'll occasionally see the keyword inline in front of a function:

inline int square(int n) {
    return n * n;
}

What this does, roughly, is hint to the compiler that it might be a good idea to paste the body of the function directly wherever it's called, rather than performing a proper function call. For tiny functions called in tight loops, this can save a little bit of work. The compiler is ultimately free to ignore the hint, and modern compilers are usually smarter about inlining than you or I would be.

You don't need to worry about inline yet — the compiler almost always does the right thing. We're mentioning it here only so that when you see it in other people's code, you know what it is. It's a hint, not a command.

Recursion

A function that calls itself is called recursive. This sounds insane the first time you hear it — how does a function calling itself not just run forever? — but in practice, recursion is a surprisingly neat way to solve certain problems.

The classic teaching example is the factorial. The factorial of a number n, written n!, is the product of all the positive integers up to n. So 5! is 5 × 4 × 3 × 2 × 1 = 120. It has a nice recursive definition: n! equals n × (n-1)!, except when n is 1, in which case it's just 1.

Here's that definition translated directly into C++:

int factorial(int n) {
    if (n <= 1) {
        return 1;             // Base case — stop recursing
    }

    return n * factorial(n - 1);   // Recursive case — call ourselves with a smaller n
}

In the preceding code, factorial calls itself with a slightly smaller value of n each time. The if (n <= 1) check is the base case — the condition that finally makes the function stop calling itself and return a real answer. Without a base case, the function would call itself forever.

Let's trace factorial(4) to see what happens:

The calls then unwind from the bottom up: 2 * 1 = 2, then 3 * 2 = 6, then 4 * 6 = 24. So factorial(4) is 24.

Every recursive function needs two things to work: a base case that stops the recursion, and a recursive case that moves closer to the base case every time. If either is missing or wrong, the function will call itself over and over until C++ runs out of memory for function calls — a crash known as stack overflow. (That's also where the famous programming website gets its name.)

Recursion isn't always the right tool. Factorial could be written just as easily with a for loop, and the loop version is usually faster. Where recursion shines is with problems that are naturally recursive — walking a tree of folders on a hard drive, navigating a tree of game-world objects, pathfinding through a branching set of choices. We'll see some of those in later chapters. For now, it's enough to know what recursion is, why a base case matters, and that it exists as a tool in your kit.

Organizing Code with Functions

Once you start writing functions, a fundamental shift happens in how your programs look. Instead of one enormous main that does everything, you end up with a short main that does almost nothing directly — it just calls a handful of well-named functions, each of which has one job.

Think about a game loop. Without functions, it's a single huge block of code inside main that handles input, updates the world, draws the screen, and so on — all tangled together. With functions, it becomes something like this:

int main() {
    while (true) {
        handleInput();
        updateWorld();
        drawFrame();

        if (playerWantsToQuit()) {
            break;
        }
    }
    return 0;
}

Every detail of how input is handled, how the world is updated, how the frame is drawn is tucked away inside those functions. The main loop reads almost like English. When you want to change how input works, you go to handleInput and nowhere else. When a new feature breaks something, the suspects are narrowed down to the function responsible for the broken bit.

The guiding principle is one function, one job. If you find a function that does three different things, it probably wants to be three functions. A good function name is usually a verb phrase that describes exactly what the function does — updatePlayer, spawnEnemy, drawHUD, calculateDamage. If you can't come up with a good name, that's a signal that the function isn't focused enough.

In a large real-world project, functions also get spread across multiple files. Declarations go in header files (.h files) and the actual code goes in source files (.cpp files). This is how C++ programs scale up without collapsing under their own weight. We'll meet the full story properly in Chapter 16 when we start building classes.

Debugging Functions

Visual Studio's debugger has a feature that was useful before but becomes invaluable once you're writing functions: the call stack.

The call stack is a list of all the functions currently "in progress." When main calls updateWorld, which calls updatePlayer, which calls checkCollisions, the call stack has all four on it. When checkCollisions returns, it comes off the top of the stack. When updatePlayer returns, it comes off too. And so on, back up to main.

When your program crashes or hits a breakpoint, the Call Stack window in Visual Studio shows you exactly this chain. You can click any function in the stack and the debugger will take you to that function's code, with the local variables and arguments for that function shown in the Locals window. This is the single best way to answer the question "how did we end up here?" when something goes wrong deep inside nested function calls.

Two other debugger commands are especially useful for functions. Step Into (F11) takes you inside a function call — you're now running the code of the function that was just called. Step Over (F10) runs the function call as a single step without taking you inside it. Use Step Into when you want to see what's happening inside the function. Use Step Over when you trust the function and just want to get past it. The pair of them, plus the call stack, will carry you through most debugging sessions you'll ever have.

AI Exercise

For this exercise, we're going to write a short, messy program on purpose, then use an AI to help us clean it up. This is one of the most common real-world uses of AI coding tools — not generating new code from scratch, but refactoring existing code into something better.

A quick note on which AI tool to use. For this exercise, please use a regular chatbot — something like Claude, ChatGPT, Gemini, or similar, in its normal chat interface. Don't use an AI agent that acts on your files directly, and don't use an AI assistant that's integrated into Visual Studio. The point here is to paste code into a chat, read what comes back, and make your own decisions about what to do with it. That's the workflow we want to build muscle memory for first. The fancier tools can come later.

Step 1 — Write this code. Create a new Visual Studio project (or reuse an old one) and type in the following:

#include <iostream>

int main() {
    int playerHealth = 100;
    int playerDamage = 0;

    // The player takes a fire hit
    playerDamage = 25;
    if (playerHealth - playerDamage > 0) {
        playerHealth = playerHealth - playerDamage;
        std::cout << "Health is now: " << playerHealth << std::endl;
    } else {
        playerHealth = 0;
        std::cout << "Player died!" << std::endl;
    }

    // The player takes a poison hit
    playerDamage = 10;
    if (playerHealth - playerDamage > 0) {
        playerHealth = playerHealth - playerDamage;
        std::cout << "Health is now: " << playerHealth << std::endl;
    } else {
        playerHealth = 0;
        std::cout << "Player died!" << std::endl;
    }

    // The player drinks a health potion
    int healing = 30;
    playerHealth = playerHealth + healing;
    if (playerHealth > 100) {
        playerHealth = 100;
    }
    std::cout << "Health is now: " << playerHealth << std::endl;

    return 0;
}

Build and run it. You should see three lines of output tracking the player's health. Notice how the damage logic is repeated almost word-for-word, just with different numbers.

Step 2 — Ask an AI to refactor it. Open your chatbot of choice, paste the full code above, and use this prompt:

"I'm a C++ beginner. I've just learned about functions, parameters, return values, and pass by reference. Please refactor the code below so that the repeated damage logic becomes a function, and the healing logic becomes a function. Keep the main function short and readable. After the refactored code, give me a brief explanation of what each function does, what parameters you chose, and why. [paste code here]"

Notice what the preceding prompt is doing. It sets the AI's expectations about your level, names the specific C++ features you want it to use, tells it exactly what should be refactored, and asks for a short explanation. That explanation is the real payoff — it's what turns the exercise from "getting working code" into "learning something."

Step 3 — Evaluate the result. When the AI responds, don't just copy and paste. Read the code carefully and ask yourself:

Then try building and running the AI's version. If it doesn't compile, paste the error messages back into the chat and ask for a fix. If it compiles but produces different output, that's a bug — your job is to find it.

The goal isn't to end up with a cleaner piece of code. The goal is to practice the one habit that separates people who use AI well from people who use AI badly: read what it gives you, understand every line, and don't move on until you do.

Summary

Functions are the foundation everything else in this book is built on. You now know how to define and declare them, how to pass data in using parameters, how to get data out using return values, and how to choose between pass by value and pass by reference. You've met function overloading, scope, default parameter values, and recursion. You know why global variables cause more problems than they solve. And you've seen the call stack — the debugger feature that turns "what on earth just happened?" into a precise, readable trail of clues.

Everything you do from here on will be organized around functions. In the next chapter you'll use loops, conditions, variables, and functions together in the biggest project of Act 1 so far. After that, Act 2 begins, and we finally get to answer a question that has been quietly lurking in the background of several chapters: what exactly is a pointer, and why should we care?