Skip to content
Shafin Chowdhury
Go back

Pass by Value vs Pass by Reference in C++: Performance and Mechanics

Software Developer & Computer Science Student

Published:
7 min read
Edit page
Table of Contents10 topics

One of the most consequential decisions you make when writing a C++ function is choosing how parameters are passed.

Should you pass an argument by value, by reference, by const reference, or by pointer?

The decision affects not only whether your function can alter the caller’s variables, but also the runtime memory efficiency, CPU cache performance, and safety of your entire application.

In this guide, we break down the call stack mechanics, explain the performance trade-offs with technical rigor, and give you a clear decision matrix used by professional C++ engineers.


What You’ll Learn

  • The underlying call stack mechanics of Pass by Value
  • How Pass by Reference enables in-place mutation without copying
  • Why void update(int x) and void update(int& x) behave differently
  • Why const T& is the industry standard for read-only complex parameters
  • Pointers as an alternative way to pass by address
  • The Performance Myth: Why references are not always faster than values (CPU register mechanics)
  • Comprehensive parameter passing comparison matrix
  • A clear, practical decision flowchart for your own code

1. Pass by Value: Independent Copies

When a parameter is passed by value, the caller’s argument is evaluated, and an entirely new, independent copy of the data is constructed in the function’s local stack frame.

void modifyValue(int x) {
    x = x + 10; // Alters ONLY the local parameter copy 'x'
}

int main() {
    int score = 50;
    modifyValue(score);
    // score is STILL 50!
    return 0;
}

Call Stack Architecture

CALL STACK:
+-----------------------------------+
| modifyValue() Stack Frame         |
|   parameter x = 60  (Local copy)  |
+-----------------------------------+
| main() Stack Frame                |
|   variable score = 50 (Untouched) |
+-----------------------------------+

When Pass by Value Excels

  1. Small Primitive Types: Types that fit within one or two CPU registers (int, char, float, double, bool, pointers, and small structures ≤16\le 16 bytes).
  2. Defensive Isolation: When the function intentionally needs its own working copy that it can modify without affecting the caller.
  3. Move Semantics (Advanced): When a function will consume or sink an object and store it elsewhere.

2. Pass by Reference: In-Place Mutation

When a parameter is passed by reference (T&), no copy of the object is created. Instead, the function parameter becomes an alias that binds directly to the caller’s argument.

Any modification made to the parameter immediately affects the variable in the caller’s scope:

void applyBonus(int& score) {
    score = score + 10; // Directly mutates the caller's variable!
}

int main() {
    int score = 50;
    applyBonus(score);
    // score is now 60!
    return 0;
}

Classic Example: The swap Function

A classic interview question asks how to swap two integers. With pass-by-value, swapping copies achieves nothing. With pass-by-reference, the swap takes effect immediately in the caller:

#include <iostream>

using namespace std;

void swapNumbers(int& a, int& b) {
    int temp = a;
    a = b;
    b = temp;
}

int main() {
    int first = 10;
    int second = 20;

    cout << "Before: first=" << first << ", second=" << second << '\n';
    swapNumbers(first, second);
    cout << "After:  first=" << first << ", second=" << second << '\n';

    return 0;
}

Output

Before: first=10, second=20
After:  first=20, second=10

3. Pass by const Reference: Zero-Copy Read-Only Access

What if you want the performance benefit of passing by reference (no copying), but you want the safety guarantee that the function cannot modify the caller’s data?

You pass by const reference (const T&):

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

using namespace std;

// Zero-copy, read-only! Cannot accidentally mutate 'document' or 'keywords'.
void analyzeText(const string& document, const vector<string>& keywords) {
    cout << "Document character length: " << document.length() << '\n';
    cout << "Searching for " << keywords.size() << " keywords...\n";

    // document += " appended"; // COMPILE ERROR: Cannot modify const reference!
}

Why const T& Is Crucial for Non-Trivial Types

Consider what happens if you pass a std::string containing 100,000 characters by value:

  1. The CPU must allocate a new chunk of heap memory.
  2. It must copy 100,000 bytes from the caller’s buffer to the new buffer.
  3. The function executes.
  4. When the function finishes, the CPU must deallocate that heap memory.

By passing const std::string&, the compiler simply passes the memory address (typically 8 bytes). Zero memory allocations occur, and zero characters are copied.

Furthermore, unlike a non-const reference, a const reference can bind directly to temporary values and literals:

void printMessage(const string& msg);

printMessage("System Ready"); // Legal! Binds to temporary string literal

4. Passing by Pointer: Optional Arguments

Before C++ introduced references, passing a pointer (T*) was the only way in C to allow a function to modify caller data or avoid copying.

Passing by pointer is still used when the argument is optional—that is, the caller is allowed to pass nullptr to indicate that no data is provided:

#include <iostream>
#include <string>

using namespace std;

void logEvent(const string& eventName, const int* errorCode = nullptr) {
    cout << "[EVENT]: " << eventName;
    if (errorCode != nullptr) {
        cout << " (Error Code: " << *errorCode << ")";
    }
    cout << '\n';
}

int main() {
    int error = 404;
    logEvent("User Login");              // No error code supplied
    logEvent("Database Query", &error);  // Error code supplied via pointer

    return 0;
}

The Performance Myth: “References Are Always Faster”

A common beginner misconception is that passing by reference is always faster than passing by value.

This is false for small primitive types.

How References Are Implemented in Machine Code

Under the hood, the compiler implements a reference as a pointer (a memory address). When a function accesses a parameter passed by reference, it must perform an extra indirection (pointer dereference): it loads the address, then accesses the value at that address.

Primitive Types in CPU Registers

On modern 64-bit systems (x86-64 and ARM64), primitive types like int, float, and double are passed directly inside high-speed CPU hardware registers (such as %rdi, %rsi, %xmm0):

  • void foo(int x) (By Value): The integer is loaded directly into a CPU register. The function reads it in 0 additional memory cycles.
  • void foo(const int& x) (By Reference): The compiler puts the memory address of x into a register. The function must then dereference that address, potentially causing a CPU cache miss if the data is not in L1 cache!

[!IMPORTANT] The Rule of Thumb:

  • For types up to 16 bytes (such as int, double, bool, char, pointers, or a simple struct Point { int x, y; }), pass by value. It is faster, uses fewer instructions, and avoids cache-unfriendly pointer indirection.
  • For types larger than 16 bytes, or types with dynamic heap memory (such as std::string, std::vector, or complex classes), pass by const T&.

Comprehensive Parameter Passing Comparison Table

Passing StyleSyntaxMutates Caller?Copies Data?Accepts Literals/Rvalues?Can Be Null?Best Used For
Pass by Valuevoid fn(int x)NoYes (full copy)Yes (fn(5))NoPrimitives (int, double, pointers), small structs ≤16\le 16 bytes
Pass by Non-Const Referencevoid fn(int& x)YesNo (0 copy)NoNoOut-parameters that must be modified (e.g. swap)
Pass by Const Referencevoid fn(const T& x)NoNo (0 copy)YesNoNon-trivial read-only objects (std::string, vector, classes)
Pass by Pointervoid fn(T* x)Yes (if *x)Yes (copies address)Only with &Yes (nullptr)Optional arguments, interfacing with C libraries

Decision Matrix: How to Choose in Practice

Follow this simple decision tree when designing any C++ function parameter:

                  Is it a primitive type (int, double, char, bool, pointer)
                  or a tiny struct (<= 16 bytes)?
                                /        \
                             YES          NO
                             /              \
                Pass by VALUE (T)       Does the function need to modify
                                        the caller's original object?
                                            /           \
                                         YES             NO
                                         /                 \
                          Pass by REFERENCE (T&)    Pass by CONST REFERENCE (const T&)

_(If the argument is optional, pass by pointer const T_or modernstd::optional<T> instead).*


Common Beginner Mistakes

1. Accidentally Copying Heavy Containers in Loops

vector<string> largeDataset = /* 100,000 strings */;

// SLOW: Copies every single string during each loop iteration!
for (string item : largeDataset) {
    cout << item << '\n';
}

// FAST: Zero-copy read-only alias
for (const string& item : largeDataset) {
    cout << item << '\n';
}

2. Forgetting const on Read-Only References

Declaring void print(std::string& s) prevents the function from accepting string literals like print("hello") because a non-const reference cannot bind to a temporary.

3. Returning References to Local Variables

As established in Article 10, never return a reference to a local variable created inside the function.


Best Practices

  1. Pass primitives by value: Write void setAge(int age), not void setAge(const int& age).
  2. Pass large objects by const T&: Make const std::string& and const std::vector<T>& your default signature for read-only non-primitives.
  3. Use T& only when modification is the intended goal: Clear names like updateState(State& state) communicate that the parameter will be mutated.
  4. Prefer references over pointers for mandatory parameters: References eliminate null checks and clarify interface expectations.

Summary

  • Pass by value creates an isolated copy; ideal for small primitives (≤16\le 16 bytes).
  • Pass by non-const reference allows modifying caller data in-place without copies.
  • Pass by const reference is the standard for read-only non-trivial types, avoiding expensive heap copies.
  • Pointers provide indirection with optional nullability.
  • Always use the decision matrix to pick the most efficient and safe parameter type.

Continue Learning C++


Edit page
Share this post:

Related Articles

Continue exploring related topics and foundational concepts.


Previous Post
const in C++: Constants, const Pointers, References, and constexpr
Next Post
References in C++: Aliases, Memory Mechanics, and Pointers Compared