Hands-On Object-Oriented C: From Structs to Design Patterns
C is often seen as a procedural language, but with careful design you can apply object-oriented (OO) principles—encapsulation, abstraction, polymorphism, and modularity—directly in C. This article walks through practical techniques that transform plain C programs into maintainable, testable, and reusable systems using structs, function pointers, and established design patterns.
Why use OO techniques in C?
- Resource control: C gives deterministic memory and performance characteristics critical in embedded and systems programming.
- Portability: C compiles everywhere—bringing OO structure without depending on C++ runtime or language features.
- Incremental adoption: You can introduce OO idioms to existing C codebases progressively.
Core building blocks
1. Encapsulation with structs and opaque types
Encapsulation hides implementation details from users of a module.
- Public header (mytype.h):
- Declare an opaque pointer type:
typedef struct MyType MyType; - Provide constructor/destructor and public methods.
- Declare an opaque pointer type:
- Private source (mytype.c):
- Define
struct MyType { /fields */ }; - Implement methods operating on
MyType *.
- Define
This prevents callers from directly depending on internal fields and allows changing internals without breaking API.
2. Methods as functions with a “this” parameter
C functions emulate methods by taking a pointer to the instance as the first argument.
Example:
Code
void mytype_set_value(MyType *self, int v); int mytype_get_value(const MyType *self);
Use consistent naming (modulemethod) to avoid symbol collisions.
3. Constructors, destructors, and ownership
Provide creation and destruction functions:
Code
MyType *mytype_new(void); void mytype_free(MyType *self);
Clearly document ownership semantics (who frees memory). For safer code, pair each new with a matching free and prefer stack-based small objects when possible.
4. Inheritance-like composition
C lacks inheritance, but composition achieves code reuse:
- Embed a “base” struct as the first field of a “derived” struct so pointer casting approximates polymorphism.
- Alternatively, include a pointer to a base instance.
Example:
Code
typedef struct { int type_id;void (*destroy)(void *);} Base;
typedef struct {
Base base; int derived_field;} Derived;
5. Polymorphism via function pointers (vtable)
Emulate virtual methods with a table of function pointers per type (vtable).
Pattern:
- Define a vtable struct with function pointers.
- Each instance stores a pointer to its type’s vtable.
- Call methods via vtable to dispatch at runtime.
Example:
Code
typedef struct ShapeVTable { void (*draw)(void *self);double (*area)(void *self);} ShapeVTable;
typedef struct {
ShapeVTable *vtable; // shape data} Shape;
This enables multiple concrete shapes (circle, rectangle) to implement the same interface.
Practical patterns and examples
Factory pattern
Encapsulate object creation logic in a factory function, returning an abstract type pointer. Use when creation varies by configuration.
Singleton pattern
For global resources, provide a function that returns a single shared instance. In C, ensure thread-safety with static initialization or synchronization primitives.
Strategy pattern
Encapsulate algorithms behind function-pointer-based interfaces to swap behavior at runtime (e.g., sorting strategies, logging backends).
Observer pattern
Implement subscription lists of function pointers for event notifications. Carefully manage lifetimes; prefer weak references or explicit unsubscribe to avoid dangling callbacks.
Adapter and Facade
- Adapter wraps incompatible interfaces by translating calls.
- Facade exposes a simplified API that composes multiple subsystems.
These are especially useful for modernizing legacy C APIs.
Memory safety and error handling
- Prefer explicit error returns (int, enum, or pointer with NULL as failure).
- Check all allocations; avoid silent failures.
- Use RAII-like helper functions where possible (e.g., init/free pairs).
- Consider reference counting for shared objects; implement atomic operations if multithreaded.
Testability and modularity
- Design modules with small, pure functions where possible.
- Use opaque types to write unit tests that exercise the public API without relying on internals.
- Mock dependencies by swapping vtables or function pointers in tests.
Example: Minimal OO-style logger
Header (logger.h):
- Opaque Logger type
Logger *logger_new(void (*write)(const char *));void logger_log(Logger *l, const char *msg);void logger_free(Logger *l);
Implementation:
- Store function pointer
writein struct, and call it fromlogger_log. - Swap
writein tests to capture messages.
Performance considerations
- Indirection from function pointers adds minimal overhead; acceptable in most applications but measure if in tight loops.
- Inline small functions to reduce call overhead.
- Keep data layout cache-friendly; prefer contiguous arrays and minimize pointer chasing.
When not to use OO in C
- Small scripts or one-off programs where plain procedural code is simpler.
- Performance-critical inner loops where virtual dispatch overhead is measurable and significant.
Quick checklist to refactor procedural C into OO-style
- Identify cohesive data + operations and make them modules.
- Create opaque types and public APIs.
- Move data into private structs.
- Add constructors/destructors and document ownership.
- Introduce vtables for polymorphism where needed.
- Write unit tests for public APIs.
- Review performance and simplify where necessary.
Conclusion Applying object-oriented design in C gives many structural benefits while retaining C’s control and portability. With opaque types, function-pointer dispatch, and careful module boundaries you can build clear, extensible systems without moving to a heavier language.
Leave a Reply