C++ / FUNCTIONS
Pass by value, reference, and const reference
Choose between by-value, reference, and const-reference parameters in C++ by reasoning about copies, mutation, and what each one can bind.
What you will learn
- Pick by value, T&, or const T& based on copy cost, mutation, and argument kind
- Predict from a signature alone whether a copy constructor runs on each call
- Explain why literals and temporaries bind to const T& but not to T&
- Default to by value for int/double/pointers and const T& for string/vector
Understanding Pass by value, reference, and const reference
A parameter is a local variable initialized from the argument, and the parameter's declared type decides how that initialization happens. void f(Tag t) initializes a brand-new Tag by copying the argument, so the copy constructor runs and everything f does afterwards happens to f's own object. That is why a by-value parameter can never change what the caller passed: the link is severed the moment the copy is made.
void f(Tag& t) initializes a reference rather than an object, so t is simply another name for the caller's Tag; no constructor runs and t has the same address as the argument. Because writes through t land on the caller's object, the language demands a modifiable lvalue, and f(makeTag()) or f("literal") is rejected: writing into something about to be destroyed would silently throw the work away. A non-const reference parameter is therefore a visible promise in the signature that the function may modify its argument.
const Tag& is the read-only view: still no copy, writes through it are rejected at compile time, and in exchange it accepts temporaries and literals, whose lifetime is stretched to the end of the full expression containing the call. The practical rule falls straight out of cost: a reference is a pointer-sized indirection, so types that fit in a register or two (int, double, pointers, std::string_view) are cheapest by value, while types that own memory (std::string, std::vector, most classes) are cheapest by const reference when you only read them. And const restricts the access path, not the object: if another parameter or a global names the same object, it can still change under you.
<iostream>
<string>
<utility>
struct Tag {
std::string name;
Tag(std::string n) : name(std::move(n)) {}
Tag(const Tag& other) : name(other.name) {
std::cout << "copied " << name << "\n";
}
};
void byValue(Tag t) { t.name += "!"; }
void byRef(Tag& t) { t.name += "!"; }
void byConstRef(const Tag& t) { std::cout << "read " << t.name << "\n"; }
int main() {
Tag a{"alpha"};
byValue(a);
std::cout << "after byValue: " << a.name << "\n";
byRef(a);
std::cout << "after byRef: " << a.name << "\n";
byConstRef(a);
std::cout << "after byConstRef: " << a.name << "\n";
}
A parameter is initialized from the argument: by value copies it into a private object, T& gives the caller's object a second name, and const T& gives a no-copy read-only view.
Worked examples
What each parameter kind accepts
Shows that a const reference parameter binds to temporaries and literals while a plain reference parameter does not.
<iostream>
<string>
void shout(std::string& s) { std::cout << "ref: " << s << "\n"; }
void whisper(const std::string& s) { std::cout << "cref: " << s << "\n"; }
int main() {
std::string name = "ada";
shout(name); // lvalue: fine
whisper(name); // lvalue: also fine
whisper("grace"); // temporary std::string bound to const&
whisper(name + "!"); // temporary result of +, alive until whisper returns
// shout("grace"); // error: cannot bind non-const lvalue ref to a temporary
}
Example explained
Line 1shout(name) binds s to name itself, so the two identifiers refer to one std::string object.
Line 2whisper("grace") first materializes a temporary std::string from the char array, binds the const reference to it, and destroys it after the call finishes.
Line 3The commented shout("grace") is rejected because a non-const lvalue reference cannot bind a temporary: any modification would be discarded, so the language forbids the call outright.
By value as a deliberate choice
Demonstrates taking a heavy type by value when the function genuinely needs its own mutable copy.
<algorithm>
<iostream>
<vector>
// By value on purpose: this function needs a copy it is allowed to rearrange.
std::vector<int> sortedCopy(std::vector<int> v) {
std::sort(v.begin(), v.end());
return v;
}
int main() {
std::vector<int> data{3, 1, 2};
std::vector<int> s = sortedCopy(data);
std::cout << data[0] << data[1] << data[2] << " "
<< s[0] << s[1] << s[2] << "\n";
}
Example explained
Line 1sortedCopy(data) copy-initializes v from data, so std::sort rearranges v only and data still holds 3 1 2.
Line 2return v hands out the local vector by move (or elides it entirely), so the whole call costs one copy rather than two.
Line 3Declaring the parameter const std::vector<int>& and then copying into a local inside the body would do identical work with more code, so the by-value parameter is the honest signature here.
const reference does not mean unchanging
Shows a const reference parameter whose object changes during the call because another parameter aliases it.
<iostream>
void scaleAndReport(int& value, const int& factor) {
value *= factor;
std::cout << "value=" << value << " factor=" << factor << "\n";
}
int main() {
int a = 3, b = 4;
scaleAndReport(a, b);
int c = 5;
scaleAndReport(c, c); // both parameters name the same int
}
Example explained
Line 1In the first call value names a and factor names b, so a becomes 12 and b is untouched.
Line 2In the second call both references name c, so value *= factor computes c = c * c and the later read of factor sees 25.
Line 3const on factor forbids writing through factor; it says nothing about the object being stable, which is why aliasing can surprise you.
Important notes
A temporary bound to a const T& parameter is destroyed when the full expression containing the call ends, so saving ¶m or a reference to it for later leaves a dangling reference.
Top-level const on a by-value parameter, as in void f(const int n), only stops f from editing its own copy; it is not part of the function type, so void f(int) and void f(const int) declare the same function.
Common mistakes
Writing void reset(std::string s) { s.clear(); } and expecting the caller's string to be empty; s is a copy, the original is untouched, and the compiler issues no warning because the code is perfectly legal.
Declaring a read-only parameter as std::string& instead of const std::string&; print("hi") and print(first + last) then fail to compile, so every caller must first create a named variable for no reason.
Writing const int& or const char& out of habit; you pay a pointer-sized indirection to avoid copying four bytes, and the added aliasing can stop the optimizer from keeping the value in a register.
Try it yourself
Change, predict, then run
Write grow(std::string& s) that appends "..." and width(const std::string& s) that returns s.size(), and call both on a named string. Then call each one with the literal "hi" and explain in a comment which call the compiler rejects and why.
Open the C++ workspaceCheck your understanding
A function is declared void merge(std::vector<int>& out, const std::vector<int>& in) and the caller passes the same vector as both arguments. What does the const on in actually guarantee inside merge?
- The vector cannot change while merge runs, so every read through in is stable
- The compiler copies the argument bound to in, so merge sees a snapshot
- Only that merge will not write through the name in; the object can still change through out
- Nothing at all, because const is dropped when a reference binds to an lvalue
Show answer
const qualifies the access path, not the object: writes through out reach the same vector, so reads through in can observe new values and references into it can be invalidated. Option 0 is the common misreading of const T& as 'nobody will modify this' rather than 'I will not modify this through here', and option 1 is wrong because avoiding that copy is the entire reason const T& exists; the compiler never inserts a hidden one.