终极 C++避坑指南|6万字长文

Posted 程序员编程指南

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了终极 C++避坑指南|6万字长文相关的知识,希望对你有一定的参考价值。

作者:boreholehu,腾讯 WXG 后台开发工程师

神级编程网站,堪称程序员的充电站,我给你找好了不能错过_程序员编程指南的博客-CSDN博客

前言

C++是一门古老的语言,但仍然在不间断更新中,不断引用新特性。但与此同时 C++又甩不掉巨大的历史包袱,并且 C++的设计初衷和理念造成了 C++异常复杂,还出现了很多不合理的“缺陷”。

本文主要有 3 个目的:

  1. 总结一些 C++晦涩难懂的语法现象,解释其背后原因,作为防踩坑之用;

  2. 和一些其他的编程语言进行比较,列举它们的优劣;

  3. 发表一些我自己作为 C++程序员的看法和感受。

来自 C 语言的历史包袱

C++有一个很大的历史包袱,就是 C 语言。C 语言诞生时间很早,并且它是为了编写 OS 而诞生的,语法更加底层。有人说,C 并不是针对程序员友好的语言,而是针对编译期友好的语言。有些场景在 C 语言本身可能并没有什么不合理,但放到 C++当中会“爆炸”,或者说,会迅速变成一种“缺陷”,让人异常费解。

C++在演变过程中一直在吸收其他语言的优势,不断提供新的语法、工具来进行优化。但为了兼容性(不仅仅是语法的兼容,还有一些设计理念的兼容),还是会留下很多坑。

数组

数组本身其实没有什么问题,这种语法也非常常用,主要是表示连续一组相同的数据构成的集合。但数组类型在待遇上却和其他类型(比如说结构体)非常不一样。

数组的复制

我们知道,结构体类型是可以很轻松的复制的,比如说:

struct St 
  int m1;
  double m2;
;

void demo() 
  St st1;
  St st2 = st1; // OK
  St st3;
  st1 = st3; // OK

但数组却并不可以,比如:

int arr1[5];
int arr2[5] = arr1; // ERR

明明这里 arr2 和 arr1 同为int[5]类型,但是并不支持复制。照理说,数组应当比结构体更加适合复制场景,因为需求是很明确的,就是元素按位复制。

数组类型传参

由于数组不可以复制,导致了数组同样不支持传参,因此我们只能采用“首地址+长度”的方式来传递数组:

void f1(int *arr, size_t size) 

void demo() 
  int arr[5];
  f1(arr, 5);

而为了方便程序员进行这种方式的传参,C 又做了额外的 2 件事:

  1. 提供一种隐式类型转换,支持将数组类型转换为首元素指针类型(比如说这里 arr 是int[5]类型,传参时自动转换为int *类型)

  2. 函数参数的语法糖,如果在函数参数写数组类型,那么会自动转换成元素指针类型,比如说下面这几种写法都完全等价:

void f(int *arr);
void f(int arr[]);
void f(int arr[5]);
void f(int arr[100]);

所以这里非常容易误导人的就在这个语法糖中,无论中括号里写多少,或者不写,这个值都是会被忽略的,要想知道数组的边界,你就必须要通过额外的参数来传递。

但通过参数传递这是一种软约束,你无法保证调用者传的就是数组元素个数,这里的危害详见后面“指针偏移”的章节。

分析和思考

之所以 C 的数组会出现这种奇怪现象,我猜测,作者考虑的是数组的实际使用场景,是经常会进行切段截取的,也就是说,一个数组类型并不总是完全整体使用,我们可能更多时候用的是其中的一段。举个简单的例子,如果数组是整体复制、传递的话,做数组排序递归的时候会不会很尴尬?首先,排序函数的参数难以书写,因为要指定数组个数,我们总不能针对于 1,2,3,4,5,6,...元素个数的数组都分别写一个排序函数吧?其次,如果取子数组就会复制出一个新数组的话,也就不能对原数组进行排序了。

所以综合考虑,干脆这里就不支持复制,强迫程序员使用指针+长度这种方式来操作数组,反而更加符合数组的实际使用场景。

当然了,在 C++中有了引用语法,我们还是可以把数组类型进行传递的,比如:

void f1(int (&arr)[5]); // 必须传int[5]类型
void demo() 
  int arr1[5];
  int arr2[8];

  f1(arr1); // OK
  f1(arr2); // ERR

但绝大多数的场景似乎都不会这样去用。一些新兴语言(比如说 Go)就注意到了这一点,因此将其进行了区分。在 Go 语言中,区分了“数组”和“切片”的概念,数组就是长度固定的,整体来传递;而切片则类似于首地址+长度的方式传递(只不过没有单独用参数,而是用 len 函数来获取)

func f1(arr [5]int) 

func f2(arr []int) 

上面例子里,f1 就必须传递长度是 5 的数组类型,而 f2 则可以传递任意长度的切片类型。

而 C++其实也注意到了这一点,但由于兼容问题,它只能通过 STL 提供容器的方式来解决,std::array就是定长数组,而std::vector就是变长数组,跟上述 Go 语言中的数组和切片的概念是基本类似的。这也是 C++中更加推荐使用 vector 而不是 C 风格数组的原因。

神级编程网站,堪称程序员的充电站,我给你找好了不能错过_程序员编程指南的博客-CSDN博客

类型说明符

类型不是从左向右说明

C/C++中的类型说明符其实设计得很不合理,除了最简单的变量定义:

int a; // 定义一个int类型的变量a

上面这个还是很清晰明了的,但稍微复杂一点的,就比较奇怪了:

int arr[5]; // 定义一个int[5]类型的变量arr

arr 明明是int[5]类型,但是这里的 int 和[5]却并没有写到一起,如果这个还不算很容易造成迷惑的话,那来看看下面的:

int *a1[5]; // 定义了一个数组
int (*a2)[5]; // 定义了一个指针

a1 是int *[5]类型,表示 a1 是个数组,有 5 个元素,每个元素都是指针类型的。

a2 是int (*)[5]类型,是一个指针,指针指向了一个int[5]类型的数组。

这里离谱的就在这个int (*)[5]类型上,也就是说,“指向int[5]类型的指针”并不是int[5]*,而是int (*)[5],类型说明符是从里往外描述的,而不是从左往右。

类型说明符同时承担了动作语义

这里的另一个问题就是,C/C++并没有把“定义变量”和“变量的类型”这两件事分开,而是用类型说明符来同时承担了。也就是说,“定义一个 int 类型变量”这件事中,int 这一个关键字不仅表示“int 类型”,还表示了“定义变量”这个意义。这件事放在定义变量这件事上可能还不算明显,但放到定义函数上就不一样了:

int f1();

上面这个例子中,int 和()共同表示了“定义函数”这个意义。也就是说,看到 int 这个关键字,并不一定是表示定义变量,还有可能是定义函数,定义函数时 int 表示了函数的返回值的类型。

正是由于 C/C++中,类型说明符具有多重含义,才造成一些复杂语法简直让人崩溃,比如说定义高阶函数:

// 输入一个函数,输出这个函数的导函数
double (*DC(double (*)(double)))(double);

DC 是一个函数,它有一个参数,是double (*)(double)类型的函数指针,它的返回值是一个double (*)(double)类型的函数指针。但从直观性上来说,上面的写法完全毫无可读性,如果没有那一行注释,相信大家很难看得出这个语法到底是在做什么。

C++引入了返回值右置的语法,从一定程度上可以解决这个问题:

auto f1() -> int;
auto DC(auto (*)(double) -> double) -> auto (*)(double) -> double;

但用 auto 作为占位符仍然还是有些突兀和晦涩的。

将类型符和动作语义分离的语言

我们来看一看其他语言是如何弥补这个缺陷的,最简单的做法就是把“类型”和“动作”这两件事分开,用不同的关键字来表示。 Go 语言:

// 定义变量
var a1 int
var a2 []int
var a3 *int
var a4 []*int // 元素为指针的数组
var a5 *[]int // 数组的指针
// 定义函数
func f1() 

func f2() int 
  return 0

// 高阶函数
func DC(f func(float64)float64) func(float64)float64 

Swift 语言:

// 定义变量
var a1: Int
var a2: [Int]

// 定义函数
func f1() 


func f2() -> Int 
  return 0

// 高阶函数
func DC(f: (Double, Double)->Double) -> (Double, Double)->Double 

javascript 语言:

// 定义变量
var a1 = 0
var a2 = [1, 2, 3]
// 定义函数
function f1() 
function f2() 
  return 0

// 高阶函数
function DC(f) 
  return function(x) 
    //...
  

指针偏移

指针的偏移运算让指针操作有了较大的自由度,但同时也会引入越界问题:

int arr[5];
int *p1 = arr + 5;
*p1 = 10// 越界

int a = 0;
int *p2 = &a;
a[1] = 10; // 越界

换句话说,指针的偏移是完全随意的,静态检测永远不会去判断当前指针的位置是否合法。这个与之前章节提到的数组传参的问题结合起来,会更加容易发生并且更加不容易发现:

void f(int *arr, size_t size) 

void demo() 
  int arr[5];
  f(arr, 6); // 可能导致越界

因为参数中的值和数组的实际长度并没有要求强一致。

其他语言的指针

在其他语言中,有的语言(例如 java、C#)直接取消了指针的相关语法,但由此就必须引入“值类型”和“引用类型”的概念。 例如在 java 中,存在“实”和“名”的概念:

public static void Demo() 
  int[] arr = new int[10];
  int[] arr2 = arr; // “名”的复制,浅复制
  int[] arr3 = Arrays.copyOf(arr, arr.length); // 用库方法进行深复制

本质上来说,这个“名”就是栈空间上的一个指针,而“实”则是堆空间中的实际数据。如果取消指针概念的话,就要强行区分哪些类型是“值类型”,会完全复制,哪些是“引用类型”,只会浅复制。

C#中的结构体和类的概念恰好如此,结构体是值类型,整体复制,而类是引用类型,要用库函数来复制。

而还有一些语言保留了指针的概念(例如 Go、Swift),但仅仅用于明确指向和引用的含义,并不提供指针偏移运算,来防止出现越界问题。例如 go 中:

func Demo() 
  var a int
  var p *int
  p = &a // OK
  r1 := *p // 直接解指针是OK的
  r2 := *(p + 1) // ERR,指针不可以偏移

swift 中虽然仍然支持指针,但非常弱化了它的概念,从语法本身就能看出,不到迫不得已并不推荐使用:

func f1(_ ptr: UnsafeMutablePointer<Int>) 
  ptr.pointee += 1 // 给指针所指向的值加1


func demo() 
  var a: Int = 5
  f1(&a)

OC 中的指针更加特殊和“奇葩”,首先,OC 完全保留了 C 中的指针用法,而额外扩展的“类”类型则不允许出现在栈中,也就是说,所有对象都强制放在堆中,栈上只保留指针对其引用。虽然 OC 中的指针仍然是 C 指针,但由于操作对象的“奇葩”语法,倒是并不需要太担心指针偏移的问题。

void demo() 
  NSObject *obj = [[NSObject alloc] init];
  // 例如调用obj的description方法
  NSString *desc = [obj description];
  // 指针仍可偏移,但几乎不会有人这样来写:
  [(obj+1) description]; // 也会越界

隐式类型转换

隐式类型转换在一些场景下会让程序更加简洁,降低代码编写难度。比如说下面这些场景:

double a = 5; // int->double
int b = a * a; // double->int
int c = '5' - '0'; // char->int

但是有的时候隐式类型转化却会引发很奇怪的问题,比如说:

#define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))
void f1() 
  int arr[5];
  size_t size = ARR_SIZE(arr); // OK

void f2(int arr[]) 
  size_t size = ARR_SIZE(arr); // WRONG

结合之前所说,函数参数中的数组其实是数组首元素指针的语法糖,所以f2中的arr其实是int *类型,这时候再对其进行sizeof运算,结果是指针的大小,而并非数组的大小。如果程序员不能意识到这里发生了int [N]->int *的隐式类型转换,那么就可能出错。 还有一些隐式类型转换也很离谱,比如说:

int a = 5;
int b = a > 2; // 可能原本想写a / 2,把/写成了>

这里发生的隐式转换是 bool->int,同样可能不符合预期。关于布尔类型详见后面章节。 C 中的这些隐式转换可能影响并不算大,但拓展到 C++中就可能有爆炸性的影响,详见后面“隐式构造”和“多态转换”的相关章节。

赋值语句的返回值

C/C++的赋值语句自带返回值,这一定算得上一大缺陷,在 C 中赋值语句返回值,在 C++中赋值语句返回左值引用。

这件事造成的最大影响就在===这两个符号上,比如:

int a1, a2;
bool b = a1 = a2;

这里原本想写b = a1 == a2,但是错把==写成了=,但编译是可以完全通过的,因为a1 = a2本身返回了 a1 的引用,再触发一次隐式类型转换,把 bool 转化为 int(这里详见后面非布尔类型的布尔意义章节)。

更有可能的是写在 if 表达式中:

if (a = 1) 

可以看到,a = 1执行后 a 的值变为 1,返回的 a 的值就是 1,所以这里的if变成了恒为真。

C++为了兼容这一特性,又不得不要求自定义类型要定义赋值函数

class Test 
 public:
  Test &operator =(const Test &); // 拷贝赋值函数
  Test &operator =(Test &&); // 移动赋值函数
  Test &operator =(int a); // 其他的赋值函数
;

这里赋值函数的返回值强制要求定义为当前类型的左值引用,一来会让人觉得有些无厘头,记不住这里的写法,二来在发生继承关系的时候非常容易忘记处理父类的赋值。

class Base 
 public:
  Base &operator =(const Base &);
;

class Ch : public Base 
 public:
  Ch &opeartor =(const Ch &ch) 
    this->Base::operator =(ch);
    // 或者写成 *static_cast<Base *>(this) = ch;
    // ...
    return *this;
  
;

其他语言的赋值语句

古老一些的 C 系扩展语言基本还是保留了赋值语句的返回值(例如 java、OC),但一些新兴语言(例如 Go、Swift)则是直接取消了赋值语句的返回值,比如说在 swift 中:

let a = 5
var b: Int
var c: Int
c = (b = a) // ERR

b = a会返回Void,所以再赋值给 c 时会报错

非布尔类型的布尔意义

在原始 C 当中,其实并没有“布尔”类型,所有表示是非都是用 int 来做的。所以,int 类型就赋予了布尔意义,0 表示 false,非 0 都表示 true,由此也诞生了很多“野路子”的编程技巧:

int *p;
if (!p)  // 指针→bool

while (1)  // int→bool

int n;
while (~scanf("%d", &n))  // int→bool

所有表示判断逻辑的语法,都可以用非布尔类型的值传入,这样的写法其实是很反人类直觉的,更严重的问题就是与 true 常量比较的问题。

int judge = 2; // 用了int表示了判断逻辑
if (judge == true)  // 但这里的条件其实是false,因为true会转为1,2 == 1是false

正是由于非布尔类型具有了布尔意义,才会造成一些非常反直觉的事情,比如说:

true + true != true
!!2 == 1
(2 == true) == false

其他语言的布尔类型

基本上除了 C++和一些弱类型脚本语言(比如 js)以外,其他语言都取消了非布尔类型的布尔意义,要想转换为布尔值,一定要通过布尔运算才可以,例如在 Go 中:

func Demo() 
  a := 1 // int类型
  if (a)  // ERR,if表达式要求布尔类型
  
  if (a != 0)  // OK,通过逻辑运算得到布尔类型
  

这样其实更符合直觉,也可以一定程度上避免出现写成类似于if (a = 1)出现的问题。C++中正是由于“赋值语句有返回值”和“非布尔类型有布尔意义”同时生效,才会在这里出现问题。

解指针类型

关于 C/C++到底是强类型语言还是弱类型语言,业界一直争论不休。有人认为,变量的类型从定义后就不能改变,并且每个变量都有固定的类型,所以 C/C++应该是强类型语言。

但有人持相反意见,是因为这个类型,仅仅是“表面上”不可变,但其实是可变的,比如说看下面例程:

int a = 300;
uint8_t *p = reinterpret_cast<uint8_t *>(&a);
*p = 1; // 这里其实就是把a变成了uint8_t类型

根源就在于,指针的解类型是可以改变的,原本int类型的变量,我们只要把它的首地址保存下来,然后按照另一种类型来解,那么就可以做到“改变 a 的类型”的目的。

这也就意味着,指针类型是不安全的,因为你不一定能保证现在解指针的类型和指针指向数据的真实类型是匹配的。

还有更野一点的操作,比如:

struct S1 
  short a, b;
;

struct S2 
  int a;
;

void demo() 
  S2 s2;
  S1 *p = reinterpret_cast<S1 *>(&s2);
  p->a = 2;
  p->b = 1;

  std::cout << s2.a; // 猜猜这里会输出多少?

这里的指针类型问题和前面章节提到的指针偏移问题,综合起来就是说 C/C++的指针操作的自由度过高,提升了语言的灵活度,同时也增加了其复杂度。

后置自增/自减

如果仅仅在 C 的角度上,后置自增/自减语法并没有带来太多的副作用,有时候在程序中作为一些小技巧反而可以让程序更加精简,比如说:

void AttrCnt() 
  static int count = 0;
  std::cout << count++ << std::endl;

但这个特性继承到 C++后问题就会被放大,比如说下面的例子:

for (auto iter = ve.begin(); iter != ve.end(); iter++) 

这段代码看似特别正常,但仔细想想,iter 作为一个对象类型,如果后置++,一定会发生复制。后置++原本的目的就是在表达式的位置先返回原值,表达式执行完后再进行自增。但如果放在类类型来说,就必须要临时保存一份原本的值。例如:

class Element 
 public:
  // 前置++
  Element &operator ++() 
   ele++;
   return *this;
  
  // 后置++
  Element operator ++(int) 
    // 为了最终返回原值,所以必需保存一份快照用于返回
    Element tmp = *this;
    ele++;
    return tmp;
  
 private:
  int ele;
;

这也从侧面解释了,为什么前置++要求返回引用,而后置++则是返回非引用,因为这里需要复制一份快照用于返回。

那么,写在 for 循环中的后置++就会平白无故发生一次复制,又因为返回值没有接收,再被析构。

C++保留的++--的语义,也是因为它和+=1-=1语义并不完全等价。我们可以用顺序迭代器来解释。对于顺序迭代器(比如说链表的迭代器),++表示取下一个节点,--表示取上一个节点。而+n或者-n则表示偏移了,这种语义更适合随机访问(所以说随机迭代器支持+=-=,但顺序迭代器只支持++--)。

其他语言的自增/自减

其他语言的做法基本分两种,一种就是保留自增/自减语法,但不再提供返回值,也就不用区分前置和后置,例如 Go:

a := 3
a++ // OK
b := a++ // ERR,自增语句没有返回值

另一种就是干脆删除自增/自减语法,只提供普通的操作赋值语句,例如 Swift:

var a = 3
a++ // ERR,没有这种语法
a += 1 // OK,只能用这种方式自增

类型长度

这里说的类型长度指的是相同类型在不同环境下长度不一致的情况,下面总结表格

类型32 位环境长度64 位环境长度
int/unsigned4B4B
long/unsigned long4B8B
long long/ unsigned long long8B8B

由于这里出现了 32 位和 64 位环境下长度不一致的情况,C 语言特意提供了stdint.h头文件(C++中在 cstddef 中引用),定义了定长类型,例如int64_t在 32 位环境下其实是long long,而在 64 位环境下其实是long

但这里的问题点在于:

1. 并没有定长格式符

例如uint64_t在 32 位环境下对应的格式符是%llu,但是在 64 位环境下对应的格式符是%lu。有一种折中的解决办法是自定义一个宏:

#if(sizeof(void*) == 8)
#define u64 "%lu"
#else
#define u64 "%llu"
#endif

void demo() 
  uint64_t a;
  printf("a="u64, a);

但这样会让字符串字面量从中间断开,非常不直观。

2. 类型不一致

例如在 64 位环境下,longlong long都是 64 位长,但编译器会识别为不同类型,在一些类型推导的场景会出现和预期不一致的情况,例如:

template <typename T>
void func(T t) 

template <>
void func<int64_t>(int64_t t) 

void demo() 
  long long a;
  func(a); // 会匹配通用模板,而匹配不到特例

上述例子表明,func<int64_t>func<long long>是不同实例,尽管在 64 位环境下longlong long真的看不出什么区别,但是编译器就是会识别成不同类型。

格式化字符串

格式化字符串算是非常经典的 C 的产物,不仅是 C++,非常多的语言都是支持这种格式符的,例如 java、Go、python 等等。

但 C++中的格式化字符串可以说完全就是 C 的那一套,根本没有任何扩展。换句话说,除了基本数据类型和 0 结尾的字符串以外,其他任何类型都没有用于匹配的格式符。

例如,对于结构体类型、数组、元组类型等等,都没法匹配到格式符:

struct Point 
  double x, y;
;

void Demo() 
  // 打印Point
  Point p 1, 2.5;
  printf("(%lf,%lf)", p.x, p.y); // 无法直接打印p
  // 打印数组
  int arr[] = 1, 2, 3;
  for (int i = 0; i < 3; i++) 
    printf("%d, ", arr[i]); // 无法直接打印整个数组
  
  // 打印元组
  std::tuple tu(1, 2.5, "abc");
  printf("(%d,%lf,%s)", std::get<0>(tu), std::get<1>(tu), std::get<2>(tu)); // 无法直接打印整个元组

对于这些组合类型,我们就不得不手动去访问内部成员,或者用循环访问,非常不方便。

针对于字符串,还会有一个严重的潜在问题,比如:

std::string str = "abc";
str.push_back('\\0');
str.append("abc");

char buf[32];
sprintf(buf, "str=%s", str.c_str());

由于 str 中出现了'\\0',如果用%s格式符来匹配的话,会在 0 的位置截断,也就是说buf其实只获取到了str中的第一个 abc,第二个 abc 就被丢失了。

其他语言中的格式符

而一些其他语言则是扩展了格式符功能用于解决上述问题,例如 OC 引入了%@格式符,用于调用对象的description方法来拼接字符串:

@interface Point2D : NSObject
@property double x;
@property double y;
- (NSString *)description;
@end

@implementation Point2D
- (NSString *)description 
  return [[NSString alloc] initWithFormat:@"(%lf, %lf)", self.x, self.y];

@end

void Demo() 
  Point2D *p = [[Point2D alloc] init];
  [p setX:1];
  [p setY:2.5];
  NSLog(@"p=%@", p); // 会调用p的description方法来获取字符串,用于匹配%@

而 Go 语言引入了更加方便的%v格式符,可以用来匹配任意类型,用它的默认方式打印。

type Test struct 
 m1 int
 m2 float32


func Demo() 
  a1 := 5
  a2 := 2.6
  a3 := []int1, 2, 3
  a4 := "123abc"
  a5 := Test2, 1.5

  fmt.Printf("a1=%v, a2=%v, a3=%v, a4=%v, a5=%v\\n", a1, a2, a3, a4, a5)

Python 则是用%s作为万能格式符来使用:

def Demo():
     a1 = 5
     a2 = 2.5
     a3 = "abc123"
     a4 = [1, 2, 3]
     print("%s, %s, %s, %s"%(a1, a2, a3, a4)) #这里没有特殊格式要求时都可以用%s来匹配

枚举

枚举类型原本是用于解决固定范围取值的类型表示,但由于在 C 语言中被定义为了整型类型的一种语法糖,导致枚举类型的使用上出现了一些问题。

1. 无法前置声明

枚举类型无法先声明后定义,例如下面这段代码会编译报错:

enum Season;

struct Data 
  Season se; // ERR
;

enum Season 
  Spring,
  Summer,
  Autumn,
  Winter
;

主要是因为enum类型是动态选择基础类型的,比如这里只有 4 个取值,那么可能会选取int16_t,而如果定义的取值范围比较大,或者中间出现大枚举值的成员,那么可能会选取int32_t或者int64_t。也就是说,枚举类型如果没定义完,编译期是不知道它的长度的,因此就没法前置声明。

C++中允许指定枚举的基础类型,制定后可以前置声明:

enum Season : int;

struct Data 
  Season se; // OK
;

enum Season : int 
  Spring,
  Summer,
  Autumn,
  Winter
;

但如果你是在调别人写的库的时候,人家的枚举没有指定基础类型的话,那你也没辙了,就是不能前置声明。

2. 无法确认枚举值的范围

也就是说,我没有办法判断某个值是不是合法的枚举值:

enum Season 
  Spring,
  Summer,
  Autumn,
  Winter
;

void Demo() 
  Season s = static_cast<Season>(5); // 不会报错

3. 枚举值可以相同

enum Test 
  Ele1 = 10,
  Ele2,
  Ele3 = 10
;

void Demo() 
  bool judge = (Ele1 == Ele3); // true

4. C 风格的枚举还存在“成员名称全局有效”和“可以隐式转换为整型”的缺陷

但因为 C++提供了enum class风格的枚举类型,解决了这两个问题,因此这里不再额外讨论。

宏这个东西,完全就是针对编译器友好的,编译器非常方便地在宏的指导下,替换源代码中的内容。但这个玩意对程序员(尤其是阅读代码的人)来说是极其不友好的,由于是预处理指令,因此任何的静态检测均无法生效。一个经典的例子就是:

#define MUL(x, y) x * y

void Demo() 
  int a = MUL(1 + 2, 3 + 4); // 11

因为宏就是简单粗暴地替换而已,并没有任何逻辑判断在里面。

宏因为它很“好用”,所以非常容易被滥用,下面列举了一些宏滥用的情况供参考:

1. 用宏来定义类成员

#define DEFAULT_MEM     \\
public:                 \\
int GetX() return x_; \\
private:                \\
int x_;

class Test 
DEFAULT_MEM;
 public:
  void method();
;

这种用法相当于屏蔽了内部实现,对阅读者非常不友好,与此同时加不加 DEFAULT_MEM 是一种软约束,实际开发时极容易出错。

再比如这种的:

#define SINGLE_INST(class_name)                        \\
 public:                                               \\
  static class_name &GetInstance()                    \\
    static class_name instance;                        \\
    return instance;                                   \\
                                                      \\
  class_name(const class_name&) = delete;              \\
  class_name &operator =(const class_name &) = delete; \\
 private:                                              \\
  class_name();

class Test 
  SINGLE_INST(Test)
;

这位同学,我理解你是想封装一下单例的实现,但咱是不是可以考虑一下更好的方式?(比如用模板)

2. 用宏来屏蔽参数

#define strcpy_s(dst, dst_buf_size, src) strcpy(dst, src)

这位同学,咱要是真想写一个安全版本的函数,咱就好好去判断 dst_buf_size 如何?

3. 用宏来拼接函数处理

#define COPY_IF_EXSITS(dst, src, field) \\
do                                     \\
  if (src.has_##field())               \\
    dst.set_##field(dst.field());       \\
                                       \\
 while (false)

void Demo() 
  Pb1 pb1;
  Pb2 pb2;

  COPY_IF_EXSITS(pb2, pb1, f1);
  COPY_IF_EXSITS(pb2, pb1, f2);

这种用宏来做函数名的拼接看似方便,但最容易出的问题就是类型不一致,加入pb1pb2中虽然都有f1这个字段,但类型不一样,那么这样用就可能造成类型转换。试想pb1.f1uint64_t类型,而pb2.f1uint32_t类型,这样做是不是有可能造成数据的截断呢?

4. 用宏来改变语法风格

#define IF(con) if (con) 
#define END_IF 
#define ELIF(con)  else if (con) 
#define ELSE  else 

void Demo() 
  int a;
  IF(a > 0)
    Process1();
  ELIF(a < -3)
    Process2();
  ELSE
    Process3();

这位同学你到底是写 python 写惯了不适应 C 语法呢,还是说你为了让代码扫描工具扫不出来你的圈复杂度才出此下策的呢~~

共合体

共合体的所有成员共用内存空间,也就是说它们的首地址相同。在很多人眼中,共合体仅仅在“多选一”的场景下才会使用,例如:

union QueryKey 
  int id;
  char name[16];
;

int Query(const QueryKey &key);

上例中用于查找某个数据的 key,可以通过 id 查找,也可以通过 name,但只能二选一。

这种场景确实可以使用共合体来节省空间,但缺点在于,共合体的本质就是同一个数据的不同解类型,换句话说,程序是不知道当前的数据是什么类型的,共合体的成员访问完全可以用更换解指针类型的方式来处理,例如:

union Un 
  int m1;
  unsigned char m2;
;

void Demo() 
  Un un;
  un.m1 = 888;
  std::cout << un.m2 << std::endl;
  // 等价于
  int n1 = 888;
  std::cout << *reinterpret_cast<unsigned char *>(&n1) << std::endl;

共合体只不过把有可能需要的解类型提前写出来罢了。所以说,共合体并不是用来“多选一”的,笔者认为这是大家曲解的用法。毕竟真正要做到“多选一”,你就得知道当前选的是哪一个,例如:

struct QueryKey 
  union 
    int id;
    char name[16];
   key;
  enum 
    kCaseId,
    kCaseName
   key_case;
;

用过 google protobuf 的读者一定很熟悉上面的写法,这个就是 proto 中oneof语法的实现方式。

在 C++17 中提供了std::variant,正是为了解决“多选一”问题存在的,它其实并不是为了代替共合体,因为共合体原本就不是为了这种需求的,把共合体用做“多选一”实在是有点“屈才”了。

更加贴合共合体本意的用法,是我最早是在阅读处理网络报文的代码中看到的,例如某种协议的报文有如下规定(例子是我随便写的):

二进制位意义
0~3协议版本号
4~5超时时间
6协商次数
7保留位,固定 为 0
8~15业务数据

这里能看出来,整个报文有 2 字节,一般的处理时,我们可能只需要关注这个报文的这 2 个字节值是多少(比如说用十六进制表示),而在排错的时候,才会关注报文中每一位的含义,因此,“整体数据”和“内部数据”就成为了这段报文的两种获取方式,这种场景下非常适合用共合体:

union Pack 
  uint16_t data; // 直接操作报文数据
  struct 
    unsigned version : 4;
    unsigned timeout : 2;
    unsigned retry_times : 1;
    unsigned block : 1;
    uint8_t bus_data;
   part; // 操作报文内部数据
;

void Demo() 
  // 例如有一个从网络获取到的报文
  Pack pack;
  GetPackFromNetwork(pack);
  // 打印一下报文的值
  std::printf("%X", pack.data);
  // 更改一下业务数据
  pack.part.bus_data = 0xFF;
  // 把报文内容扔到处理流中
  DataFlow() << pack.data;

因此,这里的需求就是“用两种方式来访问同一份数据”,才是完全符合共合体定义的用法。

共合体应该是 C 语言的特色了,其他任何高级语言都没有类似的语法,主要还是因为 C 语言更加面相底层,C++仅仅是继承了 C 的语法而已。

const 引用

先说说 const

先来吐槽一件事,就是 C/C++中const这个关键字,这个名字起的非常非常不好!为什么这样说呢?const 是 constant 的缩写,翻译成中文就是“常量”,但其实在 C/C++中,const并不是表示“常量”的意思。

我们先来明确一件事,什么是“常量”,什么是“变量”?常量其实就是衡量,比如说1就是常量,它永远都是这个值。再比如'A'就是个常量,同样,它永远都是和它 ASCII 码对应的值。 “变量”其实是指存储在内存当中的数据,起了一个名字罢了。如果我们用汇编,则不存在“变量”的概念,而是直接编写内存地址:

mov ax, 05FAh
mov ds, ax
mov al, ds:[3Fh]

但是这个05FA:3F地址太突兀了,也很难记,另一个缺点就是,内存地址如果固定了,进程加载时动态分配内存的操作空间会下降(尽管可以通过相对内存的方式,但程序员仍需要管理偏移地址),所以在略高级一点的语言中,都会让程序员有个更方便的工具来管理内存,最简单的方法就是给内存地址起个名字,然后编译器来负责翻译成相对地址。

int a; // 其实就是让编译器帮忙找4字节的连续内存,并且起了个名字叫a

所以“变量”其实指“内存变量”,它一定拥有一个内存地址,和可变不可变没啥关系。

因此,C 语言中const用于修饰的一定是“变量”,来控制这个变量不可变而已。用const修饰的变量,其实应当说是一种“只读变量”,这跟“常量”根本挨不上。

这就是笔者吐槽这个const关键字的原因,你叫个read_only之类的不是就没有歧义了么?

C#就引入了readonly关键字来表示“只读变量”,而const则更像是给常量取了个别名(可以类比为 C++中的宏定义,或者constexpr,后面章节会详细介绍constexpr):

const int pi = 3.14159; // 常量的别名
readonly int[] arr = new int[]1, 2, 3; // 只读变量

左右值

C++由于保留了 C 当中的const关键字,但更希望表达其“不可变”的含义,因此着重在“左右值”的方向上进行了区分。左右值的概念来源于赋值表达式:

var = val; // 赋值表达式

赋值表达式的左边表示即将改变的变量,右边表示从什么地方获取这个值。因此,很自然地,右值不会改变,而左值会改变。那么在这个定义下,“常量”自然是只能做右值,因为常量仅仅有“值”,并没有“存储”或者“地址”的概念。而对于变量而言,“只读变量”也只能做右值,原因很简单,因为它是“只读”的。

虽然常量和只读变量是不同的含义,但它们都是用来“读取值”的,也就是用来做右值的,所以,C++引入了“const 引用”的概念来统一这两点。所谓 const 引用包含了 2 个方面的含义:

  1. 作为只读变量的引用(指针的语法糖)

  2. 作为只读变量

换言之,const 引用可能是引用,也可能只是个普通变量,如何理解呢?请看例程:

void Demo() 
  const int a = 5; // a是一个只读变量
  const int &r1 = a; // r1是a的引用,所以r1是引用
  const int &r2 = 8; // 8是一个常量,因此r2并不是引用,而是一个只读变量

也就是说,当用一个 const 引用来接收一个变量的时候,这时的引用是真正的引用,其实在r1内部保存了a的地址,当我们操作r的时候,会通过解指针的语法来访问到a

const int a = 5;

const int &r1 = a;
std::cout << r1;
// 等价于
const int *p1 = &a; // 引用初始化其实是指针的语法糖
std::cout << *p1; // 使用引用其实是解指针的语法糖

但与此同时,const 引用还可以接收常量,这时,由于常量根本不是变量,自然也不会有内存地址,也就不可能转换成上面那种指针的语法糖。那怎么办?这时,就只能去重新定义一个变量来保存这个常量的值了,所以这时的 const 引用,其实根本不是引用,就是一个普通的只读变量。

const int &r1 = 8;
// 等价于
const int c1 = 8; // r1其实就是个独立的变量,而并不是谁的引用

思考

const 引用的这种设计,更多考虑的是语义上的,而不是实现上的。如果我们理解了 const 引用,那么也就不难理解为什么会有“将亡值”和“隐式构造”的问题了,因为搭配 const 引用,可以实现语义上的统一,但代价就是同一语法可能会做不同的事,会令人有疑惑甚至对人有误导。

在后面“右值引用”和“因式构造”的章节会继续详细介绍它们和 const 引用的联动,以及可能出现的问题。

右值引用与移动语义

C++11 的右值引用语法的引入,其实也完全是针对于底层实现的,而不是针对于上层的语义友好。换句话说,右值引用是为了优化性能的,而并不是让程序变得更易读的。

右值引用

右值引用跟 const 引用类似,仍然是同一语法不同意义,并且右值引用的定义强依赖“右值”的定义。根据上一节对“左右值”的定义,我们知道,左右值来源于赋值语句,常量只能做右值,而变量做右值时仅会读取,不会修改。按照这个定义来理解,“右值引用”就是对“右值”的引用了,而右值可能是常量,也可能是变量,那么右值引用自然也是分两种情况来不同处理:

  1. 右值引用绑定一个常量

  2. 右值引用绑定一个变量

我们先来看右值引用绑定常量的情况:

int &&r1 = 5; // 右值引用绑定常量

和 const 引用一样,常量没有地址,没有存储位置,只有值,因此,要把这个值保存下来的话,同样得按照“新定义变量”的形式,因此,当右值引用绑定常量时,相当于定义了一个普通变量:

int &&r1 = 5;
// 等价于
int v1 = 5; // r1就是个普通的int变量而已,并不是引用

所以这时的右值引用并不是谁的引用,而是一个普普通通的变量。

我们再来看看右值引用绑定变量的情况: 这里的关键问题在于,什么样的变量适合用右值引用绑定? 如果对于普通的变量,C++不允许用右值引用来绑定,但这是为什么呢?

int a = 3;
int &&r = a; // ERR,为什么不允许右值引用绑定普通变量?

我们按照上面对左右值的分析,当一个变量做右值时,该变量只读,不会被修改,那么,“引用”这个变量自然是想让引用成为这个变量的替身,而如果我们希望这里做的事情是“当通过这个引用来操作实体的时候,实体不能被改变”的话,使用 const 引用就已经可以达成目的了,没必要引入一个新的语法。

所以,右值引用并不是为了让引用的对象只能做右值(这其实是 const 引用做的事情),相反,右值引用本身是可以做左值的。这就是右值引用最迷惑人的地方,也是笔者认为“右值引用”这个名字取得迷惑人的地方。

右值引用到底是想解决什么问题呢?请看下面示例:

struct Test  // 随便写一个结构体,大家可以脑补这个里面有很多复杂的成员
  int a, b;
;

Test GetAnObj()  // 一个函数,返回一个结构体类型
  Test t 1, 2;  // 大家可以脑补这里面做了一些复杂的操作
  return t; // 最终返回了这个对象


void Demo() 
  Test t1 = GetAnObj();

我们忽略编译器的优化问题,只分析 C++语言本身。在GetAnObj函数内部,t是一个局部变量,局部变量的生命周期是从创建到当前代码块结束,也就是说,当GetAnObj函数结束时,这个t一定会被释放掉

既然这个局部变量会被释放掉,那么函数如何返回呢?这就涉及了“值赋值”的问题,假如t是一个整数,那么函数返回的时候容易理解,就是返回它的值。具体来说,就是把这个值推到寄存器中,在跳转会调用方代码的时候,再把寄存器中的值读出来:

int f1() 
  int t = 5;
  return t;

翻译成汇编就是:

push    rbp
mov     rbp, rsp
mov     DWORD PTR [rbp-4], 5     ; 这里[rbp-4]就是局部变量t
mov     eax, DWORD PTR [rbp-4]   ; 把t的值放到eax里,作为返回值
pop     rbp
ret

之所以能这样返回,主要就是 eax 放得下 t 的值。但如果 t 是结构体的话,一个 eax 寄存器自然是放不下了,那怎么返回?(这里汇编代码比较长,而且跟编译器的优化参数强相关,就不放代码了,有兴趣的读者可以自己汇编看结果。)简单来说,因为寄存器放不下整个数据,这个数据就只能放到内存中,作为一个临时区域,然后在寄存器里放一个临时区域的内存地址。等函数返回结束以后,再把这个临时区域释放掉。

那么我们再回来看这段代码:

struct Test 
  int a, b;
;

Test GetAnObj() 
  Test t 1, 2;
  return t; // 首先开辟一片临时空间,把t复制过去,再把临时空间的地址写入寄存器
 // 代码块结束,局部变量t被释放

void Demo() 
  Test t1 = GetAnObj(); // 读取寄存器中的地址,找到临时空间,再把临时空间的数据复制给t1
  // 函数调用结束,临时空间释放

那么整个过程发生了 2 次复制和 2 次释放,如果我们按照程序的实际行为来改写一下代码,那么其实应该是这样的:

struct Test 
  int a, b;
;

void GetAnObj(Test *tmp)  // tmp要指向临时空间
  Test t1, 2;
  *tmp = t; // 把t复制给临时空间
  // 代码块结束,局部变量t被释放

void Demo() 
  Test *tmp = (Test *)malloc(sizeof(Test)); // 临时空间
  GetAnObj(tmp); // 让函数处理临时空间的数据
  Test t1 = *tmp; // 把临时空间的数据复制给这里的局部变量t1
  free(tmp); // 释放临时空间

如果我真的把代码写成这样,相信一定会被各位前辈骂死,质疑我为啥不直接用出参。的确,用出参是可以解决这种多次无意义复制的问题,所以 C++11 以前并没有要去从语法层面来解决,但这样做就会让代码不得不“面相底层实现”来编程。C++11 引入的右值引用,就是希望从“语法层面”解决这种问题。

试想,这片非常短命的临时空间,究竟是否有必要存在?既然这片空间是用来返回的,返回完就会被释放,那我何必还要单独再搞个变量来接收,如果这片临时空间可以持续使用的话,不就可以减少一次复制吗?于是,“右值引用”的概念被引入。

struct Test 
  int a, b;
;

Test GetAnObj() 
  Test t 1, 2;
  return t; // t会复制给临时空间


void Demo() 
  Test &&t1 = GetAnObj(); // 我设法引用这篇临时空间,并且让他不要立刻释放
  // 临时空间被t1引用了,并不会立刻释放
 // 等代码块结束,t1被释放了,才让临时空间释放

所以,右值引用的目的是为了延长临时变量的生命周期,如果我们把函数返回的临时空间中的对象视为“临时对象”的话,正常情况下,当函数调用结束以后,临时对象就会被释放,所以我们管这个短命的对象叫做“将亡对象”,简单粗暴理解为“马上就要挂了的对象”,它的使命就是让外部的t1复制一下,然后它就死了,所以这时候你对他做什么操作都是没意义的,他就是让人来复制的,自然就是个只读的值了,所以才被归结为“右值”。我们为了让它不要死那么快,而给它延长了生命周期,因此使用了右值引用。所以,右值引用是不是应该叫“续命引用”更加合适呢~

当用右值引用捕获一个将亡对象的时候,对象的生命周期从“将亡”变成了“与右值引用共存亡”,这就是右值引用的根本意义,这时的右值引用就是“将亡对象的引用”,又因为这时的将亡对象已经不再“将亡”了,那它既然不再“将亡”,我们再对它进行操作(改变成员的值)自然就是有意义的啦,所以,这里的右值引用其实就等价于一个普通的引用而已。既然就是个普通的引用,而且没用 const 修饰,自然,可以做左值咯。右值引用做左值的时候,其实就是它所指对象做左值而已。不过又因为普通引用并不会影响原本对象的生命周期,但右值引用会,因此,右值引用更像是一个普通的变量,但我们要知道,它本质上还是引用(底层是指针实现的)。

总结来说就是,右值引用绑定常量时相当于“给一个常量提供了生命周期”,这时的“右值引用”并不是谁的引用,而是相当于一个普通变量;而右值引用绑定将亡对象时,相当于“给将亡对象延长了生命周期”,这时的“右值引用”并不是“右值的引用”,而是“对需要续命的对象”的引用,生命周期变为了右值引用本身的生命周期(或者理解为“接管”了这个引用的对象,成为了一个普通的变量)。

const 引用绑定将亡对象

需要知道的是,const 引用也是可以绑定将亡对象的,正如上文所说,既然将亡对象定义为了“右值”,也就是只读不可变的,那么自然就符合 const 引用的语义。

// 省略Test的定义,见上节
void Demo() 
  const Test &t1 = GetAnObj(); // OK

这样看来,const 引用同样可以让将亡对象延长生命周期,但其实这里的出发点并不同,const 引用更倾向于“引用一个不可变的量”,既然这里的将亡对象是一个“不可变的值”,那么,我就可以用 const 引用来保存“这个值”,或者这里的“值”也可以理解为这个对象的“快照”。所以,当一个 const 引用绑定一个将亡值时,const 引用相当于这个对象的“快照”,但背后还是间接地延长了它的生命周期,但只不过是不可变的。

移动语义

在解释移动语义之前,我们先来看这样一个例子:

class Buffer final 
 public:
  Buffer(size_t size);
  Buffer(const Buffer &ob);
  ~Buffer();
  int &at(size_t index);
 private:
  size_t buf_size_;
  int *buf_;
;

Buffer::Buffer(size_t size) : buf_size_(size), buf_(malloc(sizeof(int) * size)) 
Buffer::Buffer(const Buffer &ob) :buf_size_(ob.buf_size_),
                                  buf_(malloc(sizeof(int) * ob.buf_size_)) 
  memcpy(buf_, ob.buf_, ob.buf_size_);

Buffer::~Buffer() 
  if (buf_ != nullptr) 
    free(buf_);
  

int &Buffer::at(size_t index) 
  return buf_[index];


void ProcessBuf(Buffer buf) 
  buf.at(2) = 100; // 对buf做一些操作


void Demo() 
  ProcessBuf(Buffer16); // 创建一个16个int的buffer

上面这段代码定义了一个非常简单的缓冲区处理类,ProcessBuf函数想做的事是传进来一个 buffer,然后对这个 buffer 做一些修改的操作,最后可能把这个 buffer 输出出去之类的(代码中没有体现,但是一般业务肯定会有)。

如果像上面这样写,会出现什么问题?不难发现在于ProcessBuf的参数,这里会发生复制。由于我们在Buffer类中定义了拷贝构造函数来实现深复制,那么任何传入的 buffer 都会在这里进行一次拷贝构造(深复制)。再观察Demo中调用,仅仅是传了一个临时对象而已。临时对象本身也是将亡对象,复制给buf后,就会被释放,也就是说,我们进行了一次无意义的深复制。 有人可能会说,那这里参数用引用能不能解决问题?比如这样:

void ProcessBuf(Buffer &buf) 
  buf.at(2) = 100;


void Demo() 
  ProcessBuf(Buffer16); // ERR,普通引用不可接收将亡对象

所以这里需要我们注意的是,C++当中,并不只有在显式调用=的时候才会赋值,在函数传参的时候仍然由赋值语义(也就是实参赋值给形参)。所以上面就相当于:

Buffer &buf = Buffer16; // ERR

所以自然不合法。那,用 const 引用可以吗?由于 const 引用可以接收将亡对象,那自然可以用于传参,但ProcessBuf函数中却对对象进行了修改操作,所以 const 引用不能满足要求:

void ProcessBuf(const Buffer &buf) 
  buf.at(2) = 100; // 但是这里会报错


void Demo() 
  ProcessBuf(Buffer16); // 这里确实OK了

正如上一节描述,const 引用倾向于表达“保存快照”的意义,因此,虽然这个对象仍然是放在内存中的,但 co

以上是关于终极 C++避坑指南|6万字长文的主要内容,如果未能解决你的问题,请参考以下文章

万字避坑指南!C++的缺陷与思考(下)

终极 C++避坑指南

万字长文!动态规划的终极难题:字符匹配类

研发效能度量实践者指南(万字长文)

1.5万字长文:从 C# 入门 Kafka(Kafka .NET 基础)

万字长文复习HTTP