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.

Best Practice
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
Best Practice
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.

Danger
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.