C++避坑指南

C++避坑指南,第1张

 

前言

C++是一门古老的语言,但仍然在不间断更新中,不断引用新特性。但与此同时 class="superseo">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 风格数组的原因。

类型说明符 类型不是从左向右说明

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)float64func(float64)float64 {
}

Swift 语言:

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

// 定义函数
func f1() {
}

func f2() -> Int {
  return 0
}
// 高阶函数
func DC(f: (Double, Double)->Double) -> (DoubleDouble)->Double {
}

JavaScript 语言:

// 定义变量
var a1 = 0
var a2 = [123]
// 定义函数
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); // 可能导致越界
}

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

其他语言的指针

在其他语言中,有的语言(例如 javaC#)直接取消了指针的相关语法,但由此就必须引入“值类型”和“引用类型”的概念。例如在 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) {
  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

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/dianzi/2998324.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-09-26
下一篇 2022-09-26

发表评论

登录后才能评论

评论列表(0条)

保存