quinta-feira, 7 de junho de 2007

Dica C++: Passagem correta de objetos em funções e métodos

Existem diferentes formas de se lidar com alocação de objetos e sua passagem para métodos e funções. A escolha pela maneira correta pode evitar ter que lidar com diversos problemas e, consequentemente, com bugs e dificuldades de depuração.

Vamos analisar alguns casos e ver como é possível ter implementações diferentes para um mesmo tipo de necessidade, e como determinado tipo de implementação pode interferir no resultado final, em aspectos de confiabilidade, segurança e performance.

Funções que recebem objetos por valor

Suponha que haja necessidade da criação de uma função qualquer que tome um objeto como parâmetro à fim de obter alguns de seus valores. Chamarei esta função de FuncaoXYZ:

void FuncaoXYZ(...);

Para a passagem de um objeto de uma classe qualquer (que chamarei de TClasseQualquer) por valor, temos algumas possibilidades para esta simples função:

(1) void FuncaoXYZ(TClasseQualquer objeto);

(2) void FuncaoXYZ(const TClasseQualquer objeto);

(3) void FuncaoXYZ(const TClasseQualquer *objeto);

(4) void FuncaoXYZ(const TClasseQualquer &objeto);

Neste caso, vemos como é importante o conhecimento da linguagem de programação na qual se está implementando. Este conhecimento influencia diretamente na escolha de implementação ótima e outra passível de problemas.

Nas funções acima, tanto (1) como (2) fazem uma cópia do objeto e trabalham com esta cópia dentro da função. Ou seja, a cada vez que a função é chamada ela precisa criar uma nova cópia do objeto. Isso pode consumir tempo e recursos, se o objeto for "grande" e esta operação for executada repetidas vezes, como dentro de um laço de repetição, por exemplo.

Em (1) também não há garantia de que a função não fará modificações no objeto, pois o mesmo não foi passado como uma constante. Mesmo que neste tipo de implementação ela esteja criando uma cópia do objeto original e por isso não irá modificá-lo, esse é um tipo de comportamento indesejado, que deveria ser evitado.

Em (3) não há certeza de que o objeto passado não é nulo (NULL). Logo, a função sempre deverá fazer o teste antes de acessar o objeto recebido, sob pena de acessar um endereço de memória inválido. Esse tipo de situação deve ser evitado, sendo portanto desaconselhável a passagem de objetos por ponteiro quando só desejamos acessar seus valores, sem alterá-lo.

Assim, em (4), chegamos a uma implementação que nos livra de ter que checar se o objeto recebido é nulo, por não ser um ponteiro, que garante que ele não poderá ser modificado dentro da função, por ser uma constante, e que não criará uma cópia do objeto recebido, já que foi criado um apelido (uma referência, ou seja &) para o objeto original.

Logo, utilizando a notação "const T&" (onde T é um tipo qualquer) para funções que recebem um objeto por valor, garantimos um determinado comportamento e assim saberemos, se algo der errado, que diversos tipos de condições (como as descritas acima) não irão ocorrer.

Se tomarmos esse tipo de implementação como regra, livramos o software desses problemas.

Métodos que recebem de objetos por valor

Como métodos são funções dentro de classes, podemos levar em consideração a mesma regra adotada para funções vista acima (notação "const T&").

Porém, há ainda um aspecto importante que devemos analisar: O que garante que o método, em sua implementação, não irá modificar os atributos da classe, caso desejado ?

Em C++ você dá esta garantia adicionando "const" após a declaração do método:

void FuncaoXYZ(
  const TClasseQualquer &objeto) const;

Assim, o compilador emite um erro se método tentar alterar qualquer atributo da classe.

Funções que recebem objetos por referência

Quando há a necessidade de alterar um objeto recebido por parâmetro, dentro de uma função, podemos fazê-lo declarando de uma das seguintes formas:

(1) void FuncaoXYZ(TClasseQualquer *objeto);

(2) void FuncaoXYZ(TClasseQualquer &objeto);

Como foi visto, fica claro a escolha de (2) pois não temos que testar se o objeto recebido é nulo. E também fica mais difícil de pensar em deletar o objeto da memória, já que não é um ponteiro.

Métodos que recebem objetos por referência

A mesma notação para funções pode ser aplicada, podendo ser adicionado o uso de const quando aplicável (quando não se desejar mudar o valor de algum atributo da classe dentro da implementação do método).

Outros usos

Como a notação de referência é útil para nos poupar dos aborrecimentos trazidos pelos ponteiros, podemos utilizá-la sempre que possível. Mesmo utilizando bibliotecas de terceiros que possuam a notação de ponteiro (*), podemos adaptar seu uso.

Por exemplo:

void TMinhaClasse::ReimplementacaoDoMetodoASDFG(
  const TAlgumaClasse *ponteiro)
{
    #ifdef _DEBUG // PRE-CONDICAO
    assert( ponteiro != NULL );  
    #endif

    const TAlgumaClasse &referencia = ponteiro;

    // trabalha com "referencia" ao inves
    // de "ponteiro"
    // ...

}

Ao invés de utilizar o ponteiro real, criamos uma referência para ele.

Vale lembrar que se a função aceitasse nulo (NULL) como um valor válido, não deveríamos substituir o ponteiro pela notação de referência. (Ou até poderíamos fazê-lo se o valor nulo pudesse ser tratado em um bloco de código separado (com um if, por exemplo), sendo um caso a parte, e o resto da função utilizasse o objeto como sendo não-nulo.)

Conclusão

A utilização correta dos recursos da linguagem pode trazer diversos benefícios ao software escrito, desde melhoria de performance até diminuição dos índices de erro e tempo de depuração.

No caso do uso de objetos em funções e métodos, evita problemas indesejáveis relacionados à alocação memória e acesso irrestrito à informações.

Sempre que aplicável, devemos utilizar parâmetros constantes e referências ao invés de ponteiros.

Um comentário:

Anônimo disse...

Aprendi muito