Modern C++ Features You Should Use: auto, range-for, structured bindings
Tour the modern C++ features that pay back every day: auto, range-for, structured bindings, if-init, std::optional, and string_view.
What you'll learn
- ✓Use auto and range-for without overreaching
- ✓Destructure pairs, tuples, and structs with structured bindings
- ✓Handle absent values with std::optional
- ✓Pass strings cheaply with std::string_view
- ✓Apply if-init and constexpr where they remove clutter
Prerequisites
- •C++ fundamentals — see /blog/cpp-stl-containers
Modern C++ (C++11 onward, but especially C++17 and C++20) added features that make day-to-day code shorter, safer, and faster. None of them require runtime overhead. Adopt them and your code starts to look like a different language than the C-style C++ of the early 2000s.
auto
auto lets the compiler deduce the type. Reach for it when the type is obvious from the right-hand side or when spelling it out adds noise.
auto count = 42; // int
auto name = std::string{"ada"};
auto it = container.find(key); // iterator type
Avoid auto when the type is the most important part of the line, such as a return type or a public API.
Range-for
Range-for iterates any container or array with a clean syntax.
std::vector<int> v{1, 2, 3};
for (const auto& n : v) std::cout << n;
Use const auto& for non-trivial types and auto for cheap-to-copy ones. Use auto& when mutating elements in place.
Structured bindings
C++17’s structured bindings destructure tuples, pairs, and aggregate structs into named locals.
std::map<std::string, int> ages{{"ada", 36}, {"alan", 41}};
for (const auto& [name, age] : ages) {
std::cout << name << ' ' << age << '\n';
}
They also work on your own structs:
struct Point { double x; double y; };
Point p{1.0, 2.0};
auto [x, y] = p;
Bindings remove the noise of it->first and std::get calls.
if and switch with initializers
C++17 added an initializer clause to if and switch. Use it to scope helper variables tightly.
if (auto it = m.find(key); it != m.end()) {
use(it->second);
}
switch (auto status = check(); status) {
case Status::Ok: handle_ok(); break;
case Status::Error: handle_err(); break;
}
Anything you would have created just above the if and discarded just below belongs in the init clause.
std::optional
std::optional<T> models “a value or nothing” without sentinel hacks like -1 or null pointers.
#include <optional>
std::optional<int> parse_int(const std::string& s) {
try { return std::stoi(s); }
catch (...) { return std::nullopt; }
}
if (auto n = parse_int("42")) {
std::cout << *n; // dereference like a pointer
}
Use value_or to provide a default:
int port = parse_int(env).value_or(8080);
std::string_view
std::string_view is a non-owning view over character data. Pass it instead of const std::string& when you only need to read, and you accept both std::string and string literals without copying.
#include <string_view>
bool starts_with(std::string_view s, std::string_view prefix) {
return s.size() >= prefix.size() &&
s.substr(0, prefix.size()) == prefix;
}
starts_with("Hello", "He"); // no allocation
The caveat: a string_view is only valid as long as the underlying buffer lives. Never store one beyond the lifetime of the source string.
std::variant
std::variant<A, B, C> is a type-safe union. It always holds exactly one of the listed types.
#include <variant>
std::variant<int, std::string> v = 42;
v = std::string{"hello"};
std::visit([](const auto& x) { std::cout << x; }, v);
Use variant when a value can be one of several known types, in place of a tag-and-union or class hierarchy.
constexpr everywhere
C++14 and later loosened constexpr significantly. Functions can branch, allocate, and use loops at compile time.
constexpr int factorial(int n) {
int r = 1;
for (int i = 2; i <= n; ++i) r *= i;
return r;
}
constexpr int six_factorial = factorial(6);
Marking pure computation constexpr is free and unlocks compile-time evaluation when the inputs are known.
Lambdas with capture and init
Lambdas have grown init captures, generic parameters, and constexpr since C++14.
auto make_adder = [](int n) {
return [n](int x) { return x + n; };
};
auto add5 = make_adder(5);
std::cout << add5(10); // 15
Init captures ([x = std::move(value)]) let you move into a lambda — useful for transferring ownership.
Designated initializers (C++20)
Initialize aggregate members by name for clarity.
struct Config { int port; bool secure; std::string host; };
Config c{ .port = 8080, .secure = true, .host = "localhost" };
Order must match declaration order, but the names make intent obvious at the call site.
Putting it together
A small example using several features at once:
#include <iostream>
#include <map>
#include <optional>
#include <string_view>
std::optional<int> lookup(const std::map<std::string, int>& m,
std::string_view key) {
if (auto it = m.find(std::string(key)); it != m.end()) {
return it->second;
}
return std::nullopt;
}
int main() {
std::map<std::string, int> scores{{"ada", 95}, {"alan", 88}};
for (const auto& [name, score] : scores) {
std::cout << name << ' ' << score << '\n';
}
std::cout << lookup(scores, "ada").value_or(-1);
}
Range-for, structured binding, if-init, optional, and string_view in twenty lines.
Where this fits
These features compose with everything else: STL Containers become much shorter to iterate, and Smart Pointers play naturally with auto and optional.
Common pitfalls
- Returning a
string_viewto a temporary string — dangling. - Storing
auto itfrom a container that you then modify — invalidated iterator. - Overusing
autoto the point reviewers cannot tell what a variable is. - Calling
*optwithout checking that theoptionalholds a value.
Wrap up
Modern C++ pays for itself line by line. auto cuts noise, range-for cuts ceremony, structured bindings cut helper variables, optional cuts sentinel hacks, string_view cuts copies. Update your compiler flag to -std=c++20, then update your habits.