原文
C ++开发人员中有一部分人喜欢模板元编程(TMP)。
还有其他所有C ++开发人员。
虽然我认为自己倾向于狂热者阵营。但是我遇到过的人,相比于爱好者来说,更多的人对它没有什么兴趣甚至感到厌恶。你是哪个阵营的?
在我看来,TMP之所以无法为许多人接受的原因之一是它通常很晦涩。 有时它看起来像是黑魔法,只保留给可以理解其方言的开发人员的一个非常特殊的亚种。 当然,有时我们会遇到偶尔可以理解的TMP,但是平均而言,我发现它比常规代码更难理解。
我想指出的是,TMP不必一定是这种方式。
我将向你展示如何使TMP代码更具表现力。 它并没有那么难。
TMP通常被描述为C ++语言中的一种语言。 因此,为了使TMP更具表现力,我们只需要应用与常规代码相同的规则即可。 为了说明这一点,我们将采用一段只有我们最勇敢的人才能理解的代码,并在其上应用以下两个表达性准则:
- 选择好名字,
- 并分离出抽象层次。
我给你说过了,它并没有那么难。
示例代码的目的
我们将编写一个API,以检查表达式对于给定类型是否有效。
例如,给定类型T,我们想知道T是否可递增,也就是说,对于类型T的对象t,如下表达式是否合法:
++t
如果T为int,则表达式有效;如果T为std :: string,则表达式无效。
这是实现它的TMP的典型部分:
template< typename, typename = void >
struct is_incrementable : std::false_type { };
template< typename T >
struct is_incrementable() )>
> : std::true_type { };
我不知道你需要多少时间来解析此代码,但是花了我大量的时间才能全部解决。 让我们看看如何重新编写此代码,以使其更易于理解。
公平地说,我必须说,要了解TMP,你需要了解一些结构。 有点像需要了解“ if”,“ for”和函数重载以了解C ++的知识,TMP具有一些先决条件,例如“ std :: true_type”和SFINAE。 但是,如果你不认识它们,请不要担心,我将一路向你解释。
基础知识
如果您已经熟悉TMP,则可以跳到下一部分。
我们的目标是能够以这种方式查询类型:
is_incrementable::value
is_incrementable
是一种类型,具有一个公共布尔成员value,如果T是可递增的(例如T为int),则为true;否则,则为false(例如T为std :: string)。
我们将使用std :: true_type。 它是仅具有等于true的公共布尔成员值的类型。 在T可以递增的情况下,我们将从它继承 is_incrementable
为了允许有两个可能的定义,我们使用模板特化。 一种专门继承自std :: true_type,另一种专门继承自std :: false_type。 因此,我们的解决方案将大致如下所示:
template
struct is_incrementable : std::false_type{};
template
struct is_incrementable : std::true_type{};
特化基于SFINAE。 简而言之,我们将编写一些代码,尝试在特化中自增T。 如果T确实是可递增的,则此代码将有效,特化就会实例化(因为它始终优先于主模板)。它会继承std :: true_type。
另一方面,如果T不可递增,则特化将无效。 在这种情况下,SFINAE表示无效的实例化不会停止编译。 它只是被完全丢弃,剩下的唯一模板是主模板,即从std :: false_type继承。
选一个好名字
文章顶部的代码使用了std :: void_t。 此结构出现在C ++ 17的标准中,但可以立即在C ++ 11中复制:
template
using void_t = void;
void_t只是实例化它传递的模板类型,并且从不使用它们。 如果可以的话,它就像模板的代孕母亲。
为了使代码正常工作,我们以这种方式编写特化代码:
template
struct is_incrementable())>> : std::true_type{};
好吧,要了解TMP,还需要了解decltype和declval:decltype返回其参数的类型,而declval
如上所述,void_t只是实例化此返回类型的助手。它不携带任何数据或行为,只是一种启动板,用于实例化由decltype返回的类型。
如果增量表达式无效,则由void_t进行的实例化将失败,SFINAE启动,is_incrementable 解析为继承自std :: false_type的主模板。
这是一个很棒的机制,但我对这个名字有 异议。在我看来,这绝对是错误的抽象级别:将其实现为void,但是要做的是尝试实例化一个类型。通过将这些信息处理为代码,TMP表达式立即清晰起来:
template
using try_to_instantiate = void;
template
struct is_incrementable())>> : std::true_type{};
考虑到使用两个模板参数的特化,主模板也必须具有两个参数。 为了避免用户传值,我们提供了一个默认类型,即void。 现在的问题是如何命名该技术参数?
解决此问题的一种方法是完全不命名(顶部的代码使用了此选项):
template
struct is_incrementable : std::false_type{};
我认为这是一种说“别看这个,不相关,而且只出于技术原因”的一种方式。 另一种选择是给它起一个名字,说明它的意思。 第二个参数是尝试实例化特化形式中的表达式,因此我们可以将此信息写到名称中,从而提供到目前为止的完整解决方案:
template
using try_to_instantiate = void;
template
struct is_incrementable : std::false_type{};
template
struct is_incrementable())>> : std::true_type{};
分离抽象层次
我们可以在这里就完成了。 但是可以说 is_incrementable 中的代码仍然过于技术化,可能会被推到较低的抽象层。 此外,可以想象,在某个时候我们将需要使用相同的技术来检查其他表达式,并且最好将检查机制排除在外,以避免代码重复。
我们最终将得到类似于is_detected功能的内容。
上面代码中变化最大的部分显然是decltype表达式。 因此,让我们将其作为模板参数放到输入中。 但是,再次让我们仔细选择名称:此参数表示一个表达式类型。
此表达式本身取决于模板参数。 因此,我们不只是使用类型名作为参数,而是使用模板(因此使用template
template class Expression, typename Attempt = void>
struct is_detected : std::false_type{};
template class Expression>
struct is_detected>> : std::true_type{};
然后 is_incrementable 就变成了
template
using increment_expression = decltype(++std::declval());
template
using is_incrementable = is_detected;
在表达式中允许几种类型
到目前为止,我们已经使用了仅涉及一种类型的表达式,但是能够将多种类型传递给表达式将是很好的选择。 例如,用于测试两种类型是否可相互赋值。
为此,我们需要使用可变参数模板来表示表达式中的类型。 我们想像下面的代码一样添加一些点,但是它不起作用:
template class Expression, typename Attempt = void>
struct is_detected : std::false_type{};
template class Expression>
struct is_detected>> : std::true_type{};
这是行不通的,因为可变参数包的类型名称... Ts将占用所有模板参数,因此需要将其放在最后。 但是默认模板参数Attempt也需要放在最后。 所以我们遇到一个问题。
首先,将包移至模板参数列表的末尾,然后删除“Attempt”的默认类型:
template class Expression, typename Attempt, typename... Ts>
struct is_detected : std::false_type{};
template class Expression, typename... Ts>
struct is_detected>, Ts...> : std::true_type{};
但是传递给Attempt什么类型呢?
第一反应可能是传void,因为try_to_instantiate的成功分支处理了void,因此我们需要传递它以实例化特化模板。
但是我认为这样做会使调用者挠头:传void意味着什么? 与函数的返回类型相反,void在TMP中并不表示“无”,因为void是一种类型。
因此,给它起一个更好地表达我们意图的名称。 有人称这种事情为“dummy”,但我喜欢给个更准确的名称:
using disregard_this = void;
但是我猜这个名称因人而异。
然后可以通过以下方式编写赋值检查:
template
using assign_expression = decltype(std::declval() = std::declval());
template
using are_assignable = is_detected
当然,即使disregard_this通过说我们不需要担心来让读者放心,但它仍然会放在那碍事。
一种解决方案是将其隐藏在间接级别之后:is_detected_impl。 “ impl_”通常在TMP中(以及在其他地方)也意味着“间接级别”。 虽然我觉得这个词不自然,但我想不出一个更好的名字,而且由于很多TMP代码都使用它,所以这个名字也约定俗成了。
我们还将利用这种间接级别来获取:: value属性,从而避免所有元素在每次使用它时都要调用一次。
最终的代码如下:
template
using try_to_instantiate = void;
using disregard_this = void;
template class Expression, typename Attempt, typename... Ts>
struct is_detected_impl : std::false_type{};
template class Expression, typename... Ts>
struct is_detected_impl>, Ts...> : std::true_type{};
template class Expression, typename... Ts>
constexpr bool is_detected = is_detected_impl::value;
这是如何使用它:
template
using assign_expression = decltype(std::declval() = std::declval());
template
constexpr bool is_assignable = is_detected;
生成的值可以在编译时或运行时使用。 以下程序:
// 编译时使用
static_assert(is_assignable, "");
static_assert(!is_assignable, "");
// 运行时使用
std::cout << std::boolalpha;
std::cout << is_assignable << '\n';
std::cout << is_assignable << '\n';
编译成功,而且输出:
true
false
TMP不必那么复杂
诚然,要了解TMP,需要满足一些先决条件,例如SFINAE等。 但是除此之外,没有必要把使用TMP的代码搞的比实际需要的复杂。
考虑一下现在进行单元测试的好习惯:不能因为不是生产代码,所以我们就降低质量标准。 嗯,对于TMP来说更是如此:这是生产代码。 因此,让我们将其与代码的其余部分一样对待,并尽最大努力使其表现力更好。 很有可能会吸引更多的人。 社区越丰富,好主意就越多。