More thoughts on The Old OOP

This is a continuation of my thoughts about how the ideas of object oriented programming (OOP) have changed over my time.

I am comparing the OOP applications and ideas that I was taught early in my life against my relative recent “discoveries”. These differences are reflected in the designs of languages of respective eras.

More focus on code reuse than on roles

When I was young, the class inheritance mechanism was often presented as a way to reuse code, thus minimizing duplication. What was not told is the price it came with: the tightest source code dependency it creates.

The “is-a” relation coming means that the derived class is everything that the base class is. Not a big deal in short term, but a huge problem when it is time to change the base class. The longer the dependency hierarchy, the more disastrous effects it has on software flexibility to change after changed requirements. By overly long hierarchy, I mean three or more levels! Three is already too long.

Inheritance these days is presented more as a mechanism to enforce derived classes to implement expected roles used by their collaborators. Inheriting from purely abstract base classes, empty of concrete behavior, is preferred: while it forces derived classes to comply with the calling contract, it frees them from a burden of concrete implementation, which would have been shifting over time.

Benefits of designing shallow hierarchies (one level of derivation at most) are also more prominent.

Everything is an object

The mantra “everything is an object” sounds very attractive. Having a universal convention that any thinkable entity in a program conforms to, promises unlimited flexibility for its users.

Unfortunately, some of old OOP languages (such as C++ and Java, but not earlier Smalltalk and Lisp) do not really fulfill the “everything” part of the promise. Sometimes it was excused by performance tradeoffs, but oftentimes it was just a deliberate language design omission.

The most prominent example are of course functions. In C++ and Java, only relatively recently have functions been promoted to be first-class citizens. Earlier approaches, such as function objects, are awkward because they are heavyweight.

I do not want to have an object that I am allowed to call as a function. Doing so is like making two steps up on the complexity ladder (identity management, requirement to statically belong to a concrete class) in order to represent something one step simpler in conceptual complexity (a deferred action).

A lot of metaprogramming also relies on language’s own concepts (parts of syntax, class system etc.) being treated as objects, i.e. something that we can send a dynamically dispatched message. And again, originally it was either impossible or awkward to reach, especially in C++.

It is perfectly fine to not have everything as an object in your language. In fact, it is not really possible. Such a language would not be free of contradictions. See Gödel’s incompleteness theorems for more details. Just be honest about it.

Namespaces are classes

This was especially noticeable in Java. Classes with static methods are used as ghetto namespaces. Package system, while nice, is not a good match to the idea of flexible nesting of namespaces.

Functions, again, come up to mind as victims of this defect. The trigonometry package is a dead giveaway. Math.sin() is a static method representing sine, Because you cannot have a plain function, you have to put it into a class. Because that function does not have an need for identity, it becomes a static method.

Binding the concept of namespaces to classes is limiting for the same reason why representing functions as objects is silly. A namespace is a simpler concept: a collection of names, possibly nested.

Classes are more important than object instances

This is a case of premature optimization, it seems to me. A lot of things I was taught were about design of classes. But my education missed the point that during the runtime, classes should be barely visible. It is the objects that collaborate and make the program calculate its thing. Unless the program relies on introspection of some sorts (usually it is a relatively small part of application that needs it), classes should not be of concern, but rather what roles individual object could play.

As strange as it may sound, you may have objects without a strict set of classes to bind them to. Surely, having a class allows to churn out a lot of identical objects, but is it really always needed? There are languages that blur the necessity to have such a method to construct new objects. Instead, you may use an existing object as a template for its attributes for bringing one more object to life.

Same with relations between classes: do they really accurately represent relations that objects spawned from them will have later? Or are they existing to help the compiler to generate the most compact and arguably fast code, at the same time restricting programmer’s freedom of expressing what is visible on runtime?

A glimpse of usefulness existing in classes is that they can be used to describe roles for runtime objects to play. But classes as a sole mechanism available for runtime roles should be considered as overly heavyweight. Younger languages have come up with lightweight alternatives such as interfaces, traits etc.

C++ specific: premature optimizations

I have been taught the “syntactic sugar” basics of OOP using C++ as the language of choice (and Delphi, if I remember it correctly). Thinking about it now, this was not the best teaching tool for understanding OOP concepts correctly. C++ shifts focus towards certain “optimizations”, rather than promoting proper OOP concepts as defaults.

The most obvious reason why C++ encourages wrong habits is that during its inception it had to complete against plain C and be compatible with C in certain aspects.

Non-polymorphic methods by default

A core idea of OOP is separation of behavior concealed in methods from names for roles these methods are playing, i.e. runtime polymorphism. In the “legacy” C, the most fitting abstraction for that is function pointers.

The construct this->method(args), i.e. calling a method, should be, in common case, translated to something like this if expressed in C:

(*this).method_table[Method_Name](args);

I’d written (*this).(*method_name)(args); to express the fact that the method name refers to a function pointer, but it would not be a correct C syntax (function pointers degenerate to function names).

The indirection of mapping to which code address to jump to is important. The decision is not necessarily known at compilation time, it depends on the “choice” the object itself (this) made at runtime when it was created. And that choice is preserved during the lifetime of the object.

Unfortunately, in C++, methods are non-virtual by default. This means that, while seemingly using object-oriented syntax, the resulting code does not follow the philosophy of polymorphism. Marking a method as virtual is considered as a last resort when you cannot express your intent otherwise.

What we win by forcing concrete name resolution is one fewer indirection in machine code. What we lose here is the freedom of abstraction for designs we create.

What we need is reversed defaults. A non-virtual (let’s use keyword concrete for it) method should be a conscious choice of programmer to express that polymorphism is not needed here.

And let’s not forget that the compiler itself is often capable of streamlining virtual methods into statically resolved calls when it preserves correctness. Instead, C++ suggests that it is the human who always has to make the choice.

Compare this to how keyword inline works for C++ (and in C) methods/functions. Marking a function as inline is merely a hint for the compiler to inline its body in as many places as it deems fit. If the compiler cannot safely do that (i.e., if a pointer to such function is taken anywhere in the program) or considers inlining to be detrimental, the compiler is free to ignore the hint and handle it as a normal function. I.e., the functions may be inlined at one site and be called normally at another. Moreover, even functions not explicitly marked as inline can still be inlined. In summary, inline bears no change for program semantics.

A hypothetical concrete keyword would be similar to such interpretation. Any method marked as concrete should be possible to resolve at compilation time (maybe it can be tightened so that a failure to do so would result in an explicit compilation error). But at the same time, any (default-)virtual method that can be proven to have only one meaning, the compiler is allowed to optimize calls to it by removing the indirection of function pointers.

Doing so would retain the control C++ programmers want over the calling overhead, while tipping the scale towards better by-default designs.

Necessity to expose private guts

Another C++ weirdness is how you must have private sections laid out in public interface headers. This throws a lot of encapsulation right out of the window.

This is because objects in C++ are allocated in one of two distinct ways. This fact is covered behind constructors’ syntactic sugar. And the necessity to make the choice every time an object is constructed lays on programmer’s shoulders.

Objects can be either allocated on heap (via new()) or on stack (automatic variables).

  1. Allocation on heap requires no need to know about the size of the object allocated. You call a function (constructor) via a pointer of fixed size and you get back a pointer (to the fresh object), of fixed compile-time known size. The knowledge about the size of constructed object can be safely contained within the constructor method. I.e., it is the callee’s responsibility to know such a detail.
  2. Allocation on stack, on the other hand, means that it is the caller’s responsibility to pre-allocate enough space to place the new instance. It means that the caller must know a lot about the class of the created object.

Why have the allocation on stack? Because it is faster (can be usually expressed by a single register update). Another nice property it offers it the fixed known scope of the created object — its lifetime ends when the surrounding method ends. But nothing currently prevents the compiler from finding out these specifics and optimizing heap allocations for only locally used objects by turning them into stack-based allocations.

Conclusion

Defaults matter. Merely providing a possibility for something does not mean that people will be using it instead of relying on a default solution, even if that solution is detrimental.

Premature optimization is bad. Compromising on something only because it might work faster (regardless whether the speed will matter in this place or not) pushes programmers towards making decisions that are bad for long-term maintainability.


Written by Grigory Rechistov in Uncategorized on 31.12.2023. Tags: oop, c++,


Copyright © 2024 Grigory Rechistov