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.