Object-Oriented Programming at the Bare Metal

It has been said, with some justice, that a sufficiently determined programmer can write FORTRAN in any language. It’s not been said as often, but the converse of this is also true: a sufficiently disciplined programmer can write any language in assembly. This is both a statement about how all Turing-complete languages are in some sense equivalent, and also a statement about how, at the end of the day, everything turns into machine code, or is fed to an interpreter written in machine code.

That’s not really the part I’m interested in, though. There’s some amusement to be had in comparing tasks across languages, and finding tasks where it’s actually easier to do them in assembly language than in some high-level language one wishes to mock—and I’m pretty OK with concluding that a language is bad at something if it’s easier to do in assembly code.

But a big theme with my investigations into 8-bit retrocoding has been to take the lessons of the modern era and figure out if and how they apply to an earlier, simpler world. We’ve spent decades of diligent academic development, industrial refinement, and flamewars across all media working to improve our programming languages and the disciplines with which we use them. My question, then, is this: have we learned anything that we can take back?

I don’t mean by this "can a sufficiently determined programmer replicate the work of a compiler," mind you. The answer to that is trivially yes, but is also less of "a useful technique" and more of "a bad time." I also don’t mean "how do you get modern languages to call your assembly language routines"—that’s well documented and shockingly easy in the modern world, even in places you wouldn’t really expect it to be. I want to link the design aesthetics that we’ve settled on over the decades to specific constructs that a human programmer working in assembly language might reasonably want to use.

So let’s talk about Object-Oriented Programming.

OOP enjoyed a pretty long run where it was considered something of a sine qua non—it was so dominant that even languages that made very little use of its techniques still needed to include nods in its direction to be taken seriously. Now, this wasn’t as true as it looked. Erlang and ML-descended languages wielded and continue to wield a quiet but growing influence in industry. Lisp never went away. Haskell aggressively pursued goals that most developers don’t really care about, but inspired replication of parts of its work in languages as distinct from it as JavaScript and C++. And the new generation of practical compiled languages like Swift, Rust, and Go retain the basic look of the old OOP standbys while abandoning key tenets of OO design in favor of these alternate lineages. It’s an exciting time, right now. There’s less a sense of Language Wars and more a sense of Language Minecraft. Nevertheless, the basic principles of OOP are the ground from which the modern common programmer sprang.

This means describing OOP can be a little bit like being a fish and trying to describe water, but I’m just old enough that I had to be taught how it was different from what we’d been doing in C and Pascal. I was taught that OOP was distinguished by three core features:

  • Encapsulation. When you define an object, even if you depend on other objects, those objects have their internals hidden away enough that one could change the implementation of those other objects drastically without needing to edit or even recompile your own code. This often means something like "an object never admits anything about its own data representation" but this is not the case for many languages that are nevertheless unambiguously OO.
  • Inheritance. Kinds of objects can be specialized into similar objects that share some behavior with the kinds of objects they’re based on. This can be done by explicitly setting up a hierarchy of types (or classes—the most common technique), by having a object forward some requests to a different object (a prototype—this is how JavaScript was designed), or by cloning a prototype object or instantiating a class and altering some behavior individually (a process Python calls monkey-patching).
  • Polymorphism. Objects specify a set of behaviors that they can perform. If you have a group of different types of objects, all of which specify the same set of behaviors, you can invoke that behavior without needing to know what kind of object any individual object you’re working on.

We’ll cover each of these in turn, and see how they can be made to serve our designs at the lowest levels.

But first, we’ll need to do a bit of prep work.

Structs and Records

Objects, we were told, are a much grander thing than the mere records that non-OO languages supply. We’ll kind of be putting the lie to that over the course of this investigation, as we replicate all of OO’s effects in C and assembly language, but before we go too deep into the weeds of how objects are represented as data structures we should nail down how the older data structures actually work.

The name record is a fairly common name for this sort of thing, but it’s more common at this point to hear them called structs because that was C’s name for them. Struct is itself an abbreviation of structure, so even though "data structure" is more general than this, when I refer to data structures I mean records.

In C, a structure is defined by naming a series of types and names, with an overarching typename:

struct high_score_entry {
    int score;
    char initials[4];
}

You can then use declarations like struct high_score_entry x; to make a variable of this new type, and then the variables x.score (an integer) and x.initials (an array of 4 characters) also exist (These are referred to as the fields of the record). The names score and initials can also exist independently anywhere else in the code (as function names, or other variables, or as fields in entirely different records) and there is no confusion.

Here’s how you declare a high_score_entry in assembler, assuming 32-bit integers and 8-bit characters:

x:      resb    8

And then we could load x.score into EAX and the address of x.initials into ECX with these instructions:

        mov     eax, dword [x]
        mov     ecx, x+4

Once you get to the machine code level, structure basically evaporates; everything is just a pile of bytes. Records are simply a structured use of bulk-allocated memory: the record has a fixed size, and each field is a fixed-size element that is a fixed offset into the record. (In C, you can get the numbers the compiler is using with the macros sizeof(high_score_entry) and offsetof(high_score_entry,initials).) It’s annoying to have to memorize the numbers 8 and 4 here (or, for that matter, the implied 0 behind the score). We can solve that with new labels. Unlike C, though, we don’t have the luxury of having the a mechanism to hide the names away from interference with everything else. We can work around that by putting in a prefix:

        HIGH_SCORE_ENTRY_SIZE     equ 8
        HIGH_SCORE_ENTRY_SCORE    equ 0
        HIGH_SCORE_ENTRY_INITIALS equ 4

If you look at really old C code you’ll actually also find similar prefixes in the front of field names. The earliest versions of C actually shared field names across all structures, so you needed to make sure that your field names were unique.

There’s one last wrinkle, on modern systems, and that’s that you don’t have a completely free hand in laying out your types. Most modern architectures either require or prefer that when you load certain kinds of values that the address be an even multiple of 4, or 8, or 16. This is called an alignment restriction, and if you have something that you intend to load in 8 bytes at a time, you’d better make sure that not only your offsets are multiples of 8, but that the record as a whole is also aligned to at least an 8-byte boundary.

But that’s really all there is to structs, and this matches C’s behavior close enough that interoperation with C code or structs is quite straightforward. The offsetof macro is your best friend here, and respecting its suggestions will go a long way towards ensuring that your fields are properly aligned. You will also find the compiler adding padding bytes for improved alignment coherence; a good rule of thumb for minimizing that so that your records are small and fast is to incorporate your fields in order from largest to smallest when possible. As we’ll see in the sections to come, it won’t always be possible. But it’s a rule we should really only be breaking because we have a reason to do so.

We’ve caught up with C and Pascal. Onward to actual object-orientation features!

Encapsulation

The most important aspect of this is that you need to make sure that you can take a bunch of independent compilation units and knit them together in a sensible way, with sufficiently minimal coupling that each unit can evolve independently. While it is certainly possible to use object systems to achieve this end, we’re really talking about modules and namespaces here. Assembly language has the same amount of power here that C does, which isn’t much. For modules, each compilation unit has a bunch of labels, and it specifies which labels are coming from some different compilation unit, and which ones that it defines which can be used by other compilation units. Each source file is a module, and the linker knits them together. There may be an additional filter on labels-to-export when object files are linked into a shared library. There is no support whatsoever for namespaces, however. This may be addressed by having every exported symbol have a prefix that you’re fairly sure will be unique. In this way the first few symbols of your labels serves as the namespace. Languages that compile for interoperability with C or C-like linkers do this as part of the compilation process in a technique usually called name mangling.

That’s how to simulate it, but the lessons of OO here are less about how to do it but more about what to do with it. The key principles can be summarized as follows:

  • Export as little as possible that’s still enough for others to make full use of your system.
  • To the maximum extent possible, export only functions.

When you’re working at this level you will probably need to at least admit the size of the data objects you return. Even when everything is, as it is in assembler, just a bag of bytes, you do still need to know how large the bag is. You can drop that requirement by insisting that everything be done via addresses, but this requires reliable access to a heap allocation/deallocation system. If you manage that, though, the only thing people using you will need to deal with are calling functions and getting back opaque handles. That allows you a great deal of freedom in expanding or refining your own API.

If you are incredibly paranoid about symbol conflicts, or if you absolutely do not trust your linker to be able to make everything interoperate, there’s an extreme measure you can take here as well: only export one symbol. That symbol is the address of a read-only data structure, embedded in the code itself, that provides all the information that your module cares to export. You’re paying a small price in binary size here, but you can be far more sure of access control in this case. This isn’t a totally frivolous example, either—this is basically how Windows’s Component Object Model (COM) works. That, however, is specifically to solve the problem of knitting modules together that may be implemented in entirely different languages and running on entirely different machines. The less tightly you couple, the more flexible your execution environment can become. But the more flexible your execution becomes, the less you are programming on the metal, so here we will simply note in passing that this strategy exists and can be valuable—just not to us in this project.

Inheritance

Inheritance is a trickier case. Even back in the early 1990s when I was being introduced to OOP, explaining why inheritance was important in its own right was a bit fuzzy. It helped you get around overly-tight limits on encapsulation, and it was the primary mechanism by which you got polymorphism, but it’s hard to shake the conclusion that it was part of the list of what made OOP OOP less because of its independent virtues and more because it was something OO languages had that their competitors did not.

What inheritance even means, at the assembly language level, varies wildly depending on the exact semantics of the language’s type system, which in turn tends to depend on the exact mechanism of polymorphism provided by the language being compiled. But if we ignore that part, there’s still two aspects left. Interface inheritance declares that it is permissible to use a derived type anywhere that one would use the type it derives. Implementation inheritance means that the code written to perform an operation on the core type is used to specify the operation on the derived type.

Implementation Inheritance

We’ll start with implementation inheritance, since that produces the most gains at the low level. If we could get this at the assembly language level, we’d have some data structure and a function that acted on that data structure. We would then create a completely different data structure, possibly even of a different size, and this function would nevertheless work as intended on our new data structure. That’s pretty great, if we can get it to work! And we can. The secret to getting it to work is for the two data structures to have a consistent layout. In brief, here’s what we need:

  • Any time the function copies the data structure around, it copies a consistent number of bytes. This could mean that the two structures are identically sized, but if they aren’t, it simply means that every access to the structure is through an address. All addresses are, after all, of consistent size.
  • The code will be accessing data within the structure by adding offsets to the base address of the structure as a whole. If the layouts of our two structures are consistent, each offset the function uses will point to an identically sized chunk of memory that has the same meaning in each structure.
  • If the data structures include the addresses of other data structures within them, the data structures referenced also have a consistent layout.

That’s more general than you usually want, and it’s finicky to enforce. Here’s a much simpler one:

  • If you have two data structures, of size X bytes and size X+Y bytes, and the first X bytes of each structure have identical interpretations, then any function that works on addresses of instances of the smaller structure will work with addresses of instances of the larger structure.

In assembly language, you would guarantee this by hand when setting up your offset tables. C is kind enough to standardize this behavior: unless something compiler-specific is invoked to specifically break this rule, any two structures with a common prefix will have the members in its common prefix have identical offsets across those two structs.

Interface Inheritance

That’s all well and good, but it’s also sort of unpleasant because if we add anything to our base structure we have to go update all of our subsidiary structures so that the layout remains consistent. It’d be nice if we could get our devtools to do that for us. There are three key insights that let us do this. First, the first field in every record is at offset zero. Second, structs can embed other structs within them by reserving the appropriate amount of space at the appropriate alignment. Finally, zero is perfectly aligned with every possible alignment.

In C, then, if our derived struct has its first field be of the base type, then everything will continue to work out with nothing but a recompile, even if we extend the base type later.

For assembler, our work is a bit more finicky. Whenever we use a field in our derived type that matches the base type, we need to use the offset constant for the base type. Then, when we define the offsets for fields unique ot the derived type, we have to define them as an offset from the size of the base type.

A Worked Example: Binary Search Trees

This may become clearer with an example, let’s take some simple binary search trees:

struct int_node {
    struct int_node *left, *right;
    int val;
}

struct string_node {
    struct string_node *left, *right;
    int length;
    char val[32];
}

The left and right fields specify the child nodes of this structure, each of which is a search tree in its own right. All the values reachable from the left pointer are less than val, and all the values reachable from the right pointer are greater than it. Based on the rules we’ve defined above, each of these could be considered a derived type of this struct:

struct tree_node {
    struct tree_node *left, *right;
}

(But wait, you may ask. These structures contain themselves. Wouldn’t that make them be of infinite size? No; the * by the names of the fields means that they actually hold addresses of nodes, and thus are, on a 32-bit machine, four bytes each.)

You may think that this basic tree_node struct isn’t good for much, because without access to any actual data there’s no way to add elements or test for membership or read the data out or do anything with it. But the rules about ordering mean that we can write a function to find the maximum of a tree without ever checking the values ourselves:

struct tree_node *tree_max (struct tree_node *tree) {
    if (!tree) {
        return NULL;
    }
    while (tree->right) {
        tree = tree->right;
    }
    return tree;
}

This function will work fine if handed the address of a true tree_node, an int_node, or a string_node. If we want to underscore the relationship between these three structs, we can redefine int_node and tree_node as follows:

struct int_node {
    struct tree_node base;
    int val;
}

struct string_node {
    struct tree_node base;
    int length;
    char val[32];
}

In assembler, we would define our types as a series of sizes and offsets:

TREE_NODE_SIZE     equ 8
TREE_NODE_LEFT     equ 0
TREE_NODE_RIGHT    equ 4

INT_NODE_SIZE      equ 12
INT_NODE_VAL       equ TREE_NODE_SIZE

STRING_NODE_SIZE   equ 44
STRING_NODE_LENGTH equ TREE_NODE_SIZE
STRING_NODE_VAL    equ TREE_NODE_SIZE+4

And then we could write tree_max like this:

;; Take the address of a tree in EAX and return the node containing
;; the maximum value, also in EAX.

tree_max:
        push    ebx
        cmp     eax, 0
        je      .done
.lp:    mov     ebx, dword [eax+TREE_NODE_RIGHT]
        cmp     ebx, 0
        je      .done
        mov     eax, ebx
        jmp     .lp
.done:  pop     ebx
        ret

A Brief Reality Check

Remember that just because two types have a consistent layout, that doesn’t mean they are interchangeable! If we have two types—one representing a square, whose sole data member is a floating point number identifying its edge length, and another representing a circle, whose sole data member defines its radius—while it won’t crash our system to pass a circle to the square’s area function, we won’t be getting back the area of the circle when we do so!

To get that effect—that is, a pointer to either a circle or a square, and a call to a fixed label that compute the area properly given the type of the struct—we need the power polymorphism gives us. We’ll tackle that next.

Polymorphism

In order to take advantage of polymorphism, we need some way to have a single instruction call one of several targets. This isn’t terribly difficult in assembly language; we can use indirect addressing modes for jump and call instructions, just like for move or arithmetic instructions. In C, it’s not exactly difficult either, but the syntax is obnoxious. Unlike in assembly language, we can only take the address of a full-fledged function, and we have to specify all the arguments and return types as well. However, once that has been done, we have a variable called a function pointer that may have any compatible function assigned to it.

That’s not a particularly onerous restriction; having identical arguments and return types is necessary for two calls to not trash our memory anyway. We’ll begin with the most direct approach and then optimize or generalize it.

Example Data

Let’s consider game objects in a simple 2D video game. Each one has a position, a velocity, a graphic, and a frame number:

struct game_object {
    int x, y, dx, dy, frame;
    struct graphic *sprite;
};

We’d like to use this for pretty much everything in the game, and each frame will involve each object getting some kind of update function called on it once, and then some kind of draw function called to render it to the screen. We can imagine that most objects will update themselves by adding dx and dy to x and y, and that they will draw themselves by rendering sprite somehow at (x, y). But the player’s object needs specital update logic to interpret input, and most enemies will want more sophisticated motion patterns than just straight lines. Also, the score and lives display might also be a game object. It will need not only some extra fields to hold the score and lives total, but it will also need a special draw function.

The non-polymorphic way to do this would be to give each sort of game object its own array of stuff; the player is a single game_object, as is the score/lives display. Then we have an array of player shots, enemy shots, and one for each kind of enemy. Then each frame we’d run through each array of objects independently, calling the appropriate function.

With polymorphism, the main loop looks more like this:

for (i = 0; i < NUM_OBJECTS; i = i + 1) {
    update(objects[i]);
}
for (i = 0; i < NUM_OBJECTS; i = i + 1) {
    draw(objects[i]);
}

It won’t be exactly like that, because getting polymorphism to work means function calls end up looking a little different, but we can get pretty close.

Step 1: Doing the Obvious Thing

Our first cut at this is pretty straightforward. We need to know which version of update or draw to call on any given game object? Well, let’s just put a couple function pointers named update and draw into our record. The syntax for that is perhaps a bit eye-watering, in C:

struct game_object {
    int x, y, dx, dy, frame;
    struct graphic *sprite;
    void (*update)(struct game_object *);
    void (*draw)(struct game_object *);
};

We can then write statements like player.update = player_update; and enemies[0].update = divebomber_update; and proceed from there.

In assembler, GAME_OBJECT_UPDATE and GAME_OBJECT_DRAW are offsets into the game object record like any other, and are the size of pointers. They can be initialized by writing the value of the function’s label to them. Make sure the calling conventions match up!

Notice that these functions have to take a pointer to a game_object struct. That’s because they don’t know how their caller got their address—they need to be told which game object they are updating. This means that actually making sure that data and function match up makes the call look something like objects[i]->update(objects[i]). For assembler, our lives are much easier if we decide that the first argument has to live somewhere stable, and use it to determine the call address as part of the act of calling it. Despite other differences, all the major 32-bit Intel calling conventions seem to have settled on using ECX for this. That makes calling update on some object look something like this:

        mov     esi, 0
loop:   mov     ecx, [game_objects+4*esi]
        call    [ecx+GAME_OBJECT_UPDATE]
        inc     esi
        cmp     esi, NUM_OBJECTS
        bne     loop

(Yes, you would probably use xor esi, esi instead of mov esi, 0. My goal here is clarity, not peephole optimizations.)

This is actually a pretty solid approach, and it’s also nice because we can mix and match. If we decide that an enemy ship should have its behavior change when it gets hit, we can reassign its update field to something else. That’s kind of neat, but honestly it is not as nice a trick as you might think; you can do the same thing with an extra field and an if statement inside a single update.

At this point, we have now actually created a data structure that behaves in all ways like something that OO would accept as "an object". The update and draw fields of game_object, used in the systematic manner we have described here, end up being more fundamental to the organization of the program than the player_update or divebomber_update functions that are assigned to them. They can be thought of as a sort of super-function that is part of the game_object itself. The OO term for these super-functions is methods.

(While the individual variables inside a record are usually called fields, traditional OO terminology reserves the term for the ones that aren’t used as methods. The preferred term for the component parts of an object is members. Happily, we also often speak of the members of a struct, so as long as we’re working in C the terminology does tend to stay consistent.)

Step 2: Not Repeating Ourselves

Sticking a bunch of function pointers into your struct is actually a very solid and simple way of getting object-oriented behavior out of a non-object-oriented system. However, it doesn’t scale all that well. We’re spending 8 bytes of every little thing in our game to specify behavior, and if we had more methods, as we would in a real system, that would get worse and worse. We’re paying 4 bytes per method per object. But the way our system is set up, there is a strong correlation between all these methods. If there are a hundred different game objects on the screen, there’s probably only about five or six unique combinations of update and draw amongst them. We can save a bunch of space by not repeating ourselves. If we put all of the methods into their own struct (traditionally called a virtual function table or just vtable), and then just refer to globally unique instances of that struct in our game objects, our space usage drops from four bytes per object per method to four bytes per object plus four bytes per method. We lose the ability to update individual method values in an object on the fly, but the space savings are generally great enough—and the alternatives simple enough—that it’s a price worth paying.

Translating our game objects in C to a vtable-based system would produce something like this:

struct game_object_vtable {
    void (*update)(struct game_object *);
    void (*draw)(struct game_object *);
};

struct game_object {
    struct game_object_vtable *vtable;
    int x, y, dx, dy, frame;
    struct graphic *sprite;
};

struct game_object_vtable player_vtable = { player_update, default_draw };
struct game_object_vtable bullet_vtable = { inertial_update, default_draw };
struct game_object_vtable scoreboard_vtable = { inertial_update, score_draw };

In assembler, the vtables are much more literally tables—each one is just a set of method assignments laid out in order with a dd directive or similar.

Invoking a method is now very slightly more involved. In C, it becomes obj->vtable->update(obj). In assembler, we need another load instruction on the way in:

        mov     ecx, obj
        mov     eax, dword [ecx]        ; vtable is the first element
        call    dword [eax+GAME_OBJECT_VTABLE_UPDATE]

Vtables actually provide a bunch of neat other benefits as well. The notion of "starting with a vtable pointer" is essentially a piece of consistent layout across every object ever, and this provides a form of run-time type identification. Given two chunks of memory you know are objects, if you treat them as starting with an address, and those two addresses are identical, then you know for a fact that these two objects are using the same vtable, and are in some sense the same kind of object. You can also store stuff besides methods in vtables—a value kept within the vtable is essentially a value shared across all objects of that type.

The other nice thing about vtables is that they combine very well with the principles of encapsulation and inheritance we described earlier. That is, I think, the part where where the possibilities of OO development started making people a little giddy. We now have two structs; the vtable struct and the struct that stores the actual data (the "instance"). We’ve already talked about applying notions of inheritance to structs—it turns out that we can apply them to the instance and to the vtable. We can even do so independently! Extending the instance struct gives us additional data fields that methods can operate on. (To actually get any use out of this, we do of course also need to replace the method’s implementation.) By extending the vtable struct, we can add entirely new methods.

These mechanisms are, alone, enough to implement most of the strategies described in the classic Design Patterns book. There are two features they rely on which we haven’t addressed. The first, which is a pretty simple one, is abstract classes. These are classes that do not have implementations for all of their methods, and as a result they are illegal to instantiate. If you’re actually writing a compiler for an OO language, you generally create a special error-reporting routine to assign to these unimplemented methods and slot that routine in for your vtable.

But we aren’t writing a compiler. We want to make any mistakes that we make come out as early as possible, and that means we need the C compiler or the assembler itself to catch this error. We can do this by defining the vtable struct as usual, but then never actually providing one for an abstract class. Only completely implemented subclasses will qualify for vtables, and such only they will have labels that can actually be correctly assembled and linked.

The second feature is multiple inheritance. This is a much bigger problem for us. If we have a class descended from two otherwise unrelated types, that means there is nothing about those parent types that is layout-consistent, in either the instance struct or the vtable struct. At minimum we’d seem to need two vtables and use the right one depending on how we were being used. But our mechanism doesn’t allow for this—our scheme insists that the vtable struct be the first entry.

That’s a sticky problem, and we’re not the only one that faces it. C++ in particular also generally has this problem, and most C++ ABIs solve it by having multiple vtable structs within the object. This has the somewhat alarming side effect that exactly what value you pack into ECX for a method call depends on which supertype is the relevant one for this call. In C++ itself, this means that casting a pointer can actually change its representation when you cast it.

That’s far more finicky and unstable than we’d really like to do here. Our system should be robust even when written by human beings that have left their code alone for six months or more.

We’ll need to step back and rethink our assumptions.

Step 3: Rethinking Our Assumptions

The first assumption that we made is that objects need internally consistent vtable and instance structs. If we violate this, then altering the type our object is treated as involves altering the value that we use for the object’s pointer. That’s a bummer, and it’s an even bigger bummer on the instance struct side. When we allow multiple inheritance in its full generality, the nature of what it means to look up a field in an object starts moving further and further from our simple struct-based model.

So let’s keep that assumption, and formalize it: the only kind of multiple inheritance we’re going to care about is multiple interface inheritance. That is, we’d like to be able to use a single object with methods that are part of two, unrelated vtable structs. In Java, interfaces are actually an entirely different thing from classes, right down to the bytecode level. This means Java isn’t making an assumption that we have been making—we’ve been assuming that all method calls are equal. What if we had a notion of a call, completely independent of our standard notions of inheritance and method calls, that would let a class match some other interface?

"Match some other interface" sounds a lot like an alternative vtable. And that’s exactly what it is. Instead of loading our vtable from the object itself, we instead pass it in as an entirely separate argument. We have a bunch of options for how to do this, though, because we aren’t bound to any particular language.

We can follow Java, and indicate that specific classes intrinsically implement certain interfaces. This means that the interface vtables are actually part of the object’s vtable itself, and that we may extract this vtable by consulting the object’s own vtable. That’s enough to cover pretty much the entirety of the OO design space, and it also interoperates pretty neatly with inheritance and our inheritance-based polymorphism.

And had I been writing this article five years ago, that would really be the whole story. But I’m not. The rise of recent languages like Rust and Go show a space where languages have interface inheritance but never inherit implementations. In this case, instance structs are the only part of the object that is "real" from an implementation standpoint, but it also means that we always know the exact type of every struct we work with. That means that when we make interface calls, we can pick the correct vtable to use directly, at call time. We’ll also need to pass it in, so that if we need to call other methods from the interface we know how. This again gives us our polymorphism, but now in the absence of inheritance as we know it.

This seems like a step backwards—after all, we’ve just dropped the ability to do implementation inheritance at all—but we are buying something with it. In particular, our objects no longer intrinsically limit their own behavior. That means that if someone were to come along later, they can actually retroactively make our instance type conform to new interfaces simply by producing an appropriate vtable for it. That’s a trick Java never picked up.

But this decoupling of instance struct from the behavior associated with it does mean that we’re no longer—quite—dealing with "objects" as OO programming knows them. Rust refers to these standalone vtable structs not as classes, but as traits. That seems like a reasonable term to appropriate for this technique as well.

Concluding Heuristics

High-level languages enforce constraints on what can be expressed, in order to make sure that what we expressed and what we meant were actually the same thing. At the assembly language level, we do not have these abilities to assist us, and must rely on our own discipline. High-level language constructs thus become disciplines we may practice to make our assembly language more structured:

  • Encapsulation is a discipline that systematizes our symbol selection and exports.
  • Inheritance is a discipline that aids us in laying out sets of related data structures for in-program code reuse.
  • Polymorphism is a discipline that structures our use of function pointers, much as the notions of loops and if/then/else structure our use of jump instructions.

The vtable implementation technique also embodies a notion that our data objects really have two components—a shared component that is the same as all other instances of its type, and an instanced component for data that is unique to that one object. This split of shared vs. individual turns out to also have enormous resonance, and many alternate lineages of language design ultimately end up revolving around this very split. I will explore those in future articles.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s