Como construir objetos em C usando structs e ponteiros de função
C não tem classes. Se você tentar escrever orientação a objetos em C, vai acabar usando structs com ponteiros para funções. Isso funciona, mas tem suas dores. Vou explicar como funciona na prática, com exemplos que compilam de verdade.
introdução a objetos no c: structs com ponteiros de função
A ideia básica é criar uma struct que guarda dados e outra que guarda ponteiros para funções. Quando você chama um método, passa o ponteiro da struct como primeiro argumento. Parece simples, mas o detalhe é onde errar é fácil.
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int x;
int y;
} Ponto;
typedef struct {
Ponto p;
void (*mover)(Ponto*, int, int);
void (*mostrar)(Ponto);
} PontoObj;
void mover(Ponto* self, int dx, int dy) {
self->x += dx;
self->y += dy;
}
void mostrar(Ponto self) {
printf("(%d, %d)\n", self.x, self.y);
}
PontoObj* criar_ponto(int x, int y) {
PontoObj* obj = malloc(sizeof(PontoObj));
obj->p.x = x;
obj->p.y = y;
obj->mover = mover;
obj->mostrar = mostrar;
return obj;
}
Isso é o mínimo que funciona. Você chama assim:
PontoObj* p = criar_ponto(1, 2);
p->mover(p, 3, 4);
p->mostrar(p->p);
free(p);
Nota o "p" sendo passado duas vezes — uma como argumento do método e outra dentro da struct para o mostrar. Isso é chato e é o primeiro sinal de que algo nessa abordagem éal. Um problema real que eu encontrei trabalhando nisso: alinhamento de memória. Quando você mistura structs com diferentes tamanhos e campos, o compilador pode adicionar padding. Eu tinha um projeto onde uma struct de Vec3 com floats precisava ser passada para uma API C++ que esperava um layout específico. O padding fazia o offset de cada campo ficar errado. A solução foi usar #pragma pack(push, 1) e #pragma pack(pop) ao redor da struct, ou então declarar os campos em ordem decrescente de tamanho para minimizar o padding sem pragmas. Em sistemas onde o alinhamento importa, isso é algo que você descobre tarde demais.
Tabela de funções (vtable) para herança simulada
Quando você precisa de mais de um tipo de objeto com métodos diferentes, usar ponteiros de função soltos fica bagunçado. A solução padrão é uma vtable — uma struct contendo apenas ponteiros para funções, copiada para cada struct de objeto.
typedef struct {
void (*destruir)(void*);
void (*executar)(void*);
} IVehicleVTable;
typedef struct {
IVehicleVTable* vtable;
int id;
} IVehicle;
typedef struct {
IVehicle base;
int velocidade;
} Carro;
typedef struct {
IVehicle base;
int altitude;
} Aviao;
Cada tipo implementa sua própria vtable e a atribui na construção. Quando você chama um método, faz obj->vtable->executar(obj). É assim que C++ implementa polimorfismo por baixo dos panos — a diferença é que aqui você escreve tudo manualmente. O ganho real aparece quando você quer passar um objeto genérico para uma função que não sabe qual tipo específico é. Sem vtable, você precisaria de switch baseado em tipo, o que quebra encapsulamento. Com vtable, a função só vê IVehicle* e chama executar.
👉 Clique no botão abaixo para saber mais sobre o assunto!
Contudo, existe uma desvantagem clara: cada chamada de método é uma indireção dupla — primeiro para a vtable, depois para a função. Em código crítico de performance, isso soma. Em loops apertados com milhares de iterações, perder cache de instruction pode transformar 2ms em 8ms. Se performance é crítica, considere evitar vtables e usar funções regulares com dispatcher manual.
Destrutores e gerenciamento de memória
O maior problema prático em objetos C é quando a memória é liberada. Diferente de C++, não há construtor/destrutor automático. Se você esquece de chamar o destrutor, vazamento. Se chama duas vezes, crash. A convenção comum é ter uma função XX_destroy que recebe o ponteiro e libera tudo.
void Carro_destroy(Carro* self) {
if (self) {
free(self);
}
}
void Aviao_destroy(Aviao* self) {
if (self) {
free(self);
}
}
Para polimorfismo de destruição, coloque o destrutor na vtable:
void ivehicle_destroy(void* data) {
IVehicle* obj = (IVehicle*)data;
obj->vtable->destruir(obj);
}
Um erro comum: pessoas que migram de C++ esquecem que estruturas aninhadas não são destruídas automaticamente. Se seu objeto contém um ponteiro para outra struct alocada dinamicamente, você precisa destruir isso dentro do destrutor do objeto pai. Eu já vi gente criar um gerenciador de recursos complexo e esquecer de destruir o array interno — 4GB de memória vazando em um servidor que rodava 24/7. A solução foi adicionar um assert no destrutor e um tool de memory leak detection (Valgrind ou Dr. Memory) no pipeline de CI.
Quando isso não funciona
Objetos em C funcionam bem para bibliotecas e APIs onde você precisa de encapsulamento básico. Eles não funcionam bem quando você precisa de herança múltipla — C não suporta, e simular com múltiplas vtables cria ambiguidade de ponteiros que é difícil de resolver sem macros complicadas. Também não funcionam bem para templates/gênerics — se você precisa do mesmo objeto com diferentes tipos de dados, terá que duplicar código ou usar void*, que remove verificação de tipo em tempo de compilação. Alternativa prática: se o projeto permite, considere usar C++ com ABI compatível com C. Você pode escrever a biblioteca em C++ e expor uma interface C para clientes C. Os objetos C++ existem, a alocação/destruição é automática, e a interface C é trivial de montar com extern "C".
Resumo rápido do que fazer e do que evitar
Use structs com ponteiros de função para objetos simples com um único tipo. Use vtables quando precisar de polimorfismo. Sempre tenha uma função destroy explícita. Nunca confie em garbage collection — C não tem. Teste com Valgrind ou ASAN antes de confiar que não há vazamentos. E evite herança múltipla simulada — o custo em complexidade e bugs geralmente não vale a pena.