乐趣区

关于c++:万字避坑指南C的缺陷与思考上

导语 | 本文次要总结了自己在 C ++ 开发过程中对一些奇怪、简单的语法的了解和思考,同时作为 C ++ 开发的避坑指南。

前言

C++ 是一门古老的语言,但依然在不间断更新中,一直援用新个性。但与此同时 C ++ 又甩不掉微小的历史包袱,并且 C ++ 的设计初衷和理念造成了 C ++ 异样简单,还呈现了很多不合理的“缺点”。本文次要有 3 个目标:

  • 总结一些 C ++ 艰涩难懂的语法景象,解释其背地起因,作为防踩坑之用。
  • 和一些其余的编程语言进行比拟,列举它们的优劣。
  • 发表一些我本人作为 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 件事:

  • 提供一种隐式类型转换,反对将数组类型转换为首元素指针类型(比如说这里 arr 是 int[5]类型,传参时主动转换为 int* 类型)
  • 函数参数的语法糖,如果在函数参数写数组类型,那么会主动转换成元素指针类型,比如说上面这几种写法都齐全等价:
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 格调数组的起因。

类型说明符

(一)类型不是从左向右阐明

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 位环境下长度不统一的状况,C 语言特意提供了 stdint.h 头文件(C++ 中在 cstddef 中援用),定义了定长类型,例如 int64\_t 在 32 位环境下其实是 long long,而在 64 位环境下其实是 long。

但这里的问题点在于:

  • 并没有定长格局符

例如 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);
}

但这样会让字符串字面量从两头断开,十分不直观。

  • 类型不统一

例如在 64 位环境下,long 和 long 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 位环境下 long 和 long 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 := []int{1, 2, 3}
  a4 := "123abc"
  a5 := Test{2, 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 语言中被定义为了整型类型的一种语法糖,导致枚举类型的应用上呈现了一些问题。

  • 无奈前置申明

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

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
};

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

  • 无奈确认枚举值的范畴

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

enum Season {
  Spring,
  Summer,
  Autumn,
  Winter
};

void Demo() {Season s = static_cast<Season>(5); // 不会报错
}
  • 枚举值能够雷同
enum Test {
  Ele1 = 10,
  Ele2,
  Ele3 = 10
};

void Demo() {bool judge = (Ele1 == Ele3); // true
}
  • C 格调的枚举还存在“成员名称全局无效”和“能够隐式转换为整型”的缺点

但因为 C ++ 提供了 enum class 格调的枚举类型,解决了这两个问题,因而这里不再额定探讨。

(一)宏

宏这个货色,齐全就是针对编译器敌对的,编译器十分不便地在宏的领导下,替换源代码中的内容。但这个玩意对程序员(尤其是浏览代码的人)来说是极其不敌对的,因为是预处理指令,因而任何的动态检测均无奈失效。一个经典的例子就是:

#define MUL(x, y) x * y

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

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

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

  • 用宏来定义类成员
#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)
};

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

  • 用宏来屏蔽参数
#define strcpy_s(dst, dst_buf_size, src) strcpy(dst, src)

这位同学,咱要是真想写一个平安版本的函数,咱就好好去判断 dst\_buf\_size 如何?

  • 用宏来拼接函数解决
#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);
}

这种用宏来做函数名的拼接看似不便,但最容易出的问题就是类型不统一,退出 pb1 和 pb2 中尽管都有 f1 这个字段,但类型不一样,那么这样用就可能造成类型转换。试想 pb1.f1 是 uint64\_t 类型,而 pb2.f1 是 uint32\_t 类型,这样做是不是有可能造成数据的截断呢?

  • 用宏来扭转语法格调
#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,正是为了解决“多选一”问题存在的,它其实并不是为了代替共合体,因为共合体本来就不是为了这种需要的,把共合体用做“多选一”切实是有点“屈才”了。

更加贴合共合体本意的用法,是我最早是在浏览解决网络报文的代码中看到的,例如某种协定的报文有如下规定(例子仅供参考):

这里能看进去,整个报文有 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 个方面的含意:

  • 作为只读变量的援用(指针的语法糖)
  • 作为只读变量

换言之,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 援用相似,依然是同一语法不同意义,并且右值援用的定义强依赖“右值”的定义。依据上一节对“左右值”的定义,咱们晓得,左右值来源于赋值语句,常量只能做右值,而变量做右值时仅会读取,不会批改。依照这个定义来了解,“右值援用”就是对“右值”的援用了,而右值可能是常量,也可能是变量,那么右值援用天然也是分两种状况来不同解决:

  • 右值援用绑定一个常量
  • 右值援用绑定一个变量

咱们先来看右值援用绑定常量的状况:

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 t{1, 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(Buffer{16}); // 创立一个 16 个 int 的 buffer
}

下面这段代码定义了一个非常简单的缓冲区解决类,ProcessBuf 函数想做的事是传进来一个 buffer,而后对这个 buffer 做一些批改的操作,最初可能把这个 buffer 输入进来之类的(代码中没有体现,然而个别业务必定会有)。

如果像下面这样写,会呈现什么问题?不难发现在于 ProcessBuf 的参数,这里会产生复制。因为咱们在 Buffer 类中定义了拷贝构造函数来实现深复制,那么任何传入的 buffer 都会在这里进行一次拷贝结构(深复制)。再察看 Demo 中调用,仅仅是传了一个长期对象而已。长期对象自身也是将亡对象,复制给 buf 后,就会被开释,也就是说,咱们进行了一次无意义的深复制。有人可能会说,那这里参数用援用能不能解决问题?比方这样:

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

void Demo() {ProcessBuf(Buffer{16}); // ERR,一般援用不可接管将亡对象
}

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

Buffer &buf = Buffer{16}; // ERR

所以天然不非法。那,用 const 援用能够吗?因为 const 援用能够接管将亡对象,那天然能够用于传参,但 ProcessBuf 函数中却对对象进行了批改操作,所以 const 援用不能满足要求:

void ProcessBuf(const Buffer &buf) {buf.at(2) = 100; // 然而这里会报错
}

void Demo() {ProcessBuf(Buffer{16}); // 这里的确 OK 了
}

正如上一节形容,const 援用偏向于表白“保留快照”的意义,因而,尽管这个对象依然是放在内存中的,但 const 援用并不心愿它产生扭转(否则就不叫快照了),因而,这里最合适的,依然是右值援用:

void ProcessBuf(Buffer &&buf) {buf.at(2) = 100; // 右值援用实现绑定后,相当于一般援用,所以这里操作 OK
}

void Demo() {ProcessBuf(Buffer{16}); // 用右值援用绑定将亡对象,OK
}

咱们再来看上面的场景:

void Demo() {Buffer buf1{16};
  // 对 buf 进行一些操作
  buf1.at(2) = 50;

  // 再把 buf 传给 ProcessBuf
  ProcessBuf(buf1); // ERR,相当于 Buffer &&buf= buf1; 右值援用绑定非将亡对象
}

因为右值援用是要来绑定将亡对象的,但这里的 buf1 是 Demo 函数的局部变量,并不是将亡的,所以右值援用不能承受。但如果我有这样的需要,就是说 buf1 我不打算用了,我想把它的控制权交给 ProcessBuf 函数中的 buf,相当于,我被动让 buf1 提前“亡”,是否能够强制把它弄成将亡对象呢?STL 提供了 std::move 函数来实现这件事,“冀望强制把一个对象变成将亡对象”:

void Demo() {Buffer buf1{16};
  // 对 buf 进行一些操作
  buf1.at(2) = 50;

  // 再把 buf 传给 ProcessBuf
  ProcessBuf(std::move(buf1)); // OK,强制让 buf1 将亡,那么右值援用就能够接管
} // 但如果读者尝试的话,在这里会出 ERROR

std::move 的本意是提前让一个对象“将亡”,而后把控制权“移交”给右值援用,所以才叫「move」,也就是“挪动语义”。但很惋惜,C++ 并不能真正让一个对象提前“亡”,所以这里的“挪动”仅仅是“语义”上的,并不是理论的。如果咱们看一下 std::move 的实现就晓得了:

template <typename T>
constexpr std::remove_reference_t<T> &&move(T &&ref) noexcept {return static_cast<std::remove_reference_t<T> &&>(ref);
}

如果这里参数中的 && 符号让你懵了的话,能够参考前面“援用折叠”的内容,如果对其余乌七八糟的语法还是没整明确的话,没关系,我来简化一下:

template <typename T>
T &&move(T &ref) {return static_cast<T &&>(ref);
}

哈?就这么简略?是的!真的就这么简略,这个 std::move 不是什么多高大上的解决,就是简略把一般援用给强制转换成了右值援用,就这么简略。

所以,我上线才说“C++ 并不能真正让一个对象提前亡”,这里的 std::move 就是跟编译器玩了一个文字游戏罢了。

所以,C++ 的挪动语义仅仅是在语义上,在应用时必须要留神,一旦将一个对象 move 给了一个右值援用,那么不能够再操作本来的对象,但这种束缚是一种软束缚,操作了也并不会有报错,然而就可能会呈现奇怪的问题。

(四)挪动结构、挪动赋值

有了右值援用和挪动语义,C++ 还引入了挪动结构和挪动赋值,这里简略来解释一下:

void Demo() {Buffer buf1{16};

  Buffer buf2(std::move(buf1)); // 把 buf1 强制“亡”,但用它的“遗体”结构新的 buf2

  Buffer buf3{8};
  buf3 = std::move(buf2); // 把 buf2 强制“亡”,把“遗体”转交个 buf3,buf3 本来的货色不要了
}

为了解决用一个将亡对象来结构 / 赋值另一个对象的状况,引入了挪动结构和挪动赋值函数,既然是用一个将亡对象,那么参数天然是右值援用来接管了。

class Buffer final {
 public:Buffer(size_t size);
  Buffer(const Buffer &ob);
  Buffer(Buffer &&ob); // 挪动构造函数
  ~Buffer();
  Buffer &operator =(Buffer &&ob); // 挪动赋值函数
  int &at(size_t index);
 private:size_t buf_size_;
  int *buf_;
};

这里次要思考的问题是,既然是用将亡对象来结构新对象,那么咱们该当尽可能多得利用将亡对象的“遗体”,在将亡对象中有一个 buf\_指针,指向了一片堆空间,那这片堆空间就能够间接利用起来,而不必再复制一份了,因而,挪动结构和挪动赋值应该这样实现:

Buffer::Buffer(Buffer &&ob) : buf_size_(ob.buf_size_), // 根本类型数据,只能简略拷贝了
                              buf_(ob.buf_) { // 间接把 ob 中指向的堆空间接管过去
    // 为了避免 ob 中的空间被反复开释,将其置空
    ob.buf_ = nullptr;
}

Buffer &Buffer::operator =(Buffer &&ob) {
  // 先把本人原来持有的空间开释掉
  if (buf_ != nullptr) {free(buf_);
  }
  // 而后继承 ob 的 buf_
  buf_ = ob.buf_;
  // 为了避免 ob 中的空间被反复开释,将其置空
  ob.buf_ = nullptr;
}

仔细的读者应该能发现,所谓的“挪动结构 / 赋值”,其实就是一个“浅复制”而已。当呈现挪动语义的时候,咱们设想中是“把旧对象里的货色 挪动 到新对象中”,但其实没法做到这种挪动,只能是“把旧对象援用的货色转为新对象来援用”,实质就是一次浅复制。

援用折叠

援用折叠指的是在模板参数以及 auto 类型推导时遇到多重援用时进行的映射关系,咱们先从最简略的例子来说:

template <typename T>
void f(T &t) {
}


void Demo() {
  int a = 3;
  
  f<int>(a);
  f<int &>(a);
  f<int &&>(a);
}

当 T 实例化为 int 时,函数变成了:

void f(int &t);

但如果 T 实例化为 int& 和 int && 时呢?难道是这样吗?

void f(int & &t);
void f(int && &t);

咱们发现,这种状况下编译并没有出错,T 自身带援用时,再跟参数前面的援用符联合,也是能够失常通过编译的。这就是所谓的援用折叠,简略了解为“两个援用撞一起了,以谁为准”的问题。援用折叠满足上面法则:

左值援用短路右值援用

简略来说就是,除非是两个右值援用遇到一起,会推导出右值援用以外,其余状况都会推导出左值援用,所以是左值援用优先。

& + & -> &
& + && -> &
&& + & -> &
&& + && -> &&

(一)auto &&

这种法则同样同样实用于 auto &&,当 auto && 遇到左值时会推导出左值援用,遇到右值时才会推导出右值援用:

auto &&r1 = 5; // 绑定常量,推导出 int &&
int a;
auto &&r2 = a; // 绑定变量,推导出 int &
int &&b = 1;
auto &&r3 = b; // 右值援用一旦绑定,则相当于一般变量,所以绑定变量,推导出 int &

因为 & 比 && 优先级高,因而 auto & 肯定推出左值援用,如果用 auto & 绑定常量或将亡对象则会报错:

auto &r1 = 5; // ERR,左值援用不能绑定常量
auto &r2 = GetAnObj(); // ERR,左值援用不能绑定将亡对象
int &&b = 1;
auto &r3 = b; // OK,左值援用能够绑定右值援用(因为右值援用一旦绑定后,相当于左值)auto &r4 = r3; // OK,左值援用能够绑定左值援用(相当于绑定 r4 的援用源)

(二)右值援用传递时失去右性

后面的章节笔者频繁强调一个概念:右值援用一旦绑定,则相当于一般的援用(左值)。

这也就意味着,“右值”性质无奈传递,请看例子:

void f1(int &&t1) {}

void f2(int &&t2) {f1(t2); // 留神这里
}

void Demo() {f2(5);
}

在 Demo 函数中调用 f2,f2 的参数是 int &&,用来绑定常量 5 没问题,然而,在 f2 函数内,t2 是一个右值援用,而右值援用一旦绑定,则相当于左值,因而,不能再用右值援用去接管。所以 f2 外部调 f1 的过程会报错。这就是所谓“右值援用传递时会失去右性”。

那么如何放弃右性呢?很无奈,只能层层转换:

void f1(int &&t1) {}

void f2(int &&t2) {f1(std::move(t2)); // 保障右性
}

void Demo() {f2(5);
}

但咱们来思考另一个场景,在模板函数中这件事会怎么样?

template <typename T>
void f1(T &&t1) {}

template <typename T>
void f2(T &&t2) {f1<T>(t2);
}

void Demo() {f2<int &&>(5); // 传右值
  
  int a;
  f2<int &>(a); // 传左值
}

因为 f1 和 f2 都是模板,因而,传入左值和传入右值的可能性都要有的,咱们没法在 f2 中再强制 std::move 了,因为这样做会让左值变成右值传递上来,咱们心愿的是放弃其左右性 但如果不这样做,当我向 f2 传递右值时,右性无奈传递上来,也就是 t2 是 int && 类型,然而传递给 f1 的时候,t1 变成了 int & 类型,这时 t1 是 t2 的援用(就是左值援用绑定右值援用的场景),并不是咱们想要的。那怎么解决,如何让这种左右性质传递上来呢?就要用到模板元编程来实现了:

template <typename T>
T &forward(T &t) {return t; // 如果传左值,那么间接传出}

template <typename T>
T &&forward(T &&t) {return std::move(t); // 如果传右值,那么放弃右值性质传出
}

template <typename T>
void f1(T &&t1) {}

template <typename T>
void f2(T &&t2) {f1(forward<T>(t2));
}

void Demo() {f2<int &&>(5); // 传右值
  
  int a;
  f2<int &>(a); // 传左值
}

下面展现的是 std::forward 的一个示例型的代码,便于读者了解,理论实现要略微简单一点。思路就是,依据传入的参数来判断,如果是左值援用就间接传出,如果是右值援用就 std::move 变成右值再传出,保障其左右性。std::forward 又被称为“完满转发”,意义就在于传递援用时能放弃其左右性。

(三)auto 推导策略

C++11 提供了 auto 来主动推导类型,很大水平上晋升了代码的直观性,例如:

std::unordered_map<std::string, std::vector<int>> data_map;
// 不必 auto
std::unordered_map<std::string, std::vector<int>>::iterator iter = data_map.begin();
// 应用 auto 推导
auto iter = data_map.begin();

但 auto 的推导依然引入了不少奇怪的问题。首先,auto 关键字仅仅是用来代替“类型符”的,它并没有扭转“C++ 类型说明符具备多重意义”这件事,在后面“类型说明符”的章节我曾介绍过,C++ 中,类型说明符除了示意“类型”以外,还承当了“定义动作”的工作,auto 能够视为一种带有类型推导的类型说明符,其本质依然是类型说明符,所以,它同样承当了定义动作的工作,例如:

auto a = 5; // auto 承当了“定义变量”的工作

但 auto 却不能够和 [] 组合定义数组,比方:

auto arr[] = {1, 2, 3}; // ERR

在定义函数上,更加乏味,在 C ++14 以前,并不反对用 auto 推导函数返回值类型,然而却反对返回值后置语法,所以在这种场景下,auto 仅仅是一个占位符而已,它既不示意类型,也不示意定义动作,仅仅就是为了构造残缺占位而已:

auto func() -> int; // () -> int 示意定义函数,int 示意函数返回值类型

到了 C ++14 才反对了返回值类型主动推导,但并不反对主动生成多种类型的返回值:

auto func(int cmd) {if (cmd > 0) {return 5; // 用 5 推导返回值为 int}
  return std::string("123"); // ERR,返回值曾经推导为 int 了,不能多类型返回
}
  • auto 的语义

同样还是出自这句话“auto 是用来代替类型说明符的”,因而 auto 在语义上也更加偏向于“用它代替类型说明符”这种行为,尤其是它和援用、指针类型联合时,这种个性更加显著:

int a = 5;
const int k = 9;
int &r = a;
auto b = a; // auto->int
auto c = 4; // auto->int
auto d = k; // auto->int
auto e = r; // auto->int

咱们看到,无论用一般变量、只读变量、援用、常量去初始化 auto 变量时,auto 都只会推导其类型,而不会带有左右性、只读性这些内容。所以,auto 的类型推导,并不是“推导某个表达式的类型”,而是“推导以后地位适合的类型”,或者能够了解为“这里最简略能够是什么类型”。比如说下面 auto c = 4 这里,auto 能够推导为 int,int &&,const int,const int &,const int &&,而 auto 抉择的是外面最简略的那一种。

auto 还能够跟指针符、援用符联合,而这种时候它还是满足下面“最简略”的这种准则,并且此时指的是“auto 自身最简略”,举例来说:

int a = 5;
auto p1 = &a; // auto->int *
auto *p2 = &a; // auto->int
auto &r1 = a; // auto->int
auto *p3 = &p2; // auto->int *
auto p4 = &p2; // auto-> int **

p1 和 p2 都是指针,但 auto 都是用最简准则来推导的,p2 这里因为咱们曾经显式写了一个 * 了,所以 auto 只会推导出 int,因而 p2 最终类型依然是 int * 而不会变成 int **。同样的情理在 p3 和 p4 上也成立。在一些将“类型”和“动作”语义拆散的语言中,就齐全不会有 auto 的这种困扰,它们能够用“省略类型符”来示意“主动类型推导”的语义,而起“定义”语义的关键字得以保留而不受影响,例如在 swift 中:

var a = 5 // Int
let b = 5.6 // 只读 Double

let c: Double = 8 // 显式指定类型

在 Go 中也是相似的:

var a = 2.5 // var 示意“定义变量”动作,主动推导 a 的类型为 float64
b := 5 // 主动推导类型为 int,:= 符号示意了“定义动作”语义
const c = 7 // const 示意“定义只读变量”动作,主动推导 c 类型为 int

var d float32 = 9 // 显式指定类型
  • auto 援用

在后面“援用折叠”的章节已经提到过 auto && 的推导准则,有可能会推导出左值援用来,所以 auto && 并不是要“定义一个右值援用”,而是“定义一个放弃左右性的援用”,也就是说,绑定一个左值时会推导出左值援用,绑定一个右值时会推导出右值援用。

int a = 5;
int &r1 = a;
int &&r2 = 4;

auto &&y1 = a; // int &
auto &&y2 = r1; // int &
auto &&y3 = r2; // int &(留神右值援用自身是左值)auto &&y4 = 3; // int &&
auto &&y5 = std::move(r1); // int &&

更具体的内容能够参考后面“援用折叠”的章节。

  • C 语言已经的 auto

我置信大家当初看到 auto 都第一印象是 C ++ 当中的“主动类型推导”,但其实 auto 并不是 C ++11 引入的新要害在,在原始 C 语言中就有这一关键字的。在原始 C 中,auto 示意“主动变量地位”,与之对应的是 register。在之前“const 援用”章节中笔者已经提到,“变量就是内存变量”,但其实在原始 C 中,除了内存变量以外,还有一种变量叫做“寄存器变量”,也就是间接将这个数据放到 CPU 的寄存器中。也就是说,编译器能够管制这个变量的地位,如果更加须要读写速度,那么放到寄存器中更适合,因而 auto 示意让编译器主动决定放内存中,还是放寄存器中。而 register 润饰的则示意人工指定放在寄存器中。至于没有关键字润饰的,则示意心愿放到内存中。

int a; // 内存变量
register int b; // 寄存器变量
auto int c; // 由编译器主动决定放在哪里

须要留神的是,寄存器变量不能取址。这个很好了解,因为只有内存才有地址(地址原本指的就是内存地址),寄存器是没有的。因而,auto 润饰的变量如果被取址了,那么肯定会放在内存中:

auto int a; // 有可能放在内存中,也有可能放在寄存器中
auto int b;
int *p = &b; // 这里 b 被取址了,因而 b 肯定只能放在内存中

register int c;
int *p2 = &c; // ERR,对寄存器变量取址,会报错

然而在 C ++ 中,简直不会人工来控制变量的寄存地位了,毕竟 C ++ 更加下层一些,这样超底层的语法就被摒弃了(C++11 勾销了 register 关键字,而 auto 关键字也失去其本意,变为了“主动类型推导”的占位符)。而对于变量的存储地位则是全权交给了编译器,也就是说咱们能够了解为,在 C ++11 当前,所有的变量都是主动变量,存储地位由编译器决定。

注:点赞过 30,下周五出下篇,快点赞珍藏起来~

举荐浏览

看完这篇,成为 Grafana 高手!

10 大性能陷阱!每个 C ++ 工程师都要晓得

AI 绘画火了!一文看懂背地技术原理

CPU 如何与内存交互?

退出移动版