C++20 New Features
C++20 is one of the biggest language updates after C++11. The main features I care about are concepts, ranges, coroutines, and modules.
1. Concepts: Compile-Time Constraints for Templates
What It Is
Concepts let you say: “this template only accepts types that satisfy these requirements.” They make template errors more readable and prevent invalid overloads from participating in overload resolution.
#include <concepts>
#include <iostream>
// Define a concept that accepts only integral types like int, long, char, and bool.
template <typename T>
concept IntegerLike = std::integral<T>;
// Create a function template constrained to IntegerLike types only.
template <IntegerLike T>
T add(T a, T b) {
// Return the sum of the two integer-like values.
return a + b;
}
// Run a small demo.
void run() {
// Call add with int arguments, which satisfies std::integral.
int result = add(10, 20);
// Print the result.
std::cout << result << '\n';
// This would fail at compile time because double is not integral.
// auto bad = add(1.5, 2.5);
} Before C++20: SFINAE
Before concepts, one common trick was SFINAE: “Substitution Failure Is Not An Error.” The idea is that if template substitution fails in the function signature, the compiler removes that overload instead of producing a hard error.
#include <type_traits>
#include <iostream>
// This overload only exists if T has a nested type called value_type.
template <typename T>
typename T::value_type get_first_value(const T& container) {
// Return the first value from the container.
return *container.begin();
}
// This struct has no value_type, so the template substitution would fail.
struct BadType {};
// Run a small demo.
void run() {
// If we tried to call get_first_value(BadType{}), the compiler would remove the overload
// because BadType::value_type does not exist.
} The problem is that SFINAE is often indirect. The actual requirement is hidden inside the return type or function signature, so error messages can become hard to understand.
Before C++20: std::enable_if
A more explicit pre-C++20 approach was std::enable_if. This checks a type trait and only enables the
function if the condition is true.
#include <type_traits>
#include <iostream>
// Enable this function only when T is an integral type.
template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> add_old(T a, T b) {
// Return the sum of both integral values.
return a + b;
}
// Run a small demo.
void run() {
// This works because int satisfies std::is_integral_v<int>.
int result = add_old(10, 20);
// Print the result.
std::cout << result << '\n';
// This would fail because double is not integral.
// auto bad = add_old(1.5, 2.5);
} Why Concepts Are Better Than the Status Quo
- Better compile errors: The compiler can say which requirement failed.
- Cleaner overload resolution: Bad overloads are rejected earlier.
- Visible requirements: The constraint appears directly in the function signature.
- Less template noise: You avoid deeply nested
enable_ifexpressions. - Earlier checking: Constraints are checked before the compiler instantiates the whole function body.
2. Ranges: Cleaner Algorithms and Lazy Pipelines
What It Is
Ranges improve the old iterator-based STL algorithms. Instead of always passing
begin() and end(), you can pass the whole container directly.
Before C++20: Iterator-Based Algorithms
#include <algorithm>
#include <vector>
// Run a small demo.
void run() {
// Create a vector of integers.
std::vector<int> nums = {5, 1, 4, 2, 3};
// Sort the vector by passing the start iterator and end iterator.
std::sort(nums.begin(), nums.end());
} With C++20 Ranges
#include <algorithm>
#include <ranges>
#include <vector>
// Run a small demo.
void run() {
// Create a vector of integers.
std::vector<int> nums = {5, 1, 4, 2, 3};
// Sort the whole vector directly.
std::ranges::sort(nums);
} This is partly syntactic sugar, but it also makes APIs harder to misuse. You pass the range as one unit instead of manually passing two iterators that may not even belong together.
Views
Ranges also introduce views, which let you build lazy pipelines.Views are lazy range adaptors. They usually do not allocate or create a new container. Instead, they store references, iterators, lambdas, and small pieces of adaptor state.
#include <iostream>
#include <ranges>
#include <vector>
// Run a small demo.
void run() {
// Create a vector of integers.
std::vector<int> nums = {1, 2, 3, 4, 5, 6};
// Build a lazy pipeline that keeps even numbers and squares them.
auto view = nums
// Keep only values where x modulo 2 equals 0.
| std::views::filter([](int x) { return x % 2 == 0; })
// Transform each remaining value into x multiplied by itself.
| std::views::transform([](int x) { return x * x; });
// Iterate the lazy view, computing each output only when needed.
for (int value : view) {
// Print each computed value.
std::cout << value << '\n';
}
} Very Important: Lazy Evaluation
The pipeline above does not immediately create a new vector of squared even numbers. The filtering and transforming happen only when the loop asks for the next value.
// Iterate the lazy view, computing each output only when needed.
for (int value : view) {
// Print each computed value.
std::cout << value << '\n';
}
This is useful because if you only consume part of the view, only that part gets computed. In C++23, you can more
directly materialize a view into a new container using utilities like std::ranges::to. In C++20, you
usually build the vector manually from the view.
#include <ranges>
#include <vector>
// Run a small demo.
void run() {
// Create a vector of integers.
std::vector<int> nums = {1, 2, 3, 4, 5, 6};
// Build a lazy view.
auto view = nums
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
// Create an output vector.
std::vector<int> result;
// Iterate through the view.
for (int value : view) {
// Push each computed value into the result vector.
result.push_back(value);
}
} Low-Level Details
1. A view usually stores iterators or references to the underlying range, callable objects like lambdas, and small adaptor state. Most views do not allocate to heap memory is new.
2. For simple statically-known lambdas the compiler can inline most adaptor layers. But in hot paths, especially HFT/low-latency code, I would benchmark against a manual loop because abstraction depth, branching from filters, and iterator complexity can still matter.
3. The main danger is dangling references. If a view references a temporary container that has already been destroyed, using the view becomes unsafe.
Why better than Status Quo?
- Less boilerplate than manual loops: views let you express filter → transform → take directly instead of writing index-heavy loops with temporary variables and repeated if logic.
- Avoids intermediate containers: views are lazy, so nums | filter | transform does not immediately create extra vectors between each step; values are computed only when iterated.
3. Coroutines: Functions That Can Pause and Resume
What It Is
Coroutines let a function pause and resume execution. C++20 gives us coroutine syntax through
co_await, co_yield, and co_return. However, C++20 does not give us a full
async runtime by itself.
co_await: Suspend Until Some Operation Is Ready
co_await expr means: “pause this coroutine until expr says it is ready.” When ready, the
coroutine resumes and produces a result.
The key detail is that expr must produce an awaitable object. In this example,
socket.async_read() returns the awaitable object.
// Read a message asynchronously from a socket.
Task<std::string> read_message(Socket& socket) {
// socket.async_read() returns an awaitable object.
// co_await uses that object to decide whether to suspend or continue.
std::string data = co_await socket.async_read();
// Resume here once the read operation completes.
co_return data;
} We can rewrite the same idea more explicitly like this (this is just for explanation purposes):
// Read a message asynchronously from a socket.
Task<std::string> read_message(Socket& socket) {
// Ask the socket to create an async read operation.
AsyncReadOperation operation = socket.async_read();
// Await that operation.
// If the operation is not ready, this coroutine suspends here.
std::string data = co_await operation;
// Resume here once the operation completes.
co_return data;
} The Awaitable Object
In the example above, AsyncReadOperation is the awaitable object. It is the object returned by
socket.async_read(). It controls what happens when the coroutine reaches co_await.
#include <coroutine>
#include <string>
// Forward declare Socket so AsyncReadOperation can store a reference to it.
class Socket;
// This object represents one pending async read.
class AsyncReadOperation {
public:
// Store a reference to the socket we want to read from.
explicit AsyncReadOperation(Socket& socket)
: socket_(socket) {}
// Return true if the operation is already complete.
bool await_ready() {
// For real async IO, this is usually false.
// Returning false tells the coroutine to suspend.
return false;
}
// Called when the coroutine is about to suspend.
void await_suspend(std::coroutine_handle<> handle) {
// Register the socket with an event loop.
// The event loop may use epoll, kqueue, io_uring, or IOCP internally.
// Store the coroutine handle.
//std::coroutine_handle<> is a pointer-like object that points to a suspended coroutine frame. That frame contains local variables, the resume point, and the promise object.
//The main API is small: resume(), destroy(), done(), operator bool(), address(), and from_address(). If you use std::coroutine_handle<PromiseType>, you also get promise().
//You usually store the handle in await_suspend(), then later call resume() from an event loop, scheduler, callback, or manual trigger.
// Later, when the socket becomes readable, the event loop calls handle.resume().
register_read_with_event_loop(socket_, handle);
}
// Called when the coroutine resumes.
std::string await_resume() {
// Return the final result of the async read.
return read_completed_data_from_socket(socket_);
}
private:
// Keep the socket alive by reference while this operation is pending.
Socket& socket_;
}; The Socket Creates the Awaitable
The socket itself is not usually the awaitable. Instead, the socket has an async method that creates an awaitable operation object.
// A simplified socket type.
class Socket {
public:
// Start an async read operation.
AsyncReadOperation async_read() {
// Return an awaitable object representing this pending read.
return AsyncReadOperation(*this);
}
}; What co_await Roughly Expands Into
This line:
std::string data = co_await socket.async_read(); Roughly behaves like this:
// Create the awaitable object.
AsyncReadOperation operation = socket.async_read();
// Check whether the operation is already done.
if (!operation.await_ready()) {
// Suspend the current coroutine.
// Pass the coroutine handle into the awaitable.
operation.await_suspend(current_coroutine_handle);
// Control returns to the caller or event loop.
// The coroutine is paused here.
}
// Later, after the event loop resumes this coroutine,
// obtain the result of the completed operation.
std::string data = operation.await_resume(); What This Shows
-
socket.async_read()returns the awaitable object. -
co_awaitdoes not know about sockets,epoll,kqueue,io_uring, orIOCP. -
await_suspend()is where the async runtime usually registers the socket with the event loop and stores thestd::coroutine_handle<>. Later, when the socket becomes readable, the event loop uses that stored handle and callshandle.resume()to continue the coroutine. -
The event loop resumes the coroutine later by calling
handle.resume(). -
After resumption,
await_resume()returns the final value to the coroutine.
So the language feature only gives us the pause and resume machinery. The async runtime decides how to wait for the socket, where to store the coroutine handle, and when to resume the coroutine.
co_yield: Produce One Value, Suspend, Then Continue Later
co_yield value means: “produce this value to the caller, suspend here, and continue when the caller asks
for the next value.” It is commonly used for generators, lazy sequences, streaming parsers, and incremental traversal.
// Produce a lazy sequence of integers.
Generator<int> count_up_to(int n) {
// Start counting from 1.
for (int i = 1; i <= n; ++i) {
// Yield the current value, then suspend until the caller asks for the next value.
co_yield i;
}
} co_return: Finish the Coroutine and Provide the Final Result
co_return value means: “complete this coroutine and publish this final result.”
co_return; means: “complete this coroutine with no value.”
// Compute a final async result.
Task<int> compute_answer() {
// Pretend this suspends while some async operation completes.
int value = co_await async_fetch_number();
// Finish the coroutine and return the final result to the Task promise.
co_return value + 1;
} Python asyncio Is Different
Python gives you an event loop. That event loop waits on socket and file readiness using OS mechanisms like
epoll on Linux, kqueue on macOS/BSD, or IOCP on Windows. In C++, the language
coroutine feature does not provide that runtime by itself.
So How Do C++ Coroutines Do Async IO?
You need a library or runtime. Examples include:
- Boost.Asio or standalone Asio
cppcorolibunifex- Folly coroutines
- A custom event loop using
epoll,kqueue,io_uring, etc.
The awaiter or coroutine library decides what happens when the coroutine suspends. The language gives the mechanism; the runtime gives the policy.
Why is this better than the status quo?
- Code reads more sequentially than callback-based async code, especially when the flow is read → parse → write → retry → cleanup. Callbacks are basically lambda functions fired on a certain event.
- The compiler stores the coroutine's local variables and resume point inside the coroutine frame, so we do not have to manually track as much application-level state across callbacks.
- It does not replace the event loop or IO runtime; libraries like Boost.Asio still handle readiness, scheduling, and resumption, while coroutines improve how we express the control flow.
Before: callback-style control flow
async_read(socket, [](std::string data) {
parse(data);
async_write(socket, "OK", []() {
log("write done");
});
});
After: coroutine-style control flow
std::string data = co_await async_read(socket);
parse(data);
co_await async_write(socket, "OK");
log("write done");
4. Modules: Faster, Cleaner Replacement for Textual Includes
What It Is
Modules let you export declarations from a module and import them elsewhere. Unlike #include, modules
are not textual copy-paste. The compiler builds a module interface once, then importers consume that compiled
interface.
Example Usage
File 1: math_utils.cppm
// Declare this file as a module interface unit named math_utils.
export module math_utils;
// Export this function so importers can use it.
export int add(int a, int b) {
// Return the sum of both integer arguments.
return a + b;
} File 2: main.cpp
#include <iostream>
// Import the compiled module interface.
import math_utils;
// Run a small demo.
int main() {
// Call the exported add function from the math_utils module.
int result = add(10, 20);
// Print the result.
std::cout << result << '\n';
// Return success.
return 0;
} Why Modules Are Better Than #include
- No textual copy-paste: With
#include, the preprocessor copies header text into every translation unit. With modules, the compiler imports a compiled module interface, leading to faster builds. - Less accidental coupling: Headers can drag in other headers, macros, and implementation details. Modules expose only what you export.
- Cleaner large codebases: Changing a private implementation detail in a module is less likely to force massive recompilation.
Status Quo
Traditional headers are processed by the preprocessor before compilation. That means every file that includes a header effectively receives a pasted copy of that header. In large C++ projects, this causes repeated parsing, macro leakage, and fragile include ordering.
Final Thoughts
Personally, I think concepts and moduled are just objectively better than status quo. Views can be a zero cost abstraction, when lambdas are inlined by the compiler into code that looks like manual loops, but its not guaranteed in very latency sensitive situations. Coroutines make code look cleaner compared to having callbacks (passing lambda functions so that "when this is done/happens, call this function"). Views have their use cases and look cleaner too.