15015 字
75 分钟
C++ 模板笔记(三):enable_if、传值还是传引用、编译期编程与泛型库
2022-12-14

第六章 enable_if#

目前,类模板无法应用这个东西。针对于类模板,enable_if只能起到一种static_assert的作用。由于类和类模板不能重载,所以自然不存在候选集这个东西。也不存在SFINAE。enable_if在应用中的感觉是一种调整候选集的作用。

在函数模板中,enable_if起到的作用是活用了SFINAE的特性。

https://stackoverflow.com/questions/48045559/how-do-i-declare-sfinae-class

https://stackoverflow.com/questions/16972684/how-to-use-enable-if-for-restricting-a-whole-class

6.2 特殊成员函数模板 (笔记这里尤其指构造函数模板)#

特殊成员函数也可以是模板,比如构造函数,但是有时候这可能会带来令人意外的结果。

class Person
{
private:
string name;
public:
// constructor for passed initial name:
explicit Person(const string& n) : name(n) { //构造函数。注意这里不是拷贝构造。参数不是person
cout << "此处是拷贝字符串进来 " << name << endl;
}
explicit Person(string&& n) : name(move(n)) { //构造函数。注意这里不是移动构造。参数不是person
//里面的move是为了给字符串自己转为右值。触发string的移动
cout << "此处是移动字符串进来 " << name << endl;
}
// copy and move constructor:
Person (const Person& p) : name(p.name) { //拷贝构造
cout << "此处是拷贝person对象进来 " << name << endl;
}
Person (Person&& p) : name(move(p.name)) { //移动构造
cout << "此处是移动person对象进来 " << name << endl;
//里面的move是为了给传入对象自己的string转为右值。然后触发string的移动
}
};
int main(){
string names = "miku";
Person p1(names); //拷贝字符串
Person p2("miku");//"miku"是字面值,虽然字符串字面值是左值,但是现在它是个临时对象。所以会触发移动。这里是移动字符串。
Person p3(p1); //拷贝对象
Person p4(move(p1));//移动对象。这个move是转换p1为右值调用person的移动。
}

一切都看起来不错。我们注意到针对使用string来构造person对象的时候,我们写了两个函数。一个针对左值string,一个针对右值string。下面我们尝试使用一下万能引用+完美转发来把两个函数合到一起。

class Person
{
private:
string name;
public:
template <typename T>
explicit Person(T&& n) : name(forward<T>(n)) {
cout << "万能引用+forward兼顾左值和右值。" << name << endl;
}
// copy and move constructor:
Person (Person const& p) : name(p.name) { //没有变化
cout << "此处是拷贝person对象进来 " << name << endl;
}
Person (Person&& p) : name(move(p.name)) { //没有变化
cout << "此处是移动person对象进来 " << name << endl;
}
};

好的,让我们测试一下

int main(){
string names = "miku";
Person p1(names); //万能引用+forward兼顾左值和右值。
Person p2("miku");//万能引用+forward兼顾左值和右值。
}

真不错,一切都看起来如我们所愿。针对左值和右值的string类,成功匹配到了万能引用的构造函数。

注意这里在构建 p2 的时候并不会创建一个临时的 string 对象:T的类型不会退化,因为万能引用也是引用。所以T会被推断为const char[4]。但是将 forward<T>用于指针参数没有太大意义。成员 name 将会被一个以null 结尾的字符串构造。

但是,当试图调用拷贝构造的时候,会遇到错误:

Person p3(p1); //拷贝对象

但是调用移动构造却没有问题:

Person p4(move(p1));//移动对象。输出此处是移动person对象进来

而且使用一个const Person对象来(触发拷贝构造来)初始化也没问题

const Person cp1("miku");
Person cp2(cp1); //注意是使用const对象初始化,而不是自己是const对象
//输出:此处是拷贝person对象进来

那么原因是什么呢?问题出现在重载决议。

  • 首先我们知道两点:
    • 如果函数模板可以实例化出一个比普通函数更完美的函数,那么会优先使用模板。只有所有情况都相同的时候,才会优先使用普通函数(1.5)
    • 在重载决议的图当中,我们看到了qualification conversion是第三档。

所以在这个时候,编译器发现通过构造函数模板实例化的函数会比拷贝构造更匹配:

template<typename T>
Person(T&& n)

在这里,T会被替换成Person&

去杂记看函数模板推导。这里传入的参数是Person,函数形参是T&&, 则模板参数T会被推导为T&。随后引用折叠会变成函数形参为Person&

这明显比 const Person&更好,因为使用这个的话还需要进行qualification conversion

但是问题在这里,我们的构造函数里面怎么写的?

name(forward<T>(n));

我们是尝试用n去初始化name。但是name是string,n是Person。你能用一个Person对象初始化string对象吗?肯定不行,这啥玩意呀。

  • 所以这时候,如果我们额外提供一个非const的拷贝构造,那么没有问题
Person (Person& p) : name(p.name) { //非const的拷贝构造
cout << "此处是拷贝person对象进来, 非const的拷贝构造" << name << endl;
}
string names = "miku";
Person p1(names); //拷贝字符串
Person p3(p1); //输出:此处是拷贝person对象进来, 非const的拷贝构造

但是这样做不够优雅,我们6.3会讲一些关于enable_if的知识。然后我们在6.4会介绍如何搭配enable_if来禁用一些模板

  • 无法为构造函数显式指定模板参数类型。
Person<int> f = Person<int>();

如上是不可以的。因为int被视为是Person的模板参数类型而不是其构造函数的模板参数类型。构造函数的模板参数类型只能通过型别推导。这也是为什么模板构造函数非常适合搭配完美转发进行使用。

6.3 使用enable_if#

enable_if可以让我们在某些编译期条件下忽略掉函数模板。

比如,如果函数模板func的定义如下:

template<typename T>
typename std::enable_if<(sizeof(T) > 4)>::type func() {
//一些内容
}

这一模板定义会在 sizeof(T) > 4 不成立的时候被忽略掉。如果 sizeof(T) > 4 成立,函数模板会展开成:

template<typename T>
void func() {
//一些内容
}

在enable_if拥有第二个模板参数的时候,如果满足第一个参数的表达式,就会扩展成第二个模板参数。

template<typename T>
typename std::enable_if<(sizeof(T) > 4, MyType)>::type func() {
//一些内容
}

那么在 sizeof(T) > 4时,enable_if 会被扩展成其第二个模板参数。因此如果与T对应的模板参数被推断为 MyType,而且其 size 大于 4,那么其等效于:MyType func();

也就是说 std::enable_if<>是一种类型萃取(type trait),它会根据一个作为其(第一个)模板参数的编译期表达式决定其行为:

  • 如果这个表达式结果为 true,它的 type 成员会返回一个类型:

    • 如果没有第二个模板参数,返回类型是 void。
    • 否则,返回类型是其第二个参数的类型。
  • 如果表达式结果 false,则其成员类型是未定义的。根据模板的一个叫做 SFINAE(substitute failure is not an error,替换失败不是错误的规则,这会导致包含 std::enable_if<>表达式的函数模板被忽略掉。

  • 简而言之,意思就是,前面的表达式为真,后面的类型定义才有意义。

简单看一下原型

template <bool, typename T=void>
struct enable_if {
};
template <typename T>
struct enable_if<true, T> { ///< 第一个模板参数为 true
using type = T; ///< type 才有定义
};

一般三种用法:

  • 控制函数返回类型
  • 校验函数模板参数类型
  • 类型偏特化

这里就随便写个控制函数返回类型的例子:

template<int stat> //这里是非模板类型参数。上面提到了。注意一下
typename enable_if<stat == 1, bool>::type checkstate(){
cout <<"type is bool" << endl;
return true;
}
template<int stat>
typename enable_if<stat == 0, int>::type checkstate(){
cout <<"type is int" << endl;
return 5;
}
template<bool stat> //bool也可以。隐式转换为整型了,但是必须要常量。
typename enable_if<stat == true, int>::type checkstate1(){
cout <<"type is int" << endl;
return 5;
}
int main(){
const int myobj = 1; //必须是const
checkstate<myobj>(); //输出"type is bool"
checkstate<0>(); //输出"type is int"
return 0;
}
  • 注意事项:在C++14前,不使用enable_if_t的时候,返回类型前必须加typename来告知enable_if::type是个类型。还有就是非模板类型参数的限制。

到了c++14,我们可以使用enable_if_t来简化这一部分。我们无需在后面指定::type,自然同时也无需在前面使用typename。如这样:

template <int stat>
enable_if_t<stat == 1, bool> anotherCheck(){ //没有::type 也没有typename
cout <<"type is bool" << endl;
return true;
}
anotherCheck<1>();//一样使用
anotherCheck<0>(); //绝对不可以。就算有SFINAE,那是告诉你匹配不对的时候继续找,不报错。但是这里我们没写stat = 0情况,自然无论如何都找不到,重载决议找不到函数,调用一定会失败。所以报错。

给enable_if 添加一个额外的带默认值的参数#

  • 我们也可以给enable_if_t的第二个参数加上一个默认参数。但是长得会和之前的不太一样。之前我们是直接把整个enable_if_t或者是typename enable_if::type 当成函数返回值。
template<typename T, typename = std::enable_if_t<(sizeof(T) > 4)>>
T foo(T a) {
cout << a << endl;
return a;
}
foo<double>(20.123);//没问题
  • 当然了,也可以使用非类型模板参数,像是这样:
template <int value, typename = enable_if_t<value == 2>>
void myfunc(){
cout <<"2" << endl;
}
myfunc<2>();//也没问题
  • 有一点需要澄清。这里的模板头如何解释?

我们这里的第二个typename是一个独立的模板参数,仅仅是没有加名字。因为我们没有使用到它。所以如果你愿意,你也可以这样写

template <int value, typename whatever = enable_if_t<value == 2>>

我们知道了如果enable_if_t的第一个表达式为真,则这里是通过的。又由于在当下这个例子,我们是非类型模板参数而且enable_if_t没有给第二个参数,所以函数返回值就是void。

如果表达式为假,则会走SFINAE这一套。此处不会编译。针对当下的例子,如果没有做不等于2的条件的处理,则会报错找不到对应的函数。

但是这时候有个问题了,如果针对第一个函数,我们想让sizeof(T) < 4也成为一个模板怎么办?我们可能会直接这么写:

template<typename T, typename = std::enable_if<(sizeof(T) > 4)>>
T foo(T a) {
cout << ">4" << endl;
return a;
}
template<typename T, typename = std::enable_if<(sizeof(T) <= 4)>> //错误,重定义了。
T foo(T a) {
cout << "<=4" << endl;
return a;
}

这样是错误的,提示重定义了。为什么?

enable_if 导致的的函数模板重定义问题#

我们知道了,enable_if 第二个参数的默认值是void(也就是表达式为真的时候,没有指定第二个参数就默认值是void)

所以当如果前面表达式为真的时候,这个函数会被展开成这个样子

template<typename T, typename = std::enable_if_t<(sizeof(T) > 4)>>
void func() {
}
//展开后:
template<typename T, typename = void>
void func() {
}

所以如果我们还有一个<=4,当也为真的时候,那这个整个模板头的参数就都一样了。

**函数模板参数的默认值的不同并不足以区分两个函数模板是重载关系。**官方是这么说的:

常见错误是声明二个函数模板,而它们仅于其默认模板实参相异。这是无效的,因为这些函数声明被当做同一函数模板的再声明(默认模板实参不为函数模板等价所考虑)。

所以。上面的代码。在编译器看来是这样的:

template<typename T, typename>
T foo(T a);
template<typename T, typename>
T foo(T a);

这就很明显是重定义了。因为我们只有第二个模板参数的默认值是不同的。但是默认值不在签名范围内。就好比不能通过默认值不同重载不同函数一样。

一个详细例子

template<typename T,
typename = typename std::enable_if<std::is_same<int, T>::value>::type>
void g() { }
template<typename T,
typename = typename std::enable_if<std::is_same<double, T>::value>::type>
void g() { }
template<typename T,
typename std::enable_if<std::is_same<int, T>::value>::type* = nullptr>
void f() { }
template<typename T,
typename std::enable_if<std::is_same<double, T>::value>::type* = nullptr>
void f() { }
  • 在g()中 我们发现只有模板参数的默认值是依赖名,只有默认值依赖于前一个参数T,但是默认值又不算做签名考虑在内,所以一定会冲突。

  • 在f()中,我们发现第二个模板参数本身就是依赖名,因为里面的T是依赖于第一个参数T的。所以就成功区分了。

那么我们如何解决这个问题呢?我们可以让第二个参数本身不同即可。

我们所谓的“让参数本身不同”的方法之一就是这个办法。另一种办法是20.3.2提到的添加一个额外的带默认值的模板参数。

我们刚说过,上面那种的模板类型区分不开:template<typename, typename> 。那么我们应该这么写:

typename<typename T, std::enable_if_t<condition>* whatever = nullptr>
//或老版本的
typename<typename T, typename std::enable_if<condition>::type* whatever = nullptr>
//老版本依旧必须有typename,为了告知type是个类型

在这里,编译器看到的模板类型是这样的:template<typename, X*>。这里的X*的类型依赖于第一个参数的类型。所以此时足以区分

这里的whatever可以去掉。因为我们函数内并不使用它。

详细例子:

template <typename T1, enable_if_t<(sizeof(T1) > 4)>* whatever = nullptr> //带t的新版
T1 functions(T1 a){
cout << a << endl;
cout <<" <=4" << endl;
return a;
}
template <typename T1, enable_if_t<(sizeof(T1) <= 4)>* whatever = nullptr>
T1 functions(T1 a){
cout << a << endl;
cout <<" <=4" << endl;
return a;
}
template <typename T1, typename enable_if<(sizeof(T1) > 4)>::type* whatever = nullptr> //不带t的老版
T1 functions(T1 a){
cout << a << endl;
cout <<" > 4" << endl;
return a;
}
template <typename T1, typename enable_if<(sizeof(T1) <= 4)>::type* whatever = nullptr>
T1 functions(T1 a){
cout << a << endl;
cout <<" <= 4" << endl;
return a;
}
functions<double>(10.123);
functions<int>(10);
//模板参数和非类型模板参数混用:
template <typename T, int T1, enable_if_t<(T1 > 4)>* whatever = nullptr>
T functions(T a){
cout << a << endl;
cout <<" > 4" << endl;
return a;
}
template <typename T, int T1, enable_if_t<(T1 <= 4)>* whatever = nullptr>
T functions(T a){
cout << a << endl;
cout <<" <= 4" << endl;
return a;
}
functions<double, 8>(10.123);
functions<string, 2>("12345");

当然了,这时候我们也可以使用using别名模板来让代码看起来更加简单。

https://stackoverflow.com/questions/52083873/c-sfinae-enable-if-t-in-member-function-how-to-disambiguate

https://stackoverflow.com/questions/31500426/why-does-enable-if-t-in-template-arguments-complains-about-redefinitions

6.4 使用enable_if 禁用某些模板#

我们在6.2当中介绍了一个例子,在6.3当中学习了一下enable_if,这里我们就针对6.2当中的例子进行优化。

  • 6.2中我们想解决的问题是:如果传入的参数不能被转换为string,则不要使用其构造函数模板。
    • 我们在6.2当中解决问题的方式是添加一个非const的拷贝构造。我们在这里不这样做,而是使用enable_if在某些条件下禁用构造函数模板。

也就是当传递的模板参数的类型不正确的时候(比如不是 std::string或者可以转换成std::string的类型),禁用如下构造函数模板:

template<typename T>
Person(T&& n)

所以我们搭配is_convertiable<FROM, TO>来进行使用(此处看下面讲解)

template <typename T, typename = std::enable_if_t<std::is_convertible_v<T,std::string>>>
Person(T&& n)

如果T可以被转换为string,则这个定义会被扩展为:

template<typename T, typename = void>
Person(T&& n);

否则这个函数模板会被忽略。

  • 所以现在代码是这个样子 [c++17]:
class Person
{
private:
string name;
public:
template <typename T, typename = std::enable_if_t<std::is_convertible_v<T,std::string>>>
explicit Person(T&& n) : name(forward<T>(n)) {
cout << "万能引用+forward兼顾左值和右值。" << name << endl;
}
Person (Person const& p) : name(p.name) { //没有变化
cout << "此处是拷贝person对象进来 " << name << endl;
}
Person (Person&& p) : name(move(p.name)) { //没有变化
cout << "此处是移动person对象进来 " << name << endl;
}
};
int main(){
string names = "miku";
Person p1(names); //拷贝字符串 输出万能引用+forward兼顾左值和右值。
Person p3(p1); //拷贝对象 输出此处是拷贝person对象进来。
}

当然此处也可以使用别名模板简化,比如

using EnableIfString = std::enable_if_t<std::is_convertible_v<T,std::string>>;

一切皆大欢喜!

  • 由于c++14没有给产生一个值的类型萃取定义带_v 的别名,所以得这么写:
template <typename T, typename = std::enable_if_t<std::is_convertible<T,std::string>::value>>
  • 由于c++11没有给产生一个类型的类型萃取定义带_t 的别名,所以得这么写:
template <typename T, typename = typename std::enable_if<std::is_convertible<T,std::string>::value>::type>

以上都可以使用别名模板。

6.5 禁用某些特殊成员函数#

  • 注意我们不能通过使用 enable_if<>来禁用 拷贝或移动 构造函数以及其赋值函数。这是因为成员函数模板不会被算作拷贝构造或移动构造函数(依然会生成其默认版本),而且在需要使用这些函数的地方,相应的成员函数模板会被忽略掉。

所以就算我们有这样的类模板:

class C {
public:
template<typename T>
C (T const&) {
std::cout << "tmpl copy constructor\n";
}
};
  • 在需要拷贝构造的时候,依然会调用到编译器合成的拷贝构造。
  • 删掉拷贝构造函数也不行,因为这样在需要拷贝构造函数的地方会报错说该函数被删除了。

如果你硬要弄成模板的话,这么做:定义一个接受 const volatile 的 拷贝构造函数并将其标示为delete。这样做编译器就不会再隐式合成一个接受 const 参数的拷贝构造函数。在此基础上,可以定义一个构造函数模板,对于 non-volatile 的类型,它会优选被选择(相较于已删除的 copy 构造函数)注意这里是定义一个构造函数模板而不是拷贝构造函数模板。具体分析在最后“特殊成员函数能否是函数模板

  • 这个东西叫做使用模板构造函数替代拷贝构造函数。这样做是极度不推荐的!!!
class C{
public:
int val;
C() = default;
C(int a):val(a){};
C(const volatile C&) = delete;
template<typename T>
C(const T&){ //这是构造函数模板不是拷贝构造函数模板。
cout <<"template" << endl;
}
};
int main(){
C obj1(10);
C obj2(obj1); //template
}
  • 注意,构造函数除外。

6.6 关于对整个类使用enable if#

目前,类模板无法应用这个东西。针对于类模板,enable_if只能起到一种static_assert的作用。由于类和类模板不能重载,所以自然不存在候选集这个东西。也不存在SFINAE。enable_if在应用中的感觉是一种调整候选集的作用。

在函数模板中,enable_if起到的作用是活用了SFINAE的特性。

如果硬要做static_assert是什么样的?

template<typename T, typename = void> //主模板。注意这里。必须有默认值。但是类型需要判断一下
struct test{
};
template<typename T>
struct test<T, typename std::enable_if<(sizeof(T) <= 1)>::type>{ //偏特化。
void func(){
cout << "called" << endl;
}
};
//template<typename T> 不可这么写
// struct test<T, typename std::enable_if<(sizeof(T) <= 1)>::type>{
// void func(){
// cout << "called" << endl;
// }
// };
  • 第一点。必须采用偏特化的形式。而且因为类模板没有重载所以不能写最下面那一组。只能要么主模板,要么一个偏特化版本。
  • 第二点:主模板的第二个对应了enable_if的模板参数必须有默认值为 为什么?
    • 首先,我们主模板有了两个模板参数。我们如果没有默认值,则调用的时候比如test<int> objs1;此时我们只显式指定了一个模板参数。类模板没法自动推导参数,那么第二个参数是空白的。编译不通过。
    • 其次,因为一旦enable_if的要求我们不能满足,在这里enable_if我们没有第二个参数,所以这个type会是void。
  • 第三点:为什么默认值这里用了void,别的可以不?
    • 我们要把默认值理解为:虽然我们不需要显式指定,但是它依旧存在。什么意思?

假设我们现在把上面的默认值换为了double。现在是这样

template<typename T, typename = double>
struct test{
void func(){
cout << "default" << endl;
}
};
template<typename T>
struct test<T, typename std::enable_if<(sizeof(T) <= 1)>::type>{
void func(){
cout << "called" << endl;
}
};
int main() {
test<char> objs1;
objs1.func(); //输出default
test<int> objs2;
objs2.func(); //还是输出default
}

为什么两个都是default?

我们思考一下刚才说的:我们要把默认值理解为:虽然我们不需要显式指定,但是它依旧存在。

我们显式补齐参数类型来看一下:

test<char, double> objs1;
objs1.func();
test<int, double> objs2;
objs2.func();

因为我们提到了:在这里enable_if我们没有第二个参数,所以这个type会是void。

  • 所以针对objs1,编译时发现第二个条件满足,所以此时模板参数的第二个参数会是void。

但是我们的默认值是double。相当于我们传入了<char, double>。但是偏特化版本实例化出来的是<char, void>。我们发现明显是默认版本符合参数要求。所以一定会调用default。

  • 此时针对obj2,编译时发现第二个条件不满足,但是发现主模板匹配。则匹配主模板。

此时,如果我们显式的使用<char, void>,则可以正确满足条件。

test<char, void> objs3; // called

这个时候,我们显式指定的值会覆盖掉默认值。此时第二个参数是void。而针对于偏特化版本enable_if推断的第二个参数也是void。此时明显偏特化版本符合。所以这时候会调用偏特化。

所以按理说,如果我们把特化版本的enable_if,让他的第二个参数和主模板的类型默认值相等,这样做就可以。

template<typename T, typename = double>
struct test{
void func(){
cout << "default" << endl;
}
};
template<typename T>
struct test<T, typename std::enable_if<(sizeof(T) <= 1), double>::type>{ //第二个参数和默认值相等
void func(){
cout << "called" << endl;
}
};
test<char> objs1;
objs1.func();
test<int> objs2;
objs2.func();
  • 此时针对obj1, 编译时发现第二个条件满足,且有第二个参数。所以此时模板参数的第二个参数会是double。

    • 又因为默认值是double,但是类模板的调用顺序是 全特化类>偏特化类>主模板类。这个时候主模板和偏特化类都符合,则优先调用偏特化类。
  • 此时针对obj2,编译时发现第二个条件不满足,但是发现主模板匹配。则匹配主模板。

https://stackoverflow.com/questions/48045559/how-do-i-declare-sfinae-class

https://stackoverflow.com/questions/16972684/how-to-use-enable-if-for-restricting-a-whole-class

https://stackoverflow.com/questions/75154649/why-when-use-enable-if-in-class-tempalte-have-to-set-the-second-parameters-defa/75154785?noredirect=1#comment132622904_75154785

第七章 到底是按值传递还是按引用传递?#

一般来说,我们在使用引用传递的时候,会有三种情况。

虽然已经是老生常谈,但是这里还是写一下做一下recall。下面的X指的是具体类型。

  • 按照常量左值引用传递

    • const X&
      • 由于什么类型的参数都可以接。包括右值。但是不可修改参数。所以主要目的是防止拷贝,并且接收任意类型数据。
  • 按照左值引用传递

    • X&
      • 主要目的是为了更改参数,并且防止拷贝。
  • 按照右值引用传递

    • X&&

      • 主要是为了接收形参为右值的参数。参数可以被更改或被窃取。
      //更改的例子
      //注意不能直接写,要写完美转发。因为具名右值是左值。
      void func(int&& a){
      a = 200;
      }
      template<typename T>
      void capsule(T&& c){
      cout << c << endl; //20
      func(forward<T>(c));
      cout << c << endl;//200
      }
      int main(){
      capsule(20);
      return 0;
      }

一般来说,在如下情况下我们需要使用引用传递参数。

  • 对象不允许被拷贝的时候

  • 入参需要被修改的时候(参数被用于返回数据)

  • 参数及其所有属性需要被模板转发到别的地方的时候。

  • 拷贝开销极大,所以使用引用传递可以获得明显的性能提升之时。

其余情况可以使用值传递。

7.1 按值传递#

有关临时对象,隐式类型转换的笔记在杂记2

有关各种拷贝构造相关的在杂记

我们先来简单看看按值传递的情况

当按值传递参数时,原则上所有的参数都会被拷贝。因此每一个参数都会是被传递实参的一份拷贝。对于 T类的对象,参数会通过 T类的拷贝构造函数来做初始化。

事实上,编译器自己可以通过移动语义(move semantics)来优化掉对象的拷贝,这样即使是对复杂类型的拷贝,其成本也不会很高。

假设我们有如下函数模板:

template<typename T>
void func(T obj){
//一些内容
}

在我们应用于myobj类型参数后,实例化后的代码为

void func(myobj obj){
//一些内容
}
myobj v1(10);
func(v1);

传递myobj类型的时候,obj会是传入参数v1的拷贝。如果myobj类型对象的拷贝的成本非常高,那么会很痛苦。但并不是所有情况都会调用拷贝构造

//假设myobj类对象提供了全部6个特殊成员函数,所以
myobj a(20); //构造
func(a); //拷贝构造
func(myobj(20)); //构造 注意,只有一次构造。
func(move(a)); //移动构造

在第一次调用中,被传递的参数是左值(lvalue),因此拷贝构造函数会被调用。

但是在第二次调用中,被传递的参数是纯右值 (prvalue 多在临时对象或者某个函数的返回值),此时编译器会优化参数传递,使得拷贝构造函数不会被调用。从C++17开始,C++标准要求这一优化方案必须被实现。在C++17之前,如果编译器没有优化掉这一类拷贝,它至少应该先尝试使用移动语义,这通常也会使拷贝成本变得比较低廉。

编译器会尝试拷贝优化,如果不行,会尝试移动语义。

在最后一次调用中,被传递参数是将亡值(xvalue ,一个使用了move()的已经存在的对象), 这会通过告知编译器我们不再需要a的值。通过这种方式,我们强制调用移动构造。

NRV优化在杂记2。复制省略在杂记3

  • 再次重申按照值传递会导致参数类型退化。auto也会退化。(杂记3)

7.2 按引用传递#

7.2.1 按常量左值引用传递 const&#

为了避免(不必要的)拷贝,在传递非临时对象作为参数时,可以使用const引用传递。

  • 比如这个函数模板就永远不会拷贝被传递对象
template <typename T>
void func(const T&){
//一些内容
}
  • 内置类型通过引用传递不会提升性能。

之所以不能提高性能,是因为在底层实现上,按引用传递还是通过传递参数的地址实现的。地址会被简单编码,这样可以提高从调用者向被调用者传递地址的效率。不过按地址传递可能会使编译器在编译调用者的代码时有一些困惑:被调用者会怎么处理这个地址?理 **论上被调用者可以随意更改该地址指向的内容。这样编译器就要假设在这次调用之后,所有缓存在寄存器中的值可能都会变为无效。**而重新载入这些变量的值可能会很耗时(可能比拷贝对象的成本高很多)。你或许会问在按 const 引用传递参数时:为什么编译器不能推断出 被调用者不会改变参数的值?不幸的是,确实不能,因为调用者可能会通过它自己的非 const引用修改被引用对象的值(这个解释太好,另一种情况是被调用者可以通过const_cast移除参数中的 const)。

inline可能会对此情况有所优化。

  • 按照引用传递不会引发类型退化。也就是说数组不退化为指针,也不会移除CV限定。但是要注意函数模板参数类型。
    • T的类型推断不会是一个const类型,因为const已经是函数参数的一部分了,所以不会是模板参数类型的一部分。这里搭配1.2和函数模板参数推导看。
template <typename T>
void f(T& param) { //注意这里是T&
std::puts(__PRETTY_FUNCTION__);
}
int main() {
const int p = 2;
f(p); //T的类型为const int。很好理解。因为引用不会去除cv
//所以在f函数内使用T声明的变量自然会带const
}

但是如果是这样的:

template <typename T>
void f(const T& param) { //注意这里是const T&
std::puts(__PRETTY_FUNCTION__);
}
int main() {
const int p = 2; //T的类型为int。也很好理解。因为const已经被函数模板参数匹配了。T自然就剩下了int。
//所以在f函数内使用T声明的变量自然不会带const
f(p);
}

7.2.2 按照普通(左值/右值)引用传递#

如果想通过调用参数来返回变量值(比如修改被传递变量的值),就需要使用非 const 引用(要么就使用指针)。同样这时候也不会拷贝被传递的参数。被调用的函数模板可以直接访问被传递的参数。

假如我们有如下函数模板:

template<typename T>
void func (T& arg) {
//一些内容
}
int main() {
string s = "miku";
func(s);
func(string("miku"));//不可以。左值引用不可绑定右值
func(move(s));//不可以。左值引用不可绑定右值
}

左值引用不可绑定右值(废话)

  • 但是有个问题。如果传入的参数是const的。则在一些情况下参数类型会被推导为const引用。也就是这时候可以传递右值。

    • 原因:const引用的初始化方式:在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。----《C++ Primer》第五版2.4.1第55页

      • 这就是为什么常量左值引用可以接任何类型参数。
      • 也就是你可以这样:
      const string& a = string("abc");
      string s = "miku";
      const string& b = move(s); //注意这里并不调用移动构造或移动赋值。因为并没有构造对象或者是进行赋值。

所以说一旦我们把东西换成这样:

template<typename T>
void func (T& arg) {
//一些内容
}
int main() {
const string s = "miku"; //换成const
func(s);
func(string("miku"));//不可以。左值引用不可绑定右值
func(move(s));//可以。T被推导为const string
}
  • **在这种情况下,在函数模板内部,任何试图更改被传递参数的值的行为都是错误的。**所以我们可以使用enable_if或static_assert搭配is_const来禁止向非const应用传递const对象
//使用static_assert
template<typename T>
void func (T& arg) {
static_assert(!is_const<T>::value, "can't use const type variable");
cout << "called" << endl;
}
//使用enable_if
template<typename T, typename = enable_if_t<!is_const<T>::value>>
void func (T& arg) {
cout << "called" << endl;
}

7.2.3 按照万能引用传递 T&&#

在杂记中我们详细说明了万能引用和引用折叠的部分。这里就不赘述了。

唯一要再次提醒的是,万能引用是唯一一种可能把T推导为引用类型的情况。因为有引用折叠。

比如

template <typename T>
void f(T&& param) {
std::puts(__PRETTY_FUNCTION__);
}
int s =5;
int& ref = s;
const int& ref2 = s;
f(5); //T推导为 int
f(ref);//T推导为 int&
f(ref2);//T推导为 const int&
  • 在第二次调用中,如果在模板内部直接用 T 声明一个未初始化的局部变量,就会触发一个错误(引用对象在创建的时候必须被初始化)所以需要格外的注意。

7.3 使用ref和cref#

看下面的ref和cref介绍即可。

7.5 处理返回值。#

返回值也可以被按引用或者按值返回。但是按引用返回可能会带来一些麻烦,因为它所引用的对象不能被很好的控制。不过在日常编程中,也有一些情况更倾向于按引用返回:

  • 返回容器或者字符串中的元素(比如通过[]运算符或者 front()方法访问元素)---EFFSTL笔记提到过。
  • 允许修改类对象的成员
  • 为链式调用返回一个对象(比如>>和<<运算符以及赋值运算符)

但是使用不当可能会造成悬空引用。因此如何保证函数模板可以采用按值返回是一个问题。

因为:

  • 在某些情况下,尤指万能引用的时候,T会被隐式推断为引用类型
template<typename T>
T retR(T&& p){ // p 是万能引用
return T{…}; // 一旦传入的T类型是左值或左值引用,则T会被统一推导为引用类型。此时T就变成引用了。
}
  • 即使函数模板被声明为按值传递,也可以显式地将 T 指定为引用类型:
template<typename T>
T retV(T p){ //T完全可以被指定为引用类型 比如:
return T{…};
}
int x;
retV<int&>(x); // 在这里,显式指定这个函数模板的模板参数T为int&。

所以,解决方案有两个。

  • 第一个:使用类型萃取的remove_reference去掉T类型的引用。(下面有提到该部分)
    • decay也可以。目前还没有看到这一部分。
template<typename T>
typename remove_reference<T>::type retV(T p){
return T{…}; // T一定会被推导为非引用类型
}
  • 第二个:声明返回值为auto类型。auto类型会导致类型退化,也就是隐式去掉类型的引用。杂记3中提到过。
template<typename T>
auto retV(T p){//返回值设置为auto
return T{…}; //一定会被推导为非引用类型。
}

7.6 应该如何声明模板参数?#

我们在拷贝构造一章中详细分析了如何构建构造函数和几种方法的优劣。这里谈一谈和模板参数结合的情况。

  • 将参数声明成按值传递:
    • 这一方法很简单,它会对字符串常量和裸数组的类型进行退化,但是对比较大的对象可能会受影响性能。在这种情况下,调用者仍然可以通过 cref()和 ref()按引用传递参数,但是要确保这一用法是有效的。
  • 将参数声明成按引用传递:
    • 对于比较大的对象这一方法能够提供比较好的性能。尤其是在下面几种情况下:
      • 将已经存在的对象(lvalue)按照左值引用传递,
      • 将临时对象(prvalue)或者被 move()转换为可移动的对象(xvalue)按右值引用传递,
    • 或者是将以上几种类型的对象按照万能引用+完美转发传递。
    • 由于这几种情况下参数类型都不会退化,因此在传递字符串常量和裸数组时要格外小心。
    • 对于万能引用,需要意识到模板参数可能会被隐式推断为引用类型(引用折叠)。

综上所述:

  • 默认情况下,将参数声明为按值传递。这样做比较简单,即使对字符串常量也可以正常工作。对于比较小的对象、临时对象以及可移动对象,其性能也还不错。对于比较大的对象,为了避免成本高昂的拷贝,可以使用 ref()和 cref()。
  • 如果需要一个参数用于输出,或者即用于输入也用于输出,那么就将这个参数按非const 引用传递。但是需要按照 7.2.2 节介绍的方法禁止其接受 const 对象。因为针对普通引用类型为形参,如果传入一个const引用则会带着其形参推导为const引用。
  • **如果使用模板是为了转发它的参数,那么就使用完美转发。**也就是将参数声明为万能引用并在合适的地方使用 forward<>()。考虑使用decay<>或者 common_type<>来处理不同的字符串常量类型以及裸数组类型的情况。
  • 如果重点考虑程序性能,而参数拷贝的成本又很高,那么就使用 const 引用。不过如果最终还是要对对象进行局部拷贝的话,这一条建议不适用

7.7 不要过分泛型化#

通常来说,我们在使用函数模板的时候,不可能允许该函数传递任意类型的对象。因为针对不同的对象我们会有不同的操作。比如我们可能已经知道函数模板的参数只会是某些类型的 vector。这时候最好不要将该函数模板定义的过于泛型化,否则,可能会有一些令人意外的副作用。针对这种情况应该使用如下的方式定义模板:

template<typename T>
void printVector (const vector<T>& v){ //为啥不是T v?
//一些内容
}
  • 为啥我们不直接T v?
    • 首先,我们可以确定T不可能是引用类型。因为传入的参数必定是一个vector,变动的只是vector存储的元素类型。因为容器不能使用引用作为其元素类型。
    • 其次,通常来说,针对容器我们会使用引用传递。因为不必要的拷贝会极大降低性能。甚至针对不修改的情况我们要使用const引用传递。
    • 最后,使用这种不过分泛型化的参数传递可以很容易的让其他人看出来我们这个函数的作用:它会干什么,它不会干什么。如果我们直接传入一个T,则会非常不清晰。

第八章 编译期编程#

8.4 SFIANE#

在一个函数调用的备选方案中包含函数模板时,编译器首先要决定应该将什么样的模板参数用于各种模板方案,然后用这些参数替换函数模板的参数列表以及返回类型,最后评估替换后的函数模板和这个调用的匹配情况(就像常规函数一样)。但是这一替换过程可能会遇到问题:替换产生的结果可能没有意义。不过这一类型的替换不会导致错误,C++语言规则要求忽略掉这一类型的替换结果。

但是上面讲到的替换过程和实际的实例化过程不一样:即使对那些最终被证明不需要被实例化的模板也要进行替换(不然就无法知道到底需不需要实例化)。不过它只会替换直接出现在函数模板声明中的相关内容(不包含函数体)

我们有下面的例子:

template<typename T, unsigned N>
std::size_t len (T(&)[N]){ //这个版本是为没有size_type成员的对象准备的。
cout <<"no size" << endl;
return N;
}
template<typename T>
typename T::size_type len (T const& t){ //这个版本是为有size_type成员的对象准备的。
cout <<"size" << endl;
return t.size(); //同时要求必须有size成员函数
}
std::size_t len (...) //使用可变参数来对其他任何没有size_type成员 !!!!!!注意这里没有模板头
{
cout <<"other" << endl;
return 0;
}
int main(){
int a[10];
len(a); // OK 原始数组没有size_type
len("tmp"); //OK: 字符串没有size_type
std::vector<int> v;
len(v); // OK: vector有size_type
int* p;
len(p); //OK: 指针类型全都不匹配。走最下面的应急类型。
return 0;
}
  • 针对原始数组和字符串。因为他俩没有size_type所以会忽略第二个函数模板
  • 针对vector,因为有size_type所以匹配第二个。
    • 注意,如果有一些类型有size_type,但是由于我们在第二个函数模板中使用了size成员函数。如果类型没有这个size成员函数,会报错说缺少size成员函数
  • 针对指针,啥都匹配不了,只能匹配第三个。

SFIANE 和重载解析:#

除非在某些情况下,该模板不应该参与重载解析过程 的意思就是在该情况下,使用 SFINAE 方法 SFINAE 掉了这个函数模板

我们在6.2,6.4和6.5详细说明了某些特殊成员函数模板(尤指构造函数模板)在一些情况下会导致一些错误的匹配。所以我们需要使用enable_if让其在某些特殊情况下禁用这个函数模板。也就是在某些情况下(enable_if不满足的条件下),让这个函数退出重载候选集。

SFINAE 需要什么条件?#

SFINAE特性的激活需要三个条件发生:替换/推导语境,失败发生和其他可行选项。

连起来一句话就是:当模板形参在替换/推导中失败时,从重载集中丢弃这个特化,转而寻找其他可行选项,而非导致编译失败。

替换失败不是错误 (Substitution Failure Is Not An Error) 在函数模板的重载决议中会应用此规则:当模板形参在替换成显式指定的类型或推导出的类型失败时,从重载集中丢弃这个特化,而非导致编译失败。

…

以上类型或表达式在以用来替换的实参写出时谬构(并带有必要的诊断)的场合是替换失败。替换以词法序进行,并在遇到失败时终止。

所以,

  • 替换中出现错误,是替换失败。
  • 不是替换场合就只是失败。
  • 是替换场合但是没发生错误就只是替换。

例子请查看modern C++ design的2.5

8.4.1 使用decltype 和 SFIANE搭配解决我们上一部分的问题#

首先说明,这一部分在decltype和逗号表达式的部分理解不透彻,而且属于奇技淫巧,非常复杂。我会把所有找得到的相关资料贴在这里。

我们之前一节说道:

注意,如果有一些类型有size_type,但是由于我们在第二个函数模板中使用了size成员函数。如果类型没有这个size成员函数,会报错说缺少size成员函数

这个问题还是挺痛苦的。我们如果没有在在函数声明中以某种方式要求 size()成员函数必须存在,这个函数模板就会被选择并在实例化过程中发生错误。

  • 处理这一情况有一种常用模式或者说习惯用法:
    • 通过尾置返回类型语法(trailing return type syntax)来指定返回类型(在函数名前使用auto,并在函数名后面的->后指定返回类型)。
    • 通过 decltype 和逗号运算符定义返回类型。将所有需要成立的表达式放在逗号运算符的前面(为了预防可能会发生的运算符被重载的情况,需要将这些表达式的类型转换为 void)。
  • 在逗号运算符的末尾定义一个类型为返回类型的对象。

例子:

template<typename T>
auto len (T const& t) -> decltype( (void)(t.size()), typename T::size_type()){
return t.size();
}

类型指示符 decltype 的操作数是一组用逗号隔开的表达式,因此最后一个表达式 T::size_type()会产生一个类型为返回类型的对象(decltype 会将其转换为返回类型)。而在最后一个逗号前面的所有表达式都必须成立,在这个例子中逗号前面只有 t.size()。之所以将其类型转换为void,是为了避免因为用户重载了该表达式对应类型的逗号运算符而导致的不确定性

  • typename T::size_type()为啥要这么写?
    • 首先,我们希望size_type是一个类型。但是必须要加typename指明T::size_type是个类型。其次,我们对一个类型使用()就创建了一个这个类型的匿名对象。这是一个表达式。这个表达式是返回类类型纯右值的函数调用(说人话就是类型名+()生成匿名临时对象,这个对象的类型自然是类类型。临时对象又是纯右值),所以推导出来的类型就是它本身的类型。就好比decltype(int())等于int一样。
    • 为啥要放对象不能放类型?因为你不能decltype(int)。我们是通过一个实体或表达式去推导出类型。而不是通过一个类型推导出一个类型。T::size_type是个类型所以不可以。我们唯一能做的是使用T::size_type这个类型实例化出一个这个类型的对象。

这一部分转换的粗浅理解 (其实是我理解错了,但是也还是有用信息):

  • 首先,根据N4140标准的13.3.1.2/9 [over.match.oper]部分:

对于 operator,、一元 operator& 和 operator->,如果候选函数集中没有可行函数(见后述),那么将运算符解释为内建运算符。

出自这里 和 这里

最终我的核心理解为何要转换为void?答案是**使用它来确认该函数具有返回值,我们已断言忽略它是安全的。也可以不使用c风格转换专用static_cast。**出自这里和这里

转换为void后,表达式的值被丢弃。

比如换成这样c++风格:

template<typename T>
auto len (T const& t) -> decltype(static_cast<void>(t.size()), typename T::size_type() ){
return t.size();
}

所以说这里转换为void的目的是:先判断t.size()这个表达式是否成立,也就是这个函数调用是否能成功,也就是t是否有这个成员函数。因为我们不关心返回值,所以转成void。decltype可以在第一层接受如void这种不完整类型。其次,就算有人重载了operator,()做了一些乱七八糟的事情,由于左侧是空,所以也不会做一些什么奇怪的举动。逗号运算符只保留最右侧的作为结果。所以返回值最后还会是T::size_type()。

他防止的是comma的重载定义在t.size()的返回值类型上,因此添加到void的转换,使得这个返回值被丢弃,不能被利用,所以就不触发comma的重载 ----萧叶轩大佬的回答

和这个很相似

我们之前链接里有,operator void()不会被覆盖看这里。

假设我们有个这样的残疾类型

struct foo {
struct size_type {
bool operator,(size_type) { return false;}
};
size_type size() { return {};}
};

它确实有一个size_type并且它确实有一个size()函数。但是,如果没有转换为void,模板不会推断出正确的返回类型,因为decltype( (t.size()), typename T::size_type() )是bool类型:

decltype( (t.size()), typename T::size_type());//等价于下面
decltype((t.size()).operator,(typename T::size_type())); //这个会返回一个false。

换成void之后,也就是前面的t.size()变为void类型,不会触发size_type类型的operator,()重载。

最后,我们的整体解决方案:

template<typename T, unsigned N>
std::size_t len (T(&)[N]){
cout <<"no size" << endl;
return N;
}
template<typename T>
auto len (T const& t) -> decltype(t.size(), typename T::size_type()){
cout <<"has size"<<endl;
return t.size();
}
std::size_t len (...)
{
cout <<"other" << endl;
return 0;
}

第十一章 泛型库#

11.1 可调用对象#

注意:函数对象是可调用对象的一种。

函数对象类型可以是:

  • 函数指针类型
  • 重载了 operator()的 类类型(有时被称为仿函数),这其中包含 lambda 函数
  • 包含一个可以产生一个函数指针或者函数引用的转换函数的 类类型。

注意:函数和到函数的引用不是函数对象类型,但因为函数到指针隐式转换,它们能用在期待函数对象类型的地方。但是他们都是可调用对象类型

这样的类型对应的值被称之为函数对象。

可调用对象除了函数对象以外,还包括:

  • 比如包装在std::function或std::bind的对象。(个人理解)
  • 成员函数指针类型。
  • 成员变量类型。

由于lambda是重载了operator()的匿名类,所以符合函数对象要求。所以lambda是可调用对象,更是函数对象。

  • 函数对象和可调用对象最大的区别是,函数对象可以像函数一样调用。比如f(args...),但是可调用对象不一定可以。比如成员函数指针,就必须要进行this的添加。

尤为注意成员函数指针不是函数对象,因为不能使用如f(args)这样的方式调用。有关成员函数指针在杂记2

11.1.1 标准库中对函数对象的支持#

我们在EFFSTL中详细介绍了for_each。在这里我粘贴一部分源码。

template <class _InIt, class _Fn>
_CONSTEXPR20 _Fn for_each(_InIt _First, _InIt _Last, _Fn _Func) { // perform function for each element [_First, _Last)
_Adl_verify_range(_First, _Last);
auto _UFirst = _Get_unwrapped(_First);
const auto _ULast = _Get_unwrapped(_Last);
for (; _UFirst != _ULast; ++_UFirst) {
_Func(*_UFirst); //这里已经对迭代器对象解引用了,所以直接传入的是迭代器对应的值。
// 注意这里调用函数的方式是直接把参数塞进去。所以传递函数对象就行 不用() 加了括号叫调用
}
return _Func; //看好了!!有返回值!!
}
  • 当把函数名当作函数参数传递时,并不是传递函数本体,而是传递其指针或者引用。和数组情况类似,在按值传递时,函数参数退化为指针,如果参数类型是模板参数,那么类型会被推断为指向函数的指针。(函数指针可以直接使用而不用解引用。语法糖。参见杂记2)

    • 和数组一样,按引用传递的函数的类型不会退化。但是函数类型不能真正用 const 限制。如果将 foreach()的最后一个参数的类型声明为 const _Fn _Func &,const 会被省略。(通常而言,在主流 C++代码中很少会用到函数的引用。)
    • 我们可以对函数进行&去地址后显式传入函数指针。这和第一种调用方式相同(函数名会隐式的退化为成指针),但是相对而言会更清楚一些。
  • 如果传递的是仿函数,就是将一个类的对象当作可调用对象进行传递。通过一个类类型进行调用通常等效于调用了它的operator()。因此下面这样的调用:

_Func(*_UFirst);

会被转换为

_Func.operator()(*_UFirst);

注意在定义 operator()的时候最好将其定义成const成员函数。否则当一些框架或者库不希望该调用会改变被传递对象的状态时,会遇到很不容易发现的错误。

  • 对于 类 类型的对象,有可能会被转换为指向 surrogate call function(代理函数,参见 C.3.5)的指针或者引用。此时,下面的调用:
_Func(*current);

会被转换为

(_Func.operator F())(*_UFirst);

其中 F 就是类类型的对象可以转换为的某一个指向函数的指针或者指向函数的引用的类型。

代理函数处于 C++ 最晦涩的角落。参考这里

  • Lambda 表达式会产生仿函数(也称闭包),因此它与仿函数(重载了 operator()的类)的情况一致。不过 Lambda 引入仿函数的方法更为简便,因此它们从 C++11 开始变得很常见。虽然我不喜欢。

    • 我们在杂记4的lambda章节中提到了一点:

    • 在捕获列表为空的时候,lambda还有合成的用户定义转换函数。它的作用是返回一个函数指针。这个函数指针指向内部合成的静态成员函数,这个函数内部会调用本类的函数调用运算符operator()

    • 但是它从来不会被当作代理函数,因为它的匹配情况总是比常规仿函数的operator()要差。

11.1.2 处理成员函数和其额外的参数#

我们这一节主要讲一下上一节没有讲到的成员函数和成员函数指针。

我们在杂记2的成员函数指针,杂记3的std::bind和深度探索对象模型的4.4中详细介绍了其使用方法和原理。

这种时候就可以会用invoke。

看下面的invoke部分即可。

11.1.3 函数调用的包装 (也就是invoke)#

如果我们想要完美转发被调用函数的返回值给调用者,我们可以使用decltype(auto)搭配完美转发。

之所以要用decltype(auto)而非auto的原因是因为auto会导致类型退化。

我们提到过,auto一定会推导出返回类型为对象类型并且应用退化。而auto&或auto&&一定会推导为引用类型。而decltype(auto)则可以根据具体返回值的类型进行推导。

myobj start_const(myobj s){
return myobj(move(s));
}
template<typename callable, typename... Arg>
decltype(auto) calls(callable func, Arg&&... args){
return invoke(func, forward<Arg>(args)...); //要使用完美转发
}
int main(){
auto f = calls(start_const, myobj(4));
}

关于接收invoke的返回值的部分在下面invoke返回值部分。

11.2 类型萃取#

什么是类型萃取?我的个人理解是: 计算(查询)和修改类型。

  • 计算类型,就比如我们在STL2中提到的,增加一个中间层。有一些东西不是类,自己不能包含类型,那么我们就为它增加一个中间层,比如增加一些typedef/using来实现类型的计算。同时,标准库提供给我们的比如is_const, is_convertable都算是计算类型。
  • 修改类型,就比如标准库给的一些函数,比如remove_reference,可以把一个类型修改为另一个类型。

通过类型萃取可以实现在编译期计算、查询、判断、转换和选择,增强了泛型编程的能力,也增强了程序的弹性,使得我们在编译期就能做到优化改进甚至排错,能进一步提高代码质量。

11.2.1 类型萃取中的一些注意事项#

  • 比如我们下面提到的remove_cv,std::remove_cv_t<const int&>返回的类型依旧是const int&。因为这个const是底层const。

    • 所以这时候如果我们需要拿到int,需要注意顺序和搭配:
    std::remove_const_t<std::remove_reference_t<const int&>>; // 结果是int
    std::remove_reference_t<std::remove_const_t<const int&>>; // 结果是const int
  • 还有比如使用add_rvalue_reference(declval提到过)的时候,并不是一定会返回一个右值引用。因为如果传入一个左值引用会导致引用坍缩,返回左值引用

cout << is_same_v<const int&&, add_rvalue_reference_t<const int>> << endl; //true。给const int加右值引用变成const int&&
cout << is_same_v<const int&, add_rvalue_reference_t<const int&>> << endl; //true。给const int&加右值引用导致坍缩变成const int&

所以我们说,使用类型萃取相关的标准库函数的时候,一定要详细查阅文档,了解其精确定义!!!

11.3 完美转发临时变量#

很多时候我们可以完美转发一些泛型参数

void process(myobj& obj){
cout <<"&process called" << endl;
}
void process(myobj&& obj){
cout <<"&&process called" << endl;
}
template<typename... Args>
void forward_func(Args&&... args){
process(forward<Args>(args)...); //&&process called
process(args...); //&process called
}
int main(){
forward_func(myobj(10)); //注意传入的得是右值。forward是维持右值的右值性和左值的左值性,和move不同。不多赘述
return 0;
}
/*
const
&&process called
&process called
dest
*/

但是某些情况下,在泛型代码中我们需要转发一些不是通过参数传递进来的数据。此时我们可以使用 auto &&创建一个可以被转发的变量。

11.4 做为模板参数的引用(也就是模板参数类型为引用) [非常重要]#

假如我们有如下代码

template<typename T>
void ref_test1(T arg){
cout <<std::boolalpha;
cout <<is_same_v<int&, decltype(arg)> << endl; //false
cout <<is_same_v<int&, T> << endl; //false
}
template<typename T>
void ref_test2(T& arg){
cout <<std::boolalpha;
cout <<is_same_v<int&, decltype(arg)> << endl; //true
cout <<is_same_v<int&, T> << endl; //false
}
int main(){
int a = 20;
int& ref_a = a;
ref_test1(a);
ref_test2(a);
ref_test1(ref_a);
ref_test2(ref_a);
ref_test1<int&>(a); //true true
ref_test2<int&>(a); //true true
return 0;
}
  • 需要注意的是,无论我们传入的是否是引用类型,函数的输出都是一致的。为什么?

  • 因为ref_a本身是个引用,意思是表达式ref_a的类型是引用。但是整体表达式的类型永远不会是引用。

    • 比较难以理解?也就是说这种情况下,T永远不会被推导为引用类型。因为T代表的是整体表达式的类型。
    • 7.2.3反复强调,万能引用是唯一一种可能把T推导为引用类型的情况。因为有引用折叠。
  • 但是,如果arg是按照T&类型传入的,那么arg的类型会是T&。所以arg此时是引用类型。

  • 如果硬要指定为引用类型,则需要显式指定模板参数。但是很多时候会有严重问题。具体看书吧,太复杂了。

    • 表面上看,好像一般不会让模板参数类型为引用类型。但是在C++17 中,非类型模板参数可以通过推断得到。也就是模板参数类型可能是template<typename T, decltype(auto) SZ>这样的形式。但是我们在invoke的返回值一章中提到过,decltype(auto)会很容易的根据表达式类型推导出引用类型。所以会出现问题。

11.5 推迟计算#

可能某些时候我们会遇到不完整类型。如某个类可能是这样:

template<typename T>
class myclass{
public:
T* elem;
typename std::conditional<std::is_move_constructible<T>::value, T&&,T&>::type foo();
//...
};
  • 这里通过使用 std::conditional来决定 foo()的返回类型是 T&&还是 T&。决策标准是看模板参数 T 是否支持 移动构造。问题在于 std::is_move_constructible 要求其参数必须是完整类型。所以这时候,T如果是不完整类型,则会报错。
  • 此时的解决方案是使用一个成员函数模板来替换现有的foo()函数。因为模板只有在调用的时候会被实例化,所以可以将 std::is_move_constructible的计算推迟到foo()函数的实例化阶段。
template<typename T>
class myclass{
public:
T* elem;
template<typename D = T>
typename std::conditional<std::is_move_constructible<D>::value, T&&,T&>::type foo(); //成员函数模板。第一个换成D就足够。
//...
};

11.6 在写泛型库的时候需要考虑的事情#

  • 在模板中使用万能引用来实现对参数的完美转发。如果转发的参数并不是通过外部传入的,就可以使用 auto &&(参见 11.3)。
  • 如果一个参数被声明为万能引用,并且传递给它一个左值的话,那么模板参数会被推断为引用类型(参见杂记)。
  • 在需要一个依赖于模板参数的对象的地址的时候,最好使用 std::addressof()来获取地址,这样能避免因为对象拥有一个重载了的 operator &而导致的意外情况
  • 对于成员函数,需要确保它们不会比预定义的 拷贝或移动构造函数或者赋值运算符更能匹配某个调用(参见 6.4)。
  • 如果我们需要把函数处理的结果写回依赖于模板参数的调用参数,需要考虑如果传入参数是带有const 的情况。这种情况可能会以外导致该函数可以接受右值。(参见 7.2.2)
  • 请为将引用用于模板参数的副作用做好准备(参见 11.4 节)。当然非常不推荐这么做。尤其是在需要确保返回类型不会是引用的时候(参见 7.5)。
  • 请为将不完整类型用于嵌套式数据结构这一类情况做好准备(参见 11.5 节)。比如二叉树。
  • 为所有数组类型进行重载,而不仅仅是 T[SZ](参见 5.4 节)。
C++ 模板笔记(三):enable_if、传值还是传引用、编译期编程与泛型库
https://sterben-01.github.io/posts/模板-3/
作者
Sterben_01
发布于
2022-12-14
许可协议
CC BY-NC-SA 4.0