Struct Parameter and Return Techniques
Pass structs to functions by value or reference and return them from functions.
What Is Passing and Returning Structs?
A struct is a single object, so it crosses a function boundary as a single thing: one parameter going in, one return value coming out. That is the whole idea, and it is what makes structs worth defining in the first place.
Think of any function as a boundary with two crossings:
| Crossing | Without a struct | With a struct |
|---|---|---|
| Into the function | One parameter per attribute | One parameter, however many members there are |
| Out of the function | One result only, so anything further needs an out-parameter | One return value carrying every member |
This lesson takes the two crossings in turn: how to write the argument going in, how to write the value coming out, and what the lifetime rules are for the unnamed objects that show up on both sides.
The Parameter List Problem
Here is a temperature reading described by three loose variables and handed to a function:
#include <iostream>
void printData(int deviceId, double celsius, int signalStrength)
{
std::cout << "Device " << deviceId << ": " << celsius
<< " C, signal " << signalStrength << '\n';
}
int main()
{
int deviceId{ 201 };
double celsius{ 15.2 };
int signalStrength{ 78 };
printData(deviceId, celsius, signalStrength);
return 0;
}
Device 201: 15.2 C, signal 78
Three parameters is tolerable. Ten or twelve is not, and the parameter list is only half the problem: with three arguments in a fixed order, nothing stops a caller from swapping deviceId and signalStrength, since both are int. The compiler will happily accept the mistake.
The maintenance cost is worse still. Every attribute you later add to the reading forces an edit to the declaration, the definition, and every call site.
Crossing In: A Const Reference Parameter
Collect the members into a struct and the function needs exactly one parameter. Take it by const reference so the struct is not copied and the function cannot alter the caller's object:
#include <iostream>
struct TemperatureSensor
{
int deviceId{};
double celsius{};
int signalStrength{};
};
void printData(const TemperatureSensor& sensor)
{
std::cout << "Device " << sensor.deviceId << ": " << sensor.celsius
<< " C, signal " << sensor.signalStrength << '\n';
}
int main()
{
TemperatureSensor outdoor{ 201, 15.2, 78 };
TemperatureSensor indoor{ 202, 22.8, 92 };
printData(outdoor);
printData(indoor);
return 0;
}
Device 201: 15.2 C, signal 78
Device 202: 22.8 C, signal 92
The argument order problem disappears with the parameter list: printData(outdoor) cannot get its members out of order, because the members travel together inside one object.
Pass structs by const reference. A copy costs as much as the struct is large, and a const reference costs the same regardless of size while promising the caller the object comes back untouched.
Adding a Member Changes Nothing at the Call Site
The claim that a struct parameter absorbs future members is easy to check. Add humidity to the struct and print it:
#include <iostream>
struct TemperatureSensor
{
int deviceId{};
double celsius{};
int signalStrength{};
double humidity{}; // added after printData() was already written
};
void printData(const TemperatureSensor& sensor)
{
std::cout << "Device " << sensor.deviceId << ": " << sensor.celsius
<< " C, signal " << sensor.signalStrength
<< ", humidity " << sensor.humidity << '\n';
}
int main()
{
TemperatureSensor outdoor{ 201, 15.2, 78, 61.5 };
printData(outdoor); // this call is unchanged
return 0;
}
Device 201: 15.2 C, signal 78, humidity 61.5
The signature of printData() is byte for byte what it was, and so is the call. What changed is the struct definition, the one line of the body that prints the new member, and the initialiser that supplies a value for it. Had the reading still been three loose parameters, the declaration, the definition, and every call site in the program would all have needed editing.
Writing the Argument Without Naming It
outdoor and indoor earned their names: they record which sensor each reading came from, which the numbers alone do not say. A struct built purely to be handed straight into one call earns nothing, and naming it splits a single idea across two statements. A temporary object is the alternative, an object with no identifier at all, created inside the expression that uses it.
There are two spellings:
#include <iostream>
struct TemperatureSensor
{
int deviceId{};
double celsius{};
int signalStrength{};
};
void printData(const TemperatureSensor& sensor)
{
std::cout << "Device " << sensor.deviceId << ": " << sensor.celsius
<< " C, signal " << sensor.signalStrength << '\n';
}
int main()
{
printData(TemperatureSensor { 201, 15.2, 78 }); // type spelled out
printData({ 202, 22.8, 92 }); // type deduced from the parameter
return 0;
}
Device 201: 15.2 C, signal 78
Device 202: 22.8 C, signal 92
| Spelling | How the type is determined | Notes |
|---|---|---|
TemperatureSensor { 201, 15.2, 78 } |
You state it | Unambiguous, and readable at a glance. Prefer this one |
{ 202, 22.8, 92 } |
Deduced from the parameter's type | Counts as an implicit conversion, so it is rejected wherever only explicit conversions are allowed |
Name the type when you build a temporary struct as an argument. The braces alone are shorter, but a reader has to look up the function's signature to learn what is being constructed.
How Long a Temporary Lives
A temporary is created and initialised where it appears, and it dies as soon as the full expression containing it finishes. Inside printData({ 202, 22.8, 92 }) that is more than enough: the object outlives the call it was made for.
Because it has no name, a temporary is an rvalue, and that restricts which parameters it will bind to:
| Parameter form | Accepts a temporary struct? |
|---|---|
By value, TemperatureSensor |
Yes, the temporary initialises the copy |
By const reference, const TemperatureSensor& |
Yes, the reference binds to the temporary |
By non-const reference, TemperatureSensor& |
No |
By address, TemperatureSensor* |
No, there is no named object to take the address of |
The last two are refusals with a purpose. Writing into a temporary would be work thrown away, since nothing can observe the object after the expression ends.
The next snippet is deliberately broken:
struct TemperatureSensor
{
int deviceId{};
double celsius{};
int signalStrength{};
};
void recalibrate(TemperatureSensor& sensor)
{
sensor.celsius += 0.4;
}
void sweep()
{
recalibrate({ 203, 19.7, 88 });
}
s.cpp: In function 'void sweep()':
s.cpp:15:16: error: cannot bind non-const lvalue reference of type 'TemperatureSensor&' to an rvalue of type 'TemperatureSensor'
15 | recalibrate({ 203, 19.7, 88 });
| ~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~
A function that modifies its argument needs a named object to modify, which is exactly what the error is asking for.
Crossing Out: Returning a Struct
A function returns one value, and that has always been the constraint that made multi-part results awkward. A struct dissolves it, because one value can be an object with as many members as you like.
A position fix has three parts. Bundle them and the function returns all three:
#include <iostream>
struct GpsCoordinate
{
double latitude{};
double longitude{};
double altitude{};
};
GpsCoordinate describeFix()
{
GpsCoordinate fix{ 51.5074, -0.1278, 24.5 };
return fix;
}
int main()
{
GpsCoordinate landingSite{ describeFix() };
std::cout << "lat " << landingSite.latitude
<< ", lon " << landingSite.longitude
<< ", alt " << landingSite.altitude << " m" << '\n';
return 0;
}
lat 51.5074, lon -0.1278, alt 24.5 m
The local fix is copied out to the caller, where it initialises landingSite. Note the asymmetry with the parameter side: going in we chose a reference to avoid a copy, but coming out a reference is the one thing we must not use.
Never Return a Reference to a Local
fix is destroyed when describeFix() returns. A reference to it would designate an object that no longer exists, and reading through that reference is undefined behaviour.
The next snippet is deliberately broken:
struct GpsCoordinate
{
double latitude{};
double longitude{};
double altitude{};
};
const GpsCoordinate& describeFix()
{
GpsCoordinate fix{ 51.5074, -0.1278, 24.5 };
return fix;
}
s.cpp: In function 'const GpsCoordinate& describeFix()':
s.cpp:11:12: warning: reference to local variable 'fix' returned [-Wreturn-local-addr]
11 | return fix;
| ^~~
Note that this is a warning, not an error. The program compiles and may even appear to work, which is what makes the bug expensive to find later. Return structs built inside a function by value.
Returning a reference or address of a local object leaves the caller holding a dangling reference. The compiler warns but still builds the program, and the resulting undefined behaviour can hide for a long time.
Three Ways to Write the Return Value
The fix variable in describeFix() never earned its name either: it is created, returned, and forgotten on the next line. The value can be written without naming it, exactly as an argument can, and because the return type is already declared the compiler can fill in more than it could at a call site:
#include <iostream>
struct GpsCoordinate
{
double latitude{};
double longitude{};
double altitude{};
};
GpsCoordinate describeFix()
{
return GpsCoordinate { 51.5074, -0.1278, 24.5 }; // type spelled out
}
GpsCoordinate deducedFix()
{
return { 48.8566, 2.3522, 35.0 }; // type taken from the return type
}
GpsCoordinate unknownFix()
{
return {}; // every member value-initialised
}
int main()
{
GpsCoordinate landingSite{ describeFix() };
GpsCoordinate cruiseFix{ deducedFix() };
GpsCoordinate fallback{ unknownFix() };
std::cout << landingSite.latitude << ' ' << landingSite.longitude << ' '
<< landingSite.altitude << '\n';
std::cout << cruiseFix.latitude << ' ' << cruiseFix.longitude << ' '
<< cruiseFix.altitude << '\n';
std::cout << fallback.latitude << ' ' << fallback.longitude << ' '
<< fallback.altitude << '\n';
return 0;
}
51.5074 -0.1278 24.5
48.8566 2.3522 35
0 0 0
| Return statement | What it does |
|---|---|
return GpsCoordinate { 51.5074, -0.1278, 24.5 }; |
Builds an unnamed GpsCoordinate and returns it, with no local variable involved |
return { 48.8566, 2.3522, 35.0 }; |
The same thing, with the type taken from the declared return type. This counts as an implicit conversion |
return {}; |
Value-initialises every member, so all three doubles come back as zero |
Empty braces do not mean "return nothing". They mean "return a GpsCoordinate with every member value-initialised", which for double members is zero, as unknownFix() shows.
From Structs to Classes
Everything here transfers directly. Classes, which the next chapters build up in earnest, are structs with access control and member functions layered on top. Data members, member selection with ., default member initialisers, passing by const reference, and returning by value all behave the same way once the type is a class. Time spent getting comfortable with structs is time already invested in classes.
Summary
One object, one parameter. Passing a struct replaces a parameter per attribute with a single parameter, and removes the chance of a caller putting same-typed arguments in the wrong order.
Pass by const reference. Structs go in as const T&: no copy on the way in, and a signature that tells the caller the object comes back unmodified.
New members are free. Adding a member to the struct leaves function declarations and call sites untouched, which is the maintenance advantage that separate parameters can never offer.
Temporary arguments. TemperatureSensor { 201, 15.2, 78 } states the type; { 201, 15.2, 78 } lets the compiler deduce it from the parameter. Prefer the explicit spelling.
Temporary lifetime and binding. A temporary lives until the end of its full expression and is an rvalue, so it binds to by-value and const reference parameters, and not to non-const references or addresses.
One value out, many members. Returning a struct by value is how a function delivers several results at once. Never return a reference to a struct declared inside the function; the object is gone before the caller can read it.
Shorthand returns. With an explicit return type, return { ... }; deduces the type and return {}; value-initialises every member.
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.
Struct Parameter and Return Techniques - Quiz
Test your understanding of the lesson.
Practice Exercises
Vector Math Operations
Create a program that works with 2D vectors using structs. Implement functions to add vectors and calculate magnitude, demonstrating passing structs by const reference and returning structs by value.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!