Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Parameter Passing Conventions
Design function parameters for input-only, output-only, or bidirectional data flow.
Functions and their callers communicate through two primary mechanisms: parameters and return values. When a function is invoked, the caller supplies arguments that the function receives through its parameters. These arguments can be transmitted by value, reference, or address.
Typically, arguments are passed by value or by const reference. However, there are situations where alternative approaches are necessary.
In parameters
In most scenarios, a function parameter serves solely to receive input from the caller. Parameters used exclusively for receiving input from the caller are called in parameters.
#include <iostream>
void display(int value) // value is an in parameter
{
std::cout << value << '\n';
}
void display(const std::string& message) // message is an in parameter
{
std::cout << message << '\n';
}
int main()
{
display(42);
std::string text{ "Learning C++" };
display(text);
return 0;
}
In-parameters are typically passed by value or by const reference.
Out parameters
A function argument passed by non-const reference (or by pointer-to-non-const) enables the function to modify the value of an object passed as an argument. This provides a mechanism for a function to return data to the caller when using a return value alone is insufficient.
A function parameter used exclusively for returning information to the caller is called an out parameter.
For example:
#include <cmath>
#include <iostream>
// minResult and maxResult are out parameters
void calculateRange(double value1, double value2, double& minResult, double& maxResult)
{
minResult = (value1 < value2) ? value1 : value2;
maxResult = (value1 > value2) ? value1 : value2;
}
int main()
{
double minimum{ 0.0 };
double maximum{ 0.0 };
double first{};
std::cout << "Enter first number: ";
std::cin >> first;
double second{};
std::cout << "Enter second number: ";
std::cin >> second;
// calculateRange will return the min and max in variables minimum and maximum
calculateRange(first, second, minimum, maximum);
std::cout << "The minimum is " << minimum << '\n';
std::cout << "The maximum is " << maximum << '\n';
return 0;
}
This function has two parameters (value1 and value2, passed by value) as input, and "returns" two parameters (by reference) as output.
We've named these out parameters with the suffix "Result" to indicate they're out parameters. This helps remind the caller that the initial value passed to these parameters doesn't matter, and that we should expect them to be overwritten. By convention, output parameters are typically the rightmost parameters.
Let's examine how this works in detail. First, main() creates local variables minimum and maximum. These are passed into function calculateRange() by reference (not by value). This means calculateRange() has access to the actual minimum and maximum variables in main(), not just copies. calculateRange() accordingly assigns new values to minimum and maximum (through references minResult and maxResult respectively), overwriting the old values. Function main() then prints these updated values.
If minimum and maximum had been passed by value instead of reference, calculateRange() would have changed copies of minimum and maximum, with any changes being discarded at the function's end. But because minimum and maximum were passed by reference, any changes made to them (through the references) persist beyond the function. We can therefore use this mechanism to return values to the caller.
Out parameters have awkward usage syntax
Out-parameters, while functional, have several downsides.
First, the caller must instantiate (and initialize) objects and pass them as arguments, even if it doesn't intend to use them initially. These objects must be assignable, meaning they can't be made const.
Second, because the caller must pass in objects, these values can't be used as temporaries, or easily used in a single expression.
The following example demonstrates both downsides:
#include <iostream>
int getByValue()
{
return 8;
}
void getByReference(int& result)
{
result = 8;
}
int main()
{
// return by value
[[maybe_unused]] int num1{ getByValue() }; // can use to initialize object
std::cout << getByValue() << '\n'; // can use temporary return value in expression
// return by out parameter
int num2{}; // must first allocate an assignable object
getByReference(num2); // then pass to function to assign the desired value
std::cout << num2 << '\n'; // and only then can we use that value
return 0;
}
As you can see, the syntax for using out-parameters is somewhat unnatural.
Out-parameters by reference don't clearly indicate argument modification
When we assign a function's return value to an object, it's clear that the object's value is being modified:
num1 = getByValue(); // obvious that num1 is being modified
This is beneficial, as it makes it clear we should expect num1's value to change.
However, consider the function call to calculateRange() in the previous example:
calculateRange(first, second, minimum, maximum);
It's not clear from this function call that first and second are in parameters, while minimum and maximum are out-parameters. If the caller doesn't realize that minimum and maximum will be modified, a semantic error will likely result.
Using pass by address instead of pass by reference can sometimes help make out-parameters more obvious by requiring the caller to pass in the address of objects as arguments.
Consider the following example:
void function1(int value); // pass by value
void function2(int& value); // pass by reference
void function3(int* value); // pass by address
int main()
{
int counter{};
function1(counter); // can't modify counter
function2(counter); // can modify counter (not obvious)
function3(&counter); // can modify counter
int* ptr{ &counter };
function3(ptr); // can modify counter (not obvious)
return 0;
}
Notice that in the call to function3(&counter), we must pass in &counter rather than counter, which helps make it clearer that we should expect counter to be modified.
However, this isn't foolproof, as function3(ptr) allows function3() to modify counter without requiring the caller to take the address-of ptr.
The caller may also think they can pass in nullptr or a null pointer as a valid argument when this is disallowed. And the function is now required to perform null pointer checking and handling, adding complexity. This need for added null pointer handling often causes more issues than simply using pass by reference.
For all these reasons, out-parameters should be avoided unless no other good options exist.
Avoid out-parameters (except in rare cases where no better options exist).
Prefer pass by reference for non-optional out-parameters.
In rare cases, a function will actually use the value of an out-parameter before overwriting its value. Such a parameter is called an in-out parameter. In-out-parameters work identically to out-parameters and have all the same challenges.
When to pass by non-const reference
If you're going to pass by reference to avoid making a copy of the argument, you should almost always pass by const reference.
In the following examples, we will use `DataType` to represent some type that we care about. For now, you can imagine `DataType` as a type alias for a type of your choice (e.g. `std::string`).
However, there are two primary cases where pass by non-const reference may be the better choice.
First, use pass by non-const reference when a parameter is an in-out-parameter. Since we're already passing in the object we need back out, it's often more straightforward and performant to just modify that object.
int main() { DataType info{}; processData(info); // info modified after this call, may not be obvious
return 0;
}
Giving the function a descriptive name can help:
```cpp
void updateData(DataType& data)
{
// modify data
}
int main()
{
DataType info{};
updateData(info); // info modified after this call, slightly more obvious
return 0;
}
The alternative is to pass the object by value or const reference (as per usual) and return a new object by value, which the caller can then assign back to the original object:
DataType processData(const DataType& input)
{
DataType result{ input }; // copy here
// modify result
return result;
}
int main()
{
DataType info{};
info = processData(info); // makes it obvious info is modified, but another copy made here
return 0;
}
This has the benefit of using a more conventional return syntax, but requires making 2 extra copies (sometimes the compiler can optimize one of these copies away).
Second, use pass by non-const reference when a function would otherwise return an object by value to the caller, but making a copy of that object is extremely expensive. Especially if the function is called many times in a performance-critical section of code.
void generateHugeData(DataType& result)
{
// modify result
}
int main()
{
DataType data{};
generateHugeData(data); // data modified after this call
return 0;
}
The most common example of the above is when a function needs to fill a large C-style array or `std::array` with data, and the array has an expensive-to-copy element type. We discuss arrays in a future chapter.
That said, objects are rarely so expensive to copy that resorting to non-conventional methods of returning those objects is worthwhile.
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!