← Back to all posts

12 C++ Notes

Functions, Part 2: Lambdas, Templates, and Concepts

C++ Notes


Lambda Functions

What Is a Lambda

Syntax

C++
[capture list] (parameters) -> return type {
    // code
};

Storing a Lambda vs Calling It Immediately

Stored as function object
C++
auto func = [](int x, int y){
    return x + y;
}; // semicolon, NOT called yet

// call later, multiple times
func(10, 20); // returns 30
func(5, 3);   // returns 8
Called immediately (result stored)
C++
auto result = [](int x, int y){
    return x + y;
}(10, 20); // called right here

// result = 30 (an int, not a lambda)
// function object is gone

Common Variations

Named lambda (reusable)
C++
auto func = [](){
    // code
};
func(); // call when needed
Immediately invoked (one-time)
C++
[](){
    // runs once, cannot reuse
}();
With explicit return type
C++
auto func = []() -> int {
    return 5;
};
With parameters
C++
auto func = [](int a, int b){
    return a + b;
};
func(10, 20);
Capture by value
C++
int limit = 10;
auto func = [limit](int x){
    return x > limit;
}; // limit is a copy
Capture by reference
C++
int counter = 0;
auto inc = [&counter](){
    counter++;
}; // modifies original
Capture all by value [=]
C++
int a=1, b=2;
auto func = [=](){
    return a + b;
}; // a, b copied at creation
Capture all by reference [&]
C++
int a=1, b=2;
auto func = [&](){
    a++; b++;
}; // modifies originals
Used directly as an argument
C++
sort(v.begin(), v.end(),
[](int a, int b){
    return a < b;
}); // not stored, used inline
No return value (void)
C++
auto func = [](int x){
    std::cout << x;
}; // side-effect lambda

Code Example: main.cpp

C++
#include <iostream>

int main() {
    // 1) named lambda: reusable function object
    auto square = [](int x) {
        return x * x;
    };
    std::cout << square(4) << std::endl; // prints: 16
    std::cout << square(7) << std::endl; // prints: 49

    // 2) lambda with multiple statements
    auto doubleAndAdd = [](int x) {
        int y = x * 2;
        y = y + 3;
        return y;
    };
    std::cout << doubleAndAdd(5) << std::endl; // prints: 13

    // 3) lambda with no parameters
    auto sayHello = []() {
        std::cout << "Hello from lambda" << std::endl;
    };
    sayHello(); // prints: Hello from lambda

    // 4) explicit return type
    auto getNumber = []() -> int {
        return 10;
    };
    std::cout << getNumber() << std::endl; // prints: 10

    // 5) capture by value: limit is copied at lambda creation
    int limit = 5;
    auto isGreater = [limit](int x) {
        return x > limit;
    };
    std::cout << isGreater(10) << std::endl; // prints: 1 (true)
    std::cout << isGreater(3)  << std::endl; // prints: 0 (false)

    // 6) capture by reference: counter is modified through lambda
    int counter = 0;
    auto increase = [&counter]() {
        counter++;
    };
    increase();
    increase();
    std::cout << counter << std::endl; // prints: 2

    // 7) immediately invoked: runs once, function object not stored
    []() {
        std::cout << "This runs immediately" << std::endl;
    }(); // prints: This runs immediately

    return 0;
}

Function Templates

What Is a Template

Declaration and Type Deduction

C++
template <typename T, typename U>
T myMax(T a, U b) {
    return (a > b) ? a : b;
}

Template Parameters by Reference

C++
template <typename T, typename U>
T myMaxRef(T& a, U& b) { // & on the variables, not inside <>
    return (a > b) ? a : b;
}

Avoid defining two nearly identical templates, one taking values, one taking const references. Calling either can trigger ambiguity where the compiler can't decide which overload to use.

Template Specialization

C++
// primary template
template <typename T>
T maximumSpecial(T a, T b) {
    return (a > b) ? a : b;
}

// specialization for const char* (compare content, not address)
template <>
const char* maximumSpecial<const char*>(const char* a, const char* b) {
    return (std::strcmp(a, b) > 0) ? a : b;
}

Code Example: main.cpp

C++
#include <iostream>
#include <cstring>

// 1) basic template: T and U are deduced from arguments
template <typename T, typename U>
T myMax(T a, U b) {
    return (a > b) ? a : b;
}

// 2) single type template
template <typename T>
T add(T a, T b) {
    return a + b;
}

// 3) by reference template
template <typename T, typename U>
T myMaxRef(T& a, U& b) {
    return (a > b) ? a : b;
}

// 4) primary template + specialization for const char*
template <typename T>
T maximumSpecial(T a, T b) {
    return (a > b) ? a : b;
}
template <>
const char* maximumSpecial<const char*>(const char* a, const char* b) {
    return (std::strcmp(a, b) > 0) ? a : b; // compares content, not address
}

int main() {
    std::cout << myMax(10, 5.5) << std::endl;          // prints: 10
    std::cout << myMax(7, 3) << std::endl;              // prints: 7
    std::cout << add(5, 7) << std::endl;                // prints: 12
    // add(5, 3.2); // ERROR: T deduced as both int and double

    std::cout << myMax<int, double>(10, 5.5) << std::endl; // explicit types: prints 10

    int x = 20;
    double y = 15.2;
    std::cout << myMaxRef(x, y) << std::endl;           // by reference: prints 20

    const char* s1 = "apple";
    const char* s2 = "banana";
    std::cout << maximumSpecial(s1, s2) << std::endl;  // specialization used: prints banana

    return 0;
}

Concepts (C++20)

What Problem Do Concepts Solve

The problem with plain templates: when you write a template, the compiler accepts any type. Pass the wrong one, and the error message is a wall of confusing internal compiler text pointing inside the template machinery, not at your code. Concepts let you say upfront, "this template only works with integers" or "only with types that support multiplication." Pass the wrong type, and the compiler gives a clean, readable error pointing directly at the call site instead.

Standard Built-in Concepts

Syntax 1: requires clause before the body
C++
template <typename T>
requires std::integral<T>
T add(T a, T b) { return a + b; }
Syntax 2: requires clause after the signature
C++
template <typename T>
T add(T a, T b) requires std::integral<T> { return a + b; }
Syntax 3: concept directly in the template parameter
C++
template <std::integral T>
T add(T a, T b) { return a + b; }
Syntax 4: concept on auto parameters (abbreviated template)
C++
auto add(std::integral auto a, std::integral auto b) { return a + b; }

Custom Concepts

Form 1: wrapping an existing type trait
C++
#include <type_traits>

template <typename T>
concept MyIntegral = std::is_integral_v<T>; // same as std::integral

template <typename T>
requires MyIntegral<T>
T add(T a, T b) { return a + b; }
Form 2: checking that an expression compiles
C++
template <typename T>
concept Multipliable = requires(T a, T b) {
    a * b; // only checks that this expression is valid for T
           // does NOT check the result value, only that it compiles
};
Form 3: checking multiple operations
C++
template <typename T>
concept Incrementable = requires(T a) {
    a += 1;
    ++a;
    a++;
}; // T must support all three of these

The requires Clause: Requirement Kinds

Simple Requirement

Just checks that an expression is syntactically valid. Does not check whether the result is true.

C++
template <typename T>
concept TinyType = requires(T t) {
    sizeof(T) <= 4; // only checks this expression compiles, NOT that it equals true
};

Nested Requirement

Uses an inner requires keyword to check that an expression is actually true at compile time, not just syntactically valid.

C++
template <typename T>
concept TinyType = requires(T t) {
    sizeof(T) <= 4;           // syntax check only
    requires sizeof(T) <= 4; // actual boolean value check
};

Compound Requirement

Checks three things at once: the expression compiles, it does not throw exceptions, and the result is convertible to a specific type.

C++
template <typename T>
concept Addable = requires(T a, T b) {
    { a + b } noexcept -> std::convertible_to<int>;
    // a + b must: compile, not throw, and result must convert to int
};

Combining Concepts with Logical Operators

C++
// || : works for integers OR floating-point types
template <typename T>
T func(T t) requires std::integral<T> || std::floating_point<T> {
    return 2 * t;
}

// && : works only for types that are BOTH integral AND TinyType
template <typename T>
requires std::integral<T> && TinyType<T>
T add(T a, T b) { return a + b; }

Concepts with Auto

C++
// constrained function parameters
std::integral auto add(std::integral auto a, std::integral auto b) {
    return a + b;
}

// constrained variables
std::integral auto x   = 10 + 20;   // ok: deduces int
std::integral auto sum = add(10, 20); // ok: result is integral
// std::integral auto f = 3.14; // ERROR: double is not integral

Think of concepts like a gatekeeper at the template door. Without concepts, anyone walks in, and the error surfaces deep inside the template machinery. With concepts, the gatekeeper checks the type at the entrance and gives a clear rejection message right where you called the function.


Closing

That closes out functions in both directions: the plain mechanics of passing data across a boundary, and the more modern tools, lambdas, templates, and concepts, that let a single function body work across many types safely. Next up, classes and objects, where functions stop standing alone and start belonging to data.

← Previous 11 - Functions, Part 1: Basics and Overloading Next → 13 - Classes and Objects