It is good style to limit the scope of variables as much as possible. Sometimes, however, one first needs to obtain some value, and only if it fits a certain condition, it can be processed further.
For this purpose, C++17 comes with if
and switch
statements with initializers.
In this recipe, we use the initializer syntax in both the supported contexts in order to see how they tidy up our code:
- The
if
statements: Imagine we want to find a character in a character map using thefind
method ofstd::map
:
if (auto itr (character_map.find(c)); itr != character_map.end()) {
// *itr is valid. Do something with it.
} else {
// itr is the end-iterator. Don't dereference.
}
// itr is not available here at all
- The
switch
statements: This is how it would look to get a character from the input and, at the same time, check the value in aswitch
statement in order to control a computer game:
switch (char c (getchar()); c) {
case 'a': move_left(); break;
case 's': move_back(); break;
case 'w': move_fwd(); break;
case 'd': move_right(); break;
case 'q': quit_game(); break;
case '0'...'9': select_tool('0' - c); break;
default:
std::cout << "invalid input: " << c << '\n';
}
The if
and switch
statements with initializers are basically just syntax sugar. The following two samples are equivalent:
Before C++17:
{ auto var (init_value); if (condition) { // branch A. var is accessible } else { // branch B. var is accessible } // var is still accessible }
Since C++17:
if (auto var (init_value); condition) { // branch A. var is accessible } else { // branch B. var is accessible } // var is not accessible any longer
The same applies to switch
statements:
Before C++17:
{ auto var (init_value); switch (var) { case 1: ... case 2: ... ... } // var is still accessible }
Since C++17:
switch (auto var (init_value); var) { case 1: ... case 2: ... ... } // var is not accessible any longer
This feature is very useful to keep the scope of a variable as short as necessary. Before C++17, this was only possible using extra braces around the code, as the pre-C++17 examples show. The short lifetimes reduce the number of variables in the scope, which keeps our code tidy and makes it easier to refactor.
Another interesting use case is the limited scope of critical sections. Consider the following example:
if (std::lock_guard<std::mutex> lg {my_mutex}; some_condition) {
// Do something
}
At first, an std::lock_guard
is created. This is a class that accepts a mutex argument as a constructor argument. It locks the mutex in its constructor, and when it runs out of scope, it unlocks it again in its destructor. This way, it is impossible to forget to unlock the mutex. Before C++17, a pair of extra braces was needed in order to determine the scope where it unlocks again.
Yet another interesting use case is the scope of weak pointers. Consider the following:
if (auto shared_pointer (weak_pointer.lock()); shared_pointer != nullptr) {
// Yes, the shared object does still exist
} else {
// shared_pointer var is accessible, but a null pointer
}
// shared_pointer is not accessible any longer
This is another example where we would have a useless shared_pointer
variable leaking into the current scope, although it has a potentially useless state outside the if
conditional block or noisy extra brackets!
The if
statements with initializers are especially useful when using legacy APIs with output parameters:
if (DWORD exit_code; GetExitCodeProcess(process_handle, &exit_code)) { std::cout << "Exit code of process was: " << exit_code << '\n'; } // No useless exit_code variable outside the if-conditional
GetExitCodeProcess
is a Windows kernel API function. It returns the exit code for a given process handle but only if that handle is valid. After leaving this conditional block, the variable is useless, so we don't need it in any scope any longer.
Being able to initialize variables within if
blocks is obviously very useful in a lot of situations and, especially, when dealing with legacy APIs that use output parameters.