The Subtle Differences Between C and C++

Although we often treat C as a subset of C++, there remain subtle differences that cannot be safely dismissed. For the record, I did learn C before C++; that fact inevitably colors the way I read the boundary between the two languages.

C++ supports function overloading, which means that function names at the assembly level can acquire a nearly anti-human form. This is also why C functions called from C++ need to be declared with extern "C"; otherwise, the linker will not resolve them under their C symbol names. Taking GCC, the compiler I most often use, as an example, consider a function such as int compute(int a, int b):

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

In C++, the assembly will contain:

_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

And in 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

The same int compute(int a, int b) becomes _Z7computeii in C++, while in C it remains the plain compute. This is not quite a matter of the language standard itself, but rather a consequence of how compilers implement namespaces and overloading through implicit renaming, a process usually called name mangling. It pushes C++ some distance away from the old fantasy of being “high-level assembly,” and inline asm no longer feels as bare-handed as it does in C.

Another problem introduced by expressive language machinery is the virtual table, or vtable. For example, with this code:

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();
}

The assembly will contain:

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

This _ZTV14expanded_test1 points to the vtable. As a result, a C-style memset(this, 0, sizeof(T)) can destroy the object’s vtable layout and produce errors that are exceptionally hard to diagnose. In C++, the safer initialization techniques are therefore initializer lists or assignments performed inside the constructor. Another curious detail is that, when RTTI (Run-Time Type Information) is enabled, the compiler also provides _ZTVN10__cxxabiv120__si_class_type_infoE+16 for type information.

There are also smaller differences that nevertheless puncture the abstraction, such as NULL. In stdlib.h (C), we can see it’s:

#define NULL ((void *)0)

But in C++, it becomes:

#define NULL 0

Therefore, if NULL is used during template argument deduction, it will be deduced as an int, not as a pointer. This is why C++ code should use the built-in nullptr.

Another major topic is the difference between C and C++ memory allocation models; it cannot be reduced to a mere syntactic migration from malloc/free to new/delete. The central feature of C++‘s new and delete is that they automatically invoke constructors and destructors, which can introduce surprisingly well-hidden costs. Suppose there is a relatively complex type T. If we write new T[100];, the program calls the constructor 100 times at allocation time, rather than paying for construction only when each object is actually used. In a structure such as a hash table, where the load factor may be deliberately low and many slots may remain unused, this upfront construction becomes almost pure waste. A more modern approach is to use std::pmr (Polymorphic Memory Resources) to decouple the memory allocation strategy, or to pair std::allocator with std::construct_at, constructing objects only when they enter use and manually destroying them when they leave it. The placement new operator can achieve a similar effect, but the former style is more idiomatic in modern C++. Since both malloc/free and new/delete allocate from the global heap, they typically require locking under concurrency, effectively serializing memory operations. This is why decoupled allocation strategies have long been a hard requirement in high-performance computing.

In conclusion, contemporary low-level development around C often appears as a union of “C and assembly.” As a high-level language, C removes a great deal of mechanical labor while preserving efficiency, and its affinity with assembly makes it suitable for finely controlled domains such as the Linux kernel. By contrast, introducing C++ into a project that already contains assembly effectively creates a multi-language heterogeneous codebase; implementing a good C++ compiler is also vastly more punishing than implementing a C compiler, since the C++ template system has been Turing-complete since C++98. From this angle, C++ features such as object orientation, lambdas, and template support a different route through high-performance software development: “zero-cost abstraction” is what remains after insisting on both low-level performance and high-level architecture. It is a compromise, and also an ambition.