C vs C++: qué ganás de verdad (y qué pagás)
C te da un contrato fino con la máquina: storage explícito, lifetimes explícitos, translation units simples y muy pocas abstracciones del lenguaje. C++ conserva ese alcance low-level, pero agrega formas de expresar ownership, invariantes, algoritmos genéricos e interfaces seleccionadas por overload directamente en el código. La ganancia no es "seguridad automática"; C++ todavía tiene undefined behavior (comportamiento indefinido), punteros crudos, problemas de layout y realidad de ABI. La ganancia es que un subset disciplinado deja que el compilador haga cumplir más del protocolo de recursos que en C mantenías a mano.
El reset: C++ no es un C mágicamente más seguro. Es un lenguaje más grande donde el buen subset puede codificar lifetimes e intención, y el mal subset puede esconder bugs atrás de más sintaxis.
Cómo funciona de verdad
El salto útil de C a C++ no son las clases por sí mismas. Es lifetime como mecanismo del
lenguaje. Los constructores establecen una invariante de objeto. Los destructores corren
cuando termina el lifetime de ese objeto. Las operaciones de copy y move definen qué
significa transferir ownership. Los templates permiten que una implementación se
especialice en compile time sin un protocolo de void*. La biblioteca estándar construye
containers, strings, algoritmos, smart pointers, locks y handles de filesystem sobre esas
reglas.
| Hábito en C | Reemplazo en C++ moderno | Qué cambia |
|---|---|---|
pares malloc / free |
std::vector, std::string, std::unique_ptr |
el release queda atado al lifetime del objeto |
goto cleanup en cada salida |
destructores RAII | el cleanup se genera en salidas normales y excepcionales |
void* más callbacks |
templates, overloads, function objects | el type checking pasa antes del runtime |
| out-parameters para ownership | valores de retorno y tipos move-only | la transferencia se ve en el tipo |
| bookkeeping manual de longitud | containers y spans | el tamaño viaja con los datos |
| código genérico con macros | templates y constexpr |
el código se chequea después de sustituir, no como texto pegado |
C++ todavía compila a object files, símbolos, relocations y código de máquina. Un
std::vector no es un servicio de runtime; es un objeto chico con metadata de
punteros/capacidad más código de biblioteca que aloca, mueve y destruye elementos. Un
template no es dynamic dispatch; normalmente instancia código concreto para los tipos
usados. std::unique_ptr<T> suele ser apenas un puntero crudo más un tipo deleter, con
copy deshabilitado y move habilitado.
El costo es que C++ tiene más reglas en cada capa. El lenguaje tiene overload resolution, extensión de lifetime de temporarios, special member functions, caminos de cleanup por excepciones, template instantiation, restricciones de one-definition-rule, name mangling y contratos de biblioteca estándar. Esas reglas no son académicas. Moldean tiempos de compilación, diagnósticos, interfaces binarias, código generado y qué podés exponer con seguridad a través de una ABI C.
Qué ganás
- Cleanup determinístico. RAII convierte "acordate de llamar cleanup en todos los caminos" en una llamada a destructor que el compilador inserta al salir del scope.
- Vocabulario de ownership más fuerte. Un puntero crudo puede significar borrow,
borrow opcional, array, out-parameter, objeto owned o handle de API C.
std::unique_ptr,std::shared_ptry referencias dicen más en el borde del tipo. - Tipos valor regulares. Un tipo puede owning heap memory y aun así comportarse como un valor si define copy, move y destrucción correctamente.
- Densidad de biblioteca.
std::vector,std::string,std::array, algoritmos, iterators y smart pointers eliminan mucho boilerplate de C sin cambiar el target de máquina. - Abstracción en compile time. Templates,
constexpr, concepts y overloads pueden mover chequeos desde protocolos de runtime a selección en compile time. - Cleanup consciente de excepciones. Aunque tu proyecto evite excepciones en los bordes, el código de biblioteca C++ está diseñado alrededor de destructores que corren durante stack unwinding.
Qué pagás
- Complejidad de lenguaje. Necesitás un subset. "Todo C++" no es un estilo; es un riesgo.
- Costo de compilación. Templates y headers pueden hacer que el build graph sea más pesado que el equivalente C.
- Complejidad de ABI. Name mangling, excepciones, RTTI, vtables, versiones de biblioteca estándar y elección de allocator importan cuando distribuís interfaces binarias.
- Caminos de código escondidos. Constructores, destructores, conversiones, allocation y operadores overload pueden correr donde un lector de C vería solo una declaración o una expresión.
- Todavía no hay garantía de memory safety. Referencias colgantes, iterators inválidos, data races, indexing sin check, bugs de strict aliasing y errores de lifetime siguen existiendo.
- Fricción freestanding. Kernels y bootloaders pueden usar subsets de C++, pero assumptions hosted como excepciones, RTTI, constructores globales y partes de la biblioteca estándar requieren soporte deliberado de toolchain.
Artefacto ejecutable: el mismo problema de cleanup en C y C++
El demo ejecutable vive en
examples/modern-cpp/c-vs-cpp-what-you-actually-gain/. Construye una versión C que usa
cleanup manual y una versión C++ donde los objetos se limpian solos cuando un return normal
o una excepción sale del scope.
cd examples/modern-cpp/c-vs-cpp-what-you-actually-gain
./run.sh
El programa C tiene que centralizar ownership por convención:
IntBuffer buffer = {0, NULL};
char *label = NULL;
label = malloc(strlen(name) + strlen(" report") + 1);
if (label == NULL) {
goto cleanup;
}
if (!int_buffer_init(&buffer, 4)) {
goto cleanup;
}
cleanup:
int_buffer_destroy(&buffer);
free(label);
El programa C++ hace que ownership sea una propiedad de los objetos:
class IntBuffer {
public:
explicit IntBuffer(std::size_t count) : items_(count) {}
int sum() const;
private:
std::vector<int> items_;
};
static int make_report_cpp(const std::string &name, bool fail_after_alloc) {
IntBuffer buffer{4};
std::string label = name + " report";
if (fail_after_alloc) {
throw std::runtime_error("simulated error");
}
return buffer.sum();
}
Lo importante no es que std::vector sea elegante. Es que el release path no es un
contrato social separado. Si el control sale del scope C++ después de que buffer y
label fueron construidos, sus destructores corren. Para inspeccionar el mecanismo
generado, compilá el lado C++ con salida assembly:
g++ -std=c++20 -O2 -S demo.cpp -o demo.O2.s
En Compiler Explorer, compará las labels de cleanup de C con los caminos de destructores de C++. La versión C++ tiene más maquinaria de lenguaje, pero después de optimizar el camino exitoso común sigue siendo código directo más llamadas a las operaciones de biblioteca que usaste.
Fallas típicas y trade-offs
- Usar C++ como "C con sintaxis más linda" pierde el beneficio principal. Punteros
owning crudos,
new/deletedesnudos y cleanup manual recrean los problemas de C con un lenguaje más grande. - Usar todas las features de C++ a la vez pierde el norte. Multiple inheritance, conversiones implícitas, excepciones por todos lados, templates profundos y estado global pueden hacer que el código sea más difícil de auditar que C disciplinado.
- RAII no es garbage collection. Es destrucción determinística. Ciclos con
std::shared_ptr, threads detached, orden de lifetime global y punteros crudos owning filtrados todavía hacen leak. - Los bordes ABI C necesitan contratos planos. Nombres C++, excepciones, destructores y tipos de biblioteca estándar no deberían filtrarse por una ABI C de plugin o kernel salvo que controles ambos lados estrictamente.
- Zero-cost es condicional. No pagás por features que no usás, pero las features usadas tienen costos reales: allocation, reference counting, virtual dispatch, exception tables, tamaño de código o comportamiento de cache.
En la práctica
- Aprendé C primero, después usá C++ para codificar la disciplina. Este atlas es C-first porque el proyecto de OS necesita el modelo crudo de la máquina. C++ se vuelve útil cuando elimina protocolo repetido de ownership, no cuando esconde la máquina.
- Preferí valores y handles RAII.
std::vector<T>,std::string,std::unique_ptr<T>y clases chicas que mantienen invariantes deberían venir antes que allocation cruda. - Hacé explícito el ownership en APIs. Usá punteros crudos y referencias para borrows,
std::unique_ptrpara transferencia ystd::shared_ptrsolo cuando shared lifetime es el modelo real. - Mantené inspeccionable el código generado. Usá
-Wall -Wextra, sanitizers, Compiler Explorer y herramientas de object files. Las abstracciones C++ son aceptables cuando podés explicar a qué compilan. - Definí un subset de proyecto. Un kernel, un runtime embebido, una app GUI y un server no necesitan las mismas reglas C++.
Conecta con: RAII: la idea que cambia todo · Smart pointers: unique_ptr, shared_ptr, ownership · Move semantics y value categories · Por qué C todavía importa · Undefined behavior: el contrato que no sabías que firmaste · Memory leaks y disciplina de ownership · El pipeline
Fuentes
- Bjarne Stroustrup - A Tour of C++ - mapa conciso del lenguaje moderno y la biblioteca estándar para programadores con experiencia. https://www.stroustrup.com/tour3.html
- C++ Core Guidelines - el set práctico de reglas de "modern C++", especialmente resource management, interfaces y el marco zero-overhead. https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines
- cppreference - Classes - mapa de referencia para constructores, destructores, special members y mecánica de clases. https://en.cppreference.com/w/cpp/language/classes
- cppreference - Object model - lifetime de objetos, alineación, storage y reglas de tipos que mantienen C++ atado a la realidad de máquina. https://en.cppreference.com/w/cpp/language/object
- cppreference - The rule of three/five/zero - la regla de special members detrás de tipos que owning recursos y se comportan como valores. https://en.cppreference.com/w/cpp/language/rule_of_three
- Compiler Explorer - la herramienta diaria para chequear en qué se convierten las abstracciones C++ bajo un compilador real. https://godbolt.org/