Parameter Passing Conventions
Design function parameters for input-only, output-only, or bidirectional data flow.
What Are In and Out Parameters?
Every parameter has a direction: the way information travels through it. A parameter that only carries information from the caller into the function is an in parameter. A parameter that only carries information from the function back to the caller is an out parameter. A parameter that does both, reading the value it arrived with and then replacing it, is an in-out parameter.
Direction is not a language feature. C++ has no in or out keyword, and the compiler never checks that you honoured your intent. Direction is a design decision, and the only thing you get to encode it with is how the parameter is declared.
| Direction | What the function does with it | How it is normally declared | The argument after the call |
|---|---|---|---|
| In | Reads the value, never writes to it | By value, or const T& (std::string_view for read-only strings) |
Unchanged |
| Out | Ignores whatever arrived, writes a result into it | T&, occasionally T* |
Replaced |
| In-out | Reads the incoming value, then writes over it | T&, occasionally T* |
Updated in place |
The rest of this lesson works through that table one row at a time, then explains why the bottom two rows are worth avoiding when you have a choice.
In parameters: the ordinary case
Most parameters you write are in parameters. The function needs a value, it reads that value, and the caller's object is none of its business:
#include <iostream>
#include <string_view>
void report(int quantity)
{
std::cout << "quantity: " << quantity << '\n';
}
void report(std::string_view label)
{
std::cout << "label: " << label << '\n';
}
int main()
{
report(12);
std::string_view heading{ "shipment ready" };
report(heading);
return 0;
}
Output:
quantity: 12
label: shipment ready
Both parameters are in parameters. quantity is passed by value because copying an int is trivial, and label is passed as a std::string_view because copying a string is not. Neither declaration gives the function any way to write back to the caller, which is exactly what you want when the direction is inward only.
Out parameters: returning through the parameter list
A return statement hands back one value. When a function has two results to deliver, one common workaround is to bind a reference to an object the caller already owns and assign to it:
#include <iostream>
void splitDuration(int totalSeconds, int& minutesResult, int& secondsResult)
{
minutesResult = totalSeconds / 60;
secondsResult = totalSeconds % 60;
}
int main()
{
int minutes{};
int seconds{};
splitDuration(245, minutes, seconds);
std::cout << "245 seconds is " << minutes << "m " << seconds << "s\n";
splitDuration(3600, minutes, seconds);
std::cout << "3600 seconds is " << minutes << "m " << seconds << "s\n";
return 0;
}
Output:
245 seconds is 4m 5s
3600 seconds is 60m 0s
splitDuration() has one in parameter and two out parameters. main() creates minutes and seconds, and because they are passed by reference rather than by value, minutesResult and secondsResult are bound to those very objects. The two assignments inside the function land on main()'s variables, so the new values are still there once the call finishes. Had the parameters been declared int minutesResult and int secondsResult, the function would have assigned to copies, and every trace of the work would have been discarded at the closing brace.
Two conventions make out parameters easier to spot in a signature. Give them a name that ends in something like Result or Out, which tells the caller that whatever they pass in will be overwritten and that its starting value is irrelevant. And put them last, so a signature reads inputs first and outputs afterwards.
The three costs the caller pays
The mechanism works. What follows is what it costs at every call site.
The caller must hand over a writable object first
A non-const reference has to bind to a modifiable object that already exists. The caller therefore has to declare and initialize something before the call, even when it has no interest in the value beforehand, and that something cannot be const or a temporary. Both of the calls below are errors, so this program will not compile:
void writeTripleOf(int base, int& tripleResult)
{
tripleResult = base * 3;
}
int main()
{
const int locked{ 21 };
writeTripleOf(14, locked);
writeTripleOf(14, 0);
return 0;
}
The first call is rejected with error: binding reference of type 'int&' to 'const int' discards qualifiers, and the second with error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'. Neither restriction is arbitrary: the function's whole purpose is to assign through that reference, and there is nothing durable to assign to in either case.
The result cannot be used where it is produced
A returned value is an expression, so it can initialize an object, feed an operator, or be printed on the spot. A value delivered through a parameter cannot do any of that, because it does not exist until the call has already finished:
#include <iostream>
int tripleOf(int base)
{
return base * 3;
}
void writeTripleOf(int base, int& tripleResult)
{
tripleResult = base * 3;
}
int main()
{
const int direct{ tripleOf(14) };
std::cout << "returned value: " << direct << '\n';
std::cout << "used inline: " << tripleOf(14) + 1 << '\n';
int viaParameter{};
writeTripleOf(14, viaParameter);
std::cout << "out parameter: " << viaParameter << '\n';
return 0;
}
Output:
returned value: 42
used inline: 43
out parameter: 42
The return-by-value version reaches the same answer in one line and can be stored in a const object. The out-parameter version needs three separate steps: declare, call, then read.
The call site says nothing about what changed
This line is unambiguous about which object is about to change:
tripled = tripleOf(14);
This one is not:
splitDuration(245, minutes, seconds);
Nothing in that call distinguishes the argument that is read from the two arguments that are overwritten. A reader has to go and look at the declaration of splitDuration() to find out. A caller who assumes minutes and seconds survive the call unchanged has written a semantic error that the compiler is perfectly happy with.
Does passing by address make the change visible?
Pass by address is sometimes offered as a fix, on the grounds that the caller has to write & at the call site:
#include <iostream>
void inspectOnly(int tally)
{
std::cout << "inspected " << tally << '\n';
}
void bumpByReference(int& tally)
{
tally += 5;
}
void bumpByAddress(int* tally)
{
*tally += 5;
}
int main()
{
int tally{ 100 };
inspectOnly(tally);
bumpByReference(tally);
bumpByAddress(&tally);
int* handle{ &tally };
bumpByAddress(handle);
std::cout << "tally is now " << tally << '\n';
return 0;
}
Output:
inspected 100
tally is now 115
Three of those four calls modify tally, and only bumpByAddress(&tally) announces it. The & is a genuine hint, but it is a weak one. As soon as the caller already holds a pointer, bumpByAddress(handle) looks exactly as harmless as bumpByReference(tally) and the hint disappears.
Pass by address also brings a problem of its own. A pointer parameter can be null, so callers may reasonably assume nullptr is an acceptable argument, and the function now has to check for it and decide what to do. That extra handling usually causes more trouble than the small gain in visibility is worth.
Avoid out parameters except in the rare case where no better option exists. When an out parameter is unavoidable and the argument is required, prefer pass by reference over pass by address.
In-out parameters
Occasionally a function reads a parameter's incoming value and then writes over it. That is an in-out parameter, and it behaves exactly like an out parameter, including every cost listed above:
#include <iostream>
void applyDiscount(double rate, double& price)
{
price -= price * rate;
}
double discountedCopy(double rate, double price)
{
return price - price * rate;
}
int main()
{
double ticketPrice{ 80.0 };
applyDiscount(0.25, ticketPrice);
std::cout << "after in-out call: " << ticketPrice << '\n';
ticketPrice = discountedCopy(0.25, ticketPrice);
std::cout << "after return-by-value call: " << ticketPrice << '\n';
return 0;
}
Output:
after in-out call: 60
after return-by-value call: 45
Both functions do the same arithmetic. applyDiscount() edits the caller's object in place, and the call site gives no hint of it. discountedCopy() takes a copy, returns a new value, and the assignment makes the change obvious, at the price of one extra copy of the argument and one of the result.
When a non-const reference is the right choice
If you are passing by reference purely to avoid a copy, pass by const reference. There are two situations where dropping the const is justified.
The first is a genuine in-out parameter. The object has to go in and come back out anyway, so modifying it in place is both simpler and cheaper than copying it twice. Naming the function so the modification is expected does a lot of the work that the call site cannot do on its own: applyDiscount(0.25, ticketPrice) reads far better than a name like computeDiscount would.
The second is a function that would otherwise return an expensive object by value, called often enough that the copies matter. Letting the caller supply the object once and reusing it avoids allocating a fresh one on every call:
#include <iostream>
#include <string>
void appendPackingList(std::string& buffer)
{
buffer += "crate-a;crate-b;crate-c;";
}
int main()
{
std::string manifest{};
appendPackingList(manifest);
appendPackingList(manifest);
std::cout << manifest << '\n';
std::cout << "length: " << manifest.length() << '\n';
return 0;
}
Output:
crate-a;crate-b;crate-c;crate-a;crate-b;crate-c;
length: 48
manifest is created once and then filled by repeated calls. A version returning a std::string by value would build and hand back a separate string on every call, which the caller would then have to join onto what it already had.
This pattern earns its keep most often when a function fills a large array whose elements are expensive to copy, which is a case we reach in a later chapter. Outside of that, objects are seldom costly enough to justify abandoning a normal return value.
Choosing how to hand a result back
Work down this list and stop at the first option that fits:
- One result, always available: return it by value.
- A result that may legitimately be absent: return
std::optional, which the next lesson covers. - Several results: prefer a function that returns one of them, or split the work across two functions. Once we reach structs, bundling the results into a single returned object beats a row of out parameters.
- A large object that the caller can supply and reuse, filled repeatedly in hot code: pass a non-const reference.
Out parameters sit below all of these. Reach for them when the alternatives genuinely do not apply, not as a default way of returning more than one thing.
Summary
Direction is a design intent you express through the declaration, not something the compiler enforces. In parameters carry data inward and are passed by value or by const reference. Out parameters carry data back to the caller through a non-const reference or a pointer to non-const. In-out parameters are read first and then overwritten.
Out parameters cost the caller in three ways: a writable, non-const object must exist before the call; the result cannot be used in the expression that produces it; and nothing at the call site reveals which arguments are about to change. Passing by address makes the modification slightly more visible thanks to the & at the call site, but the hint vanishes when a pointer variable is passed, and null handling is added to the function's job.
Prefer returning by value. Keep non-const reference parameters for genuine in-out cases and for objects whose copies are expensive enough to measure.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Parameter Passing Conventions - Quiz
Test your understanding of the lesson.
Practice Exercises
In and Out Parameters
Understand the different types of function parameters: in-parameters for input, out-parameters for output, and in-out parameters for both. Learn when to use each and their tradeoffs.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!