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.