One can find an awful lot of hoopla these days about how object-oriented programming (OOP) failed as a programming paradigm, especially as compared with a recent resurgance in functional programming (FP) and even increased interest in procedural programming, along with the rise of data-oriented programming.
It absolutely is a serious problem that our industry espoused OOP as the One True Paradigm for decades. However, OOP is just a tool. No tool is inherently bad or good; they have target use cases, and it is the application of the wrong tool for a job that causes problems. Using OOP for everything is a serious mistake; avoiding OOP even where it works well would also be a mistake.
I hope I can do my part to shed light on what use cases OOP fits well so that we can make reasoned arguments about when to pull OOP out of the toolbox without shame or regret as well as possessing well-justified arguments for when OOP should be set aside.
This article does not demonize or evangelize any particular languages but rather focuses on OOP and other paradigms. Folks who dislike the semantic complexity of C++ or dislike the rigid class-based type system of Java may often espouse “simpler” languages like C as superior. They mistakenly phrase this in terms of OOP vs procedural programming. OOP can be used in C almost as easily as it can be in so-called “object-oriented languages.” OOP exists independent of any language feature.
Object Oriented Programming
As a first point of order we should examine the definition of object-oriented programming. The Internet abounds with sources claiming many things about OOP (and I will add to that pile, no doubt), but a number of these seem to confuse elements of particular languages with OOP itself.
The Wikipedia definition:
Object-oriented programming (OOP) is a programming paradigm based on the concept of “objects”, which can contain data, in the form of fields (often known as attributes or properties), and code, in the form of procedures (often known as methods).
That’s… pretty much it. Nothing in there about inheritance, or class hierarchies, or public and private members, or dynamic polymorphism, or dependency injection, or anything like that.
OOP does not belong to a language, nor does OOP require a specialized language. OOP is not Java. OOP is not C++. OOP is not SmallTalk nor Python nor C#. OOP can be used in C. OOP can be used in Rust or Haskell or LISP.
OOP - Just a Paradigm
OOP belongs to the family of concepts called programming paradigms. Many such paradigms exist, and they’re not mutually exclusive! For example, structured programming is a paradigm and one we tend to liberally use whether writing OOP or procedural code. Metaprogramming is another paradigm, popular in languages from C (via the preprocessor) to C++ to Python to Rust, alongside (or not) with OOP.
Paradigms do not exist in a mutally-exclusive set. Paradigms provide guidelines that help a software architect design elements of software to satisfy certain goals. A complete program can be made up of many elements, each with their own goals and thus their own appropriate choices of paradigms.
Software can follow OOP principles without admantly adhering to specific forms of object. Software may use an OOP wrapper around procedural logic composed by a declarative configuration in a functional environment.
OOP - No Inheritance Required
A great deal of complaints about OOP speak about OOP in the same breath as the word “inheritance,” or even just aside words like “classes.” Inheritance isn’t actually a property of OOP, but rather a property of certain type systems.
Inheritance is a language feature common in class-based languages for modeling composition, or more accurately, extension. It’s also leveraged in languages like C++ or Java to enable dynamic polymorphism (virtual
functions), but that is a facet of the languages.
Rust for example uses traits for modeling polymorphism. Rust lacks inheritance support in the language at all, and yet Rust code can still follow OOP principles! OOP is even fairly common in C; the language just doesn’t offer syntactic shortcuts for the class-based variety of OOP like C++ does.
OOP - Taxonomy Conflation
OOP can sometimes be taught using taxonomical measures; this is especially common in class-based programming environments. Examples like animals (dogs and cats are both mammals which are animals) or shapes (squares are rectangles which are polygons which are shapres) are especially common.
The first and foremost problem with this taxonomical teaching is that it implies inheritance is important to object-oriented programming. We’ve established this is not the case.
The second problem is that computers aren’t people. Human beings have a tendedency to want to label and categorize everything; software typically is not concerned with these. Software is concerned with computation and communication.
Objects should be defined around how they interact, not what they represent. An object should not be defined for Animal
because there’s a very great many things that an Animal
object would represent. An object should instead be defined around a set of related interactions and states, such as Locomotion
(or something even more fine-grained). As a guideline with design here, focus on what the software seeks to accomplish first before deciding how to structure OOP interfaces (or before even deciding if OOP is a useful paradigm for the code in question).
OOP - Modularity Strengths
OOP possesses strengths for designing modular software via objects that interact along well-defined interfaces. In other words, OOP composes on behavioral boundaries.
While functional or procedural programming allow modularity along the boundaries of functions/procedures, these can sometimes fail to accurately model more complex behaviors. Modularity may involve a behavior that interfaces with a large number of “verbs.”
Well-defined interfaces (the “public” functions and fields) of OOP enable this modularity. They allow a developer to swap in a different implementation of a hash table with modifying all the code that uses hash tables, for example.
Other approaches to modular software exist. The programmer’s toolbox contains a great many tools, of course.
Functional programming, specifically with the use of sum types, can implement the multi-verb behavior use case without the need for objects and multiple functions, for example. Sum types do not exist free of cost, either syntactically nor at run-time, however, though they may reduce the surface area of an interface compared to OOP principles; there are always trade-offs to weigh and consider before making a final choice.
OOP - Dynamic Polymorphism Optional
Another frequent complaint of OOP is that it inherently incurs inefficiency because of virtual
(dynamically polymorphic) member functions. There is no requirement that OOP use dynamic polymorphism at all; while some languages like Java or Python provide class systems with dynamic polymorphic behavior by default, languages like C++ or Rust provide type systems offering statically polymorphism by default.
Rust traits enable a flavor of OOP which typically function completely statically. C#’s generics contraints or C++ templates (with or without C++20 “concepts”) offer another example of enabling static polymorphism over objects with zero runtime costs.
When dynamic polymorphism is required, the question of overhead is a little murky. For example, using C++’s native support for virtual
functions can be slightly less efficient than a hand-rolled C implementation in some very specific cases, though the C++ approach is as good as anything that C can do in other cases. Whether using C++’s virtual
functions or something hand-rolled in C doesn’t change whether code is following OOP principles or not.
OOP - Object Encapsulation
Encapsulation is really just another facet of modularity. Encapsulation can sometimes be cast in light of safety, as in protecting the user from shooting their own foot, but that’s not its sole benefit.
For modularity, well-defined interfaces are key; encapsulation is part of defining that interface. If code can reach into the internals of a data structure or algorithm, then de facto the ability to do so is part of the interface (see Hyrum’s Law), and hence different implementations of the interface may not be as interchangeable as desired.
Encapsulation isn’t strictly necessary for OOP. Dynamic languages often allow “monkey patching” of code to mutate classes or objects with new or replacement member functions. C, C++, and Rust libraries exist with OOP interfaces that expose internal data fields.
Complaints exist that encapsulation and hence OOP necessitates boilerplate, specifically getter and setter functions. This is especially common when languages like Java or C++ are brought up. To reiterate, though, these are failings of the languages and their particular class-based type systems. They lack the ability to model externally-immutable fields. C++ especially suffers from a lot of verbosity due to its lack of an abbreviated function syntax or “properties.” Language problems, not OOP problems.
OOP - Cache Inefficiency
Pronouncements exist on the inefficiency of OOP compared to other paradigms, particularly data-oriented programming. There’s a few reasons these claims are made, but I’ll focus on the larger problem.
OOP implementations typically require fields to be co-located. Each object in a class-based language bundles all of its fields together in a single contiguous block of memory. This causes inefficiency if an algorithm needs to iterate a collection of the objects and use only a subset of their fields. Cachelines are poorly utilized and more cache misses occur. This is certainly a real problem!
Code that has a need to iterate just parts of objects indicates that perhaps the objects are violating the single responsibility principle. This is less an argument that OOP is bad and more an argument that poorly structuring data can be inefficient. Procedural code can easily use poor data structures, and even highly data-oriented code can use OOP elements if those objects are clearly defined in a data-oriented friendly manner.
Class-based languages and engineers who take an OOP-first design approach absolutely can end up designing software that bundles data together in inefficient ways, though. This doesn’t mean that OOP is necessarily bad; it just means that it has unnecessary costs when applied to the wrong parts of software, same as any other paradigm!
OOP - Avoid Dependencies
OOP can be lumped into complaints about messy dependencies. By association then, supposedly, OOP necessitates dependency injection (DI) and thus forces the use of dynamic polymorphism and spaghetti dependencies.
The first important thing to recall is our good friend, the single responsibility principle. Dependencies indicate that an object is probably doing too many things. If an object’s purpose is, say, generating email from text templates, it probably shouldn’t also be responsible for sending those emails nor should it be responsible for loading templates off of disk.
Yes, if a responsibility can reasonably be scoped to a single procedure, OOP should perhaps be avoided. There is not strong need to create an “object” that contains a single function and no data; even most “object-oriented languages” provide some means of writing stand-alone functions.
Procedural code often suffers from this problem as well. One could argue that “OOP” code that has these kinds of dependency problems is actually too procedural in its implementation. Ultimately, though, the golden rule is to avoid dependencies by keeping both objects and procedures focused on singular purposes.
OOP - Rampant Mutation
Another frequent complaint of OOP is that it relies on heavy mutation of data, which in turn makes it difficult to guarantee memory safety in an unmanged (non-garbage-collected) language and makes it difficult to avoid data races in multi-threaded code.
OOP certainly can be written in a less problematic way, though that is uncommon in mainstream languages like C++ or Java. Some FP languages or FP-like libraries for example use OOP principles to model their non-trivial data types (with interfaces guaranteeing immutability). JavaScript code is quite frequently these days written in a “functional style” even though all its non-trivial types are modeled via OOP (in a prototypal language, no less).
The primary reason mutability is so common in mainstream languages is often performance rather than object orientation. Compilers generally can’t opportunistically replace something like a functional map
operation with an opportunistic in-place transform
, in no small part because identifying when such is safe to do is difficult.
Some languages, like C++ or Rust, offer variations that allow immutability and mutability to co-exist. C++ and C do so weakly with const
semantics, while Rust offers a strong “immutable XOR shared” model that mixes the reliability of FP’s immutable semantics with procedural efficiency when necessary.
When to Use OOP
I propose the following “goal definition” for deciding when OOP may be the right tool for the job:
The primary goal of the OOP paradigm is to promote composition of interchangeable behaviors and their associated state via the principles of well-defined interfaces, data and algorithmic encapsulation, and isolation of concerns.
Use OOP - Decoupled Stateful Behavious
Drivers in an OS kernel can serve as a perfect use case for OOP.
Indeed, despite the Linux kernel’s preference for procedural programming and the C language, it uses OOP for its driver interface. Micro-kernels, were drivers exist in wholly separate processes, still typically follow OOP principles for driver interfaces; they use protocols to communicate between drivers rather than regular procedures, true, but many are object-oriented in design.
Another example would be the actor model of concurrent computation and state management. Highly-concurrent systems prefer to avoid state of any kind, but that is not always feasible or desirable. Such cases can be served by the actor model, which is another flavor of OOP.
Use OOP - Interchangeable Models
Data types and containers are well served by OOP. Even many functional programming environments use OOP methodologies for non-trivial data types, such as sets and maps.
The key benefit of OOP for these types is that it allows multiple implementations to co-exist and be easily interchanged (be it statically or dynamically). For example, a key-value map can be implemented with a great number of strategies (trees and hash tables, and multiple implementations of either).
Purely procedural environments provide some functions to operate on a specific form of data and require that data to be the appropriate type. Changing a body of procedural code to use a different kind of hash table implementation, while preserving the existing implementation for other code, requires changing both the data definition and every procedure invocation.
Object-oriented environments allow the code to express intent by following a well-defined interface, possibly implemented by multiple objects. Statically-typed languages may still require some types to be updated in various parts of the code base to change which map implementation is in use, but this is a far smaller surface area than found in procedural code.
This is indeed why so many functional environments follow OOP principles when definining data types, though of course they may not provide any specialized syntax for doing so.
Use OOP - External Interfaces With Many Entrypoints
Some interfaces naturally offer many entrypoints or verbs in their definition. While it may be theoretically possible to redefine these interfaces to be more compatible with data-oriented or functional paradigms, sometimes that isn’t an option. The interface may be part of an established protocol, an external system, or something along those lines.
Such an interface may require a small amount of state to maintain the connection to the external resource and is otherwise easily modeled as a series of procedures of functions. OOP paradigms easily fit this use case, even though the polymorphism enabled by OOP may not be necessary.
However, with external systems, multiple polymorphic implementations may indeed be quite useful. Well-written OOP should not in itself require any kind of dependency injection or mock interfaces; however, the external natural of certain dependencies can making DI and mocking highly valuable whether using OOP or any other paradigm, and OOP just happens to be a strong contender for such cases.
It is useful to be mindful of how external interfaces are modeled in OOP. A “single” external interface may or may not be best modeled as multiple objects. Consider how the interfaces are used rather than how they are specified.
When Not to Use OOP
I’ve written many words above mostly in defense of OOP. However, just because OOP isn’t _as bad as some claim does not mean that OOP is always good. OOP has weaknesses and poorly fits many circumstances in which it should probably be avoided, despite the preponderance of “OOP everywhere” in the last few decades.
I’ll also offer some advice on other paradigms to consider to use alongside or instead of OOP in these situations.
Avoid OOP - Large Data Sets
Data residing in a large set may not be the best fit for OOP. The overall collection itself may be fine, but there can be efficiency concerns if the individual data elements are modeled with OOP and the object interfaces do not match the computation requirements.
The cache efficiency complaints with OOP are quite often valid. Especially in class-based languages.
Data-oriented programming is often championed as the better approach for mutating large extant data sets efficiently. Functional programming is a strong contender for computations involving lazy data sets, or for composing a pipeline of transformations and computations.
Avoid OOP - Logic and Data Are Separable
OOP, and to an extent also encapsulation, are most beneficial when data and logic are usefully encapsulated behind interchangeable interfaces.
Note that this isn’t talking about separation of interface from implementation, but rather cases where the implementation itself is stateless.
Functional programming is a very strong contender for cases where logic needs to be composable over a data set. Querying, filtering, transforming, and analyzing collections of data, for example. These can be used with objects defined via OOP principles, certainly, but the logic and construction of the pipeline itself can fit better with functional paradigms.
Procedural or data-oriented techniques also can make a lot of sense when logic needs to be executed over data that is not intimately tied to the logic.
Avoid OOP - Behavioral Configuration
OOP provides excellent tools for behavior composition, but actually defining which behaviors to compose or how they should interact may be a different story.
OOP interfaces with great deals of configuration and internal flexibility imply that the objects “do too much” and have wide interfaces. Those interfaces themselves also might require frequent extension and change to accomodate new configuration needs, which indicates that the interfaces may not be all that well-defined (at design time).
Declarative programming eases the boilerplate of configuration for these sorts of uses, though it can make debugging more difficult in many cases. Functional reactive programming will also typically have less boilerplate than most OOP equivalents, and they retain a decent debugging experience, though perhaps not as convenient as procedural techniques.
Avoid OOP - Long Fixed Procedures
Long procedures are themselves something of a problem, but they do exist with good reason in some codebases. This is especially common for the core “business logic” of some sorts of programs; breaking up a single-use procedure into multiple single-use procedures can in some cases just make code harder to follow and debug (though usually it helps with readability and debugability!).
When longer procedures are necessary or preferred, however, that’s generally a strong indication that OOP may not be a good model. The procedure might use objects that follow OOP principles, but the procedure itself should perhaps not be part of an OOP interface. After all, a long procedure indicates that it’s doing many things, and doing many things indicates that the procedure doesn’t have a true single responsibility.
This is perhaps an obvious one, but procedural programming may be the best approach for long procedures. Procedural programming also results in relatively linear code - especially when combined with structured programming - which results in an easier time debugging and maintaining the code.
Avoid OOP - Private Data
OOP benefits from encapsulation. Encapsulation establishes well-defined interfaces, which are one of the most important requirements for the use of OOP. However, OOP does not have sole ownership of encapsulation.
In some cases, the encapsulation and the well-defined interface aspect of OOP may be desirable without the other elements of the OOP paradigm.
Procedural environments like C or Rust can provide encapsulation in procedural code via the use of translation unit or module boundaries. Functional programming like JavaScript or Rust can provide encapsulation via closures and upvalues. Encapsulation does not require OOP.
Avoid OOP - Ghost of Flexibility
OOP is great at composing behaviors which offers great flexibility. However, not all “flexibility” actually requires composing object interfaces and encapsulated data.
Data-oriented techniques like the Entity-Component-System (ECS) architecture present a means for achieving highly flexible composition of logic (“systems”) over a data set (“components”) with no requirement for OOP principles. Of course, ECS can be used with OOP principles, and indeed many ECS libraries use OOP for definining ECS systems or even components while still being data-oriented.
Avoid OOP - Single Function Interfaces
When an interface is very small, as in a single function, OOP is probably not the best paradigm; single-function interfaces are almost always well-served by functional programming, assuming the host programming environment offers closures as a language feature.
Some popular environments like C, C++98/03, or older versions of Java do not offer closures, resulting in a tendency to use classes to define callback “interfaces” to simple objects that contain some state and a single method.
As a paradigm choice, consider leaning into function programming rather than OOP when single functions (simple transformations or event receivers) are necessary. Even in languages like C, which lacks closures, a combination of a function pointer and data pointer may prove superior to a callback object.
Summary
OOP is a’ight, but far from perfect. FP and procedural programming are also fine and dandy, when used in appropriate places.
Do due diligence during design (and actively apply alliteration as appropriate). Choose paradigms when they are beneficial to the problems at hand, not whether they’re trendy or a pet favorite.
And remember, a paradigm is just a set of guidelines. Fancy language features, type systems, or strict rules aren’t always necesary to using a paradigm well.
Bonus - OOP in C
Some small examples of OOP in C with various features, mostly as evidence that OOP is not tied to a specific language. These examples reflect structures found in languages like SDL2, PCRE, Lua, libuv, GTK, and many others.
These approaches are options, not advisements; when they fit the problem domain at hand they may provide some valuable tools, but as with the OOP paradigm itself, these approaches are not meant to be applied to every problem.
Bonus - OOP in C - Static Without Data Encapsulation
This form allows no data encapsulation, but does allow the user to allocate the object memory wherever they see fit.
Note that C mostly lacks the primitives to enable static polymorphism for open-ended object definitions, which degrades somewhat the usefulness of this approach; however, it still allows compiler- or tool-assisted transition between object implementations more easily that code which doesn’t attempt to follow OOP hygiene.
struct object {
int field1;
float field2;
};
void object_create (struct object *self);
void object_destroy(struct object *self);
void object_action1(struct object *self, int arg);
int object_action2(struct object *self, int arg1, float arg2);
int object_action3(struct object *self);
// usage exmaple
object obj;
object_create(&obj);
object_action1(&obj, 1);
int result = object_action2(&obj, 2, 3.5f);
object_destroy(&obj);
Bonus - OOP in C - Static With Data Encapsulation
This form encapsulates all fields but requires that objects be allocated explicitly. Such APIs may offer custom allocator support via a custom malloc
hook or may just use the default malloc
implementation.
As above, this still lacks the ability for static polymorphism.
typedef struct object_t object;
object *object_create ();
void object_destroy(object *self);
void object_action1(object *self, int arg);
int object_action2(object *self, int arg1, float arg2);
int object_action3(object *self);
// usage exmaple
object *obj = object_create();
object_action1(obj, 1);
int result = object_action2(obj, 2, 3.5f);
object_destroy(obj);
Bonus - OOP in C - Static Polymorphism
This form illustrates the limited way that C11 enables static polymorphism. This only works on closed sets of implementations, meaning that all implementations must be known at the time the object interface is authored. It is somewhat equivalent to sum types approaches for polymorphism in functional languages in this regard.
The advantage is that it allows for easily swapping out implementations at compile time without needing to change much code. This works especially well when paired with generic programming (preprocessor generics in C).
typedef struct object_impl1_t {
int field;
} object_impl1;
void object_impl1_create(object_impl1 *self);
void object_impl1_action(object_impl1 *self, int arg);
typedef struct object_impl2_t {
float field;
} object_impl2;
void object_impl2_create(object_impl2 *self);
void object_impl2_action(object_impl2 *self, int arg);
#define object_action(obj, arg) \
_Generic(*(obj), \
object_impl1: object_impl1_action((obj), (arg)), \
object_impl2: object_impl2_action((obj), (arg)))
#define object_destroy(obj) \
_Generic(*(obj), \
object_impl1: object_impl1_destroy((obj)), \
object_impl2: object_impl2_destroy((obj)))
// usage exmaple
object_impl1 obj;
object_impl1_create(*obj);
object_action(&obj, 1);
object_destroy(&obj);
Bonus - OOP in C - Dynamic With VTables
Hand-rolled code mostly equivalent to typical C++ implementations of virtual
member functions.
This can be combined with either of the previous approaches. This example does not encapsulate the vtable pointer itself but assumes that the specific implementations are encapsulated.
Note that the object_t
could expose some fields (that are common to all implementations) if desired.
struct vtable_t;
typedef struct object_t {
const vtable *vtable;
} object;
struct vtable_t {
void (*destroy)(object *self);
void (*action1)(object *self, int arg);
int (*action2)(object *self, int arg1, float arg2);
int (*action3)(object *self);
};
object *object_create_impl1();
object *object_create_impl2();
inline void object_destroy(object *self) { self->klass->destroy(self); }
inline void object_action1(object *self, int arg) { self->klass->action1(self, arg); }
inline int object_action2(object *self, int arg1, float arg2) { return self->klass->action2(self, arg1, arg2); }
inline int object_action3(object *self, int arg1, float arg2) { return self->klass->action3(self); }
// usage exmaple
object *obj = object_create_impl1();
object_action1(obj, 1);
int result = object_action2(obj, 2, 3.5f);
object_destroy(obj);
Bonus - OOP in C - Dynamic Without VTables
Almost identical to the above except the function pointers are inlined into the object structure. This increases the size of the object and incurs less cache friendliness, but removes a level of indirection. This can be ideal when there are few instances (perhaps just one) of a given implementation, and it is called into frequently.
typedef struct object_t {
void (*method_destroy)(object *self);
void (*method_action1)(object *self, int arg);
int (*method_action2)(object *self, int arg1, float arg2);
int (*method_action3)(object *self);
} object;
object *object_create_impl1();
object *object_create_impl2();
inline void object_destroy(object *self) { self->method_destroy(self); }
inline void object_action1(object *self, int arg) { self->method_action1(self, arg); }
inline int object_action2(object *self, int arg1, float arg2) { return self->method_action2(self, arg1, arg2); }
inline int object_action3(object *self, int arg1, float arg2) { return self->method_action3(self); }
// usage exmaple
object *obj = object_create_impl1();
object_action1(obj, 1);
int result = object_action2(obj, 2, 3.5f);
object_destroy(obj);
Bonus - OOP in C - Dynamically Polymorphic With Separable VTables
This implementation models Rust’s traits when they’re used dynamically. The trick is that a vtable is used for the dynamic method lookups, but it is not stored in the object iself. This results in minimally small objects (the best cache usage available) while still allowing for fully dynamic polymorphism.
A potential use case here is that code may need to store a large number of identical objects, but cannot know the implementation of those logic at compile time nor can the specifics of the collection be known at compile-time. If the collection is statically known, it may be better to build the OOP interface around the collection itself to reduce the number of indirection function calls required.
Another potential use case here is when the OOP interface contains only method and no fields or internal state. Storing a pointer to the vtable directly removes one level of indirection. Note that OOP cases that are pure interfaces can in many cases just be replaced with functional interfaces, though C lacks facilities like function closures which may make function styles of code more difficult to build than OOP interfaces.
struct vtable_t;
typedef struct object_t {
int field1;
float field2;
} object;
struct vtable_t {
void (*create) (object *self);
void (*destroy)(object *self);
void (*action1)(object *self, int arg);
int (*action2)(object *self, int arg1, float arg2);
int (*action3)(object *self);
};
const vtable_t object_impl1 = { /* function bindings */ };
const vtable_t object_impl2 = { /* function bindings */ };
inline void object_create (vtable_t *vtable, object *self) { self->klass->create(self); }
inline void object_destroy(vtable_t *vtable, object *self) { self->klass->destroy(self); }
inline void object_action1(vtable_t *vtable, object *self, int arg) { self->klass->action1(self, arg); }
inline int object_action2(vtable_t *vtable, object *self, int arg1, float arg2) { return self->klass->action2(self, arg1, arg2); }
inline int object_action3(vtable_t *vtable, object *self, int arg1, float arg2) { return self->klass->action3(self); }
// usage exmaple
object obj;
object_create(&object_impl1, &obj);
object_action1(&object_impl1, &obj, 1);
int result = object_action2(&object_impl1, &obj, 2, 3.5f);
object_destroy(&object_impl1, &obj);