OOP-bashing seems fashionable nowadays. I decided to write this article after seeing two OOP-related articles on Lobsters in quick succession. I’m not interested in defending or attacking OOP, but I do want to throw in my two cents and offer a more nuanced view.
The industry and the academy have used the term “object-oriented” to mean so many different things. One thing that makes conversations around OOP so unproductive is the lack of consensus on what OOP is.
What is Object-Oriented Programming? Wikipedia defines it as “a programming paradigm based on the concept of objects.” This definition is unsatisfactory, as it requires a definition of an “object” and fails to encompass the disparate ways the term is used in the industry. There is also Alan Kay’s vision of OOP. However, the way most people use the term has drifted apart, and I don’t want to fall into essentialism or etymological fallacy by insisting on a “true” meaning.
Instead, I think it is better to treat OOP as a mixed bag of interrelated ideas and examine them individually. Below, I will survey some ideas related to OOP and mention their pros and cons (in my subjective mind).
Classes
Object-oriented programming is a method of implementation in which programs are organized as cooperative collections of objects, each of which represents an instance of some class, and whose classes are all members of a hierarchy of classes united via inheritance relationships. — Grady Booch
Classes extend the idea of a “struct” or “record” with support for the method syntax, information hiding, and inheritance. We will talk about those specific features later.
Classes can also be viewed as blueprints for objects. It is not the only way to do that, and prototypes is an alternative pioneered by Self and, most famously, used by JavaScript. Personally, I feel that prototypes are harder to wrap one’s head around compared to classes. Even JavaScript tries to hide its usage of prototypes from newcomers with ES6 classes.
Method Syntax
In Japanese, we have sentence chaining, which is similar to method chaining in Ruby — Yukihiro Matsumoto
The method syntax is one of the less controversial OOP features. It captures common programming use cases involving operations on a specific subject. Even in languages without methods, it is common to see functions effectively serve as methods by taking the relevant data as their first argument (or last, in languages with currying).
The syntax involves method definitions and method calls. Usually, languages supporting methods have both, unless you consider the “pipe operators” in functional languages as a form of method call.
The method call syntax aids IDE autocompletion, and method chaining is often more ergonomic than nested function calls (similar to the pipe operator in functional languages).
There are some debatable aspects of the method syntax, too. First, in many languages, methods are often not definable outside of a class, which causes a power imbalance compared to functions. There are certain exceptions, such as Rust (methods are always defined outside of the struct), Scala, Kotlin, and C# (extension methods).
Second, in many languages, this or self is implicit. This keeps the code more concise, but it can also introduce confusion and increase the risk of accidental name shadowing. Another drawback of an implicit this is that it is always passed as a pointer, and its type cannot be changed. This means you cannot pass it as a copy, and sometimes this indirection leads to performance issues. More importantly, because the type of this is fixed, you cannot write generic functions that accept different this types. Python and Rust got this right from the start, and C++ just fixed this issue in C++23 with deducing this.
Third, in languages with both “free functions” and methods, they become two incompatible ways to do the same thing. This can cause problems in generic code. Rust addresses this issue by allowing fully qualifying a method name and treating it as a function.
Fourth, the dot notation is used for both instance variable accesses and method calls in most languages. This is an intentional choice to make methods look more uniform with objects. In certain dynamically typed languages where methods are instance variables, this is fine and pretty much not even a choice. On the other hand, in languages like C++ or Java, this can cause confusion and shadowing problems.
Information Hiding
Its interface or definition was chosen to reveal as little as possible about its inner workings — [Parnas, 1972b]
In Smalltalk, all instance variables are not directly accessible from outside the object, and all methods are exposed. More modern OOP languages support information hiding via access specifiers like private at the class level. Even non-OOP languages usually support information hiding in some way, be it module systems, opaque types, or even C’s header separation.
Information hiding is a good way to prevent invariant from being violated. It is also a good way to separate frequently changed implementation details from a stable interface.
Nevertheless, aggressively hiding information may cause unnecessary boilerplate or abstraction inversion. Another criticism comes from functional programmers, who argue that you don’t need to maintain invariants and thus don’t need much information hiding if data is immutable. And, in a sense, OOP encourages people to write mutable objects that must be maintained as invariants.
Information hiding also encourages people to create small, self-contained objects that “know how to handle themselves,” which leads directly into the topic of encapsulation.
Encapsulation
If you can, just move all of that behavior into the class it helps. After all, OOP is about letting objects take care of themselves. — Bob Nystrom, Game Programming Patterns
Encapsulation is often confused with information hiding, but the two are distinct. Encapsulation refers to bundling data with the functions that operate on it. OOP languages directly support encapsulation with classes and the method syntax, but there are other approaches (e.g., the module system in OCaml).
Data-oriented design has a lot to say about bundling data and functionality. When many objects exist, it is often much more efficient to process them in batches rather than individually. Having small objects with distinct behaviors can lead to poor data locality, more indirection, and fewer opportunities for parallelism. Of course, advocates of data-oriented design don’t reject encapsulation outright, but they encourage a more coarse-grained form of it, organized around how the code is actually used rather than how the domain model is conceptually structured.
Interfaces
“No part of a complex system should depend on the internal details of any other part.” — Daniel, Ingalls. “The Smalltalk-76 Programming System Design and Implementation”
Separation of interface and implementation is an old idea closely related to information hiding, encapsulation, and abstract data type. In some sense, even C’s header files can be considered an interface, but OOP usage of “interface” most often refers to a specific set of language constructs that support polymorphism (typically implemented via inheritance). Usually, an interface can’t contain data, and in more restricted languages (e.g., early versions of Java), they can’t contain method implementations either. The same idea of an interface is also common in non-OOP languages: Haskell type classes, Rust traits, and Go interfaces all serve the role of specifying an abstract set of operations independent of implementations.
Interface is often considered a simpler, more disciplined alternative to full-blown class inheritance. It is a single-purpose feature and doesn’t suffer from the same diamond problem that plagues multiple inheritance.
Interface is also extremely useful in combination with parametric polymorphism, since it allows you to constrain the operations a type parameter must support. Dynamically-typed languages (and C++/D template) achieve something similar through duck-typing, but even languages with duck-typing introduce interface constructs later to express constraints more explicitly (e.g., C++ concepts or TypeScript interfaces).
The interface as implemented in OOP languages often has a runtime cost, but that’s not always the case. For example, C++ concepts is an example that only supports compile-time, and Rust’s trait only has opt-in runtime polymorphism support via dyn.
Late Binding
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things — Alan Kay
Late binding refers to delaying the lookup of a method or a member until runtime. It is the default of most dynamic-typed languages, where method calls are often implemented as a hash table lookup, but can also be achieved with other means, such as dynamic loading or function pointers.
A key aspect of late binding is that behaviour can be changed while the software is still running, enabling all kinds of hot-reloading and monkey-patching workflows.
The downside of late binding is its non-trivial performance cost. Moreover, it can also be a footgun for breaking invariants or even interface mismatches. Its mutable nature can also introduce subtler issues, for example, the “late binding closures” pitfall in Python.
Dynamic Dispatch
A programming paradigm in C++ using Polymorphism based on runtime function dispatch using virtual functions — Back to Basics: Object-Oriented Programming - Jon Kalb - CppCon 2019
A concept related to late binding is dynamic dispatch, in which the implementation of a polymorphic operation is selected at runtime. The two concepts overlap, though dynamic dispatch focuses more on selecting multiple known polymorphic operations rather than on name lookup.
In a dynamically typed language, dynamic dispatch is the default since everything is late-bound. In statically typed languages, it is usually implemented as a virtual function table that looks something like this under the hood:
// function pointer to destroy the base
// function pointer to one method implementation
// function pointer to another method implementation
These languages also provide compile-time guarantees that the vtable contains valid operations for the type.
Dynamic dispatch can be decoupled from inheritance, whether by manually implementing a v-table (e.g., C++‘s “type-erased types” such as std::function) or an interface/trait/typeclass kind of constructs. When not paired with inheritance, dynamic dispatch alone is usually not considered “OOP.”
Another thing to note is that the pointer to the v-table can be directly inside the object (e.g., C++) or embedded in “fat pointers” (e.g., Go and Rust).
Complaints about dynamic dispatch are usually about its performance. Although a virtual function call itself can be pretty fast, it opens room for missing compiler inlining opportunities, cache misses, and branch mispredictions.
Inheritance
programming using class hierarchies and virtual functions to allow manipulation of objects of a variety of types through well-defined interfaces and to allow a program to be extended incrementally through derivation — Bjarne Stroustrup
Inheritance has a long history, way backed to Simula 67. It is probably the most iconic feature of OOP. Almost every language marketed as “object-oriented” includes it, while languages that avoid OOP typically omit it.
It can be damn convenient. In many cases, using an alternative approach will result in significantly more boilerplate.
On the other hand, inheritance is a very non-orthogonal feature. It is a single mechanism that enables dynamic dispatch, subtyping polymorphism, interface/implementation segregation, and code reuse. It is flexible, though that flexibility makes it easy to misuse. For that reason, some languages nowadays replace it with more restrictive alternative constructs.
There are some other problems with inheritance. First, using inheritance almost certainly means you are paying the performance cost of dynamic dispatch and heap allocation. In some languages, such as C++, you can use inheritance without dynamic dispatch and heap allocation, and there are some valid use cases (e.g., code reuse with CRTP), but the majority of uses of inheritance are for runtime polymorphism (and thus rely on dynamic dispatch).
Second, inheritance implements subtyping in an unsound way, requiring programmers to manually enforce the Liskov substitution principle.
Finally, inheritance hierarchies are rigid. They suffer from issues like the diagonal problem, and that inflexibility is one of the main reasons people prefer composition over inheritance. The component pattern chapter of Game Programming Patterns provides a good example.
Subtyping Polymorphism
If for each object
of type there is another object of type such that for all programs defined in terms of , the behavior of is unchanged when is substituted for , then is a subtype of . — Barbara Liskov, “Data Abstraction and Hierarchy”
Subtyping describes an “is a” relation between two types. The Liskov substitution principle defines the property that safe subtyping relationships must uphold.
OOP languages often support subtyping via inheritance, but note that inheritance doesn’t always model subtyping, and it is not the only form of subtyping either. Various interface/trait constructs in non-OOP languages often support subtyping. And besides nominal subtyping, where one explicitly declares the subtyping relationship, there are also structural subtyping, where the subtyping is implicit if one type contains all the features of another type. Good examples of structural subtyping include OCaml (objects and polymorphic variants) and TypeScript interfaces. Subtyping also shows in all kinds of little places, such as Rust lifetime and TypeScript’s coercion from a non-nullable type to its nullable counterpart.
A related concept to subtyping is variance (not related to class-invariant), which bridges parametric polymorphism and subtyping. I won’t bother explaining variance here, as this topic probably needs an entire blog post to explain well. It is a great ergonomic boost (e.g., C++ pointers will be unusable for polymorphic use if it is not covariant), but most languages only implement a limited, hard-coded version, because it is hard to understand and also error-prone. In particular, mutable data types usually should be invariant, and Java/C#‘s covariant arrays are a primary example on this got wrong. There are a few languages that support programmers explicitly control variance, including Scala and Kotlin.
Type conversion via subtyping relationships is often implicit. Implicit conversion has a bad reputation. Though doing it with subtyping is ergonomic, and is probably the least surprising kind of implicit conversion. Another way to view subtyping is as the dual of implicit conversions. We can “fake” a subtyping relation with implicit conversion. For example, C++ templated types are invariant, but std::unique_ptr achieves covariance with an implicit conversion from std::unique_ptr<Derived> to std::unique_ptr<Base>. Does Go Have Subtyping? is a good article to further explore this idea.
One reason that language designers often try to avoid subtyping is the implementation complexity. Integrating bidirectional type inference and subtyping is notoriously difficult. Stephen Dolan’s 2016 thesis Algebraic Subtyping makes good progress addressing this issue.
Message Passing
I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages — Alan Kay
Message passing means using objects that send each other “messages” as a way of execution. It is the centric theme of Alan Kay’s vision of OOP, though the definition can be pretty vague. An important point is that message names are late-bound, and the structures of these messages are not necessarily fixed at compile time.
Many early object-oriented concepts were influenced by distributed and simulation systems, where message passing is natural. However, in the era where most people work on single-threaded code, the message was gradually forgotten in languages such as C++ and Java. The method syntax only has limited benefit compared to the original message-passing idea (Bjarne Stroustrup was definitely aware of the idea from Simula, but there is practical constraint on how to make it fast). There was still some genuine message passing, but only in specific areas such as inter-process communication or highly event-driven systems.
Message passing gains a Renaissance in concurrent programming, ironically through non-OOP languages like Erlang and Golang, with constructs such as actors and channels. This kind of shared-nothing concurrency removed a whole range of data race and race condition bugs. In combination with supervision, actors also provide fault tolerance, so that the failure of one actor will not affect the entire program.
Open Recursion
In general, a rule of thumb is: use classes and objects in situations where open recursion is a big win. — Real World OCaml
Originating in the famous Types and Programming Languages, open recursion is probably the least well-known and understood term in this blog post. Nevertheless, it just describes a familiar property of object-oriented systems: methods for an object can call each other, even if they are defined in different classes in the inheritance hierarchy.
The term is somewhat misleading, as there may not be recursive function calls, but here “recursion” means “mutually recursive.” The word “open” refers to “open to extension,” typically empowered by inheritance.
It’s easiest to see with an example:
void print_name() const {
// Note that we call `name` here, although it is not defined in Animal
std::print("{}\n", name());
virtual std::string name() const = 0;
std::string name() const override {
// note we call print_name here, although it is not defined in Cat
For anyone with some familiarity with OOP, we probably take open recursion for granted, even though we may not be aware of its name. However, not all language constructs have this property. For example, in many languages, functions are not mutually recursive by default:
// This won't compile in C++ because `name` is not defined
void print_name(const Animal& animal) {
std::string name(const Cat& cat) {
Now, in languages with late-bound functions, functions in the same module can always call each other (e.g., Python, JavaScript). There are other languages where functions are mutually recursive by default (e.g., Rust), or have forward declarations (C) or a letrec construct (Scheme and ML family) to make functions mutually recursive. This solves the “recursion” part, but still not the “open” part yet:
std::string name(const Cat& cat);
void print_name(const Animal& animal) {
// This still won't compile because we can't downcast an Animal to a Cat
std::string name(const Cat& cat) {
Let’s fix this problem by using a callback:
std::function<std::string()> get_name;
void print_name() const {
std::print("{}\n", get_name());
.get_name = []() { return "Kitty"; },
Tada, we just reinvented prototype-style dispatch!
Anyway, with my quick example above, I want to show that open recursion is a property that OOP gives for free, but reproducing it in languages without built-in support can be tricky. Open recursion allows interdependent parts of an object to be defined separately, and this property is used in many instances, for example, the entire idea of decorator pattern depends on open recursion.
OOP Best Practices
Perhaps the more common complaints about OOP are not about specific language features, but rather about the programming styles it encourages. Many practices are taught as universal best practices, sometimes with rationales, but their downsides are often omitted. Some examples popped into my mind are
| Practice | Advantages | Disadvantage |
|---|---|---|
| preferring polymorphism over tagged union/if/switch/pattern matching | Open to extension, easier to add new cases. | Performance hit; Related behaviors get scattered in multiple places; harder to see the whole controll flow in one place |
| making all data members private | Protect class invariants | More boilerplates; Often unnecessary to hide data without invariants; Getter/setter pairs work less well compared to direct access in languages without property syntax |
| preferring small “self-managed” objects over central “managers” | Harder to violate invariants, cleaner code organization | Potential bad data locality, missing parallelism opportunities, and duplicated references to common data (“back pointer”) |
| preferring extension rather than modification | Prevent new features from breaking the old one. Prevent API break | No reason to “close” a non-public module where you own its usage. Leads to unnecessary complexity and inheritance chain; poorly designed interfaces are not changed; Can cause abstraction inversion |
| preferring abstraction over concrete implementations | Making more system swappable and testable | Overuse sacrifices readability and debuggability. Performance cost of extra indirection |
This blog post is long enough, so I will not go into more details. Feel free to disagree with my “advantages and disadvantages”. What I want to convey is that almost all those practices come with trade-offs.
In the End
Congratulations on making it to the end of this article! There are other topics I’d love to discuss, such as RAII and design patterns. However, this article is long enough, so I will leave those for you to explore on your own.