Trailing Return Types

An alternative syntax for defining function templates, which allows the return type to be based on their parameter types
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated

In our earlier lessons on template functions, we introduced scenarios where we don’t necessarily know what the return type of the instantiated function will be. This is because the specific return type will depend on the type of the arguments.

In many simple scenarios, we can get around this simply by setting the return type to auto, asking the compiler to figure it out based on our return statements:

#include <iostream>

template <typename T1, typename T2>
auto Multiply(T1 x, T2 y){ return x * y; }

int main(){
  std::cout << "Double: " << Multiply(2.4, 3.2);
  std::cout << "\nInt: " << Multiply(2, 3);
}
Double: 7.68
Int: 6

But in more complex scenarios, this is not enough.

The Problem with Return Types

Let's consider a simple, contrived addition to our function where we want to return a zero-initialized object based on a conditional check:

#include <iostream>

template <typename T1, typename T2>
auto Multiply(T1 x, T2 y){
  if (x == 0 || y == 0) return 0;
  return x * y;
}

int main(){
  std::cout << "Double: " << Multiply(2.4, 3.2);
}

In this situation, we now have one branch of our generated function returning an int, whilst another returns a double. With a defined return type, this is not necessarily a problem. The compiler can cast what is returned by the return statement into the function’s returned type.

But, with an auto return type, the compiler no longer knows what object it needs to return. And, when different branches of our function return different types, it doesn’t know what to do and throws an error:

all return expressions must deduce to the same type

We could try solving this problem by coercing our return value to one of our template types. For example, we could explicitly call a type constructor in our return statement:

template <typename T1, typename T2>
auto Multiply(T1 x, T2 y) {
  if (x == 0 || y == 0) return T1(0);
  return x * y;
}

Or we could replace the auto return type with one that matches one of our template parameters:

template <typename T1, typename T2>
T1 Multiply(T1 x, T2 y) {
  if (x == 0 || y == 0) return 0;
  return x * y;
}

This will let our program compile, but now the return type of our multiplication function, and therefore its arithmetic value, depends on the order of our parameters:

#include <iostream>

template <typename T1, typename T2>
T1 Multiply(T1 x, T2 y){
  if (x == 0 || y == 0) return 0;
  return x * y;
}

int main(){
  std::cout << "First: " << Multiply(2.1, 2);
  std::cout << "\nSecond: " << Multiply(2, 2.1);
}
First: 4.2
Second: 4

Additionally, these techniques aren’t always an option. They require our return type to be the same as one of our argument types.

That is not always possible. Were we to be multiplying matrices, the type of matrix that is returned is not necessarily the same type as either of the arguments.

For example, multiplying a 3x1 matrix by a 1x3 matrix results in a 3x3 matrix, which would be an entirely different type in almost any implementation:

int main(){
  Matrix<3, 1> A{1, 2, 3};
  Matrix<1, 3> B{4, 5, 6};
  Matrix<3, 3> C{Multiply(A, B)};
}

So, we need a better way to solve this problem.

decltype()

In our earlier lessons, we introduced decltype, which lets us deduce a type at compile time, based on an expression.

In this excessively contrived example, we show how we can use decltype to construct a type based on the types of our arguments:

auto Multiply(int x, int y) {
  using ReturnType = decltype(x * y);
  return ReturnType { x * y };
}

We can also remove the intermediate type alias if preferred:

auto Multiply(int x, int y) {
  return decltype(x * y) { x * y };
}

Within regular functions, this is rarely useful, but it becomes very helpful when writing function templates. For example, it can be used to solve the problem we introduced at the start of this section:

#include <iostream>

template <typename T1, typename T2>
auto Multiply(T1 x, T2 y) {
  if (x == 0 || y == 0) {
    return decltype(x * y){0};
  }
  return x * y;
}

int main(){
  std::cout << "First: " << Multiply(2.1, 2);
  std::cout << "\nSecond: " << Multiply(2, 2.1);

  auto ReturnValue{Multiply(0.0, 0.0)};
  std::cout << "\nZero-Initialization: "
    << ReturnValue
    << " ("
    << typeid(decltype(ReturnValue)).name()
    << ")";
}
First: 4.2
Second: 4.2
Zero-Initialization: 0 (double)

This works correctly, however, it is somewhat indirect. If we know the return type of a function, we’d ideally like that return type to be part of the function signature. Were we to attempt this decltype technique with the regular function syntax, we’d run into an issue, as the return type comes before the parameter list:

decltype(x * y) Multiply(int x, int y) {
  return x * y;
}

This results in the usual compilation error we’d get from trying to use an identifier before it was declared:

'x': undeclared identifier
'y': undeclared identifier

So, we need an alternative syntax that moves the function’s return type after the parameter list.

Using Trailing Return Types

The following shows a minimalist example of a function that uses a trailing return type. We use the auto keyword in place of the traditional return type. We then then place the return type between the function’s parameter list and the body, using an arrow -> syntax.

auto GetNumber() -> int {
  return 42;
}

When our function has parameters, it looks like this:

auto Add(int x, int y) -> int {
  return x + y;
}

The benefit of the trailing return type is that it now has access to those parameters. This lets the compiler infer the return type based on those values:

auto Add(int x, int y) -> decltype(x + y) {
  return x + y;
}

Function Templates

The trailing return type syntax is most useful within template functions, where we don’t necessarily know the parameter types when we’re creating the template function.

The following example fixes our earlier problem, by allowing us to correctly determine the return type based on an expression involving our parameters:

#include <iostream>

template <typename T1, typename T2>
auto Multiply(T1 x, T2 y) -> decltype(x * y){
  if (x == 0 || y == 0) return 0;
  return x * y;
}

int main(){
  std::cout << "First: " << Multiply(2.1, 2);
  std::cout << "\nSecond: " << Multiply(2, 2.1);

  auto ReturnValue{Multiply(0.0, 0.0)};
  std::cout << "\nZero-Initialization: "
    << ReturnValue
    << " ("
    << typeid(decltype(ReturnValue)).name()
    << ")";
}
First: 4.2
Second: 4.2
Zero-Initialization: 0 (double)

Naturally, this also scales to more complex types. Below, we’ve added a new class. Without modifying our template function at all, it still works as expected, returning a type that doesn’t match either of the argument types but is still derived from them.

Note that the following Matrix class template is nowhere near complete, as that’s not our focus. It is only implemented to the extent that demonstrates the behavior of our Multiply template function.

The class also uses static variables, which may be unfamiliar at this stage. We have a dedicated lesson on later in the course:

#include <iostream>

template <int R, int C>
class Matrix {
public:
  static const int Rows{R};
  static const int Cols{C};

  // Constructors
  Matrix(){}
  Matrix(int){}

  // Operators
  bool operator==(int){ return true; }

  template <typename T>
  auto operator*(T Other){
    return Matrix<Rows, Other.Cols>{};
  }
};

template <typename T1, typename T2>
auto Multiply(T1 x, T2 y) -> decltype(x * y){
  if (x == 0 || y == 0) return 0;
  return x * y;
}

int main(){
  Matrix<3, 1> A;
  Matrix<1, 3> B;
  auto Result{Multiply(A, B)};

  std::cout
    << "Rows: " << Result.Rows
    << "\nColumns: " << Result.Cols;
}
Rows: 3
Columns: 3

Member Functions

Within class methods, we can use trailing return types in the same way.

class Character {
public:
  auto GetName() -> std::string {
    return Name;
  }

private:
  std::string Name;
};

However, there are some interactions with qualifiers such as const, override, and final we need to note.

The const qualifier needs to go before the return type, whilst override and final appear after:

class Character {
public:
  virtual auto GetName() const -> std::string {
    return Name;
  }

  virtual auto GetLevel() const -> int {
    return Level;
  }

private:
  std::string Name;
  int Level;
};

class Monster : public Character {
public:
  auto GetName() const -> std::string override {
    // do monster things, then...
    return Character::GetName();
  }

  auto GetLevel() const -> int final {
    // do monster things, then...
    return Character::GetLevel();
  }
};

Using Trailing Return Types by Default

Within the community, the use of the trailing return type is gaining traction as the default way of defining functions, even in scenarios where it is not required.

There are two main arguments that supporters of the convention use.

Firstly, having the parameter types before the return type is often a more intuitive way of ordering information. It maps to how we typically think about functions ("this function accepts x and returns y") and also how functions behave (they use their parameters, and then they return).

The second reason is that, within a long list of function declarations, using a trailing return type causes the function names to visually align. This can make scanning class definitions to understand their capabilities a more pleasant experience:

// Before
class Character {
public:
  const std::string& GetName() const;
  void SetName(std::string);
  int GetLevel() const;
  void SetLevel(int);
  std::shared_ptr<Party> GetParty() const;
  void SetParty(std::shared_ptr<Party>);
};

// After
class Character {
public:
  auto GetName() const -> const std::string&;
  auto SetName(std::string) -> void;
  auto GetLevel() const -> int;
  auto SetLevel(int) -> void;
  auto GetParty() const -> std::shared_ptr<Party>;
  auto SetParty(std::shared_ptr<Party>) -> void;
};

This example also shows the main drawback of the trailing return type syntax - it simply requires more code. This is mostly due to the requirement that we need to include the auto keyword at the start of every declaration.

As such, there is generally no agreed recommendation here. Most teams will have their own agreed standard, otherwise, we should feel free to use whichever pattern we prefer.

Summary

This lesson introduced trailing return types, enabling more flexible function declarations, especially in templates and complex type deduction scenarios. The key topics we learned include:

  • The basics of trailing return types and their syntax, using auto and ->.
  • How to use trailing return types to overcome limitations of auto return type deduction in functions with conditional or complex returns.
  • The use of decltype in conjunction with trailing return types to accurately deduce and specify return types in template functions.
  • Implementing trailing return types in class methods, including interactions with qualifiers such as const, override, and final.
  • The advantages and considerations of adopting trailing return types as a standard practice, even in scenarios where they’re not required

Was this lesson useful?

Next Lesson

Recursion and Memoization

An introduction to recursive functions, their use cases, and how to optimize their performance
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated
Lesson Contents

Trailing Return Types

An alternative syntax for defining function templates, which allows the return type to be based on their parameter types

A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access

This course includes:

  • 125 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Recursion and Memoization

An introduction to recursive functions, their use cases, and how to optimize their performance
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved