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

FeatureMinimal formPitfall
autoauto 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 forfor (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_ptrstd::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.
nullptrf(nullptr);With f(int) and f(char*), f(NULL) is ambiguous (g++: call of overloaded 'f(NULL)' is ambiguous).
Brace initializationstd::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 semanticsstd::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 / finalvoid run() const override;Without override, a signature mismatch (missing const) silently declares a new function.
enum classenum class Color { Red, Green };No implicit conversion to int; use static_cast or std::underlying_type.
constexpr functionsconstexpr int sq(int x) { return x * x; }C++11 allows a single return statement; loops need C++14.
= default / = deleteNoCopy(const NoCopy&) = delete;Declaring a copy constructor suppresses the implicit move constructor.
static_assertstatic_assert(sq(4) == 16, "msg");The message argument is mandatory until C++17.
Trailing return typeauto add(int a, int b) -> intUseful 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

FeatureMinimal formPitfall
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_uniqueauto p = std::make_unique<T>(args);No custom-deleter overload; use the constructor for that.
Return type deductionauto 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 constexprloops and locals inside constexprStill no allocation or virtual calls in constant evaluation until C++20.
Digit separators, binary literals1'000'000, 0b1010None 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

FeatureMinimal formPitfall
Structured bindingsfor (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 initializerif (auto it = m.find(k); it != m.end())The variable lives through the else branch too, which is usually what you want.
std::optionalopt.value_or(-1)*opt on an empty optional is undefined behavior, not an exception. Only value() throws std::bad_optional_access.
std::variantstd::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_viewvoid 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 constexprif 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.
CTADstd::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 variablesinline 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 elisionreturn 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

FeatureMinimal formPitfall
Conceptstemplate <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 viewsv | 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 algorithmsstd::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 comparisonauto operator<=>(const P&) const = default;A defaulted <=> also gives you ==; a hand-written <=> does not, so you must declare operator== yourself.
Designated initializersOpts o{.width = 120};Designators must follow declaration order: designator order for field 'Opts::width' does not match declaration order in 'Opts'.
constevalconsteval int kib(int n)Every call must be a constant expression; passing a runtime value is an error.
std::spanint total(std::span<const int> s)Non-owning, like string_view: never return one that points into a local container.
Coroutinesco_yield, co_await, co_returng++ 10 needs -fcoroutines. There is no std::generator until C++23; you write the promise type yourself.
char8_tu8"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:

  1. C++11 basics: nullptr, override, enum class, unique_ptr for owning pointers, range-based for. These are mechanical, and override alone tends to surface real bugs in class hierarchies.
  2. C++14/17 value types: make_unique, structured bindings, optional for “maybe a value” return types, if with initializer.
  3. C++17 vocabulary for interfaces: string_view and variant, once the team has agreed on the lifetime rules above.
  4. 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.

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