Skip to content
academia.sh

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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close