Pass by Lvalue Reference

In previous lessons, we introduced lvalue references and lvalue references to const. In isolation, they might not have seemed particularly useful. Why create an alias when you can use the variable directly?

This lesson reveals why references are powerful. From this point forward, you'll see references used extensively.

First, let's review pass by value. When passing arguments by value, they're copied into function parameters:

#include <iostream>

void showLevel(int level)
{
    std::cout << level << '\n';
} // level destroyed here

int main()
{
    int playerLevel{25};

    showLevel(playerLevel); // playerLevel copied into parameter level (inexpensive)

    return 0;
}

When showLevel(playerLevel) is called, playerLevel's value (25) is copied into parameter level. At function's end, level is destroyed.

This means we created a copy of our argument's value, used it briefly, then destroyed it. Fortunately, fundamental types are cheap to copy, so this isn't problematic.

Some Objects Are Expensive to Copy

Most standard library types (like std::string) are class types. Class types are usually expensive to copy. We want to avoid unnecessary copies of expensive objects, especially when they're immediately destroyed.

Consider this example:

#include <iostream>
#include <string>

void displayName(std::string name)
{
    std::cout << name << '\n';
} // name destroyed here

int main()
{
    std::string playerName{"Sir Galahad the Brave"}; // playerName is std::string

    displayName(playerName); // playerName copied into parameter name (expensive)

    return 0;
}

Output:

Sir Galahad the Brave

This program works correctly but inefficiently. When displayName() is called, argument playerName is copied into parameter name. However, name is a std::string, which is expensive to copy. This expensive copy occurs every time displayName() is called!

We can do better.

Pass by Reference

One way to avoid expensive copies when calling functions is using pass by reference instead of pass by value. With pass by reference, we declare function parameters as reference types (or const reference types) rather than normal types. When the function is called, each reference parameter binds to the corresponding argument. Since references act as aliases, no copy is made.

Here's the same example using pass by reference:

#include <iostream>
#include <string>

void displayName(std::string& name) // Type changed to std::string&
{
    std::cout << name << '\n';
} // name destroyed here

int main()
{
    std::string playerName{"Sir Galahad the Brave"};

    displayName(playerName); // playerName passed by reference into name (inexpensive)

    return 0;
}

This program is identical except parameter name's type changed from std::string to std::string& (an lvalue reference). Now when displayName(playerName) is called, lvalue reference parameter name binds to argument playerName. Binding a reference is always inexpensive, and no copy of playerName is needed. Since references act as aliases, when displayName() uses name, it's actually accessing playerName (not a copy).

Key Concept
Pass by reference lets us pass arguments to functions without making copies each time the function is called.

This program demonstrates that value parameters are separate objects from arguments, while reference parameters are treated as the arguments themselves:

#include <iostream>

void showAddresses(int byValue, int& byReference)
{
    std::cout << "Value parameter address: " << &byValue << '\n';
    std::cout << "Reference parameter address: " << &byReference << '\n';
}

int main()
{
    int health{100};
    std::cout << "Argument address: " << &health << '\n';
    showAddresses(health, health);

    return 0;
}

One run produced:

Argument address: 0x7ffee8c0a8e0
Value parameter address: 0x7ffee8c0a8e4
Reference parameter address: 0x7ffee8c0a8e0

The argument has a different address from the value parameter (meaning the value parameter is a separate object). Since they have different addresses, the argument's value must be copied into the value parameter's memory.

However, the reference parameter's address matches the argument's address, meaning the reference parameter is treated as the same object as the argument.

Pass by Reference Allows Modifying Arguments

When passing objects by value, function parameters receive copies of arguments. Changes to parameter values modify the copy, not the original argument:

#include <iostream>

void heal(int health) // health is a copy of playerHealth
{
    health += 25; // Modifies the copy, not playerHealth
}

int main()
{
    int playerHealth{75};

    std::cout << "Health: " << playerHealth << '\n';

    heal(playerHealth);

    std::cout << "Health: " << playerHealth << '\n'; // playerHealth unchanged

    return 0;
}

Since value parameter health is a copy of playerHealth, healing only affects health. Output:

Health: 75
Health: 75

However, since references act identically to their referents, when using pass by reference, changes made to reference parameters affect the arguments:

#include <iostream>

void heal(int& health) // health bound to actual playerHealth
{
    health += 25; // Modifies actual playerHealth
}

int main()
{
    int playerHealth{75};

    std::cout << "Health: " << playerHealth << '\n';

    heal(playerHealth);

    std::cout << "Health: " << playerHealth << '\n'; // playerHealth modified

    return 0;
}

Output:

Health: 75
Health: 100

Initially playerHealth has value 75. When heal(playerHealth) is called, reference parameter health binds to argument playerHealth. When heal() adds to health, it's actually modifying playerHealth from 75 to 100 (not a copy). This changed value persists after heal() finishes.

Key Concept
Passing values by reference to non-const allows writing functions that modify passed argument values.

This capability is useful. Imagine a function determining whether a monster successfully attacked a player. If so, the monster damages the player's health. Passing the player object by reference lets the function directly modify the actual player object's health. Passing by value would only modify a copy's health, which is less useful.

Pass by Reference Only Accepts Modifiable Lvalue Arguments

Since non-const references can only bind to modifiable lvalues (essentially non-const variables), pass by reference only works with modifiable lvalue arguments. This significantly limits usefulness, as we can't pass const variables or literals:

#include <iostream>

void showHealth(int& health) // health only accepts modifiable lvalues
{
    std::cout << health << '\n';
}

int main()
{
    int playerHealth{100};
    showHealth(playerHealth); // Valid: playerHealth is modifiable lvalue

    const int maxHealth{200};
    showHealth(maxHealth); // Error: maxHealth is non-modifiable lvalue

    showHealth(50); // Error: 50 is rvalue

    return 0;
}

Fortunately, there's an easy solution, which we'll discuss in the next lesson. We'll also examine when to use pass by value versus pass by reference.

Summary

Pass by reference: Declaring function parameters as reference types allows passing arguments without making copies—the reference parameter binds to the argument directly.

Avoiding expensive copies: Pass by reference is particularly useful for class types like std::string that are expensive to copy, avoiding performance penalties while maintaining access to the original object.

Modifying arguments: When using non-const references, functions can modify the original arguments since references act as aliases to the referents.

Limitation: Pass by non-const reference only accepts modifiable lvalue arguments, which limits its usefulness—const variables and literals cannot be passed.

Pass by reference provides an efficient alternative to pass by value for expensive-to-copy types and enables functions to modify caller arguments when needed. However, its limitation to modifiable lvalues means we often need const references for maximum flexibility.