您现在的位置是:首页 >技术杂谈 >盘一盘C++的类型描述符(四)网站首页技术杂谈

盘一盘C++的类型描述符(四)

borehole打洞哥 2023-04-26 11:55:52
简介盘一盘C++的类型描述符(四)

先序文章请看
盘一盘C++的类型描述符(三)
盘一盘C++的类型描述符(二)
盘一盘C++的类型描述符(一)

模板类型

模板类型不是类型

说到模板类型,我们必须要区分两个概念:

  1. 模板类型或偏特化模板类型
  2. 全特化模板类型

二者最大的区别在于「是不是模板」,所有模板形式的类型中,只有全特化模板它不是模板,而其他的则是模板。

关于模板概念、类型、特化等相关内容,请读者参考《C++模板元编程详细教程》这里不再赘述。

高清上面的概念以后,我们需要知道的是,本文所讨论的「C++类型」是不包括未实例化或未完全实例化的模板类型的,本质来说,模板类型根本就不是数据类型(它还没有成为类型)。举例来说:

template <typename T>
class Test {
};

对于上例来说,Test是「模板类型」,而Test<int>才是「类类型」,前者并不是数据类型。

既然模板类型不是真正的数据类型,那么这也就意味着,我们无法通过任何类型运算符获取到模板类型,这里的类型运算符包括decltypeauto,以及各种type_traits工具比如说std::decay_t。这个其实很好理解,看几个例子就明了了:

template <typename T>
class Test {
 public:
  Test(const T &t) {}
};

void Demo() {
  using type1 = Test; // 非法
  using type2 = Test<int>; // OK

  Test t1 {2}; // 隐式实例化
  Test<double> t2 {3};

  using type3 = decltype(t1); // Test<int>,注意,并不是Test
  using type4 = decltype(t2); // Test<double>

  using type5 = std::decay_t<Test>; // 非法
  using type6 = std::decay_t<Test<int>>; // OK
  using type7 = std::decay_t<type3>; // OK
}

因此,能够参与类型运算的一定是数据类型,类型运算的结果也一定是数据类型,而模板类型并不是数据类型。模板类型只能出现在模板参数当中,这一点请读者一定要注意!

再来看一个很容易踩坑的例子:

template <typename T = int>
struct Test1 {};

struct Test2 {
  Test1 t; // 报错
};

读者可以思考一下,上面代码为什么会报错?明明Test1有指定默认参数呀!

这里我们需要明确,Test1是模板,而Test1<int>才是类型,所以,用一个没有实例化的模板类型,去定义变量自然是不合法的。那么如何体现,我们这里要用它的默认参数呢?加上尖括号就好了:

struct Test2 {
  Test1<> t; // OK,等价于Test1<int> t
};

有的读者可能会有疑问,为什么我直接定义局部变量的时候可以?

void Demo() {
  Test1 t; // OK
}

道理很简单,我们知道定义变量一定要用「类型」,所以,模板一定要实例化,那么这里其实就是隐式实例化了。而隐式实例化依赖于「构造参数」,在定义局部变量中,是会调用它默认构造函数的,因此,编译期由「默认构造函数(无参)」这个动作触发了「模板隐式实例化(空参)」,推导出了Test1<>,再由于默认参数为int,所以Test1<>等价于Test<int>

但在定义成员变量的时候,是没有「构造」这个动作的(构造则发生在初始化列表中),仅仅只是需要类型,以确定成员的大小和偏移量(其实本质就是C语言中结构体的定义和处理方式)。既然都没有构造这个动作,也就不会进行隐式实例化了。

类型推导

最后我们来看看C++类型推导的两大难,它们是autodecltype

auto

先来说说auto,我看到很多教程上各种强调类似于「auto一般不会推出引用」,「auto一般不会推出const」等等这种方式的归纳总结,虽然完整详细,但着实会让读者一脸懵,而且很难记住。

因此,这里还是希望读者能够明白「语义」,我们了解它究竟是怎么样的表达方式,自然也就很容易推导出规律。

从语义上讲,auto其实是一个「占位符」,用来充当变量类型的位置。所以,根据它出现的位置,本着「最简原则」即可。

比如说:

auto a = 5;

这里照理说,intconst intconst int &int &&const int &&甚至是unsignedlong等都可以。但何为「最简」?自然是int。那为什么不是long呢?因为5int类型,如果推导为long则相当于进行了类型转换,所以它并不如int简单。

「可明明5是常量啊?为什么不认为是const?」因为对于auto来说,类型的「最简」是针对于其修饰的部分而言的。刚才我们说过,auto就是个占位符,所以这里的语义是「定义一个变量a,初始化为5」,因此这个auto显然是修饰a的,而不是修饰5的(5只是定义变量a的一个初始值罢了,或者可以理解为构造参数。)因此auto并不会因为5的这种「常量」性质而添加const或右值引用。

或者我们用一种更简单粗暴的方式来解释,我们把auto当做一个未知数,那么能让式子auto a = 5;成立的auto的最简表达式是什么?显然是int

再看几个例子:

int f1();
int &f2();
int &&f3();
const int f4();
const int &f5();

void Demo() {
  int a = 5;

  auto e1 = a;
  auto e2 = std::move(a);
  auto ef1 = f1();
  auto ef2 = f2();
  auto ef3 = f3();
  auto ef4 = f4();
  auto ef5 = f5();
}

请问这7个e开头的变量都是什么类型?还是,用「式子能成立的最简类型」来分析,不难了解,所有的都是int类型。

「那如果是复杂类型,这样做岂不是会复制?这样还算最简吗?」我相信这也是一些读者会产生的疑问,我们来看个例子:

struct Test {
  // 假设这里面很复杂,变量很多,也可能是非可平凡复制的
};

Test f1();
Test &f2();
const Test &f3();
Test &&f4();

void Demo() {
  Test t;
 
  auto e1 = t;
  auto e2 = std::move(t);
  auto e3 = Test{};
  auto ef1 = f1();
  auto ef2 = f2();
  auto ef3 = f3();
  auto ef4 = f4();
}

如果它们的类型也都识别为Test,那这里面除了e3ef1ef3会触发C++17的CE(复制省略)以外,其他的都会触发拷贝构造或移动构造,从这个角度来解释,似乎推导为引用或const引用才更简化?但真的是这样吗?

答案自然是否定的!因为auto的类型推导是在「编译期」而不是「运行期」。这也很好理解,毕竟「类型」这个东西一定是编译期的概念,运行期就全变成机器指令的,在冯·诺依曼体系的计算机中,本来就是不区分指令段和数据段的,仅仅看你如何解释。

因此,auto推导的所谓「最简」就是静态的,程序字面上的最简,是不会考虑运行时的情况的,这一点请读者悉知。

上面我们都是用int为例,会更明显一些,如果是一些复杂类型,大家不要忘了我们前面章节讲过的方法,要看到哪些是修饰这个类型本身的,而那些外层间接的部分是无论如何都不会发生改变的,比如说:

int *const p;
auto a = p; // int *, 这里的const修饰的p本身,所以auto不会推导出。
// 上面的类似于:
const int v1 = 0;
auto e = v1; // int,而不是const int

const int *const p2;
auto e2 = p2; // const int *,注意识别解类型中的const,不会被消除

void (*pf)(int);
auto e3 = pf; // void (*)(int)

void (Test::*const pmf)(const int) const;
auto e4 = pmf; // void (Test::*)(const int) const,同理,函数原型中的const不会消除

我们再来看几个可能在意料之外,但分析后仍在情理之中的例子:

void f();

void Demo() {
  auto e1 = f; // void (*)()
 
  int arr[] {1, 2, 3};
  auto e2 = arr; // int *

  auto e3 = "abcd"; // const char *
}

别激动!我们一个一个来解释。首先,对于e1来说,以一个函数来初始化,那么它只能是对应函数的函数指针类型。「为什么不是函数类型?」因为不存在函数类型的变量,所以能满足auto e1 = f;式子的最简类型只能是void (*)(),也就是void (*e1)() = f;

再来看看e2,明明arr是数组啊,为什么会推导出指针呢?也很简单,因为我们没法用一个数组来初始化另一个数组,类似于int e2[3] = arr;这种显然是不合法的。但因为数组类型可以隐式转换为首元素的指针类型,所以,满足auto e2 = arr;式子的最简类型只能是int *,也就是int *e2 = arr;

那么e3这个与e2同理,单独的字符串字面量会被编译期处理为一个全局区的只读字符数组,也就是说"abcd"的类型是const char[5](注意字符串末尾隐含的''),所以满足式子的最简类型就是const char *,也就是const char *e3 = "abcd";。「哎?这里为啥有个const呢?」,当然是因为const是解类型中的呀,不会消除。

我们知道了上面的原则,那么auto加上引用符、const后的情况也迎刃而解了,原则就是「满足式子的auto最简类型」,注意是auto占位符本身最简,而不是整体最简,举例来说:

const auto a = 5; // auto->int, a->const int
auto &b = a; // auto->int, b->int &

int *const p;
auto &r = p; // auto->int *, r->int *&

auto &e1 = "abc"; // auto->const char [4], e1->const char (&)[4]

int arr[] {1, 2, 3};
const auto &e2 = arr; // auto->int [3], e2->const int (&)[3]

哎?后面这两个是不是写错了呀!刚才明明说了遇见数组的时候auto会推导出指针呀,这里怎么又可以是数组了呢?

还是要回到我们一直强调的原则,auto应当推导出「能让式子成立的最简类型」,刚才我们推导出指针,是因为推导出数组那么式子不成立了(因为不能用数组来定义另一个数组),但当这里是引用的时候,却是合法的(定义一个数组的引用)。因此,「数组首元素指针类型」相比「数组类型」自然就不是最简的了,所以这里最简的解应该就是auto是数组类型,然后auto &是数组引用类型。

auto &&

我相信有的读者看到这一节的标题可能都懵了,上一节不是刚说过,auto加上修饰符以后,仍然满足auto最简原则嘛,怎么auto &&又不一样了?貌似上一节的例子中,还真的没有出现auto &&的例子。

C++复杂就复杂在这些问题上(笔者也是无力吐槽了……)。auto &&还真的是一个特殊情况,虽然说大原则仍然是auto最简,但对于「能让式子成立」这一条来说,auto &&却有着独特的规则。

首先需要强调一点,只有auto &&是特殊的,而const auto &&就完全按照上一节的原则来进行了,不会符合这一节所讲的规则。

auto &&的特殊性在于,它可以推导出左值引用,而究竟是左值引用还是右值引用,取决于绑定的表达式是左值还是右值。如果是左值,就推导左值引用;如果是右值,就推导右值引用。注意,除了这一项特性以外,其他的仍然满足「auto最简原则」。我们来看几个例子:

int f1();
int &f2();
int &&f3();

void Demo() {
  int a = 0;

  auto &&e1 = a; // a是左值,所以推导int &
  auto &&e2 = 5; // 5是右值,所以推导int &&
  auto &&e3 = e2; // e1是int &&类型,但右值引用本身是左值,所以推导int &
  auto &&ef1 = f1(); // 返回值是右值,所以推导int &&,
  auto &&ef2 = f2(); // 返回左值引用,所以推导int &
  auto &&ef3 = f3(); // int &&,注意这里可能不好理解,稍后详细解释
  auto &&e4 = std::move(a); // int &&,这个case用于跟ef3做对比
}

由于auto &&会根据表达式的左右性来自动选择对应的引用类型,因此auto &&也被称为「万能引用」,所以它不能按再简单按照auto占位加右值引用这种语义来理解。C++之所以提供这个语法,是希望能够给我们提供一个「最佳引用类型」的方法,尤其在模板编程里,这种特性会非常好用。如果读者对左右值的部分有疑问,可以参考《C++为什么会有这么多难搞的值类别?》;如果对模板编程感兴趣,则可以参考《C++模板元编程详细教程》。

因此,auto &&就当做独立于auto的一个特殊语法来对待就好了。

现在我们来解释一下为什么ef3是右值引用。这就需要解释一下函数返回值类型的语义,变量在函数间的传递(包括传入和传出)只有两种:值传递,引用传递。

从语义上来说,「值传递」就是在传递的过程中,只保证它值的正确性,而不保证所用实体。也就是说,在值传递的过程中是会发生数据复制的,对「取值」的原始对象不会做任何改动。

而「引用传递」就是在传递的过程中,保证操作的是原本的实体。那么在引用传递的过程中,只会对「替身」进行复制,而对「替身」进行的任何操作,都会映射到原本的实体上,也就保证了所有操作都会对原本的实体生效。

这两种传递方式在参数传入的时候非常明显,也容易理解,这里就不再赘述了。而在返回值传出的时候,就会变得不那么明显,进而令人费解。我们首先来看下面的例子:

int g1 = 5; // 这里放一个全局变量,方便理解

int f1() {return g1;} // 值传递
int &f2(); {return g1;} // 引用传递

void Demo() {
  auto &&r1 = f1();
  auto &&r2 = f2();

  r1 = 8;
  std::cout << g1 << std::endl;
  r2 = 8;
  std::cout << g1 << std::endl;
}

f1f2都是返回全局变量g1,但f1是值传递,那么,通过调用f1函数所接收到的返回值,应该是「g1的值」这样的语义,所以r1所引用的对象并不是g1,而是一个「g1的值」。又因为「值」本身没法被引用,所以只能是用一个匿名变量来保存这个值,再去引用(类似于const引用绑定常量的那种方式)。所以r1其实是引用了「一个保存了g1的值的匿名变量」。因此,对r1进行操作,其实是对那个匿名变量进行操作,并不会影响g1的值,因此第一行打印是5。所以它利用了右值引用绑定常量的语义,将r1推导为右值引用,它的类型是int &&,用来保证正确性。

而对于f2来说,它是引用传递,那么,通过调用f2函数所接收到的返回值,应该是「g1的替身」这样的语义,所以r2应当就是g1本身的引用才对。g1本身就是左值,因此只有把r2推导为左值引用,才能符合这里f2函数调用的语义,因此r2的类型是int &

这两个case理解应当不难理解,那么我们再来看一下返回右值引用的情况:

int tool() {return 5;} // 辅助函数

int &&f3() {return 0;} // 常量,纯右值的情况
int &&f4() {return tool();} // 函数返回值,纯右值的情况

struct Test {
  int a, b;
};

int &&f5() {return Test{1, 2}.b;} // 临时对象的成员,将亡值的情况

void Demo() {
  auto &&r3 = f3();
  auto &&r4 = f4();
  auto &&r5 = f5();
}

既然一个函数能返回右值引用,那么这个右值引用一定是要绑定一个右值的,f3f4f5列举了3种返回右值的情况,大家可以分析一下,它们表达的是「值传递」还是「引用传递」呢?

答案很清晰了,既然都返回右值了,自然它重要的是「值」而不是「实体」,所以对于返回右值引用的函数,本质上是要返回一个「值」,而不是「实体」。那么理所应当地,应当按照值传递的语义,将auto &&推导为右值引用。

所以,这才出现了「auto &&接收右值引用会变左值引用」但「auto &&接收返回右值引用的函数仍然是右值引用」的奇怪现象。其本质在于它们的语义不同,所以结果也不同。

前面的例子我们还给出了一个std::move的例子作为对比,也是为了加深大家的印象。倘若说函数返回右值引用的会被推导为左值引用的话,那岂不是auto &&val = std::move(obj)也会被推倒为左值引用?那这里的std::move岂不完全失去了作用?(毕竟std::move也是函数呀!哦,它应该是模板函数,但是这里已经隐式实例化过了,所以是函数。)这肯定是不合预期的。所以我们也不难推断,返回右值引用的函数,经过auto &&推导后一定是右值引用。

【第五篇待更】

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。