I also suspect the problems with OOP are hard to communicate. I for one always had a problem with OOP, but I could never quite point it out. Sure, when faced with an OOP design, I could almost always find simplifications. But maybe I never saw the good designs? Maybe this was OOP done wrong?
I do have reasons to think OOP not the way (no sum types, cumbersome support for behavioural parameters, and above all an unreasonable encouragement of mutability), but then I have to justify why those points are important, and why they even apply to OOP (it's kind of a moving target). Overall, all I'm left with is a sense of uneasiness and distrust towards OOP.
That may very well apply to the GP's comment—but, my observation of the pattern is derived from a mix of mini-essay comments, and articles people are writing on Medium or their blogs or whatever, where the space constraints aren't so tight.
There are a couple things you'll regularly find: laughably bad straw-men (GP is free of these), overly vague statements that only survive scrutiny because of their vagueness (e.g. when the GP says OOP produces "abstractions that do not easily model computation"), and unjustified claims.
The net effect is something that sounds bad, but if looked at closely carries very little force.
I suspect the reasons for it are:
1) Actually evaluating a language paradigm is more difficult than these folks suspect. Their view matches their experience and they assume their experience is more global than it really is. Additionally, we don't have a mature theoretical framework for making the comparisons.
2) People are arguing for personal reasons. They have committed themselves to some paradigm and they want to feel secure in their justification for doing so.
This is really the problem. As much as I have strong opinions and beliefs about how to architect code, every argument I come up with boils down to some flavor of "I like it better this way". Which is true -- I do like it better this way -- but hardly actionable, and it doesn't get at the essence of why I like it better.
The problem with making everything an object -- or more precisely, having lots of mutable objects in an object space with a complex dependency graph -- is that it becomes very hard to model both how the program state changes over time and what causes the program state to change in the first place. I think the prevailing OOP fashion is to cut objects and methods apart into ridiculously small pieces, which takes encapsulation and loose coupling to an extreme. This gives rise to the popular quip, "In an OOP program, everything happens somewhere else." I can't think straight in this kind of setting.
I believe that mutable state should be both minimized and aggregated. As much as is humanly possible, immutable values should be used to mediate interactions between units of code (be those functional or OO units), and mutation should occur at shallow points in the call stack. Objects can work well for encapsulating this mutable state, but within the scope of an object, mutation should be minimized and functional styles preferred.
Using a functional style doesn't mean giving up on loose coupling or implementation hiding. Rust, Haskell, and plenty of other languages support these same concepts in the form of parametric polymorphism, e.g. traits or typeclasses. It does mean giving up on the idea that you can mutate state whenever it's convenient. Instead, you have to return a representation of the action you'd like to take, and let the imperative shell perform that action.
Speaking of imperative shells and functional cores, Gary Bernhardt's talk called "Boundaries" is an excellent overview of this kind of architecture [1]. There was also a thread here on HN about similar principles [2].
Btw, one other idea I've had on the subject is that the problems with mutable state can be mitigated if we were able to more easily see/comprehend the state as it's being modified by a program; without that capability the only recourse we're left with is our imagination, which of course is woefully inadequate for the task. You can see more concretely what I'm talking about in my project here (video): http://symbolflux.com/projects/avd
From what I've seen, structuring a program to not modify state is almost always more difficult than the alternative[0]. There are certain problems where this difficulty is justified (because of, e.g., reliability demands); but I think most problems in programming are not those, and if we could just mitigate the error-proneness of state mutation, that may leave us at a good middle ground.
[0] The exception is when you're in a problem domain that can naturally be dealt with via pure functions, where you're essentially just mapping data in one format to another (i.e. no complex interaction aspects).
> From what I've seen, structuring a program to not modify state is almost always more difficult than the alternative
You're not wrong! I don't think we should get rid of mutable state, but I do think we should be much more cognizant of how we use it. Mutation is one of the most powerful tools in our toolbox.
I've found that keeping a separation between "computing a new value" and "modifying state" has a clarifying effect on code: you can more easily test it, more easily understand how to use it, and also more easily reuse it. My personal experience is that I can more easily reason locally about code in this style -- I don't need to mentally keep track of a huge list of concepts. (I recall another quip, about asking for a monkey and getting the whole jungle.)
There is a large web app at my workplace that is written in this style, and it is one of the most pleasant codebases I've ever been dropped into.
I'll quote from the readme:
> This Domain/Converter framework is a way of being explicit about where the boundaries in your code are for a section using one ‘vocabulary,’ as well as a way of sequestering the translation activities that sit at the interface of two such demarcated regions.
Inside each Domain I imagine something like an algebra... a set of core data structures and operations on them.
But yeah, I have very frequently thought about visualizing its behavior while working on that visualizer :D
Is your research related to programming languages?
Also I'm going to have to think about "computing a new value" vs. "modifying state" —not sure I quite get it...
Yep: I just finished a Master's degree with a focus on programming language semantics and analysis. I'm interested in all kinds of static analyses and type systems -- preferably things we as humans can deduce from the source without having to run a separate analysis tool.
> Also I'm going to have to think about "computing a new value" vs. "modifying state" —not sure I quite get it...
It's kind of a subtle distinction. A value doesn't need to have any particular locus of existence; semantically, we only care about its information content, not where it exists in memory. As a corollary, for anyone to use that value, we have to explicitly pass it onward.
On the other hand, mutation is all about a locus of existence, since ostensibly someone else will be looking at the slot you're mutating, and they don't need to be told that you changed something in order to use the updated value. (Which is the root of the problem, quite frankly!)
There are plenty of applications I would never use OOP for. I use Elixir for my backend work. I tend to use OOP for interactive simulation/game sorts of applications.
OOP is popular in large enterprise systems because it (purports to) promote encapsulation and abstraction, which allows many teams/people to interact. Organizations may like OOP because it helps reinforce their drive for independence and fiefdom (see Conway's law https://en.m.wikipedia.org/wiki/Conway%27s_law). Whether it is the best paradigm, the most practical, 'just good enough', the wrong choice is anecdotal. Also, in experience many companies would rather fail conventionally than succeed unconventionally.
It’s easier to write bad code in some languages than others.
> Languages won't save you from poor design choices.
They do. They save you from entire classes of bugs, and make it less easy to shoot yourself in the foot.
Do we need to have this conversation every time we talk about language design? Do you think the tool you use to achieve a task doesn’t matter?
I think many people do think that, with a twist; specifically, when presented with a higher level language they'd argue "it's just a tool", while a lower level language would be "the wrong tool for the job".
I.e, for a hypothetical Ruby programmer, Haskell = "just a tool", C = "the wrong tool".
From my view, here are the concrete issues I have with some of the OO flavors out there.
1) It encourages shared mutable state.
OO creates a new layer of shared state, the object fields. In its very essence, the idea is to have methods which accesses and modifies the object state through direct access of its fields. Thus each field is mutated by the object methods which directly access them. You need to look at the method code to know what state they read and write too. You can not make the fields immutable, because that renders non read only methods useless. You must thus coordinate access of the fields between the methods using explicit locks. Over time, in practice, it also creates massive classes with too many fields and too many methods that only make use of a subset of them, degenerating into even more of a globally shared state structure.
2) It makes dependency injection trickier and thus discourages its use.
Once again, the idea of methods to have direct access to fields is the cause of this, it means that methods don't have their dependencies injected, instead they go find them themselves, through direct access. Testing becomes hard, configuration is pushed down the stack inside the methods and reuse is made harder.
3) It makes inversion of control trickier and thus discourages its use.
Because code can only be passed around wrapped inside an object, and objects are heavy constructs requiring a lot of verbosity to create: a new file is required, a class must be defined, an instance must be created, etc. It means that in OOP it is rare to see code being injected from caller to callee. Instead, conditionals creep inside the callee, and configuration parameters are passed it.
4) It handles stateless code poorly, and thus discourages its use.
Code that requires no state over time, aka stateless operations must be wrapped inside a class for no good reason. OO provides nothing to such code. OO is designed for statefull code. A class with no fields, it's not useful. Why do I need one if all my code is stateless? So people start using them like namespaces.
5) Inheritance is too easy to mess up.
Inheritance as a mechanism for code reuse appeared to be pretty smart at first. The problem is it turned out its pretty hard to do right. You soon find yourself with hidden state from parents and confusing override hierarchies, which forced us into single inehritance chains, which limited code reuse, etc. Bottom line, it just created ton of hidden coupling.
6) Objects are not extendable from the outside.
You can't add functionality or state to an object from the outside. You need to modify the source code of the class directly to do so, or rely on defining new subclasses through inheritance. This minimizes code reuse, and encourages object code to grow ever so bigger as more and more features are added.
That's all I can think of for now. Now, some flavours of OOP work differently in that some or all of these problems might not exist or have solutions to them. Which is why I agree with you, it's best to know the actual problems so you can spot them. Just saying something is OOP doesn't imply all of them will exist.
A lot of languages allow stateless subroutines to exist on their own and offer a seperate namespace system not conflated with the OO layer. Trait or mixin like systems enable open extension. Inehritance can support multiple parents, or composition is used in its place. Some allow subroutines to also be passed around, not needing to be wrapped in an object. Dependency injection frameworks were added to simplify and encourage its use. Value objects allow a bit more support for immutability. Etc.
FP doesn't suffer from these issues though. That said, it has others in its place, and different FP languages have also found different solution to them. That said, in my experience with many languages that were more OO oriented and ones that were more FP oriented, I found FP overall had less issues and had found cleaner solutions to its limitations.
> OOP is prove a poor model for computation
> OO code is almost always significantly more complex and error prone than an equivalent computation written in a concurrent, functional, or structured paradigm.
These claims may or may not be true, but they aren't very useful at all if you don't provide a justification as well as merely asserting them.
> Recent trends in language design (see Rust, Go, Elixir) also seem to be abandoning OOP in favor of other models
There are a number of ways to account for these trends besides OOP being an intrinsically bad model. The most obvious one is that there are fashions in language design, and right now OOP is not fashionable. We already know that; it's not a strong argument against it. Ironically, many in the OOP opposition use the same argument to justify OOP every getting popular in the first place: "it was just fashionable."
> OOP provides competing ways of abstracting behavior that in Haskell we can model with type parameters and constraints.
> Objects are not truly encapsulated in the way Erlang processes are and a poor fit for SMP.
You have pointed out here that more classical OOP languages do things differently from Haskell and Erlang. This should be expected and is not an argument against those OOP languages. (Yes, you could say, "Erlang is better at concurrency" because of the way in which it's different—but my understanding is it's pretty well accepted that Erlang is sort of freak of nature here, so it's not a good argument against OOP generally.)
> Objects also pathologically hide data in attempt to manage mutability, making it impossible to reason about the memory layout of the program.
They do hide data, but the 'pathologically' is something you've added on your own. There is a design philosophy in which this data hiding plays an important, positive role. When you say, "making it impossible to reason about the memory layout of the program." —this sounds to me like missing the point of that design philosophy: the purpose (and oftentimes tradeoff) of higher-level languages is that you don't need to personally manage these details. I think it's largely an application-dependent thing: you many be writing code that requires that, but not all interesting software hinges on low-level performance tuning.
> OOP is a toolkit for building bad abstractions:
> ... abstractions that do not easily model computation
> ... that hide data
> ... and has tended to create overly complex solutions to problems that are often full of errors that a language focused more on type expressivity could catch at compile time.
Another collection of unjustified assertions, except the 'hide data' part which I accounted for earlier.
So across ~10 negative assertions about OOP you have 3 quasi-justifications: newer languages aren't using OOP as much, hiding data is bad, and Erlang is better for SMP.