Skip to content

Syntax Unification: More Important Than We Think

I used to think syntax unification was just superficial—making code "look pretty." But if you look back at Simula or Java, you'll notice an awkward design: custom types must be created with class, but built-in types cannot. In Simula, you couldn't even use new with built-in types. This leads to a fatal consequence: you can never write a truly generic container or algorithm because the syntax splits the world in two. One half handles built-in types, the other handles custom types—two sets of code, two sets of rules.

C++ avoided this problem from day one. int and class are syntactically identical. This means when you write a template, the way you create an object is exactly the same whether T is int or a custom type. This decision seems insignificant, but it's the prerequisite for the entire existence of C++ generic programming.

The same logic applies to resource management. If resource management isn't part of the type design, and you have to manually new/delete or open/close, your generic code can never be truly universal—you always have to special-case "this type needs manual resource release" somewhere. RAII embeds resource management into the lifecycle of the type itself, allowing generic code to treat all types equally. I was deeply moved when I realized this: the significance of RAII isn't just "preventing forgetfulness," it's the cornerstone of the type system that makes generic programming possible.

Locking Down the Smart Pointer Arrow Operator with Concepts

Now that we understand the premise, let's look at a specific example. When writing a simple smart pointer, I encountered an issue: operator-> shouldn't exist for all types.

Think about it: the semantics of operator-> are "access a member through a pointer." If my smart pointer wraps an int, what members does int have to access? Therefore, operator-> only makes sense when T is a class type. Before concepts, you either provided it unconditionally (resulting in indecipherable template errors when the user called it on int), or you used SFINAE with a pile of std::enable_if that made your code look like hieroglyphics. Now, with concepts, things are clean.

cpp
template <typename T>
concept SmartPtrArrow = requires(T ptr) {
    { *ptr } -> std::same_as<typename T::element_type&>;
    { ptr.operator->() } -> std::same_as<typename T::element_type*>;
};

template <typename T>
class SmartPtr {
    T* p;
public:
    SmartPtr(T* p = nullptr) : p(p) {}
    ~SmartPtr() { delete p; }

    T& operator*() { return *p; }

    // Only exists if T is a class type
    T* operator->() requires SmartPtrArrow<SmartPtr<T>> {
        return p;
    }
};

I tested this. SmartPtr<std::string> works perfectly. If I uncomment the ptr-> line with SmartPtr<int>, GCC gives a clean error: "no member named 'operator->' in 'SmartPtr<int>'." In the past, with std::enable_if, the error would scroll for pages. Now it's one line. This is the experience concepts bring—not "doing what was impossible before," but "doing the same thing ten times better."

You might ask, why not just use operator*? Indeed, if you only use operator*, the smart pointer behaves uniformly for all types. But operator-> is too convenient when dealing with objects; removing it completely is a waste. The correct approach isn't a "one-size-fits-none removal," but "precise control over when it exists." Concepts are the tool for this.

pair Copy Constructor: A Narrowing Hazard in the Standard

After finishing the smart pointer, I looked into the implementation of std::pair. std::pair has a templated copy constructor that looks roughly like this: you can copy construct a pair<U, V> to a pair<T1, T2>, provided U converts to T1 and V converts to T2. The standard specifies this, and it looks reasonable, right?

But looking closer, I found a problem: this conversion uses ordinary implicit conversion, meaning it allows narrowing conversion. For example, you can copy a pair<double, double> to a pair<int, int>, truncating the decimals without a single warning from the compiler. This is not the behavior I want.

cpp
std::pair<double, double> src{1.5, 2.5};
std::pair<int, int> dst = src; // Silent truncation!

I ran it, and the output is indeed 1, 2. The compiler (GCC 15 with -Wall -Wextra) stayed completely silent.

Writing a Safe Pair: NonNarrowConvertible

We discussed narrowing conversion detection mechanisms in depth in the first article. Here, we use a simpler method—leveraging the language rule that brace initialization prohibits narrowing—to implement NonNarrowConvertible. The idea is simple: constrain the conversion process with a concept during copy construction to disallow narrowing.

cpp
template <typename From, typename To>
concept NonNarrowConvertible = requires(From f) {
    { To{f} }; // Ill-formed if narrowing occurs
};

template <typename T1, typename T2>
struct SafePair {
    T1 first;
    T2 second;

    // Safe copy constructor
    template <typename U1, typename U2>
    requires NonNarrowConvertible<U1, T1> && NonNarrowConvertible<U2, T2>
    SafePair(const SafePair<U1, U2>& other)
        : first(other.first), second(other.second) {}
};

I spent a whole night figuring out this NonNarrowConvertible concept trick. Its principle relies on the language rule that brace initialization prohibits narrowing: if narrowing exists from From to To, To{f} is ill-formed. The requires expression detects this ill-formed nature and turns it into a concept failure rather than a hard compiler error. This elevates narrowing detection from "runtime data loss" to "compile-time rejection."

However, there's a caveat: this implementation relies on "whether brace initialization compiles," not precisely on "whether narrowing exists." For numeric types, these are equivalent, but for complex types, brace initialization might fail for other reasons (e.g., missing corresponding constructor), which could lead to confusing error messages. It's sufficient for the current scenario, but for more complex cases, we can refine the concept later.

Also, C++ protection against narrowing is incomplete—the brace initialization rule only applies to initialization. Assignment, function argument passing, and return values are all wide open. True safety relies on type system constraints, like using concepts to block unsafe conversion paths at compile time.

First Taste of C++26 Static Reflection

At this point, while hand-rolling SafePair exercises our understanding of concept composition, the speaker presented a simpler idea: instead of defining "narrowing" ourselves, why not just ask the compiler "can you initialize a To with a From value?" This subtle shift in perspective solved a problem that stumped me for a long time—our manual version wasn't accurate enough for scenarios like double to long double, but if you ask the compiler directly "can you initialize To with From," the compiler knows perfectly well.

However, the speaker honestly reminded us: don't confuse this specific trick with the general methodology it illustrates. The "small concepts combined into big concepts" technique we learned earlier is the truly reusable weapon. This initialization version works purely because "can initialize" happens to overlap highly with "can narrow" in this specific scenario. In assignments or comparisons, you won't be so lucky; you still have to assemble concepts manually. Tools in the toolbox are general, but where you can cut corners depends on luck.

At the end of the talk, something "completely impossible five years ago" was shown—Static Reflection (P2996). Previously, if you needed to know a struct's members, their names, types, and memory offsets, you had to use macros pre-C++26. One typo meant silent errors and debugging despair. C++26 static reflection finally lets us ask the compiler directly "what does this type look like?"

cpp
#include <experimental/meta>
#include <iostream>

struct MyStruct {
    int a;
    char b;
    double c;
};

int main() {
    constexpr auto info = std::experimental::reflect(MyStruct{});
    std::experimental::for_each(info, [](auto member) {
        std::cout << "Name: " << std::experimental::name_of(member)
                  << ", Offset: " << std::experimental::offset_of(member)
                  << std::endl;
    });
}

My output looked like this (specific offsets may vary by platform and compiler alignment):

text
Name: a, Offset: 0
Name: b, Offset: 4
Name: c, Offset: 8

Wait, c's offset is 8, not 12—this is memory alignment at work. double requires 8-byte alignment, so the compiler inserted 4 bytes of padding after b. Previously, verifying this required manual calculation or writing macros one by one. Now, one line of code reveals everything.

Recall when we learned concepts: concepts essentially ask the compiler "what conditions does this type satisfy?" But concepts can ask limited questions—"can add?", "can iterate?", "can convert?". Static reflection opens up the compiler's internal knowledge about the type: names, members, base classes, function signatures, template parameters... take what you want. I used to think templates were black magic; concepts made black magic readable; static reflection makes black magic composable. In the future, combining reflection with concepts allows iterating members at compile time, checking constraints, and generating code—clean and efficient.

However, while C++26 static reflection was voted into the working draft in mid-2025 (P2996), as of early 2026, no mainstream compiler implements it fully—GCC and Clang (Bloomberg's experimental branch clang-p2996) are actively developing but incomplete. The code above is based on P2996 R12 for learning purposes; don't expect to use it in production yet.


Concepts Are Not Just "Template Parameter Tags"—They Are More Flexible Than You Think

Honestly, for the first two years of learning concepts, I treated them as syntactic sugar for "tagging template parameters." Writing a concept felt like writing SFINAE if-else, just prettier. Only when I revisited this recently did I realize how shallow my understanding was—concepts are essentially compile-time functions. Since they are functions, they can accept multiple arguments, even value arguments. This realization was a lightbulb moment—many constraints I thought "couldn't be expressed with concepts" weren't language limitations, I just hadn't figured it out.

Debunking a Myth: Concepts Aren't Limited to One Type Parameter

When I wrote concepts, they almost always looked like this:

cpp
template <typename T>
concept Integral = std::is_integral_v<T>;

One concept, one type parameter. But think about this—if a generic function accepts two different type parameters, is constraining them individually enough? For a function signature template <typename T, typename U> void foo(T, U), using Integral<T> and Integral<U> only says "T is integral, U is integral." It says nothing about the relationship between T and U. But since they appear in the same function, they likely have some association, otherwise why put them together?

The talk mentioned a statistic: over half of concepts accept more than one parameter. I thought that was exaggerated, but checking my project code proved it true—as soon as generic code gets slightly complex, cross-type constraints are everywhere.

Here's a concrete example. Suppose I'm writing a serialization library and need a concept to express "a value of type T can be serialized into a buffer of type U":

cpp
template <typename T, typename U>
concept SerializableTo = requires(T value, U buffer) {
    { serialize(value, buffer) } -> std::same_as<void>;
};

You see? If this concept only accepted one parameter, I'd have to split the constraint (losing relationship info) or use awkward nesting. Multi-parameter concepts let you explicitly state "T and U must satisfy this relationship," making it clear to readers that these types aren't independent.

What Excited Me More: Concepts Can Accept Value Parameters

I didn't know this at all. I thought concept parameter lists could only contain types (typename T) or template template parameters. I didn't realize they could accept ordinary values. This means you can mix "type constraints" and "value constraints" at compile time, and the code looks almost identical to runtime code.

Suppose I'm writing network code requiring a buffer with two hard requirements: first, it must hold at least k elements; second, its size must be a power of two (common in memory pools and ring buffers for modulo optimization via bitwise AND).

cpp
template <typename T, std::size_t N>
concept BufferRequirement = requires {
    requires N >= 64; // At least 64 elements
    requires (N & (N - 1)) == 0; // Must be power of 2
};

Then I define a few buffer types to test:

cpp
template <typename T, std::size_t N>
struct MyBuffer {
    T data[N];
};

Now use this concept to constrain a template function:

cpp
template <typename T, std::size_t N>
requires BufferRequirement<T, N>
void process_buffer(MyBuffer<T, N>& buf) {
    // ...
}

Let's run it and see how clear the error is:

cpp
MyBuffer<int, 32> buf1; // Error: N >= 64 failed
MyBuffer<int, 128> buf2; // OK
MyBuffer<int, 100> buf3; // Error: (N & (N - 1)) == 0 failed

I tried this in GCC 15. Uncommenting the buf1 line causes the compiler to point directly to the failed constraint N >= 64. Without concepts, using static_assert, you'd write checks inside the function body, and deep call stacks make errors hard to trace. Concepts bring constraints to the signature, pointing errors at the call site—a tangible experience improvement.

Looking back at why this works—a concept declaration is essentially template <...> concept Name = bool-expression. This boolean expression is evaluated at compile time. Since it's a template parameter list, typename T, typename U, std::size_t N can all appear. C++20 templates support non-type parameters, and concepts just inherit this. There's no special "concept value parameter syntax"; it's just a normal template non-type parameter.

And N works in the concept's requires expression because it's declared as a template parameter. The logic relies on compile-time constant evaluation.

Will we actually use value-parameter concepts in development? In my experience, when writing libraries or frameworks, yes. Thread pools requiring queue sizes to be powers of two, memory allocators requiring block alignment, SIMD requiring vector lengths to be multiples of 4/8/16, protocol parsers requiring buffers to fit a frame—in these scenarios, "is the type right" and "is the value compliant" are intertwined. Previously, I'd use static_assert or if constexpr scattered in function bodies. Now, value-parameter concepts let me centralize all constraints at the interface signature.

Now I understand why the "concepts are compile-time functions" perspective is crucial. If you treat them as "template parameter tags," you're limited to "one concept, one type." If you treat them as functions—accepting multiple arguments, value arguments, calling other compile-time functions, composing—their expressive power is almost as strong as runtime code, just executed at compile time.


Judging Powers of Two: From a Small Algorithm to the Feud Between Generic and OOP

A "Slap the Thigh" Algorithm

I was solving a basic problem recently: determine if an integer is a power of two. I used the dumbest method—constantly dividing by two and checking remainders, or the "advanced" way using logarithms. But then I saw a bitwise approach. Honestly, the logic was so clean it impressed me.

The idea: if a number is a power of two, its binary representation has exactly one 1 and the rest 0s. For example, 8 is 1000, 32 is 100000. So you just shift right repeatedly, dropping the last bit while checking if it was 1. If you shift until only one 1 remains, it's a power of two; if you hit a non-zero bit, return false; if you finish and it's all 0s, 0 isn't a power of two, so return false.

I always thought the classic bitwise check n & (n - 1) == 0 was the one-liner, but that has a pitfall—it treats 0 as true, requiring an extra n != 0 check. The shifting method requires more lines but is logically self-contained without special cases. I wrote a verification:

cpp
bool is_power_of_two_shift(int n) {
    if (n <= 0) return false;
    int count = 0;
    while (n > 0) {
        if (n & 1) count++;
        n >>= 1;
    }
    return count == 1;
}

The results matched the classic method, but the shifting logic reads smoother because "intent" and "implementation" align—counting the ones. The classic n & (n - 1) is clever but requires thinking about why it clears the lowest set bit. That said, the classic method is faster—one AND and one comparison versus a loop—so I'll still use it in production. But understanding the shifting approach helps build bitwise intuition.

Generic vs. OOP: A Question I Struggled With

Moving on from the algorithm, I want to discuss a bigger topic that finally clarified a vague concept I had—the fundamental difference between generic programming and object-oriented programming.

When I started C++ in 2022, I learned classes and inheritance first, thinking OOP was everything. Later, encountering templates and seeing angle brackets and compilation errors gave me headaches—"black magic" to avoid. Then I learned concepts and realized generic programming could do much more than I thought, but a question remained: when should I use which?

Now I get it. The core difference is one sentence: Generic programming is more flexible and doesn't rely on indirect function calls.

The "no indirect calls" part is key. OOP polymorphism uses virtual function tables (vtables). Calling a virtual function requires a table lookup and a jump at runtime—indirection. Generic programming determines types at compile time, inlining or specializing as needed, generating code as direct as hand-written code. So generic programming is usually faster—not magic, but a mechanism decision.

My "Blood and Tears" Designing a Container Base Class

Speaking of OOP limitations, I must吐槽 a pit I fell into. I tried to manage different container types uniformly, naturally thinking: define a Container base class, then Vector and List inherit from it.

cpp
class Container {
public:
    virtual void push_back(int) = 0;
    virtual int& operator[](size_t) = 0;
    // ...
};

Looks good, right? But it explodes in practice. Vector's push_back and List's push_back have different behavior details. Vector has operator[] but List doesn't. List has splice but Vector doesn't use it. I tried to find a "common interface" in the base class covering all container operations, but their operation sets and constraints differ.

I struggled for days. The result was either a bloated interface (methods throwing "not supported" in subclasses) or a tiny one (just push_back, why have a base class?). I gave up and switched to template functions for containers—things became simple:

cpp
template <typename C>
concept SequenceContainer = requires(C c) {
    { c.push_back(std::declval<typename C::value_type>()) };
    { c.size() } -> std::convertible_to<std::size_t>;
};

template <SequenceContainer C>
void process(C& container) {
    // ...
}

You see? With concepts, I can impose different requirements on different container types without forcing them into a unified base class. vector needs random access? Write a concept for that. list doesn't? Write another. They satisfy their own constraints and use their own function overloads. The principle is simple: don't try to box everything into a fixed interface; match the best processing method based on the type's capabilities.

They Are Not Enemies, They Are Partners

But I must emphasize: don't reject OOP entirely just because I praised generic programming. OOP has a scenario generic programming struggles to replace: open type sets. What's an open type set? When writing code, you don't know what types will be added in the future. For example, a GUI framework's drawing system defines a Shape base class with a draw virtual function. Users can write a Circle inheriting from Shape in their code, and your framework handles this new type without recompilation. This "runtime extension" capability is something generic programming can't do because templates must know all types at compile time.

So my understanding is: if you can enumerate all types (or know them at compile time), use generic programming for better performance and precision; if you need runtime dynamic type extension, use OOP polymorphism. They are complementary, not mutually exclusive.

draw_all: Same Problem, Two Solutions

To verify this, I wrote a classic "draw all shapes" example using both OOP and generic programming:

Expand (30 lines)Collapse
cpp
// OOP Approach
class Shape {
public:
    virtual void draw() const = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    void draw() const override { std::cout << "Circle\n"; }
};

class Square : public Shape {
public:
    void draw() const override { std::cout << "Square\n"; }
};

void draw_all(const std::vector<std::unique_ptr<Shape>>& shapes) {
    for (const auto& s : shapes) s->draw();
}

// Generic Approach
template <typename T>
concept Drawable = requires(T t) {
    { t.draw() } -> std::same_as<void>;
};

void draw_all(const Drawable auto& container) {
    for (const auto& item : container) item.draw();
}

Running the output:

text
Circle
Square
Circle
Square

Note the last example—draw_all is a generic function, but it handles Circle (an OOP type with virtual functions) perfectly because Circle has a draw method satisfying the Drawable concept. In other words, generic programming with concepts covers everything classic OOP class hierarchies can do, while also handling types outside any hierarchy (like Circle and Square if they didn't inherit).

I've finally connected the dots. I used to think templates and concepts were "advanced tricks" while virtual functions were "orthodox." Now I realize generic programming is more expressive and faster due to no indirection. But OOP is irreplaceable for open type sets. Complementary tools, chosen by scenario—that's the right way.


Concepts Don't Need to Be Perfect on Day One—Practice Makes Them Precise

This analogy is apt—you're Overthinking like an LLM. Before you even start, you make crazy assumptions, trying to use computation to describe an essentially uncertain world. The result? Every time you want to write a concept, you stare at the screen for hours, thinking "did I miss a constraint?", and never write a single line of code.

Concepts are like "contracts" for the type system—once signed, they can't be changed, so you must enumerate all constraints immediately—many people think this at first. For example, constraining a "numeric type," I'd agonize: add std::is_integral? Add std::is_floating_point? Add std::is_arithmetic? The more I thought, the more I scared myself away.

Actually, concepts are like normal code. The first version is for "just getting started." You don't need to consider all edge cases on day one. Write the constraints you currently need, add more later when you find them lacking. That's perfectly fine.

Writing a Number Concept from Scratch

For the full implementation and deep discussion on Number, refer to the first article. Here, I only want to show the core skeleton to illustrate the "iterative evolution" philosophy:

cpp
template <typename T>
concept Number = requires(T a, T b) {
    { a + b } -> std::convertible_to<T>;
    { a - b } -> std::convertible_to<T>;
    { a * b } -> std::convertible_to<T>;
    { a / b } -> std::convertible_to<T>;
};

You see, this Number concept misses a lot: no constraints for += or -=, no compound assignment like *=, no << output, nothing. But for a function like add(T a, T b), it's fully sufficient. If tomorrow I write a function needing equality comparison, I can write a EqualityComparable concept for that function, instead of bloating Number retroactively.

Suppose I later need a more complete numeric concept, I can extend based on existing Number rather than overthrowing it:

cpp
template <typename T>
concept CompleteNumber = Number<T> && requires(T a) {
    { a += a } -> std::same_as<T&>;
    { ++a } -> std::same_as<T&>;
};

This "constrain what you use" approach is similar to typeclasses in functional programming—define minimal, orthogonal capability primitives, then compose them where needed, rather than starting with a "God concept" stuffing everything in.

The Worry: "Will It Match the Wrong Thing?"

I worried about this too: if my Number concept only checks for arithmetic operators, could some type coincidentally have them but isn't a number, and get matched incorrectly?

The talk mentioned a classic example: ForwardIterator and InputIterator are syntactically almost identical. Their difference is semantic—forward iterators guarantee multiple passes yield the same result; input iterators don't. This difference can't be expressed by pure syntactic constraints.

But let's be realistic. A type coincidentally implementing five arithmetic operators with matching signatures but "not being a number"—the probability is extremely low. If a type provides these operators and signatures match, it syntactically behaves like a number, even if semantically it's a "matrix" or "polynomial." In scenarios only needing add/subtract/multiply/divide, using it is fine.

Moreover, concept-constrained name lookup is safer than unconstrained lookup. When you constrain a function template parameter with a concept, the compiler only considers candidates satisfying the concept during overload resolution. This is more reliable than traditional SFINAE hiding conditions in return types via std::enable_if, because concepts are explicit, named constraints. The compiler tells you "this type doesn't satisfy Number" instead of a fifty-line template instantiation error.

Complementary Relationship with OOP Hierarchical Constraints

Another point clarified it for me: concepts provide "flat" capability constraints, while OOP class hierarchies provide "structured" hierarchical constraints. They aren't mutually exclusive; they are complementary.

For example, you have a class hierarchy Shape (structured, inheritance). But you can also write a Drawable concept. This concept doesn't care if your type inherits from Shape; it only cares if you can be output to a stream. A Circle can satisfy both "is a subclass of Shape" and "is Drawable." These constraints serve different purposes.

I used to think "either OOP or template generics, pick one." Now that seems too narrow. Tools in the toolbox aren't for using just one.


Concepts Aren't Just for Template Parameters—I Completely Missed This

Honestly, seeing this content touched me. Since learning C++ in 2022, I had a deep-rooted impression: concepts are for constraining template parameters, written inside template <...>. That's it. Turns out, concepts can be used independently of template parameters, even on normal function parameters. This opened a door I hadn't seen before.

First, the "Tail Wagging the Dog" Problem

Before expanding, I want to share a resonating point. We often fall into inverted thinking when discussing "how to distinguish forward and input iterators." We rack our brains inventing syntactic differences—adding a tag to one, or a special member function, then writing a concept to detect the tag. The entire design exists to solve one specific problem, getting more complex.

The right approach: design the most elegant solution for the general problem first. Then, if special cases really need distinction, use a small trick as a patch. Don't reverse the priority.

Starting Simple: Constraining Normal Function Parameters with Concepts

Let's look at a basic example. Suppose I have a function processing integers, accepting int, long, short, but not float, double.

With traditional template thinking, you might write:

cpp
template <typename T>
void process(T t) {
    static_assert(std::is_integral_v<T>, "Must be integral");
    // ...
}

I've written this countless times. The problem is the error message—you see a template instantiation stack failure, incomprehensible to beginners.

Now, switch to concept thinking, but here's the key—I don't have to write a template:

cpp
template <typename T>
concept Integral = std::is_integral_v<T>;

void process(Integral auto t) {
    // ...
}

Notice? No template keyword, no typename T. Just a normal function, but the parameter type is Integral auto instead of int. The compiler sees the Integral concept and treats it as a constraint, checking if the passed type satisfies it during overload resolution.

When I first saw this, it clicked—concepts can be used like this! This is basically generic programming syntax leaning towards normal programming. When writing functions, the mindset shifts from "I'm writing a template" to "I'm writing a function, the parameter type is a concept." This psychological shift is important.

Of course, you can write it as a template; the effect is equivalent:

cpp
template <Integral T>
void process(T t) {
    // ...
}

In most scenarios, there's no essential difference; the compiler's overload resolution is the same. But the non-template syntax has a psychological benefit: reading code, you first see a normal function, no need to mentally preprocess "this is a template, what will T deduce to?" The intent is clearer.

However, a small pitfall: if you use the non-template syntax, you can't use the name T in the function body because you never declared it. You need to use decltype(t) or auto:

cpp
void process(Integral auto t) {
    using Type = decltype(t); // Need this to get the type
    // ...
}

The Scenario That Really Clicked for Me: Infrastructure Needs in Industrial Code

The integer example above is too simple; you might think, "That's all there is to it." What truly made me understand the value of this feature was the industrial software scenario mentioned in the talk.

During a previous internship, I participated in a large C++ project, and I had a profound realization: production code is worlds apart from teaching code. The std::advance function in textbooks is just three or four lines, moving an iterator forward by n steps—clean and concise. But in actual projects, std::advance, or core functions similar to it, are stuffed with things unrelated to the core logic—logging, debug assertions, correctness checks, telemetry data collection, call chain tracing... Every time an infrastructure requirement is added, the function bloats a bit more.

Let's look at an example that simulates this scenario. Suppose I have a simplified version of std::advance that moves an iterator forward by two steps:

cpp
void advance_2_steps(InputIt& it) {
    ++it;
    ++it;
}

Now, returning to the feature that concepts aren't limited to template parameters. If we constrain the parameters of advance_2_steps using a concept and write it in a non-template form, we actually gain a crucial capability: the function's "identity" in the type system becomes much clearer. It is no longer a template open to all types, but a function with a clear interface contract. This lays the foundation for using concepts for more fine-grained dispatching and composition later.

cpp
void advance_2_steps(std::random_access_iterator auto& it) {
    it += 2; // Fast path: O(1)
}

template <std::input_iterator It>
    requires (!std::random_access_iterator<It>) // Exclude random access iterators
void advance_2_steps(It& it) {
    ++it; // Slow path: O(N)
    ++it;
}

Here, the first function uses the std::random_access_iterator auto shorthand syntax (a shorthand form allowed in C++20). The second function, because it needs to exclude random access iterators (to avoid ambiguity from matching both), uses the full template + requires syntax. I added !std::random_access_iterator<It> to the constraint to ensure mutual exclusion. Two functions with the same name achieve overloading through different concept constraints—random access iterators take the it += 2 fast path, while plain input iterators take the slow path of calling ++it twice. This is the more elegant overloading mechanism brought by concepts.

A Previous Misunderstanding of Mine

Speaking of this, I must confess a previous misunderstanding. When I first learned concepts, I thought their greatest value was "making template error messages look better." It is true that concept error messages are a hundred times prettier than static_assert failures, but if that's all you see, you are greatly underestimating concepts.

The true value of concepts lies in how they change the mindset of generic programming. When writing templates before, my thought process was "I need a type parameter here, let me add a constraint"; now with concepts, my thinking becomes "I need something that satisfies a certain semantics here." The shift from "type parameter" to "semantic requirement" seems subtle, but it actually affects your entire design.

Just like the advance_2_steps example above, I didn't write "a template function accepting auto," but rather "a function accepting random access iterators" and "a function accepting input iterators." The intent of the code is elevated from implementation details to the semantic level.

The Misconception About "Isolated Compilation"

Many people (including the speaker initially) believe that generic functions must be able to compile in isolation—that is, type checking should be completable just by looking at the function definition itself, without the context of the call site. However, I later realized that this is neither what we truly need nor what concepts provide.

I also had this misconception before. I felt that a good generic function should be "self-contained" and able to prove its own type requirements are reasonable. But thinking about it carefully, this is actually an over-requirement. A generic function's constraints should describe "what I need," not "I can handle everything." Whether the specific type passed at a given call site satisfies the requirements is a contract verification between the call site and the function constraints; the function doesn't need to worry about it. We will discuss the template compilation model and type checking in more depth in Part 4.

The example using the std::integral constraint on parameters is the best illustration: the function simply declares "I need an integer," and whether you pass int or long is your business. The function doesn't need to know all possible integer types in isolation.

At this point, this usage of concepts finally clicked for me. It is not just a "better static_assert," but a tool that allows you to think about interfaces in a semantic way. Furthermore, it isn't limited to template parameters—you can use it directly on normal function parameters, which brings the syntax of generic programming much closer to ordinary programming. Looking back, it wasn't actually that hard; I just had colored glasses on, viewing "concept = template constraint syntax sugar."

v0.7.1-2-g3718060 · 3718060 · 2026-07-06