第15.3章 概念:编译器替你检查的要求
1. 没有概念的函数模板要你付出什么
裸的函数模板什么都接受。编译器在有人调用它之前根本不看函数体,之后才把实参替换进去, 然后报出任何出错的地方 —— 通常深在好几层、在你没写过的头文件里。
template <typename T>
[[nodiscard]] inline ::std::string_view show(T const &t) noexcept
{
return t.name();
}
show(42) 不会告诉你“42 没有 name()”。它说的是
show<int> 的实例化失败了,并指向函数体,附一句
int 没有成员 name。若嵌套三层模板,这条信息会变成一整页
“in instantiation of”。
概念的解法是:把要求写在前面,让调用者读得到,让编译器在实例化任何 东西之前就检查它。
2. 概念就是一个具名的 requires 表达式
template <typename T>
concept named = requires(T const t) {
{ t.name() } -> ::std::convertible_to<::std::string_view>;
};
template <named T>
[[nodiscard]] inline ::std::string_view show(T const &t) noexcept
{
return t.name();
}
把 named 读作“关于类型的谓词”。
named<widget> 是 true 或 false;
named<int> 是 false,而且这个判断不编译函数体。
requires(T const t) { ... } 这种写法叫要求列表。
每一项有四种形状:
| 要求 | 含义 |
|---|---|
t.name() |
这个表达式是良构的 |
{ t.name() } -> X |
良构,且结果类型满足概念 X |
{ t.name() } noexcept |
良构,且不会抛 —— 在本书里这是一条真实要求 |
typename T::value_type |
这个类型存在 |
花括号里的一切都是不求值的:不调用、不运行,失败也不是错误,只让概念变成
false。
3. 约束一个比较器
本书要求每个比较器都是 noexcept
(第8.1章)。概念可以一字不差地写下这一点,
于是忘记 noexcept 的比较器会在调用点被拒绝,而不是悄悄变慢、或者更糟 ——
从比较里抛出:
template <typename F>
concept comparator = requires(F f, int a, int b) {
{ f(a, b) } noexcept -> ::std::convertible_to<bool>;
};
template <comparator F>
inline void my_sort(int *first, ::std::size_t n, F cmp) noexcept
{
for (::std::size_t i{1}; i < n; ++i)
{
for (::std::size_t j{i}; j != 0 && cmp(first[j], first[j - 1]); --j)
{
auto const t{first[j]};
first[j] = first[j - 1];
first[j - 1] = t;
}
}
}
my_sort(data, 3, ::std::ranges::greater{}) 能编译:
::std::ranges::greater 的调用运算符是 noexcept。
一个没写 noexcept 的 lambda 编译不过,而这个编译器会明确告诉你原因:
error: no matching function for call to ‘my_sort(int [3], int, main()::<lambda(int, int)>)’
• candidate 1: ‘template<class F> requires comparator<F> void my_sort(int*, std::size_t, F)’
• constraints not satisfied
{ f(a, b) } noexcept -> ::std::convertible_to<bool>;
• ‘f.main()::<lambda(int, int)>(a, b)’ is not ‘noexcept’
六行就是全部收益:要求写在一处、调用者找得到,错误信息点出要求而不是函数体。
4. 用类型特征也能约束
两类要求可以自由组合。一个概念既能要求某个成员函数(语法),也能要求类型的某个性质(语义):
template <typename T>
concept byte_like = ::std::integral<T> && (sizeof(T) == 1);
::std::integral<T> 是建立在 ::std::is_integral_v
上的库概念;(sizeof(T) == 1) 是普通的常量表达式。两者都必须成立。
语法那一半优先用现成的。::std::integral 是对标准库自己持有的类型的承诺;
requires { t.size() } 是对调用者传入的任何类型的承诺,即使标准库日后又加了
一个重载,它仍然成立。
5. 包含关系:约束更紧的那个胜出
两个重载都可行,其中一个要求了另一个的一切、并且还多一点,这时选约束更强的那个。 这不是什么平局规则的花招,而是因为两个概念是彼此的子集。
template <typename T>
concept integral_like = ::std::integral<T>;
template <typename T>
concept byte_like = integral_like<T> && (sizeof(T) == 1);
template <integral_like T>
[[nodiscard]] inline ::std::string_view kind(T) noexcept
{
return "integral";
}
template <byte_like T>
[[nodiscard]] inline ::std::string_view kind(T) noexcept
{
return "byte";
}
kind(static_cast<char>(1)) 打印 byte,因为
byte_like 是用 integral_like 写出来的,因此已知更严格。
注意方向:只有当更强概念的定义里按名字包含更弱概念时,包含关系才成立。
两个恰好有交集但互不相关概念会形成歧义,调用直接编译不过。
6. 概念重载:每个类别一个重载
写概念最常见的理由,是搭一小族重载,每类类型一个,而每个重载都可以假定它的概念已经保证了什么:
template <::std::integral T>
[[nodiscard]] inline ::std::string_view classify(T) noexcept
{
return "integral";
}
template <::std::floating_point T>
[[nodiscard]] inline ::std::string_view classify(T) noexcept
{
return "floating point";
}
classify(1) 选第一个,classify(2.5) 选第二个,而
classify(widget{}) 是命名了两个候选的编译错误 —— 这正是重点:
这个集合是封闭的,集合之外的类型会被拒绝,而不是悄悄落到某个泛型分支上。
本页现在必须警告你,因为概念重载集是 C++ 里最脆的分派机制。下面两节是警告,最后一节是 该写什么。
7. 约束是重载解析最后才看的东西
重载解析按固定顺序给候选排序,约束排在最后:
| 步骤 | 规则 |
|---|---|
| 1 | 更好的转换序列胜出(精确匹配 > 提升 > 转换 > 省略号) |
| 2 | 然后:转换相同的情况下,非模板胜过模板 |
| 3 | 然后:更特化的模板胜出 |
| 4 | 然后,最后才是:约束更强的模板胜出 |
所以除非前面一切都已经打平,约束根本不起作用。两个实测后果,任何一个在真实代码库里 发生都要耗掉一个下午。
你忘掉的一个普通重载直接赢
[[nodiscard]] inline ::std::string_view priority(int) noexcept
{
return "plain int overload";
}
template <::std::integral T>
[[nodiscard]] inline ::std::string_view priority(T) noexcept
{
return "integral template";
}
priority(1)=plain int overload
priority(char)=integral template
对 1 两者都是精确匹配,于是走第 2 步,非模板赢 ——
integral 这个约束连票都没投上。对 char 而言非模板需要一次提升,
所以模板的精确匹配在第 1 步就赢了。
更特化的模板也会赢
[[nodiscard]] inline ::std::string_view ranks(long) noexcept
{
return "long overload";
}
template <byte_like T>
[[nodiscard]] inline ::std::string_view ranks(T) noexcept
{
return "byte template";
}
ranks(char)=byte template
这里模板赢了,仍然是在第 1 步:精确匹配胜过需要转换的非模板。把实参类型反过来,答案就翻转。 一句话总结问题所在:实际跑的是哪个重载,取决于你没有写出来的那些转换, 而约束救不了你,因为它最后才被考虑。
8. 该写什么:唯一的调度器
如果选择发生在一个固定的类别集合之间,就不要把它表达成重载集。把类型分类一次,得到一个
constexpr 值(第15.4章),
让一个函数用 if constexpr 在它上面分支
(第15.8章):
enum class kind
{
integral,
floating,
named,
unknown
};
template <typename T>
inline constexpr kind kind_of = []() constexpr {
if constexpr (::std::integral<T>)
{
return kind::integral;
}
else if constexpr (::std::floating_point<T>)
{
return kind::floating;
}
else if constexpr (requires(T const &t) { t.name(); })
{
return kind::named;
}
else
{
return kind::unknown;
}
}();
三件事同时变好了。分支的顺序就是作者写的顺序,而不是标准里某个埋着的平局规则。
什么都不匹配的类型会走 unknown 分支 —— 这是有意为之 ——
而不是编译错误或悄悄发生的转换。而且这个选择是一个值,所以可以打印、可以放进表、
可以用 static_assert 检查:
static_assert(kind_of<int> == kind::integral);
static_assert(kind_of<::std::float64_t> == kind::floating);
9. 标签类型:让这套重载根本无法被搞混
调度器最终还是得调用点什么,而诱惑是把它又做成一个重载集。不要这样:给每个分支一个 自己的标签类型,并把标签作为第一个实参传进去。标签是一个什么都不能隐式转换 过去的独立类型,于是重载解析恰好只有一个精确匹配的候选,没有任何转换需要排序:
template <kind k>
struct kind_tag
{
};
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::integral>, T) noexcept
{
return "integral";
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::named>, T const &value) noexcept
{
return value.name();
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(T const &value) noexcept
{
return describe(kind_tag<kind_of<T>>{}, value);
}
这正是 fast_io 到处都在用的形状:::fast_io::io_reserve_type<char, T>
就是这样一种标签,每个打印或扫描定制都把它当第一个形参
(第15.11章)。
标签让调用没有歧义,把函数需要的类型信息作为类型携带,而且新增一个类别时不可能
悄悄劫持已有的调用 —— kind_tag<kind::integral> 与
kind_tag<kind::named> 之间没有任何转换。
同一个技巧在运行期也成立,用值标签:一个命名操作的 enum class 形参,
比三个只有约束不同的重载要好,因为调用者明确说出它要什么,而不是指望重载集同意。
10. 概念在哪里被检查
概念出现在四个地方,全部在编译期:
- 作为类型约束:
template <named T>。 - 作为后置子句:
template <typename T> requires named<T>。意思相同,写法不同; 约束很长时用子句。 - 作为一个变量:
named<T>是bool, 可以用static_assert检查,也可以放进if constexpr(第15.8章)。 - 作为临时
requires:requires { t.size(); }直接写在函数体里,不起名字。
下面整个程序把一个文件里全展示了 —— 重载集、两个优先级惊喜、调度器,以及标签:
#include <fast_io.h>
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <string_view>
struct widget
{
[[nodiscard]] ::std::string_view name() const noexcept { return "widget"; }
};
struct plain
{
int value{};
};
// ---- concept overloads: one overload per category
template <::std::integral T>
[[nodiscard]] inline ::std::string_view classify(T) noexcept
{
return "integral";
}
template <::std::floating_point T>
[[nodiscard]] inline ::std::string_view classify(T) noexcept
{
return "floating point";
}
// ---- constraints are the LAST tie-break: a plain overload silently wins
[[nodiscard]] inline ::std::string_view priority(int) noexcept
{
return "plain int overload";
}
template <::std::integral T>
[[nodiscard]] inline ::std::string_view priority(T) noexcept
{
return "integral template";
}
// ---- a better conversion rank outranks the non-template preference
[[nodiscard]] inline ::std::string_view ranks(long) noexcept
{
return "long overload";
}
template <typename T>
concept byte_like = ::std::integral<T> && (sizeof(T) == 1);
template <byte_like T>
[[nodiscard]] inline ::std::string_view ranks(T) noexcept
{
return "byte template";
}
// ---- the recommended shape: classify once, dispatch with if constexpr, key with a tag
enum class kind
{
integral,
floating,
named,
unknown
};
template <typename T>
inline constexpr kind kind_of = []() constexpr {
if constexpr (::std::integral<T>)
{
return kind::integral;
}
else if constexpr (::std::floating_point<T>)
{
return kind::floating;
}
else if constexpr (requires(T const &t) { t.name(); })
{
return kind::named;
}
else
{
return kind::unknown;
}
}();
template <kind k>
struct kind_tag
{
};
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::integral>, T) noexcept
{
return "integral";
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::floating>, T) noexcept
{
return "floating point";
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::named>, T const &value) noexcept
{
return value.name();
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(kind_tag<kind::unknown>, T) noexcept
{
return "something else";
}
template <typename T>
[[nodiscard]] inline ::std::string_view describe(T const &value) noexcept
{
return describe(kind_tag<kind_of<T>>{}, value);
}
int main()
{
try
{
::fast_io::io::println("classify(1)=", classify(1));
::fast_io::io::println("classify(2.5)=", classify(2.5));
::fast_io::io::println("priority(1)=", priority(1));
::fast_io::io::println("priority(char)=", priority(static_cast<char>(1)));
::fast_io::io::println("ranks(char)=", ranks(static_cast<char>(1)));
::fast_io::io::println("describe(1)=", describe(1));
::fast_io::io::println("describe(2.5)=", describe(2.5));
::fast_io::io::println("describe(widget)=", describe(widget{}));
::fast_io::io::println("describe(plain)=", describe(plain{}));
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
输出:
classify(1)=integral
classify(2.5)=floating point
priority(1)=plain int overload
priority(char)=integral template
ranks(char)=byte template
describe(1)=integral
describe(2.5)=floating point
describe(widget)=widget
describe(plain)=something else
11. 概念做不到的事
概念是编译期谓词。它不能决定运行期的任何事,不能让一个变量这一轮装 widget、
下一轮装 gadget,也不会出现在二进制里。
人们退而求其次的两个办法是带 virtual 的继承
(第18章)与类型擦除
(第15.10章)。
本章用后者,因为它除了必须动态的那一次调用之外,把一切都保持静态绑定。
12. 经验法则
- 概念用两次就起名字;只用一次就写内联
requires。 - 用其他概念来约束,这样包含关系才看得见。
- 算法依赖
noexcept时,就把noexcept写进概念;本书的比较器与 分配器总是如此。 - 不要约束用不到的东西。一个要求五个成员、只用两个的概念,只会毫无理由地拒绝类型。
- 不要用概念重载来搭分派。约束是重载解析最后才看的东西, 所以不相关的重载、一次转换、或更特化的模板都可能替你赢下来。
- 把类型分类成一个
constexpr值,用if constexpr分派; 那样是确定的、可排序的、可 grep 的。 - 给每个分支一个自己的标签类型当第一个形参。标签不会被转换搞混,也正是 fast_io 的
io_reserve_type的工作方式。 - 记住概念并不减少膨胀:它只是更早地拒绝错误实参。膨胀是 第15.9章的事。