C++Foundations

C++ Functions: Parameter Passing, RVO, noexcept, Overloading & Defaulted Arguments (C++23)

TT
TopicTrick Team
C++ Functions: Parameter Passing, RVO, noexcept, Overloading & Defaulted Arguments (C++23)

C++ Functions: Parameter Passing, RVO, noexcept, Overloading & Defaulted Arguments (C++23)


Table of Contents


Parameter Passing: The Complete Decision Guide


Pass by Value: When It's Optimal

For small types (≤ 16 bytes on x86-64 — fits in registers), pass by value is fastest — no indirection, the CPU loads the data directly into registers:

cpp
// Optimal — int, double, float, bool, char all fit in registers
void process_score(int score, double factor, bool normalize) { /*...*/ }

// Optimal for small structs (fits in 2-3 registers)
struct Point { float x, y; };  // 8 bytes — register-eligible
void draw_point(Point p) { /* p.x, p.y in registers */ }

// Sink parameter: function unconditionally stores the value
class Logger {
    std::vector<std::string> messages_;
public:
    // Pass by value: caller decides whether to copy or move
    void log(std::string message) {             // ONE overload handles both:
        messages_.push_back(std::move(message));// Store the message
    }
    // If caller does: logger.log("literal") → move-constructed from temp
    // If caller does: logger.log(my_str)    → copy then move
    // If caller does: logger.log(std::move(my_str)) → move only
};

Pass by const Reference: The Read-Only Workhorse

const T& is the workhorse for read-only access to expensive-to-copy types. No copy occurs, and the reference guarantees the function won't modify the data:

cpp
#include <string>
#include <vector>

// NO copy of the string — just a reference
void log_error(const std::string& message) {
    std::cerr << "[ERROR] " << message << '\n';
}

// Processes entire vector without copying any element
double compute_mean(const std::vector<double>& data) {
    if (data.empty()) return 0.0;
    double sum = 0.0;
    for (const auto& v : data) sum += v;
    return sum / static_cast<double>(data.size());
}

// const& extends lifetime of temporaries:
const std::string& greet() {
    return "Hello"; // ERROR: dangling reference to temporary!
}
const std::string& r = std::string("Hello"); // OK: lifetime extended

[!WARNING] Never return const T& to a local variable or temporary — the reference becomes dangling the moment the function returns. Return by value instead and let RVO handle the performance.


Pass by Rvalue Reference: Sink Parameters

Rvalue references (T&& as a non-templated function parameter) specifically accept temporaries and std::move'd values — used where the function consumes the argument:

cpp
#include <memory>
#include <utility>

class Connection {
public:
    Connection() = default;
    Connection(Connection&&) noexcept = default;
};

class ConnectionPool {
    std::vector<Connection> connections_;
public:
    // Explicitly accepts only rvalues — caller MUST move
    void add(Connection&& conn) {
        connections_.push_back(std::move(conn));
    }
};

// Usage:
Connection c;
pool.add(std::move(c));      // OK: explicitly moved
pool.add(Connection{});      // OK: temporary is rvalue
// pool.add(c);             // ERROR: c is an lvalue, must use std::move

Return Value Optimization (RVO and NRVO)

The C++ myth: "returning a large object from a function is always slow." Wrong. Compilers apply Named Return Value Optimization (NRVO) and the standard mandates Return Value Optimization (RVO):

cpp
#include <vector>

// NRVO applies: the vector 'result' is constructed directly in the caller's space
// Zero copy, zero move — elided by definition
std::vector<int> create_dataset(int size) {
    std::vector<int> result;    // NRVO kicks in:
    result.reserve(size);       // This vector IS the caller's variable
    for (int i = 0; i < size; i++) result.push_back(i * i);
    return result;              // No copy or move!
}

// RVO (guaranteed since C++17): returning a pure temporary
std::vector<int> make_empty() {
    return std::vector<int>{1, 2, 3, 4, 5};  // C++17: GUARANTEED zero copy
}

// Using the results:
auto data  = create_dataset(1000000); // 8MB of data — zero copy!
auto small = make_empty();            // Guaranteed elision

C++17 Guaranteed Copy Elision: When a function returns a pure prvalue (a temporary expression), the standard mandates the object is constructed directly in the destination — no move, no copy, even if the type has = delete copy/move. This makes:

cpp
struct Immovable {
    Immovable() = default;
    Immovable(Immovable&&) = delete;
    Immovable(const Immovable&) = delete;
};
Immovable create() { return Immovable{}; } // OK since C++17!
auto obj = create(); // Guaranteed — no move needed

[!TIP] Never write return std::move(local_variable); — this explicitly disables NRVO and forces a move where elision would have been free. Just return local_variable;.


noexcept: Safety Contracts and Code Generation

noexcept is a contract: this function will never propagate an exception. The compiler uses this to:

  1. Skip exception handling code generation (smaller binary, faster function epilogues).
  2. Allow std::vector to use move semantics during reallocation — vectors only move elements (O(n) move vs O(n) copy) if the move constructor is noexcept.
cpp
class BigBuffer {
    std::unique_ptr<char[]> data_;
    size_t size_;
public:
    BigBuffer(size_t n) : data_(std::make_unique<char[]>(n)), size_(n) {}
    
    // noexcept move constructor — CRITICAL for vector performance
    BigBuffer(BigBuffer&& other) noexcept
        : data_(std::move(other.data_)), size_(other.size_) {
        other.size_ = 0;
    }
    
    // noexcept move assignment
    BigBuffer& operator=(BigBuffer&& other) noexcept {
        if (this != &other) {
            data_ = std::move(other.data_);
            size_ = other.size_;
            other.size_ = 0;
        }
        return *this;
    }
};

// Test if a function is noexcept at compile time:
static_assert(std::is_nothrow_move_constructible_v<BigBuffer>);

// Without noexcept on move constructor:
std::vector<BigBuffer> v;
v.push_back(BigBuffer(1024)); // Would COPY (expensive!) instead of move
// With noexcept: MOVES — orders of magnitude faster for large buffers

std::expected: Error Handling Without Exceptions (C++23)

std::expected<T, E> (C++23) returns either a value T or an error E — Rust's Result<T, E> in C++:

cpp
#include <expected>
#include <string>
#include <charconv>

enum class ParseError { EmptyString, InvalidFormat, Overflow };

std::expected<int, ParseError> parse_int(std::string_view s) {
    if (s.empty()) return std::unexpected(ParseError::EmptyString);
    
    int result;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), result);
    
    if (ec == std::errc::result_out_of_range)
        return std::unexpected(ParseError::Overflow);
    if (ec != std::errc{} || ptr != s.data() + s.size())
        return std::unexpected(ParseError::InvalidFormat);
    
    return result;  // Success
}

void use_it() {
    auto result = parse_int("42");
    if (result) {
        std::cout << "Parsed: " << *result << '\n';
    } else {
        switch (result.error()) {
            case ParseError::EmptyString:    std::cerr << "Empty input\n"; break;
            case ParseError::InvalidFormat:  std::cerr << "Bad format\n"; break;
            case ParseError::Overflow:       std::cerr << "Overflow\n"; break;
        }
    }
    
    // Monadic operations (C++23):
    auto doubled = parse_int("21")
        .and_then([](int n) -> std::expected<int, ParseError> {
            return n * 2;
        });
}

Frequently Asked Questions

Should I always mark move constructors noexcept? Yes — always mark move constructors and move assignment operators noexcept if they don't throw (they almost never should). This is one of the most impactful single-character additions you can make to a class. Without it, std::vector and other containers will COPY (not move) your objects during reallocation.

When should I use std::expected vs exceptions vs error codes?

  • Exceptions: For truly exceptional conditions (unexpected errors like OOM, disk failure). Recovery is rare.
  • std::expected: For expected failure paths that are part of normal control flow (validation, parsing, lookups). Forces callers to handle errors explicitly.
  • Error codes: For C API compatibility or performance-critical paths where even std::expected overhead is too much.

Can two functions with the same name but different parameter counts overload? Yes — C++ overloading is based on the number AND types of parameters, NOT on return type. Two functions with identical names but different parameters (or parameter types) are distinct overloads; the compiler selects the best match at the call site via overload resolution.


Key Takeaway

C++ function design is the intersection of performance engineering and API design. The sink parameter idiom (pass by value + move) gives you a single overload that efficiently handles both copies and moves. NRVO/RVO makes returning large objects free. noexcept enables container move semantics and smaller binaries. std::expected provides Rust-style error propagation without exceptions. Combined, these turn function boundaries from performance bottlenecks into zero-cost abstractions.

Read next: C++ Strings & Output: std::string_view, std::format & std::print →


Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.