Alternatives to OOP in D
H. S. Teoh
hsteoh at qfbox.info
Mon Sep 1 16:26:15 UTC 2025
On Mon, Sep 01, 2025 at 01:58:23PM +0000, Brother Bill via Digitalmars-d-learn wrote:
> I have heard that there are better or at least alternative ways to
> have encapsulation, polymorphism and inheritance outside of OOP.
>
> With OOP in D, we have full support for Single Inheritance, including
> for Design by Contract (excluding 'old'). D also supports multiple
> Interfaces.
>
> What would be the alternatives and why would they be better?
> I assume the alternatives would have
> 1. Better performance
> 2. Simpler syntax
> 3. Easier to read, write and maintain
>
> If possible, please provide links to documentation or examples.
OOP is useful for certain classes (ha) of problems, but not all, as the
OOP proponents would like you to believe. Some classes of problems are
better solved with other means.
OOP is useful when you need runtime polymorphism, as in, when the
concrete type of your objects cannot be determined until runtime. This
flexibility comes with a cost, however. It adds an extra layer of
indirection to your data, which means data access involves an extra
pointer access, which in performance-critical loops may cause cache
misses and performance degradation. Also, class objects in D are
by-reference types; in some situations this is not ideal. If you have a
large number of small class objects, they can add a significant amount
of GC pressure, also not ideal if you're dealing with lots of them
inside a tight loop.
My preferred alternative is to use structs and DbI (design by
introspection). Rather than deal with monolithic class hierarchies
(that often fail to capture all the true complexities of polymorphic
data anyway -- real data is rarely easily decomposed into neat textbook
class hierarchies), I have a bunch of data-bearing structs with diverse
attributes. The functions that operate on them are template functions
that use compile-time introspection to determine exactly what the
concrete type is capable of, and select the most appropriate
implementation based on that. This way, rather than trying to shoehorn
the data into a predetermined class hierarchy, the code instead adapts
itself to whatever form the data has that it receives. This allows
great malleability in fast prototyping and extending existing code to
deal with new forms of data, with the advantage that (almost) all
concrete types are resolved at compile-time rather than runtime, so the
resulting code is fully optimized to work on the specific forms of data
actually encountered, rather than pay the overhead tax on potential
forms of data that it does not know until runtime.
In OOP, data and code are tightly coupled, which works well when you
only ever need to perform a fixed set of operations on your data. But
things get messy once you have diverse operations, or an open set of
operations, that you need to perform on your data. You start running
counter to the grain of OOP access restrictions, and end up wasting more
time fighting with OOP than OOP helping you to solve your programming
problem. DbI lets you separate data from the algorithms that operate on
them, so that your data types can focus on the most effective data
storage scheme, while your operations use compile-time introspection to
discover what form your data, adapting itself accordingly to work on the
data in the most effective way.
(If you're interested in more details of DbI, search online for "design
by introspection" and you should see the relevant material.)
Here's an example of the contrast between OOP and DbI, from one of my
own projects. The basic problem is that I have a bunch of data,
residing in various objects of diverse types, representing program
state, that I want to serialize and save to disk, to be restored at a
later time.
In the traditional OOP approach, you'd create an interface, say
Serializable, that might look something like this:
interface Serializable {
void serialize(Storage store);
}
Then you'd add this interface to every class you want to serialize, and
implement the .serialize method to write the class data to the Storage.
If you have n classes, this means writing n different functions.
In my actual project, however, I chose to use DbI instead. In DbI, you
*don't* impose any additional structure on the incoming data. In
contrast to the OOP philosophy of data-hiding, DbI is usually most
effective when your types have public access data. Instead of writing n
different functions for n different types (and I have a *lot* of
different types -- one for every kind of data in the program that I want
to serialize), I just have *one* template function that handles it all.
Here's what it looks like:
void serialize(T)(Storage store, T data) {
static if (is(T == int)) {
store.write(data.to!string);
} else if (is(T == string)) {
store.write(data);
} else if (...) {
... // handle other basic data types here
} else if (is(T == S[], S)) {
// we have an array, loop over the elements and
// serialize each of them
foreach (e; data) {
// N.B.: recursive call, to a
// *different* instantiation of
// serialize() adapted to the specific
// type of the array element
serialize(store, e);
}
} else if (is(T == struct)) {
// This is a struct; loop over its fields and
// invoke the appropriate overload of .serialize
// to handle each specific field type
foreach (field; FieldNameTuple!T) {
serialize(store, __traits(getMember, data, field));
}
} else {
static assert(0, "Unsupported type: " ~ T.stringof);
}
}
Notice that .serialize does NOT have any code that deals with specific
user-defined types. Rather, it detects built-in types and aggregates,
and uses compile-time introspection to adapt itself to each type of data
it encounters, leveraging the combinatorial nature of these basic type
building blocks to handle anything that the caller might throw at it.
Instead of writing n different .serialize functions, one for each
user-defined type, like you'd do in OOP, here you have a *single*
function that handles everything. There are only as many static if
cases as there are basic types you'd like to handle (and you don't even
need to include all of them -- only those you actually use). With just a
small, bounded set of static if blocks, you can handle *any* number of
types. And you can throw new user-defined data types at it and have it
Just Work(tm) without adding a single new line of code to .serialize(!).
You can also incrementally build up your .serialize function: that last
static assert is there deliberately so that if you ever hand it
something it doesn't know what to do with, it will forcefully stop
compilation loudly and tell you exactly what's the missing type that it
hasn't learned to handle yet. Then you just add another appropriate
static if block to the function, and it will now learn to handle that
new type *everywhere it might occur*, including deep inside some nested
aggregate type that it has never seen before.
IOW, once your .serialize function is able to handle all the basic types
you might have in your user-defined types, it will be able to handle any
kind of new data type. All without needing to add a single new line of
code. Compare this with the OOP case where every new class you add will
require to inherit from the Serialiable interface, and then you'd have
to implement the .serialize function. And hope that you didn't make a
mistake and leave out an important data field. Whereas our DbI
.serialize function automatically discovers all your data fields and
serializes them using the appropriate overloads -- without human effort,
and therefore without room for human error.
//
Now, you might ask, what if I want to serialize OOP objects? Since the
whole point of OOP is that you *don't* know the concrete type of your
data until runtime, how can .serialize know what the concrete data
fields of the object are, in order to serialize them? Since they are
not known at compile-time, does that mean our class objects are left out
in the cold while the structs enjoy the power of our DbI .serialize
function?
Nope! Here's how, with a little scaffolding, we can teach our clever
DbI .serialize function to dance with OOP class objects too:
First, we create a CRTP template class that will serve as our DbI
analog of class interfaces:
class Serializable(Derived, Base = Object) : Base {
static if (!is(Base : Serializable!(Base, C), C)) {
// This is the top of our class hierarchy,
// declare the .serialize method.
void serialize(Storage store) {
serializeImpl(store);
}
} else {
// This is a derived class in our hierarchy;
// override the base class method
override void serialize(Storage store) {
serializeImpl(store);
}
}
private void serialize(Storage store) {
// Record concrete type, since it's not
// predictable at compile-time
store.saveClassName(Derived.stringof);
// Downcast to concrete subclass and save it
serializeClassFields(store, cast(Derived) this);
}
}
The saveClassFields function is similar to our original DbI .serialize
function, except that it takes care to iterate over base class members
as well, so that when serializing a derived class we don't miss base
class members that will be required to deserialize the object later.
Other than this handling, it just forwards the bulk of the work to the
DbI .serialize function that uses compile-time introspection to discover
and serialize these members.
All that remains, then, is to add this static if block to our DbI
.serialize function:
void serialize(T)(Storage store, T data) {
...
else static if (is(T == class)) {
serializeClassFields(store, data);
}
}
and, for every class that we want to serialize, declare them this way:
class MyClass : Serializable!(MyBaseClass, MyClass) {
...
}
This slightly unusual way of declaring a base class is to allow the
Serializable template class to inject the necessary .serialize() methods
into the class objects so that you don't have to write class-specific
serialize methods by hand.
And with this, our clever little DbI .serialize function now knows how
to serialize classes, too, and to do so automatically and with almost no
human intervention (other than declaring the class in the above way).
Now, you can even use OOP with DbI and enjoy its benefits! (Well OK, to
a certain extent. The above assumes that your classes are data-storage
classes with public members that can be mechanically serialized. If this
isn't the case, you'll have to do more work. Which is why I prefer not
to do that. Data-centric types work better.)
//
Now, the above is really only half of the story. The other half is
deserialization, which follows the same principle: the corresponding DbI
.deserialize function does pretty much the same thing: use compile-time
introspection to discover the incoming type's data fields, read the
serialized data from disk, and use std.conv.to to convert it to the
correct type (in spite of the naysayers, I think std.conv.to is one of
the best things that's ever happened to D -- my .deserialize function
literally consists of calls to `data.field = serializedString.to!T` --
and it all Just Works(tm)). All fully automated and with no human
intervention, which means that once you've fully debugged .serialize and
.deserialize, you don't ever have to worry about serialization again;
all new data types you add will automatically be serializable /
deserializable with no further effort (or bugs).
Handling classes in deserialization is a bit tricky, but nothing too
hard by combining template classes with static ctors. I won't get into
the details here, but if you're curious, just ask and I'd be more than
happy to spill all the gory details. Basically, we (again) use
compile-time introspection to discover base classes, instantiate a
factory function for recreating the class, then use a static ctor to
inject this function into a global registry of factories that the
deserialization code can use to deserialize the original class.
And of course, as you should expect by now, this is fully automated
thanks to templates and DbI; the programmer does not need to write a
single line of boilerplate to make it all work. Just declare the class
using the CRTP serializable injector above, and the templates take care
of the rest of the boilerplate for you. No need for human intervention,
and therefore no room for human error. It all Just Works(tm).
//
Metaprogramming is D's greatest strength, and templates are one of the
keystones. Rather than shy away from templates, we should rather
embrace them and exploit them in ways other languages can only dream of.
T
--
Real Programmers use "cat > a.out".
More information about the Digitalmars-d-learn
mailing list