Skip to content

C++20 Concepts 补课——让编译器帮你检查类型

概念介绍

我们在写模板代码的时候,经常会遇到一种令人抓狂的场景:模板参数明明传了一个完全不合适的类型,编译器也不吱一声,直到实例化到某个深处才炸出一整屏的错误信息,你花半个小时才从几百行模板展开里找到真正的问题在哪。这种体验写 C++ 的人都懂,而且越是复杂的模板库就越严重。C++20 引入的 Concepts(概念)就是为了彻底解决这个问题的——它让我们可以给模板参数加上有名字的约束,告诉编译器"这个类型必须满足什么条件",如果传入的类型不满足,编译器会在第一时间给出一个清晰、人性化的错误信息,而不是把模板展开的垃圾堆甩给你。

Concepts 本质上就是对模板参数的一组命名约束。在 C++20 之前,我们只能用 SFINAE(Substitution Failure Is Not An Error)加上 std::enable_if 这种黑魔法来做类似的事情,写起来又丑又难维护,报错信息更是灾难级别的。而 Concepts 把这些约束变成了一等公民——你可以给它们起名字,可以组合它们,可以在模板参数列表里直接使用它们,甚至可以用它们替代 typename 关键字。这不是什么语法糖,而是一次模板编程范式的升级。

动机

让我们先看一个没有 Concepts 的经典场景,来理解为什么我们需要它。假设我们写了一个打印容器内容的函数:

cpp
template<typename T>
void print_container(const T& c) {
    for (auto it = c.begin(); it != c.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n';
}

这个函数看起来没什么问题,但如果你传一个 int 进去:

cpp
print_container(42);  // int 哪来的 begin()?

编译器会给你吐出一大坨模板实例化错误,告诉你 int 没有 begin 方法,但这个错误信息夹杂在几百行模板展开的上下文里,可读性约等于零。更糟糕的是,如果你是一个库的作者,你的用户遇到这种错误的时候大概率直接骂娘——他甚至不知道是自己的类型传错了还是你的库有 bug。

有了 Concepts,我们就可以这样写:

cpp
template<typename T>
concept has_begin_end = requires(T t) {
    { t.begin() } -> std::input_or_output_iterator;
    { t.end() } -> std::sentinel_for<decltype(t.begin())>;
};

template<has_begin_end T>
void print_container(const T& c) {
    for (auto it = c.begin(); it != c.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n';
}

现在如果你传一个 int 进去,编译器会直接告诉你:"constraint has_begin_end<int> not satisfied",干净利落,一眼就能看懂问题出在哪。

最小示例:构建一个 is_printable 概念

让我们从头到尾构建一个完整的示例,把 Concepts 的几种用法都过一遍。首先是最基本的 requires 子句,它可以直接放在模板声明后面:

cpp
#include <iostream>
#include <concepts>
#include <string>

// 方法一:requires 子句直接约束
template<typename T>
    requires std::integral<T>
T add(T a, T b) {
    return a + b;
}

这里的 requires std::integral<T> 就是说"T 必须是整数类型"。std::integral 是标准库预定义的概念之一,涵盖了 intlongshortchar 等所有整数类型。如果你传一个 float 或者 std::string,编译器会立刻拒绝。

接下来,我们用 requires 表达式来定义自己的概念。requires 表达式是一种特殊的编译期检查机制,它可以验证一个类型是否拥有特定的成员、是否支持特定的操作:

cpp
// 方法二:用 requires 表达式定义自己的概念
template<typename T>
concept is_printable = requires(T t, std::ostream& os) {
    { os << t } -> std::same_as<std::ostream&>;
};

这段代码的意思是:"类型 T 是 printable 的,当且仅当你能把 T 的实例用 << 输出到 std::ostream"。{ os << t } 是要检查的表达式,-> std::same_as<std::ostream&> 是对表达式返回类型的约束——要求 os << t 的返回类型必须是 std::ostream&

有了概念之后,我们有两种方式使用它。第一种是在 template<> 参数列表里用概念名替代 typename,这是最推荐的写法:

cpp
// 方法三:概念直接作为模板参数约束(最简洁)
template<is_printable T>
void println(const T& value) {
    std::cout << value << '\n';
}

第二种是用 requires 子句,适用于需要多个约束组合的情况:

cpp
template<typename T>
    requires is_printable<T> && std::copyable<T>
void println_twice(const T& value) {
    std::cout << value << '\n';
    std::cout << value << '\n';
}

现在我们可以测试一下:

cpp
int main() {
    println(42);            // OK: int 支持 <<
    println(3.14);          // OK: double 支持 <<
    println(std::string("hello"));  // OK: string 支持 <<

    struct Foo {};
    // println(Foo{});      // 编译错误!Foo 不满足 is_printable
    // 错误信息会明确告诉你 constraint not satisfied

    return 0;
}

标准库在 <concepts> 头文件里预定义了大量有用的概念,我们最常用到的几个包括:std::integral 检查是否是整数类型,std::floating_point 检查浮点类型,std::same_as<T, U> 检查两个类型是否相同,std::convertible_to<From, To> 检查是否可以隐式转换,以及 std::invocable<F, Args...> 检查是否可以用给定参数调用。这些标准概念可以组合使用,也可以作为自定义概念的构建模块。

Concepts 相比 SFINAE 的优势不只是语法上的——更重要的是语义上的清晰。当你看到 template<is_printable T>,你立刻就知道这个函数需要什么类型的参数,不需要去猜。而 std::enable_if_t<...> 的写法本质上是在利用一个语言漏洞,读起来像天书,维护起来更痛苦。

与 edgecv 的关联

edgecv 库大量使用了 Concepts,其中最核心的就是 is_pixel_format 概念。我们来看看它的定义:

cpp
template <typename format>
concept is_pixel_format = requires {
    { format::channels } -> std::convertible_to<int>;
    typename format::value_type;
};

这个概念要求一个像素格式类型必须满足两个条件:它要有一个 channels 静态成员且能转换为 int,还要有一个 value_type 类型别名。edgecv 预定义了 BGR、RGB、Gray、Float1 等一系列像素格式,它们全部满足这个概念:

cpp
struct BGR {
    static constexpr int channels = 3;
    using value_type = uint8_t;
};
// BGR::channels 可以转换为 int ✓
// BGR::value_type 存在 ✓
// 因此 BGR 满足 is_pixel_format ✓

有了这个概念之后,整个库的模板都受到了约束。比如 Image 类的声明:

cpp
template <is_pixel_format format_type> class Image { ... };

以及 ImageView

cpp
template <is_pixel_format F> class ImageView { ... };

如果你试图写 Image<int>,编译器会立刻告诉你 int 不满足 is_pixel_format 约束,而不是等到你调用某个成员函数时才在模板深处报错。这在实际使用中极大地提升了开发体验。

edgecv 还在算法层面使用了 Concepts 做更细粒度的约束。比如 median_blur 函数模板:

cpp
template <is_pixel_format F>
    requires(F::channels == 1)
expected<Image<F>, AlgorithmError>
median_blur(Image<F> img, int kernel_size);

这里不仅要求 F 满足 is_pixel_format,还额外用 requires 子句要求 F::channels == 1,也就是说只有单通道的像素格式才能做中值滤波。如果你试图对 BGR 图像调用 median_blur,编译器会直接拒绝,因为这个约束在编译期就能检查出来。

这种"在类型层面就把错误掐死"的设计思路贯穿了整个 edgecv 库,而 Concepts 就是实现这个思路的核心工具。如果你之前没有接触过 C++20 Concepts,在阅读 edgecv 的代码时看到 template<is_pixel_format F> 这样的声明,现在你应该知道它的含义了——这不仅仅是一个类型参数声明,更是一个编译期的契约,确保传入的类型是合法的像素格式。

Built with VitePress