Free Handbook · Every example compiled & verified

Functions

Declarations and definitions, pass by value, reference and const reference, overloading, default arguments, recursion, function templates and lambdas.

0 / 145 lessons🔥 0 day streak
ShareXLinkedIn

Module 03 · what you'll be able to do

  • Declare a function before use, define it once, and split code into a header and a source file
  • Choose between pass by value, by reference and by const&, and predict which one changes the caller's variable
  • Return several values with a struct or structured bindings, and signal "no result" with std::optional
  • Overload functions, give parameters default values, and write a first function template
  • Write lambdas with the right captures and store them in auto or std::function
01

Declaring and defining functions

A function has a return type, a name, a parameter list and a body: int add(int a, int b) { return a + b; }. That whole thing is a definition. A declaration (also called a prototype) is just the first line followed by a semicolon: int add(int a, int b);. It promises the compiler "this function exists, with this signature", which is enough to compile a call to it.

The C++ compiler reads a file top to bottom, once. A function must be declared above the first line that calls it. You can either define helper functions above main, or declare them at the top and define them below. A program may declare a function many times but define it only once (the one definition rule).

C++main.cpp
#include <iostream>

// Declarations: enough for main to call these
double celsiusToF(double c);
void printRow(const char* label, double c);

int main() {
    printRow("freezing", 0);
    printRow("body", 37);
    printRow("boiling", 100);
}

// Definitions: the actual bodies, after main
double celsiusToF(double c) {
    return c * 9 / 5 + 32;
}

void printRow(const char* label, double c) {     // void: returns nothing
    std::cout << label << ": " << c << " C = " << celsiusToF(c) << " F\n";
}
Outputcompiled & run with real C++
freezing: 0 C = 32 F
body: 37 C = 98.6 F
boiling: 100 C = 212 F
Your turn

Delete the two declarations at the top and compile. Then move the definitions above main and it compiles again with no declarations at all.

Error you will hit

use of undeclared identifier (calling before declaring)

C++
#include <iostream>

int main() {
    std::cout << square(4) << '\n';
}

int square(int x) { return x * x; }
main.cpp:4:18: error: use of undeclared identifier 'square'
    4 |     std::cout << square(4) << '\n';
      |                  ^~~~~~
Why the compiler said that

When the compiler reaches line 4 it has not seen square yet; it does not look ahead. Python and JavaScript find functions at runtime, so this surprises people coming from them.

The fix

Add a declaration above main (or move the definition up).

C++
#include <iostream>

int square(int x);                // declaration

int main() {
    std::cout << square(4) << '\n';
}

int square(int x) { return x * x; }
02

Pass by value, by reference and by const reference

By default C++ passes arguments by value: the parameter is a fresh copy, so changing it inside the function does not touch the caller's variable. Add & to pass by reference: the parameter becomes another name for the caller's variable, and changes go straight through. Add const & to get the speed of a reference (no copy) with a promise not to modify.

ParameterCopies?Can change caller?Use for
int xyesnosmall, cheap types: numbers, char, bool
int& xnoyes"output" parameters, updating something in place
const std::string& snonoreading anything bigger than a couple of words: strings, vectors, structs
C++main.cpp
#include <iostream>
#include <string>
#include <vector>

void tryToReset(int n) {                        // n is a copy
    n = 0;
    std::cout << "inside tryToReset: " << n << '\n';
}
void reset(int& n)          { n = 0; }          // changes the caller's variable
void addBonus(std::vector<int>& pay, int bonus) {
    for (int& p : pay) p += bonus;
}
std::size_t countVowels(const std::string& s) { // no copy, read-only
    std::size_t count = 0;
    for (char c : s) {
        if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u') ++count;
    }
    return count;
}

int main() {
    int score = 42;
    tryToReset(score);
    std::cout << "after tryToReset: " << score << '\n';
    reset(score);
    std::cout << "after reset: " << score << '\n';

    std::vector<int> pay{1000, 1200};
    addBonus(pay, 50);
    std::cout << pay[0] << ' ' << pay[1] << '\n';
    std::cout << countVowels("education") << " vowels\n";
}
Outputcompiled & run with real C++
inside tryToReset: 0
after tryToReset: 42
after reset: 0
1050 1250
5 vowels
Your turn

Change countVowels to take std::string& s (no const). The call with "education" stops compiling. The error card below explains why.

VisualizeA copy versus a referenceStep 1 / 5
void tryToReset(int n) { n = 0; }
void reset(int& n) { n = 0; }
int main() {
int score = 42;
tryToReset(score);
reset(score);
}
Line 5

score is created in main.

Variables now
score42
All 5 steps as a table
StepLineWhat happenedVariables now
15score is created in main.score = 42
26Calling tryToReset copies 42 into a brand-new n.score = 42 n = 42 (copy)
31n = 0 changes only the copy. When the function returns, the copy is destroyed.score = 42 n = 0 (copy)
47Calling reset binds n to score itself; no copy is made.score = 42 n = refers to score
52n = 0 writes through the reference into score.score = 0 n = refers to score
Error you will hit

candidate function not viable: expects an lvalue

C++
#include <iostream>

void addTen(int& x) { x += 10; }

int main() {
    addTen(5);
}
main.cpp:6:5: error: no matching function for call to 'addTen'
    6 |     addTen(5);
      |     ^~~~~~
main.cpp:3:6: note: candidate function not viable: expects an lvalue for 1st argument
    3 | void addTen(int& x) { x += 10; }
      |      ^      ~~~~~~
Why the compiler said that

A non-const reference must refer to a real, named variable (an lvalue) because the function is allowed to modify it. 5 is a temporary value with nowhere to write back to. A const& parameter, by contrast, happily accepts temporaries and literals, which is why read-only parameters should always be const&.

The fix

Pass a variable, or, if the function should not modify its argument, take it by value or const& and return the result.

C++
#include <iostream>

int plusTen(int x) { return x + 10; }

int main() {
    std::cout << plusTen(5) << '\n';
}
In real jobs
Passing a std::vector or std::string by value "by accident" is one of the most common performance comments in C++ code review: every call copies every element. The house rule almost everywhere is: numbers by value, everything else by const&, and a plain & only when the function's name makes it obvious it modifies the argument. Pointers as parameters are covered in Module 05.
03

Return values, several results and std::optional

return ends the function and hands one value back. A function can only return one thing, but that thing can be a struct with several fields, which you can unpack with structured bindings (auto [a, b] = f();). When a function might have no answer, return std::optional<T> instead of a magic value like -1. Returning a local object by value is cheap: the compiler builds it directly in the caller (copy elision).

C++main.cpp
#include <iostream>
#include <optional>
#include <string>
#include <vector>

struct Stats {
    int min;
    int max;
    double average;
};

Stats summarize(const std::vector<int>& v) {
    Stats s{v[0], v[0], 0.0};
    int sum = 0;
    for (int x : v) {
        if (x < s.min) s.min = x;
        if (x > s.max) s.max = x;
        sum += x;
    }
    s.average = static_cast<double>(sum) / static_cast<double>(v.size());
    return s;
}

std::optional<std::size_t> findIndex(const std::vector<std::string>& v, const std::string& target) {
    for (std::size_t i = 0; i < v.size(); ++i) {
        if (v[i] == target) return i;
    }
    return std::nullopt;               // "no answer", not a fake index
}

int main() {
    auto [lo, hi, avg] = summarize({7, 2, 9, 4});
    std::cout << lo << ' ' << hi << ' ' << avg << '\n';

    std::vector<std::string> team{"Asha", "Ravi", "Meera"};
    if (auto idx = findIndex(team, "Ravi")) std::cout << "Ravi at " << *idx << '\n';
    std::cout << findIndex(team, "Zoya").value_or(999) << '\n';
}
Outputcompiled & run with real C++
2 9 5.5
Ravi at 1
999
Your turn

summarize reads v[0], so it breaks on an empty vector. Make it return std::optional<Stats> and handle the empty case.

Error you will hit

non-void function does not return a value in all control paths

C++
#include <iostream>

int sign(int x) {
    if (x > 0) return 1;
    if (x < 0) return -1;
}

int main() {
    std::cout << sign(4) << '\n';
}
main.cpp:6:1: warning: non-void function does not return a value in all control paths [-Wreturn-type]
    6 | }
      | ^
Why the compiler said that

When x is 0, neither return runs and the function falls off the end. For any function other than main, that is undefined behaviour: the caller reads whatever garbage is in the return register. It is only a warning, and clang shows it even without -Wall, but treat it as an error.

The fix

Make sure every path returns a value.

C++
int sign(int x) {
    if (x > 0) return 1;
    if (x < 0) return -1;
    return 0;
}
04

Overloading and default arguments

Overloading means several functions share a name but differ in their parameter types or count. The compiler picks the best match for each call at compile time (overload resolution). The return type alone cannot tell overloads apart. Default arguments let a caller leave out trailing parameters; defaults must be at the end of the list and are written in the declaration, not repeated in the definition.

C++main.cpp
#include <iostream>
#include <string>

// Overloads: same name, different parameter types
void show(int x)                { std::cout << "int " << x << '\n'; }
void show(double x)             { std::cout << "double " << x << '\n'; }
void show(const std::string& s) { std::cout << "string " << s << '\n'; }

// Default arguments: only trailing parameters
std::string pad(const std::string& s, std::size_t width = 8, char fill = '.') {
    std::string out = s;
    while (out.size() < width) out += fill;
    return out;
}

int main() {
    show(3);
    show(3.5);
    show(std::string{"three"});
    show('A');                         // char promotes to int: picks show(int)

    std::cout << '[' << pad("id") << "]\n";
    std::cout << '[' << pad("name", 6) << "]\n";
    std::cout << '[' << pad("x", 4, '-') << "]\n";
}
Outputcompiled & run with real C++
int 3
double 3.5
string three
int 65
[id......]
[name..]
[x---]
Your turn

Call show("hi") without std::string{}. A string literal is a const char*, and converting it to std::string is a user-defined conversion, so which overload wins? Predict, then compile.

Error you will hit

call to 'show' is ambiguous

C++
#include <iostream>

void show(int x)    { std::cout << "int " << x << '\n'; }
void show(double x) { std::cout << "double " << x << '\n'; }

int main() {
    show(42L);
}
main.cpp:7:5: error: call to 'show' is ambiguous
    7 |     show(42L);
      |     ^~~~
main.cpp:3:6: note: candidate function
    3 | void show(int x)    { std::cout << "int " << x << '\n'; }
      |      ^
main.cpp:4:6: note: candidate function
    4 | void show(double x) { std::cout << "double " << x << '\n'; }
      |      ^
Why the compiler said that

42L is a long. There is no show(long), and converting long to int and long to double are equally ranked conversions, so neither overload is better. The compiler refuses to guess.

The fix

Add an exact overload, or convert the argument yourself so one candidate matches exactly.

C++
show(static_cast<int>(42L));   // or add: void show(long x)
Error you will hit

missing default argument on parameter

C++
#include <iostream>

int power(int base = 2, int exp) {
    int r = 1;
    for (int i = 0; i < exp; i++) r *= base;
    return r;
}

int main() {
    std::cout << power(3, 2) << '\n';
}
main.cpp:3:29: error: missing default argument on parameter 'exp'
    3 | int power(int base = 2, int exp) {
      |                             ^
Why the compiler said that

Arguments are matched left to right, so a caller can only leave off parameters at the end. Once one parameter has a default, every parameter after it needs one too.

The fix

Reorder the parameters so the defaulted ones come last.

C++
int power(int exp, int base = 2) {
    int r = 1;
    for (int i = 0; i < exp; i++) r *= base;
    return r;
}
05

Recursion

A recursive function calls itself on a smaller piece of the problem. It needs a base case that answers directly and a recursive case that moves towards it. Every call gets its own copy of its parameters and locals on the call stack, so the calls do not interfere. Without a reachable base case, calls pile up until the stack runs out and the program crashes. Module 13 uses recursion on trees and graphs.

C++main.cpp
#include <iostream>
#include <string>

long long power(long long base, int exp) {
    if (exp == 0) return 1;                 // base case
    return base * power(base, exp - 1);     // smaller problem
}

std::string toBinary(int n) {
    if (n < 2) return std::to_string(n);
    return toBinary(n / 2) + std::to_string(n % 2);
}

bool isPalindrome(const std::string& s, std::size_t lo, std::size_t hi) {
    if (lo >= hi) return true;
    return s[lo] == s[hi] && isPalindrome(s, lo + 1, hi - 1);
}

int main() {
    std::cout << power(2, 10) << '\n';
    std::cout << toBinary(13) << '\n';
    std::string word = "racecar";
    std::cout << std::boolalpha << isPalindrome(word, 0, word.size() - 1) << '\n';
}
Outputcompiled & run with real C++
1024
1101
true
Your turn

Write long long fastPower(long long b, int e) that uses fastPower(b, e / 2) once and squares it. It needs about 10 calls for e = 1000 instead of 1000.

VisualizetoBinary(6) going down and coming back upStep 1 / 7
std::string toBinary(int n) {
if (n < 2) return std::to_string(n);
return toBinary(n / 2) + std::to_string(n % 2);
}
int main() {
std::string b = toBinary(6);
}
Line 6

main calls toBinary(6).

Variables now
n6
All 7 steps as a table
StepLineWhat happenedVariables now
16main calls toBinary(6).n = 6
236 is not below 2. This call must wait for toBinary(3) before it can add "0" (6 % 2).n = 6
33A new frame: toBinary(3) waits for toBinary(1) and will add "1".n = 3
42Base case: toBinary(1) returns "1" straight away.n = 1
53Back in the n = 3 frame: "1" + "1" gives "11".n = 3
63Back in the n = 6 frame: "11" + "0" gives "110".n = 6
76main receives the finished string.b = "110"
06

Function templates

If you find yourself writing the same function for int, double and std::string, write it once as a template. template <typename T> introduces a placeholder type; for each type you call it with, the compiler generates (instantiates) a real function. You usually do not name T: the compiler deduces it from the arguments. Templates and the STL get a full module in Module 09.

C++main.cpp
#include <iostream>
#include <string>
#include <vector>

template <typename T>
T biggest(T a, T b) {
    return a > b ? a : b;
}

template <typename T>
T sum(const std::vector<T>& v) {
    T total{};                  // zero for numbers, "" for strings
    for (const T& x : v) total += x;
    return total;
}

int main() {
    std::cout << biggest(3, 9) << '\n';                          // T = int
    std::cout << biggest(2.5, 1.5) << '\n';                      // T = double
    std::cout << biggest(std::string{"pear"}, std::string{"apple"}) << '\n';
    std::cout << biggest<double>(3, 4.5) << '\n';                // T named explicitly

    std::cout << sum(std::vector<int>{1, 2, 3}) << '\n';
    std::cout << sum(std::vector<std::string>{"ab", "cd"}) << '\n';
}
Outputcompiled & run with real C++
9
2.5
pear
4.5
6
abcd
Error you will hit

deduced conflicting types for parameter 'T'

C++
#include <iostream>

template <typename T>
T biggest(T a, T b) { return a > b ? a : b; }

int main() {
    std::cout << biggest(3, 4.5) << '\n';
}
main.cpp:7:18: error: no matching function for call to 'biggest'
    7 |     std::cout << biggest(3, 4.5) << '\n';
      |                  ^~~~~~~
main.cpp:4:3: note: candidate template ignored: deduced conflicting types for parameter 'T' ('int' vs. 'double')
    4 | T biggest(T a, T b) { return a > b ? a : b; }
      |   ^
Why the compiler said that

Both parameters are the same T. The first argument says T is int, the second says double, and template deduction never converts to break the tie. Note the useful part is in the note, not the error line: with templates, always read the notes.

The fix

Name the type explicitly, or make both arguments the same type.

C++
std::cout << biggest<double>(3, 4.5) << '\n';   // 4.5
07

Lambdas and captures

A lambda is a function you write inline, right where you need it: [captures](params) { body }. The square brackets say which local variables from the surrounding scope the lambda may use, and how: [x] copies x when the lambda is created, [&x] refers to the real x, [=] and [&] capture everything used by copy or by reference. Lambdas are how you pass behaviour to algorithms like std::sort and std::count_if.

C++main.cpp
#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    auto square = [](int x) { return x * x; };
    std::cout << square(7) << '\n';

    int threshold = 50;
    auto byValue = [threshold](int x) { return x > threshold; };  // copies 50 now
    auto byRef   = [&threshold](int x) { return x > threshold; }; // sees later changes
    threshold = 10;
    std::cout << std::boolalpha << byValue(30) << ' ' << byRef(30) << '\n';

    int calls = 0;
    auto counter = [&calls]() { ++calls; };
    counter();
    counter();
    std::cout << "calls: " << calls << '\n';

    std::vector<int> v{42, 7, 19, 88, 3};
    std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });  // descending
    for (int x : v) std::cout << x << ' ';
    std::cout << '\n';
    auto big = std::count_if(v.begin(), v.end(), [threshold](int x) { return x > threshold; });
    std::cout << big << " above " << threshold << '\n';
}
Outputcompiled & run with real C++
49
false true
calls: 2
88 42 19 7 3
3 above 10
Your turn

Write a lambda makeRaise that takes a percentage and returns another lambda which applies it: auto raise10 = makeRaise(10); raise10(1000) should give 1100. (The inner lambda must capture the percentage by value; think about why by reference would dangle.)

Error you will hit

variable cannot be implicitly captured in a lambda

C++
#include <iostream>

int main() {
    int bonus = 5;
    auto addBonus = [](int x) { return x + bonus; };
    std::cout << addBonus(10) << '\n';
}
main.cpp:5:44: error: variable 'bonus' cannot be implicitly captured in a lambda with no capture-default specified
    5 |     auto addBonus = [](int x) { return x + bonus; };
      |                                            ^
main.cpp:4:9: note: 'bonus' declared here
    4 |     int bonus = 5;
      |         ^
main.cpp:5:21: note: lambda expression begins here
    5 |     auto addBonus = [](int x) { return x + bonus; };
      |                     ^
main.cpp:5:22: note: capture 'bonus' by value
    5 |     auto addBonus = [](int x) { return x + bonus; };
      |                      ^
      |                      bonus
Why the compiler said that

An empty [] means the lambda captures nothing, so local variables of the enclosing function are invisible inside it. Clang goes on to list all four fixes (by value, by reference, default by value, default by reference); the first one is shown here.

The fix

Capture the variable explicitly. Prefer naming each capture over [=] / [&] so readers see what the lambda depends on.

C++
#include <iostream>

int main() {
    int bonus = 5;
    auto addBonus = [bonus](int x) { return x + bonus; };
    std::cout << addBonus(10) << '\n';
}

Storing callables: auto versus std::function

Every lambda has its own unique, unnamed type, which is why you store one in auto. When you need one variable or container that can hold different callables with the same signature (a table of commands, a callback member), use std::function<Ret(Args...)> from <functional>. It costs a little: it may allocate and every call goes through an indirection, so do not use it where a template or auto will do.

C++main.cpp
#include <functional>
#include <iostream>
#include <map>
#include <string>

int twice(int x) { return 2 * x; }

int main() {
    int offset = 100;
    std::map<std::string, std::function<int(int)>> ops{
        {"negate", [](int x) { return -x; }},
        {"shift",  [offset](int x) { return x + offset; }},
        {"twice",  twice},                        // a plain function fits too
    };
    for (const auto& [name, op] : ops) {
        std::cout << name << "(7) = " << op(7) << '\n';
    }
}
Outputcompiled & run with real C++
negate(7) = -7
shift(7) = 107
twice(7) = 14
Dangling reference captures
A lambda that captures by reference must not outlive the variables it refers to. Returning [&local]() { ... } from a function, or storing it for a callback that runs later, leaves it pointing at a destroyed variable: undefined behaviour that usually "works" in testing. Capture by value whenever the lambda may run after the current scope ends.
08

Headers, source files and inline

Real programs split code across files. The convention: a header (.h or .hpp) holds declarations, the "what exists"; a source file (.cpp) holds the definitions, the "how". Any file that wants to call the functions #includes the header. Each .cpp is compiled on its own and the linker joins them, as Module 00 described.

C++pricing.h
#pragma once                       // include this file at most once per .cpp

#include <string>

double withTax(double amount, double rate = 0.18);   // defaults go in the header
std::string formatRupees(double amount);

inline int percent(int part, int whole) {            // small and defined here:
    return part * 100 / whole;                       // must be marked inline
}
C++pricing.cpp
#include "pricing.h"                // quotes: your own header

#include <format>

double withTax(double amount, double rate) {        // no default repeated here
    return amount * (1 + rate);
}

std::string formatRupees(double amount) {
    return std::format("Rs {:.2f}", amount);
}
C++main.cpp
#include <iostream>
#include "pricing.h"

int main() {
    std::cout << formatRupees(withTax(1000)) << '\n';   // Rs 1180.00
    std::cout << percent(3, 4) << "%\n";                 // 75%
}
bash
c++ -std=c++20 main.cpp pricing.cpp -o shop
./shop

Pass every .cpp file to the compiler. Forget pricing.cpp and you get the linker's "undefined symbol" error, not a compile error.

  • #pragma once (or a classic #ifndef PRICING_H / #define / #endif include guard) stops a header being pasted twice into one file, which would redefine everything in it.
  • inline today mostly means "this definition may appear in several .cpp files", which is what happens when a function body lives in a header. Without it, two .cpp files including pricing.h would each define percent and the linker would report a duplicate symbol. Whether the call is actually inlined is the optimiser's decision, not yours.
  • Templates are usually defined entirely in the header, because the compiler needs the full body to instantiate them for each type.
  • Never put using namespace std; in a header: it leaks into every file that includes it.
Declaration (prototype)
A function's signature followed by ;. Tells the compiler the function exists so calls can be compiled.
Definition
The declaration plus the body. Exactly one per program (the one definition rule), except for inline functions and templates.
Pass by value
The parameter is a copy; changes do not reach the caller.
Pass by reference
The parameter (T&) is another name for the caller's variable; changes go through.
const reference
const T&: no copy and no modification. The default for passing anything bigger than a number.
Overload resolution
The compiler choosing the best-matching function among several with the same name.
Function template
A function written once with a placeholder type T; the compiler generates a version for each type used.
Lambda capture
The [...] list saying which outside variables a lambda uses, by copy ([x]) or by reference ([&x]).
std::function
A type-erased holder for any callable with a given signature, from <functional>.
Header file
A .h/.hpp file of declarations that other files #include.
Quick check

Which signature is the usual choice for a function that only reads a large std::vector<double>?

Quick check

int n = 1; auto f = [n]() { return n; }; n = 5; What does f() return?

Frequently asked questions

When should I pass by reference in C++?
Pass small built-in types (int, double, char, bool) by value. Pass anything larger that you only read, such as strings, vectors and structs, by const&. Use a non-const reference only when the function is meant to modify the caller's variable.
What is the difference between a declaration and a definition in C++?
A declaration introduces a name and its type, like int add(int, int);, so the compiler can check calls. A definition also provides the body. A function can be declared many times but defined only once in the whole program.
Should I use a lambda or std::function?
Store a single lambda in auto and pass callables to your own functions through a template parameter; that is free. Use std::function when you need one type that can hold different callables, such as a map of commands or a stored callback.

Finish the C++ handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.