前言
C++是一门古老的语言,但仍然在不间断更新中,不断引用新特性。但与此同时 class="superseo">C++又甩不掉巨大的历史包袱,并且 C++的设计初衷和理念造成了 C++异常复杂,还出现了很多不合理的“缺陷”。
本文主要有 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 件事:
-
提供一种隐式类型转换,支持将数组类型转换为首元素指针类型(比如说这里 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) {
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
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)