Pass by Value vs Pass by Reference in C++

Master pass by value and pass by reference in C++. Learn when to use each method, understand performance implications, and write efficient functions with clear examples and best practices.

When you call a function and pass arguments to it, something important happens behind the scenes that affects how your program behaves and performs. Understanding the different ways C++ can pass data to functions—pass by value, pass by reference, and pass by pointer—represents a crucial step in writing efficient, correct code. These parameter passing mechanisms determine whether functions work with copies of your data or with the actual data itself, which has profound implications for both program correctness and performance.

Many beginners write functions without thinking deeply about parameter passing, and their programs work fine for simple cases. However, as programs grow more complex and work with larger data structures, the differences between passing mechanisms become critical. Choosing the wrong approach can lead to functions that unexpectedly fail to modify data when they should, or conversely, that modify data when they shouldn’t. Performance can suffer dramatically when large objects are copied unnecessarily. Understanding these concepts transforms you from someone who writes functions that happen to work into someone who deliberately chooses the right mechanism for each situation.

Let me start with the default and simplest mechanism: pass by value. When you pass an argument by value, the function receives a copy of the argument. The function works with this copy, and any modifications it makes affect only the copy, not the original variable in the calling code. This behavior provides important safety—functions can’t accidentally modify variables they receive, which makes code easier to reason about and less prone to unexpected side effects.

Here’s a simple example that demonstrates pass by value:

C++
void increment(int number) {
    number = number + 1;
    std::cout << "Inside function: " << number << std::endl;
}

int main() {
    int value = 10;
    std::cout << "Before function call: " << value << std::endl;
    
    increment(value);
    
    std::cout << "After function call: " << value << std::endl;
    return 0;
}

When you run this program, it prints:

C++
Before function call: 10
Inside function: 11
After function call: 10

Notice that even though increment adds one to its parameter, the value variable in main remains unchanged at 10. This happens because increment receives a copy of value. When the function increments its parameter, it modifies the copy, not the original. When the function returns, the copy is destroyed, and the original variable remains exactly as it was before the function call.

This copying behavior makes pass by value safe for many situations. If you want a function to perform calculations without affecting the caller’s data, pass by value ensures this isolation automatically. However, this safety comes at a cost: creating copies takes time and memory. For simple types like integers and floating-point numbers, this cost is negligible—copying four or eight bytes happens essentially instantaneously. But what about larger types?

Consider what happens when you pass a large array or a complex object by value:

C++
struct LargeData {
    int values[10000];  // Array of 10,000 integers - 40,000 bytes
};

void processData(LargeData data) {  // Receives entire copy - 40,000 bytes copied!
    // Process the data
    data.values[0] = 100;
}

int main() {
    LargeData myData;
    // Initialize myData...
    
    processData(myData);  // Copies all 40,000 bytes
    return 0;
}

Every time you call processData, C++ copies 40,000 bytes from myData into the function’s parameter. If you call this function in a loop a thousand times, you’re copying 40 megabytes of data unnecessarily. This copying takes time and can significantly impact performance. Moreover, any modifications the function makes to its parameter don’t affect the original data, which might or might not be what you want.

Pass by reference solves both these problems—it avoids copying and allows functions to modify the original data. When you pass by reference, the function receives a reference to the original variable, essentially another name for it. Any modifications through the reference affect the original variable. The syntax uses an ampersand (&) in the parameter declaration:

C++
void increment(int& number) {  // & makes it a reference parameter
    number = number + 1;
    std::cout << "Inside function: " << number << std::endl;
}

int main() {
    int value = 10;
    std::cout << "Before function call: " << value << std::endl;
    
    increment(value);
    
    std::cout << "After function call: " << value << std::endl;
    return 0;
}

Now the output changes:

C++
Before function call: 10
Inside function: 11
After function call: 11

The value variable in main is now 11 after the function call because increment received a reference to value, not a copy. When increment modifies its parameter, it modifies the original variable. The reference parameter acts like an alias—another name for the same variable.

This capability to modify the caller’s variables through references enables functions to return multiple values or to modify data structures without the overhead of copying:

C++
void getMinMax(const int arr[], int size, int& min, int& max) {
    min = arr[0];
    max = arr[0];
    
    for (int i = 1; i < size; i++) {
        if (arr[i] < min) min = arr[i];
        if (arr[i] > max) max = arr[i];
    }
}

int main() {
    int numbers[] = {45, 23, 67, 12, 89, 34};
    int minimum, maximum;
    
    getMinMax(numbers, 6, minimum, maximum);
    
    std::cout << "Min: " << minimum << std::endl;  // Prints: 12
    std::cout << "Max: " << maximum << std::endl;  // Prints: 89
    return 0;
}

The getMinMax function “returns” two values by modifying reference parameters. When you call the function, minimum and maximum are passed by reference, so the function can store results directly in these variables. This pattern is common when you need a function to compute multiple related values.

But what about situations where you want the efficiency of references—avoiding copies—without giving the function permission to modify the data? This is where const references become invaluable. A const reference parameter prevents the function from modifying the data while still avoiding the copy:

C++
void displayData(const LargeData& data) {  // const reference - no copy, no modification
    std::cout << "First value: " << data.values[0] << std::endl;
    // data.values[0] = 100;  // Compiler error - can't modify const reference
}

int main() {
    LargeData myData;
    // Initialize myData...
    
    displayData(myData);  // No copy made, but function can't modify data
    return 0;
}

The const reference gives you the best of both worlds: the efficiency of avoiding a copy and the safety of knowing the function can’t modify your data. This is why const references are the preferred way to pass large objects to functions that only need to read them. The compiler enforces the const guarantee—if displayData tries to modify its parameter, compilation fails with an error.

Let me show you a comprehensive comparison with a practical example:

C++
#include <iostream>
#include <string>

// Pass by value - receives copy, can't modify original
void printByValue(std::string text) {
    text += " (modified in function)";
    std::cout << "Inside printByValue: " << text << std::endl;
}

// Pass by reference - receives reference, can modify original
void printByReference(std::string& text) {
    text += " (modified in function)";
    std::cout << "Inside printByReference: " << text << std::endl;
}

// Pass by const reference - receives reference, can't modify
void printByConstReference(const std::string& text) {
    // text += " (modified)";  // Error - can't modify const reference
    std::cout << "Inside printByConstReference: " << text << std::endl;
}

int main() {
    std::string message = "Hello";
    
    std::cout << "\n--- Pass by Value ---" << std::endl;
    std::cout << "Before: " << message << std::endl;
    printByValue(message);
    std::cout << "After: " << message << std::endl;  // Unchanged
    
    std::cout << "\n--- Pass by Reference ---" << std::endl;
    std::cout << "Before: " << message << std::endl;
    printByReference(message);
    std::cout << "After: " << message << std::endl;  // Modified
    
    std::cout << "\n--- Pass by Const Reference ---" << std::endl;
    printByConstReference(message);
    
    return 0;
}

This example demonstrates all three approaches with strings, which are large enough that copying has measurable cost. The pass by value version leaves the original unchanged, the pass by reference version modifies it, and the pass by const reference version provides read-only access without copying.

Pass by pointer represents a third option that predates references in C++. Pointers can achieve the same effects as references but with different syntax and semantics:

C++
void increment(int* ptr) {  // Pointer parameter
    if (ptr != nullptr) {  // Should check for null
        *ptr = *ptr + 1;   // Dereference to access/modify value
    }
}

int main() {
    int value = 10;
    increment(&value);  // Pass address of value
    std::cout << value << std::endl;  // Prints: 11
    return 0;
}

The pointer version requires dereferencing with the asterisk operator, and callers must explicitly pass the address using the ampersand. This makes the indirection more visible at the call site—you can see that increment receives an address, suggesting it might modify the variable. Pointers also introduce the possibility of null, which references don’t allow.

References are generally preferred over pointers for function parameters when you don’t need null as a valid value and don’t need to reassign what the parameter refers to. References are cleaner, can’t be null, and don’t require explicit dereferencing. However, pointers remain useful when you need to indicate that “no object” is a valid state, or when working with dynamic memory or arrays:

C++
void processArray(int* arr, int size) {  // Array passed as pointer
    if (arr == nullptr) {
        std::cout << "Error: null array" << std::endl;
        return;
    }
    
    for (int i = 0; i < size; i++) {
        arr[i] *= 2;
    }
}

Understanding when each passing mechanism makes sense requires considering several factors. Let me provide clear guidelines:

Use pass by value when:

  • The parameter is a simple built-in type (int, double, char, bool, etc.)
  • You want the function to work with a copy without affecting the original
  • The type is small and cheap to copy
  • You’re implementing value semantics deliberately

Use pass by reference when:

  • You need the function to modify the caller’s variable
  • You’re returning multiple values through parameters
  • The parameter is a large object and you need to modify it

Use pass by const reference when:

  • The parameter is a large object or string
  • The function only needs to read the data, not modify it
  • You want efficiency without sacrificing safety
  • The type is expensive to copy (the default choice for class types)

Use pass by pointer when:

  • Null is a meaningful value (no object)
  • You’re working with arrays in a C-style way
  • You’re interfacing with C libraries or legacy code
  • You need to reassign what’s being pointed to

Let me demonstrate these principles with a more complex example—a function that processes student records:

C++
#include <iostream>
#include <string>
#include <vector>

struct Student {
    std::string name;
    int id;
    double gpa;
    std::vector<std::string> courses;
};

// Const reference - read-only access to large object
void displayStudent(const Student& student) {
    std::cout << "Name: " << student.name << std::endl;
    std::cout << "ID: " << student.id << std::endl;
    std::cout << "GPA: " << student.gpa << std::endl;
}

// Reference - need to modify the object
void updateGPA(Student& student, double newGPA) {
    student.gpa = newGPA;
}

// Value - small data that function should own
void calculateGradePoints(double gpa, int creditHours) {
    double gradePoints = gpa * creditHours;
    std::cout << "Grade points: " << gradePoints << std::endl;
}

// Pointer - null is meaningful (optional student)
void displayIfExists(const Student* student) {
    if (student == nullptr) {
        std::cout << "No student data available" << std::endl;
        return;
    }
    displayStudent(*student);  // Dereference pointer to get reference
}

int main() {
    Student alice = {"Alice Johnson", 12345, 3.7, {"Math", "Physics", "CS"}};
    
    displayStudent(alice);      // Pass by const reference
    updateGPA(alice, 3.9);      // Pass by reference
    calculateGradePoints(alice.gpa, 15);  // Pass by value
    
    Student* ptr = &alice;
    displayIfExists(ptr);       // Pass by pointer
    displayIfExists(nullptr);   // Pass null pointer
    
    return 0;
}

This example shows each passing mechanism in appropriate contexts. The displayStudent function takes a const reference because it needs to read a large object. The updateGPA function takes a reference because it modifies the student. The calculateGradePoints function takes simple values by value because they’re small. The displayIfExists function takes a pointer because null represents a valid state.

Performance implications of parameter passing become significant with larger types. Consider this benchmark conceptually:

C++
class LargeClass {
    double data[1000];  // 8000 bytes
    // Plus other members...
};

// Slow - copies 8000+ bytes every call
void processByValue(LargeClass obj) {
    // Process obj
}

// Fast - no copy, just passes reference
void processByReference(const LargeClass& obj) {
    // Process obj
}

If you call processByValue in a loop a million times, you’re copying 8 gigabytes of data. The processByReference version copies nothing—just passes a reference that’s typically 4 or 8 bytes regardless of the object size. This difference can make programs orders of magnitude faster.

However, don’t over-optimize prematurely. For simple types like integers, pass by value is actually preferable:

C++
// Good - simple type, pass by value
void printAge(int age) {
    std::cout << "Age: " << age << std::endl;
}

// Unnecessarily complex - reference adds no benefit
void printAge(const int& age) {
    std::cout << "Age: " << age << std::endl;
}

Passing integers by const reference provides no performance benefit and actually might be slightly slower because of the indirection. Use const reference for class types, strings, vectors, and other containers, but pass built-in types by value.

Return values interact with parameter passing in important ways. Modern C++ includes return value optimization (RVO), which eliminates copies when returning objects:

C++
std::string createMessage() {
    std::string msg = "Hello, World!";
    return msg;  // RVO eliminates the copy
}

int main() {
    std::string greeting = createMessage();  // No copy made
    return 0;
}

Despite appearances, no copying occurs here. The compiler constructs the string directly in the caller’s space. This optimization makes returning objects by value efficient, so don’t feel compelled to use output parameters just to avoid returns:

C++
// Modern style - return by value, RVO eliminates copy
std::string formatName(const std::string& first, const std::string& last) {
    return first + " " + last;
}

// Old style - output parameter
void formatName(const std::string& first, const std::string& last, std::string& result) {
    result = first + " " + last;
}

The first version is cleaner and performs just as well thanks to RVO.

Common mistakes with parameter passing often involve using references when values are needed or vice versa. Here’s a problematic example:

C++
// WRONG - returns reference to local variable
int& getBiggerValue(int a, int b) {
    int result = (a > b) ? a : b;
    return result;  // Danger! result is destroyed when function returns
}

This function returns a reference to a local variable that’s destroyed when the function ends. The caller receives a reference to invalid memory, causing undefined behavior. Never return references to local variables. Return by value instead:

C++
// Correct - returns value
int getBiggerValue(int a, int b) {
    return (a > b) ? a : b;
}

Another mistake is modifying a parameter you shouldn’t:

C++
void displaySum(std::vector<int>& numbers) {  // Non-const reference
    int sum = 0;
    for (int num : numbers) {
        sum += num;
    }
    std::cout << "Sum: " << sum << std::endl;
    
    // Someone adds this later:
    numbers.clear();  // Oops! Modifies caller's vector
}

If the function shouldn’t modify the parameter, declare it const:

C++
void displaySum(const std::vector<int>& numbers) {  // Const reference
    int sum = 0;
    for (int num : numbers) {
        sum += num;
    }
    std::cout << "Sum: " << sum << std::endl;
    // numbers.clear();  // Compiler error - can't modify const reference
}

The const qualifier documents your intent and lets the compiler enforce it.

Rvalue references, introduced in C++11, add another dimension to parameter passing. They enable move semantics, allowing functions to efficiently transfer resources from temporary objects:

C++
void processString(std::string&& str) {  // Rvalue reference
    // Can "steal" str's contents since it's temporary
}

processString(std::string("temporary"));  // Binds to temporary

While rvalue references represent an advanced topic, understanding that they exist prepares you for deeper C++ study. For now, know that const references can bind to both lvalues (named variables) and rvalues (temporaries), making them versatile for function parameters.

Function overloading interacts with parameter passing—you can provide both const and non-const versions:

C++
class DataContainer {
    std::vector<int> data;
public:
    // Const version - for const objects or when not modifying
    const std::vector<int>& getData() const {
        return data;
    }
    
    // Non-const version - for modifiable access
    std::vector<int>& getData() {
        return data;
    }
};

This pattern provides read-only access for const objects while allowing modification for non-const objects, giving callers flexibility while maintaining safety.

Key Takeaways

Pass by value creates a copy of the argument, protecting the original from modification but incurring copy costs. Use it for small built-in types and when you want the function to work with its own copy. Pass by reference avoids copying and allows modification, making it efficient for large objects when modification is needed. Pass by const reference provides the efficiency of references with the safety of preventing modification—the default choice for class types and strings.

Pass by pointer offers similar capabilities to references but with nullable semantics and explicit syntax at call sites. Choose pointers when null is meaningful or when interfacing with C-style code. For modern C++, prefer references over pointers for function parameters unless you specifically need pointer semantics.

Understanding parameter passing mechanisms is fundamental to writing correct, efficient C++ code. The right choice depends on the parameter’s size, whether the function needs to modify it, and whether null is a valid value. Default to const reference for class types, value for built-in types, and use non-const references or pointers when modification is required. These guidelines lead to code that’s both efficient and maintains clear intent.

Share:
Subscribe
Notify of
0 Comments
Inline Feedbacks
View all comments

Discover More

Anduril Expands with New Long Beach Defense Tech Campus

Anduril announces major Long Beach campus investment to scale advanced weapons systems production. Defense tech…

What is a Robot? Understanding the Difference Between Automation and Robotics

Discover what truly defines a robot and how it differs from simple automation. Learn the…

Introduction to Java Methods: Defining and Calling Methods

Learn how to define and call methods in Java. Explore advanced topics like method overloading,…

Reading Your First Robot Schematic: A Complete Walkthrough

Learn to read robot schematics and circuit diagrams with this beginner-friendly guide. Understand symbols, connections,…

Understanding the Basics of Data Visualization Tools: Excel, Tableau, and Google Sheets

Explore data visualization tools—Excel, Tableau, Google Sheets. Learn their features, use cases, and tips to…

Bolivia Opens Market to Global Satellite Internet Providers in Digital Infrastructure Push

Bolivia reverses satellite internet ban, allowing Starlink, Project Kuiper, and OneWeb to operate. New decree…

Click For More
0
Would love your thoughts, please comment.x
()
x