Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 2

Safe Function Interfaces: References, Const-Correctness, and Pass-By-Value/Reference

Recommended resources

Welcome to your second lesson. In our first session, we established your C++ development environment, covering the essential tools from the compiler to CMake and the sanitizers. With that foundation in place, we can now shift our focus from the mechanics of building code to the principles of writing it well.

This lesson tackles a fundamental aspect of C++ programming: how functions receive and return data. In languages like Python, you pass objects and the runtime handles the underlying details of references. C++ gives you explicit control over these mechanics, which is a double-edged sword: it unlocks significant performance opportunities but also demands a more disciplined approach to ensure safety and correctness.

Today, we will explore references, the concept of const-correctness, and the standard idioms for passing arguments—by value, by reference, and by pointer. Mastering these concepts is crucial for designing function interfaces that are safe, expressive, and efficient, forming the vocabulary for the high-performance numerical code we aim to write.

The Foundation: Pass-by-Value and Pass-by-Reference

At the most basic level, C++ offers two primary ways to pass an argument to a function: by value or by reference.

Pass-by-value means the function receives a copy of the argument. Any modifications made to the parameter inside the function do not affect the original object in the calling code.

void increment_value(int x) {
    x = x + 1; // Modifies the local copy 'x'
}

int main() {
    int a = 10;
    increment_value(a);
    // 'a' is still 10 here
    return 0;
}

Pass-by-reference means the function receives an alias to the original argument, not a copy. The & symbol is used to declare a reference parameter. Any modifications made to the parameter inside the function directly affect the original object.

void increment_by_ref(int& x) {
    x = x + 1; // Modifies the original object that 'x' refers to
}

int main() {
    int a = 10;
    increment_by_ref(a);
    // 'a' is now 11
    return 0;
}

For simple types like int or double, the cost of making a copy is negligible. However, for larger objects—imagine a struct containing a large matrix or a std::string with a lot of text—making a copy on every function call can become a significant performance bottleneck. Pass-by-reference avoids this copy, making it a more efficient choice for larger data structures.

However, passing by reference creates a new problem: it allows the function to modify the original data, which might not be intended. This is where const-correctness becomes essential.


const-Correctness: A Contract of Safety

The const keyword is one of the most important tools in your C++ safety belt. It allows you to declare that a variable, parameter, or member function will not—and cannot—be modified. The compiler enforces this promise, turning potential runtime bugs into compile-time errors.

const References for Input Parameters

When you combine const with pass-by-reference, you get the best of both worlds for input parameters: efficiency and safety.

const T& (a reference to a constant T) tells the compiler two things:

  1. "I want to avoid making a copy of this object." (the & part)
  2. "I promise not to change the object." (the const part)



#include <vector>
#include <numeric>

// This function promises not to modify the input vector.
// It is efficient because it avoids copying the vector's data.
double calculate_sum(const std::vector<double>& numbers) {
    // numbers.push_back(42.0); // This would be a COMPILE ERROR.
    double sum = 0.0;
    for (double num : numbers) {
        sum += num;
    }
    return sum;
}

This const T& idiom is the default, go-to way to pass non-trivial objects that are inputs to a function.

const Member Functions

You can also apply const to member functions of a class. When you add const to a member function's declaration, you are making a promise that calling this function will not change the object's observable state.

class Tensor {
private:
    int _rows;
    int _cols;

public:
    Tensor(int rows, int cols) : _rows(rows), _cols(cols) {}

    // A 'getter' that does not modify the object's state.
    // It is marked 'const'.
    int get_rows() const {
        // _rows = 5; // COMPILE ERROR: cannot modify a member in a const function.
        return _rows;
    }

    // A 'setter' that *does* modify the state.
    // It is not marked 'const'.
    void set_dimensions(int new_rows, int new_cols) {
        _rows = new_rows;
        _cols = new_cols;
    }
};

int main() {
    const Tensor t(10, 20); // A constant Tensor object.
    int rows = t.get_rows(); // OK: get_rows() is a const member function.
    // t.set_dimensions(5, 5); // COMPILE ERROR: cannot call a non-const function on a const object.
}

By marking accessor methods (getters) as const, you allow them to be used on const objects, which is essential for writing robust code.

Sometimes, a const and a non-const member function might share a lot of logic. A common pattern is to implement the logic in a shared helper function (often private) and have both public methods call it.

A common pattern where `const` and non-`const` member functions delegate their core logic to a shared internal function to avoid code duplication while maintaining const-correctness.

Parameter Passing Idioms: The C++ Core Guidelines

With these building blocks, we can establish clear rules for how to design function interfaces. The C++ Core Guidelines, a community-driven set of best practices, provide an excellent framework.

The guidelines categorize parameters based on their intended use:

  • in: The function only reads from the parameter.
  • out: The function writes to the parameter without reading its initial value.
  • in-out: The function both reads from and writes to the parameter.

The following resource from the "Modernes C++" blog provides a concise summary of these rules.

C++ Core Guidelines: The Rules for in, out, in-out, consume, and forward Function Parameter – MC++ BLOG

This blog post provides a clear and practical summary of the C++ Core Guidelines for function parameters. It will serve as our guide for the standard idioms.

Please read the following sections, which map directly to our parameter categories: Guideline F.16 for "in" parameters: Read the rule and its explanation. This covers the distinction between passing cheap types by value and others by const reference. Guideline F.17 for "in-out" parameters: Read the rule. This justifies using a non-const reference for parameters that will be modified. Guideline F.20 for "out" parameters: Read the guideline on preferring return values. This is a key principle of modern C++ style. Guideline F.21 for multiple "out" values: Read the rule. This introduces the modern way to return multiple values, which we will revisit later. Guideline F.60 for Pointers vs. References: Read the final rule. This explains when to use a pointer to signal an optional parameter.

Let's summarize and solidify these rules:

  • For in parameters:

    • If the type is cheap to copy (like int, double, pointers), pass by value.
    • Otherwise, pass by const T&.
  • For in-out parameters:

    • Pass by T& (non-const reference). The absence of const clearly signals to the caller that the object might be changed.
  • For out parameters:

    • Prefer returning by value over using an output reference parameter. Modern compilers use Return Value Optimization (RVO) to eliminate the copy, making this highly efficient. return result; is much clearer than compute_result(result);.
  • For optional parameters:

    • If a parameter is optional (the caller can provide "nothing"), use a pointer (T*) and check for nullptr. A reference (T&) must always refer to a valid object, so it cannot be used to represent an optional argument.

This distinction between owning pointers (std::unique_ptr, std::shared_ptr) and non-owning pointers/references for observing objects is critical. Herb Sutter explains this masterfully in the following clip.

CppCon 2014: Herb Sutter "Back to the Basics! Essentials of Modern C++ Style"

In this segment from his "Back to the Basics" talk, Herb Sutter clarifies that smart pointers are for ownership, while raw pointers and references remain the default and preferred way to pass non-owning observers into functions.

Watch the section from Modern C++ guidance. He explains why raw pointers and references are still the best choice for function parameters that just need to "look at" an object without participating in its lifetime management.


A Deeper Dive: Optimizing for Copied Parameters

Let's consider a common case: a function that needs to make its own copy of an input. A classic example is a setter that updates a std::string member.

class Employee {
    std::string name_;
public:
    // How should we implement set_name?
    void set_name(/* ??? */ new_name);
};

This seemingly simple question opens up a fascinating look into the trade-offs of modern C++ function design, involving performance, simplicity, and exception safety. This is a perfect scenario to apply your analytical skills. Herb Sutter provides a detailed analysis of the options.

CppCon 2014: Herb Sutter "Back to the Basics! Essentials of Modern C++ Style"

This part of the talk is a masterclass in analyzing a real-world C++ design problem. It compares different ways to implement a setter function, backed by performance benchmarks.

First, watch the summary of parameter passing rules from C++98 vs modern C++. The key takeaway is that move semantics didn't radically change the rules but made the default advice (pass-by-value for cheap, const-ref for expensive) apply more broadly. Next, watch the detailed case study on implementing set_name from the set_name example. This is a dense but extremely valuable segment. Pay close attention to: Option 1 (const std::string&): The simple, safe, and still excellent default. Option 3 (pass-by-value std::string): The "sink" argument. Why it's simpler to write but can be a "pessimization" that prevents reusing memory capacity. The benchmark graphs clearly show this performance trap. The Conclusion: For set-like functions, const T& is the best default. For constructors that initialize many members, pass-by-value can be a good optimization.

The key insight from Sutter's analysis is that for a class member like a std::string or std::vector that manages its own memory buffer, re-assigning to it is often cheaper than destroying the old one and constructing a new one, because the existing memory capacity can be reused.

  • void set_name(const std::string& new_name) allows for this reuse. It performs a copy assignment (name_ = new_name;), which can avoid a new memory allocation if name_ already has enough capacity.
  • void set_name(std::string new_name) forces a copy construction before the function is even entered. This new copy always allocates its own memory (for non-small strings), losing the opportunity to reuse the capacity of the member variable.

This is a subtle but powerful example of how understanding the underlying mechanics allows you to write more performant code.


Conclusion

In this lesson, we've established the core principles of designing safe and efficient function interfaces in C++. You've moved beyond the syntax of the language to understand the semantics and performance implications of your design choices.

Here are the key takeaways:

  • const is your contract: Use const liberally to enforce at compile time that data will not be modified. This applies to both const T& parameters and const member functions.
  • Follow the standard idioms: For in parameters, pass cheap types by value and complex types by const T&. For in-out parameters, use T&. For out parameters, prefer return by value.
  • Use pointers for optionality: A pointer T* signals an optional argument that can be nullptr. A reference T& guarantees a valid object.
  • Performance requires thought: The simple pass-by-value approach can sometimes be a performance trap when it prevents the reuse of existing resources, as we saw with the set_name example. The default const T& is often the safest and most performant starting point.

You now have a solid grasp of how data flows between functions. Our next topic will be RAII (Resource Acquisition Is Initialization). We've seen how to handle data, but what about other resources like allocated memory, file handles, or network sockets? RAII is C++'s foundational pattern for managing these resources automatically and safely, and it is the key to writing code that is free from leaks and robust in the face of exceptions.