Los 10 errores de C++ más comunes que cometen los desarrolladores
Publicado: 2022-03-11Hay muchas trampas que un desarrollador de C++ puede encontrar. Esto puede hacer que la programación de calidad sea muy difícil y el mantenimiento muy costoso. Aprender la sintaxis del lenguaje y tener buenas habilidades de programación en lenguajes similares, como C# y Java, no es suficiente para utilizar todo el potencial de C++. Requiere años de experiencia y gran disciplina para evitar errores en C++. En este artículo, vamos a echar un vistazo a algunos de los errores comunes que cometen los desarrolladores de todos los niveles si no son lo suficientemente cuidadosos con el desarrollo de C++.
Error común n.º 1: usar los pares "nuevo" y "eliminar" incorrectamente
Por mucho que lo intentemos, es muy difícil liberar toda la memoria asignada dinámicamente. Incluso si podemos hacer eso, a menudo no está a salvo de las excepciones. Veamos un ejemplo sencillo:
void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }
Si se lanza una excepción, el objeto "a" nunca se elimina. El siguiente ejemplo muestra una forma más segura y más corta de hacerlo. Utiliza auto_ptr, que está en desuso en C++ 11, pero el antiguo estándar todavía se usa ampliamente. Se puede reemplazar con C++11 unique_ptr o scoped_ptr de Boost si es posible.
void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }
Pase lo que pase, después de crear el objeto "a", se eliminará tan pronto como la ejecución del programa salga del alcance.
Sin embargo, este fue solo el ejemplo más simple de este problema de C++. Hay muchos ejemplos en los que la eliminación debe realizarse en algún otro lugar, tal vez en una función externa o en otro subproceso. Es por eso que el uso de nuevos/eliminar en pares debe evitarse por completo y en su lugar deben usarse punteros inteligentes apropiados.
Error común #2: Destructor virtual olvidado
Este es uno de los errores más comunes que provoca pérdidas de memoria dentro de las clases derivadas si hay memoria dinámica asignada dentro de ellas. Hay algunos casos en los que el destructor virtual no es deseable, es decir, cuando una clase no está destinada a la herencia y su tamaño y rendimiento son cruciales. El destructor virtual o cualquier otra función virtual introduce datos adicionales dentro de una estructura de clase, es decir, un puntero a una tabla virtual que aumenta el tamaño de cualquier instancia de la clase.
Sin embargo, en la mayoría de los casos, las clases se pueden heredar incluso si no se pretendía originalmente. Por lo tanto, es una muy buena práctica agregar un destructor virtual cuando se declara una clase. De lo contrario, si una clase no debe contener funciones virtuales por razones de rendimiento, es una buena práctica colocar un comentario dentro de un archivo de declaración de clase que indique que la clase no debe heredarse. Una de las mejores opciones para evitar este problema es utilizar un IDE que admita la creación de destructores virtuales durante la creación de una clase.
Un punto adicional al tema son las clases/plantillas de la biblioteca estándar. No están destinados a la herencia y no tienen un destructor virtual. Si, por ejemplo, creamos una nueva clase de cadena mejorada que hereda públicamente de std::string, existe la posibilidad de que alguien la use incorrectamente con un puntero o una referencia a std::string y provoque una pérdida de memoria.
class MyString : public std::string { ~MyString() { // ... } }; int main() { std::string *s = new MyString(); delete s; // May not invoke the destructor defined in MyString }
Para evitar estos problemas de C++, una forma más segura de reutilizar una clase/plantilla de la biblioteca estándar es usar herencia o composición privada.
Error común n.º 3: eliminar una matriz con "eliminar" o usar un puntero inteligente
A menudo es necesario crear arreglos temporales de tamaño dinámico. Después de que ya no se necesiten, es importante liberar la memoria asignada. El gran problema aquí es que C ++ requiere un operador de eliminación especial con corchetes [], que se olvida muy fácilmente. El operador delete[] no solo eliminará la memoria asignada para una matriz, sino que primero llamará a los destructores de todos los objetos de una matriz. También es incorrecto usar el operador de eliminación sin corchetes [] para tipos primitivos, aunque no haya destructor para estos tipos. No hay garantía para cada compilador de que un puntero a una matriz apunte al primer elemento de la matriz, por lo que usar eliminar sin corchetes [] también puede generar un comportamiento indefinido.
El uso de punteros inteligentes, como auto_ptr, unique_ptr<T>, shared_ptr, con matrices también es incorrecto. Cuando un puntero inteligente de este tipo sale de un alcance, llamará a un operador de eliminación sin corchetes [], lo que da como resultado los mismos problemas descritos anteriormente. Si se requiere el uso de un puntero inteligente para una matriz, es posible usar scoped_array o shared_array de Boost o una especialización unique_ptr<T[]>.
Si no se requiere la funcionalidad de conteo de referencias, que suele ser el caso de las matrices, la forma más elegante es usar vectores STL en su lugar. No solo se encargan de liberar memoria, sino que también ofrecen funcionalidades adicionales.
Error común n.º 4: devolver un objeto local por referencia
Este es principalmente un error de principiante, pero vale la pena mencionarlo, ya que hay una gran cantidad de código heredado que sufre este problema. Veamos el siguiente código donde un programador quería hacer algún tipo de optimización evitando copias innecesarias:
Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);
El objeto "suma" ahora apuntará al objeto local "resultado". Pero, ¿dónde se encuentra el objeto "resultado" después de ejecutar la función SumComplex? En ningún lugar. Estaba ubicado en la pila, pero después de que la función regresara, la pila se desenvolvió y todos los objetos locales de la función se destruyeron. Esto eventualmente resultará en un comportamiento indefinido, incluso para tipos primitivos. Para evitar problemas de rendimiento, a veces es posible utilizar la optimización del valor de retorno:
Complex SumComplex(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imaginar + b.imaginar); } Complex sum = SumComplex(a, b);
Para la mayoría de los compiladores actuales, si una línea de retorno contiene un constructor de un objeto, el código se optimizará para evitar todas las copias innecesarias: el constructor se ejecutará directamente en el objeto "suma".
Error común n.º 5: usar una referencia a un recurso eliminado
Estos problemas de C++ ocurren con más frecuencia de lo que piensa y, por lo general, se ven en aplicaciones de subprocesos múltiples. Consideremos el siguiente código:
Hilo 1:
Connection& connection= connections.GetConnection(connectionId); // ...
Hilo 2:
connections.DeleteConnection(connectionId); // …
Hilo 1:
connection.send(data);
En este ejemplo, si ambos subprocesos usaron el mismo ID de conexión, esto dará como resultado un comportamiento indefinido. Los errores de violación de acceso a menudo son muy difíciles de encontrar.
En estos casos, cuando más de un hilo accede al mismo recurso, es muy arriesgado mantener punteros o referencias a los recursos, porque algún otro hilo puede eliminarlo. Es mucho más seguro usar punteros inteligentes con conteo de referencias, por ejemplo shared_ptr de Boost. Utiliza operaciones atómicas para aumentar/disminuir un contador de referencia, por lo que es seguro para subprocesos.
Error común n.º 6: permitir que las excepciones abandonen los destructores
Con frecuencia no es necesario lanzar una excepción desde un destructor. Incluso entonces, hay una mejor manera de hacerlo. Sin embargo, las excepciones en su mayoría no se lanzan explícitamente desde los destructores. Puede suceder que un comando simple para registrar la destrucción de un objeto provoque el lanzamiento de una excepción. Consideremos el siguiente código:

class A { public: A(){} ~A() { writeToLog(); // could cause an exception to be thrown } }; // … try { A a1; A a2; } catch (std::exception& e) { std::cout << "exception caught"; }
En el código anterior, si la excepción ocurre dos veces, como durante la destrucción de ambos objetos, la instrucción catch nunca se ejecuta. Debido a que hay dos excepciones en paralelo, sin importar si son del mismo tipo o de un tipo diferente, el entorno de tiempo de ejecución de C++ no sabe cómo manejarlo y llama a una función de terminación que da como resultado la terminación de la ejecución de un programa.
Entonces, la regla general es: nunca permita que las excepciones dejen destructores. Incluso si es feo, la posible excepción debe protegerse de esta manera:
try { writeToLog(); // could cause an exception to be thrown } catch (...) {}
Error común #7: Usar “auto_ptr” (incorrectamente)
La plantilla auto_ptr está obsoleta en C++ 11 por varios motivos. Todavía se usa mucho, ya que la mayoría de los proyectos todavía se están desarrollando en C++98. Tiene una cierta característica que probablemente no sea familiar para todos los desarrolladores de C++ y podría causar serios problemas a alguien que no tenga cuidado. La copia del objeto auto_ptr transferirá la propiedad de un objeto a otro. Por ejemplo, el siguiente código:
auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text auto_ptr<ClassA> b = a; a->SomeMethod(); // will result in access violation error
… resultará en un error de violación de acceso. Solo el objeto "b" contendrá un puntero al objeto de Clase A, mientras que "a" estará vacío. Intentar acceder a un miembro de clase del objeto "a" dará como resultado un error de violación de acceso. Hay muchas formas de usar auto_ptr incorrectamente. Cuatro cosas muy críticas para recordar acerca de ellos son:
Nunca use auto_ptr dentro de contenedores STL. La copia de contenedores dejará contenedores de origen con datos no válidos. Algunos algoritmos STL también pueden conducir a la invalidación de "auto_ptr".
Nunca use auto_ptr como un argumento de función, ya que esto conducirá a la copia y dejará inválido el valor pasado al argumento después de la llamada a la función.
Si se usa auto_ptr para miembros de datos de una clase, asegúrese de hacer una copia adecuada dentro de un constructor de copias y un operador de asignación, o desactive estas operaciones haciéndolas privadas.
Siempre que sea posible, use algún otro puntero inteligente moderno en lugar de auto_ptr.
Error común n.º 8: utilizar iteradores y referencias no validados
Sería posible escribir un libro entero sobre este tema. Cada contenedor STL tiene algunas condiciones específicas en las que invalida iteradores y referencias. Es importante tener en cuenta estos detalles al usar cualquier operación. Al igual que el anterior problema de C++, este también puede ocurrir con mucha frecuencia en entornos multiproceso, por lo que es necesario utilizar mecanismos de sincronización para evitarlo. Veamos el siguiente código secuencial como ejemplo:
vector<string> v; v.push_back(“string1”); string& s1 = v[0]; // assign a reference to the 1st element vector<string>::iterator iter = v.begin(); // assign an iterator to the 1st element v.push_back(“string2”); cout << s1; // access to a reference of the 1st element cout << *iter; // access to an iterator of the 1st element
Desde un punto de vista lógico, el código parece completamente correcto. Sin embargo, agregar el segundo elemento al vector puede resultar en la reasignación de la memoria del vector, lo que hará que tanto el iterador como la referencia no sean válidos y dará como resultado un error de violación de acceso al intentar acceder a ellos en las últimas 2 líneas.
Error común n.º 9: pasar un objeto por valor
Probablemente sepa que es una mala idea pasar objetos por valor debido a su impacto en el rendimiento. Muchos lo dejan así para evitar escribir caracteres extra, o probablemente piensen en volver más tarde para hacer la optimización. Por lo general, nunca se hace y, como resultado, conduce a un código de menor rendimiento y a un código que es propenso a un comportamiento inesperado:
class A { public: virtual std::string GetName() const {return "A";} … }; class B: public A { public: virtual std::string GetName() const {return "B";} ... }; void func1(A a) { std::string name = a.GetName(); ... } B b; func1(b);
Este código se compilará. Llamar a la función "func1" creará una copia parcial del objeto "b", es decir, copiará solo la parte de la clase "A" del objeto "b" al objeto "a" ("problema de corte"). Entonces, dentro de la función, también llamará a un método de la clase "A" en lugar de un método de la clase "B", que probablemente no sea lo que espera alguien que llama a la función.
Se producen problemas similares al intentar detectar excepciones. Por ejemplo:
class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }
Cuando se lanza una excepción de tipo ExceptionB desde la función "func2", será capturada por el bloque catch, pero debido al problema de corte, solo se copiará una parte de la clase ExceptionA, se llamará al método incorrecto y también se volverá a lanzar. lanzará una excepción incorrecta a un bloque externo de intento y captura.
Para resumir, siempre pase los objetos por referencia, no por valor.
Error común n.º 10: utilizar conversiones definidas por el usuario mediante constructores y operadores de conversión
Incluso las conversiones definidas por el usuario son muy útiles a veces, pero pueden dar lugar a conversiones imprevistas que son muy difíciles de localizar. Digamos que alguien creó una biblioteca que tiene una clase de cadena:
class String { public: String(int n); String(const char *s); …. }
El primer método está diseñado para crear una cadena de longitud n, y el segundo está diseñado para crear una cadena que contenga los caracteres dados. Pero el problema comienza tan pronto como tienes algo como esto:
String s1 = 123; String s2 = 'abc';
En el ejemplo anterior, s1 se convertirá en una cadena de tamaño 123, no en una cadena que contenga los caracteres "123". El segundo ejemplo contiene comillas simples en lugar de comillas dobles (que pueden ocurrir por accidente), lo que también dará como resultado la llamada del primer constructor y la creación de una cadena con un tamaño muy grande. Estos son ejemplos realmente simples, y hay muchos casos más complicados que generan confusión y conversiones imprevistas que son muy difíciles de encontrar. Hay 2 reglas generales de cómo evitar tales problemas:
Defina un constructor con palabra clave explícita para no permitir conversiones implícitas.
En lugar de utilizar operadores de conversión, utilice métodos de conversación explícitos. Requiere escribir un poco más, pero es mucho más limpio de leer y puede ayudar a evitar resultados impredecibles.
Conclusión
C++ es un lenguaje poderoso. De hecho, muchas de las aplicaciones que usa todos los días en su computadora y que le encantan, probablemente estén creadas con C++. Como lenguaje, C++ brinda una enorme cantidad de flexibilidad al desarrollador, a través de algunas de las características más sofisticadas que se ven en los lenguajes de programación orientados a objetos. Sin embargo, estas características o flexibilidades sofisticadas a menudo pueden convertirse en causa de confusión y frustración para muchos desarrolladores si no se usan de manera responsable. Con suerte, esta lista lo ayudará a comprender cómo algunos de estos errores comunes influyen en lo que puede lograr con C++.
Lecturas adicionales en el blog de ingeniería de Toptal:
- Cómo aprender los lenguajes C y C++: la lista definitiva
- C# frente a C++: ¿Qué hay en el núcleo?