Usability Improvement I: Automatic Type Deduction and Initialization

In previous lessons, we’ve already touched on some of the features introduced in C++11. In the next two lessons, we’ll focus on the usability improvements brought by modern C++ (C++11/14/17).

Automatic Type Deduction

If we were to pick the most significant change introduced in C++11, automatic type deduction would definitely rank in the top three. And if we’re only considering improvements in usability or expressiveness, then it absolutely deserves the top spot.

auto

Automatic type deduction means that the compiler can infer a variable’s type from the type of the expression assigned to it (and, starting from C++14, also deduce the return type of functions), so the programmer no longer needs to explicitly declare the type. It’s important to note that auto does not change the fact that C++ is a statically typed language—the type of a variable declared with auto is still determined at compile time; the compiler just fills it in for you.

Thanks to auto, verbose expressions like the following are a thing of the past:

// vector<int> v;
for (vector<int>::iterator it = v.begin(), end = v.end(); it != end; ++it) {
  // loop body
}

Now we can simply write (when not using range-based for loops):

for (auto it = v.begin(), end = v.end(); it != end; ++it) {
  // loop body
}

Without auto, if the container type is unknown, you’d also need to write typename (and if using const reference, you’d need to use const_iterator):

template <typename T>
void foo(const T& container) {
  for (typename T::const_iterator it = container.begin(), ... ) {
    // loop body
  }
}

And if begin() doesn’t return the container’s nested const_iterator type, then it’s not even possible to write this without auto. This is not hypothetical. For example, if you want your function to also support C-style arrays, you’d need two different overloads without auto:

template <typename T, std::size_t N>
void foo(const T (&a)[N]) {
  typedef const T* ptr_t;
  for (ptr_t it = a, end = a + N; it != end; ++it) {
    // loop body
  }
}

template <typename T>
void foo(const T& c) {
  for (typename T::const_iterator it = c.begin(), end = c.end(); it != end; ++it) {
    // loop body
  }
}

With auto, and the global begin and end functions introduced in C++11, we can unify both into one:

template <typename T>
void foo(const T& c) {
  using std::begin;
  using std::end;
  // Use argument-dependent lookup (ADL)
  for (auto it = begin(c), ite = end(c); it != ite; ++it) {
    // loop body
  }
}

This example shows how auto not only reduces verbosity but also improves abstraction, allowing us to write more generic code with less effort.

auto uses rules similar to template parameter deduction. When you write an expression using auto, it’s like matching it against a hypothetical function template. Examples:

  • auto a = expr; → matches template <typename T> f(T), so the result is the value type of expr.
  • const auto& a = expr; → matches template <typename T> f(const T&), giving a const lvalue reference type.
  • auto&& a = expr; → matches template <typename T> f(T&&), and based on reference collapsing rules, the result is a reference that matches the value category of expr.

decltype

decltype lets you obtain the type of an expression and use it as a type. It has two main use cases:

  • decltype(variableName) gives you the exact type of the variable.
  • decltype(expression) (when the expression is not just a variable name—or includes decltype((variableName))) gives the reference type of the expression, unless the expression is a pure rvalue (prvalue), in which case it gives the value type.

Examples:

int a;
decltype(a)       // int
decltype((a))     // int& (because 'a' is an lvalue)
decltype(a + a)   // int (because 'a + a' is a prvalue)

decltype(auto)

Usually, using auto makes code easier to write. However, there’s a limitation—you need to know whether the result should be a value or a reference at the time you write auto.

  • auto → value
  • auto& → lvalue reference
  • auto&& → forwarding reference (could be lvalue or rvalue)

auto alone can’t deduce whether the result is a reference or a value based on the expression’s type. However, decltype(expr) can.

You could write:

decltype(expr) a = expr;

But this is unsatisfying—especially if expr is long, and repeating code is always a potential problem. So, C++14 introduced the decltype(auto) syntax.

For the above, you can simply write:

decltype(auto) a = expr;

This is especially helpful in writing generic forwarding function templates, where you may not know whether the function you’re calling returns by reference or by value. This syntax handles both seamlessly.

Function Return Type Deduction

Starting from C++14, function return types can also be declared using auto or decltype(auto). As before, using auto yields a value type, while auto& or auto&& yields a reference type. Using decltype(auto) allows the return type to be deduced from the return expression—whether it’s a value or a reference.

Related to this is another syntax: trailing return type declaration. Strictly speaking, this isn’t exactly “type deduction,” but we’ll cover it here anyway. Its syntax looks like this:

auto foo(parameters) -> return_type
{
  // function body
}

This is typically used when the return type is complex or depends on the types of the parameters.

Class Template Argument Deduction**

If you’ve used std::pair, you probably don’t write it like this:

std::pair<int, int> pr{1, 42};

Using make_pair is clearly easier:

auto pr = make_pair(1, 42);

This is because function templates support argument deduction, so callers don’t need to manually specify the types. However, class templates didn’t support this before C++17—hence the need for utility functions like make_pair.

But with C++17, these helper functions are no longer necessary. Now you can simply write:

std::pair pr{1, 42};

Life suddenly gets a lot easier!

When I first saw std::array, one of its main shortcomings was that it couldn’t automatically deduce its size from an initializer list like C-style arrays can:

int a1[] = {1, 2, 3};               // Works
std::array<int, 3> a2{1, 2, 3};     // Verbose
// std::array<int> a3{1, 2, 3};    // Doesn’t compile

This issue mostly disappears in C++17. While you still can’t provide just one template argument, you can omit both:

std::array a{1, 2, 3};
// Deduces as std::array<int, 3>

This automatic deduction mechanism can be based on the constructor:

template <typename T>
struct MyObj {
  MyObj(T value);
  
};

MyObj obj1{std::string("hello")};
// Deduces as MyObj<std::string>
MyObj obj2{"hello"};
// Deduces as MyObj<const char*>

Or you can provide a deduction guide manually to get the desired behavior:

template <typename T>
struct MyObj {
  MyObj(T value);
  
};

MyObj(const char*) -> MyObj<std::string>;

MyObj obj{"hello"};
// Deduces as MyObj<std::string>

Structured Binding

When discussing associative containers, we saw an example like this:

std::multimap<std::string, int>::iterator lower, upper;
std::tie(lower, upper) = mmp.equal_range("four");

Here, the return value is a pair, and we want to use two separate variables to hold the result. So we had to declare both variables and use std::tie. In C++11/14, we couldn’t use auto here. Fortunately, C++17 introduces new syntax to solve this problem:

auto [lower, upper] = mmp.equal_range("four");

This allows us to declare variables with auto to directly unpack the individual elements of a pair or tuple, greatly improving readability.

List Initialization

In C++98, standard containers had a clear disadvantage compared to C-style arrays: you couldn’t conveniently initialize them with values inline. For example, you could write:

int a[] = {1, 2, 3, 4, 5};

But for std::vector, you had to do:

std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
v.push_back(4);
v.push_back(5);

This was verbose and inefficient—clearly unsatisfactory. So the C++ standard committee introduced list initialization, allowing objects to be initialized more easily:

std::vector<int> v{1, 2, 3, 4, 5};

Importantly, this isn’t some special feature of the standard library—it’s a general feature that can be used with user-defined types as well. Technically, the compiler interprets expressions like {1, 2, 3} as an initializer_list<int>. You just need to declare a constructor that takes an initializer_list to take advantage of this feature.

In terms of performance, especially for dynamic objects, containers and arrays are essentially equivalent—they’re initialized via copy (construction) either way.

Uniform Initialization

You may have noticed that I used curly braces {} to initialize objects in the code. This is indeed a new syntax introduced in C++11, which can replace many uses of parentheses () during variable initialization. This is called uniform initialization.

The biggest benefit of using curly braces when constructing an object is that it avoids what’s known in C++ as “the most vexing parse.” I’ve encountered this issue myself. Suppose you have a class defined like this:

class utf8_to_wstring {
public:
  utf8_to_wstring(const char*);
  operator wchar_t*();
};

Then, under Windows, you want to use this class to help convert a filename and open a file:

ifstream ifs(
  utf8_to_wstring(filename));

You’ll soon find that ifs behaves incorrectly no matter what. The compiler interprets this as equivalent to:

ifstream ifs(
  utf8_to_wstring filename);

In other words, the compiler thinks you’re declaring a function named ifs, not an object!

If you replace any pair of parentheses with curly braces—or both, as shown below—you can avoid this problem:

ifstream ifs{
  utf8_to_wstring{filename}};

More broadly, you can use curly braces instead of parentheses nearly everywhere you initialize an object. Another benefit: when a constructor is not marked as explicit, you can use curly braces without writing the class name if the context requires an object of that type. For example:

Obj getObj()
{
  return {1.0};
}

If the Obj class can be constructed from a floating-point number, the above is valid. If the class has both default and multi-parameter constructors, this form can still be used. Besides the difference in syntax, the key distinction is that Obj(1.0) allows narrowing conversions (like to int), while {1.0} or Obj{1.0} does not—the compiler will reject such narrowing conversions.

A major caveat of this syntax is that if a class has both a constructor that uses an initializer list and another that doesn’t, the compiler will go out of its way to call the initializer list constructor, which can lead to unexpected behavior. So here’s the general recommendation:

  • If a class does not have an initializer list constructor, you can freely use uniform initialization ({}).
  • If a class does have an initializer list constructor, then only use {} when you actually want to invoke that constructor.

Default Member Initialization

In C++98, class data members could only be initialized inside constructors. This wasn’t a problem by itself, but in practice, when a class has many data members and multiple constructors, it becomes tedious and error-prone to manually initialize everything—especially when adding new members and potentially forgetting to initialize them in all constructors.

To address this, C++11 introduced a feature that allows data members to be given default initialization values directly at the point of declaration. This default initializer is only used if and only if the member is not explicitly initialized in the constructor’s initializer list.

That may sound abstract, so here’s an example. First, the traditional C++98-style code:

class Complex {
public:
  Complex() : re_(0), im_(0) {}
  Complex(float re) : re_(re), im_(0) {}
  Complex(float re, float im) : re_(re), im_(im) {}
  

private:
  float re_;
  float im_;
};

Let’s say for some reason you can’t use default parameters to simplify these constructors. How can we improve the code?

By using default member initializers, we can write:

class Complex {
public:
  Complex() {}
  Complex(float re) : re_(re) {}
  Complex(float re, float im)
    : re_(re), im_(im) {}

private:
  float re_{0};
  float im_{0};
};

In this version:

  • The first constructor has no initializer list, so both re_ and im_ use their default values (0).
  • The second constructor explicitly initializes re_, but im_ still uses the default.
  • The third constructor explicitly initializes both, overriding the defaults.

This makes the code cleaner and safer, reducing the risk of uninitialized members and the overhead of manually setting defaults in every constructor.