본문으로 건너뛰기 The Ultimate Complete Guide to C++20 Concepts | A New Era...

The Ultimate Complete Guide to C++20 Concepts | A New Era of Template Constraints

The Ultimate Complete Guide to C++20 Concepts | A New Era of Template Constraints

이 글의 핵심

A comprehensive guide to C++20 Concepts for clarifying template constraints. Learn about requires, standard concepts, custom concepts, replacing SFINAE, and more.

originalId: cpp-concept

What Are C++20 Concepts and Why Do We Need Them?

Problem Scenario: The Template Error Message Nightmare

The Problem: Passing an incorrect type to a template function often results in error messages spanning hundreds of lines.

template<typename T>
T add(T a, T b) {
    return a + b;
}
int main() {
    add("hello", "world");  
    // Error: no operator+ for const char*
    // 50 lines of template instantiation error messages...
}

The Solution: Concepts allow you to specify constraints on template arguments, producing clear and immediate errors when an invalid type is used.

template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::same_as<T>;
};
template<Addable T>
T add(T a, T b) {
    return a + b;
}
int main() {
    add("hello", "world");
    // Error: const char* does not satisfy Addable
    // Clear and concise error message!
}
flowchart TD
    subgraph before[Before C++20]
        call1["add(string, string)"]
        inst1["Template Instantiation"]
        err1["50-line Error Message"]
    end
    subgraph after[With C++20 Concepts]
        call2["add(string, string)"]
        check["Concept Check"]
        err2["Clear Error: Addable Violation"]
    end
    call1 --> inst1 --> err1
    call2 --> check --> err2

Table of Contents

  1. Basic Syntax: concept, requires
  2. Standard Concepts
  3. Writing Custom Concepts
  4. requires Expressions
  5. Concept Composition
  6. Common Errors and Solutions
  7. Production Patterns
  8. Complete Example: Generic Container
  9. SFINAE vs Concepts
  10. Migration Guide

1. Basic Syntax: concept, requires

Defining a Concept

#include <concepts>
// Basic form
template<typename T>
concept MyConstraint = /* boolean expression */;
// Example: Types that support addition
template<typename T>
concept Addable = requires(T a, T b) {
    { a + b } -> std::same_as<T>;
};

Using a Concept

// Method 1: template<Concept T>
template<Addable T>
T add(T a, T b) {
    return a + b;
}
// Method 2: requires clause
template<typename T>
    requires Addable<T>
T add(T a, T b) {
    return a + b;
}
// Method 3: trailing requires
template<typename T>
T add(T a, T b) requires Addable<T> {
    return a + b;
}
// Method 4: auto (abbreviated function template)
auto add(Addable auto a, Addable auto b) {
    return a + b;
}

2. Standard Concepts

Type Categories

#include <concepts>
// Integral types
template<std::integral T>
T square(T x) {
    return x * x;
}
// Floating-point types
template<std::floating_point T>
T sqrt_approx(T x) {
    return x / 2;
}
// Signed integers
template<std::signed_integral T>
T negate(T x) {
    return -x;
}
// Unsigned integers
template<std::unsigned_integral T>
T increment(T x) {
    return x + 1;
}
int main() {
    square(5);          // OK: int
    sqrt_approx(9.0);   // OK: double
    negate(-10);        // OK: int
    increment(10u);     // OK: unsigned int
}

Relationship Concepts

// Same type
template<typename T, typename U>
    requires std::same_as<T, U>
void func(T a, U b) {
    // T and U are the same type
}
// Convertible types
template<typename From, typename To>
    requires std::convertible_to<From, To>
To convert(From value) {
    return static_cast<To>(value);
}
// Derived relationship
template<typename Derived, typename Base>
    requires std::derived_from<Derived, Base>
void process(Derived* ptr) {
    Base* base = ptr;  // OK
}

Comparison Concepts

// Equality comparable
template<std::equality_comparable T>
bool is_equal(T a, T b) {
    return a == b;
}
// Totally ordered
template<std::totally_ordered T>
T max(T a, T b) {
    return (a > b) ? a : b;
}

Callable Concepts

// Callable
template<typename F, typename... Args>
    requires std::invocable<F, Args...>
auto call(F func, Args... args) {
    return func(args...);
}
// Predicate (returns bool)
template<typename F, typename T>
    requires std::predicate<F, T>
bool test(F pred, T value) {
    return pred(value);
}

Object Concepts

// Default constructible
template<std::default_initializable T>
T create() {
    return T{};
}
// Copy constructible
template<std::copy_constructible T>
T duplicate(const T& value) {
    return T(value);
}
// Move constructible
template<std::move_constructible T>
T transfer(T&& value) {
    return T(std::move(value));
}

3. Writing Custom Concepts

Container Concept

template<typename T>
concept Container = requires(T c) {
    // Type members
    typename T::value_type;
    typename T::iterator;
    
    // Member functions
    { c.size() } -> std::same_as<std::size_t>;
    { c.begin() } -> std::same_as<typename T::iterator>;
    { c.end() } -> std::same_as<typename T::iterator>;
    { c.empty() } -> std::convertible_to<bool>;
};
template<Container C>
void print_size(const C& container) {
    std::cout << "Size: " << container.size() << '\n';
}
int main() {
    std::vector<int> v = {1, 2, 3};
    print_size(v);  // OK
    
    int arr[] = {1, 2, 3};
    // print_size(arr);  // Error: int[] not Container
}

Serializable Concept

template<typename T>
concept Serializable = requires(T obj, std::ostream& os, std::istream& is) {
    { obj.serialize(os) } -> std::same_as<void>;
    { T::deserialize(is) } -> std::same_as<T>;
};
template<Serializable T>
void save(const T& obj, std::ostream& os) {
    obj.serialize(os);
}
template<Serializable T>
T load(std::istream& is) {
    return T::deserialize(is);
}

Numeric Concept

template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template<Numeric T>
T abs(T value) {
    return value < 0 ? -value : value;
}
template<Numeric T>
T clamp(T value, T min, T max) {
    if (value < min) return min;
    if (value > max) return max;
    return value;
}

4. requires Expressions

Simple Requirements

template<typename T>
concept HasSize = requires(T t) {
    t.size();  // size() member function exists
};

Type Requirements

template<typename T>
concept HasValueType = requires {
    typename T::value_type;  // value_type type member exists
};

Compound Requirements

template<typename T>
concept Comparable = requires(T a, T b) {
    { a < b } -> std::convertible_to<bool>;
    { a > b } -> std::convertible_to<bool>;
    { a == b } -> std::convertible_to<bool>;
};

Nested Requirements

template<typename T>
concept ComplexConstraint = requires(T t) {
    // Simple requirement
    t.method();
    
    // Type requirement
    typename T::value_type;
    
    // Compound requirement
    { t.size() } -> std::same_as<std::size_t>;
    
    // Nested requirements
    requires std::default_initializable<T>;
    requires sizeof(T) <= 64;
};

5. Concept Composition

Combining Concepts with && and ||

Concepts compose with ordinary logical operators, letting a constraint be built from smaller, individually testable pieces.

template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template<typename T>
concept SortableContainer = requires(T t) {
    { t.begin() } -> std::input_iterator;
    { t.end() } -> std::input_iterator;
} && std::totally_ordered<typename T::value_type>;

Subsumption: More Specific Concepts Win Overload Resolution

When two overloads are both viable, the compiler prefers the one whose concept subsumes (is a stricter superset of) the other — this is what lets you write a general version and a more specific, more optimized version without ambiguity errors.

template<std::input_iterator It>
void process(It begin, It end) { /* generic, slower path */ }
template<std::random_access_iterator It>  // subsumes input_iterator
void process(It begin, It end) { /* specialized, faster path */ }
// Called with a std::vector<int>::iterator: the random_access_iterator
// overload wins because random_access_iterator subsumes input_iterator.

Refining a Concept for a Specific Use Case

Layering constraints lets you express “a Container, but specifically one holding numeric elements” without duplicating the container requirements.

template<typename T>
concept NumericContainer = requires(T t) {
    { t.begin() } -> std::input_iterator;
    { t.end() } -> std::input_iterator;
} && Numeric<typename T::value_type>;

6. Common Errors and Solutions

Error 1: Constraint Not Satisfied

template<std::integral T>
T add(T a, T b) { return a + b; }
add(1.5, 2.5);  // ❌ error: constraints not satisfied [with T = double]

Fix: either use std::floating_point too (via a composed concept), or call with an integral type.

Error 2: Ambiguous Concept Overloads

template<typename T> concept A = requires(T t) { t.foo(); };
template<typename T> concept B = requires(T t) { t.foo(); };  // same requirement as A!
template<A T> void f(T);
template<B T> void f(T);  // ❌ ambiguous: A and B don't subsume each other

Fix: define B as A && <extra requirement> so the compiler can establish subsumption, or merge them into a single concept.

Error 3: Using auto Constraints Incorrectly

void f(std::integral auto x) { }  // ✅ constrained function parameter, C++20
std::integral auto g() { return 5; }  // ✅ constrained return type
// std::integral auto x = 5.0;  // ❌ error: 5.0 doesn't satisfy std::integral

Error 4: Forgetting Concepts Are Not Types

template<typename T>
concept Sized = requires(T t) { t.size(); };
// Sized x;  // ❌ error: Sized is a concept, not a type — cannot declare a variable of it

7. Production Patterns

Replacing enable_if Library Code

Migrating an existing enable_if-constrained API to concepts is usually a mechanical, low-risk change since the runtime behavior doesn’t change — only the compile-time constraint expression does.

// Before
template<typename T, typename = std::enable_if_t<std::is_arithmetic_v<T>>>
class NumericWrapper { T value; };
// After
template<std::integral T>  // or a custom Numeric concept for both int/float
class NumericWrapper { T value; };

Constraining Class Templates

template<typename T>
requires std::default_initializable<T> && std::copyable<T>
class Cache {
    std::vector<T> items;
public:
    void add(const T& item) { items.push_back(item); }
};

Concepts as API Documentation

A well-named concept doubles as living documentation of what an API actually requires — a caller reading template<Drawable T> void render(T obj) immediately knows the contract, unlike an unconstrained template<typename T>.

template<typename T>
concept Drawable = requires(T t, Canvas& c) {
    { t.draw(c) } -> std::same_as<void>;
};
template<Drawable T>
void render(const T& shape, Canvas& canvas) {
    shape.draw(canvas);
}

8. Complete Example: Generic Container

A self-contained example combining a custom concept, standard-library concepts, and subsumption-based overloading in one generic container.

#include <concepts>
#include <vector>
#include <iostream>
template<typename T>
concept Printable = requires(T t, std::ostream& os) {
    { os << t } -> std::same_as<std::ostream&>;
};
template<typename T>
requires Printable<T> && std::copyable<T>
class LoggedContainer {
    std::vector<T> items;
public:
    void add(const T& item) {
        items.push_back(item);
        std::cout << "Added: " << item << "\n";
    }
    template<std::input_iterator It>
    void addRange(It begin, It end) {
        for (auto it = begin; it != end; ++it) add(*it);
    }
    std::size_t size() const { return items.size(); }
};
int main() {
    LoggedContainer<int> container;
    container.add(42);
    std::vector<int> nums = {1, 2, 3};
    container.addRange(nums.begin(), nums.end());
    std::cout << "Size: " << container.size() << "\n";
}

9. SFINAE vs Concepts

AspectSFINAE (enable_if)Concepts (C++20)
ReadabilityLow — constraint hidden in template parameter listHigh — constraint reads like a sentence
Error messagesLong substitution-failure dumpsPoints directly at the unsatisfied requirement
ComposabilityManual &&/`
Overload resolutionWorks, but ambiguity is harder to reason aboutSubsumption gives predictable “most specific wins”
Compile timeCan be slower (deep substitution attempts)Generally faster (dedicated language feature)
// SFINAE
template<typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void f(T);
// Concepts — same constraint, clearer intent
template<std::integral T>
void f(T);

10. Migration Guide

Step 1: Identify enable_if/type_traits Usage

grep -rn "enable_if\|is_same_v\|is_integral_v\|is_base_of_v" src/

Step 2: Map Traits to Standard Concepts

Old trait checkEquivalent concept
std::is_integral_v<T>std::integral<T>
std::is_floating_point_v<T>std::floating_point<T>
std::is_default_constructible_v<T>std::default_initializable<T>
std::is_copy_constructible_v<T>std::copy_constructible<T>
std::is_base_of_v<Base, T>std::derived_from<T, Base>

Step 3: Replace Incrementally

Convert one template at a time and re-run the test suite — since concepts and SFINAE can coexist in the same codebase, there’s no need for a big-bang rewrite.

// Old and new can coexist during migration
template<typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
void legacy_func(T value);
template<std::integral T>
void modern_func(T value);

Step 4: Require C++20 in the Build

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

Other articles related to this topic.



Keywords Covered in This Article (Related Search Terms)

This article covers C++, concept, cpp20, template, constraint, requires.