When you hand an argument to a function, C++ gives you three ways to do it: pass a copy, pass an alias to the original, or pass an address that points to the original. The choice changes whether the function can modify the caller's data, how much memory gets copied, and how the call site reads. This lesson covers all three, when to pick each one, and the bugs that show up when the choice is wrong.
The default in C++ is pass by value. The function gets its own copy of the argument, and anything it does to that copy stays inside the function. The caller's variable is untouched.
The function received a copy of cartItemPrice. Inside applyDiscount, the parameter price is a separate variable that happens to start with the same value. Reassigning it doesn't reach back into main. The original cartItemPrice keeps its value of 100.0.
For small built-in types like int, double, bool, and char, this is what you want. A copy of an int is eight bytes (or fewer) of memory movement, which is as cheap as taking a reference, and the function gets a safe scratch space to work in.
The memory layout: each function call gets its own region on the stack called a stack frame, and the parameter lives inside the callee's frame.
price and cartItemPrice live in separate stack frames. They start with the same value because the copy was made at the moment of the call, but they're two distinct objects. Writing to one has no effect on the other.
Things get more interesting when the type being copied isn't small. Consider passing a std::string:
This compiles, runs, and prints the expected output. But every time greetCustomer is called, the runtime allocates new memory for a duplicate string and copies the characters across. For a short name that's harmless. For a 50,000-character product description, you're copying 50,000 characters for no reason. The function only needed to read the string.
Passing a std::string or std::vector by value allocates new memory and copies every element. For a vector of 10,000 entries that's 10,000 element copies per call. Use const T& for read-only access to non-trivial types.
The central trade-off with pass by value: it's safe (the function can't damage the caller's data) and clear (the call site looks like every other argument), but it can be expensive for large objects, and it can't be the answer when the function needs to modify the caller's variable.
Pass by reference lets the function reach back into the caller's variable. The parameter is declared with an &, and instead of getting a copy, the function gets an alias: another name for the same object.
One character changed in the function signature (double became double&) and the behavior flipped. Now writes to price inside the function go directly to cartItemPrice in main. No copy was made.
The memory layout differs. There's still one double in main's stack frame, but the parameter inside applyDiscount doesn't have its own storage for the value. It refers to the caller's variable.
price and cartItemPrice are two names for the same memory. Writing price = ... writes to cartItemPrice. Reading price reads from cartItemPrice. There is no second double anywhere.
Pass by (non-const) reference fits when the function's job is to modify the caller's variable. A function that adjusts stock count, normalizes a name, or fills in an output parameter is what T& is for.
The function rewrites every uppercase letter to lowercase. Because email is a reference, the changes happen directly to customerEmail. The caller doesn't need to assign the result back.
Two things about references. First, the call site looks identical whether you're passing by value or by reference. Looking at applyDiscount(cartItemPrice) alone, you can't tell which one is happening. You have to read the function's signature to know. This is sometimes called out as a downside of references in C++: the modification is invisible at the call site. Pointers, covered next, are louder about it.
Second, a reference must refer to an existing object. You can't pass applyDiscount(100.0) to a function that takes double&. The compiler will reject it, because 100.0 is a temporary value with no permanent address for the reference to bind to:
g++ produces:
That error is the compiler protecting you. If the function modifies its parameter, modifying a temporary that's about to be destroyed has no observable effect. Better to fail at compile time than to do nothing useful at runtime.
The combination of "no copy" and "no modification" is what fits most non-trivial types. const T& provides that. The function gets an alias to the caller's object (no copy), and the const guarantees it can't write through that alias.
Compare this to the earlier pass-by-value version. The behavior is identical from the caller's view, but no string copy happens. For a long string, that's a meaningful saving. For a std::vector<Product> with thousands of entries, it's much larger.
const T& is the default for any non-trivial type you only need to read. It costs the same as a pointer (one address) and protects against accidental modification.
const matters here. Without it, the function could still write to the caller's variable, which defeats the point of "read-only". Add const and the compiler enforces the contract:
g++ rejects this with:
The compiler holds the function to its promise. If a function takes a const reference, neither the function nor its caller needs to worry about the caller's variable changing.
const T& has one more advantage over a plain T&: it can bind to temporaries. Recall that applyDiscount(100.0) didn't compile for a double& parameter. With a const double&, it does:
Both calls pass temporary values, and both work. The temporary lives long enough for the function to read it, and because the function promised not to modify it (via const), there's no risk of writing to a doomed object.
Picking between T, T&, and const T& for a parameter type comes down to a short decision tree.
| Goal | Use |
|---|---|
Read a small built-in type (int, double, char, bool) | T (pass by value) |
Read a non-trivial type without copying (std::string, std::vector, classes) | const T& |
| Modify the caller's variable | T& |
| Accept "no value" as a possibility | T* (pointer, covered next) |
That table covers the common cases. For small types, value is cheap and the safety from the copy is worth it. For non-trivial types, const T& is the cheap-and-safe default. T& is for the rarer case where modification is the point of the function. A pointer takes a similar role to a reference but adds the option of representing "no object at all".
A pointer parameter takes the address of the caller's variable. Inside the function, you dereference it with * to read or write the underlying object. The full mechanics of pointers (creation, arithmetic, what nullptr means in depth) are covered later. Here the focus is how they show up as function parameters.
Three things changed from the reference version. The parameter is double* instead of double&. The call site is applyDiscount(&cartItemPrice) instead of applyDiscount(cartItemPrice), with an explicit & to take the address. And inside the function, every use of price is wrapped in * to dereference it.
The memory layout is similar to references, but with an explicit pointer object holding the address.
The pointer parameter itself is a small object (usually 8 bytes on a 64-bit machine) that holds the address of cartItemPrice. Dereferencing it reads or writes the original double in main.
Why use a pointer when a reference does almost the same thing with less syntax? For new code, you usually shouldn't. References are simpler, safer, and the modern C++ default for non-owning function parameters. Pointers fit three situations.
First, when "no value" is a meaningful option. A reference must always refer to something. A pointer can be nullptr, which is a legal value the function can check for. If a function takes an optional output parameter, a pointer makes the optionality explicit.
The first call passes nullptr to mean "no discount info". The second passes the address of a real double. The function checks the pointer before dereferencing, so both calls are safe.
Second, when the call site needs to advertise that modification might happen. A reference modification is invisible at the call site: process(cart) could either read or modify cart. A pointer modification has an explicit &: process(&cart) signals that the function might write to cart. Some code styles prefer pointers for output parameters for this reason. The C++ Core Guidelines lean the other way (use references when you can, pointers when you must), but both styles appear in real code.
Third, when interfacing with C code or older C++ APIs that take pointers. References don't exist in C, so any function declared in a C header that wants to modify a variable takes a pointer. New C++ APIs lean toward references, but pointer-based signatures are common when reading library code.
A pointer parameter must be checked for nullptr before dereferencing if there's any chance the caller passed null. Dereferencing a null pointer is undefined behavior, and on most systems it crashes the program with a segmentation fault.
If the caller will never pass nullptr, a reference parameter is a better fit. The compiler will then enforce that the argument is a real object, and you don't need the null check.
A more application-flavored example pulls all three styles together. A small Cart struct with three functions, one for each passing style, that do something appropriate for that style.
Three different signatures, three different intents. printCart reads the cart without modifying it, and uses const Cart& so no copy of the vector or string happens. addItem mutates the cart by appending to its prices vector, so it takes Cart&. hasMoreItemsThan is a slightly contrived case, but it shows pass by value: the function gets its own copy, and any sorting, modification, or scratch work it does inside doesn't touch the caller's cart.
That hasMoreItemsThan example is also a teaching point. It works, but the copy is wasteful. The function only needs to read cart.prices.size(), so const Cart& would be a better choice. This is covered in the common-mistakes section coming up.
A Cart with a vector of 10,000 prices and a long customer name is several hundred kilobytes when copied by value. Passing it by const Cart& is 8 bytes (a single address). The difference is real, especially in code that's called in a loop.
Each of the three passing styles has its own bug pattern. Knowing the shape of the mistake makes it easier to spot in code review.
The most common performance bug is taking a big object by value when only reading is needed.
What's wrong with this code?
The function only reads prices, but the parameter type is std::vector<double>, which means the entire vector gets copied on every call. For a vector of 1,000,000 entries, that's a megabyte of memory allocation and 1,000,000 element copies, every call.
Fix:
const std::vector<double>& is the correct signature. The function still reads the vector the same way, but no copy happens, and the const guarantees the function can't accidentally modify the caller's vector.
A non-const reference can't bind to a temporary. The compiler will reject the call, which is good. The trickier case is storing a const reference to a temporary and outliving it, which the compiler doesn't catch.
"Welcome!" is a temporary string created inside the function. The function returns a reference to it, but the temporary is destroyed when the function returns. The caller gets a reference to memory that no longer holds the string. Reading through that reference is undefined behavior.
g++ will warn:
The function shouldn't return a reference here. Either return by value (a std::string instead of a const std::string&), or return a reference to an object that outlives the function (a global, a static, or a parameter).
The same pattern in parameter form:
Here the temporary survives long enough for process to use it, because temporaries bound to a const reference live until the end of the full expression. That's safe. The unsafe version is storing the reference somewhere that outlives the temporary.
A pointer parameter can point to nothing (nullptr) or to something that no longer exists (a dangling pointer). Both are undefined behavior when dereferenced.
On most systems this segfaults immediately, but the standard says "undefined behavior", which means the compiler is allowed to assume it never happens. Always check pointer parameters for nullptr before dereferencing, unless the function's contract documents that null is forbidden.
Fix:
Or, better, switch to a reference parameter if nullptr should never be a valid input:
A reference parameter can't be null. The compiler enforces that at the call site, so the function body doesn't need a null check.
A const T& parameter is read-only. Writing through it is a compile error, not a runtime bug.
g++ rejects this with:
That's the point of const: the compiler enforces the read-only contract. The fix depends on intent. If the function should modify the caller's variable, drop the const. If it should compute a new value without modifying anything, return the new value:
This is often the right shape for "read input, produce output" functions.
To make the differences concrete, the same function written three times, each with a different parameter style, called with the same cart object:
The pass-by-value function clears its own copy, so myCart keeps its three items afterward. The const-reference function only reads, so myCart is untouched. The non-const reference function clears the caller's vector, and myCart.prices is empty after the call. Three flavors of the same signature, three different effects on the caller.
The cost is also different. The by-value call copies the entire cart (string + vector of three doubles + their values). The two reference calls pass a single address, regardless of how big the cart is. For a cart with thousands of items, the by-value call would do far more work, and most of that work would be discarded the moment the function returned.
10 quizzes