💾 Archived View for gemini.ctrl-c.club › ~michal_atlas › posts › oop-c.gmi captured on 2024-08-31 at 12:14:01. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2024-02-05)

-=-=-=-=-=-=-

Ambience

This is a dumb little experiment based on the paper “Protoypes: Object-Orientation, Functionally” which I happened to be reading just before somebody said that no OOP in possible in C, well we have to see about that won’t we? And yes, this is silly, but I thought I’d share anyways.

Imports & co.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int throw() {
  printf("No applicable slot");
  exit(1);
}

The Type

typedef struct object_t {
  char *slot_name;
  enum { d_fn, d_value } d;
  int (*fn)(struct object_t *);
  int value;
  struct object_t *super;
} object_t;

Each object contains a name, their predecessor and either a value or method (including a discriminating enum that tells you which one is in there at the moment).

Both are int here to make it easier, but I might as well could’ve returned object_t with a type slot or simply a void* and taken a “list of arguments” argument in addition to self. Also the slot_name should definitely be something better than a string, ideally something that mimics Lisp symbols (so I guess a global enum would do the trick wonderully, but I’m not sure if there’s a way to define that in multiple files / compilation units because you couldn’t combine objects arbitrarily without that), but this is just a proof of concept.

Inheriting

object_t inherit_with_value(object_t *super, char *slot_name, int value) {
  object_t a = {
      .slot_name = slot_name,
      .d = d_value,
      .value = value,
      .super = super,
  };
  return a;
}

object_t inherit_with_method(object_t *super, char *slot_name,
                             int (*value)(object_t *)) {
  object_t a = {
      .slot_name = slot_name,
      .d = d_fn,
      .fn = value,
      .super = super,
  };
  return a;
}

This just returns a new object which has the name and content you pass. And “inherits” the rest of its values from super.

Invocation

int invoke_i(object_t *self, object_t * orig, char *slot_name) {
  if (!self)
    throw();
  if (strcmp(slot_name, self->slot_name))
    return invoke_i(self->super, orig, slot_name);
  if (self->d == d_fn)
    return self->fn(orig);
  return self->value;
}

int invoke(object_t *self, char *slot_name) {
  return invoke_i(self, self, slot_name);
}

The reason why there’s two is that the actual invoke has to remember the original object and just wraps it in a nice interface.

strcmp returns nonzero (or true in C) only if the two strings aren’t equal, in which case I just try to call the same invocation on the super of my object, traversing the inheritance tree until I find something that matches and I return the value of that. If I’ve indeed found an object represeting the given slot_name, I return its value or if it’s a function just call it on the original object so that it has access to all the slots of the object.

If I only passed self at that point, then the order of inheritance would matter, even without shadowing.

If I inherit with the method C and then have values A and B, in this order (where -> means super is): A -> B -> C -> ... then I’d not be able to access A or B from the C method.

set!

It also allows shadowing so I could just inherit_with_value multiple times with slot name of "A" and I’d just get the same object with "A" set to the given value.

There could actually be an imperative style set! here, that traverses and explicitly modifies the value in the old struct.

However, you might notice that if two objects inherit from the same one and you set something inside that object then both will see the change. The solution for this is probably either programmer discipline, or introducing a sort of "===FINALIZED===" slot, that marks that everything before that belongs to the object and everything after that is what’s inherited. You’d probably also want to check for it in the inherit_with_* functions to be completely safe.

Method definitions

int square_s(object_t *self) {
  int width = invoke(self, "width");
  return width * width;
}

int rect_s(object_t *self) {
  return invoke(self, "width") * invoke(self, "height");
}

int hey(object_t *self) {
  printf("Hello\n");
  return 0;
}

void overload_example(object_t *x, object_t *y) {
  printf("%d %d\n", invoke(x, "size"), invoke(y, "size"));
}

This should be quite self-explanatory, we see invoke being ran, retreiving some values from some objects, and printing/returning them.

Let’s Run it

object_t hello_class = inherit_with_method(&baseclass, "say_hello", hey);

This creates an object with a slot bound to a function, if you’re questioning why the name is hello_class, then ask yourself what exactly happens now if I inherit from it: c object_t square_1 = inherit_with_value(&hello_class, "width", 4); Square is some object now, that has access to both "say_hello" and "width", which means that I didn’t need to have an explicit class to create something with the width slot, however, anything that has hello_class as a predecessor, can use any methods defined on it, so it acts as a sort of class. c invoke(&rect, "say_hello"); And the line between object and class is almost nonexistent unless you add the differences yourself.

Now let’s create a rectangle

object_t rect_1 = inherit_with_value(&square_1, "height", 7);

And imbue both of them with a size function:

object_t square = inherit_with_method(&square_1, "size", &square_s);
object_t rect = inherit_with_method(&rect_1, "size", &rect_s);

Now when I make this call, each one calls its own size function:

overload_example(&square, &rect);

Epilogue

I know this is kinda dumb, but it’s quite fascinating to me so I thought I’d share it. It’s basically the simplest bare minimum example of objects, and I always love tiny code that fits in a few lines, but has massive implications and emergence. A few qol features (and removing all the “did this cause lazy” parts) and even this might actually be quite useful.