---
title: 'Object-Like Design'
source: 'https://academia.sh/en/courses/c-systems-programming/object-like-design'
course: 'Systems Programming with C'
language: en
updated: '2026-09-26T03:02:44+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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."

```c
// 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
```

```c
// 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.

```c
// 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.

```c
// 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
```

```c
// 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;
}
```

```c
// 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.

```c
// 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
```

```c
// 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;
}
```

```c
// 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.

```c
// 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
```

```c
// 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));
}
```

```c
// 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.
