Simulando objetos em C com structs e ponteiros de função
Eu sei que C não tem classes nativas, mas gente tenta fazer orientação a objetos no C há décadas. A técnica básica envolve structs combinadas com ponteiros para funções. Nada mágico, só memória e indireção. O compilador não sabe que você está simulando um objeto. Ele vê structs e funções normais. A ideia central é agrupar dados e comportamentos juntos usando uma estrutura e funções separadas que recebem um ponteiro para essa estrutura como primeiro argumento. Parece estranho no começo, mas é assim que bibliotecas como GLib e GObject funcionam internamente.
introdução a objetos no c: o básico na prática
Vamos começar com algo simples. Um retângulo. Você quer calcular área e perímetro, e quer mudar as dimensões depois de criar o objeto.
typedef struct Retangulo {
int largura;
int altura;
} Retangulo;
Essa é a parte de dados. Agora as funções que operam nisso:
int retangulo_area(Retangulo *r) {
return r->largura * r->altura;
}
int retangulo_perimetro(Retangulo *r) {
return 2 * (r->largura + r->altura);
}
void retangulo_redimensionar(Retangulo *r, int nova_largura, int nova_altura) {
r->largura = nova_largura;
r->altura = nova_altura;
}
Para usar:
Retangulo meu_retangulo = {10, 5};
printf("%d\n", retangulo_area(&meu_retangulo)); // 50
retangulo_redimensionar(&meu_retangulo, 20, 8);
printf("%d\n", retangulo_area(&meu_retangulo)); // 160
Isso funciona. É funcionalmente correto. Mas falta um detalhe importante que a maioria dos tutoriais não mostra: encapsulamento. Ninguém te impede de acessar meu_retangulo.largura diretamente se você declarou a struct num header público. A solução é usar um ponteiro incompleto no header e expondo apenas funções.
Encapsulamento real com opaque pointers
Em vez de expor a estrutura inteira, você declara apenas um forward declaration e define a struct num arquivo .c. Quem inclui o header só vê um ponteiro. No arquivo retangulo.h:
#ifndef RETANGULO_H
#define RETANGULO_H
typedef struct Retangulo Retangulo;
Retangulo* retangulo_criar(int largura, int altura);
void retangulo_destruir(Retangulo *r);
int retangulo_area(Retangulo *r);
void retangulo_redimensionar(Retangulo *r, int largura, int altura);
#endif
No arquivo retangulo.c:
#include "retangulo.h"
#include
struct Retangulo {
int largura;
int altura;
};
Retangulo* retangulo_criar(int largura, int altura) {
Retangulo *r = malloc(sizeof(Retangulo));
if (!r) return NULL;
r->largura = largura;
r->altura = altura;
return r;
}
void retangulo_destruir(Retangulo *r) {
free(r);
}
int retangulo_area(Retangulo *r) {
return r->largura * r->altura;
}
void retangulo_redimensionar(Retangulo *r, int largura, int altura) {
r->largura = largura;
r->altura = altura;
}
Agora quem usa a biblioteca não consegue acessar os campos diretamente. Só via funções. Isso é o mínimo de segurança que você consegue em C puro.
Herança simulada com composição
C não tem herança, mas você pode simular colocando uma struct base como primeiro campo de uma struct derivada. O truque é que você precisa saber que o ponteiro da base aponta para o início da struct derivada. Isso funciona porque a C standard garante que o primeiro membro de uma struct está no offset zero.
typedef struct Forma Forma;
struct Forma {
int x;
int y;
int (*area)(Forma *self);
};
typedef struct Circulo {
Forma base;
int raio;
} Circulo;
int circulo_area_impl(Forma *self) {
Circulo *c = (Circulo *)self;
return 3 * c->raio * c->raio;
}
Perceba o cast de Forma * para Circulo * dentro da função. Como o campo base está no início da struct, o endereço é o mesmo. Funciona em todos os compiladores conformes. Para instanciar:
Circulo *circulo_criar(int x, int y, int raio) {
Circulo *c = malloc(sizeof(Circulo));
if (!c) return NULL;
c->base.x = x;
c->base.y = y;
c->base.area = circulo_area_impl;
c->raio = raio;
return c;
}
O problema que ninguém conta sobre polymorfismo em C
Polimorfismo dinâmico em C puro é doloroso. Cada tipo precisa implementar seus próprios ponteiros de função manualmente. Esqueceu de inicializar um ponteiro de função e você tem um crash em tempo de execução, não em compilação. Já perdi duas horas rastreando um segfault porque alguém passou NULL num ponteiro de função durante um code review apressado. O workaround que uso agora é criar um macro de validação no construtor:
👉 Clique no botão abaixo para saber mais sobre o assunto!
#define VALIDAR_FUNCOES(obj) do { \
if (!(obj)->base.area) { \
fprintf(stderr, "funções não inicializadas em %s\n", #obj); \
free(obj); \
return NULL; \
} \
} while(0)
Isso adiciona sobrecarga mínima e mata bugs comuns antes de chegarem ao runtime.
Vantagens e desvantagens reais
Usar OOP em C tem custos que valem a pena considerar. Você ganha controle total sobre a memória e o layout. Não há overhead de virtual table em hardware embarcado limitado. O código compila com qualquer GCC, Clang ou MSVC sem extensões. Por outro lado, cada tipo novo exige escrever setters, getters e construtores manualmente. Um projeto pequeno pode passar 30% do tempo escrevendo infraestrutura em vez de lógica. Em C++ ou Rust, o mesmo código leva uma fração do tempo graças a templates e trait system.
Se o seu objetivo é aprender como bibliotecas C modernas funcionam, isso é essencial. Se você precisa entregar um produto e não há restrição de linguagem, C++ seria mais produtivo. C com objetos é uma ferramenta, não uma recomendação universal.
Runtime type information manual
Um detalhe prático que facilita muito a manutenção é adicionar um campo de tipo à struct base:
typedef enum { TIPO_FORMA, TIPO_CIRCULO, TIPO_QUADRADO } TipoForma;
struct Forma {
int x;
int y;
TipoForma tipo;
int (*area)(Forma *self);
void (*destruir)(Forma *self);
};
Assim você pode escrever funções genéricas que dispatcham baseado no tipo:
void forma_imprimir(Forma *f) {
switch (f->tipo) {
case TIPO_CIRCULO:
printf("Círculo em (%d,%d) raio=%d\n", f->x, f->y, ((Circulo*)f)->raio);
break;
case TIPO_QUADRADO:
printf("Quadrado em (%d,%d) lado=%d\n", f->x, f->y, ((Quadrado*)f)->lado);
break;
default:
printf("Forma genérica em (%d,%d)\n", f->x, f->y);
}
}
Isso substitui o need de dynamic_cast que existe em outras linguagens. Funciona, mas exige disciplina. Se você adicionar um novo tipo e esquecer de atualizar o switch, o programa compila e falha silenciosamente no default case. A mensagem de error helpa, mas não previne o bug.
Gerenciamento de memória com construtores e destrutores
A parte mais crítica é garantir que cada objeto criado seja destruído. Em C, o compilador não faz garbage collection e não chama destrutores automaticamente. Você precisa ser rigoroso. Um padrão que funciona bem é ter uma função de criação que sempre retorna um ponteiro alocado, e uma função de destruição correspondente. O caller é responsável por chamar a destruição quando terminar de usar.
Para objects criados na stack, não precisa de destrutor explícito. Use alocação dinâmica apenas quando o objeto precisa sobreviver além do escopo da função atual. Misturar os dois estilos é receita para memory leaks ou double-free.
Objeto *obj = objeto_criar(...);
if (!obj) {
// tratar erro, malloc pode falhar
return;
}
// usar obj...
objeto_destruir(obj);
Esse padrão é simples mas eficaz. Em projetos maiores, uso wrappers com RAII-like em C99 via __attribute__((cleanup)) do GCC, mas isso limita a portabilidade para MSVC.
Quando Não Fazer Isso
Se o seu projeto já tem uma linguagem aceita, não force objetos em C só porque parece elegante. A complexidade adicionada é real. Ponteiros de função mal inicializados causam crashes imprevisíveis. O custo de manutenção supera o benefício em qualquer código que precise evoluir rapidamente. Use quando estiver escrevendo uma biblioteca que precisa ser consumida por múltiplas linguagens, quando o hardware não suporta runtime pesado, ou quando o código precisa rodar em ambientes embedded com restrições severas de memória. Fora isso, considere alternatives como C++ com classes reais ou Zig com structs e métodos implícitos.
Recursos para continuar
O código-fonte do GLib é o melhor exemplo de OOP em C que existe. A implementação de GObject comsignals e propriedades é sofisticada mas documentada. Fontes disponíveis em https://gitlab.gnome.org/GNOME/glib. Para exemplos mais simples, o projeto Linux kernel usa structs com ponteiros para funções de operação em drivers. A interface block_device_operations no subsistema de blocos é um caso real de polimorfismo em C rodando em produção há anos.
Documentação oficial da standard C não cobre OOP porque a linguagem não tem suporte nativo. Tudo que você encontrar é técnica informal adotada pela comunidade. Isso significa que diferentes projetos usam convenções diferentes. Aprenda uma e entenda que a próxima biblioteca pode seguir padrões distintos.