CodeGym /Courses /C++ SELF /T& and const T& — "a second name"

T& and const T& — "a second name"

C++ SELF
Level 15, Lesson 1
Available

1. Introduction

When a beginner sees & in a parameter, the first reaction is often: “Oh no, this is dangerous, probably one of those scary topics where everything crashes”. In fact, references in parameters are one of the friendliest tools in C++… if you treat them as a contract, not as a "trick".

Imagine a function as a small workshop. Arguments are what you bring inside. There are two ways to work: either you bring a copy of the part (pass-by-value), or you bring the actual part and say "here it is, work with it directly" (pass-by-reference). A reference is like a badge saying "I am the official representative of this object".

T& as "a second name"

Important gentle start: a reference is not a new object. A reference is another name for an already existing object.

So if you have a variable a, and somewhere you obtain int& r = a;, then r and a are "two nameplates on the same door". Inside the room it's the same object.

Mini example to feel it by hand:

#include <iostream>

int main() {
    int a = 10;
    int& r = a;      // r — the second name of a

    r += 5;
    std::cout << a << '\n';  // 15
}

There is no copying. No "second int". There is one int, but two ways to access it.

Parameter T&: the function can modify the argument

Now move the idea to functions. When a parameter is declared as T&, it reads: “The function works with the caller's real object”. That means changes inside the function will be visible outside. This is not a "side effect", this is the purpose of such a parameter: you explicitly allow the function to modify your object.

The shortest example — increment:

#include <iostream>

void inc(int& x) {
    x += 1;
}

int main() {
    int a = 10;
    inc(a);
    std::cout << a << '\n'; // 11
}

Here x is a. Therefore x += 1 changes a.

And here appears a very mature readability rule: if you use T&, the function name should usually make it clear that it modifies the argument. inc, append, normalize, sort_inplace — good names. calc, get, print — suspicious, because "calc" usually computes without rewriting your variable.

2. T& does not create a copy

Another reason to like references — they often eliminate unnecessary copies of large objects. But today we focus not on performance for the sake of performance, but on meaning: if a function should modify a string — it's logical that it takes a reference.

For example, add an exclamation mark to the end of a string:

#include <iostream>
#include <string>

void add_exclamation(std::string& s) {
    s += "!";
}

int main() {
    std::string name = "Bob";
    add_exclamation(name);

    std::cout << name << '\n'; // Bob!
}

Important detail: if the parameter was std::string s (by value), you would change a copy, and nothing would happen outside. So the parameter type is not "what's convenient", but what exactly the function promises.

3. Limitation of T&: cannot bind to a temporary value

Now comes a moment where many get a compiler error for the first time and think: “The compiler broke”. No, the compiler saved you.

You cannot bind T& to a temporary value (an object that lives very briefly).

Example:

#include <string>

void add_exclamation(std::string& s) {
    s += "!";
}

int main() {
    add_exclamation(std::string("Bob")); // compilation error
}

Why? Because std::string("Bob") is a temporary object. It appears "for the duration of the expression", then disappears. If C++ allowed you to make a "long-lived" reference T& to it, you could start modifying an object that's about to evaporate. It's like trying to mount a shelf on air: while you're holding the drill it seems to exist, but later you'll find there was no wall.

Remember the practical rule: T& requires a "real variable" (a named object), not the result of an expression "on the fly".

4. const T&: read without copying and promise "I won't modify"

Now the second hero of the lecture: const T&.

When you write const T&, you do two things at once:

  1. you say "I don't want to copy, give me access to the original object"
  2. you say "and I won't modify it — honest programmer"

This is a very common parameter type for functions that only read an object, especially if the object can be large (string, vector).

Example: a function that prints a string:

#include <iostream>
#include <string>

void print_line(const std::string& s) {
    std::cout << s << '\n';
}

int main() {
    std::string msg = "hello";
    print_line(msg); // prints: hello
}

Key idea: print_line has no right to change s. If you write s += "!" inside, the compiler will stop you. This is a straightforward, ironclad way to protect yourself from accidental data corruption.

By the way, in the standard library the idea of “reference/const_reference” appears as a steady way to name "reference to an element" and "const reference to an element" (for example, in containers), and even in editorial notes to the standard you can find formulations about (const_)reference as a standard naming pattern.

const T& can be bound to a temporary value

This is where const T& wins over T& in convenience. const T& can be bound to a temporary object. That makes sense: if you promise "not to modify", then referring to a temporary is safer, because you won't mutate it and usually use it "right now".

Example:

#include <iostream>
#include <string>

std::size_t len(const std::string& s) {
    return s.size();
}

int main() {
    std::cout << len(std::string("abc")) << '\n'; // 3
}

This is a very handy possibility: you can pass the result of an expression without extra variables and still avoid copying the string.

Important — don't turn this into a religion. For small types (int, double) const T& is often excessive, and in lecture 71 we discussed why "const int& everywhere" is noise in code. But for strings and vectors it's usually a good style.

5. Mini table: T, T& or const T&

It's useful to collect the picture into one compact table to stop "guessing" and start choosing consciously.

What you mean semantically What to write in the parameter What it means
"I need a copy, I work with an independent value"
T x
a copy of the argument is created
"I will modify your object"
T& x
work with the original object, changes are visible outside
"I only read, I don't want a copy"
const T& x
work with the original object, but modification is forbidden

This table is not about "optimization". It's about keeping signatures free of surprises.

Diagram: how "a second name" works

When you are getting used to references, it's helpful to keep a visual model: T& is not a box, it's an arrow "to the same object".

flowchart LR
    A["Variable a (real object)"] -->|alias| R["Reference r (second name)"]
    A -->|alias| P["Parameter x of type T& in a function"]

The idea: there is one object (a), but there can be multiple names/paths to it (r, x).

6. Practice: references in a console TodoList

Now we will take a practical step: add functions to our application (let it be a simple console TodoList) so that the signatures show where we modify the task list and where we only read.

Assume tasks are just a std::vector<std::string>. We won't model a "task" as a struct yet (that will come later in the course), so we keep everything as simple as possible.

Print tasks: const std::vector<std::string>&

Start with printing. Printing must not change the list. So — const&.

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

void print_tasks(const std::vector<std::string>& tasks) {
    std::cout << "Tasks: " << tasks.size() << '\n';
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        std::cout << i << ": " << tasks[i] << '\n';
    }
}

Note: we do not copy tasks, we read it. If you accidentally try to call tasks.push_back("..."), the compiler will stop you — and that's good.

Add a task: modify the list (T&), read the text (const T&)

Adding a task modifies the vector, so the vector is by reference &. The task text is only read, so the string is by const&.

#include <string>
#include <vector>

void add_task(std::vector<std::string>& tasks, const std::string& text) {
    if (!text.empty()) {
        tasks.push_back(text);
    }
}

The signature reads almost like a phrase: "I modify tasks, but I don't touch text".

In-place string normalization: example of an honest std::string&

A common task: fix a string in-place, for example, remove extra spaces at the ends. We won't implement a perfect trim for all cases (that's a separate large topic), we'll make a learning version: if the string starts with a space — remove one leading space; if it ends with a space — remove one trailing space.

#include <string>

void trim_one_space(std::string& s) {
    if (!s.empty() && s.front() == ' ') {
        s.erase(0, 1);
    }
    if (!s.empty() && s.back() == ' ') {
        s.pop_back();
    }
}

And usage:

#include <iostream>
#include <string>

int main() {
    std::string t = " hello ";
    trim_one_space(t);
    std::cout << t << '\n'; // hello
}

Here std::string& is absolutely justified: the function truly edits the string.

"Output parameter" via T&

Sometimes you want a function not only to "compute and return", but to write the result into an existing variable. This is called an output parameter. We're not diving into design "how best to return multiple values" today, but the technique at the level of references is useful to see.

Example: a function tries to find the first task that contains a substring. If found — it writes the index into out_index and returns true, otherwise returns false.

#include <string>
#include <vector>

bool find_task(const std::vector<std::string>& tasks,
               const std::string& query,
               std::size_t& out_index) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].find(query) != std::string::npos) {
            out_index = i;
            return true;
        }
    }
    return false;
}

Usage:

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

int main() {
    std::vector<std::string> tasks{"buy milk", "learn c++", "sleep"};
    std::size_t idx = 0;

    if (find_task(tasks, "c++", idx)) {
        std::cout << idx << '\n'; // 1
    }
}

The meaning of std::size_t& out_index here is simple: "if found — I'll write the answer here". Again, the contract is readable from the signature.

If you mix up T& and const T&

A very practical point: beginners often put T& "just in case", then unexpectedly change data "outside" and catch strange effects.

Imagine you wrote a "print tasks" function but accidentally forgot const:

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

void print_tasks_wrong(std::vector<std::string>& tasks) {
    tasks.push_back("oops");          // unexpectedly changed the list
    std::cout << tasks.size() << '\n';
}

This is an example of a bad contract: the function's name says "prints", but it actually modifies the data. Such things turn the program into a detective story where the culprit is you from two weeks ago.

The opposite mistake also happens: you wanted to modify the object but accidentally wrote const T& — and the compiler forbade you. That's actually a pleasant case: better the compiler complains immediately than the program "silently does the wrong thing".

Mini skeleton main.cpp

To make it easier to connect examples into one application, here's a small skeleton where you can insert functions. It's not a "final architecture", but it already resembles a tidy learning project with a thin main.

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

void print_tasks(const std::vector<std::string>& tasks);
void add_task(std::vector<std::string>& tasks, const std::string& text);

int main() {
    std::vector<std::string> tasks;

    add_task(tasks, "learn references");
    add_task(tasks, "drink tea");
    print_tasks(tasks);

    return 0;
}

And the expected output can be:

// Tasks: 2
// 0: learn references
// 1: drink tea

7. Common mistakes when working with T& and const T&

Mistake #1: using T& "just in case" while the function shouldn't modify the argument.
This is the most common problem because it seems that "reference is faster" and "this is more correct". As a result you get a function that by contract can modify the argument, and the reader of the code must expect surprises. If you do not modify the object — choose const T& (for large types) or pass-by-value (for small types).

Mistake #2: trying to bind T& to a temporary value and being surprised by a compilation error.
For example, f(std::string("hi")) with f(std::string&) will not compile. This is not the language being capricious, but a protection against binding to an object that will disappear "in a moment". If you want to accept temporaries and only read them — use const T&.

Mistake #3: thinking that const is "just for beauty" and removing it without reason.
const in const T& is a contract. It's not only for humans but also for the compiler: it forbids accidental modification of the argument. If you remove const, you literally turn off the seatbelt because "it's more convenient to reach into the glovebox".

Mistake #4: writing const T x instead of const T& x, thinking "this won't copy".
The parameter const T x still creates a copy — just an "immutable copy". This is sometimes useful, but if the goal was to avoid copying std::string/std::vector, you need exactly const T&.

Mistake #5: writing functions that unexpectedly modify via reference without any hint in the name.
If a function modifies an argument via T&, this should be visible from the signature (it already is), but it's also desirable to be visible from the name. Otherwise the next reader of the code (including your future self) will run the program "just in case" and check whether another surprise is hiding there.

1
Task
C++ SELF, level 15, lesson 1
Locked
Adjust the counter
Adjust the counter
1
Task
C++ SELF, level 15, lesson 1
Locked
Character Counter
Character Counter
1
Task
C++ SELF, level 15, lesson 1
Locked
Remove One Space from the Ends
Remove One Space from the Ends
1
Task
C++ SELF, level 15, lesson 1
Locked
First match
First match
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION