想象你是一家公司负责招聘算法工程师的团队。旧式 HR 流程是这样的:候选人简历先被塞进一个黑箱,如果这个人“没有明显毛病”,就放行;如果不合适,系统只回一行冷冰冰的“未找到合适简历”。至于缺哪项技能、差在哪一点,你完全无从得知。C++20 之前的模板编程,就是这个黑箱:函数模板想找出“合适的类型”,不得不用 SFINAE 这类暗语手写筛选条件,编译失败时只丢给你一长串天书般的报错,让无数程序员劝退。 C++20 Concepts 改变了这一切。它把“职位要求”直接写成一张清晰的招聘说明——例如“必须有 size()、必须能相加、必须是数值类型”。编译器就像一位懂行的 HR,看一眼候选类型,直接告诉你:“这个类型没通过 ‘Numeric’ 职位要求,因为它缺少 operator+”。本文就用这个“招聘 / 筛选”的比喻,带你从零认识 Concepts,并理解它为什么能替代 SFINAE,成为模板约束的新方案。

岗位关联

学习 Concepts 能让 AI 或 C++ 开发者把模板接口从“玄学筛选”变成“白纸黑字”,显著降低训练框架、推理引擎的维护成本。对 AI 从业者而言,清晰的模板约束意味着算法迭代更快、报错不再劝退。

目录
  1. 可视化演示
  2. 模板之痛
  3. Concepts 概念
  4. 约束写法
  5. 替代 SFINAE
  6. 可读性提升
  7. 泛型编程应用
  8. 实践与优化
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示C++20 Concepts的核心机制,通过概览/流程/对比/趋势四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

C++20 Concepts:CCon C++20 Concepts的 Concepts的声明与定义语 核心机制可视化 C++20 Concepts整体架构与关键组件

模板之痛

假设你们公司的 HR 从不公布职位要求,只用一套神秘规则筛选简历:候选人看起来没问题就放行,不行就静默淘汰。C++20 之前的模板世界就像这样。编译器会根据函数调用生成一份“候选函数简历”,然后逐个尝试把类型装进去。模板参数匹配本身没有大问题,真正痛苦的是当失败发生时,编译器并不直接说“这个类型缺什么”,而是默默放弃,最后只丢给你一句“找不到匹配的函数”。

为了让 HR 有一点筛选能力,程序员发明了 SFINAE——Substitution Failure Is Not An Error,替换失败不算错误。严格来说,这是一条重要的安全规则:它在模板中遇到 typename T::iterator 这类不存在的东西时,不是立刻报错,而是去掉这个候选。问题在于,写约束的人通常要借助 std::enable_if_t<std::is_integral_v<T>> 这样的长表达式,把条件藏在返回类型或参数里。读起来就像一份简历上写着暗语,约束越多,模板重载越多,编译错误也像洋葱一样一层层剥不到头。

回到招聘比喻:老 HR 只会用排除法干活。你看不到“缺什么技能”,只看到一堆落选简历被扔进碎纸机。模板之痛,正是约束意图被深埋在机械的替换规则里。

SubstFailure⁡(F,T)≠error  ⟹  F∈Candidates⁡(T)\displaystyle \operatorname{SubstFailure}(F,T) \neq \text{error} \implies F \in \operatorname{Candidates}(T)
公式解释:SFINAE 规则的本质是“替换失败不当作错误,只把候选函数从名单里划掉”。
  1. 替换失败\operatorname{SubstFailure}(F,T) 表示把类型 T 代入模板 F 的函数签名时,某些形如 typename T::iterator 的写法可能非法:
    SubstFailure⁡(F,T)≡T⊭签名要求\operatorname{SubstFailure}(F,T) \equiv T \not\models \text{签名要求}
  2. 不是错误SFINAE 规定该失败不产生编译错误,而是让这个候选函数退出竞争:
    SubstFailure⁡(F,T)≠error\operatorname{SubstFailure}(F,T) \neq \text{error}
  3. 进入候选集如果 F 没有被排除,则它留在 \operatorname{Candidates}(T) 中,继续参加重载决议:
    F∈Candidates⁡(T)F \in \operatorname{Candidates}(T)

Concepts 概念

如果我们把“职位要求”本身命名成一个东西,事情就清晰多了。C++20 的 concept 就是在做这件事:它把一个布尔条件集合起一个名字,比如 Numeric,用来表达“这种类型应当像数字一样工作”。定义方式很直白:

这里 Numeric 不是变量,而是编译期可求值的约束谓词。它像一张职位描述:要么是整数,要么是浮点数,两者满足其一即可。你可以继续叠加要求,例如“必须支持 + 运算、必须能转成 double”。这些要求被拆成一个一个小条件,全部通过时,概念返回 true;任何一条不满足,整个概念立即变成 false。

编译期求值意味着概念不会带来运行时开销。它发生在模板实例化的瞬间,像招聘初筛在投递窗口就已经完成,而不是入职之后才发现问题。概念还可以组合,用 &&、|| 和 ! 像搭积木一样拼出新要求:SignedNumeric && !Char。这比在 enable_if 里不断嵌套 std::conjunction、std::disjunction 要可读得多。

这就像一个招聘启事,把 JD 写清楚:学历、年限、技能点一目了然。候选人满足所有条条框框,才会被放进下一轮面试。

C(T)≡⋀i=1mRi(T)\displaystyle \mathcal{C}(T) \equiv \bigwedge_{i=1}^{m} R_i(T)
公式解释:一个具名概念 \mathcal{C} 本质上是若干需求 R_i 同时成立的合取。
  1. 概念 C\mathcal{C} 代表某个具名概念,例如 Numeric:
    C∈{Numeric,Integral,Sortable,…}\mathcal{C} \in \{\text{Numeric}, \text{Integral}, \text{Sortable}, \ldots\}
  2. 需求集合 R_iR_i(T) 是第 i 条约束,例如“类型 T 必须有 size() 成员”,或“T 必须能转换为 double”:
    Ri(T)≡合法表达式或类型 trait 成立R_i(T) \equiv \text{合法表达式或类型 trait 成立}
  3. 合取关系\bigwedge 表示所有需求必须同时成立,任一失败则概念整体失败:
    C(T)=R1(T)∧R2(T)∧⋯∧Rm(T)\mathcal{C}(T) = R_1(T) \land R_2(T) \land \cdots \land R_m(T)
cpp2 行
template <typename T>concept Numeric = std::integral<T> || std::floating_point<T>;

约束写法

有了概念之后,怎么把它写到模板上?C++20 提供了三种常见的写法。第一种最简洁,直接把概念写在模板参数列表里:template <Numeric T> void func(T x);。这里的 Numeric T 表示“T 必须是满足 Numeric 的类型”,相当于把职位要求贴在简历投递箱外面。

第二种是 requires 子句。它紧跟在模板参数列表和函数返回类型之间:template <typename T> requires Numeric<T> void func(T x);。这种写法能表达更复杂的条件,例如多概念组合:requires Numeric<T> && Comparable<T>。如果你写出的条件很复杂,但又不想为此定义一个具名概念,随时可以在这里展开。

第三种是 requires 表达式。它不是用来声明模板约束,而是用来检查某个表达式是否合法。例如 requires(T x) { x.size(); } 会验证“对类型为 T 的对象调用 size() 是否可行”。这相当于让编译器现场做一次技能测试:候选人必须展示“会写 size() 这个方法”,测试通过才算过关。requires 表达式还能检查调用返回类型、是否抛出异常等,是编写自定义概念的基本砖块。

三种写法就像三种贴 JD 的方式:写在模板头部最显眼,写在 requires 子句里更灵活,写在 requires 表达式里则是考核细节。它们殊途同归,都让“类型是否合格”这件事从黑箱变成了白纸黑字的规则。

T⊨C  ⟺  C(T)=true\displaystyle T \models \mathcal{C} \iff \mathcal{C}(T) = \mathbf{true}
公式解释:类型 T 满足概念 C,当且仅当编译器对概念 C 求值得到 true。
  1. 满足关系$T \models \mathcal{C}$ 读作“类型 T 满足概念 C”,等价于简历匹配职位要求:
    T⊨CT \models \mathcal{C}
  2. 编译期求值\mathcal{C}(T) 是编译器在编译阶段算出的布尔值,不会出现在运行时代码里:
    C(T)∈{true,false}\mathcal{C}(T) \in \{\mathbf{true}, \mathbf{false}\}
  3. 充要条件只有结果是 \mathbf{true},模板实例化才能继续:
    C(T)=true\mathcal{C}(T) = \mathbf{true}

替代 SFINAE

来看一段经典对比。旧代码想限定整数类型,会让 HR 在黑箱里用排除法:

新代码可以直接把约束写在“额头”上:

std::enable_if 是 SFINAE 最常用的工具:它把一个条件藏在模板参数里,条件满足时这个模板参数类型存在,条件不满足时替换失败。这个技巧能解决问题,但可读性和报错都不理想。阅读者必须一层层剥开 ::type、std::enable_if_t 才能知道这个模板到底在要求什么。还有 void_t 技巧,用来检测某个类型是否有合法表达式,写法更是绕:先定义 template <typename...> using void_t = void;,再在偏特化里判断 decltype(T::foo()) 是否有效。这些做法本质上都在“用语言缺陷表达语义”。

Concepts 直接把这些语义提升为语言一级的声明。编译器不再需要靠“替换失败”来猜,而是直接检查概念是否满足。重载决议的逻辑也随之变得清晰:如果两个模板函数分别用不同概念约束,编译器会优先选择约束更满足的那一方;如果一个候选满足约束、另一个不满足,选择结果一目了然。旧 SFINAE 时代这种决策像地下暗箱操作,C++20 时代则像公开的评分表。

旧 SFINAE 像 HR 偷偷摸摸地翻简历;Concepts 则把面试规则贴在公司门口,让候选人和面试官都清楚谁该晋级。

Best⁡(F1,F2,T)=Fi  ⟺  Ci(T)=true∧Cj(T)=false\displaystyle \operatorname{Best}(F_1,F_2,T) = F_i \iff \mathcal{C}_i(T) = \mathbf{true} \land \mathcal{C}_j(T) = \mathbf{false}
公式解释:当两个候选函数各自的约束互斥时,满足概念的那个函数胜出。
  1. 两个候选$F_1, F_2$ 是两个模板函数,各自带有概念约束 $\mathcal{C}_1$、$\mathcal{C}_2$:
    Fi∼Ci(T)F_i \sim \mathcal{C}_i(T)
  2. 判断满足编译器尝试把 T 放进约束表达式,得到布尔值:
    C1(T), C2(T)\mathcal{C}_1(T), \ \mathcal{C}_2(T)
  3. 唯一胜出如果 $F_i$ 满足而 $F_j$ 不满足,则 $F_i$ 被选中;这比 SFINAE 的“谁能替换成功”更可解释:
    Best⁡(F1,F2,T)=Fi\operatorname{Best}(F_1,F_2,T) = F_i
cpp3 行
template <typename T,          typename = std::enable_if_t<std::is_integral_v<T>>>void f(T x) { }
cpp2 行
template <std::integral T>void f(T x) { }

可读性提升

可读性提升是 Concepts 最直接的红利。第一个体现是“代码即文档”。旧模板函数只有一串 typename... 和 enable_if,看半天才知道它想要什么;新代码直接说 template <Numeric T> 或 requires Sortable<T>,意图从名字里跳出来。函数签名不再需要注释解释前置条件,因为约束本身就是注释。

第二个体现是“报错信息变短”。旧 SFINAE 报错经常从模板定义一路展开到实例化点,几十行内部实现全都堆在脸上,真正有用的信息淹没在 ... 中。Concepts 的报错通常能直接定位:编译器会告诉你模板参数 T 不满足哪个概念,以及具体是哪一个 requires 表达式失败。例如,“std::sort 无法运行,因为迭代器类型不满足 std::sortable”。这对刚入门的小白尤其友好,因为错误信息把抽象的编译知识翻译成人话。

第三个体现是“调试与评审更轻松”。调试模板代码时,你不再需要反复猜测 enable_if 条件到底影响哪个重载;代码评审时,requires 子句像一份契约,其他人可以一眼看出函数对类型的要求。团队合作时,概念名成为共享词汇:一说 NumericLike,所有人都知道是“数值类型,且支持常见算术运算”。这比每个人各自写一套 traits 要高效得多。

可读性提升的本质,是把约束从“实现细节”提升到“接口契约”。招聘流程也一样:如果 JD 上写着“必须会 Python、5 年工程经验”,候选人一眼就知道自己为何不合适,HR 也不必反复解释。

泛型编程应用

在泛型编程中,Concepts 几乎是量身定做的工具。C++20 标准库已经提供了大量现成概念:std::integral、std::floating_point、std::same_as、std::convertible_to、std::sortable 等等。它们像一套标准化的职位模板,开发者不需要从零发明概念,可以直接拿来用。std::ranges::sort 就用概念约束了迭代器必须满足 std::sortable,这比旧 STL 用 SFINAE 东躲西藏要直观得多。

算法约束是最典型的应用场景。假设你写一个 average 函数,它要求类型不仅能相加,还要能被除数除。你完全可以定义:

从此,任何想调用 average 的类型都必须先过这道“技能测试”。如果用户传入一个不支持除法运算的类型,编译器会明确告诉你缺少 operator/,而不是报出泛泛的“没有匹配函数”。

类型安全和工厂接口也因 Concepts 而受益。在工厂模式中,注册的组件可以统一要求“必须实现 create 方法”;在容器自定义中,可以约束元素必须可拷贝、可比较。这样模板被误用时,错误发生在接口层面,而不是深埋在某个算法内部。对 AI 基础库而言,这意味着张量、模型、数据集等类型可以被精确约束,错误在编译期就被拦下,而不是留到运行时才崩溃。

cpp5 行
template <typename T>concept Averagable = requires(T a, T b) {    { a + b } -> std::convertible_to<T>;    { a / b } -> std::convertible_to<T>;};

实践与优化

实践中,Concepts 最迷人的特性是“零开销抽象”。概念检查和 requires 表达式都在编译期完成,不会生成任何运行时代码,也不会让程序变慢。你得到的只是更清晰的错误信息和更准确的重载决议,付出的成本仅仅是编译器的额外算力。这就像招聘初筛只发生在简历投递阶段,候选人入职后不会背着“初筛机器”工作。

Concepts 还与 auto 产生了奇妙的化学反应。std::ranges 算法允许用 auto 声明变量,配合概念可以写出像 std::ranges::sort(v) 这种既简洁又有约束的代码。模板参数也可以用 auto 占位,配合 concept 限制,例如 void f(std::same_as<int> auto x);。这种写法让函数签名更像一句自然语言。

迁移到 Concepts 不一定要推倒重来。现有的 SFINAE 模板依然可以保留,但新代码应该优先考虑 requires。推荐策略是:先将函数模板里复杂的 enable_if 改写成 requires 子句;再把反复出现的条件抽成具名概念;最后用概念名替换普通 typename T。这样每一步都小、可回归测试,旧重载代码也能平滑过渡。虽然 Concepts 和 SFINAE 背后共享同一套约束求解机制,但从可读性角度,Concepts 是更现代的答案。

AI 应用场景

场景一:推理引擎中的模型类型约束

阶段:模型部署 / 推理引擎开发

解决的问题:传统 C++ 推理引擎希望同时支持 PyTorch、ONNX、TensorRT 等多种模型后端,旧方式用 enable_if 判断“模型是否支持 forward”,条件藏在模板签名深处,用户传错类型时编译器只会说“没有匹配的函数”,定位困难。Concepts 可以把“模型必须有 forward 方法且接受张量数组”定义成 CallableModel 概念,让接口直接表达语义。

具体案例:某团队开发高性能推理引擎,定义 template <CallableModel M> void infer(M& model, const Tensor& input);。当用户意外传入一个预训练权重文件描述符而非模型对象时,编译器直接指出“该类型不满足 CallableModel,缺少 forward(Tensor) 方法”,省去大量排查时间。

场景二:数据管道中的回调约束

阶段:机器学习数据加载 / 训练管线

解决的问题:在训练流水线里,回调函数签名的正确性至关重要。旧方法是约定期望的参数类型,但用户传错回调时只能等到运行时报错;Concepts 则可以把回调约束提升到编译期,提前暴露不匹配。

具体案例:一个 C++ 实现的数据加载器要求回调必须能接受 Batch 对象并返回 bool 表示是否继续训练。定义 BatchCallback 概念后,train_loop 的模板参数直接写 template <BatchCallback C>。当用户传入一个只接受 vector<float> 的 lambda 时,编译期就得到清晰报错,而不是训练两小时后崩溃。

Python 演示

python52 行
from typing import Protocol, runtime_checkable  @runtime_checkableclass Numeric(Protocol):    def __add__(self, other):        ...     def __mul__(self, other):        ...  @runtime_checkableclass BatchCallback(Protocol):    def __call__(self, batch):        ...  def require(concept, obj, arg_name="参数"):    if not isinstance(obj, concept):        raise TypeError(            f"{arg_name} 不满足概念 {concept.__name__}:"            f"类型 {type(obj).__name__} 缺少所需成员"        )    return obj  def constrained(concept):    def decorator(func):        def wrapper(param, *args, **kwargs):            require(concept, param)            return func(param, *args, **kwargs)        return wrapper    return decorator  @constrained(Numeric)def double_it(x):    return x + x  @constrained(BatchCallback)def run_pipeline(callback, data):    return callback(data)  if __name__ == "__main__":    print(double_it(3.5))    try:        print(double_it("abc"))    except TypeError as exc:        print("Concepts 风格报错:", exc)
目的:用 Python 的 Protocol 模拟 C++20 Concepts 的约束模型,让函数在调用前检查参数是否满足接口要求,并给出清晰报错。
  1. @runtime_checkable开启运行时检查,让 isinstance 能识别协议,相当于让 Python 具备“编译期概念检查”能力。
  2. class Numeric(Protocol):像 C++ concept Numeric 一样定义“数值类型”的技能要求。
  3. raise TypeError(...)模仿 Concepts 的报错格式,指出哪个概念没满足,以及传入类型缺少什么。
  4. @constrained(Numeric)用装饰器给函数自动增加参数约束,类似 template <Numeric T>。
  5. print(double_it(3.5))传入浮点数,通过检查并输出 7.0;传入字符串则被拦截并报错。

常见误区

误区我听说 Concepts 能在程序运行时自动检查类型,那是不是像虚函数一样,运行时有多态分发?

正解Not at all。Concepts 全部在编译期求值,模板实例化之前编译器就已经算出概念是否为 true。它不生成任何运行时代码,也不参与运行时多态。打个比方:Concepts 是招聘初筛,在投递阶段完成;而运行时多态是入职后的轮岗,两者不是一回事。

为何常见:很多初学者接触过 Python 的 isinstance 或 Java 接口,看到“检查类型”就先入为主地认为是运行时行为。

误区Concepts 是 C++20 的新特性,以后我再也不用学 SFINAE 了,所有旧代码都可以立刻重写。

正解Concepts 的目标是提供更可读的约束表达方式,但它并不消灭 SFINAE,标准库和大量现有代码还是在用 enable_if、void_t。甚至在实现自定义概念时,背后的约束规范化机制仍与 SFINAE 有相通之处。更现实的策略是“先加概念,后逐步替换”,而不是一夜之间推翻重写。

为何常见:新特性发布时,媒体总喜欢强调“替代 XX”,让人误以为旧技术瞬间作废。实际工程迁移需要时间。

误区只要看到 `requires`,就是给模板加约束。反正都差不多,写在哪都行。

正解它们是两个不同语法。requires 子句出现在模板参数列表之后,用来说明“这个模板要求什么概念”,例如 requires Numeric<T>。requires 表达式则是一个右值表达式,用来检查某个写法是否合法,常用于概念定义内部,例如 requires(T x) { x.size(); }。这两种语法都叫 requires,但作用位置完全不同。

为何常见:同一个关键字展示了 C++ 二十年累积的语法包袱,初学者很难第一眼分清“声明约束”和“测试表达式”。

误区像 Java 里 `implements` 一样,类型声明的时侯必须写 `class MyType implements Numeric` 才能被 Concepts 约束。

正解C++20 Concepts 是“结构化约束”,不是“登记制”。只要类型在语法和语义上满足概念中的表达式,它就被自动认为满足该概念。这就是所谓的鸭子类型:像一个数字,可以加可以乘,那它就是 Numeric。用户不需要修改类型定义,这是 Concepts 能兼容旧代码的根基。

为何常见:很多人受过面向对象思想影响,自然认为“检查类型”必须显式声明接口实现关系。

面试题

Q1. C++20 中,template <Numeric T> 里的 Numeric 是什么?

单选题 · easy

  • A.一个 Concepts 概念← 正确答案
  • B.一个模板特化宏
  • C.一个数值类型别名
  • D.一个分支预测关键字

答案:A。Numeric 是开发者或标准库定义的概念(concept),它作为模板参数的约束条件,说明 T 必须满足数值类型的相关要求。宏、别名和分支预测都不是模板约束的概念。

Q2. requires 子句在模板中的主要作用是什么?

单选题 · easy

  • A.约束模板参数满足概念← 正确答案
  • B.强制函数返回指定类型
  • C.引入标准库头文件
  • D.在运行时检查用户输入

答案:A。requires 子句写在模板参数之后,用来声明模板实参必须满足的概念。它不会改变函数返回值,也不涉及运行时检查,更不是 include 机制。

Q3. 与 SFINAE 相比,Concepts 带来的最大改进是什么?

单选题 · mid

  • A.增加编译速度
  • B.降低运行内存
  • C.约束意图直接可读← 正确答案
  • D.减少模板展开次数

答案:C。Concepts 把模板约束从隐晦的 enable_if 表达式变成具名概念和 requires,让“函数要求什么”一目了然。它不能保证编译变快,也不直接影响运行时内存。

Q4. 定义 C++20 Concept 时,可以组合哪些元素?

多选题 · mid

  • A.requires 表达式← 正确答案
  • B.类型 traits 表达式← 正确答案
  • C.普通布尔常量表达式← 正确答案
  • D.运行时 try/catch 语句

答案:ABC。概念体可以是 requires 表达式、std::is_* 这类 traits,以及普通 constexpr bool 表达式的组合。try/catch 是运行时异常处理机制,不能用来做编译期约束判断。

Q5. template <typename T> requires std::integral<T> 表示什么?

单选题 · mid

  • A.T 必须是整数类型← 正确答案
  • B.T 必须是浮点类型
  • C.T 必须支持随机访问
  • D.T 必须拥有迭代器

答案:A。std::integral<T> 是标准概念,要求 T 是整数类型,如 int、long、char 等。浮点类型用的是 std::floating_point。

Q6. 下列哪个概念用于要求类型可按小于比较?

单选题 · mid

  • A.std::same_as
  • B.std::less_than
  • C.std::sortable← 正确答案
  • D.std::convertible

答案:C。std::sortable 综合要求类型支持比较、可移动等排序所需操作。std::same_as 是类型相等判断,std::less_than 不是标准概念,std::convertible 不完整。

Q7. Concepts 对重载决议最直接的影响是什么?

单选题 · hard

  • A.满足约束的候选优先← 正确答案
  • B.随机选择一个函数
  • C.所有候选机会均等
  • D.按照字母顺序选择

答案:A。当多个模板重载被 Concepts 约束时,编译器优先选择约束被满足的候选;若多个候选都满足,则选择约束更强的一方。随机、均等和字母序都不符合 C++ 重载规则。

Q8. 关于 requires(T x) { x.size(); },下列哪些说法正确?

多选题 · hard

  • A.检查 x.size() 是否合法← 正确答案
  • B.不会在运行时调用 size← 正确答案
  • C.要求 x 有 size 成员函数← 正确答案
  • D.强制执行 size() 并抛出异常

答案:ABC。这是一个 requires 表达式,编译期检查“对 T 的对象调用 size() 是否合法”,例如是否存在 size() 成员或自由函数。它是语法检查,不会真正执行 size()。

Q9. 下列哪项是 Concepts 替换 std::enable_if<!std::is_same_v<T, U>::value> 的更直接写法?

单选题 · extreme

  • A.requires !std::same_as<T, U>← 正确答案
  • B.requires (!is_same)
  • C.if constexpr (!same)
  • D.static_assert(!std::is_same)

答案:A。C++20 中用 requires !std::same_as<T, U> 能直接表达“T 不等于 U”,无需 enable_if。if constexpr 和 static_assert 虽然也能做约束,但不是 Concepts 方案中的典型替代写法。

Q10. Concepts 给泛型编程带来了哪些好处?

多选题 · easy

  • A.编译期检查更明确← 正确答案
  • B.错误信息更可读← 正确答案
  • C.接口意图更清晰← 正确答案
  • D.运行时开销增加

答案:ABC。Concepts 把类型约束放在接口层,让编译器在实例化前就判断类型是否合格,并给出更精准的错误提示。它是零运行时开销的,因此 D 错误。