虽然大多数情况下我们把C视作C++的 subset,但是事实上有一些细微的差异是不得不重视的。啊,对,没错,我是先学C,再学C++的。
C++ 支持了函数重载,这就导致在汇编层面上C++的函数名会变得非常逆天,几乎是人类不可读的,也因此在C++中调用C函数时要加上extern “C”
,不然编译器找不到。以我最常用的 GCC 而言,比如一个int compute(int a, int b)
:
int compute(int a, int b)
{
return a + b;
}
汇编会得到
_Z7computeii:
.LFB0:
pushq %rbp
.seh_pushreg %rbp
movq %rsp, %rbp
.seh_setframe %rbp, 0
.seh_endprologue
movl %ecx, 16(%rbp)
movl %edx, 24(%rbp)
movl 16(%rbp), %edx
movl 24(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
compute:
pushq %rbp
.seh_pushreg %rbp
movq %rsp, %rbp
.seh_setframe %rbp, 0
.seh_endprologue
movl %ecx, 16(%rbp)
movl %edx, 24(%rbp)
movl 16(%rbp), %edx
movl 24(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
这个int compute(int a, int b)
在C++中会变成_Z7computeii
,C中就是直接的compute
;倒不是语言标准的问题,是编译器在实现命名空间和重载的时候会隐式地进行重命名,导C++脱离了高级汇编的范畴,使用asm
嵌入也不会如同C那般自由。
另外一个为了功能带来的问题是虚表。 例如对于
struct test1
{
virtual int compute(void)
{
return 1;
}
};
struct expanded_test1 : public test1
{
int compute(void) override
{
return 2;
}
};
int main(void)
{
expanded_test1 object;
test1 &reference = object;
reference.compute();
}
汇编会得到
main:
.LFB2:
subq $56, %rsp
.seh_stackalloc 56
.seh_endprologue
call __main
leaq 16+_ZTV14expanded_test1(%rip), %rax
movq %rax, 40(%rsp)
movl $0, %eax
addq $56, %rsp
ret
.seh_endproc
.globl _ZTS5test1
.section .rdata$_ZTS5test1,"dr"
.linkonce same_size
_ZTS5test1:
.ascii "5test1\0"
.globl _ZTI5test1
.section .rdata$_ZTI5test1,"dr"
.linkonce same_size
.align 8
_ZTS14expanded_test1:
.ascii "14expanded_test1\0"
.globl _ZTI14expanded_test1
.section .rdata$_ZTI14expanded_test1,"dr"
.linkonce same_size
.align 8
_ZTI14expanded_test1:
.quad _ZTVN10__cxxabiv120__si_class_type_infoE+16
.quad _ZTS14expanded_test1
.quad _ZTI5test1
.globl _ZTV14expanded_test1
.section .rdata$_ZTV14expanded_test1,"dr"
.linkonce same_size
.align 8
_ZTV14expanded_test1:
.quad 0
.quad _ZTI14expanded_test1
.quad _ZN14expanded_test17computeEv
这个_ZTV14expanded_test1
导致C风格的memset(this, 0, sizeof(T))
会破坏虚表造成非常隐蔽的错误,所以C++中安全的初始化方式就是初始化列表或者构造函数中赋值。另外一个比较有意思的是启用 RTTI 的情况下(现在已经是标准规定的了),编译器是提供了_ZTVN10__cxxabiv120__si_class_type_infoE+16
的。
还有一些小细节,比如NULL。
在stdlib.h
中我们可以看到它是:
#define NULL ((void *)0)
但是在C++中它变成了:
#define NULL 0
那么,如果在模板推导的时候使用NULL
,它会是一个int
类型,而不是指针。所以在C++中应当使用的就是语言内置的nullptr
。
值得细说的另一大是C与C++在内存分配上的差异,可不能简单地视为从malloc-free
到new-delete
。C++的new
和delete
最大的问题在于,它们会自动调用构造函数和析构函数,这会导致非常隐蔽的高开销:假设有一个相对复杂的类型T
,如果我来一句new T[100];
,会直接在分配的适合调用100次构造函数,而不是在使用的适合才付费;如果是在散列表这种对装填因子要求低,事实上导致大量对象未被利用的情况下,是纯纯的浪费。更 modern 的做法是用pmr
解耦内存分配策略,或者搭配allocator
,在使用的适合调用std::construct_at
,不需要的时候手动删除;定位new
运算符,即 placement new 也有同样的效果,但是前者显然更现代。鉴于malloc-free
或者new-delete
都是在全局堆分配内存,并发下会加锁导致事实上的串行,所以解耦内存分配策略一直是高性能计算的刚需。
总的来说,如果现在来看C相关的底层开发,一般是“C and assembly”的联合,因为C作为高级语言在很多地方省下了功夫保障了效率,与汇编的亲和性也好很多,适合很多精细的场合,比如 Linux 内核。同样地,如果项目中使用了汇编,C++会带来事实上的多语言异构代码,而且良好实现一个C++的编译器可比C折磨上无数倍:C++的模板系统从C++98开始就是图灵完备的。所以C++引入面向对象、lambda、template
等等特性更适合高性能软件开发——零成本抽象是底层性能与高层架构既要又要的结果。