Os 10 erros mais comuns de C++ que os desenvolvedores cometem
Publicados: 2022-03-11Há muitas armadilhas que um desenvolvedor C++ pode encontrar. Isso pode tornar a programação de qualidade muito difícil e a manutenção muito cara. Aprender a sintaxe da linguagem e ter boas habilidades de programação em linguagens semelhantes, como C# e Java, não é suficiente para utilizar todo o potencial do C++. Requer anos de experiência e muita disciplina para evitar erros em C++. Neste artigo, vamos dar uma olhada em alguns dos erros comuns que são cometidos por desenvolvedores de todos os níveis se não forem cuidadosos o suficiente com o desenvolvimento em C++.
Erro comum nº 1: usar pares “novo” e “excluir” incorretamente
Não importa o quanto tentemos, é muito difícil liberar toda a memória alocada dinamicamente. Mesmo que possamos fazer isso, muitas vezes não está a salvo de exceções. Vejamos um exemplo simples:
void SomeMethod() { ClassA *a = new ClassA; SomeOtherMethod(); // it can throw an exception delete a; }Se uma exceção for lançada, o objeto “a” nunca será excluído. O exemplo a seguir mostra uma maneira mais segura e mais curta de fazer isso. Ele usa auto_ptr, que está obsoleto no C++ 11, mas o padrão antigo ainda é amplamente usado. Ele pode ser substituído por C++11 unique_ptr ou scoped_ptr do Boost, se possível.
void SomeMethod() { std::auto_ptr<ClassA> a(new ClassA); // deprecated, please check the text SomeOtherMethod(); // it can throw an exception }Não importa o que aconteça, após criar o objeto “a” ele será deletado assim que a execução do programa sair do escopo.
No entanto, este foi apenas o exemplo mais simples deste problema C++. Existem muitos exemplos em que a exclusão deve ser feita em algum outro lugar, talvez em uma função externa ou em outro encadeamento. É por isso que o uso de new/delete em pares deve ser completamente evitado e ponteiros inteligentes apropriados devem ser usados.
Erro comum nº 2: Destruidor virtual esquecido
Este é um dos erros mais comuns que leva a vazamentos de memória dentro de classes derivadas se houver memória dinâmica alocada dentro delas. Existem alguns casos em que o destruidor virtual não é desejável, ou seja, quando uma classe não é destinada à herança e seu tamanho e desempenho são cruciais. Destruidor virtual ou qualquer outra função virtual introduz dados adicionais dentro de uma estrutura de classe, ou seja, um ponteiro para uma tabela virtual que aumenta o tamanho de qualquer instância da classe.
No entanto, na maioria dos casos, as classes podem ser herdadas mesmo que não tenham sido originalmente planejadas. Portanto, é uma prática muito boa adicionar um destruidor virtual quando uma classe é declarada. Caso contrário, se uma classe não deve conter funções virtuais por motivos de desempenho, é uma boa prática colocar um comentário dentro de um arquivo de declaração de classe indicando que a classe não deve ser herdada. Uma das melhores opções para evitar esse problema é usar um IDE que suporte a criação de um destruidor virtual durante a criação de uma classe.
Um ponto adicional ao assunto são as classes/templates da biblioteca padrão. Eles não são destinados à herança e não possuem um destruidor virtual. Se, por exemplo, criarmos uma nova classe de string aprimorada que herda publicamente de std::string, existe a possibilidade de alguém usá-la incorretamente com um ponteiro ou uma referência a std::string e causar um vazamento de memória.
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 esses problemas de C++, uma maneira mais segura de reutilizar uma classe/modelo da biblioteca padrão é usar herança ou composição privada.
Erro comum nº 3: excluindo uma matriz com “delete” ou usando um ponteiro inteligente
Muitas vezes é necessário criar arrays temporários de tamanho dinâmico. Depois que eles não forem mais necessários, é importante liberar a memória alocada. O grande problema aqui é que C++ requer um operador delete especial com colchetes [], que é esquecido com muita facilidade. O operador delete[] não apenas excluirá a memória alocada para um array, mas também chamará destruidores de todos os objetos de um array. Também é incorreto usar o operador delete sem colchetes [] para tipos primitivos, mesmo que não haja um destruidor para esses tipos. Não há garantia para cada compilador de que um ponteiro para uma matriz aponte para o primeiro elemento da matriz, portanto, usar delete sem colchetes [] também pode resultar em comportamento indefinido.
Usar ponteiros inteligentes, como auto_ptr, unique_ptr<T>, shared_ptr, com arrays também está incorreto. Quando esse ponteiro inteligente sai de um escopo, ele chama um operador de exclusão sem colchetes [] que resulta nos mesmos problemas descritos acima. Se o uso de um ponteiro inteligente for necessário para uma matriz, é possível usar scoped_array ou shared_array do Boost ou uma especialização unique_ptr<T[]>.
Se a funcionalidade de contagem de referência não for necessária, o que é principalmente o caso de arrays, a maneira mais elegante é usar vetores STL. Eles não cuidam apenas de liberar memória, mas também oferecem funcionalidades adicionais.
Erro comum nº 4: retornando um objeto local por referência
Este é principalmente um erro de iniciante, mas vale a pena mencionar, pois há muito código legado que sofre com esse problema. Vejamos o código a seguir onde um programador queria fazer algum tipo de otimização evitando cópias desnecessárias:
Complex& SumComplex(const Complex& a, const Complex& b) { Complex result; ….. return result; } Complex& sum = SumComplex(a, b);O objeto “soma” agora apontará para o objeto local “resultado”. Mas onde está localizado o objeto “resultado” após a execução da função SumComplex? Lugar algum. Ele estava localizado na pilha, mas depois que a função retornou, a pilha foi desempacotada e todos os objetos locais da função foram destruídos. Isso eventualmente resultará em um comportamento indefinido, mesmo para tipos primitivos. Para evitar problemas de desempenho, às vezes é possível usar a otimização do 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 a maioria dos compiladores de hoje, se uma linha de retorno contém um construtor de um objeto, o código será otimizado para evitar todas as cópias desnecessárias - o construtor será executado diretamente no objeto “sum”.
Erro comum nº 5: usando uma referência a um recurso excluído
Esses problemas de C++ acontecem com mais frequência do que você imagina e geralmente são vistos em aplicativos multithread. Consideremos o seguinte código:
Tópico 1:
Connection& connection= connections.GetConnection(connectionId); // ...Tópico 2:
connections.DeleteConnection(connectionId); // …Tópico 1:
connection.send(data);Neste exemplo, se ambos os encadeamentos usarem o mesmo ID de conexão, isso resultará em um comportamento indefinido. Erros de violação de acesso geralmente são muito difíceis de encontrar.
Nesses casos, quando mais de uma thread acessa o mesmo recurso é muito arriscado manter ponteiros ou referências aos recursos, pois alguma outra thread pode excluí-lo. É muito mais seguro usar ponteiros inteligentes com contagem de referência, por exemplo shared_ptr do Boost. Ele usa operações atômicas para aumentar/diminuir um contador de referência, portanto, é seguro para threads.
Erro comum nº 6: permitir que exceções deixem os destruidores
Não é frequentemente necessário lançar uma exceção de um destruidor. Mesmo assim, há uma maneira melhor de fazer isso. No entanto, as exceções geralmente não são lançadas de destruidores explicitamente. Pode acontecer que um simples comando para registrar a destruição de um objeto cause um lançamento de exceção. Vamos considerar o seguinte 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"; }No código acima, se a exceção ocorrer duas vezes, como durante a destruição de ambos os objetos, a instrução catch nunca será executada. Como há duas exceções em paralelo, não importa se são do mesmo tipo ou de tipo diferente, o ambiente de tempo de execução C++ não sabe como lidar com isso e chama uma função de término que resulta no término da execução de um programa.
Portanto, a regra geral é: nunca permita que exceções deixem destruidores. Mesmo que seja feio, a exceção em potencial deve ser protegida assim:
try { writeToLog(); // could cause an exception to be thrown } catch (...) {}Erro comum nº 7: usando “auto_ptr” (incorretamente)
O modelo auto_ptr foi preterido do C++11 por vários motivos. Ele ainda é amplamente utilizado, pois a maioria dos projetos ainda está sendo desenvolvida em C++98. Ele tem uma certa característica que provavelmente não é familiar a todos os desenvolvedores de C++, e pode causar sérios problemas para quem não for cuidadoso. A cópia do objeto auto_ptr transferirá uma propriedade de um objeto para outro. Por exemplo, o seguinte 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á em um erro de violação de acesso. Apenas o objeto “b” conterá um ponteiro para o objeto da Classe A, enquanto “a” ficará vazio. Tentar acessar um membro de classe do objeto “a” resultará em um erro de violação de acesso. Existem muitas maneiras de usar o auto_ptr incorretamente. Quatro coisas muito importantes para lembrar sobre eles são:
Nunca use auto_ptr dentro de contêineres STL. A cópia de contêineres deixará os contêineres de origem com dados inválidos. Alguns algoritmos STL também podem levar à invalidação de “auto_ptr”s.
Nunca use auto_ptr como argumento de função, pois isso levará à cópia e deixará o valor passado para o argumento inválido após a chamada da função.
Se auto_ptr for usado para membros de dados de uma classe, certifique-se de fazer uma cópia adequada dentro de um construtor de cópia e um operador de atribuição, ou desabilite essas operações tornando-as privadas.
Sempre que possível, use algum outro ponteiro inteligente moderno em vez de auto_ptr.
Erro comum nº 8: usando iteradores e referências inválidos
Seria possível escrever um livro inteiro sobre este assunto. Cada contêiner STL tem algumas condições específicas nas quais invalida iteradores e referências. É importante estar ciente desses detalhes ao usar qualquer operação. Assim como o problema anterior do C++, este também pode ocorrer com muita frequência em ambientes multithread, por isso é necessário usar mecanismos de sincronização para evitá-lo. Vamos ver o seguinte código sequencial como exemplo:
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 elementDo ponto de vista lógico, o código parece completamente bom. No entanto, adicionar o segundo elemento ao vetor pode resultar na realocação da memória do vetor, o que tornará o iterador e a referência inválidos e resultará em um erro de violação de acesso ao tentar acessá-los nas últimas 2 linhas.
Erro comum nº 9: passando um objeto por valor
Você provavelmente sabe que é uma má ideia passar objetos por valor devido ao seu impacto no desempenho. Muitos deixam assim para evitar digitar caracteres extras, ou provavelmente pensam em retornar mais tarde para fazer a otimização. Geralmente, isso nunca é feito e, como resultado, leva a um código de menor desempenho e a um código propenso a comportamentos inesperados:
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 irá compilar. A chamada da função “func1” criará uma cópia parcial do objeto “b”, ou seja, copiará apenas a parte da classe “A” do objeto “b” para o objeto “a” (“problema de fatiamento”). Portanto, dentro da função, ele também chamará um método da classe “A” em vez de um método da classe “B”, o que provavelmente não é o esperado por alguém que chama a função.
Problemas semelhantes ocorrem ao tentar capturar exceções. Por exemplo:
class ExceptionA: public std::exception; class ExceptionB: public ExceptionA; try { func2(); // can throw an ExceptionB exception } catch (ExceptionA ex) { writeToLog(ex.GetDescription()); throw; }Quando uma exceção do tipo ExceptionB é lançada da função “func2” ela será capturada pelo bloco catch, mas por causa do problema de fatiamento apenas uma parte da classe ExceptionA será copiada, método incorreto será chamado e também relançado lançará uma exceção incorreta para um bloco try-catch externo.
Para resumir, sempre passe objetos por referência, não por valor.
Erro comum nº 10: usando conversões definidas pelo usuário por construtores e operadores de conversão
Mesmo as conversões definidas pelo usuário são muito úteis às vezes, mas podem levar a conversões imprevistas que são muito difíceis de localizar. Digamos que alguém criou uma biblioteca que tem uma classe string:
class String { public: String(int n); String(const char *s); …. }O primeiro método destina-se a criar uma string de comprimento n, e o segundo destina-se a criar uma string contendo os caracteres fornecidos. Mas o problema começa assim que você tem algo assim:
String s1 = 123; String s2 = 'abc';No exemplo acima, s1 se tornará uma string de tamanho 123, não uma string que contém os caracteres “123”. O segundo exemplo contém aspas simples em vez de aspas duplas (o que pode acontecer por acidente), o que também resultará na chamada do primeiro construtor e na criação de uma string com um tamanho muito grande. Esses são exemplos realmente simples, e há muitos casos mais complicados que levam à confusão e conversões imprevistas que são muito difíceis de encontrar. Existem 2 regras gerais de como evitar tais problemas:
Defina um construtor com palavra-chave explícita para não permitir conversões implícitas.
Em vez de usar operadores de conversão, use métodos de conversação explícitos. Requer um pouco mais de digitação, mas é muito mais fácil de ler e pode ajudar a evitar resultados imprevisíveis.
Conclusão
C++ é uma linguagem poderosa. Na verdade, muitos dos aplicativos que você usa todos os dias em seu computador e adora são provavelmente criados usando C++. Como linguagem, C++ oferece uma enorme flexibilidade ao desenvolvedor, por meio de alguns dos recursos mais sofisticados vistos em linguagens de programação orientadas a objetos. No entanto, esses recursos ou flexibilidades sofisticados muitas vezes podem se tornar a causa de confusão e frustração para muitos desenvolvedores se não forem usados com responsabilidade. Espero que esta lista o ajude a entender como alguns desses erros comuns influenciam o que você pode alcançar com C++.
Leitura adicional no Blog da Toptal Engineering:
- Como aprender as linguagens C e C++: a lista definitiva
- C# vs. C++: O que está no núcleo?
