Modern C++ Syntax Cheatsheet: C++11 to C++20 Features and Their Pitfalls
Key takeaways
One table per standard (feature, minimal form, pitfall) plus a compiled snippet for each: auto and brace init, lambdas and init-capture, smart pointers, structured bindings, optional/variant/string_view, concepts, ranges, <=>, designated initializers and coroutines. Includes the g++ 10 flags you need.
This is a reference sheet, not a tutorial. Each section lists the features a given standard added, the shortest correct way to write them, and the one mistake that most often turns a working feature into a bug. Every code block was compiled with g++ 10.3 using -std=c++11, -std=c++14, -std=c++17 or -std=c++20 (plus -fcoroutines for coroutines) and -Wall -Wextra. Where the pitfall has a compiler diagnostic, the actual g++ message is quoted; where g++ stays silent, that is noted too, because silent pitfalls are the expensive ones.
One build fact first: g++ 10 defaults to -std=gnu++14 (__cplusplus is 201402L), and g++ 11 moved to gnu++17. If your build does not set the standard explicitly, a compiler upgrade changes the language you are compiling. Set it in CMake with set(CMAKE_CXX_STANDARD 17) and set(CMAKE_CXX_STANDARD_REQUIRED ON).
C++11
| Feature | Minimal form | Pitfall |
|---|---|---|
auto | auto it = m.find(k); | Drops top-level const and references: auto x = cref; is a copy. auto il = {1, 2}; is std::initializer_list<int>. |
Range-based for | for (const auto& x : v) | for (auto x : v) copies every element. Modifying the container inside the loop invalidates the hidden iterators. |
| Lambdas | [factor](int x) { return x * factor; } | Capturing by reference ([&]) and returning or storing the lambda leaves a dangling reference. |
unique_ptr / shared_ptr / weak_ptr | std::make_shared<T>(...) | Two shared_ptrs pointing at each other never free; break cycles with weak_ptr. make_unique is C++14, not C++11. |
nullptr | f(nullptr); | With f(int) and f(char*), f(NULL) is ambiguous (g++: call of overloaded 'f(NULL)' is ambiguous). |
| Brace initialization | std::vector<int> v{1, 2, 3}; | vector<int> a(3) has 3 zeros, vector<int> b{3} has one element 3. Narrowing: int i{3.7} is an error, int i{d} with a variable is only a -Wnarrowing warning in g++. |
| Move semantics | std::string t = std::move(s); | std::move is only a cast. Moving from a const object silently copies. A moved-from object is valid but unspecified: reassign before reading. |
override / final | void run() const override; | Without override, a signature mismatch (missing const) silently declares a new function. |
enum class | enum class Color { Red, Green }; | No implicit conversion to int; use static_cast or std::underlying_type. |
constexpr functions | constexpr int sq(int x) { return x * x; } | C++11 allows a single return statement; loops need C++14. |
= default / = delete | NoCopy(const NoCopy&) = delete; | Declaring a copy constructor suppresses the implicit move constructor. |
static_assert | static_assert(sq(4) == 16, "msg"); | The message argument is mandatory until C++17. |
| Trailing return type | auto add(int a, int b) -> int | Useful when the return type depends on parameters; otherwise it is only noise. |
// g++ -std=c++11 -Wall -Wextra
#include <iostream>
#include <memory>
#include <vector>
void f(int) { std::cout << "f(int)\n"; }
void f(char*) { std::cout << "f(char*)\n"; }
struct Base { virtual ~Base() = default; virtual void run() const {} };
struct Derived final : Base { void run() const override {} };
constexpr int square(int x) { return x * x; }
static_assert(square(4) == 16, "constexpr");
int main() {
std::vector<int> v = {1, 2, 3};
for (auto& x : v) x *= 2; // modify through a reference
int factor = 10;
auto mul = [factor](int x) { return x * factor; }; // copy taken here
auto inc = [&factor]() { ++factor; };
inc();
std::cout << mul(5) << ' ' << factor << '\n'; // 50 11
std::unique_ptr<int> up(new int(42)); // C++11 has no make_unique
auto sp = std::make_shared<int>(7);
auto sp2 = sp;
std::weak_ptr<int> wp = sp;
if (auto locked = wp.lock()) std::cout << *locked << ' ' << sp.use_count() << '\n'; // 7 3
f(nullptr); // f(char*)
std::vector<int> a3(3), b3{3};
std::cout << a3.size() << ' ' << b3.size() << '\n'; // 3 1
}
The use_count() of 3 in that output is worth a second look: sp, sp2 and the temporary locked all own the object while the if body runs. weak_ptr itself does not count.
C++14
| Feature | Minimal form | Pitfall |
|---|---|---|
| Generic lambdas | [](const auto& x) { ... } | Each call with a new type instantiates a new operator(); errors appear at the call site, deep in the lambda body. |
| Init-capture | [p = std::move(p)] { ... } | The only way to move a unique_ptr into a lambda. The lambda becomes move-only, so it cannot be stored in std::function. |
std::make_unique | auto p = std::make_unique<T>(args); | No custom-deleter overload; use the constructor for that. |
| Return type deduction | auto half(int x) { return x / 2; } | All return statements must deduce the same type; return 1; and return 1.0; do not mix. |
decltype(auto) | decltype(auto) first(V& v) { return v[0]; } | return (local); with parentheses deduces a reference to a local, which dangles. |
Relaxed constexpr | loops and locals inside constexpr | Still no allocation or virtual calls in constant evaluation until C++20. |
| Digit separators, binary literals | 1'000'000, 0b1010 | None worth noting; use them freely. |
// g++ -std=c++14 -Wall -Wextra
#include <iostream>
#include <memory>
#include <string>
#include <type_traits>
#include <vector>
decltype(auto) first(std::vector<int>& v) { return v[0]; } // returns int&
constexpr int fact(int n) { int r = 1; for (int i = 2; i <= n; ++i) r *= i; return r; }
static_assert(fact(5) == 120, "");
int main() {
auto print = [](const auto& x) { std::cout << x << '\n'; };
print(42);
print("hi");
auto p = std::make_unique<std::string>("owned");
auto task = [p = std::move(p)] { return p->size(); };
std::cout << task() << ' ' << (p == nullptr) << '\n'; // 5 1: the outer p is now empty
std::vector<int> v{1, 2};
first(v) = 9;
static_assert(std::is_same<decltype(first(v)), int&>::value, "");
std::cout << v[0] << ' ' << 1'000'000 << ' ' << 0b1010 << '\n'; // 9 1000000 10
}
C++17
| Feature | Minimal form | Pitfall |
|---|---|---|
| Structured bindings | for (const auto& [k, v] : m) | Plain auto [a, b] = x; copies x; use auto& to modify. You cannot bind an iterator: auto [k, v] = m.begin(); fails with 2 names provided for structured binding. |
if / switch with initializer | if (auto it = m.find(k); it != m.end()) | The variable lives through the else branch too, which is usually what you want. |
std::optional | opt.value_or(-1) | *opt on an empty optional is undefined behavior, not an exception. Only value() throws std::bad_optional_access. |
std::variant | std::visit(overloaded{...}, var) | std::get<T> throws std::bad_variant_access on the wrong alternative; use std::get_if for a pointer-or-null check. |
std::string_view | void log(std::string_view msg) | Binding it to a temporary std::string dangles, and g++ 10 compiles std::string_view sv = make_string(); without any warning. |
if constexpr | if constexpr (std::is_integral_v<T>) | The discarded branch must still parse, and only template-dependent code is skipped, so it is not a macro replacement. |
| CTAD | std::vector v{1, 2, 3}; | std::pair p(1, "hi"); deduces pair<int, const char*>, not std::string. |
| Fold expressions | (args + ... + 0) | Without the 0 initial value, an empty pack with + fails to compile. |
inline variables | inline static int count = 0; | Solves the ODR definition problem for header-only statics; it does not make them thread-safe. |
[[nodiscard]] | [[nodiscard]] int compute(); | Only a warning: g++ prints ignoring return value of 'int compute()', declared with attribute 'nodiscard'. |
| Guaranteed copy elision | return T{...}; | Applies to prvalues only; return local; is still NRVO, which is optional. |
// g++ -std=c++17 -Wall -Wextra
#include <iostream>
#include <map>
#include <optional>
#include <string>
#include <variant>
#include <vector>
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
template <class... Args> auto sum(Args... args) { return (args + ... + 0); }
std::optional<int> find(const std::vector<int>& v, int target) {
for (int x : v) if (x == target) return x;
return std::nullopt;
}
int main() {
std::map<std::string, int> m{{"a", 1}, {"b", 2}};
for (auto& [key, value] : m) value *= 10; // auto& to modify in place
if (auto [it, inserted] = m.try_emplace("c", 3); inserted)
std::cout << it->first << " added\n";
if (auto it = m.find("a"); it != m.end()) std::cout << it->second << '\n'; // 10
std::vector<int> v{1, 2, 3};
auto r = find(v, 5);
std::cout << r.value_or(-1) << '\n'; // -1
try { (void)r.value(); } catch (const std::bad_optional_access&) { std::cout << "bad_optional_access\n"; }
std::variant<int, std::string> var = 42;
std::visit(overloaded{[](int i) { std::cout << "int " << i << '\n'; },
[](const std::string& s) { std::cout << "str " << s << '\n'; }},
var);
if (auto* p = std::get_if<int>(&var)) std::cout << *p << '\n';
std::cout << sum(1, 2, 3) << ' ' << sum() << '\n'; // 6 0
}
The overloaded helper is not in the standard library; it is two lines you paste once. The deduction guide on its second line is required in C++17 and becomes unnecessary in C++20.
I have lost the most time in this section to std::string_view. It is the right parameter type for functions that only read text, but as soon as it is stored in a member or returned from a function, it has the lifetime rules of a raw pointer with none of the visual warning. The typical failure is refactoring a const std::string& member into std::string_view “for performance”, after which a caller passes a temporary and the program prints garbage only in release builds. Since g++ 10 says nothing, I treat any string_view that outlives the full expression it was created in as a code-review question, and I run tests under -fsanitize=address to catch the rest.
C++20
| Feature | Minimal form | Pitfall |
|---|---|---|
| Concepts | template <std::integral T> or void f(Addable auto x) | A concept checks syntax, not meaning: std::convertible_to<T> in a requires-expression says nothing about correctness. |
| Ranges and views | v | std::views::filter(pred) | std::views::transform(fn) | Views are lazy and non-owning. filter_view caches begin(), so it cannot be iterated through a const& (g++: passing 'const std::ranges::filter_view<...>' as 'this' argument discards qualifiers). |
| Range algorithms | std::ranges::sort(v); | Called on a temporary container, the returned iterator is std::ranges::dangling, and dereferencing it fails with no match for 'operator*' (operand type is 'std::ranges::dangling'). That compile error is the feature working. |
| Three-way comparison | auto operator<=>(const P&) const = default; | A defaulted <=> also gives you ==; a hand-written <=> does not, so you must declare operator== yourself. |
| Designated initializers | Opts o{.width = 120}; | Designators must follow declaration order: designator order for field 'Opts::width' does not match declaration order in 'Opts'. |
consteval | consteval int kib(int n) | Every call must be a constant expression; passing a runtime value is an error. |
std::span | int total(std::span<const int> s) | Non-owning, like string_view: never return one that points into a local container. |
| Coroutines | co_yield, co_await, co_return | g++ 10 needs -fcoroutines. There is no std::generator until C++23; you write the promise type yourself. |
char8_t | u8"text" | u8 literals change type: const char* s = u8"x"; now fails with invalid conversion from 'const char8_t*' to 'const char*'. |
Lambda [=] and this | [=, this] | Implicit capture of this via [=] is deprecated; g++ warns implicit capture of 'this' via '[=]' is deprecated in C++20. |
// g++ -std=c++20 -Wall -Wextra
#include <algorithm>
#include <compare>
#include <concepts>
#include <iostream>
#include <ranges>
#include <span>
#include <vector>
template <std::integral T> T gcd_of(T a, T b) { return b == 0 ? a : gcd_of(b, a % b); }
template <class T>
concept Addable = requires(T a, T b) { { a + b } -> std::convertible_to<T>; };
auto sum2(Addable auto a, Addable auto b) { return a + b; }
struct Point {
int x, y;
auto operator<=>(const Point&) const = default; // also provides ==
};
struct Opts { int width = 80; bool color = false; };
consteval int kib(int n) { return n * 1024; }
int total(std::span<const int> s) { int t = 0; for (int x : s) t += x; return t; }
int main() {
std::cout << gcd_of(12, 18) << ' ' << sum2(1, 2) << '\n'; // 6 3
std::vector<int> v{1, 2, 3, 4, 5, 6};
auto evens_doubled = v | std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * 2; });
for (int x : evens_doubled) std::cout << x << ' '; // 4 8 12
std::cout << '\n';
std::ranges::sort(v, std::greater{});
for (int x : v | std::views::take(3)) std::cout << x << ' '; // 6 5 4
std::cout << '\n';
Point a{1, 2}, b{1, 3};
std::cout << (a < b) << ' ' << (a == b) << '\n'; // 1 0
Opts o{.width = 120}; // color keeps its default
int arr[] = {1, 2, 3};
std::cout << o.width << ' ' << kib(4) << ' ' << total(arr) << ' ' << total(v) << '\n';
}
Coroutine generator that actually runs
Coroutine snippets often show only a promise_type and stop there, which compiles but cannot be used: nothing resumes the coroutine and nothing destroys its frame. This version owns the handle, resumes it on next(), and destroys the frame in its destructor.
// g++ -std=c++20 -fcoroutines -Wall -Wextra
#include <coroutine>
#include <exception>
#include <iostream>
#include <utility>
class Generator {
public:
struct promise_type {
int current = 0;
Generator get_return_object() { return Generator{handle::from_promise(*this)}; }
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(int v) noexcept { current = v; return {}; }
void return_void() noexcept {}
void unhandled_exception() { std::terminate(); }
};
using handle = std::coroutine_handle<promise_type>;
explicit Generator(handle h) : h_(h) {}
Generator(Generator&& o) noexcept : h_(std::exchange(o.h_, {})) {}
Generator(const Generator&) = delete;
~Generator() { if (h_) h_.destroy(); }
bool next() { h_.resume(); return !h_.done(); }
int value() const { return h_.promise().current; }
private:
handle h_;
};
Generator fibonacci() {
int a = 0, b = 1;
while (true) { co_yield a; a = std::exchange(b, a + b); }
}
int main() {
auto gen = fibonacci();
for (int i = 0; i < 8 && gen.next(); ++i) std::cout << gen.value() << ' ';
std::cout << '\n'; // 0 1 1 2 3 5 8 13
}
Two details matter. final_suspend returns suspend_always, so the frame stays alive after the body ends and done() can be checked safely; the destructor then frees it. And the class is move-only, because two owners of one handle would destroy the frame twice.
Pitfalls g++ 10 does not report
These compile cleanly with -Wall -Wextra, which is exactly why they are on this list:
// g++ -std=c++17 -Wall -Wextra: no diagnostics for any of these
const std::string s = "x";
std::string t = std::move(s); // copies: std::move on const cannot move
std::string_view sv = make_string(); // dangles after this statement
for (int x : make_box().items()) // make_box() dies before the loop body runs
use(x); // (lifetime extension arrives only in C++23)
The range-for case catches people who know the temporary-lifetime rules. make_box() returns a temporary, .items() returns a reference into it, and only the final reference is bound to the hidden loop variable, so the Box is destroyed before the first iteration. Store the temporary in a named variable first. When I review modernization patches, this one appears most often right after someone converts an index loop into a range-for over a getter chain; the old loop called the getter on each iteration and was accidentally safe.
Adopting features in an existing codebase
A reasonable order for legacy code, based on how much each step reduces bugs versus how much it demands from the toolchain:
- C++11 basics:
nullptr,override,enum class,unique_ptrfor owning pointers, range-basedfor. These are mechanical, andoverridealone tends to surface real bugs in class hierarchies. - C++14/17 value types:
make_unique, structured bindings,optionalfor “maybe a value” return types,ifwith initializer. - C++17 vocabulary for interfaces:
string_viewandvariant, once the team has agreed on the lifetime rules above. - C++20: concepts to replace
enable_if, ranges for pipelines,<=>for comparable value types, coroutines only where there is a clear async or generator need.
Pick the minimum standard from the oldest compiler in CI, not from the newest one on a developer machine. Feature-test macros such as __cpp_concepts and __cpp_lib_ranges from <version> let a library check support, but in application code a single enforced CMAKE_CXX_STANDARD is simpler than scattered #if blocks.
Related Articles
- auto type deduction rules
- Lambda capture modes and dangling captures
- std::optional in practice
- Concepts and constraints
- Structured bindings
- Ranges introduction
- C++20 coroutines
References
- cppreference: https://en.cppreference.com/ (each feature page lists the standard and the feature-test macro)
- “Effective Modern C++” by Scott Meyers (C++11/14)
- “C++17 - The Complete Guide” and “C++20 - The Complete Guide” by Nicolai M. Josuttis