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
- Pass by const Reference: The Read-Only Workhorse
- Pass by Rvalue Reference: Sink Parameters
- Return Value Optimization (RVO and NRVO)
- Guaranteed Copy Elision (C++17)
- noexcept: Safety Contracts and Code Generation
- Function Overloading and ADL
- Default Arguments and Their Pitfalls
- std::expected: Error Handling Without Exceptions (C++23)
- Frequently Asked Questions
- Key Takeaway
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:
// 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:
#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:
#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::moveReturn 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):
#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 elisionC++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:
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. Justreturn local_variable;.
noexcept: Safety Contracts and Code Generation
noexcept is a contract: this function will never propagate an exception. The compiler uses this to:
- Skip exception handling code generation (smaller binary, faster function epilogues).
- Allow
std::vectorto use move semantics during reallocation — vectors only move elements (O(n) move vs O(n) copy) if the move constructor isnoexcept.
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 buffersstd::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++:
#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::expectedoverhead 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.
