Concepts 解决的问题

先看一段没有 Concepts 的代码:

template <typename T>
void printSorted(std::vector<T>& v) {
    std::sort(v.begin(), v.end());
    for (const auto& x : v) {
        std::cout << x << '\n';
    }
}

如果传入一个 std::vector<MyStruct>,而 MyStruct 没有定义 operator<,编译器会在 std::sort 的几千行模板代码深处报错。报错信息长到你的终端需要翻十几页才找到一句「no match for operator<」。

Concepts 的本质是:在编译期检查模板参数是否满足要求,不满足就在最上层的接口处直接报错,告诉他缺了什么。

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

template <Sortable T>
void printSorted(std::vector<T>& v) { /* 同上 */ }

现在传 MyStruct 进去,编译器直接在 printSorted 这一行报错:「T does not satisfy constraint Sortable」。一行搞定。


从需求出发写 Concept

需求一:某个类型必须支持特定操作

template <typename T>
concept Printable = requires(std::ostream& os, const T& val) {
    { os << val } -> std::convertible_to<std::ostream&>;
};

requires 表达式里的代码不会被实际执行,编译器只检查语法是否合法。这个 concept 说:如果 os << val 能编译通过、返回类型能转成 std::ostream&,那 T 就满足 Printable

使用:

template <Printable T>
void debugPrint(const T& val) {
    std::cerr << "[DEBUG] " << val << '\n';
}

需求二:类型必须是整数

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

标准库已经提供了一堆预定义的 concept,在 <concepts> 里:std::integralstd::floating_pointstd::copyablestd::movablestd::derived_from<Base> 等。不要自己重新发明。

需求三:组合多个约束

template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;

需求四:类型有特定成员

template <typename T>
concept HasWriteMethod = requires(T& t, std::string_view msg) {
    { t.write(msg) } -> std::same_as<void>;
};

template <typename T>
concept HasFormatMethod = requires(std::string_view level, std::string_view msg) {
    { T::format(level, msg) } -> std::convertible_to<std::string>;
};

这俩 concept 就是前面那篇 Logger Policy 的编译期接口定义。任何有 write(string_view) 方法的类自动满足 HasWriteMethod,不需要继承某个基类。


四种使用 Concepts 的方式

方式一:requires 子句(最推荐)

template <typename T>
    requires std::copyable<T>
void process(T val) {
    auto copy = val;  // 放心用,因为 concept 已经保证 T 是可拷贝的
}

最清晰,读起来像一句话:「T 必须可拷贝」。

方式二:直接替代 typename

template <std::copyable T>
void process(T val) { /* 同上 */ }

更简洁,但只适用于单一 concept。多个 concept 要用方式一或方式三。

方式三:尾置 requires

template <typename T, typename U>
    requires std::copyable<T> && std::equality_comparable_with<T, U>
bool sameValue(const T& a, const U& b) {
    return a == static_cast<T>(b);
}

方式四:简写函数模板(C++20 auto 参数)

auto add(std::integral auto a, std::integral auto b) {
    return a + b;
}

最简洁的写法,适合简单的工具函数。但概念复杂时还是用前三种。


实战:给组件写 Concept

回到上一篇文章里 Logger 的例子。没有 concept 时,Logger 的策略接口是「鸭子类型」——你能编译过就是合法的:

template <typename OutputPolicy, typename FormatPolicy = RawFormat>
class Logger : private OutputPolicy {
public:
    void log(std::string_view level, std::string_view msg) {
        auto formatted = FormatPolicy::format(level, msg);
        this->write(formatted);
    }
};

如果有人写了 Logger<WrongPolicy>,报错位置在 this->write(formatted),错误信息是「no member named ‘write’ in WrongPolicy」——还好,不算太糟,因为这个 Logger 很简单。

但我们加上 concept 会更好:

template <typename T>
concept OutputPolicy = requires(T& t, std::string_view msg) {
    { t.write(msg) } -> std::same_as<void>;
};

template <typename T>
concept FormatPolicy = requires(std::string_view level, std::string_view msg) {
    { T::format(level, msg) } -> std::convertible_to<std::string>;
};

template <OutputPolicy OP, FormatPolicy FP = RawFormat>
class Logger : private OP {
    // 同上,但接口约束现在写在了类签名里
};

现在写 Logger<int> 会立即报错:「int does not satisfy OutputPolicy」——看报错的人不需要知道 write 不存在,他只需要知道 int 不满足 OutputPolicy 这个 concept。这就像 Java 的 implements 一样清晰了。


标准库提供的 Concept 速查

Concept 含义 例子
std::integral<T> 整数类型 int, long, size_t
std::floating_point<T> 浮点类型 float, double
std::copyable<T> 可拷贝 std::string, int
std::movable<T> 可移动 所有容器
std::derived_from<T, Base> T 继承自 Base 类型检查
std::convertible_to<T, U> T 可转为 U 返回类型检查
std::same_as<T, U> T 和 U 完全相同 精确匹配
std::invocable<F, Args...> F 可用 Args 调用 回调检查
std::predicate<F, Args...> F 返回 bool 判断条件

Concept 不是银弹

Concepts 是编译期检查工具,不是运行时校验工具。它能告诉你在哪个接口层犯错了、少了什么方法,但它不会替你写方法。

另外,Concepts 不要求类型显式声明它满足某个 concept。Java 需要写 class MyType implements MyInterface,C++ 不需要——编译器自动判断你的类型有没有 concept 要求的方法。这个设计叫「结构化类型系统」,好处是侵入性为零,坏处是你引入一个方法时可能「意外地」满足了某个 concept。


下一篇

编译期 vs 运行期:何时用哪种 →


系列文章:C++ 组件化基础 | 作者:Logic