Cleverton Bueno
← Voltar para todos os artigos

Passando structs e classes como parametros

Este texto demonstra por que passar dados complexos como structs e classes por valor é ineficiente e perigoso, estabelecendo a passagem por referência constante (const&) como o padrão-ouro do C++ moderno.

No nosso artigo anterior, estabelecemos os três pilares da passagem de parâmetros usando tipos simples, como um int. Vimos a segurança da cópia (valor), o poder do endereço (ponteiro) e a elegância do apelido (referência).

Agora, vamos sair do mundo das variáveis únicas e entrar no mundo real da programação, onde os dados quase sempre andam em grupo. Precisamos de uma forma de agrupar um ID de produto com seu preço e seu nome; os dados de um funcionário com seu salário e cargo. Em C e C++, a ferramenta mais fundamental para isso é a struct.

As regras que aprendemos ainda se aplicam, mas as consequências de escolher o pilar errado se tornam muito mais dramáticas.

1: A struct e o Custo Oculto da Cópia

Vamos começar com a struct, uma herança direta da linguagem C e uma ferramenta essencial no arsenal de qualquer programador C++.

O que é uma struct?

Pense em uma struct como uma "ficha de cadastro" ou uma "caixa de dados". É uma forma de declarar um novo tipo que agrupa diferentes variáveis sob um único nome. Em vez de ter variáveis soltas como id_produto, preco_produto e nome_produto, podemos organizá-las de forma lógica:

Snippet de Código 1:

#include <string>

// Definimos um novo tipo chamado 'Produto'
struct Produto {
    int id;
    double preco;
    std::string nome;
};

Agora, Produto é um tipo em nosso programa, assim como int ou double, mas muito mais descritivo.

A Forma Intuitiva (e a Armadilha da Ineficiência)

Seguindo o que aprendemos, a forma mais segura e padrão de passar um parâmetro é por valor. Então, nosso primeiro instinto para criar uma função que exiba os dados de um produto seria:

Snippet de Código 2:

void imprimirProduto(Produto p) { ... }

O problema é que a nossa analogia da "fotocópia" agora tem um peso muito maior.

  • Tirar a cópia de um int é como copiar um número de telefone em um post-it. É instantâneo.
  • Tirar a cópia de uma struct Produto é como levar uma enciclopédia inteira para a copiadora e reproduzir cada uma de suas páginas. O processo é lento e gasta muito "papel" (memória).

Cada vez que você chama a função, o programa precisa alocar espaço na memória e copiar, byte por byte, todos os membros da struct original para a nova struct parâmetro. Isso é um custo de performance oculto, uma armadilha que pode deixar programas complexos muito lentos.

Vendo na Prática

O código abaixo parece inofensivo, mas esconde essa operação de cópia pesada.

Snippet de Código 3:

#include <iostream>
#include <string>

// Nossa ficha de cadastro para um produto
struct Produto {
    int id;
    double preco;
    std::string nome;
};

// Esta função recebe uma CÓPIA COMPLETA da struct Produto.
void imprimirProdutoPorValor(Produto p) {
    std::cout << "--- Imprimindo Produto (Copia) ---" << std::endl;
    std::cout << "ID: " << p.id << std::endl;
    std::cout << "Nome: " << p.nome << std::endl;
    std::cout << "Preco: R$ " << p.preco << std::endl;
    std::cout << "------------------------------------" << std::endl;
}

int main() {
    Produto meu_produto = { 101, 19.99, "Livro de C++ Moderno" };
    
    std::cout << "Chamando a funcao para imprimir o produto..." << std::endl;

    // NESTA LINHA, A TOTALIDADE DA STRUCT 'meu_produto' ESTÁ SENDO COPIADA
    // para o parâmetro 'p' da função. Isso pode ser muito custoso!
    imprimirProdutoPorValor(meu_produto);
    
    std::cout << "Funcao concluida." << std::endl;

    return 0;
}

Embora o programa funcione perfeitamente, ele está fazendo um trabalho desnecessário a cada chamada da função. Para um único produto, é imperceptível. Para um relatório com milhares de produtos, a diferença de performance seria gritante.

Felizmente, não precisamos gastar toda essa "tinta e papel" da nossa memória. Usando os outros dois pilares que já conhecemos — ponteiros e referências — podemos dar à nossa função acesso ao "documento original" de forma eficiente e segura.

A seguir, vamos refatorar nosso exemplo e explorar a forma correta e profissional de passar structs como parâmetros.

2: A Forma Correta de Passar structs

No bloco anterior, percebemos que passar uma struct por valor é como fotocopiar uma enciclopédia: funciona, mas é um desperdício de tempo e recursos. Para resolver isso, precisamos de um método que permita à nossa função aceder aos dados originais sem criar uma cópia. Já conhecemos as ferramentas para isso: ponteiros e referências.

Solução #1 - O Estilo C (Passagem por Ponteiro)

A abordagem tradicional em C é passar um ponteiro para a struct. Em vez de enviar a "enciclopédia" inteira, enviamos apenas o "endereço da prateleira" onde ela se encontra.

Snippet de Código 4:

// A função agora recebe um ponteiro para um Produto
void imprimirProdutoPorPonteiro(Produto* p) {
    // É crucial verificar se o ponteiro não é nulo!
    if (p != nullptr) {
        std::cout << "--- Imprimindo Produto (Ponteiro) ---" << std::endl;
        // Usamos o operador -> para aceder aos membros através de um ponteiro
        std::cout << "ID: " << p->id << std::endl;
        std::cout << "Nome: " << p->nome << std::endl;
        std::cout << "Preco: R$ " << p->preco << std::endl;
        std::cout << "------------------------------------" << std::endl;
    }
}

// Na chamada da função:
// Precisamos de passar o endereço da nossa struct usando o operador &
imprimirProdutoPorPonteiro(&meu_produto);

Vantagens e Desvantagens:

  • Vantagem: Extremamente eficiente. Apenas o endereço (4 ou 8 bytes) é copiado, independentemente do tamanho da struct.
  • Desvantagem: Traz de volta a sintaxe mais complexa (-> em vez de .) e a responsabilidade de verificar se o ponteiro não é nulo para evitar crashes.

Solução #2 - O Estilo C++ (Passagem por Referência)

O C++ oferece uma solução mais limpa e segura: a referência. Como vimos, uma referência é um "apelido". Passamos à função um apelido para a nossa struct original.

Snippet de Código 5:

// A função agora recebe uma referência para um Produto
void imprimirProdutoPorReferencia(Produto& p) {
    std::cout << "--- Imprimindo Produto (Referencia) ---" << std::endl;
    // Usamos o operador . normalmente, como se fosse o objeto original
    std::cout << "ID: " << p.id << std::endl;
    std::cout << "Nome: " << p.nome << std::endl;
    std::cout << "Preco: R$ " << p.preco << std::endl;
    std::cout << "------------------------------------" << std::endl;
}

// Na chamada da função:
// Passamos o objeto diretamente. A sintaxe é limpa!
imprimirProdutoPorReferencia(meu_produto);

Vantagem:

  • Tão eficiente quanto um ponteiro, mas com a sintaxe limpa e simples da passagem por valor. Além disso, uma referência não pode ser nula, o que elimina uma classe inteira de erros.

Neste ponto, parece que a referência é a solução perfeita. Mas ainda há um pequeno problema de segurança. Tanto a versão com ponteiro quanto a com referência dão à função imprimirProduto a capacidade não só de ler, mas também de alterar o produto original.

Snippet de Código 6:

p.preco = 0; // Isto alteraria o preço do meu_produto original!

Isto viola o princípio da "mínima surpresa". Uma função chamada imprimir não deveria ter o poder de modificar nada. Como podemos obter a eficiência da referência, mas com a segurança da passagem por valor?

O Padrão Ouro: A Referência Constante (const&)

A solução é o padrão de ouro do C++ moderno para passar dados complexos: a referência constante. Ao adicionar a palavra-chave const antes da declaração da referência, fazemos uma promessa ao compilador.

Snippet de Código 7:

const Produto& p

Esta declaração significa: "Eu quero um apelido eficiente para o objeto original, mas prometo que não o vou usar para modificar nada". Se você tentar quebrar essa promessa, o compilador irá gerar um erro de compilação, protegendo os seus dados.

Este é o melhor dos dois mundos:

  • Eficiência: Nenhuma cópia pesada é feita (como um ponteiro/referência).
  • Segurança: O objeto original está protegido contra modificações (como na passagem por valor).

O Código Final e Ideal:

Snippet de Código 8:

#include <iostream>
#include <string>

struct Produto {
    int id;
    double preco;
    std::string nome;
};

// A forma ideal: eficiente e segura.
void imprimirProduto(const Produto& p) {
    std::cout << "--- Imprimindo Produto (const&) ---" << std::endl;
    std::cout << "ID: " << p.id << std::endl;
    std::cout << "Nome: " << p.nome << std::endl;
    std::cout << "Preco: R$ " << p.preco << std::endl;

    // A linha abaixo causaria um ERRO DE COMPILAÇÃO, o que é ótimo!
    // p.preco = 0; // ERRO: não se pode atribuir a um membro de uma referência const
    std::cout << "------------------------------------" << std::endl;
}

int main() {
    Produto meu_produto = { 101, 19.99, "Livro de C++ Moderno" };
    imprimirProduto(meu_produto);
    return 0;
}

Com este conhecimento, já dominamos a forma profissional de passar structs. Mas o que acontece quando saímos das simples "caixas de dados" e entramos no mundo da Programação Orientada a Objetos com classes?

Indo adiante, veremos que, embora a mecânica seja a mesma, as implicações de passar classes por valor são ainda mais profundas e podem levar a erros subtis e perigosos.

3. O Mundo das classes e as Suas Nuances

Até agora, focamos nas structs. Um programador C++ experiente, no entanto, diria que, embora tudo o que aprendemos se aplique a classes, as razões para seguir estas regras são ainda mais fortes e as consequências de as ignorar são muito mais graves.

struct vs. class: Uma Questão de Intenção

Tecnicamente, em C++, a única diferença entre uma struct e uma class é o nível de acesso padrão: os membros de uma struct são public por defeito, enquanto os de uma class são private.

No entanto, a diferença conceptual é muito mais importante.

  • Usamos uma struct quando queremos uma simples "caixa de dados" passiva. A nossa struct Produto é um exemplo perfeito.
  • Usamos uma class quando queremos criar um "objeto" que tem não apenas dados, mas também comportamento (métodos) e regras internas (invariantes) que ele próprio gere.

Uma classe é frequentemente responsável por gerir recursos complexos – memória alocada dinamicamente, ligações a bases de dados, arquivos abertos, etc. E é aqui que a passagem por valor se torna verdadeiramente perigosa.

O Custo Oculto #2: O Construtor de Cópia

Quando passamos um objeto de uma classe por valor, não ocorre apenas uma cópia bit a bit como numa struct simples. Em vez disso, uma função especial é chamada: o construtor de cópia.

O trabalho deste construtor é criar um objeto completamente novo e independente a partir do original. Se a sua classe gere um recurso complexo, o construtor de cópia pode ter de realizar operações muito dispendiosas, como alocar um novo bloco de memória gigante e copiar todo o seu conteúdo. Fazer isto repetidamente pode destruir a performance da sua aplicação.

A Armadilha Mortal: O Object Slicing (Fatiamento de Objeto)

Este é, talvez, o bug mais subtil e perigoso relacionado com a passagem de parâmetros em POO. Ocorre quando se trabalha com herança (um pilar da POO).

Imagine que tem uma classe base Animal e uma classe derivada Cachorro. A classe Cachorro tem tudo o que um Animal tem, e mais alguma coisa (por exemplo, o método latir()).

Agora, considere esta função:

Snippet de Código 9:

// ATENÇÃO: Esta função é perigosa!
void analisarAnimal(Animal a) { 
    // ... 
} 

A função espera um Animal por valor. O que acontece se você lhe passar um Cachorro?

Snippet de Código 10:

Cachorro meu_cao;
analisarAnimal(meu_cao); // PERIGO!

Quando meu_cao é passado, o programa precisa de criar uma cópia do tipo que a função espera (Animal). Para o fazer, ele "copia" a parte Animal do seu Cachorro e descarta completamente a parte Cachorro. O objeto é literalmente "fatiado" (sliced).

Dentro da função analisarAnimal, a cópia a já não é um Cachorro. É apenas um Animal. Toda a informação específica do Cachorro (incluindo o método latir()) foi perdida. Isto quebra o polimorfismo (outro pilar da POO) e leva a bugs extremamente difíceis de encontrar.

A solução? Passar por ponteiro ou, preferencialmente, por referência.

Snippet de Código 11:

// A forma correta e segura que preserva o polimorfismo
void analisarAnimal(const Animal& a) { 
    // Agora 'a' é um apelido para o objeto original 'meu_cao'.
    // Nenhuma informação é perdida.
}

Conclusão e Regra Final

A nossa jornada através da passagem de dados estruturados deixa-nos com uma regra de ouro clara e inegociável para a programação C++ moderna:

A passagem por valor é para tipos simples e pequenos. Para structs e, especialmente, para classes, a passagem por referência (const& ou &) não é apenas uma otimização, é uma questão fundamental de eficiência, segurança e, acima de tudo, correção do programa.

Ao usar const& como o seu padrão, você garante que o seu código é rápido, seguro contra modificações acidentais e imune à perigosa armadilha do object slicing.

Com este conhecimento, estamos agora ainda mais preparados para revisitar os tópicos avançados que se seguem: a passagem de ações (funções e métodos) como parâmetros.