Lesson 09 / 15
Object-Like Design
In object-like interfaces built with an opaque struct and function pointers embedded in the struct, measuring that two handles share the same object and that a method call does not change that sharing.
Published Updated
In this lesson
- Builds an object interface that hides information through an opaque struct and accessor functions
- Implements an interface that gives the impression of a method call using function pointers embedded in the struct
- Measures that two handles share the same object and that method dispatch does not change that
Contents
C has no classes, but the impression of an “object” can be built with two tools: an
opaque struct (a type whose internal fields are visible only in the .c file
where it’s defined) and embedded function pointers (callable fields that read like
a “method” call but are actually just data fields). This lesson builds both, and asks
the same question: when an “object” is accessed through two handles, is a change made
through one visible through the other? The answer is once again shared — opacity
and method-call syntax do not change the underlying sharing, they only change how it
is read.
Opaque Struct: An Object Interface Through Information Hiding
struct Object‘s definition lives only in object.c; object.h has only a forward
declaration (struct Object;). This means a file that includes the header cannot
access the value field directly, and can only speak to the object through the
object_set/object_get functions — these two functions are the object’s “public
interface.”
// object.h — an opaque object interface #ifndef OBJECT_H #define OBJECT_H struct Object; struct Object *object_create(int initial); void object_set(struct Object *object, int value); int object_get(const struct Object *object); void object_free(struct Object *object); #endif
// object.c — the struct's actual fields, hidden from callers
#include <stdlib.h>
#include "object.h"
struct Object {
int value;
};
struct Object *object_create(int initial) {
struct Object *object = malloc(sizeof *object);
if (object == NULL) {
return NULL;
}
object->value = initial;
return object;
}
void object_set(struct Object *object, int value) {
object->value = value;
}
int object_get(const struct Object *object) {
return object->value;
}
void object_free(struct Object *object) {
free(object);
}
When object_free gets called is a separate question — the previous course’s (Memory
and Pointers in C, K02) question of “who releases this object”, which here too is a
contract taken on by whoever calls object_create. This lesson measures not that
question but whether two handles share the same object.
Two Handles Sharing the Same Object
object and second_handle carry the same struct Object * value — both point to the
same object. The opaque type only blocks direct access to the value field; access
through object_set/object_get still reaches the same object.
// two_handles.c — two variables holding the same object
#include <stdio.h>
#include "object.h"
int main(void) {
struct Object *object = object_create(1);
printf("%s %d\n", "value at creation", object_get(object));
struct Object *second_handle = object;
object_set(second_handle, 42);
printf("%s %d\n", "value read through the original handle", object_get(object));
printf("%s %d\n", "is second_handle the same object as object", (int)(second_handle == object));
object_free(object);
return 0;
}
value at creation 1 value read through the original handle 42 is second_handle the same object as object 1
DS10. The assignment second_handle = object does not copy an object, it copies a
pointer — in the end both carry the address of the same malloc block, just like
the assignment second = head->next in the linked list (the Linked Structures
lesson). The call object_set(second_handle, 42) changes the value field, and that
change is read back through object_get(object): 42. The opaque type changes
nothing here — the fact that object->value cannot be written to from outside doesn’t
hide that object and second_handle share the same object, it only restricts how
that sharing can be reached (only through functions). The last line confirms this
without printing an address: the comparison object == second_handle gives 1. The
class is shared.
The Impression of Method Calls Through Function Pointers
The second approach doesn’t hide information, it embeds the “methods” directly inside
the struct: the increment and get_value fields are function pointers, and
counter_object_init binds them to concrete functions. When the caller writes
counter.increment(&counter, 5), this produces a syntax similar to the C++ call
counter.increment(5) — but increment is not a method, it is a data field.
// counter_object.h — an object that carries its own operations
#ifndef COUNTER_OBJECT_H
#define COUNTER_OBJECT_H
struct CounterObject {
int count;
void (*increment)(struct CounterObject *, int);
int (*get_value)(const struct CounterObject *);
};
void counter_object_init(struct CounterObject *counter, int initial);
#endif
// counter_object.c — the functions a counter object's fields point at
#include "counter_object.h"
static void counter_increment(struct CounterObject *self, int amount) {
self->count = self->count + amount;
}
static int counter_get_value(const struct CounterObject *self) {
return self->count;
}
void counter_object_init(struct CounterObject *counter, int initial) {
counter->count = initial;
counter->increment = counter_increment;
counter->get_value = counter_get_value;
}
// method_call.c — two handles calling the same struct's function pointers
#include <stdio.h>
#include "counter_object.h"
int main(void) {
struct CounterObject counter;
counter_object_init(&counter, 0);
struct CounterObject *first_handle = &counter;
struct CounterObject *second_handle = &counter;
first_handle->increment(first_handle, 5);
printf("%s %d\n", "value read through the second handle after the first handle's increment",
second_handle->get_value(second_handle));
second_handle->increment(second_handle, 3);
printf("%s %d\n", "value after both handles have incremented in sequence", counter.get_value(&counter));
return 0;
}
value read through the second handle after the first handle's increment 5 value after both handles have incremented in sequence 8
DS11. first_handle and second_handle both carry &counter — the address of
the same local variable, exactly like the object/second_handle pair in the earlier
measurement. first_handle->increment(first_handle, 5) raises counter.count from 0
to 5, and second_handle->get_value(second_handle) sees it: 5. Because
counter.increment and counter.get_value are function pointers, first_handle->increment
and second_handle->increment read the same pointer too — the function actually
called is counter_increment either way. The syntax that looks like a method call
never changes how counter.count is reached; increment and get_value only carry,
as data, which function gets called. The class is once again shared: both
handles point at the same struct, and the only difference between them is the syntax
used to reach it.
A Shared Method Table
counter_object.c’s approach gives every counter its own copy of the two function
pointers, even though every counter uses the exact same two functions. CounterManager
instead stores a single pointer to a shared, const table of methods — every manager
points at the same table rather than carrying its own copy of the function pointers.
// counter_vtable.h — a shared, read-only method table
#ifndef COUNTER_VTABLE_H
#define COUNTER_VTABLE_H
struct CounterManager {
int count;
const struct CounterMethods *methods;
};
struct CounterMethods {
void (*increment)(struct CounterManager *self, int amount);
int (*get_value)(const struct CounterManager *self);
};
extern const struct CounterMethods COUNTER_METHODS;
void counter_manager_init(struct CounterManager *manager, int initial);
#endif
// counter_vtable.c — one table, shared by every counter manager
#include "counter_vtable.h"
static void counter_increment_v(struct CounterManager *self, int amount) {
self->count = self->count + amount;
}
static int counter_get_value_v(const struct CounterManager *self) {
return self->count;
}
const struct CounterMethods COUNTER_METHODS = { counter_increment_v, counter_get_value_v };
void counter_manager_init(struct CounterManager *manager, int initial) {
manager->count = initial;
manager->methods = &COUNTER_METHODS;
}
// shared_table.c — two managers pointing at the exact same method table
#include <stdio.h>
#include "counter_vtable.h"
int main(void) {
struct CounterManager first;
struct CounterManager second;
counter_manager_init(&first, 0);
counter_manager_init(&second, 100);
first.methods->increment(&first, 5);
second.methods->increment(&second, 5);
printf("%s %d\n", "first counter's value", first.methods->get_value(&first));
printf("%s %d\n", "second counter's value", second.methods->get_value(&second));
printf("%s %d\n", "do both managers point at the same method table",
(int)(first.methods == second.methods));
return 0;
}
first counter's value 5 second counter's value 105 do both managers point at the same method table 1
DS17. first.count and second.count are independent of each other — each
instance carries its own int field, and changing one does not affect the other. But
first.methods and second.methods carry the same address: &COUNTER_METHODS, a
const object defined exactly once at file scope. In the CounterObject design, each
instance kept its own copy of the increment and get_value fields (even though
they pointed at the same functions, from two separate memory locations); here,
instances share not the individual function addresses but a single pointer to the
table itself. Two different classes are visible inside the same struct at once: for
the count field, instances are as independent as a copy; for the table that
methods points at, they are shared — the table is a single object, and every
instance reaches it through the same address.
Producing an Independent Object With a Cloning Function
The two examples so far (handle copying, the shared method table) always produced the
shared class: whether a pointer was copied or shared, the underlying structure
stayed one. But object.h‘s public interface is already enough to produce an
independent object — reading the current value with object_get and building a new
object from that value with object_create produces a deep copy, without ever
touching the opaque type’s internal field.
// object_clone.h — cloning an opaque object through its own interface #ifndef OBJECT_CLONE_H #define OBJECT_CLONE_H #include "object.h" struct Object *object_clone(const struct Object *source); #endif
// object_clone.c — building an independent object through the public interface
#include "object_clone.h"
struct Object *object_clone(const struct Object *source) {
return object_create(object_get(source));
}
// object_clone_demo.c — a clone that no longer shares anything with the source
#include <stdio.h>
#include "object.h"
#include "object_clone.h"
int main(void) {
struct Object *source = object_create(7);
struct Object *clone = object_clone(source);
printf("%s %d\n", "clone's initial value", object_get(clone));
object_set(source, 99);
printf("%s %d\n", "clone's value after source changed", object_get(clone));
printf("%s %d\n", "is clone the same object as source", (int)(clone == source));
object_free(source);
object_free(clone);
return 0;
}
clone's initial value 7 clone's value after source changed 7 is clone the same object as source 0
DS19. object_clone never touches the struct source points at — it only reads
the current value with object_get(source) and builds a new malloc block with
object_create. As a result, clone carries a separate address from source: the
last line confirms this with clone == source returning 0. object_set(source, 99)
changes only the block source points at; the block clone points at is unaffected,
so object_get(clone) still reads 7. This is the exact opposite of the lesson’s
first two examples: second_handle = object copied a pointer and shared the same
struct (shared), while object_clone uses the public interface to build a new
struct, landing the result in the copy class — the two objects are now
independent, and changing one does not change the other. The difference is not in the
shape of the code: both assign a pointer, both work behind the opaque type; the
difference is whether the right-hand side carries an existing address or comes
from a fresh allocation.
The Bounding Measurement
The shape of an “object” interface — whether it’s an opaque type, function pointers
inside a struct, or ordinary functions — does not determine the underlying sharing.
object_set(second_handle, 42) and first_handle->increment(first_handle, 5) look
different (one reads like an ordinary function call, the other like a call through a
struct field), but both rest on the same principle: modifying a structure reached
through a pointer is visible through every pointer that reaches that structure. An
interface looking “object-like” — information hiding, method-call syntax — does not
hide or remove the sharing, it only shapes how the shared structure is accessed. The
sharing itself can still only be measured by pointer equality (object == second_handle, first_handle == second_handle).
Summary
- An opaque type (the forward declaration
struct Object;) blocks direct access to the internal fields, but does not change the sharing between two handles pointing at the object; function pointers embedded inside a struct (increment,get_value) also give a method-call syntax but remain data fields. Sharing can only be measured by pointer equality. - When two handles point at the same object/struct, a change made through one is visible through the other (no matter which interface style is used) — the class is shared.
- Who releases an object (K02’s question) and who sees a change (this lesson’s question) are separate axes.
- Instances carrying their own function pointers versus being linked by a pointer to a single shared table give different results: in the first, every instance keeps its own copy; in the second, the table is single and every instance reaches it through the same address.
- Building a new object through the public interface (only
object_create/object_get) is different from copying a pointer: the result lands in the copy class, and the two objects stay independent of each other.
Next Step
In both this lesson’s examples, resource management was simple: a single object_free
call, or no allocation at all. The next lesson, Resource Cleanup Idioms, measures
situations where several resources (several allocations, several open handles) must be
opened in sequence within a function and, on failure, closed in the correct order —
there the class is unobserved: a resource is closed and disappears on the failure
path without ever reaching a reader.
To keep your progress and take notes, Log in
My notes
Log in to take notes.