第15.3章 概念:编译器替你检查的要求

English

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. 概念在哪里被检查

概念出现在四个地方,全部在编译期:

下面整个程序把一个文件里全展示了 —— 重载集、两个优先级惊喜、调度器,以及标签:


#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. 经验法则