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;
→ matchestemplate <typename T> f(T)
, so the result is the value type ofexpr
.const auto& a = expr;
→ matchestemplate <typename T> f(const T&)
, giving a const lvalue reference type.auto&& a = expr;
→ matchestemplate <typename T> f(T&&)
, and based on reference collapsing rules, the result is a reference that matches the value category ofexpr
.
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 includesdecltype((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
→ valueauto&
→ lvalue referenceauto&&
→ 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_
andim_
use their default values (0). - The second constructor explicitly initializes
re_
, butim_
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.