sexta-feira, 22 de junho de 2012

Aprenda aqui boas práticas de programação C++

Atualizado em 29 de junho de 2012
- Adicionei dica sobre retorno de objetos e contêineres por referência

Olá, programadores novatos em C++.

Este artigo é dedicado aos iniciantes em programação C++ e orientação a objetos em geral. Aqui você vai aprender algumas boas práticas de programação baseadas nos meus 10 anos de experiência como programador. Sem perder tempo, vamos logo às dicas e boas práticas:

Coloque cada classe no seu próprio arquivo

Para melhorar a manutenção do código, procure sempre manter dois arquivos para cada classe, um para declaração (arquivo .h ou .hpp) e outro para a implementação (arquivo .cpp). Evite colocar mais de uma classe num único arquivo, a não ser que as classes sejam extremamente dependentes uma da outra, tipo inseparáveis.

Separe em dois arquivos a declaração da classe e sua implementação

Em C++ costuma-se separar a definição de cada classe em dois arquivos: um para declaração (arquivo .h ou .hpp) e outro para a implementação (arquivo .cpp).

Sempre dê nomes descritivos aos identificadores

Se uma variável guarda o nome de um funcionário, o nome mais provável é nomeDoFuncionario ou nome_do_funcionario. Se um método calcula o salário, o nome mais óbvio seria calculaSalario() ou calcula_salario(). Dê nomes que façam sentido. O que você imagina que faz um método chamado calcSal() ou uma variável chamada nmFun? Não dá pra ter certeza por que os nomes não são claros o suficiente, então tente evitar abreviações excessivas e nomes ininteligíveis.

Use consistentemente alguma convenção para nomear identificadores

Em C++ a convenção mais usada é: palavras em nomes de classes e enumerações sempre começam com letra maiúscula e as restantes minúsculas. Nomes de variáveis e métodos sempre em letra minúscula e em nomes compostos cada palavra exceto a primeira começa com letra maiúscula. Constantes sempre com todas as letras maiúsculas e separando palavras com underscore. Exemplos:

class AlgumaClasse {};
enum AlgumEnum {};
void algumMetodo();
int algumaVariavel;
const int ALGUMA_CONSTANTE;

Use consistentemente um estilo de formatação que facilite a clareza do programa

Para melhorar a clareza e a legibilidade do seu código, é bom manter um estilo que seja convencional, como por exemplo, indentar usando a tecla TAB ou com no mínimo uns 3 espaços. Usar linhas em branco antes e depois da implementação de métodos, etc. Como no exemplo a seguir:

void X::metodoExemplo1()
{
     // Esta linha foi indentada.
}

// Este método foi separado do anterior por uma linha em branco
void X::metodoExemplo2()
{
     // Esta linha também foi indentada.
}

Comentários são valiosos, mas não abuse deles

Comentários podem tanto esclarecer o leitor do código como confundir, se você não os manter atualizado. De que adianta um comentário que está no código há uma década se o código já foi alterado mil vezes e nem sequer faz o que o comentário diz que ele faz? Se vai encher o código de comentários então os mantenha atualizados com o que o código realmente está fazendo. Outro detalhe, comentário demais é sinônimo de código ruim. Se você precisa comentar tanto assim pra explicar o que o código faz, provavelmente é porque o seu código em si não está claro o suficiente. Um óbvio exemplo de comentário supérfluo e desnecessário é o seguinte:

// Esta funcao calcula salario de um funcionario
void calculaSalarioDeUmFuncionario();

Evite números mágicos, usando constantes com nome

Números mágicos são aqueles números que aparecem às vezes no código e que não são dados introduzidos de fora do programa, são constantes numéricas que não mudam nunca. O problema é que quando você vê uma constante numérica assim perdida em qualquer lugar, dependendo do caso não é possível entender o que aquele número significa. Uma forma muito mais atraente e que clarifica o código é usar constantes com nome ou constantes simbólicas. Repare nesse trecho de código ruim usando números mágicos:

porta.defineEstado(2);
personagem.caminhar(4);
efeitoSonoro.tocar(43859301);

O que significa 2 para o método defineEstado()? O que quer dizer o 4 para o método caminhar()? E o que é aquele monstro 43859301 que o objeto efeitoSonoro está tocando? Com esses números mágicos fica difícil saber. Agora arrumando esse código para que use constantes simbólicas, veja se não melhora:

#define PORTA_ABERTA  1
#define PORTA_FECHADA 2
#define DIRECAO_OESTE 4
#define SOM_TIRO      43859301

porta.defineEstado( PORTA_ABERTA );
personagem.caminhar( DIRECAO_OESTE );
efeitoSonoro.tocar( SOM_TIRO );

Impossível não entender o que os métodos estão fazendo agora. Melhor ainda do que constantes simbólicas é usar enumerações quando possível, conforme a próxima dica revela.

Prefira enumerações a constantes simbólicas

Algumas vezes entre as constantes que usamos no programa existem valores relacionados que fazem parte de um conjunto lógico. Por exemplo, podemos ter códigos que representam o estado de algum objeto, índices particulares de arrays, etc. Ao invés de ter várias constantes simbólicas, que simplesmente são substituídas pelo preprocessador pelos números que elas representam e que não possuem sequer tipo definido, uma opção interessante é usar enumerações. Compare os exemplos:

Usando constantes simbólicas:

#define SOLIDO   0
#define LIQUIDO  1
#define GRANDE   3
#define MEDIO    2
#define PEQUENO  1
#define VERDE    1
#define VERMELHO 2
#define AZUL     3

Usando enumerações:

enum Estado { SOLIDO, LIQUIDO };
enum Tamanho { GRANDE, MEDIO, PEQUENO };
enum Cor { VERDE, VERMELHO, AZUL };

Bem mais descritivo e bonito, não acha? Mas não é só isso. Com enumerações você não precisa ficar definindo os valores porque o compilador sabe que os valores estão na ordem em que foram definidos na declaração, a não ser que você tenha mudado, como por exemplo:

enum TipoDeMonstro { TERRESTRE = 5, VOADOR = 10, AQUATICO = 20 };

Evite repetições de código sempre que possível

Sempre que você ver que no programa existe algum trecho de código que se repete em vários pontos, é sinal de que talvez aquele código poderia estar encapsulado em uma função, método ou classe. Exemplo de código repetido:

void temCodigoRepetidoAquiDentro()
{
     clear();
     color( RED, BLUE );
     print( "Ola mundo 1!" );
     wait( 400 );
     clear();
     color( GREEN, YELLOW );
     print( "Ola mundo 2!" );
     wait( 500 );
}

Na função acima, temos a duplicação de um trecho de código que poderia muito bem estar dentro de uma função separada, e esta função poderia estar sendo apenas invocada duas vezes com parâmetros diferentes. Compare agora o código melhorado, sem a repetição de código, com o acréscimo de uma nova função:

void escreveColoridoEAguarda( string texto, Cor corDaLetra, Cor corDoFundo, int pausa )
{
     clear();
     color( corDaLetra, corDoFundo );
     print( texto );
     wait( pausa );
}

void naoTemMaisCodigoRepetidoAquiDentro()
{
     escreveColoridoEAguarda( "Ola mundo 1!", RED, BLUE, 400 );
     escreveColoridoEAguarda( "Ola mundo 2!", GREEN, YELLOW, 400 );
}

Óbvio, esse exemplo é mínimo. Mas imagine se aquele código fosse repetido 200 vezes no programa? Sempre preste atenção em código repetido e elimine sempre que for possível substituí-lo por uma chamada de função. Outra vantagem é que, nesse exemplo, se precisar mudar a forma de escrever colorido, é só mudar o corpo da função, e não precisa mexer no resto do programa.

Escolha o tipo mais eficiente para variáveis primitivas

Sempre lembre-se da capacidade das variáveis primitivas em C++. Por exemplo, um char pode armazenar um valor entre -128 e 127. Um short pode armazenar um valor entre -32768 a 32767. Um int armazena um valor bem maior que isso. E se usar o modificador unsigned, um char armazena de 0 a 255, um short de 0 a 65535 e um int muito mais do que isso. Se você vai armazenar a idade de uma pessoa, nada mais simples do que um unsigned char. Sim, pois muito dificilmente o seu programa algum dia precisará cadastrar uma pessoa com idade superior a 255 anos, e não existe idade negativa. Se você optar por usar um int, é puro desperdício de memória, sendo que um char ocupa 1 único byte na memória, e um int geralmente ocupa 4 bytes. É importante seguir essa dica a não ser que por algum motivo, talvez pela arquitetura do programa, você precise realmente usar um int. E obviamente, se vai armazenar o CPF de uma pessoa, um int é pouco porque o número de algarismos em um número de CPF não costuma caber num int, e pra isso se usa o tipo long. Existem compiladores atualmente que otimizam o código e substituem os tipos das variáveis transparentemente quando possível, mas se você valoriza o seu trabalho não confie no compilador para fazê-lo por você.

Aprenda a usar os contêineres da STL ou de outras bibliotecas

Se você vem de um passado de programação em C, deve usar muitos arrays de char para simular strings, já que em C não existem "strings" reais, e sim arrays de caracteres com o último elemento sendo o caractere nulo (zero). Em C++ você não precisa disso. Existe a biblioteca STL (Standard Template Library), que apesar de não ser uma parte inseparável do C++, praticamente todo compilador a inclui porque ela fornece funcionalidades extremamente úteis. Na STL você encontra duas classes imprescindíveis e extremamente úteis no dia-a-dia: string e vector. Com a classe string você obtém strings reais e com a classe vector você obtém arrays de tamanho variável, então é importante conhecer e estudar esta biblioteca.

Ao retornar objetos grandes ou contêineres, retorne referências ao invés de cópias

Se no seu programa existe uma função ou método que retorna um objeto grande (isto é, com muitos membros de dados) ou um contêiner, como por exemplo um vetor, lista, pilha, etc. prefira retornar referências ao invés de cópias. Sim, porque dependendo do tamanho dos objetos, da quantidade de elementos no contêiner ou de quantas vezes essa função é invocada, o fato de retornar uma cópia pode reduzir significantemente a performance do seu programa! Isso porque ao retornar um objeto por cópia, o sistema cria um objeto temporário na memória, que ele vai preencher com uma cópia dos dados do objeto original, isto é, cada membro do objeto original será copiado para cada membro do objeto temporário, e a função retornará o objeto temporário que terá os membros com valores idênticos ao original. E isso leva tempo. Pior ainda se for um contêiner contendo dezenas, centenas ou milhares destes objetos, o sistema terá que copiar todos os membros um por um, de cada objeto no contênier, um de cada vez. Imagine a lentidão... Pois bem, como resolver isso? Retorne referências ao invés de cópias. Quando você retorna uma referência para um objeto ou contênier, o sistema não copia nada, a única coisa que ele faz é te retornar o objeto original. Não sabe como implementar isso? Coloque um sinal de referência (um "e" comercial, ou seja, &) entre o tipo retornado e o nome da função. Confira o exemplo de retorno por cópia e por referência de objetos e vetores de objetos:

Retornando por cópia, é mais devagar:

ObjetoGigante getObjetoGigante() { return objeto; }
std::vector<ObjetoGigante*> getObjetosGigantes() { return objetos; }

Retornando por referência, é mais rápido:

ObjetoGigante& getObjetoGigante() { return objeto; }
std::vector<ObjetoGigante*>& getObjetosGigantes() { return objetos; }

Quando uma variável não deve ser modificada, declare-a como const

Se você tem uma variável cujo valor você sabe que não pode nunca ser alterado, então não deveria ser uma variável, e sim uma constante. Se não quer que uma variável seja alterada, declare ao invés disso uma constante e o problema está resolvido. Mas se você tem um objeto que não pode ser constante, precisa passá-lo por parâmetro a algum método, e não quer correr o risco de que ele seja alterado lá dentro, então uma opção simples é declarar o parâmetro como sendo const e pronto.

Ponteiros devem ser sempre inicializados

Todo ponteiro deve ser inicializado na declaração para evitar que ele seja utilizado incorretamente. Se você tenta acessar alguma variável ou objeto através de um ponteiro não inicializado, estará acessando memória desconhecida, com conteúdo desconhecido, que pode ser absolutamente qualquer coisa, inclusive o seu querido sistema operacional. Um ponteiro não inicializado é conhecido popularmente como "wild pointer" ou "ponteiro selvagem", porque ele pode estar apontando pra qualquer lugar desconhecido. Geralmente, graças ao sistema de proteção de memória implementado em certos sistemas operacionais, o máximo que acontece ao tentar usar um ponteiro selvagem é um erro fatal de segmentação, mais conhecido como "segmentation fault", e o programa quebra na hora. Mas nunca se sabe quando pode travar tudo e/ou aparecer a temida tela azul da morte. Portanto, não brinque com ponteiros. Se não tiver como inicializar um ponteiro logo na declaração, então aponte-o para NULL e problema resolvido. Exemplo:

// ISSO ESTA ERRADO E PODE QUEBRAR O PROGRAMA:
Objeto* ptrObjeto;
ptrObjeto->morteCerta();

// ISSO ESTA CERTO
Objeto* ptrObjeto = NULL;
ptrObjeto = new Objeto();
ptrObjeto->semMorte();

Um detalhe importante é que, se você inicializa um ponteiro com NULL, obviamente você não tem nenhum objeto alocado por lá e não pode usá-lo. Tentar usar um ponteiro NULL é um erro extremamente besta chamado de "null pointer error", ou "null pointer exception" como é muito comum em linguagens como Java onde os ponteiros são implícitos e é muito fácil esquecer desse detalhe.

Delete e anule sempre os ponteiros quando terminar de utilizá-los

Sempre que você instancia um objeto através de um ponteiro, o sistema tenta alocar memória suficiente para armazená-lo. Se o ponteiro sai de escopo, a memória que tinha sido alocada continua lá sendo ocupada pelo que restou do objeto. Se isso acontecer inúmeras vezes, uma hora cedo ou tarde não vai mais ter memória para alocar, e o programa vai morrer uma morte agonizante. Para poupar o seu programa deste trágico destino, sempre libere a memória que for alocada, usando o operador delete no ponteiro. Outro problema que pode causar uma morte horrível ao seu programa é quando você usa um ponteiro que nunca sai de escopo (tipo um ponteiro global), e então dentro de alguma função você aponta o ponteiro para um objeto local. Quando esse objeto local sai de escopo, a memória é liberada mas o ponteiro permanece lá apontando pra seja lá o que o sistema tiver enfiado naquele lugar, podendo ser qualquer coisa. Se você tenta usar o ponteiro perdido pra qualquer coisa, o programa morre na hora, ou então trava tudo, ou qualquer outro fenômeno macabro. Esse erro é relativamente comum, e costumamos chamá-lo de "dangling pointer", ou "ponteiro pendente", então se você gosta do seu programa, tenha cuidado para não matá-lo assim. Anule (atribua NULL) o ponteiro para salvá-lo.

Passe objetos grandes sempre por referência ou ponteiro

Passar parâmetros para funções e métodos leva tempo. Dependendo do tamanho do objeto, e dependendo de quantas vezes esse objeto é passado inteiro por parâmetro, isso pode afetar negativamente a performance do seu programa. Eu vi isso muitas vezes na prática e esse problema é muito real. Quando o programa tem que rodar rapidamente, você não pode desperdiçar tempo passando objetos inteiros por parâmetro. O que fazer então? Simples, passe uma referência ou um ponteiro. Um ponteiro contém apenas um número qualquer, tipo um inteiro, que é o endereço do objeto para o qual ele aponta, independente do tamanho. Então quando a performance do seu programa for um fator importante, sempre passe objetos grandes como referência ou ponteiro.

Evite longas listas de parâmetros, prefira passar referência a um único objeto

Se você tem um método ou função que recebe 10 parâmetros, pare e pense na possibilidade de passar uma estrutura contendo esses parâmetros. É muito mais fácil lembrar que uma função requer como parâmetro uma única estrutura qualquer do que 5 ou 10 objetos diferentes. Exemplo de função com lista de parâmetros excessivamente longa:

void cadastraPessoa( string nome, unsigned char idade, long cpf, long rg, string endereco );

Se essa função cadastra uma pessoa, ao invés de passar todos os dados da pessoa como parâmetros, crie uma estrutura que encapsule esses dados e passe uma referência da estrutura por parâmetro. Desta maneira assinatura da função ficaria assim:

void cadastraPessoa( Pessoa& p );

É bem mais curto, simples, e rápido assim.

Lembre-se que objetos passados por valor nunca são alterados

Se você quer que uma variável seja alterada dentro de uma função, você tem que passá-la por referência, e não por valor. Se você passa uma variável por valor, o valor é copiado pra dentro da função mas a variável original não fica acessível por lá. Repare os exemplos:

// Essa função apenas copia o valor da variável
void copiaValorDaVariavel( int var );

// Essa função consegue alterar o valor da variável
void alteraValorDaVariavel( int& var );

O erro de passar variáveis por valor e tentar manipulá-las dentro de uma função parece besta mas é muito comum, então não caia nessa.

Declare os membros de uma classe sempre como sendo privados

Uma das idéias principais por trás da orientação a objetos é que as classes encapsulam dados e métodos para operar sobre esses dados. Se você tem uma classe onde todos os membros são públicos, você está desrespeitando o princípio do encapsulamento, ou seja, desviando de um propósito crucial da classe, que é manter o controle sobre seus membros, já que membros públicos podem ser acessados de qualquer forma em qualquer lugar onde um objeto da classe esteja acessível. Membros de classe são sempre privados. Para fornecer acesso controlado aos membros da classe, a solução é implementar métodos de acesso que sejam públicos, conforme está descrito na próxima dica.

Crie métodos de acesso para controlar alterações nos membros privados

Se você deixa todos os membros de dados da classe como privados, então como acessá-los? Aí é que entram os métodos de acesso. Para cada membro de dados ao qual seja permitido o acesso, você implementa métodos que alterem ou retornem o seu valor. A diferença está no fato de que, quando o acesso é feito apenas através de métodos, é possível controlar e restringir as alterações que podem ser feitas em um objeto, evitando assim a manipulação indevida dele e mantendo a integridade de seus dados. Confira o exemplo de uma classe que encapsula, protege e cuida de seus membros:

class ClasseProtetora
{
private:
     int membroProtegido;

public:
     int getValorDoMembroProtegido();
     void setValorDoMembroProtegido( int valor );
};

Declare métodos auxiliares como sendo privados

Muitas vezes dentro de uma classe precisamos escrever métodos que só servem para auxiliar na construção da classe ou tratam apenas de trabalhar em conjunto com outros métodos. Estes métodos muitas vezes não precisam ser acessados de fora da classe. Quando esse for o caso, declare esses métodos como sendo privados, para deixar claro que eles não foram feitos para serem acessados externamente, que eles são apenas auxiliares.

Em uma hierarquia declare destrutores de superclasse sempre como sendo virtuais

Explicando da forma mais simples possível, digamos que você tenha uma hierarquia de classes, isto é, classes que derivam (ou em outras palavras, herdam) de outras classes. Então imagine que você use um ponteiro da superclasse para instanciar um objeto da subclasse (que é perfeitamente possível graças ao polimorfismo). Digamos que depois de usar o objeto você use o operador delete para destruí-lo através do ponteiro. O que acontece? Na hora de chamar o destrutor da classe por padrão somente o destrutor da classe derivada é invocado. Por isso é importante sempre declarar o destrutor da superclasse como virtual, para que quando um objeto de subclasse for destruído, o sistema seja capaz de invocar os destrutores tanto da subclasse quanto o da superclasse.

Cada método deve ter somente uma única responsabilidade

Ao implementar um método, sempre lembre-se de verificar que ele executa uma única tarefa. Se você perceber que um método está fazendo coisas que não tem relação nenhuma entre si, é sinal de que é preciso dividí-lo em dois ou mais métodos.

Cada classe deve ter somente uma única responsabilidade

Essa serve como corolário da dica acima, cada classe deve ser responsável por um único aspecto do seu programa. Se o seu programa em C++ consiste numa única classe que faz tudo, então o seu programa não é orientado a objetos. Dividir as responsabilidades do programa entre distintas classes focadas que tratam de uma única tarefa ou de tarefas íntimamente relacionadas é um dos maiores desafios na hora de desenvolver orientado a objetos, mas cuja recompensa é enorme, por causa da grande facilidade de manutenção e da compartimentalização do programa.

Prefira composição a herança

Herança é quando você deriva uma classe maior a partir de outra classe já existente, ou em outras palavras, quando você estende uma classe já existente adicionando novos membros para criar uma nova classe maior e mais especializada. Já a composição é quando você inclui membros em uma classe que são instâncias ou referências a objetos de outras classes. Em certos casos, é possível modelar um problema usando herança ou composição. Sempre que você tiver essa escolha, prefira composição. Composição poupa você de ter que lembrar que classes herdam de quais outras classes, que dependendo do tamanho e profundidade da sua hierarquia de classes pode se tornar um pesadelo na hora de manter.

Polimorfismo só ocorre com método virtual

Em C++ o polimorfismo através dos métodos das classes numa hierarquia só ocorre quando esses métodos são declarados como virtual na superclasse. Confira o exemplo:

// Não ocorre polimorfismo com essa hierarquia:
class A
{
     void morph();
};

class B
{
     void morph();
};

int main()
{
     A* ptrA = new B();
     ptrA->morph();
}

No exemplo acima, o método invocado será o da classe A, porque o sistema está se baseando no tipo do ponteiro. Isso não é polimorfismo. Polimorfismo no C++ é, na prática, quando você consegue acessar o método da subclasse através de um ponteiro de superclasse, desde que ele aponte para um objeto de subclasse. Para ocorrer o polimorfismo nesse exemplo, basta incluir a declaração virtual no método morph() da superclasse, ficando assim:

// Agora sim ocorre polimorfismo com essa hierarquia:
class A
{
     virtual void morph();
};

O erro de tentar obter polimorfismo de um método não-virtual é muito comum, então lembre-se sempre de declará-lo virtual na superclasse para ver a mágica polimórfica diante dos seus olhos!



E aí gostaram das dicas? Pode ser que eu atualize este artigo com mais dicas e boas práticas de programação. Fique ligado! Em caso de dúvidas, sugestões ou correções, deixe seu comentário.