[...] were does OOP makes things worse instead of helping? [...] And what about procedural programming? What are its advantages over other paradigms?
I've come to favor writing more and more procedural or featherweight object-oriented code in C over the years, kind of making a full circle back to where I started (working backwards in the eyes of many of my peers, and maybe I'm becoming a dinosaur). I'll try to cover why and the benefits I see to doing that. And the benefits aren't set in stone. They're related to human tendencies I just observed in the past.
Again these are just tendencies I personally observed and there's no rule that says that OOP has to be this way, but my acquired distaste for OOP in certain areas (even though I still use it a lot for higher-level code) came from the tendency of OOP enthusiasts to increase the amount of coupling and to redirect dependencies from simpler data types (possibly primitive types like
int) to more and more complex user-defined types.
Now as an aside, to help understand my subjectivity, it's worth noting that my specialty in the past related to image and mesh processing. I wrote a lot of code related to looping over pixels, vertices, edges, triangles, and polygons along with data structures to process and represent them (with the mesh-related data structures and algorithms typically being the most complex). These are also performance-critical areas as well where my input sizes often spanned in the millions of pixels, millions of vertices, millions of polygons. The users do notice performance differences here very easily with their production models and images/textures being so high resolution, though performance had nothing to do with my full circle back towards C (I only mention it to help understand the lower-level domain of the code I typically wrote, often divorced from high-level user-end concepts and user interfaces and so forth).
My Lovely Procedural Image Library
So I'll go over an incident that happened to me in the past that might help to appreciate my viewpoint, though keep in mind it's not an isolated one. It's not like this one incident made me decide to write more procedural code in C again. But I was working with a team of object-oriented enthusiasts long ago (I also counted myself among them, at least "sorta") and I had a procedural image library I wrote in C which was completely decoupled from the outside world. Most of its functions resembled this form processing 32-bit images in RGBA pixel formats:
void image_filter(unsigned char pixels, int w, int h, int stride);
And of course I have my author's bias, but if you ask me that's perfectly fine and lovely C code: one of the better and longer-lasting things I wrote, well-tested, and something I had been using for years. There's no reason for it to ever be changed except to perhaps provide support for a wider range of pixel formats or improve its performance against newer hardware (using parallel loops and SIMD, which I would have welcomed if anyone wanted to apply such optimizations and test them thoroughly).
It was also completely decoupled from the outside world (unless you count the C standard library which was uber stable and something we would never personally have to maintain). The dependencies looked like this:
With incoming (afferent) dependencies but no outgoing (efferent) ones, and that made it build in a blink of an eye, made it very easy to deploy in any needed project (and I had been using it in many personal ones), and made it very stable. It had no reason to change as a result of something else changing, since it depended on nothing else.
But working with this team of OOP enthusiasts, and this was in the context of trying to move away from a legacy C codebase, we had the peer code reviews and they didn't like it. They thought it was too primitive and C-like. Someone also pointed out that it was duplicating some math functions in our math repository, like lerps and clamps and min/max functions operating on bytes. So one of the things they got me to do was to change the code to use our central math functions, and that wasn't related to OOP so much as DRY, but I hated it. So now I had to do this:
And in my opinion that made my image library so much uglier because our math "repository" (I refuse to call it "library") was not designed by people dedicating their time trying to figure out how to create a nice, minimalist math library with most of its responsibilities anticipated upfront. It was an eclectic mess of functions and classes programmers just added to whenever they needed something with no rhyme and reason to it and it was constantly changing so now my image library takes longer to build and has to be rebuilt all the time now depending on this unstable math repository.
But it didn't stop there. We ended up introducing an abstract
Image interface, so the next iteration was to make the functions operate on these image interfaces instead of raw pixels:
void image_filter(Image& image);
At which point the dependencies were like this:
And that image interface was also unstable and prone to change in ways I thought were really dumb, like people adding virtual methods to gaussian blur an image. Such methods need not belong in the interface when we could be defining and implementing them outside the class without making everything we could ever want to do with images a member function of an image interface*.
- This leads to another point. I don't believe that an object should try to provide every function you need as a direct method of that class if such functions could be implemented outside the class to improve its encapsulation, loosen up the coupling, and keep its interface design more stable. But I tended to be in the minority here, and most people I've worked with believe it is far more desirable to add everything you need as a method to the class so that you can get auto-completion/intellisense and have everything you would ever want right there inside the class.
Later on it got to the point where my library was "refactored" (more like "demolished" in my view) in its procedural form while the functions it provided became members of a concrete class implementing the abstract image interface above.
And what did that gain us? Maybe some syntactical sugar, but in exchange what started out as my original code went from being dependent on nothing to being dependent on multiple, and in my eyes, poorly-designed interfaces and "repositories" of functions which never achieved a point of stability, always receiving changes and updates and additions while the same code had to be rebuilt over and over. And with all the moving parts and ugly interfaces, I missed the way the code originally was in its simple C form, like so:
void image_filter(unsigned char pixels, int w, int h, int stride);
... with no dependencies to the outside world. And it was upon realizing this more and more that I started working my way back towards C, valuing its tendency to produce code that is decoupled and isolated from the outside world, invulnerable to its changes, and generally quite stable as long as there aren't a team of OO enthusiasts trying to OOP-ify it.
So that's why I like plain old procedural programming in C when appropriate. I tend to use it with a "library-building" mindset, and I like the fast build times, the decoupled nature of the code, the dependencies on simpler and more stable data types instead of complex and unstable user-defined types, and maybe even just the aesthetics of it all. I like code that stays stable and doesn't have to change in a technological world of moving parts, even if it's code as low-level and simple as an image library. I like having that little slice of stability against an unstable world, and I tend to find heavily object-oriented architectures and frameworks to often be a lot more unstable due to the increased amount of coupling against delicate design ideas prone to change over the course of time.
So it's admittedly a totally subjective answer, and one filled with a lot of bias and shaped by personal experiences, but I find procedural programming appealing in this way.
In what scenarios does other paradigms fits better than OOP? What are its advantages over OOP and were does OOP makes things worse instead of helping?
It's hard to put my finger on it but typically I find procedural programming well-suited when the focus is more on just "transforming data". The above image library example as it originally started just operated on the idea of an array of pixels as "data" to transform. And I don't think there's so much benefit to a pixel or a collection of them to be modeled as anything more complex than "data", especially in the context of a function which only needs to loop through pixels and transform them.
And if people attempted to abstract the notion of a pixel or a collection of pixels as anything more than data, then the abstraction risks being awkward, unstable, and being rendered obsolete over time even though the underlying data was the same all along and never faced the tendency towards obsolescence. Besides that, in this case if we tried to model an
Image interface to abstract the idea of a collection of pixels, the objects implementing that interface don't necessarily benefit from encapsulation and information hiding of their pixels that much, because their ability to interop and work with other image processing code requires them to expose their raw pixel data. Such objects cannot effectively hide that data and maintain invariants over it in light of functionality that seeks to transform pixels unless it wants to try to provide for every single image-related operation known to mankind or require a dynamic dispatch just to
get_pixel here and
set_pixel there while sacrificing their ability to work directly against existing image processing code.
So in this case the scenario is just operate straight on the pixel "data" with procedural code or operate on pixel/image "objects" with functionality but still ending up being objects that practically have to leak their data to the outside world, with the objects more likely being unstable (prone to require changes), bulky, and harder to design properly. Sometimes the most effective and stable model for a concept is to leave it as just "raw data" instead of user-defined objects and interfaces to interact with in far more complex ways.
Dependencies should flow towards stability (as in, things that are unlikely to need future changes -- not the same definition that Martin uses but just code that has the lowest probability of requiring changes), and sometimes raw data is more stable and no more problematic than abstract interfaces of functionality. In such cases, I find procedural programming focused on transforming data to often yield code that is easier to maintain and generally requires much less maintenance in the first place.
It is perhaps for a similar reason that I've come to like C so much again that I've also come to love entity-component systems. Their dependencies flow uniformly towards "raw data" rather than abstractions, and in my cases I've often found data to be easier to stabilize than abstractions on top of requiring fewer dependencies, less object->object interactions, etc.
It also suits my brain better? I mean, when I look at a large-scale codebase, it's not the complexity of any individual function that overwhelms me. It's the control flow and the number of interactions from one function to the next, abstract or not. You could show me a codebase with a thousand very simple objects and straightforward abstractions, all individually easy to understand, and I'd still be overwhelmed by what's going on between the objects and functions. The sheer number of interactions between everything and the complex graph of dependencies that results would overwhelm the mind.
And procedural code of the sort I'm describing that depends more on just "raw data" minimizes those interactions, keeps the call stack shallower, makes it so you can graph your codebase dependencies and not be overwhelmed by the result. It seems a lot of people focus on clean code at the intra-function or intra-object level, but the ultimate factor that leads to my ability to maintain and comprehend a system is generally more about the inter-function and inter-object level. I find it far more beneficial to minimize the interactions and complex control flows between code than to make any given function or object easier to comprehend, and favoring a more procedural mindset revolving around just loops that transform raw data tends to have that effect.
I also don't believe ECS to be object-oriented at all but far more procedural, since what in an ECS benefits conceptually from being an object? Systems don't benefit since they're generally decoupled and don't talk to each other. Components don't benefit since they model raw data. Entities don't since they just contain components. Even if we use OOP languages and model, say, systems as objects just for some minor conveniences, the entity-component systems as used in a lot of game engines ultimately model a very procedural mindset, so I would use such game engines that benefit from entity-component systems as an example of where procedural programming is more suitable and flexible than OOP.
[...] and functional programming?
This I'm ill-suited to answer since I had my little love affair with LISP long ago in the past but never wrote anything of scale in it. However, the way it encourages immutability and discourages side effects is just something that seems so wonderful, so beautiful, so much easier to maintain if I can just get my head around such heavy recursive logic.
My previous adventures with LISP were always one where I had to take maybe 10 times longer to write the same thing because of how much thought I had to put into expressing it recursively and without any side effects (note that I was far from good or practiced at this) along with functions that input functions that return functions, but then came up with something that didn't flake out where it was easy to reason about the correctness of the code. It's been ages but I remember even trying to make it fall apart exploring possible edge cases and couldn't, and it was also easy to change it without worrying about cascading code breakages.
I've still found that lesson valuable even when writing C code where I now try to write as many functions as I can to avoid side effects. I even have my own little library of immutable C data structures that I use here and there to help cheapen the costs of writing functions to be free of side effects. I do still mutate data inside such functions and my immutable structures revolve around mutable "builders" or "transients" which makes it easier for my brain, but the data is local to the function.
So I actually think functional programming is theoretically ideal in many cases. So many more functions tend to cause side effects in production than required and there are a lot more algorithms we might be able to apply in more situations if we had expressive languages built around lambdas and closures and predicates and things of that sort. The only problem is that my brain has a hard time just writing functional code in the first place even if the result is much easier to maintain, so I take many lessons from functional languages and pragmatically try to apply them in C, C++, and Lua (the three languages I use most nowadays), with the biggest one being to favor writing as many functions as possible to be free of side effects (we don't necessarily need to make every data type immutable to achieve that, however, since we can just copy them by value into a function and return a new result).
So anyway, among my mess of thoughts I got requested to do a summary. I'll try to come up with one.
I find procedural programming useful because:
- It tends to arguably be the most straightforward among the three.
- It discourages complex user-defined types, which I consider both a good and bad thing (good when appropriate, bad when not). That tends to encourage minimal coupling and code that operates on the lowest common denominator of data.
- It makes it easier to come up with code that doesn't feel outdated and awkward 2 years later when it is used in areas where it tends to excel, like just transforming raw data in a function, perhaps if only because it's simpler and more limited in nature. You can't go wrong with things you can't do.
- Sometimes the most stable way to design something is to just leave it as data. Among the countless image libraries ever built by mankind so far, there have only been a handful of pixel formats. If more code was written procedurally against raw pixel data rather than abstract pixel or image interfaces, we would probably end up with a world with much less code that needs to be written, and much less code than needs to be maintained and rewritten.
Functional programming I find useful, although I have the most limited experience here, because:
- I find the most difficult things to reason about in a complex codebase are the side effects that go on between code interactions and complex, graph-like control flows. Complex side effects make it so you can no longer reason about code correctness just in terms of "what" it does. It also makes you have to worry about "when" and "where" everything happens which raises the difficulty to reason about correctness exponentially. Functional programming, at least one that encourages pure functions, minimizes those side effects. When you bring the places causing side effects to an absolute minimum, then even the most complex recursive control flows become much easier to reason about in terms of correctness. A series of functions that cause no side effects can potentially be called in any order at any given time by any given thread.
- There's a lot of rich expressiveness to algorithms we can apply in a functional way, like using a less than comparator to sort a range of elements while the comparator itself might be a function calling a lambda function. Functional programming allows you to get very fancy and recursive and extremely flexible with your control flows which could normally be a nightmare to debug if there were all kinds of side effects along the way, but it avoids that problem by avoiding those side effects in the first place.
I find OOP useful because:
- It does allow creating complex and rich user-defined types. Sometimes raw data is too raw, too primitive, too exposed, to effectively express what we want to do and effectively maintain invariants.
- Some things do map beautifully to the concept of an object and/or an abstract interface.
- Sometimes an abstract interface is more stable than a data representation. An example is that of a forward iterator or enumerable. Countless data structures have been made representing data in a different way, and many of them are worthwhile and excel in their different areas. However, the concept of a forward iterator to iterate through whatever elements they contain, however they represent them, is a far more stable concept. Code written against a forward iterator can iterate through any data structure that provides one. While such concepts could potentially be expressed in a functional way or through coroutines or other means, iterator objects might be the most straightforward way to design it. In such a case, a counting function written against an abstract forward iterator interface implemented by forward iterator objects need not be rewritten for every single data structure ever made, provided they all provide conforming iterator objects.
Continuing from #3, however, it is actually difficult to think of many abstract interfaces, widely-applicable, that have lasted the test of time beyond the standard libraries (they also tend to use other concepts like generics and procedural functions and some lightweight functional programming), and that lack of stability and wide applicability is one of the most difficult things I find about OOP.
I can more easily come up with many more cases where data representations are more stable than the abstractions meant to hide them away. Consider the data representing a rectangle in raw form versus a concrete rectangle object implementing, say, an abstract shape interface. The former comes in very few varieties, with the only noteworthy differences being perhaps whether they use integers or floating-point. The latter comes in endless, all coming and going each year and changing like a fashion trend. Standardizing functionality is often much more difficult than standardizing data representations, and for a lot of object-oriented code to be stable and require the minimum maintenance efforts, it needs to standardize functionality (interface designs) at least within a given team. And especially in loosely-coordinated teams of people with a mixture of skillsets and design sensibilities, this can be damned near impossible to do for all but a handful of things.
For OO enthusiasts that seek to maximize code reuse, a question worth thinking about is whether code becomes more reusable and widely applicable over the long term using, say, an abstract
Image interface your team came up with or from a third party library or the above procedural form I originally had which just operates directly on pixel data. Sometimes the most reusable solutions that will last you for years will just operate straight on data if the data representation is more stable than the abstractions meant to hide them away. In such cases, you may find much more widely-applicable, reusable, stable code by leaving it operating directly on data rather than interacting with such data indirectly through an abstract interface. In some cases the abstract interface may actually be more stable, but it's also worth considering the alternative approach of just straightforward procedural functions operating directly over data, in choosing how you go about designing code.