본문으로 건너뛰기
Embedded C++ for Real Systems · 18/41

C++20 Concepts 활용 — 템플릿 제약과 가독성 개선

· Hawk · 5분 읽기

#한 줄 요약

“Concepts = templates의 type safety.” — 어떤 type을 받는지 함수 시그니처에 명시되며, 에러 메시지가 사람이 읽을 수 있는 형태가 됩니다.

#어떤 문제를 푸는가

C++20 이전의 template은 받을 수 있는 type 제약이 코드에 드러나지 않습니다.

template<typename T>
T add(T a, T b) {
return a + b;
}
add(1, 2); // OK
add(1.5, 2.5); // OK
add(std::string("a"), std::string("b")); // OK
add(MyStruct{}, MyStruct{}); // ERROR — operator+ 없으면 컴파일 에러

에러 메시지가 수십 줄짜리 내부 detail로 쏟아져 사용자가 이해하기 어렵습니다.

error: no match for 'operator+' (operand types are 'MyStruct' and 'MyStruct')
note: candidates are:
note: /usr/include/c++/11/bits/stl_iterator.h:1234:5: note: ...
note: /usr/include/c++/11/bits/stl_function.h:567:1: note: ...
(continue for 30 lines)

C++20의 concepts는 제약을 시그니처에 명시하고 명확한 에러 메시지를 제공합니다.

#Concept 정의

#include <concepts>
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;
}
add(1, 2); // OK
add(MyStructWithoutPlus{}, MyStructWithoutPlus{});
// ERROR — but with clear message:
// error: no matching function for call to 'add'
// note: candidate template ignored: constraints not satisfied
// note: because 'MyStructWithoutPlus' does not satisfy 'Addable'

에러 메시지가 의미를 가지며, “Addable을 만족하지 않는다”는 사실이 즉시 드러납니다.

#Concept syntax 세 가지

#1. requires clause

template<typename T>
requires std::integral<T>
T square(T x) {
return x * x;
}

#2. 짧은 form

template<std::integral T>
T square(T x) {
return x * x;
}

#3. abbreviated function template

auto square(std::integral auto x) {
return x * x;
}

가장 짧고, 함수 시그니처 자체가 제약을 표현하므로 권장됩니다.

#표준 concepts (<concepts>)

// 기본 type
std::integral<T>
std::signed_integral<T>
std::unsigned_integral<T>
std::floating_point<T>
// 관계
std::same_as<T, U>
std::derived_from<Derived, Base>
std::convertible_to<From, To>
std::common_with<T, U>
// 비교
std::equality_comparable<T>
std::totally_ordered<T>
std::three_way_comparable<T>
// 호출 가능
std::invocable<F, Args...>
std::predicate<F, Args...>
// 생성/이동
std::default_initializable<T>
std::copy_constructible<T>
std::move_constructible<T>
std::movable<T>
std::copyable<T>
// 범위 (ranges)
std::ranges::range<R>
std::ranges::input_range<R>
std::ranges::random_access_range<R>

C++ Core Library가 대부분의 표준 concepts를 제공하므로 직접 정의할 일이 많지 않습니다.

#requires expression

custom concept 정의의 기본 도구입니다.

template<typename T>
concept Container = requires(T c) {
typename T::value_type; // 1. nested type 존재
typename T::iterator;
{ c.begin() } -> std::same_as<typename T::iterator>; // 2. method 시그니처
{ c.end() } -> std::same_as<typename T::iterator>;
{ c.size() } -> std::convertible_to<std::size_t>; // 3. 변환 가능 반환
};

requires 안에는 4가지 표현이 있습니다.

  1. Simple requirementexpr;로, expr이 valid해야 합니다.
  2. Type requirementtypename T::xxx;로, nested type이 존재해야 합니다.
  3. Compound requirement{ expr } -> Concept;로, expr의 결과가 concept을 만족해야 합니다.
  4. Nested requirementrequires Concept<T>;로, 또 다른 concept을 적용합니다.

#임베디드 — Driver concept

template<typename T>
concept Driver = requires(T d) {
{ d.init() } -> std::same_as<bool>;
{ d.reset() } -> std::same_as<void>;
{ d.is_ready() } -> std::convertible_to<bool>;
};
template<Driver D>
void register_driver(D& driver) {
if (!driver.init()) {
log_error("driver init failed");
return;
}
// ...
}

Driver를 만족하지 않는 type은 시그니처에서 거부되므로 function body 안에서 검사를 할 필요가 없습니다.

#임베디드 — 컴파일 타임 dispatch with concepts

if constexpr과 concept을 함께 씁니다.

template<typename T>
concept HasUserDeserialize = requires(T t, const uint8_t* buf, size_t len) {
{ t.deserialize(buf, len) } -> std::same_as<bool>;
};
template<typename T>
bool decode(T& obj, const uint8_t* buf, size_t len) {
if constexpr (HasUserDeserialize<T>) {
return obj.deserialize(buf, len);
} else if constexpr (std::is_trivially_copyable_v<T>) {
if (len < sizeof(T)) return false;
std::memcpy(&obj, buf, sizeof(T));
return true;
} else {
static_assert(sizeof(T) == 0,
"Type must have deserialize() or be trivially copyable");
}
}

우선순위가 코드에 그대로 드러나, type별 분기가 자연스럽게 읽힙니다.

#Concept 조합

&&(and)와 ||(or)로 조합합니다.

template<typename T>
concept NumericSerializable =
std::integral<T> ||
std::floating_point<T>;
template<typename T>
concept LightweightNumeric =
NumericSerializable<T> &&
sizeof(T) <= 4;
template<LightweightNumeric T>
void write_field(T value) {
// ...
}
write_field(int8_t(5)); // OK
write_field(float(1.5f)); // OK
write_field(double(1.5)); // ERROR — double은 8 byte
write_field(std::string()); // ERROR — not numeric

#CRTP + Concepts — 강력한 결합

Part 2-08의 CRTP를 concept으로 명확하게 만듭니다.

template<typename T>
concept LoggerImpl = requires(T t, const char* msg) {
{ t.log_impl(msg) } -> std::same_as<void>;
};
template<LoggerImpl Derived>
class LoggerBase {
public:
void log(const char* msg) {
static_cast<Derived*>(this)->log_impl(msg);
}
};
class UartLogger : public LoggerBase<UartLogger> {
public:
void log_impl(const char* msg) { /* */ }
};
class BadLogger : public LoggerBase<BadLogger> {
public:
// log_impl 누락 — 컴파일 에러
};
// error: BadLogger does not satisfy 'LoggerImpl'

CRTP base의 type 매개변수가 concept을 만족해야 하므로, missing method가 명확한 에러로 잡힙니다.

#Concept으로 SFINAE 대체

// SFINAE (C++17)
template<typename T,
std::enable_if_t<std::is_integral_v<T>, int> = 0>
T abs(T x) {
return x < 0 ? -x : x;
}
// Concept (C++20)
template<std::integral T>
T abs(T x) {
return x < 0 ? -x : x;
}
// abbreviated (C++20)
auto abs(std::integral auto x) {
return x < 0 ? -x : x;
}

C++20 쪽이 압도적으로 짧고 명확합니다.

#임베디드 — Range-based 알고리즘

#include <ranges>
template<std::ranges::input_range R>
auto sum(R&& range) {
typename std::ranges::range_value_t<R> total{};
for (auto&& val : range) total += val;
return total;
}
int arr[] = {1, 2, 3, 4, 5};
auto s = sum(arr); // 15
std::array<float, 3> floats = {1.0f, 2.0f, 3.0f};
auto sf = sum(floats); // 6.0f

array, vector, std::array, 사용자 컨테이너 어느 것이든 concept만 만족하면 그대로 동작합니다.

#Concept 오버로드 — 더 specific 우선

template<std::integral T>
void print(T value) { /* integer 전용 */ }
template<std::floating_point T>
void print(T value) { /* float 전용 */ }
template<typename T>
void print(T value) { /* fallback */ }
print(1); // integer 버전
print(1.5); // float 버전
print("str"); // fallback

더 제약된 concept이 우선합니다. 오버로드 resolution이 명확해집니다.

#자주 보는 함정과 안티패턴

#1. concept이 너무 복잡함

template<typename T>
concept Foo = requires(T t) {
{ t.method1() } -> std::convertible_to<int>;
{ t.method2() } -> std::same_as<void>;
{ t.method3(int(0), float(0.0f)) } -> std::same_as<bool>;
typename T::value_type;
typename T::iterator;
requires std::integral<typename T::value_type>;
// ... 20 more
};

작은 concept으로 분해해 조합합니다.

#2. requires 표현이 의도와 어긋남

template<typename T>
concept Foo = requires(T t) {
t.bar(); // 호출만 됨 (반환 타입 무관)
{ t.baz() } -> std::same_as<int>; // 반환 정확히 int
{ t.qux() } -> std::convertible_to<int>; // int로 변환 가능
};

표현마다 의미가 다르므로 의도에 맞게 골라 씁니다.

#3. concept 없이 그냥 template

template<typename T>
T process(T x); // 어떤 T든 받음

문서성과 사용자 친화성이 떨어집니다. concept으로 제약을 명시합니다.

#4. concept과 macro 혼동

concept은 type system이고 macro는 텍스트 치환이므로 둘은 전혀 다릅니다.

#5. toolchain 미지원

ARM Compiler 6, IAR 일부는 C++20 concepts를 지원하지 않습니다. C++17과 SFINAE 폴백을 쓰거나 toolchain을 업그레이드합니다.

#6. 직접 만든 concept 과용

표준 concept이 이미 있는데도 직접 정의하는 경우가 있습니다. std::integral 대신 MyInt를 새로 만드는 식이 그 예입니다.

#측정 — concept 사용 시 코드 변화

같은 함수를 C++17 SFINAE와 C++20 concept으로 비교합니다.

// C++17 — SFINAE
template<typename T,
std::enable_if_t<std::is_integral_v<T>, int> = 0>
T add(T a, T b) { return a + b; }
// C++20 — concept
template<std::integral T>
T add(T a, T b) { return a + b; }

코드 크기는 동일합니다. concept은 컴파일 시점에만 영향을 주며 런타임은 동일합니다.

컴파일 시간은 concept 쪽이 약간 빠릅니다. SFINAE 추론보다 단순하기 때문입니다.

에러 메시지는 concept이 훨씬 짧고 명확합니다.

#C++20 concepts의 실용 가치

  1. 에러 메시지 — 가장 큰 이점입니다. 30줄짜리 에러가 1~2줄로 줄어듭니다.
  2. 자체 문서화 — 함수 시그니처가 제약을 명시합니다.
  3. 오버로드 명확화 — 더 specific한 concept이 우선합니다.
  4. SFINAE 대체 — 짧고 읽기 좋습니다.

#정리

  • Concepts는 template 제약을 시그니처에 명시하는 C++20 기능입니다.
  • 표준 concepts는 <concepts><ranges>에 풍부하게 준비돼 있습니다.
  • 3가지 syntax를 지원합니다 — requires, template<Concept T>, auto func(Concept auto x).
  • CRTP와 concept을 함께 쓰면 interface가 명확해지고 누락된 멤버는 명확한 에러로 드러납니다.
  • SFINAE를 대체하며 짧고 읽기 좋고 에러 메시지가 훨씬 명확합니다.
  • toolchain 확인이 필수입니다 — GCC 10+, Clang 12+이며 ARM Compiler 6는 부분 지원입니다.

#관련 항목

#다음 글 (Part 3 시작)

Part 3-01: 동적 할당 없이 C++ 쓰기 — 임베디드의 첫 번째 원칙입니다. new, malloc 없이 modern C++를 쓰는 패턴을 다룹니다.

Embedded C++ for Real Systems · 19 of 41

  1. 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
  2. 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
  3. 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
  4. 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
  5. 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
  6. 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
  7. 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
  8. 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
  9. 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
  10. 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
  11. 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
  12. 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
  13. 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
  14. 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
  15. 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
  16. 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
  17. 17CRTP 패턴 분석 — vtable 없는 정적 다형성
  18. 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
  19. 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
  20. 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
  21. 21Custom Allocator 기초 — std::allocator 인터페이스 분석
  22. 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
  23. 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
  24. 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
  25. 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
  26. 26std::expected 분석 — C++23 결과 타입과 에러 전파
  27. 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
  28. 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
  29. 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
  30. 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
  31. 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
  32. 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
  33. 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
  34. 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
  35. 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
  36. 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
  37. 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
  38. 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
  39. 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
  40. 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
  41. 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교