C/C++ 的小差别

虽然大多数情况下我们把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-freenew-delete。C++的newdelete最大的问题在于,它们会自动调用构造函数和析构函数,这会导致非常隐蔽的高开销:假设有一个相对复杂的类型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等等特性更适合高性能软件开发——零成本抽象是底层性能与高层架构既要又要的结果。