My main complaint with OOP

Started by
18 comments, last by Green_Baron 4 years, 6 months ago

The religion
Simplicity is not an art to be frowned upon, for it takes courage to stand with the inexperienced and see the world with their fresh untainted eyes. Bless thee who admits having the sin of personal bias and learns common sense, for thou shall ascend into a multi-paradigm programmer.

The bizarre
We used to have a rule at the company against global functions. Ended up with expressions like "(x.cos()+1).sin()" instead of "sin(cos(x)+1)" because the language's core math library wasn't OOP enough. The developers making advanced algorithms could barely work anymore and it was later replaced with normal math functions again.

The milking cow
Workers who feel that their skills are no longer needed at the company will always find new ways to pretend that they're doing something useful. Mostly to protect the fragile ego, otherwise they would idly browse Facebook in plain sight like the others. OOP was something that allowed solving complex problems that didn't exist to begin with. One can spend years maintaining bloated frameworks or go around changing the style back and forward while causing version conflicts, adding administrative overhead and introducing defects without having to solve any real problems for the customers.

Advertisement
On 9/28/2019 at 9:44 PM, HappyCoder said:

A naive way to implement this with OOP is [snip]

The problem with this example is that it doesn't not really follow the basic tenants of object oriented design, no wonder then that it contains many issues. I think the real criticism here is that it is possible to write "object-oriented" code without following good first-principles and arrive at a mess.

 

On 9/28/2019 at 9:44 PM, HappyCoder said:

Lets try to fix this problem using OOP approved methods. First lets try wrapper classes [snip]

This example has beneficial characteristics that are not wholly present in your other examples. Which I feel almost creates an applies-to-oranges comparison even.

For example this design enables multiple implementations of a 'circle renderer' from FancyCircleRenderer to GruesomeCircleRenderer and everything in-between, all adhering to a common interface.

You have also achieved a partial-application of the render function. You can pass renderers around in the form of the Renderer interface and invoke DrawToCanvas without ever knowing that you'll be drawing a circle. Imagine a function that takes a List<Renderable> and draws all the shapes in batch. Of course there are languages out there that support partial-application more succinctly than this.

 

On 9/28/2019 at 9:44 PM, HappyCoder said:

It has the unfortunate side effect of greatly multiplying the number of classes in the code. Adding new shapes would require having many parallel classes scattered throughout the code base.

I am not sure that the number of classes is a particularly important metric so I don't really see it as a downside per-se.

Whether those classes are scattered throughout the codebase is really up to you. Many languages (e.g. C++) allow multiples classes to exist in a single code file so you could very well implement all of those in one file if you wished and then it would not be any more scattered than your first example.

 

On 9/28/2019 at 9:44 PM, HappyCoder said:

Lets solve this problem without OOP. [snip]

This is completely fine. Although it obviously lacks 'features' that some of your other examples sported but as the author you get to decide whether those features were useful to you in the first place.

It is all too common to want to draw a 'shape' without knowing specifically what shape that is (circle, square, 3d model, ...) and this solution provides no form of abstraction as-is. That would require further code not shown here. So whether this example is a fair comparison really depends on the overall requirements.

 

On 9/28/2019 at 9:44 PM, HappyCoder said:

I personally think that golang has a good approach here. Any thoughts on this?

I guess you are referring to Golang's ability to attach functions to a type after the type has already been defined?

It's a good approach for Golang, but it may not be a good approach for, say, C++ where it would probably complicate the compilation model even more than it already is.

Other languages can also support that through various mechanisms:

Golang's approach is to define functions that 'listen' on an existing struct type, this allows types to satisfy an unlimited number of interfaces 'after the fact' and can subsequently be consumed by functions via a form of static duck-typing.

C#'s approach is called 'extension methods' which simply augment some existing type with extra methods.

Python's approach allows for methods defined at the time of a class definition but they can also be attached to objects after construction. As a dynamically typed language functions rely on duck-typing to consume objects with a compatible interface.

Rust's approach is to explicitly implement traits for an existing type, e.g.


struct Circle {
  center: Vec2,
  radius: f32,
}

impl Renderer for Circle {
  fn DrawToCanvas(&self, c: &Canvas) { }
}

This approach known as a 'typeclasses' and is not unique to Rust (e.g. Haskell and Scala support this too).

All in all, we live in a multi-paradigm world and nobody is really forcing us to use one approach for everything. Most languages let us pick and choose from at least a small set of approaches and one of those usually gets the job done. Programming languages are just tools after all.

8 hours ago, dmatter said:

You can pass renderers around in the form of the Renderer interface and invoke DrawToCanvas without ever knowing that you'll be drawing a circle. Imagine a function that takes a List<Renderable> and draws all the shapes in batch. Of course there are languages out there that support partial-application more succinctly than this.

A renderer instance is a global state. This can lead to flickering colors caused by other people's modules. Turning off one module can break completely unrelated features.

Adding a base class Renderable is easy, but removing it two years later will break things. Then you somehow ended up with seven layers of inheritance and have no idea which function will be called because they all have the same name.

You'll later see that you needed Printable, Listener, Serializable, FactoryConstructable, et cetera and create more helper classes trying to wrestle the growing complexity. Compile time type safety goes out the window when you don't have time to implement virtual methods for all 47 types, so you end up leaving an error message in the base class. Then you realize that you never needed polymorphic storage to begin with and can drastically reduce the number of heap allocations that caused memory leaks and cache misses. A basic API using overloads and templates then brings back type safety and allow customers to call the API from another programming language using simple type prefixes, so don't add inheritance just because you can.

On 9/28/2019 at 10:44 PM, HappyCoder said:

Now imagine we want to add functionality so the circle can

  • Calculate area/circumference of the shape
  • Draw it to a canvas
  • Add the shape to a spacial index
  • Serialize the shape to a file 

In a good OO design, this doesn't happen. The Circle class

  • Must be the representation of a circle in the geometric sense and nothing else. It cannot be allowed to depend on something that isn't geometry, such as a spatial index, a Canvas, or a file. This is called the "single responsibility principle": forcing together multiple responsibilities means creating tensions when anything changes.
  • Should derive from a Shape interface or abstract class, together with other shape types. This makes clear that there are functions that apply to any shape (e.g. computing area, perimeter, bounding shapes), and operations that only make sense for circles (e.g. construct a regular polygon with the same center and the same area and a given number of sides, which can be useful as an approximation)

In practical terms:

  • The renderer should find a way to treat all shapes the same way (e.g. if the Shape interface offers a point containment test and a bounding convex polygon the renderer can test samples inside the region of interest) or segregate different Shape subclasses by their different rendering methods (e.g. scanline rendering of circles, scanline rendering of whole convex polygons and scanline rendering of a triangle decomposition of nonconvex polygons)
  • A spatial index should probably deal with Shapes and their bounding shapes only.
  • Serialization could be allowed in Shape classes because it's so basic and intrusive, but keeping it outside would be cleaner.

Omae Wa Mou Shindeiru

3 minutes ago, LorenzoGatti said:

In a good OO design, this doesn't happen. The Circle class

  • Must be the representation of a circle in the geometric sense and nothing else. It cannot be allowed to depend on something that isn't geometry, such as a spatial index, a Canvas, or a file. This is called the "single responsibility principle": forcing together multiple responsibilities means creating tensions when anything changes.
  • Should derive from a Shape interface or abstract class, together with other shape types. This makes clear that there are functions that apply to any shape (e.g. computing area, perimeter, bounding shapes), and operations that only make sense for circles (e.g. construct a regular polygon with the same center and the same area and a given number of sides, which can be useful as an approximation)

In practical terms:

  • The renderer should find a way to treat all shapes the same way (e.g. if the Shape interface offers a point containment test and a bounding convex polygon the renderer can test samples inside the region of interest) or segregate different Shape subclasses by their different rendering methods (e.g. scanline rendering of circles, scanline rendering of whole convex polygons and scanline rendering of a triangle decomposition of nonconvex polygons)
  • A spatial index should probably deal with Shapes and their bounding shapes only.
  • Serialization could be allowed in Shape classes because it's so basic and intrusive, but keeping it outside would be cleaner.

Separation is always good, but I've never seen a single case where run-time polymorphism offers any advantage for generic geometry, because the types are always stored by value or given directly as arguments for performance reasons.

The problem with OOP is that you can always write bad code in any language, using any paradigm.  Coming up with example of bad code written using a given language or paradigm does not in fact act as an example of why the language or paradigm is bad, it's just supporting evidence of how bad coders can create bad code.

Languages and paradigms are forever being touted as a great way to get less-expensive labour to create product at greater profit (although not always in those words -- but do a close reading on claims of "reduced time to release" and "less error-prone").  It turns out software development is like those squishy things where if you squeeze one part it bulges out in another part.  OOP makes reasoning about many things easier (and reasoning about things is the biggest cost in software development and maintenance) at the cost of more typing (always cheap) and either better planning (large up-front cost but very low cost amortized over the lifetime of the software) or constant refactoring (lower initial cost but larger cost amortized over the lifetime of the software).  If your goal is a write-once-and-throw-it-over-the-wall app, OOP is a poor choice and Agile is your friend.  If you need your stuff to run on a HA server for years processing trillion-dollar financial transactions, double-down on OOP with a lot of design up front.

tl;dr OOP is no more problematic than any other design paradigm, but can be abused and can certainly add to development and maintenance cost when used incorrectly.

Stephen M. Webb
Professional Free Software Developer

In addition (it is a paradigm/philosophy with pros and cons like any other), you can mix more than one together.

You can have code that follows Object Oriented Programming principals, and also follows Data Driven Programming principals, and also follows Event Driven Programming principals, and also follows SOLID principals, and also follows more besides those.

For example, carefully crafted objects with a clearly defined interface and abstractions that allow for extension can fit with OOP principals, the interface can be built around arrays that minimize hardware calls and enable fast bulk processing to follow Data Driven principals.  Those calls can be triggered in response to user events, hardware events, and similar that follows Event Driven models, and so on.  

Very few sets of paradigms, principals, and practices are mutually exclusive.

"Principals" are heads of schools. Here we are talking about "principles".

I don't usually point out spelling mistakes if the message is understandable, but that was too many "principals".

Suggestion to refactor to point out the "has a " relationship between principals and principles. Like


principal.getPrinciples()

Just trying to be funny ... ? ?

This topic is closed to new replies.

Advertisement