C/C++ 的细微差异

虽然大多数情况下我们会把 C 视作 C++ 的 subset,但二者之间仍有一些不应被忽略的细微差异。顺便一提,我确实是先学 C,再学 C++ 的;这多少会影响我观察二者边界的方式。

C++ 支持函数重载,这使得它在汇编层面的函数名经常呈现出近乎反人类的形态,也因此在 C++ 中调用 C 函数时需要加上 extern "C",否则链接器无法按 C 的符号名完成解析。以我最常用的 GCC 为例,对于一个 int compute(int a, int b)

int compute(int a, int b)
{
    return a + b;
}

在 C++ 中,汇编结果会得到:

_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

而在 C 中则是:

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。这与语言标准本身并不完全等价,而是编译器在实现命名空间与重载机制时进行的隐式重命名,也就是所谓 name mangling。它使 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,也能达到类似效果,但前者显然更符合现代 C++ 的表达方式。鉴于 malloc/free 或者 new/delete 都是在全局堆上分配内存,并发场景下往往需要加锁,从而造成事实上的串行化,解耦内存分配策略一直是高性能计算的刚需。

总的来说,若从当下的底层开发实践回望 C,常见形态往往是 “C and assembly” 的结合:C 作为高级语言,在许多位置节省了机械性劳动,同时维持了效率,并且与汇编之间保持着更好的亲和性,因此适合 Linux 内核这类需要精细控制的场合。同样地,如果项目中已经使用汇编,C++ 会带来事实上的多语言异构代码;而要良好实现一个 C++ 编译器,也远比实现一个 C 编译器折磨得多,毕竟 C++ 的模板系统从 C++98 开始就已经是图灵完备的。由此看,C++ 引入面向对象、lambda、template 等特性,更适合高性能软件开发中的另一条路线:零成本抽象,是底层性能与高层架构既要又要之后留下的妥协,也是野心。