Skip to content

Functions & Parameter Passing

Master the different ways to pass arguments in C++, understand return value optimization, function overloading, and modern attributes like [[nodiscard]].

Functions: The Core Abstraction

Functions are the fundamental unit of code organization in C++. Unlike many other languages, C++ gives you fine-grained control over how arguments are passed to and returned from functions. This control is not just syntactic sugar — it has real performance implications.

The way you pass parameters determines whether the function works with a copy of the argument, a reference to the original, or a pointer to the original. Choosing the right mechanism is one of the most important decisions you make when designing a function's interface.

Parameter Passing Mechanisms

C++ supports four primary ways to pass arguments. Each has different trade-offs regarding performance, safety, and semantics:

parameter_passing.cpp
#include <iostream>
#include <string>
#include <vector>

// 1. Pass by VALUE — the function gets a copy
//    Use for: small types (int, double, char), when you need a local copy
void square(int x) {           // x is a copy of the argument
    x = x * x;                 // Modifying x does NOT affect the caller
    std::cout << "Inside: " << x << '\n';
}

// 2. Pass by REFERENCE — the function works on the original
//    Use for: when you need to modify the caller's variable
void double_it(int& x) {       // x IS the caller's variable
    x *= 2;                    // This DOES modify the caller's variable
}

// 3. Pass by CONST REFERENCE — read-only access to the original, no copy
//    Use for: large objects you only need to read
void print_all(const std::vector<std::string>& names) {
    for (const auto& name : names) {
        std::cout << name << '\n';
    }
    // names.push_back("Eve");  // ERROR: names is const
}

// 4. Pass by POINTER — explicit nullable indirection
//    Use for: optional parameters (can be nullptr), C API interop
void maybe_increment(int* ptr) {
    if (ptr != nullptr) {       // Must check for null!
        ++(*ptr);
    }
}

int main() {
    int val = 5;

    square(val);
    std::cout << "After square: " << val << '\n';      // Still 5 (copied)

    double_it(val);
    std::cout << "After double_it: " << val << '\n';   // Now 10 (modified)

    std::vector<std::string> people = {"Alice", "Bob", "Charlie"};
    print_all(people);         // No copy of the vector — efficient

    maybe_increment(&val);     // Pass address explicitly
    std::cout << "After maybe_increment: " << val << '\n';  // 11

    maybe_increment(nullptr);  // Safe: the function checks for null
}
Best Practice

Follow these guidelines from the C++ Core Guidelines for choosing how to pass parameters:

- Cheap to copy and never modified? Pass by value (int, double, char, bool, small structs, std::string_view).
- Expensive to copy and only read? Pass by const& (std::string, std::vector, large objects).
- Need to modify the caller's variable? Pass by & (non-const reference).
- Might be null? Pass by pointer. But consider std::optional& instead.
- Taking ownership (sinking)? Pass by value and move (covered in the move semantics module).

As a rule of thumb, objects larger than 2-3 pointers (16-24 bytes on 64-bit) should be passed by reference rather than by value.

  • Pass small, cheap types by value: int, double, char, bool, string_view
  • Pass large read-only objects by const reference: const std::string&, const std::vector&
  • Pass by non-const reference only when the function needs to modify the caller's object
  • Pass by pointer when nullptr is a valid argument — but prefer std::optional where possible
  • When in doubt, pass by const reference — it is safe and efficient

Return Value Optimization (RVO/NRVO)

A common concern for beginners is that returning large objects by value is expensive because of copying. In modern C++, this concern is almost always unfounded thanks to Return Value Optimization (RVO) and Named Return Value Optimization (NRVO).

RVO (guaranteed since C++17): When a function returns a temporary (e.g., return std::vector{1,2,3};), the compiler must construct the object directly in the caller's memory. No copy or move occurs.

NRVO (permitted, not guaranteed): When a function returns a named local variable (e.g., return result;), the compiler may construct that variable directly in the caller's memory. All major compilers do this in practice, even in debug mode.

The practical advice: return by value freely. Do not use output parameters (void f(std::vector& out)) to avoid copies — let the compiler optimize the return.

Function Overloading & Default Arguments

C++ allows multiple functions with the same name but different parameter lists. The compiler selects the best match based on the arguments provided at the call site:

overloading.cpp
#include <iostream>
#include <string>
#include <vector>

// Function overloading — same name, different parameter types/counts
void log(int value) {
    std::cout << "[INT] " << value << '\n';
}

void log(double value) {
    std::cout << "[DOUBLE] " << value << '\n';
}

void log(const std::string& value) {
    std::cout << "[STRING] " << value << '\n';
}

// Default arguments — provide fallback values
void greet(const std::string& name, const std::string& greeting = "Hello") {
    std::cout << greeting << ", " << name << "!\n";
}

// [[nodiscard]] (C++17) — warn if return value is ignored
[[nodiscard]] int compute(int a, int b) {
    return a * b + a;
}

// [[nodiscard]] with reason (C++20)
[[nodiscard("Ignoring error codes can hide failures")]]
int open_file(const std::string& path) {
    // Returns 0 on success, negative on error
    return 0;  // Simplified
}

// inline — suggests the compiler inline this function (also allows
// the function to be defined in a header without ODR violations)
inline int fast_max(int a, int b) {
    return (a > b) ? a : b;
}

int main() {
    log(42);               // Calls log(int)
    log(3.14);             // Calls log(double)
    log(std::string{"hi"});// Calls log(const string&)

    greet("Alice");                // Uses default: "Hello, Alice!"
    greet("Bob", "Good morning"); // Uses provided: "Good morning, Bob!"

    // int result = compute(3, 4);  // OK
    // compute(3, 4);               // WARNING: ignoring [[nodiscard]] return value

    int status = open_file("data.txt");  // OK: return value used
}
Pitfall

Function overload resolution follows complex rules, and ambiguous calls are a common source of compilation errors. Be aware of these pitfalls:

1. Implicit conversions create ambiguity: If you have f(int) and f(double), calling f(3.14f) (a float) is ambiguous because float converts equally well to both int and double. The fix: add an f(float) overload, or use static_cast.

2. Default arguments interact poorly with overloads: void f(int x, int y = 0) and void f(int x) create ambiguity when called with one argument.

3. const reference vs value: f(int) and f(const int&) are ambiguous for lvalue arguments because both are equally good matches.

When the compiler reports an ambiguous overload, do not guess — read the error message carefully. It will tell you which overloads are candidates and why they are equally good.

  • Implicit conversions can make overload resolution ambiguous — add explicit overloads to resolve
  • Default arguments and overloads can conflict — avoid both on the same function name
  • When in doubt, the compiler error message lists the candidate overloads and the ambiguity
  • Use static_cast to explicitly select an overload when calling with arguments that match multiple overloads
Key Takeaways
  • Pass small types by value, large read-only types by const reference, and mutable types by non-const reference
  • Return by value freely — RVO/NRVO eliminates the copy in practice
  • Function overloading selects the best match based on argument types — beware of ambiguity from implicit conversions
  • Use [[nodiscard]] on functions whose return value must not be ignored (error codes, computed results)
  • Default arguments provide convenience but can conflict with overloads — use one or the other
  • inline allows functions to be defined in headers without violating the One Definition Rule

Quiz — Test Your Knowledge

(15 XP)

1. When should you pass a `std::vector<int>` by `const&` instead of by value?

2. What is Return Value Optimization (RVO)?

3. What does the `[[nodiscard]]` attribute do?