Boring C++
Boring C++

Functions, Lambdas, and Callables

13 min read

Chapter 4: Functions, Lambdas, and Callables

Functions are the basic unit of reuse in C++. You write a function once, call it from many places, and when the function needs to change, you change it in one place. That is not a new idea — every language has it. But C++ gives you several mechanisms for defining and passing callable things: regular functions, function overloads with default arguments, lambda expressions, std::function, and function pointers. This chapter covers all of them.

By the end of this chapter you will be able to write functions that take any argument type efficiently, define inline functions (lambdas) that capture local variables, and store any callable thing in a variable.

4.1 Function basics and overloading

A function declaration tells the compiler the function's name, return type, and parameters. A function definition provides the body:

#include <iostream>
#include <string>

// Three overloaded functions: same name, different parameter lists
double area(double radius) {
    return 3.14159265358979 * radius * radius;
}

double area(double width, double height) {
    return width * height;
}

int area(int side) {
    return side * side;
}

int main() {
    std::cout << "Circle area (r=5.0):  " << area(5.0) << "\n";
    std::cout << "Rectangle area 3x4:   " << area(3.0, 4.0) << "\n";
    std::cout << "Square area (side=6): " << area(6) << "\n";
    return 0;
}

Function overloading lets multiple functions share the same name as long as their parameter lists differ. The compiler picks the right one at each call site based on argument types. In the example above: area(5.0) calls the first (one double), area(3.0, 4.0) calls the second (two doubles), and area(6) calls the third (one int).

Overloads must differ in their parameter types or number. You cannot overload on return type alone — the compiler would not know which one to call.

4.2 Default arguments

Parameters can have default values, which the caller can omit:

#include <iostream>
#include <string>

void greet(const std::string& name, const std::string& greeting = "Hello") {
    std::cout << greeting << ", " << name << "!\n";
}

void printSeparator(char c = '-', int count = 40) {
    for (int i = 0; i < count; ++i) std::cout << c;
    std::cout << "\n";
}

int main() {
    greet("Alice");                    // Hello, Alice!
    greet("Bob", "Good morning");      // Good morning, Bob!
    greet("Carol", "Zdravo");          // Zdravo, Carol!

    printSeparator();                  // ----------------------------------------
    printSeparator('=');               // ========================================
    printSeparator('*', 20);           // ********************
    return 0;
}

Default arguments must appear at the end of the parameter list. You cannot have a default argument followed by a required one. If you have a function f(int a, int b = 0, int c), the compiler cannot tell whether f(1, 2) means a=1, b=2 or a=1, c=2.

Default arguments are evaluated at the call site each time the function is called, not once when the function is defined. This matters if the default is a function call with side effects.

4.3 Pass by value, reference, and const reference

How you pass arguments to functions determines whether the function sees the original or a copy, and whether it can modify what was passed. This is one of the most practically important sections in this chapter.

#include <iostream>
#include <string>
#include <vector>

// Pass by value: function receives a COPY
// Changes to x inside the function do NOT affect the caller's variable
void doubleValue(int x) {
    x *= 2;
    std::cout << "Inside doubleValue: " << x << "\n";
}

// Pass by reference: function receives the ORIGINAL
// Changes to x affect the caller's variable
void doubleInPlace(int& x) {
    x *= 2;
}

// Pass by const reference: no copy, no modification
// Efficient for large types; communicates "read-only" to the reader
void printString(const std::string& s) {
    std::cout << s << "\n";
    // s += " suffix";  // compile error: s is const
}

// For comparison: pass by value for large type (expensive copy)
void printStringByValue(std::string s) {
    std::cout << s << "\n";
}

int main() {
    int n = 5;
    doubleValue(n);
    std::cout << "After doubleValue: " << n << "\n";  // still 5

    doubleInPlace(n);
    std::cout << "After doubleInPlace: " << n << "\n";  // now 10

    std::string message = "Hello from a moderately long string";
    printString(message);            // no copy — just a reference
    printStringByValue(message);     // copies the entire string

    return 0;
}

The rule for choosing parameter types:

  • Small, cheap types (int, double, bool, char, enum): pass by value.
  • Large or expensive-to-copy types (std::string, std::vector, any class): pass by const T&.
  • When you need to modify the caller's variable: pass by T&.
  • When you are transferring ownership (Chapter 6): pass by T&& (rvalue reference) or by value and move.

A string passed by const std::string& costs nothing at the call site — the function receives only the address of the original string. A string passed by value copies all its characters into a new allocation. For a short string, this is trivial. For a string with a million characters, it is not.

4.4 Lambda expressions

A lambda is an anonymous function defined inline, right where you need it. The syntax is:

[capture](parameters) -> return_type { body }

The -> return_type part is usually omitted; the compiler deduces the return type from the body.

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    // A basic lambda assigned to a variable
    auto square = [](int x) { return x * x; };
    std::cout << "Square of 7: " << square(7) << "\n";  // 49

    // Lambdas are most useful with algorithms
    std::vector<int> numbers{5, 2, 8, 1, 9, 3, 7, 4, 6};

    // Sort ascending
    std::sort(numbers.begin(), numbers.end(), [](int a, int b) {
        return a < b;
    });

    std::cout << "Sorted: ";
    for (int n : numbers) std::cout << n << " ";
    std::cout << "\n";

    // Sort descending
    std::sort(numbers.begin(), numbers.end(), [](int a, int b) {
        return a > b;
    });

    std::cout << "Reversed: ";
    for (int n : numbers) std::cout << n << " ";
    std::cout << "\n";

    // Count elements greater than 5
    int count = std::count_if(numbers.begin(), numbers.end(), [](int x) {
        return x > 5;
    });
    std::cout << "Count > 5: " << count << "\n";

    // Find first element less than 4
    auto it = std::find_if(numbers.begin(), numbers.end(), [](int x) {
        return x < 4;
    });
    if (it != numbers.end()) {
        std::cout << "First < 4: " << *it << "\n";
    }

    return 0;
}

The lambda [](int a, int b) { return a < b; } passed to std::sort is a comparison function: it returns true if a should come before b. The algorithm calls it for pairs of elements to determine their order.

Without lambdas you would need to define a named function somewhere and pass a pointer to it. With lambdas you write the logic exactly where it is used.

4.5 Capturing variables in lambdas

The [ ] at the start of a lambda is the capture list. It specifies which variables from the surrounding scope the lambda can see.

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    int threshold = 5;

    // Capture by value: lambda gets a copy of threshold at the moment of definition
    auto greaterThanVal = [threshold](int x) {
        return x > threshold;
    };

    // Capture by reference: lambda sees the current value of threshold
    auto greaterThanRef = [&threshold](int x) {
        return x > threshold;
    };

    std::vector<int> nums{1, 3, 5, 7, 9, 11};

    int countVal = std::count_if(nums.begin(), nums.end(), greaterThanVal);
    int countRef = std::count_if(nums.begin(), nums.end(), greaterThanRef);
    std::cout << "Count > 5 (captured 5 by value): " << countVal << "\n";
    std::cout << "Count > 5 (sees threshold by ref): " << countRef << "\n";

    // Now change threshold
    threshold = 8;

    int countValAfter = std::count_if(nums.begin(), nums.end(), greaterThanVal);
    int countRefAfter = std::count_if(nums.begin(), nums.end(), greaterThanRef);
    std::cout << "Count > 8 (lambda still has 5): " << countValAfter << "\n";
    std::cout << "Count > 8 (lambda sees 8 now):  " << countRefAfter << "\n";

    return 0;
}

Capture options:

Capture Meaning
[x] Capture x by value
[&x] Capture x by reference
[=] Capture everything used by value
[&] Capture everything used by reference
[=, &x] Everything by value, but x by reference
[&, x] Everything by reference, but x by value

The safety rule: capture by reference only if the captured variable outlives the lambda. If you capture a local variable by reference and the lambda escapes the function that owns that variable (stored in a container, passed to another thread), you have a dangling reference — undefined behavior.

When in doubt, capture by value. The copy is usually cheap, and the safety is guaranteed.

4.6 std::function

std::function<ReturnType(ParamTypes...)> is a type-erased wrapper that can hold any callable: a regular function, a lambda, a lambda with captured state, an object with operator().

#include <iostream>
#include <functional>
#include <string>
#include <vector>

// A function that accepts any callable int -> int
void applyAndPrint(const std::string& label, std::function<int(int)> fn, int value) {
    std::cout << label << "(" << value << ") = " << fn(value) << "\n";
}

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

int main() {
    // Store a regular function
    std::function<int(int)> fn1 = doubleIt;

    // Store a lambda
    std::function<int(int)> fn2 = [](int x) { return x * x; };

    // Store a lambda with captured state
    int factor = 3;
    std::function<int(int)> fn3 = [factor](int x) { return x * factor; };

    applyAndPrint("double", fn1, 7);   // double(7) = 14
    applyAndPrint("square", fn2, 7);   // square(7) = 49
    applyAndPrint("triple", fn3, 7);   // triple(7) = 21

    // std::function objects can be stored in a vector
    std::vector<std::function<int(int)>> transforms{fn1, fn2, fn3};
    for (const auto& f : transforms) {
        std::cout << f(5) << " ";
    }
    std::cout << "\n";  // 10 25 15

    return 0;
}

When to use std::function: when you need to store a callable in a data structure, return it from a function, or accept any callable without knowing its type at compile time.

When not to use std::function: in performance-critical hot loops. std::function has overhead — it uses type erasure internally, which involves a virtual-dispatch-like mechanism and may heap-allocate for lambdas with captured state. For a loop that runs a million times, prefer a template parameter (Chapter 7) or a direct lambda.

4.7 Function pointers (briefly)

Before lambdas existed, C++ used function pointers for the same purpose. A function pointer is a variable that holds the address of a function:

#include <iostream>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

// Takes a function pointer as an argument
int applyOp(int x, int y, int (*op)(int, int)) {
    return op(x, y);
}

int main() {
    std::cout << applyOp(10, 3, add) << "\n";  // 13
    std::cout << applyOp(10, 3, sub) << "\n";  // 7
    std::cout << applyOp(10, 3, mul) << "\n";  // 30

    // A non-capturing lambda can be implicitly converted to a function pointer
    auto square = [](int x, int y) -> int { return x * y + y; };
    std::cout << applyOp(4, 5, square) << "\n";  // 25

    return 0;
}

The syntax int (*op)(int, int) reads as: "a pointer to a function that takes two ints and returns an int." It is ugly. std::function<int(int, int)> is cleaner, and for generic code, a template is cleaner still.

In modern C++, you encounter function pointers primarily when interfacing with C libraries or older C++ APIs that predate std::function. You should know what they are, but you rarely write new code that uses them.

4.8 Exercises

Easy

  1. Write three overloaded functions named clamp — one for int, one for float, one for double — that take a value and min/max bounds and return the value clamped to [min, max]. Call all three from main and print the results.

  2. Write a function greet(const std::string& name, const std::string& title = "") that prints "Hello, Dr. Alice!" if a title is provided, or "Hello, Alice!" otherwise.

Medium

  1. Write a function applyAll that takes a std::vector<int> and a std::vector<std::function<int(int)>> of transformations. For each integer in the first vector, apply every transformation and print all results. Demonstrate it with at least three different transformations including one that captures a variable.

Stretch

  1. Write a compose function template that takes two callables f and g and returns a new callable that computes f(g(x)). The signature should work for any two callables where the output type of g matches the input type of f. Demonstrate it by composing doubling and squaring.

4.9 Common mistakes

  • Returning a reference to a local variable. int& badRef() { int x = 42; return x; } — x is destroyed when the function returns, and the reference is dangling. The program will usually crash or produce garbage. Always ensure the lifetime of a referenced value outlives the reference.

  • Capturing a local variable by reference in a lambda that outlives it. If you store a lambda in a std::function member of an object, and that lambda captures a local variable by reference, the reference becomes dangling when the local variable goes out of scope. Capture by value when the lambda might outlive its context.

  • Overloading on return type alone. int f() and double f() cannot both exist — the compiler picks the overload at the call site, and the return type alone is not enough to distinguish them. Overloads must differ in parameter types or count.

  • Using std::function in a hot loop. The type erasure in std::function introduces overhead. For an inner loop called a million times, use a template parameter instead.

  • Missing const on reference parameters that should not be modified. void print(std::string& s) will not accept string literals or temporary values because temporaries cannot bind to non-const references. Write void print(const std::string& s).

  • Confusing pass by value and pass by reference. Modifying a parameter passed by value has no effect on the caller's variable. This is the correct behavior — but it surprises people who expect the caller to see the change.

  • Using [&] capture for a lambda stored in a container. Capturing everything by reference is convenient for local use, but dangerous for lambdas that outlive the scope where they were created.

  • Forgetting that lambdas with captured state cannot decay to a function pointer. Only non-capturing lambdas can be implicitly converted to function pointers. [x](int y) { return x + y; } cannot be stored in an int (*)(int) variable.

4.10 Summary

Functions are the unit of reuse in C++. Function overloading lets multiple functions share a name; the compiler picks the right one based on argument types. Default arguments reduce boilerplate for common cases.

The three ways to pass arguments — by value, by reference, and by const reference — have different costs and semantics. Large types should be passed by const reference to avoid copying; use a mutable reference when modification is required.

Lambdas are anonymous functions defined inline. They are the modern way to pass small pieces of behavior to algorithms and event handlers. The capture list specifies which surrounding variables the lambda can see.

std::function stores any callable with a known signature. It is flexible but has runtime overhead; for performance-critical code, prefer templates or direct lambdas.

Function pointers still exist and show up in older APIs. Know how to read their syntax, but prefer std::function or templates in new code.

In Chapter 5 we move to classes and objects — the mechanism for grouping related data and the functions that operate on it into a single, coherent unit.

All code for this chapter is in the GitHub repository:
<TODO: book code repository URL>/tree/main/chapter04

Boring C++

Modern C++ Mastery for Programmers Who Prefer Clarity Over Cleverness.

View book →