颜林林的个人网站

现代C++学习笔记(2):型别推导基础

2022-01-16 00:01

Image (题图来自网络并做适当修改)

这篇开始,正式进入《Effective Modern C++》的学习。第一章,型别推导,包括四个条款。我不会严格按照书中的顺序和内容来介绍,而是基于我的理解,挑出我认为的重点,并按照我一时心血来潮的想法进行展示。

所以,这份笔记并不是一个入门教材。我会假定你有一定的C++语言基础,而且你很可能会同时阅读原书,或是其他相关书目。而我也仅将这份笔记的用途,定位为查缺补漏或技能提升,并希望它让你对现代C++的一些新特性有更深入的理解,从而在某些时候,你可以因此向他人进行技术炫耀,而这其实正是《Effective XXX》系列书籍的重要价值。

当然,我会对一些重要基础知识做适当展开,以尽量让这份笔记易于理解,但限于篇幅,我不会过多刨根问底,以避免挖到过于底层的世界,超出C++的主题。如果你发现某个地方我写得不够清楚,或者你有更感兴趣的问题,欢迎在公众号的后台给我留言,我会尽量在未来的文章(不一定是这个笔记系列)里进行阐述(或闲聊)。对了,在后台留言,会有一位聊天机器人帮助我第一时间回复,词不达意之时请一笑了之。

今天的内容,包括如下几部分:

声明与定义

数值变量的类型

解决代码重复问题的模板

按值传参或按引用传参

由模板语法衍生而得的型别推导

前四部分,其实是传统C++的内容,出于内容需要,在此复习下。

1

声明与定义

C++程序代码,经常会写到两种不同文件中。一是头文件(header file),扩展名 .h 或 .hpp;一是源文件(source file),扩展名 .cpp 或 .cxx。通常,在头文件中存放声明,而在源文件中存放定义。两者的区别在于:声明仅指出类型和名称,而并不分配内存空间;定义则会分配内存空间,还可以为变量指定初始值。

举例说明如下:

// 以下是声明 extern int a; // extern关键字,仅说明变量类型 int f(int x); // 函数无实际代码,仅说明参数与返回值类型

// 以下是定义 int a = 42; // 无extern关键字,会分配空间,且能指定初始值 int f(int x) { return x + 1; // 指定了函数实际代码 } 2

数值变量的类型

C++语言,在内置数值类型上,与计算机硬件实现有密切的关系。它可以非常精确地控制一个变量在内存中占用的字节数,这种与硬件的绑定特性,赋予了它其他大多数语言都难以企及的灵活性和性能。同时,它又能兼容不同体系架构的硬件,这使它能跻身于“高级语言”的队伍。

下面的代码,将通过 sizeof() 来展示不同数值类型的字节数:

#include using namespace std; int main() { cout « sizeof(int) « endl; // 4 cout « sizeof(char) « endl; // 1 cout « sizeof(short int) « endl; // 2 cout « sizeof(long int) « endl; // 4 或 8 cout « sizeof(long long) « endl; // 8 cout « sizeof(float) « endl; // 4 cout « sizeof(double) « endl; // 8 cout « sizeof(long double) « endl; // 16 return 0; } 当使用C++语言时,需要养成的一个比较重要的习惯是,当写下某个变量及其类型时,需要很清楚它在内存中到底占用了多少字节的空间,以及这些空间能够被使用多久(变量的生存期)。这是C++语言赋予我们的强大权利,好好珍惜,并敬畏地使用它。

3

解决代码重复问题的模板

接下来考虑一个简单的加法函数,由于我们有各种不同的数值类型,这就意味着,需要给每种类型都分别写一个对应的(重载)函数才行。

int add(int a, int b) { return (a + b); } char add(char a, char b) { return (a + b); } short int add(short int a, short int b) { return (a + b); } long int add(long int a, long int b) { return (a + b); } long long add(long long a, long long b) { return (a + b); } float add(float a, float b) { return (a + b); } double add(double a, double b) { return (a + b); } long double add(long double a, long double b) { return (a + b); } 这是一个非常繁琐的工作任务。于是,传统C++中引入的最基本的模板功能,便是解决这个问题的:

template T add(T a, T b) { return (a + b); } 只需要写一次代码逻辑,然后编译器会根据实际被调用的情况,自动扩展出上述特定类型的函数版本,并生成相应的代码出来。没有被调用的,则根本不会生成任何代码。

4

按值传参或按引用传参

接下来一个值得注意的问题,是关于函数参数的,直接看代码:

// 以下三个函数,都是为了实现把参数变量自加1 void f1(int x) { x = x + 1; } void f2(int* x) { *x = *x + 1; } void f3(int& x) { x = x + 1; } // 分别进行测试 void test() { int a = 1; // 初始值为1 f1(a); printf("%d\n", a); // 1,未变化 f2(&a); printf("%d\n", a); // 2,已加1 f3(a); printf("%d\n", a); // 3,已再加1 } 在上面三个函数中:

f1 的参数类型为 int,在C++中,这样的缺省“无修饰”的参数类型,是“按值传递”的,也就是在函数调用发生时,传入的参数会被拷贝一份,因此,在 test() 中的 a(称为“实参”)与 f1() 中的 x(称为“形参”),其实是完全不同的两个变量了。所以,在函数内部虽然确实进行了自加,但并未改变原来的变量 a。

f2 的参数类型为 int*,定义成了指向int类型的指针。该指针其实也是发生了“按值传参”的过程,但指针值无变化,所以在 f2() 函数的内外,都指向了同一个变量,这时在函数内部通过指针去修改变量值,自然就能修改成功。只不过,这种写法太过繁琐,函数定义中,参数要使用指针类型,导致自加代码中都得使用指针操作(*x),且调用的时候也得采用地址获取的方法(&a)。

f3 的定义与 f1 几乎完全相同,唯一差别仅仅是参数类型处,增加了一个“&”,这让C++(相比C语言)有了“按引用传参”(或者说“按变量传参”)的能力。

区分这两种传参的方法,对于型别推导,具有很关键的意义。

5

由模板语法衍生而得的型别推导

来到《Effective Modern C++》的“条款1:理解模板型别推导”。它尝试解释清楚一个最重要的规则:

template void f(ParamType param); f(expr); // 即如何根据这里 expr,推导出上面的ParamType 模板是现代C++中的最基础且最重要的语法元素,各种C++黑魔法都源于此。在“条款4:掌握查看型别推导结果的方法”中,还会再次强调,即使有各种技术手段帮助,我们也还是需要亲自理解并吃透这个推导规则。

知其然,知其所以然,还要见到代码就能立即人脑解析,识别出编译器将完成怎样的推导过程,以及得到最终怎样的代码编译结果。这就是C++学习曲线之陡峭所在,却也是一旦真能掌握,就能所向披靡的原因。

Scott Meyers将其区分为三种情况,从而化简了该推导过程:

情况1:ParamType是个引用(或指针),但不是一个万能引用: template void f(T& param); // param是个引用

int x = 42; // x类型是int const int cx = x; // cx类型是const int const int& rx = x; // rx类型是const int&

f(x); // T推导为int,param类型是int& f(cx); // T推导为const int,param类型是const int& f(rx); // T推导为const int,param类型是const int& 这里,无论实参是否引用,形参都会被推导成为对应的引用。上述结果基本都是个符合直觉的表现。指针的情况与之类似,这里不再展开。

情况2:ParamType是个万能引用 万能引用(universal reference)是Scott Meyers自创的一个概念,后来大家更多会使用转发引用(forward reference)的概念。这里也先不展开,按照书中说的“它既可以用于左值、也可以用于右值”来简单理解,知道它被写成“T&&”即可。

template void f(T&& param); // param是个万能引用

int x = 42; const int cx = x; const int& rx = x;

f(x); // T推导为int&,param类型是int& f(cx); // T推导为const int&,param类型是const int& f(rx); // T推导为const int&,param类型是const int& f(42); // T推导为int,param类型是int&& 上面的四种调用情况,前三种(x、cx和rx)都是左值(即可以写到赋值表达式左边,通常指一个变量),所以推导得到的就是相应变量的引用。而第四种(42)是一个右值(即仅仅能写在赋值表达式的右侧;写到左侧会造成语法错误),所以推导得到的是一个指向某临时变量内存块的引用(即int&&),而该内存块并非任何一个显式变量。

情况3:ParamType即非引用也非指针 template void f(T param); // param即非引用也非指针

int x = 42; const int cx = x; const int& rx = x;

f(x); // T和param的类型都是int f(cx); // T和param的类型都是int f(rx); // T和param的类型都是int 这种情况最简单,它是前面提到的函数参数的缺省行为,“按值传递”,即实参会被复制一份,作为形参进入函数被使用。也因此,不管原来的实参是否有const等修饰词,一旦进入函数,其拷贝体形参就不再被const约束了。

--- END ---

注:本文首发表于“不靠谱颜论”公众号,并同步至本站。

相关文章