前言
我从大学时期就开始研究,并稳定扎根在C++方向上,作为一名专职的C++程序员,在这些年的开发经历中,对C++语言的一些“缺陷”有了一些思考总结。与此同时,新兴的编程语言越来越受欢迎,而C++本身为了克服自己的一些缺陷,也在发生着一些改变。
本文主要有3个目的:
- 总结一些C++晦涩难懂的语法现象,解释其背后原因,作为防踩坑之用
- 和一些其他的编程语言进行比较,列举它们的优劣
- 发表一些我自己作为C++程序员的看法和感受
需要说明的是,本文并不是在批判C++,或是说其他语言就比C++好。 C++仍然是我个人认为最值得学习的语言,优秀的C++程序员转去用其他编程语言后一样会很优秀,但反过来并不一定成立。
文中的“缺陷”更多的是想让大家去注意这个点,了解它的底层原因,避免踩坑,并不建议大家一刀切地杜绝使用这些语法。
另外由于笔者并没有精通各种编程语言,仅仅能在利索能力范围内跟一些其他语言进行比较,还请见谅。
继承自C的缺陷
C++有一个很大的历史包袱,就是C语言。C语言诞生时间很早,并且它是为了编写OS而诞生的,语法更加底层。有人说,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位环境长度 |
---|---|---|
int/unsigned | 4B | 4B |
long/unsigned long | 4B | 8B |
long long/ unsigned long long | 8B | 8B |
由于这里出现了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位环境下,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语言中被定义为了整型类型的一种语法糖,导致枚举类型的使用上出现了一些问题。
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);
}
这种用宏来做函数名的拼接看似方便,但最容易出的问题就是类型不一致,加入pb1
和pb2
中虽然都有f1
这个字段,但类型不一样,那么这样用就可能造成类型转换。试想pb1.f1
是uint64_t
类型,而pb2.f1
是uint32_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语法呢,还是说你为了让代码扫描工具扫不出来你的圈复杂度才出此下策的呢~~
小结
C++的所谓“缺陷”,有很大程度上是C语言造成的,由于C语言的特殊性,使得C++背负着历史包袱,而要提供一些方便的用法,都不太敢通过直接改变语法的方式,因为可能会造成对C、或者老版本C++的不兼容。因此C++采用的方式就是封装成STL中的工具,但这样做的缺点就是用起来会很别扭,不如原生语法那样优雅。
本文主要介绍的是从C继承来的缺陷,上述关于封装成STL工具后引发的一些问题将会在后续文章将会继续介绍,后续文章也会介绍C++的一些其他缺陷和笔者的思考。
第二篇已经脱稿,请看C++的缺陷和思考(二)