Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Passing arguments

Pass by value vs by reference

In C++ a function parameter can be a value, a reference, or a pointer. The choice determines whether the callee receives its own copy of the argument or an alias to the caller’s object.

ParameterTypical useEffect on caller
T (by value)The function needs its own mutable objectThe argument is copied or moved. The caller’s object is unchanged
const T&The function only reads the argumentNo copy is performed. The callee cannot modify the object
T&The function must modify the caller’s objectThe caller sees the mutation. No copy is performed
T*Legacy C‑style API or optional argumentThe pointer can be null. The callee can reassign the pointer

The rule of thumb is:

  • Pass by value when the function will create a new object, store it, or move from it.
  • Pass by const reference when the function only needs to observe the argument.
  • Pass by non‑const reference only when the function must change the caller’s object.

Contrast this with C. In C every argument is passed by value. To share an object the programmer writes a pointer parameter (T* p). The reader must locate * to know that the function can observe or mutate shared state. C++ hides that pointer indirection behind references. This makes the intent visible in the function signature.

Size is not the only driver. A type that is expensive or impossible to copy, such as std::unique_ptr or a large std::vector, must travel by reference or by rvalue move, never by value copy. For a value that the function only inspects, const T& is the default even when T is small, because it avoids a copy and works for every argument type. Pass by value is the right default only when the function keeps or modifies a copy of what it was given.

Arguments of differing size

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

struct Small {
    int x;
    Small(int v) : x(v) { std::cout << "Small ctor\n"; }
    Small(const Small& other) : x(other.x) { std::cout << "Small copy\n"; }
    Small(Small&& other) noexcept : x(other.x) { std::cout << "Small move\n"; }
    Small& operator=(const Small&) = delete;
    Small& operator=(Small&&) = delete;
    ~Small() = default;
};

void by_value(Small s) {
    std::cout << "by_value address " << &s << "\n";
}

void by_const_ref(const Small& s) {
    std::cout << "by_const_ref address " << &s << "\n";
}

void by_mut_ref(Small& s) {
    std::cout << "by_mut_ref address " << &s << "\n";
    s.x += 1;
}

void vec_by_value(std::vector<int> v) {
    std::cout << "vec_by_value size " << v.size() << "\n";
}

void vec_by_const_ref(const std::vector<int>& v) {
    std::cout << "vec_by_const_ref size " << v.size() << "\n";
}

int main() {
    Small a(5);
    std::cout << "original address " << &a << "\n";
    by_value(a);
    by_const_ref(a);
    by_mut_ref(a);
    std::cout << "after mutation value " << a.x << "\n";

    std::vector<int> big(1000, 1);
    vec_by_value(big);
    vec_by_const_ref(big);
    return 0;
}

Running the program prints the address of each parameter. The by_value call receives a copy, therefore its address differs from the caller’s object. The by_const_ref and by_mut_ref calls receive the same address, confirming they are aliases. The vector examples show that a large container passed by value incurs a copy of its control block, while a const reference avoids any copy.

Const correctness

A const qualifier promises not to modify the object it decorates. In a function signature:

void f(const T& arg);

the callee can call only const member functions on arg. Attempting to call a non‑const member function triggers a compile‑time error because the implicit this pointer is const. The same rule applies to member functions:

struct S {
    void mutate()       { ++value; }          // non‑const
    void inspect() const { std::cout << value; } // const
    int value;
};

When inspect is called on a const S object, the compiler guarantees that value is not altered. If a const reference were bound to a temporary, the temporary becomes immutable for the duration of the reference. The compiler enforces this rule even though the temporary will soon be destroyed.

The same const placement rules apply to pointers. See Chapter 2.

If we modify ch08_pass_by.cpp to call a non‑const member on a const reference, the compilation fails, illustrating that const truly prevents mutation.

The discipline pays off via const overloading: a type can provide both T& at(std::size_t) and const T& at(std::size_t) const. The former binds to non‑const objects, and the latter binds to const objects and temporaries. This enables read‑only access such as std::string{}.size().

Pass by value then move

The “sink” idiom accepts a parameter by value and immediately moves it into a member or another container. This design gives two useful behaviors:

  • When the caller supplies an lvalue, the argument is copied into the parameter and then moved into the destination. The copy costs one construction. The move costs no additional allocation.
  • When the caller supplies an rvalue (a temporary), the argument is constructed directly in the parameter slot and then moved. This results in zero copies.

The diagram below shows both paths.

caller lvalue          caller rvalue
   |                       |
   v copy                   v construct
parameter (value)   <--->  parameter (value)
   | move                     | move
destination                 destination

Sink idiom

#include <iostream>
#include <string>

struct Small {
    int x;
    Small(int v) : x(v) { std::cout << "Small ctor\n"; }
    Small(const Small& other) : x(other.x) { std::cout << "Small copy\n"; }
    Small(Small&& other) noexcept : x(other.x) { std::cout << "Small move\n"; }
    Small& operator=(const Small&) = delete;
    Small& operator=(Small&&) = delete;
    ~Small() = default;
};

void take_and_store(Small s) {
    std::cout << "parameter address " << &s << "\n";
    Small member = std::move(s);
    std::cout << "member address " << &member << "\n";
}

int main() {
    Small a(1);
    std::cout << "original address " << &a << "\n";
    take_and_store(a);          // lvalue path – copy
    take_and_store(Small(2));   // rvalue path – move
    return 0;
}

The program prints the address of the caller’s object, the parameter, and the member after the move. When the first call uses an lvalue, a copy occurs. The second call uses a temporary, so only the move runs. The output confirms the expected behavior.

Return by value

Returning a value by copy used to be expensive because the caller received a separate object that required a copy. Modern C++ solves this with copy elision, including the guaranteed elision of prvalues, and with move semantics for named temporaries. The compiler constructs the return object directly in the caller’s storage (NRVO) or treats the temporary as an rvalue that can be moved. Consequently, returning a std::vector or a std::string does not copy the underlying buffer on the happy path.

The rule is simple: return‑by‑value is cheap because copy elision and move semantics eliminate unnecessary copies. Elision is guaranteed when the function returns a single local or a prvalue. Otherwise the compiler falls back to a move. Return a clearly identified local or construct the result directly in the return statement (see Chapter 05 for container move semantics).

References are not pointers

A reference is an alias bound to an object at initialization. It cannot be reseated and cannot be null. The language guarantees that a reference denotes a valid object for its lifetime.

The reference does not extend the lifetime of the object it refers to. If the referent is destroyed while the reference remains alive, using the reference yields undefined behavior. This hazard is described in Chapter 07.

A pointer can be null or reassigned and offers no guarantee that the pointee remains alive. Unlike Rust’s borrow checker, C++ relies on the programmer. Chapter 07’s lifetime analysis tool can detect violations. Rule: a reference must never outlive its referent.

There is one exception that surprises even experienced programmers. Binding a const reference to a temporary extends the temporary’s lifetime to match the reference’s scope. The temporary is not destroyed at the end of the full expression. It lives until the reference goes out of scope. This extension does not apply when the reference is a member of an object or when it is returned from a function. It is a property of the local binding only. It is why const auto& x = compute() avoids a copy of a temporary safely.

reference_wrapper and views as parameters

Sometimes a function must store a reference in a container or type‑erase the argument. std::reference_wrapper<T> wraps a reference in an assignable object. This allows it to appear in std::vector or std::function. The wrapper forwards operators to the underlying reference.

A std::vector<T&> is ill formed, because a reference is not a complete object that a container can own. std::reference_wrapper<T> solves this. It is a copyable, assignable object that stores a pointer to the referent and behaves like the reference in almost every context. This is the standard way to keep a collection of aliases into other objects.

For non‑owning sequences, the standard library provides view types:

  • std::string_view: a read‑only window over a character array.
  • std::span<T>: a read‑only or mutable view over a contiguous range of T.

Both types carry a pointer to the data and a length. They replace the old “pointer + size” pattern:

void process(std::span<const int> data);

The caller can pass a std::vector<int>, a C‑array, or a pointer with length. The callee receives a lightweight handle that does not own the elements. The same lifetime rules from Chapter 07 apply: the owner must outlive any view or span.

std::forward and perfect forwarding

Templates that accept universal references (T&&) can forward their argument preserving its value category. The pattern:

template<class T>
void wrapper(T&& t) {
    target(std::forward<T>(t));
}

If t is an lvalue, std::forward<T>(t) yields an lvalue reference. If t is an rvalue, it yields an rvalue reference. This enables factories, wrappers, and higher‑order functions to forward arguments without unintentionally copying or moving them.

The cost of omitting std::forward is concrete. A function that takes T&& t and then passes t to another function without forwarding always passes an lvalue, because inside the body t is a named variable. That forces a copy where the caller expected a move. std::forward<T>(t) restores the original category. An lvalue argument stays an lvalue and a temporary stays a temporary. Every standard-library helper that accepts a forwarding reference, from std::make_unique to container emplace, relies on this.

Perfect forwarding

#include <iostream>
#include <type_traits>
#include <utility>

void target(int& i) {
    std::cout << "target received lvalue reference, value " << i << "\n";
}

void target(int&& i) {
    std::cout << "target received rvalue reference, value " << i << "\n";
}

template<class T>
void wrapper(T&& t) {
    std::cout << "wrapper forwarding, is lvalue? " << std::boolalpha
              << std::is_lvalue_reference<decltype(t)>::value << "\n";
    target(std::forward<T>(t));
}

int main() {
    int x = 42;
    wrapper(x);    // lvalue path
    wrapper(100);  // rvalue path, creates temporary int
    return 0;
}

The program reports whether the forwarded argument is an lvalue. The first call forwards an lvalue (x). The second call forwards a temporary integer created from the literal 100. The target function receives the correct reference type in each case.

Try this

Write a function

std::vector<int> take_and_double(std::vector<int> v);

that doubles every element and returns v. Call it once with an lvalue and once with a prvalue. Observe (by printing the address of the parameter) that the prvalue call avoids a copy.