Back

第二部分-模板特性

第二部分: 模板特性

9 类模板参数推导

C++17之前,你必须显式指定类模板的所有模板参数类型。比如,你不能忽略这里的double:

std::complex<double> c{5.1,3.3};

也不能忽略第二次的std::mutex

std::mutex mx;
std::lock_guard<std::mutex> lg(mx);

C++17开始,必须显式指定类模板的所有模板参数类型这个限制变得宽松了。有了类模板参数推导(class template argument deduction,CTAD)技术,如果构造函数可以推导出所有模板参数,那么你可以跳过显式指定模板实参。

比如:

  • 你可以这样声明:
std::complex c{5.1,3.3}; // 好的:推断出 std::complex<double>
  • 你可以这样实现:
std::mutex mx;
std::lock_guard lg{mx}; // OK: 推断出 std::lock_guard<std_mutex>
  • 你甚至可以让容器推导其元素的类型:
std::vector v1 {1, 2, 3} // OK: 推断出 std::vector<int>
std::vector v2 {"hello", "world"}; // OK: 推断出 std::vector<const char*> 

9.1 类模板参数推导的使用

只要传给构造函数的实参可以用来推导类型模板参数,那么就可以使用类模板参数推导技术。该技术支持所有初始化方式:

std::complex c1{1.1, 2.2}; // 推断出  std::complex<double>
std::complex c2(2.2, 3.3); // 推断出  std::complex<double>
std::complex c3 = 3.3; // 推断出  std::complex<double>
std::complex c4 = {4.4}; // 推断出  std::complex<double>

c3和c4的初始化方式是可行的,因为你可以传递一个值来初始化std::complex<>,这对于推导出模板参数T来说足够了,它会被用于实数和虚数部分:

namespace std {
    template<typename T>
    class complex {
        constexpr complex(const T& re = T(), const T& im = T());
        ...
    }
};

假设有如下声明

std::complex c1{1.1, 2.2};

编译器会在调用的地方找到构造函数

constexpr complex(const T& re = T(), const T& im = T());

因为两个参数T都是double,所以编译器推导出T是double,然后编译下面的代码:

complex<double>::complex(const double& re = double(),
                         const double& im = double());

注意模板参数必须是无歧义、可推导的。因此,下面的初始化是有问题的:

std::complex c5{5,3.3}; // 错误:尝试将 int 和 double 作为 T

对于模板来说,不会在推导模板参数的时候做类型转换。

对于可变参数模板的类模板参数推导也是支持的。比如,std::tuple<>定义如下:

namespace std {
  template<typename... Types>
  class tuple;
    public:
    constexpr tuple(const Types&...);
    ...
  };
};

这个声明:

std::tuple t{42, 'x', nullptr};

推导出的类型是std::tuple

你也可以推导出非类型模板参数。举个例子,像下面例子中传递一个数组,在推导模板参数的时候可以同时推导出元素类型和数组大小:

template<typename T, int SZ>
class MyClass {
    public:
    MyClass (T(&)[SZ]) {
        ...
    }
};
MyClass mc("hello"); // 将 T 推导出为 const char 并将 SZ 推导出为 6

SZ推导为6,因为模板参数类型传递了一个六个字符的字符串字面值。

你甚至可以推导出用作基类的lambda的类型,或者推导出auto模板参数类型。

9.1.1 默认复制

如果类模板参数推导发现一个行为更像是拷贝初始化,它就倾向于这么认为。比如,在用一个元素初始化std::vector后:

std::vector v1{42}; // 带有一个元素的vector<int>

用这个vector去初始化另一个vector:

std::vector v2{v1}; // v2 也是 vector<int>

v2会被解释为vector而不是vector>

又比如,这个规则适用于下面所有初始化形式:

std::vector v3(v1); // v3 也是 vector<int>
std::vector v4 = {v1}; // v4 也是 vector<int>
auto v5 = std::vector{v1}; // v5 也是 vector<int>

如果传递多个元素时,就不能被解释为拷贝初始化,此时initializer list的类型会成为新vector的元素类型:

std::vector vv{v, v}; // vv vector<vector<int>>

那么问题来了,如果传递可变参数模板,那么类模板参数推导会发生什么:

template<typename... Args>
auto make_vector(const Args&... elems) {
    return std::vector{elems...};
}

std::vector<int> v{1, 2, 3};
auto x1 = make_vector(v, v); // vector<vector<int>>
auto x2 = make_vector(v); // vector<int> 或vector<vector<int>> ?

当前,不同的编译器有不同的处理方式,这个问题还在讨论中。

9.1.2 推导 Lambda 的类型

有了类模板参数推导,我们现在终于可以用lambda的类型实例化类模板类。举个例子,我们可以提供一个泛型类,然后包装一下callback,并统计调用了多少次callback:

// tmpl/classarglambda.hpp
#include <utility> // for std::forward()

template<typename CB>
class CountCalls
{
private:
  CB callback; // callback to call
  long calls = 0; // counter for calls
public:
  CountCalls(CB cb) : callback(cb) {
  }
  template<typename... Args>
  auto operator() (Args&&... args) {
    ++calls;
    return callback(std::forward<Args>(args)...);
  }
  long count() const {
    return calls;
  }
};

这里,构造函数接受一个callback,然后包装一下,用它的类型来推导出模板参数CB。比如,我们可以传一个lambda:

CountCalls sc([](auto x, auto y) {
    return x > y;
});

这意味着sc的类型被推导为CountCalls

通过这种方式,我们可以计算传递给排序函数的sc的调用次数:

std::sort(v.begin(), v.end(),
          td::ref(sc));
std::cout << "sorted with " << sc.count() << " calls\n";

包装后的lambda通过引用的方式传递给排序函数,因为如若不然std::sort()只会计算传递给他的lambda的拷贝的调用,毕竟是传值的方式。

然而,我没可以传递包装后的lambda给std::for_each,因为这个算法可以返回传递给他的callback的拷贝:

auto fo = std::for_each(v.begin(), v.end(),
                        CountCalls([](auto i) {
                                      std::cout << "elem: " << i << '\n';
                        }));
std::cout << "output with " << fo.count() << " calls\n";
9.1.3 非部分类模板参数推导

不像函数模板那样,类模板参数不能部分推导(显示模板参数的一部分)。比如:

template<typename T1, typename T2, typename T3 = T2>
class C {
    public:
    C (T1 x = T1{}, T2 y = T2{}, T3 z = T3{}) {
        ...
    }
    ...
};
// all deduced:
C c1(22, 44.3, "hi"); // OK:T1 是 int,T2 是 double,T3 是 const char*
C c2(22, 44.3); // OK: T1 是 int,T2 和 T3 是 double
C c3("hi", "guy"); // OK: T1、T2 和 T3 是 const char*
// only some deduced:
C<string> c4("hi", "my"); // ERROR: 只有 T1 明确定义
C<> c5(22, 44.3); // ERROR: 既不是 T1 也不是 T2 明确定义
C<> c6(22, 44.3, 42); // ERROR: T1 和 T2 都没有明确定义
// all specified:
C<string,string,int> c7; // OK: T1,T2 是字符串,T3 是整数
C<int,string> c8(52, "my"); // OK: T1 是 int,T2 和 T3 是字符串
C<string,string> c9("a", "b", "c"); // OK: T1,T2,T3 是字符串

因为第三个模板参数类型有默认值,所以如果已经指定了第二个就可以省略第三个。

如果i想知道为什么不支持偏特化,下面是造成这个抉择的原因:

std::tuple<int> t(42, 43); // still ERROR

std::tuple是一个可变参数模板,所以你可以指定任意数量的参数。在这种情况下,到底是认为这是只指定了一个类型的而导致的错误还是有意为之很难说清。看起来是有问题的。后期有更多考量后,偏特化也有可能加入C++标准。尽管目前没有。

不幸的是,缺少部分特化就不能解决一个常见代码需求。对于关联容器的排序规则,或者无序容器的hash函数,我们仍然不能简单的传一个lambda:

std::set<Cust> coll([](const Cust& x, const Cust& y) { // still ERROR
    return x.name() > y.name();
});

我们还是得指定lambda的类型,因此需要像下面这样写:

auto sortcrit = [](const Cust& x, const Cust& y) {
    return x.name() > y.name();
};
std::set<Cust, decltype(sortcrit)> coll(sortcrit); // OK
9.1.4 类模板参数推导代替便捷的工具函数。

有了类模板参数推导,我们可以不再使用那些目的仅是推导传的参数的类型的便捷工具函数。

最明显的是make_pair,他允许我们不指定传的参数的类型。比如,对于v:

std::vector<int> v;

我们可以使用:

auto p = std::make_pair(v.begin(), v.end());

来代替

std::pair<typename std::vector<int>::iterator,typename std::vector<int>::iterator> p(v.begin(), v.end());

现在,make_pair()不再需要了,可以直接这么写:

std::pair p(v.begin(), v.end());

然而,std::make_pair() 也是一个很好的例子,它证明了有时便利函数不仅仅是推导模板参数。 事实上,std::make_pair() 也会衰减,这尤其意味着传递的字符串文字的类型被转换为 const char *:

auto q = std::make_pair("hi", "world"); // 一对指针

在这种情况下,q 的类型为 std::pair<const char*, const char*>。 通过使用类模板参数推导,事情变得更加复杂。 让我们看看像 std::pair 这样的简单类声明的相关部分:

template<typename T1, typename T2>
struct Pair1 {
    T1 first;
    T2 second;
    Pair1(const T1& x, const T2& y) : first{x}, second{y} {
    }
};

关键是元素是通过引用传递的。 并且根据语言规则,当通过引用传递模板类型的参数时,参数类型不会衰减,这就是 将原始数组类型转换为相应的原始指针类型的机制。 因此,在调用时:

Pair1 p1{"hi", "world"}; // 推导出一对不同大小的数组,但是......

T1 推导出为 char[3],T2 推导为 char[6]。 原则上,这样的推导是有效的。 但是,我们使用 T1 和 T2 来声明成员 first 和 second。 结果,他们是 声明为:

char first[3];
char second[6];

并且不允许从数组的左值初始化数组。 这就像尝试编译:

const char x[3] = "hi";
const char y[6] = "world";
char first[3] {x}; // ERROR
char second[6] {y}; // ERROR

请注意,在声明要按值传递的参数时,我们不会遇到这个问题:

template<typename T1, typename T2>
struct Pair2 {
    T1 first;
    T2 second;
    Pair2(T1 x, T2 y) : first{x}, second{y} {
    }
};

如果对于这种类型,我们会调用:

Pair2 p2{"hi", "world"}; // 推导出一对指针

T1 和 T2 都将被推导出为 const char*。 因为声明了类 std::pair<> 以便构造函数通过引用获取参数, 您现在可能期望以下初始化不会编译:

std::pair p{"hi", "world"}; // 似乎推断出一对不同大小的数组,但是......

但它编译。 原因是我们使用推到。

9.2 推导指南

您可以定义特定的推导指南以提供额外的类模板参数推导 或修复构造函数定义的现有扣除。 例如,您可以定义每当 推导出 Pair3 的类型,类型推导应该像类型被传递一样操作 价值:

template<typename T1, typename T2>
struct Pair3 {
    T1 first;
    T2 second;
    Pair3(const T1& x, const T2& y) : first{x}, second{y} {
    }
};
// 构造函数的推导指南:
template<typename T1, typename T2>
Pair3(T1, T2) -> Pair3<T1, T2>;

在这里,在->的左边,我们声明我们要推导的东西。在这个例子中,它是由两个任意类型的对象T1和T2通过值创建一个 Pair3,由两个任意类型的T1和T2的对象通过值传递。在"->“的右边,我们 的右边定义了所产生的推理。在这个例子中,Pair3被实例化为两个类型的T1和T2。 你可能会说,这就是构造函数已经做的事情。然而,构造函数采用 参数的引用,这是不一样的。一般来说,即使在模板之外,参数 衰减,而通过引用传递的参数不会衰减。衰减意味着,原始 数组转换为指针,而顶级限定符,如const和引用,则被忽略。 如果没有推导指南,例如,在声明以下内容时:

Pair3 p3{"hi", "world"};

参数 x 的类型,因此 T1 是 const char[3] 和参数 y 的类型,因此 T2 是 const char[6]。 由于推导,模板参数衰减,这意味着传递的数组或 字符串文字衰减为相应的指针类型。 现在,当声明以下内容时:

Pair3 p3{"hi", "world"};

使用推导指南,它按值获取参数,以便两种类型都衰减为 const char*。 声明的效果就好像我们已经声明了:

Pair3<const char*, const char*> p3{"hi", "world"};

请注意,构造函数仍然通过引用获取参数。 推导指南只重要 用于推导模板类型。 这与类型之后的实际构造函数调用无关 推导出 T1 和 T2。

9.2.1 使用推导引导强制衰减

正如前面的例子所展示的,一般来说,这些重载的一个非常有用的应用 规则是确保模板参数 T 在推导时衰减。考虑一个典型的类模板:

template<typename T>
struct C {
    C(const T&) {
    }
    ...
};

如果我们在这里传递一个字符串字面量“hello”,则推导出 T 为字符串字面量的类型,即 const char[6]:

C x{"hello"}; // T 推导出为 const char[6]

原因是模板参数推导没有衰减到对应的指针类型,当参数通过引用传递时。 有一个简单的推导指南

template<typename T> C(T) -> C<T>;

我们解决了这个问题:

C x{"hello"}; // T 推导出为 const char*

现在,因为推导指南按值接受它,它的类型衰减了,所以"hello” 推导出 T 为 const char* 类型。 因此,对于任何类模板,相应的推导指南听起来都非常合理 有一个构造函数通过引用获取其模板参数的对象。 C++ 标准 库提供了 pair 和 tuple 的相应推导指南。

9.2.2 非模板推导

推导指南不必是模板,也不必适用于构造函数。 例如, 给定以下结构和推导指南:

template<typename T>
struct S {
    T val;
};
S(const char*) -> S<std::string>; // 将字符串文字的 S<> 映射到 S<std::string>

以下声明是可能的,其中 std::string 从 const 推导出为 T 的类型 char* 因为传递的字符串文字隐式转换为它:

S s1{"hello"}; // OK, 等同于: S<std::string> s1{"hello"};
S s2 = {"hello"}; // OK, 等同于: S<std::string> s2 = {"hello"};
S s3 = S{"hello"}; // OK, 两个 S 都推断为 S<std::string>

请注意,聚合需要列表初始化(推导有效,但不允许初始化):

S s4 = “hello”; // 错误(不能以这种方式初始化聚合)

9.2.3 推导与构造函数

推导指南与类的构造函数竞争。类模板参数推导使用根据重载决议具有最高优先级的构造函数/指南。如果构造函数和推导指南同样匹配,则优选推导指南。

考虑我们有以下定义:

template<typename T>
struct C1 {
    C1(const T&) {
    }
};
C1(int) -> C1<long>;

当传递一个int时,使用推导指南,因为它被重载解析所青睐。 因此,T被推导为long:

C1 x1{42}; // T 被推导为 long

但是如果我们传递一个char,构造函数是更好的匹配(因为不需要类型转换),所以 我们将 T 推导出为 char:

C1 x3{'x'}; // T 被推导为 char

因为通过值匹配取参数与通过引用取参数和演绎指南同样适用于同样好的匹配,所以通常让演绎指南取 价值论据(这也有衰减的优势)。

9.2.4 显式推导

一个推导指南可以被声明为是明确的。然后它只在以下情况下被忽略,即 显式会使初始化或转换失效。例如,鉴于:

template<typename T>
struct S {
    T val;
};
explicit S(const char*) -> S<std::string>;

传递推导指南参数类型的 S 对象的复制初始化(使用 =)忽略 扣除指南。 在这里,这意味着初始化变得无效:

S s1 = {"hello"}; // ERROR (推导指南被忽略,否则无效)

直接初始化或在右侧进行显式推导仍然是可能的:

S s2{"hello"}; // OK, same as: S<std::string> s1{"hello"};
S s3 = S{"hello"}; // OK
S s4 = {S{"hello"}}; // OK

作为另一个示例,我们可以执行以下操作:

template<typename T>
struct Ptr
{
    Ptr(T) { std::cout << "Ptr(T)\n"; }
    template<typename U>
    Ptr(U) { std::cout << "Ptr(U)\n"; }
};
template<typename T>
explicit Ptr(T) -> Ptr<T*>;

这将产生以下效果:

Ptr p1{42}; // 由于推导引导推导 Ptr<int*>
Ptr p2 = 42; // 由于构造函数推导出 Ptr<int>
int i = 42;
Ptr p3{&i}; // 由于推导引导推导 Ptr<int**>
Ptr p4 = &i; // 由于构造函数推导出 Ptr<int*>
9.2.5 集合体推导

推导指南可用于通用聚合以启用类模板参数推导 那里。 例如,对于:

template<typename T>
struct A {
    T val;
};

任何没有推导指南的类模板参数推导试验都是错误的:

A i1{42}; // ERROR
A s1("hi"); // ERROR
A s2{"hi"}; // ERROR
A s3 = "hi"; // ERROR
A s4 = {"hi"}; // ERROR

您必须显式传递类型 T 的参数:

A<int> i2{42};
A<std::string> s5 = {"hi"};

但经过推导如:

A(const char*) -> A<std::string>;

您可以按如下方式初始化聚合:

A s2{"hi"}; // OK
A s4 = {"hi"}; // OK

但是,与聚合一样,您仍然需要花括号。 否则,类型 T 成功 推导出来,但初始化是错误的:

A s1("hi"); // 错误:T 是字符串,但没有聚合初始化
A s3 = "hi"; // 错误:T 是字符串,但没有聚合初始化

std::array 的推导指南是聚合推导指南的另一个示例。

9.2.6 标准推导

C++ 标准库在 C++17 中引入了一些推导指南。 Pairs 和 Tuples 推导指南 正如在推导指南的动机中所介绍的那样,std::pair 需要推导指南来确保 类模板参数推导使用传入参数的衰减类型:

namespace std {
    template<typename T1, typename T2>
    struct pair {
        ...
    constexpr pair(const T1& x, const T2& y); // 引用参数
        ...
    };
    template<typename T1, typename T2>
    pair(T1, T2) -> pair<T1, T2>; // 按值推断参数类型
}

因此,声明

std::pair p{"hi", "world"}; // 采用 const char[3] 和 const char[6] 等价于:
std::pair<const char*, const char*> p{"hi", "world"};

对于可变参数类模板 std::tuple,使用相同的方法:

namespace std {
    template<typename... Types>
    class tuple {
    public:
    	constexpr tuple(const Types&...); // 通过引用获取参数
        template<typename... UTypes> constexpr tuple(UTypes&&...);
        ...
    };
    template<typename... Types>
    tuple(Types...) -> tuple<Types...>; // 按值推断参数类型
};

因此,声明:

std::tuple t{42, "hello", nullptr};

将 t 的类型推导出为 std::tuple<int, const char*, std::nullptr_t>。

迭代器的推导: 为了能够从定义初始化范围的迭代器中推断出元素的类型, 容器对 std::vector<> 有如下推导指南:

// let std::vector<> 从初始化迭代器推断元素类型:
namespace std {
    template<typename Iterator>
    vector(Iterator, Iterator)
    -> vector<typename iterator_traits<Iterator>::value_type>;
}

例如,这允许:

std::set<float> s;
std::vector v1(s.begin(), s.end()); // OK, 推导出 std::vector<float>

请注意,此处使用带括号的初始化很重要。 如果使用花括号:

std::vector v2{s.begin(), s.end()}; // 注意:不推断 std::vector<float>

这两个参数被视为初始化列表的元素(根据 重载决议规则)。 也就是说,相当于:

std::vector<std::set<float>::iterator> v2{s.begin(), s.end()};

这样我们就初始化了一个包含两个元素的向量,第一个引用第一个元素,第二个引用表示最后一个元素后面的位置。 另一方面,考虑:

std::vector v3{"hi", "world"}; // OK, 推导std::vector<const char*>
std::vector v4("hi", "world"); // OOPS:致命的运行时错误

虽然 v3 的声明还使用两个元素(都是 C 字符串)初始化向量,但 第二个导致致命的运行时错误,这可能会导致核心转储。 问题是那个字符串 文字转换为字符指针,这是有效的迭代器。 因此,我们传递了两个迭代器 不指向同一个对象。 换句话说:我们传递了一个无效的范围。 取决于两者在哪里 文字被存储,你会得到一个带有任意数量元素的 std::vector 。 如果 它太大了你得到一个 bad_alloc 异常,或者你得到一个核心转储,因为没有距离 全部,或者你会得到一些存储在它们之间的未定义字符的范围。 因此,在初始化向量的元素时,使用花括号总是最好的。 唯一的 例外是传递单个向量时(首选复制构造函数)。 通过时 别的东西,使用括号更好。

std::array<> 推导

一个更有趣的例子提供了 std::array<> 类:为了能够推导出这两个元素 类型和元素数量:

std::array a{42,45,77}; // OK, 推导出 std::array<int,3>

定义了以下推导指南:

// let std::array<> 推断它们的元素数量(必须具有相同的类型):
namespace std {
    template<typename T, typename... U>
    array(T, U...)
    -> array<enable_if_t<(is_same_v<T,U> && ...), T>,
    (1 + sizeof...(U))>;
}

演绎指南使用折叠表达式

(is_same_v<T,U> && ...)

确保所有传递的参数的类型相同。 因此,以下是不可能的:

std::array a{42,45,77.7}; // ERROR: 类型不同
(无序)Map推导

可以证明获得行为正确的演绎指南所涉及的复杂性 通过试验为具有 key/value 对(map、multimap、 unordered_map,unordered_multimap)。 这些容器的元素具有 std::pair<const keytype, valuetype> 类型。 常量 是必要的,因为元素的位置取决于键的值,因此能够 修改密钥可能会在容器内产生不一致。 因此,C++17 标准中用于 std::map 的方法:

namespace std {
    template<typename Key, typename T,
    typename Compare = less<Key>,
    typename Allocator = allocator<pair<const Key, T>>>
        class map {
            ...
        };
}

例如,为以下构造函数定义:

map(initializer_list<pair<const Key, T>>,
    const Compare& = Compare(),
    const Allocator& = Allocator());

以下推导指南:

namespace std {
    template<typename Key, typename T,
    typename Compare = less<Key>,
    typename Allocator = allocator<pair<const Key, T>>>
        map(initializer_list<pair<const Key, T>>,
            Compare = Compare(),
            Allocator = Allocator())
        -> map<Key, T, Compare, Allocator>;
}

由于所有参数都是按值传递的,因此本推导指南允许传递的比较器或分配器的类型如所讨论的那样衰减。 然而,我们天真地使用了相同的参数类型, 意味着初始化列表采用 const 键类型。 但结果是,以下不起作用 正如 Ville Voutilainen 在 https://wg21.link/lwg3025 中指出的那样:

std::pair elem1{1,2};
std::pair elem2{3,4};
...
std::map m1{elem1, elem2}; // 与原始 C++17 指南有关的错误

因为这里的元素被推导为 std::pair<int,int>,这与需要 const 类型作为第一对类型的推导指南不匹配。 因此,您仍然必须编写以下内容:

std::map<int,int> m1{elem1, elem2}; // OK, 因此,在推导指南中,应该删除 const:
namespace std {
    template<typename Key, typename T,
    typename Compare = less<Key>,
    typename Allocator = allocator<pair<const Key, T>>>
        map(initializer_list<pair<Key, T>>,
            Compare = Compare(),
            Allocator = Allocator())
        -> map<Key, T, Compare, Allocator>;
}

然而,为了仍然支持比较器和分配器的衰减,我们还必须重载 具有 const 键类型的对的推导指南。 否则将使用构造函数,以便 当与 const 和配对时,类模板参数推导的行为会略有不同 非常量键被传递。

智能指针无推导指南

请注意,C++ 标准库中的某些地方没有推导指南,尽管您可能 期望它们可用。 例如,您可能希望有共享和唯一指针的推导指南,以便 代替:

std::shared_ptr<int> sp{new int(7)};

你可以写:

std::shared_ptr sp{new int(7)}; // 不支持

这不会自动工作,因为对应的构造函数是一个模板,所以没有 隐式推导指南适用:

namespace std {
    template<typename T> 
    class shared_ptr {
        public:
        ...
        template<typename Y> explicit shared_ptr(Y* p);
        ...
    };
}

Y 是与 T 不同的模板参数,因此从构造函数推导出 Y 并不意味着 我们可以推导出类型 T。这是一个能够调用类似以下内容的功能:

std::shared_ptr<Base> sp{new Derived(...)};

相应的推导指南将很容易提供:

namespace std{
    template<typename Y> shared_ptr(Y*) -> shared_ptr<Y>;
}

但是,这也意味着在分配数组时会采用本指南:

std::shared_ptr sp{new int[10]}; // OOPS:会推导出 shared_ptr<int>

在 C++ 中,我们经常遇到令人讨厌的 C 问题,即指向一个对象的指针的类型和 对象数组具有或衰减为相同类型。 因为这个问题看起来很危险,所以 C++ 标准委员会决定不支持 (还没完成)。 您仍然需要调用单个对象:

std::shared_ptr<int> sp1{new int}; // OK
auto sp2 = std::make_shared<int>(); // OK

对于数组:

std::shared_ptr<std::string> p(new std::string[10],
                               [](std::string* p) {
                                   delete[] p;
                               });

或者:

std::shared_ptr<std::string> p(new std::string[10],
std::default_delete<std::string[]>());

9.3 后记

类模板参数推导由 Michael Spertus 于 2007 年在 https 中首次提出: //wg21.link/n2332。 该提案于 2013 年由 Michael Spertus 和 David Vandevoorde 在 https://wg21.link/n3602 中提出。 最终接受的措辞由迈克尔制定 Spertus、Faisal Vali 和 Richard Smith 在 https://wg21.link/p0091r3 中进行了修改 https://wg21.link/p0512r0 中的 Michael Spertus、Faisal Vali 和 Richard Smith,https://wg21.link/p0620r0 中的 Jason Merrill,以及 Michael Spertus 和 Jason Merrill(作为缺陷报告) 反对 C++17) 在 https://wg21.link/p702r1。 Michael 添加了对标准库中类模板参数推导的支持 Spertus、Walter E. Brown 和 Stephan T. Lavavej 在 https://wg21.link/p0433r2 和(作为 https://wg21.link/p0739r0 中针对 C++17 的缺陷报告。

10 编译时if

使用 if constexpr(. . . ) 语法,编译器使用编译时表达式在编译时决定是使用 if 语句的 then 部分还是 else 部分(如果有)。 另一部分(如果 any) 被丢弃,因此不会生成任何代码。 这并不意味着它是丢弃的部分. 不过,完全忽略了。 它将像未使用模板的代码一样进行检查。 例如:

#include <string>
template <typename T>
std::string asString(T x)
{
    if constexpr(std::is_same_v<T, std::string>) {
        return x; // 语句无效,如果没有转换为字符串
    }
    else if constexpr(std::is_arithmetic_v<T>) {
        return std::to_string(x); // 声明无效,如果 x 不是数字
    }
    else {
        return std::string(x); // 语句无效,如果没有转换为字符串
    }
}

在这里,我们使用这个特性在编译时决定是否只返回一个传递的字符串,调用 std::to_string() 获取传递的整数或浮点值,或尝试将传递的参数转换为 std::string。 因为无效调用被丢弃,下面的代码编译(其中 如果使用常规运行时,则不会出现这种情况):

#include "ifcomptime.hpp"
#include <iostream>
int main()
{
    std::cout << asString(42) << '\n';
    std::cout << asString(std::string("hello")) << '\n';
    std::cout << asString("hello") << '\n';
}

10.1 编译时if的时机

如果我们在刚刚介绍的示例中使用运行时 if:

#include <string>
template <typename T>
std::string asString(T x)
{
    if (std::is_same_v<T, std::string>) {
        return x; // ERROR, 如果没有转换为字符串
    }
    else if (std::is_numeric_v<T>) {
        return std::to_string(x); // ERROR, 如果 x 不是数字
    }
    else {
        return std::string(x); // ERROR, 如果没有转换为字符串
    }
}

相应的代码永远不会编译。 这是函数模板通常不编译或作为一个整体编译的规则的结果。 if 条件的检查是 运行时功能。 即使在编译时很明显条件必须为假,那么 部分必须能够编译。 因此,当传递 std::string 或字符串文字时,编译失败, 因为对传递的参数调用 std::to_string() 无效。 当通过一个 数值,编译失败,因为第三个和第三个返回语句无效。 现在且仅通过使用编译时 if,无法使用的 then 和 else 部分变成 丢弃的语句:

  • 当传递一个std::string 值时,第一个if 的else 部分被丢弃。

  • 当传递一个数值时,第一个if 的then 部分和最后一个else 部分被丢弃。

  • 当传递一个字符串文字(即类型 const char*)时,第一个和第二个 if 的 then 部分 被丢弃。

因此,每个无效组合在编译时都不会再出现,代码编译成功。 请注意,丢弃的语句不会被忽略。 效果是它没有被实例化,当 取决于模板参数。 语法必须正确,不依赖模板参数的调用必须有效。 实际上,执行第一个翻译阶段(定义时间), 它检查正确的语法和所有不依赖于模板参数的名称的使用。 所有 static_asserts 也必须有效,即使在未编译的分支中也是如此。 例如:

template<typename T>
void foo(T t)
{
    if constexpr(std::is_integral_v<T>) {
        if (t > 0) {
            foo(t-1); // OK
        }
    }
    else {
        undeclared(t); // 如果未声明且未丢弃则错误(即 T 不是整数)
        undeclared(); // 如果未声明则错误(即使已丢弃)
        static_assert(false, "no integral"); // 总是断言(即使被丢弃)
    }
}

使用符合标准的编译器,此示例永远不会编译,原因有两个:

即使 T 是整数类型,调用

undeclared(); // 如果未声明则错误(即使已丢弃)

如果没有声明这样的函数,则丢弃的 else 部分是一个错误,因为这个调用没有 依赖于模板参数

调用:

static_assert(false, "no integral"); // 总是断言(即使被丢弃)

即使它是被丢弃的 else 部分的一部分,它也总是会下降,因为这个调用再次不依赖于 模板参数。 重复编译时条件的静态断言会很好:

static_assert(!std::is_integral_v<T>, "no integral");

请注意,某些编译器(例如,Visual C++ 2013 和 2015)没有正确实现或执行模板的两阶段转换。 它们将第一阶段(定义时间)的大部分时间推迟到第二阶段(实例化时间),因此无效的函数调用甚至一些语法错误都可能编译。

10.2 使用编译时if

原则上,如果条件是 编译时表达式。 在以下情况下,您还可以混合编译时和运行时:

if constexpr (std::is_integral_v<std::remove_reference_t<T>>) {
    if (val > 10) {
        if constexpr (std::numeric_limits<char>::is_signed) {
            ...
        }
        else {
            ...
        }
    }
    else {
        ...
    }
}
else {
    ...
}

请注意,您不能在函数体之外使用 if constexpr。 因此,你不能用它来代替 条件预处理器指令。

10.2.1 编译时if的注意事项

即使有可能使用 compile-time if ,如果可能有一些后果不是 很明显,这将在以下小节中讨论。

compile-time if 影响返回类型

compile-time if可能影响函数的返回类型。例如,下面的代码总是可以编译,但返回类型可能不同:

auto foo()
{
    if constexpr (sizeof(int) > 4) {
        return 42;
    }
    else {
        return 42u;
    }
}

在这里,由于我们使用了auto,函数的返回类型取决于返回语句,而返回语句则 取决于int的大小。

  • 如果大小大于4,只有一个有效的返回语句返回42,所以返回 类型是int。
  • 否则,只有一个返回语句返回42u,所以返回类型变成了 unsigned int.。

这样一来,带有if constexpr的函数的返回类型可能会有更大的差别例如,如果我们跳过else部分,返回类型可能是int或void。

auto foo() // 返回类型可能是int或void
{
    if constexpr (sizeof(int) > 4) { 
        return 42;
    }
}

请注意,如果这里使用了compile-time if,这段代码永远不会被编译,因为那样的话,两个返回语句都会被考虑在内,这样一来,对返回类型的推断就会变得模糊不清了。

else Matters Even if then Returns

对于compile-time if语句,有一种模式不适用于compile-time if语句。如果 代码的then和else部分都有返回语句,你可以跳过compile-time if语句中的else语句。也就是说,不要用:

if (...) {
    return a;
}
else {
    return b;
}

你总是可以写:

if (...) {
    return a;
}
return b;

如果条件为真(int的大小大于4),编译器会推断出两种不同的返回类型,这是不成立的。否则,我们只有一个重要的返回语句,这样代码就可以编译了。

短回路compile-time条件

考虑以下代码:

template<typename T>
constexpr auto foo(const T& val)
{
    if constexpr (std::is_integral<T>::value) { 
        if constexpr (T{} < 10) {
            return val * 2;
        }
    }
    return val;
}

这里我们有两个compile-time条件来决定是按原样返回传递的值还是加倍。

这为两者编译:

constexpr auto x1 = foo(42); // yields 84
constexpr auto x2 = foo("hi"); // OK, yields ”hi”

运行时ifs中的条件是短路的(评估带有&&的条件只到第一个false,评估带有||的条件只到第一个true)。这可能会导致人们期望compile-time if也是这种情况:

template<typename T>
constexpr auto bar(const T& val)
{
    if constexpr (std::is_integral<T>::value && T{} < 10) { 
        return val * 2;                                 
    }
    return val;
}

然而,compile-time if的条件总是被实例化的,并且需要整体有效,因此,传递一个不支持<10的类型不再能编译:

constexpr auto x2 = bar("hi"); // compile-time ERROR

因此,compile-time if不会使实例化short-circuit。 如果编译时条件的有效性依赖于较早的编译时条件,则必须像在 foo() 中那样嵌套它们。 作为另一个例子,你必须写:

if constexpr (std::is_same_v<MyType, T>) { 
    if constexpr (T::i == 42) {
        ...
    }
}

而不仅仅是:

if constexpr (std::is_same_v<MyType, T> && T::i == 42) {
    ...
}
10.2.2 其他编译时if的例子

编译时的一种应用是返回值的完美转发,当它们必须得到处理才能返回时。 因为 void 不能推导出 decltype(auto) (因为 void 是一个不完整的类型),所以你必须编写如下内容:

#include <functional> // for std::forward()
#include <type_traits> // for std::is_same<> and std::invoke_result<>
template<typename Callable, typename... Args>
decltype(auto) call(Callable op, Args&&... args)
{
    if constexpr(std::is_void_v<std::invoke_result_t<Callable, Args...>>) {
        // return type is void:
        op(std::forward<Args>(args)...);
        ... // do something before we return
        return;
    }
    else {
        // return type is not void:
        decltype(auto) ret{op(std::forward<Args>(args)...)};
        ... // do something (with ret) before we return
        return ret;
    }
}
Compile-Time if用于标签调度

编译时 if 的一个典型应用是标签调度。 在 C+17 之前,您必须提供 为您要处理的每种类型设置一个单独的函数的重载。 现在,在编译时 如果,您可以将所有逻辑放在一个函数中。 例如,不要重载 std::advance() 算法:

template<typename Iterator, typename Distance>
void advance(Iterator& pos, Distance n) {
    using cat = std::iterator_traits<Iterator>::iterator_category;
    advanceImpl(pos, n, cat); // 迭代器类别上的标签调度
}

template<typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n, std::random_access_iterator_tag) {
    pos += n;
}

template<typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n, std::bidirectional_iterator_tag) {
    if (n >= 0) {
        while (n--) {
            ++pos;
        }
    }
    else {
        while (n++) {
            --pos;
        }
    }
}

template<typename Iterator, typename Distance>
void advanceImpl(Iterator& pos, Distance n, std::input_iterator_tag) {
    while (n--) {
        ++pos;
    }
}

我们现在可以在一个函数中实现所有行为:

template<typename Iterator, typename Distance>
void advance(Iterator& pos, Distance n) {
    using cat = std::iterator_traits<Iterator>::iterator_category;
    if constexpr (std::is_same_v<cat, std::random_access_iterator_tag>) {
        pos += n;
    }
    else if constexpr (std::is_same_v<cat,
                       std::bidirectional_access_iterator_tag>) {
        if (n >= 0) {
            while (n--) {
                ++pos;
            }
        }
        else {
            while (n++) {
                --pos;
            }
        }
    }
    else { // input_iterator_tag
        while (n--) {
            ++pos;
        }
    }
}

所以,在某种程度上,我们现在有一个compile-time切换,不同的情况必须在这里得到 但是,由 if constexpr 子句制定。 但是,请注意一个可能很重要的区别:

  • 重载函数集为您提供最佳匹配语义。

  • 编译时if 的实现为您提供了第一个匹配语义。

标签调度的另一个例子是使用编译时 if for get<>() 重载来实现结构绑定接口。

第三个例子是通用 lambda 中不同类型的处理,如 std::variant<> visitors

10.3 带有初始化的编译时if

请注意,compile-time if 也可以使用新形式的 if with 初始化。 例如,如果 有一个 constexpr 函数 foo(),你可以使用:

template<typename T>
void bar(const T x)
{
    if constexpr (auto obj = foo(x); std::is_same_v<decltype(obj), T>) {
        std::cout << "foo(x) yields same type\n";
        ...
    }
    else {
        std::cout << "foo(x) yields different type\n";
        ...
    }
}

如果传递的类型有 constexpr 函数 foo(),您可以使用此代码提供不同的 关于 foo(x) 是否产生与 x 相同类型的行为。 要确定 foo(x) 返回的值,您可以编写:

constexpr auto c = ...;
if constexpr (constexpr auto obj = foo(c); obj == 0) {
    std::cout << "foo() == 0\n";
    ...
}

请注意,必须将 obj 声明为 constexpr 才能在条件中使用其值。

10.4 在模板之外使用编译时if

if constexpr 可以在任何函数中使用,而不仅仅是在模板中。 我们只需要一个编译时表达式,它会产生可转换为 bool 的东西。 但是,在那种情况下,在 then 和 the else 部分即使被丢弃,所有语句也必须始终有效。

例如,下面的代码总是会编译失败,因为 undeclared() 的调用必须是有效的,即使 chars 被签名并且 else 部分被丢弃:

#include <limits>
template<typename T>
void foo(T t);
int main()
{
    if constexpr(std::numeric_limits<char>::is_signed) {
        foo(42); // OK
    }
    else {
        undeclared(42); // 如果没有声明总是错误(即使被丢弃)
    }
}

此外,以下代码永远无法成功编译,因为其中一个静态断言将始终失败:

if constexpr(std::numeric_limits<char>::is_signed) {
    static_assert(std::numeric_limits<char>::is_signed);
}
else {
    static_assert(!std::numeric_limits<char>::is_signed);
}

如果在泛型代码之外,编译时的(唯一)好处是被丢弃语句中的代码,尽管它必须是有效的,但不会成为结果程序的一部分,这减少了生成的可执行文件。 例如,在这个程序中:

#include <limits>
#include <string>
#include <array>
int main()
{
    if (!std::numeric_limits<char>::is_signed) {
        static std::array<std::string,1000> arr1;
        ...
    }
    else {
        static std::array<std::string,1000> arr2;
        ...
    }
}

arr1或arr2是最终可执行文件的一部分,但不是两者都是。

10.5 后记

Compile-time if最初是由Walter Bright, Herb Sutter, 和Andrei Alexandrescu在 https://wg21.link/n3329,以及Ville Voutilainen在https://wg21.link/n4461,提出了静态的if语言特性。 一个静态的if语言特性。在https://wg21.link/p0128r0,Ville Voutilainen提出了这个 该特性首次被称为constexpr_if(该特性的名称由此而来)。最终被接受的 措辞是由Jens Maurer https://wg21.link/p0292r2

11 折叠表达式

从C++17开始,有一个功能是计算在一个参数包的所有参数上使用二元运算符的结果(有一个可选的初始值)。 例如,下面的函数返回所有传递参数的总和:

template<typename... T>
auto foldSum (T... args) {
    return (... + args); // ((arg1 + arg2) + arg3) ...
}

注意,返回表达式周围的括号是折叠表达式的一部分,不能省略。

调用函数时要注意

foldSum(47, 11, val, -1); 

实例化要执行的模板:

return 47 + 11 + val + -1;

调用它:

foldSum(std::string("hello"), "world", "!");

实例化模板:

return std::string("hello") + "world" + "!";

另请注意,折叠表达式参数的顺序可能不同且很重要(并且可能看起来有点违反直觉):如所写,

(... + args)

结果是

((arg1 + arg2) + arg3) ...

这意味着它会反复“添加”东西。 你也可以写

(args + ...)

它反复“预添加”事物,因此结果表达式为:

(arg1 + (arg2 + arg3)) ...

11.1 折叠表达式的动因

折叠表达式避免了递归实例化模板以对参数包的所有参数执行操作的需要。 在 C++17 之前,您必须实现:

template<typename T>
auto foldSumRec (T arg) {
    return arg;
}
template<typename T1, typename... Ts>
auto foldSumRec (T1 arg1, Ts... otherArgs) {
    return arg1 + foldSumRec(otherArgs...);
}

这样的实现不仅写起来麻烦,而且对 C++ 编译器也有压力。 和

template<typename... T>
auto foldSum (T... args) {
    return (... + args); // arg1 + arg2 + arg3 ...
}

程序员和编译器的工作量都大大减少了。

11.2 使用折叠表达式

给定参数 args 和运算符 op,C++17 允许我们编写

  • 要么是一元左折叠

    ( ... op args )
    

    扩展为: ((arg1 op arg2) op arg3) op . . .

  • 或一元右折叠

    ( args op ... )
    

    扩展为:arg1 op (arg2 op . . . (argN-1 op argN))

括号是必需的。 但是,括号和省略号 (…) 不必用空格分隔。 左右折叠之间的差异比预期的更重要。 例如,即使使用 operator + 也可能会有不同的效果。 使用左折叠表达式时:

template<typename... T>
auto foldSumL(T... args){
    return (... + args); // ((arg1 + arg2) + arg3) ...
}

调用

foldSumL(1, 2, 3)

计算结果为:

(1 + 2) + 3)

这也意味着以下示例可以编译:

std::cout << foldSumL(std::string("hello"), "world", "!") << '\n'; // OK

请记住,运算符 + 是为标准字符串定义的,前提是至少有一个操作数是 std::string。 因为使用了左折叠,所以调用首先计算:

std::string("hello") + "world"

它返回一个 std::string,以便添加字符串文字"!" 那么也是有效的。

但是,诸如:

std::cout << foldSumL("hello", "world", std::string("!")) << '\n'; // ERROR

不会编译,因为它的计算结果为:

("hello" + "world") + std::string("!")

并且不允许添加两个字符串文字。

但是,如果我们将实现更改为:

template<typename... T>
auto foldSumR(T... args){
    return (args + ...); // (arg1 + (arg2 + arg3)) ...
}

调用:

foldSumR(1, 2, 3)

计算结果为:

(1 + (2 + 3)

这意味着以下示例不再编译:

std::cout << foldSumR(std::string("hello"), "world", "!") << '\n'; // ERROR

而下面的调用现在编译:

std::cout << foldSumR("hello", "world", std::string("!")) << '\n'; // OK

因为在几乎所有情况下,从左到右的评估都是意图,通常应该首选带有参数包的左折叠语法(除非这不起作用):

(... + args); // 折叠表达式的首选语法
11.2.1 处理空参数包

如果折叠表达式使用了一个空的参数包,那么以下规则适用。

  • 如果使用了操作符&&,其值为真。

  • 如果使用了操作符||,则值为假。

  • 如果使用了逗号运算符,值是void()。

  • 对于所有其他的操作符来说,调用是不符合格式的。 对于所有其他情况(以及一般情况下),你可以添加一个初始值。给定一个参数包args,一个初始值值和一个运算符op,C++17还允许我们写出以下两种情况

  • 或者二元左折叠

    ( value op ... op args )
    

    扩展为: (((value op arg1) op arg2) op arg3) op . . .

  • 或二元右折叠

    ( args op ... op value )
    

省略号两边的运算符 op 必须相同。 例如,以下定义允许在添加值时传递一个空参数包:

template<typename... T>
auto foldSum (T... s){
    return (0 + ... + s); // 如果 sizeof...(s)==0 甚至可以工作
}

从概念上讲,我们是否将 0 添加为第一个或最后一个操作数并不重要:

template<typename... T>
auto foldSum (T... s){
    return (s + ... + 0); // 如果 sizeof...(s)==0 甚至可以工作
}

但是对于一元折叠表达式,不同的评估顺序比想象的更重要,应该首选二元左折叠:

(val + ... + args); // 二进制折叠表达式的首选语法

此外,第一个操作数可能是特殊的,例如在此示例中:

template<typename... T>
void print (const T&... args)
{
    (std::cout << ... << args) << '\n';
}

在这里,重要的是第一个调用是第一个传递给 print() 的参数的输出,它返回流以执行其他输出调用。 其他实现可能无法编译甚至做一些意想不到的事情。 例如,与

std::cout << (args << ... << '\n');

像 print(1) 这样的调用将编译但打印值 1 左移了 ‘\n’ 的值,通常为 10,因此结果输出为 1024。 请注意,在此 print() 示例中,没有空格将参数包的所有元素彼此分开。 诸如 print(“hello”, 42, “world”) 之类的调用将打印:

hello42world

要通过空格分隔传递的元素,您需要一个帮助器来确保除第一个参数之外的任何输出都由前导空格扩展。 例如,这可以使用辅助函数模板 spaceBefore() 来完成:

template<typename T>
const T& spaceBefore(const T& arg) {
    std::cout << ' ';
    return arg;
}
template <typename First, typename... Args>
void print (const First& firstarg, const Args&... args) {
    std::cout << firstarg;
    (std::cout << ... << spaceBefore(args)) << '\n';
}

这里,

(std::cout << ... << spaceBefore(args))

是一个折叠表达式,展开为:

std::cout << spaceBefore(arg1) << spaceBefore(arg2) << ...

因此,对于参数包 args 中的每个元素,它调用一个辅助函数,在返回传递的参数之前打印出一个空格字符,并将其写入 std::cout。 为了确保这不适用于第一个参数,我们添加了一个不使用 spaceBefore() 的附加第一个参数。 请注意,参数包输出的评估要求左侧的所有输出都在为实际元素调用 spaceBefore() 之前完成。 由于定义了运算符 « 和函数调用的评估顺序,这保证从 C++17 开始就可以工作。 我们还可以使用 lambda 在 print() 中定义 spaceBefore():

template<typename First, typename... Args>
void print (const First& firstarg, const Args&... args) {
    std::cout << firstarg;
    auto spaceBefore = [](const auto& arg) {
        std::cout << ' ';
        return arg;
    };
    (std::cout << ... << spaceBefore(args)) << '\n';
}

但是,请注意 lambdas 默认按值返回对象,这意味着这将创建传递参数的不必要副本。 避免这种情况的方法是将 lambda 的返回类型显式声明为 const auto& 或 decltype(auto):

template<typename First, typename... Args>
void print (const First& firstarg, const Args&... args) {
    std::cout << firstarg;
    auto spaceBefore = [](const auto& arg) -> const auto& {
        std::cout << ' ';
        return arg;
    };
    (std::cout << ... << spaceBefore(args)) << '\n';
}

如果你不能将这一切结合在一个语句中,C++ 就不会是 C++:

template<typename First, typename... Args>
void print (const First& firstarg, const Args&... args) {
    std::cout << firstarg;
    (std::cout << ... << [](const auto& arg) -> decltype(auto) {
        std::cout << ' ';
        return arg;
    }(args)) << '\n';
}

然而,实现 print() 的一种更简单的方法是使用 lambda 打印空间和参数并将其传递给一元折叠:

template<typename First, typename... Args>
void print(First first, const Args&... args) {
    std::cout << first;
    auto outWithSpace = [](const auto& arg) {
        std::cout << ' ' << arg;
    };
    (... , outWithSpace(args));
    std::cout << '\n';
}

通过使用使用 auto 声明的附加模板参数,我们可以使 print() 更加灵活,可以将分隔符参数化为字符、字符串或任何其他可打印类型。

11.2.2 支持的操作符

您可以将所有二元运算符用于折叠表达式,除了 ., ->, and []。

折叠函数调用

折叠表达式也可以用于逗号运算符,将多个表达式组合成一个语句。 例如,您可以折叠逗号运算符,它可以执行对可变数量的基类的成员函数的函数调用:

tmpl/foldcalls.cpp

#include <iostream>
// template fo ame... Bases>
class MultiBase : private Bases...
{
    public:
    void print() {
        // call print() of all base classes:
        (... , Bases::print());
    }
};
struct A {
    void print() { std::cout << "A::print()\n"; }
};
struct B {
    void print() { std::cout << "B::print()\n"; }
};
struct C {
    void print() { std::cout << "C::print()\n"; }
};
int main()
{
    MultiBase<A,B,C> mb;
    mb.print();
}

这里,

template<typename... Bases>
class MultiBase : private Bases...
{
    ...
};

允许我们使用可变数量的基类来初始化对象:

MultiBase<A,B,C> mb;

并与

(... , Bases::print());

折叠表达式用于扩展它以调用每个基类的打印。 也就是说,带有折叠表达式的语句扩展为以下内容:

(A::print() , B::print()) , C::print();

但是,请注意,由于逗号运算符的性质,我们使用左折叠运算符还是右折叠运算符并不重要。 函数总是从左到右调用。 和

(Bases::print() , ...);

括号仅对调用进行分组,以便第一个 print() 调用与其他两个 print() 调用的结果组合如下:

A::print() , (B::print() , C::print());

但是因为逗号运算符的计算顺序总是从左到右,所以第一个调用发生在括号内的两个调用组之前,其中中间调用仍然发生在右调用之前。 尽管如此,由于左折叠表达式与结果求值顺序匹配,因此在将左折叠表达式用于多个函数调用时,再次建议使用左折叠表达式。

结合哈希函数

使用逗号运算符的一个示例是组合散列值。 这可以按如下方式完成:

template<typename T>
void hashCombine (std::size_t& seed, const T& val)
{
    seed ^= std::hash<T>()(val) + 0x9e3779b9 + (seed<<6) + (seed>>2);
}
template<typename... Types>
std::size_t combinedHashValue (const Types&... args)
{
    std::size_t seed = 0; // initial seed
    (... , hashCombine(seed,args)); // chain of hashCombine() calls
    return seed;
}

通过调用

std::size_t combinedHashValue ("Hello", "World", 42,);

中间的语句扩展为:

hashCombine(seed,"Hello"), (hashCombine(seed,"World"), hashCombine(seed,42);

通过这个定义,我们可以轻松地为诸如 Customer 的类型定义一个新的散列函数对象:

struct CustomerHash
{
    std::size_t operator() (const Customer& c) const {
        return combinedHashValue(c.getFirstname(), c.getLastname(), c.getValue());
    }
};

我们可以用它来把客户放在一个无序的集合中:

std::unordered_set<Customer, CustomerHash> coll;

折叠路径遍历

您还可以使用折叠表达式通过运算符 ->* 遍历二叉树中的路径:

tmpl/foldtraverse.cpp

// 定义二叉树结构和遍历辅助函数:
struct Node {
    int value;
    Node* left;
    Node* right;
    Node(int i=0) : value(i), left(nullptr), right(nullptr) {
    }
    ...
};
auto left = &Node::left;
auto right = &Node::right;
// 遍历树,使用折叠表达式:
template<typename T, typename... TP>
Node* traverse (T np, TP... paths) {
    return (np ->* ... ->* paths); // np ->* paths1 ->* paths2 ...
}
int main()
{
    // 初始化二叉树结构:
    Node* root = new Node{0};
    root->left = new Node{1};
    root->left->right = new Node{2};
    ...
    // 遍历二叉树:
    Node* node = traverse(root, left, right);
    ...
}

这里,

(np ->* ... ->* paths)

使用折叠表达式来遍历来自 np 的路径的可变参数元素。 调用时

traverse(root, left, right);

fold 表达式的调用扩展为:

root -> left -> right
11.2.3 为类型使用折叠表达式

通过使用类型特征,我们还可以使用折叠表达式来处理模板参数包(作为模板参数传递的任意数量的类型)。 例如,您可以使用折叠表达式来确定类型列表是否是齐次的:

tmpl/ishomogeneous.hpp

#include <type_traits>
// 检查传递的类型是否是同质的:
template<typename T1, typename... TN>
struct IsHomogeneous {
    static constexpr bool value = (std::is_same<T1,TN>::value && ...);
};
// 检查传递的参数是否具有相同的类型:
template<typename T1, typename... TN>
constexpr bool isHomogeneous(T1, TN...)
{
    return (std::is_same<T1,TN>::value && ...);
}

可以使用类型特征 IsHomogeneous<>,例如,如下所示:

IsHomogeneous<int, Size, decltype(42)>::value

在这种情况下,初始化成员值的折叠表达式扩展为:

std::is_same<int,MyType>::value && std::is_same<int,decltype(42)>::value

可以使用函数模板isHomogeneous<>(),例如如下:

isHomogeneous(43, -1, "hello", nullptr)

在这种情况下,初始化成员值的折叠表达式扩展为:

std::is_same<int,int>::value && std::is_same<int,const char*>::value
    && std::is_same<int,std::nullptr_t>::value

像往常一样,操作符&&是短 循环的(在第一个假的之后中止评估)。 std::array<>的推导指南在标准库中使用了这个特性。

11.3 后记

折叠表达式首先由 Andrew Sutton 和 Richard Smith 在 https://wg21.link/n4191 中提出。 最终接受的措辞由 Andrew Sutton 和 Richard Smith 在 https://wg21.link/n4295 中制定。 后来删除了对运算符 *、+、& 和 | 的空序列支持 正如 Thibaut Le Jehan 在 https://wg21.link/p0036 中提出的那样。

12 将字符串作为模板参数处理

随着时间的推移,不同版本的 C++ 放宽了可用作模板参数的规则,而在 C++17 中,这种情况再次发生。 现在可以使用模板,而无需在当前范围之外定义它们。

12.1 在模板中使用字符串

非类型模板参数只能是常量整数值(包括枚举)、指向对象/函数/成员的指针、对对象或函数的左值引用或 std::nullptr_t(nullptr 的类型)。 对于指针,链接是必需的,这意味着您不能直接传递字符串文字。 但是,从 C++17 开始,您可以拥有带有内部链接的指针。 例如:

template<const char* str>
class Message {
    ...
};
extern const char hello[] = "Hello World!"; // 外联
const char hello11[] = "Hello World!"; // 内部链接
void foo()
{
    Message<hello> msg; // OK (all C++ versions)
    Message<hello11> msg11; // OK since C++11
    static const char hello17[] = "Hello World!"; // 无联动
    Message<hello17> msg17; // OK since C++17
}

也就是说,从 C++17 开始,您仍然需要两行来将字符串文字传递给模板。 但是您可以将第一行放在与类实例化相同的范围内。 这种能力也解决了一个不幸的限制:虽然你可以将指针传递给自 C++11 以来的类模板:

template<int* p> struct A {
};
int num;
A<&num> a; // OK since C++11

您不能使用返回地址的编译时函数,现在支持:

int num;
...
constexpr int* pNum() {
    return &num;
}
A<pNum()> b; // ERROR before C++17, now OK

12.2 后记

允许对所有非类型模板参数进行持续评估是由 Richard Smith 在 https://wg21.link/n4198 中首次提出的。 最终接受的措辞由 Richard Smith 在 https://wg21.link/n4268 中制定。

13 占位符类型(例如 auto)作为模板参数

从 C++17 开始,您可以使用占位符类型(auto 和 decltype(auto))作为非类型模板参数类型。 这意味着,我们可以为不同类型的非类型参数编写通用代码。

13.1 使用auto作为模板参数

从 C++17 开始,您可以使用 auto 来声明非类型模板参数。 例如:

template<auto N> class S {
    ...
};

这允许我们为不同类型实例化非类型模板参数 N:

S<42> s1; // OK: S 中 N 的类型是 int
S<'a'> s2; // OK: S 中 N 的类型是 char

但是,您不能使用此功能来获取通常不允许作为模板参数的类型的实例化:

S<2.5> s3 // 错误:模板参数类型仍然不能为双精度

我们甚至可以有一个特定的类型作为部分特化:

template<int N> class S<N> {
    ...
};

甚至支持类模板参数推导。 例如:

template<typename T, auto N>
class A {
public:
    A(const std::array<T,N>&) {
    }
    A(T(&)[N]) {
    }
    ...
};

这个类可以推导出T的类型,N的类型,N的值:

A a2{"hello"}; // OK, 推导出 A<const char, 6> 其中 N 为 int
std::array<double,10> sa1;
A a1{sa1}; // OK, 推导出 A<double, 10> 其中 N 为 std::size_t

您还可以限定 auto,例如,要求模板参数的类型是指针:

template<const auto* P> struct S;

通过使用可变参数模板,您可以参数化模板以使用异构常量模板参数列表:

template<auto... VS> class HeteroValueList {
};

或同质常量模板参数列表:

template<auto V1, decltype(V1)... VS> class HomoValueList {
};

例如:

HeteroValueList<1, 2, 3> vals1; // OK
HeteroValueList<1, 'a', true> vals2; // OK
HomoValueList<1, 2, 3> vals3; // OK
HomoValueList<1, 'a', true> vals4; // ERROR
13.1.1 字符和字符串的参数化模板

此功能的一个应用是允许将字符或字符串作为模板参数传递。 例如,我们可以改进使用折叠表达式输出任意数量参数的方式,如下所示:

#include <iostream>
template<auto Sep = ' ', typename First, typename... Args>
void print(const First& first, const Args&... args) {
    std::cout << first;
    auto outWithSep = [](const auto& arg) {
        std::cout << Sep << arg;
    };
    (... , outWithSep(args));
    std::cout << '\n';
}

尽管如此,我们可以打印带有空格的参数作为模板参数的默认参数 Sep:

template<auto Sep = ' ', typename First, typename... Args>
void print (const First& firstarg, const Args&... args) {
    ...
}

也就是说,我们仍然可以调用:

std::string s{"world"};
print(7.5, "hello", s); // prints: 7.5 hello world

但是通过为分隔符 Sep 参数化 print(),我们现在可以显式传递一个不同的字符作为第一个模板参数:

print<'-'>(7.5, "hello", s); // prints: 7.5-hello-world

由于使用了 auto,我们甚至可以传递一个字符串文字,我们必须将其声明为没有链接的对象,不过:

static const char sep[] = ", ";
print<sep>(7.5, "hello", s); // prints: 7.5, hello, world

或者我们可以传递可用作模板参数的任何其他类型的分隔符(这比这里更有意义):

print<-11>(7.5, "hello", s); // prints: 7.5-11hello-11world
13.1.2 定义元编程常量

模板参数自动特性的另一个应用是更容易定义编译时常量。 而不是定义:

template<typename T, T v>
struct constant
{
    static constexpr T value = v;
};
using i = constant<int, 42>;
using c = constant<char, 'x'>;
using b = constant<bool, true>;

您现在可以执行以下操作:

template<auto v>
struct constant
{
    static constexpr auto value = v;
};
using i = constant<42>;
using c = constant<'x'>;
using b = constant<true>;

而不是:

template<typename T, T... Elements>
struct sequence {
};
using indexes = sequence<int, 0, 3, 4>;

你现在可以实现:

template<auto... Elements>
struct sequence {
};
using indexes = sequence<0, 3, 4>;

您现在甚至可以定义表示异构值列表的编译时对象(类似于压缩元组):

using tuple = sequence<0, 'h', true>;

13.2 使用 auto 作为可变模板参数

您还可以将 auto 用作带有变量模板的模板参数。 例如,以下可能出现在头文件中的声明定义了一个变量模板 arr 参数化为元素的类型以及元素数量的磁带和值:

template<typename T, auto N> std::array<T,N> arr;

在每个翻译单元中, arr<int,10> 的所有用法共享同一个全局对象,而 arr<long,10> 和 arr<int,10u> 将是不同的全局对象(同样,它们都可用于所有翻译单元)。

作为一个完整的示例,请考虑以下头文件:

tmpl/vartmplauto.hpp

#ifndef VARTMPLAUTO_HPP
#define VARTMPLAUTO_HPP
#include <array>
template<typename T, auto N> std::array<T,N> arr{};
void printArr();
#endif // VARTMPLAUTO_HPP

在这里,一个翻译单元可以修改这个变量模板的两个不同实例的值

tmpl/vartmplauto1.cpp

#include "vartmplauto.hpp"
int main()
{
    arr<int,5>[0] = 17;
    arr<int,5>[3] = 42;
    arr<int,5u>[1] = 11;
    arr<int,5u>[3] = 33;
    printArr();
}

另一个翻译单元可以打印这两个变量:

tmpl/vartmplauto2.cpp

#include "vartmplauto.hpp"
#include <iostream>
void printArr()
{
    std::cout << "arr<int,5>: ";
    for (const auto& elem : arr<int,5>) {
        std::cout << elem << ' ';
    }
    std::cout << "\narr<int,5u>: ";
    for (const auto& elem : arr<int,5u>) {
        std::cout << elem << ' ';
    }
    std::cout << '\n';
}

该程序的输出将是:

arr<int,5>: 17 0 0 42 0
arr<int,5u>: 0 11 0 33 0

与声明从其初始值推导出的任意类型的常量变量相同的方式:

template<auto N> constexpr auto val = N; // OK since C++17

并在以后使用它,例如,如下:

auto v1 = val<5>; // v1 == 5, v1 is int
auto v2 = val<true>; // v2 == true, v2 is bool
auto v3 = val<'a'>; // v3 == ’a’, v3 is char

为了澄清这里发生了什么:

std::is_same_v<decltype(val<5>), int> // yields false
std::is_same_v<decltype(val<5>), const int> // yields true
std::is_same_v<decltype(v1), int>; // yields true (because auto decays)

13.3 使用decltype(auto)作为模板参数

您还可以使用 C++14 引入的其他占位符类型 decltype(auto)。 但是请注意,这种类型有非常特殊的规则来推断类型。 根据decltype,如果传递的是表达式而不是名称,它会根据表达式的值类别推导类型:

  • 纯右值的类型(例如,临时值)
  • type& 用于左值(例如,具有名称的对象)
  • xvalue 的类型&&(例如,转换为右值引用的对象,与 std::move() 一样。 这意味着,您可以轻松地将模板参数推导出为引用,这可能会产生令人惊讶的效果。 例如:

tmpl/decltypeauto.cpp

#include <iostream>
template<decltype(auto) N>
struct S {
    void printN() const {
        std::cout << "N: " << N << '\n';
    }
};
static const int c = 42;
static int v = 42;
int main()
{
    S<c> s1; // 将 N 推导出为 const int 42
    S<(c)> s2; // 将 N 推导出为 const int& 引用 c
    s1.printN();
    s2.printN();
    S<(v)> s3; // 将 N 推导出为 int& 引用 v
    v = 77;
    s3.printN(); // prints: N: 77
}

13.4 后记

非类型模板参数的占位符类型最早由 James Touton 和 Michael 提出 Spertus 作为 https://wg21.link/n4469 的一部分。 最终接受的措辞是由 https://wg21.link/p0127r2 中的 James Touton 和 Michael Spertus。

14 扩展Using声明

使用声明被扩展为允许以逗号分隔的声明列表,以允许它们在包扩展中使用。 例如,您现在可以编程:

class Base {
    public:
    void a();
    void b();
    void c();
};
class Derived : private Base {
    public:
    using Base::a, Base::b, Base::c;
};

在 C++17 之前,您需要三种不同的 using 声明。

14.1 使用可变参数using声明

使用逗号分隔的声明提供了从基类的可变参数列表中一般派生所有同类操作的能力。 这种技术的一个非常酷的应用是创建一组 lambda 重载。 通过定义以下内容:

tmpl/overload.hpp

// ”inherit” all function call operators of passed base types:
template<typename... Ts>
struct overload : Ts...
{
    using Ts::operator()...;
};
// base types are deduced from passed arguments:
template<typename... Ts>
overload(Ts...) -> overload<Ts...>;

您可以重载两个 lambda,如下所示:

auto twice = overload {
    [](std::string& s) { s += s; },
    [](auto& v) { v *= 2; }
};

这里我们创建了一个类型重载的对象,我们使用推导指南将 lambdas 的类型推导出为模板类型重载的基类,并使用聚合初始化来初始化具有闭包类型的复制构造函数的基类的子对象 ,每个 lambda 都有。 然后 using 声明使两个函数调用运算符都可用于类型重载。 如果没有 using 声明,基类将具有同一成员的两个不同重载 函数 operator(),这是模棱两可的。 因此,您可以传递一个字符串,它调用第一个重载或传递另一个类型,它(假设运算符 *= 有效)使用第二个重载:

int i = 42;
twice(i);
std::cout << "i: " << i << '\n'; // prints: 84
std::string s = "hi";
twice(s);
std::cout << "s: " << s << '\n'; // prints: hihi

这种技术的一个应用是 std::variant 访问者。

14.2 使用声明继承构造函数的可变参数

除了对继承构造函数的一些说明外,现在还可以进行以下操作:您可以声明一个可变参数类模板 Multi,该模板派生自其每个传递的类型的基类:

tmpl/using2.hpp

template<typename T>
class Base {
    T value{};
    public:
    Base() {
        ...
    }
    Base(T v) : value{v} {
        ...
    }
    ...
};
template<typename... Types>
class Multi : private Base<Types>...
{
    public:
    // derive all constructors:
    using Base<Types>::Base...;
    ...
};

使用所有基类构造函数的 using 声明,您可以为每种类型派生一个对应的构造函数.

现在,当为三种不同类型的值声明 Multi<> 类型时:

using MultiISB = Multi<int,std::string,bool>;

您可以使用每个相应的构造函数来声明对象:

MultiISB m1 = 42;
MultiISB m2 = std::string("hello");
MultiISB m3 = true;

根据新的语言规则,每次初始化都会为匹配的基类调用相应的构造函数,并为所有其他基类调用默认构造函数。 因此:

MultiISB m2 = std::string("hello");

调用 Base 的默认构造函数、Basestd::string 的字符串构造函数和 Base 的默认构造函数。 原则上,您还可以通过指定启用 Multi<> 中的所有赋值运算符:

template<typename... Types>
class Multi : private Base<Types>...
{
    ...
    // 导出所有赋值运算符:
    using Base<Types>::operator=...;
}

14.3 后记

逗号分隔的 using 声明由 Robert Haberlach 在 https://wg21.link/p0195r0 中提出。 最终接受的措辞由 Robert Haberlach 和 Richard Smith 在 https://wg21.link/p0195r2 中制定。 各种核心问题要求对继承构造函数进行澄清。 最终接受的修复它们的措辞由 Richard Smith 在 https://wg21.link/n4429 中制定。 Vicente J. Botet Escriba 提出了一个建议,即添加一个泛型重载函数来重载 lambda,以及普通函数和成员函数。 然而,这篇论文并没有进入 C++17。 有关详细信息,请参阅 https://wg21.link/p0051r1