28

I recently had a discussion with a friend of mine about OOP in video game development.

I was explaining the architecture of one of my games which, to my friend's surprise, contained many small classes and several abstraction layers. I argued that this was the result of me focusing on giving everything a Single Responsibility and also to loosen the coupling between components.

His concern was that the large number of classes would translate to a maintenance nightmare. My view was that it would have the exact opposite effect. We proceeded to discuss this for what seemed like centuries, eventually agreeing to disagree, saying that perhaps there were cases where SOLID principles and proper OOP didn't actually mix well.

Even the Wikipedia entry on SOLID principles states that they're guidelines that help to writing maintainable code and that they are part of an overall strategy of agile and adaptive programming.

So, my question is:

Are there cases in OOP where some or all of the SOLID principles do not lend themselves to clean code?

I can imagine right away that the Liskov Substitution Principle could possibly conflict with another flavour of safe inheritance. That is to say, if someone devised another useful pattern implemented through inheritance, it is quite possible the LSP might be in direct conflict with it.

Are there others? Perhaps certain types of projects or certain target platforms work better with a less SOLID approach?

Edit:

I'd just like to specify that I'm not asking how to improve my code ;) The only reason I mentioned a project in this question was to give a little context. My question is about OOP and design principles in general.

If you're curious about my project, see this.

Edit 2:

I imagined this question would be answered in one of 3 ways:

  1. Yes, there exist OOP design principles which partially conflict with SOLID
  2. Yes, there exist OOP design principles which completely conflict with SOLID
  3. No, SOLID is the bee's knees and OOP will forever be better with it. But, as with everything, it's not a panacea. Drink responsibly.

Options 1 and 2 would have likely generated long and interesting answers. Option 3, on the other hand, would be a short, uninteresting, but overall reassuring, answer.

We seem to be converging onto option 3.

6
  • How are you defining 'clean code'? Apr 4, 2014 at 18:35
  • Clean code, in the scope of this question, is code structure in a way that meets the goals of OOP and a set of chosen design principles. So, I'm familiar with clean code in terms of the goals set by OOP + SOLID. I'm wondering if there is another set of design principles that work with OOP but are completely or partially incompatible with SOLID.
    – MetaFight
    Apr 4, 2014 at 18:39
  • 2
    I'm much more worried about performance of that game of yours than about anything else. Unless we are talking about text-based game.
    – Euphoric
    Apr 4, 2014 at 20:23
  • 1
    This game is essentially an experiment. I'm trying out different development approaches, and I'm also trying to see if enterprise-y code can work when writing a game. I'm kind of expecting performance to become an issue, but it hasn't yet. If/when it does, then next thing I'll be experimenting with is how to adapt my code into performant code. So much learning!
    – MetaFight
    Apr 4, 2014 at 20:26
  • 1
    At the risk of some downvotes, I would like to go back to the "why" question: OOP in game development. Due to the nature of game development, "traditional" OOP seems to be falling out of vogue in favor of various flavors of entity/component systems, an interesting variant here for example. This rather makes me think that the question shouldn't be "SOLID + OOP" but rather, "OOP + Game Development". When you look at a graph of object interaction for game dev. vs. say N-tier application, the differences may mean a new paradigm is good.
    – J Trana
    Apr 7, 2014 at 4:34

8 Answers 8

22

Are there cases in OOP where some or all of the SOLID principles do not lend themselves to clean code?

In general, no. History has shown that the SOLID principles all largely contribute to increased decoupling, which in turn has been shown to increase flexibility in code and thus your ability to be accommodating of change as well as making the code easier to reason about, test, reuse... in short, make your code cleaner.

Now, there can be cases where the SOLID principles collide with DRY (don't repeat yourself), KISS (keep it simple stupid) or other principles of good OO design. And of course, they can collide with the reality of requirements, the limitations of humans, the limitations of our programming languages, other obstacles.

In short, SOLID principles will always lend themselves to clean code, but in some scenarios they'll lend themselves less than conflicting alternatives. They're always good, but sometimes other things are more good.

6
  • 14
    I like but sometimes other things are more good. It has an "Animal Farm" feel to it. ;) Apr 4, 2014 at 20:20
  • 12
    +1 When I just looked at the SOLID principles, I see 5 "nice to haves". But none of them top DRY, KISS, and "make your intentions clear". Use SOLID, but show some caution and skepticism.
    – user949300
    Apr 4, 2014 at 23:36
  • I agree. I think the correct principle(s) to follow clearly depend on the situation, but aside from hard performance requirements, which always rank above everything else (this matters especially in game development), I tend to think DRY and KISS are usually both more important than SOLID. Of course, the more clean you make the code the better, so if can follow all the principles without conflicts, all the better.
    – Ben Lee
    Apr 8, 2014 at 16:43
  • What about integration segregation vs. the efficient design of aggregates? If the basic "sequence" interface [e.g. IEnumerable] includes methods like Count and asImmutable, and properties like getAbilities [whose return would indicate whether things like Count will be "efficient"], then one could have a static method which takes multiple sequences and aggregates them so they'll behave as a single longer sequence. If such abilities are present in the basic sequence type, even if they only chain to default implementations, then an aggregate will be able to expose those abilities...
    – supercat
    Jul 14, 2014 at 17:06
  • ...and implement them efficiently when used with base classes that can do so. If one aggregates a ten-million item list which knows its count, and a five item list that can only retrieve items sequentially, a request for the 10,000,003rd entry should read the count from the first list, and then read three items from the second. Kitchen-sink interfaces may violate the ISP, but they could greatly improve them performance of some composition/aggregation scenarios.
    – supercat
    Jul 14, 2014 at 17:10
14

I think I have an unusual perspective in that I've worked in both finance and games.

Many of the programmers I met in games were terrible at Software Engineering - but they had no need for practices like SOLID. Traditionally, they put their game in a box - then they're done.

In finance, I've found developers can be really sloppy and undisciplined because they don't need the performance that you do in games.

Both the above statements are of course over-generalizations, but actually, in both cases clean code is critical. For optimization clear and easy to understand code is essential. For maintainability's sake, you want the same thing.

That is not to say that SOLID is not without criticism. They're called principles and yet they're supposed to be followed like guidelines? So when exactly should I follow them? When should I break the rules?

You could probably look at two pieces of code and say which one complies best with SOLID. But in isolation, there is no objective way to transform code to be SOLID. It is definitely to the interpretation of the developer.

It is a non sequitur to say that, "a large number of classes may lead to a maintenance nightmare". However, if SRP is not correctly interpreted you could easily end up with this problem.

I've seen code with many layers of pretty much no responsibility. Classes that do nothing apart from pass state from one class to the other. The classic problem of too many layers of indirection.

SRP if abused can end up with a lack of cohesion in your code. You can tell this when you try to add a feature. If you always have to change several places at the same time then your code lacks cohesion.

Open-Closed is not without its critics. For example, see Jon Skeet's review of the Open Closed principle. I won't reproduce his arguments here.

Your argument about LSP seems rather hypothetical and I don't really understand what you mean by another form of safe inheritance?

ISP seems to somewhat duplicate SRP. Surely they must be interpreted the same as you can never anticipate what all clients of your interface will do. Today's cohesive interface is tomorrow's ISP violator. The only illogical conclusion is to have no more than one member per interface.

DIP can lead to unusable code. I've seen classes with massive numbers of parameters in the name of DIP. These classes would have ordinarily "newed" up their composite parts, but I the name of test-ability we must never use the new keyword again.

SOLID on its own is not enough for you to write clean code. I've seen Robert C. Martin's book, "Clean Code: A Handbook of Agile Software Craftsmanship". The biggest problem with it is that it's easy to read this book and interpret it as rules. If you do that, you're missing the point. It contains a large list of smells, heuristics and principles - many more than just the five from SOLID. All of these 'principles' arent really principles but guidelines. Uncle Bob descibes them himself as "cubby holes" for concepts. They're a balancing act; following any guideline blindly will cause problems.

3
  • 2
    If you have a large number of constructor parameters it suggests that you have violated the SRP. Yet a DI container removes the pain associated with large number of constructor parameters anyway, so I disagree with your statements on DIP.
    – Stephen
    Sep 10, 2015 at 23:29
  • All of these 'principles' arent really principles but guidelines. They're a balancing act; as I said in my post following SRP too the extreme itself can be problematic. Sep 13, 2015 at 20:47
  • Good answer, +1. One thing I would like to add: I read Skeet's review more a critics of the standard textbooks definition of the OCP, not of the core ideas behind the OCP. To my understanding, the protected variation idea is what the OCP should have been from the first place.
    – Doc Brown
    Apr 15, 2018 at 12:23
5

Here's my opinion:

Although SOLID principles aim for a non-redundant and flexible codebase, this might be a trade-off in readability and maintenance if there are too many classes and layers.

It's especially about the number of abstractions. A large number of classes might be ok if they are based on few abstractions. Since we don't know the size and the specifics of your project, it's hard to tell, but it's a little worrysome that you mention several layers in addition to a large number of classes.

I guess SOLID principles do not disagree with OOP, but it's still possible to write non-clean code adhering to them.

2
  • 4
    +1 By definition almost, a highly flexible system must be less maintainable than one which is less flexible. Flexibility comes at a cost due to the added complexity it brings.
    – Andy
    Apr 6, 2014 at 23:51
  • @Andy not necessarily. at my current job, a lot of the worst parts of the code is messy because it's just thrown together by "doing something simple, just hack it together and get it working so it doesn't get so complex". As a result, many parts of the system are 100 % rigid. You can't modify them at all without rewriting large parts of them. It's really hard to maintain. Maintainability comes from high cohesion and low coupling, not from how flexible the system is.
    – sara
    Jun 7, 2016 at 6:31
3

In my early developing days, I began to write a Roguelike in C++. As I wanted to apply the good object-oriented methodology I learned from my education, I immediately saw the game items as being C++ objects. Potion, Swords, Food, Axe, Javelins etc. all derived from a basic core Item code, handling name, icon, weight, that kind of stuff.

Then, I coded bag logic, and faced a problem. I can put Items in the bag, but then, if I pick one, how do I know if it's a Potion or a Sword ? I looked up on the Internet how to downcast items. I hacked the compiler options to enable runtime information so I would know what my abstracted Item true types are, and started switch logic on types to know what operations I would allow (e.g avoid drinking Swords and put on Potions on my feet)

It essentially turned the code in a big ball of half-copypasted mud. As project went on, I started to understand what was the design failure. What happened is that I've tried to use classes to represent values. My class hierarchy wasn't a meaningful abstraction. What i should have done is making Item implement a set of functions, such as Equip (), Apply (), and Throw (), and make each behave based on values. Using enums to represent equip slots. Using enums to represent different weapon kinds. Using more values and less classing, because my sub-classing had no other purpose than to fill these final values.

Since you are facing object multiplication under an abstraction layer, I think my insight can be valuable here. If you seem to have too many object types, it may be that you are confusing what should be types with what should be values.

10
  • 2
    The problem was confusing types with classes of values (note: I'm not using the word class to refer to the OOP/C++ notion of class.) There's more than one way to represent a point in a cartesian plane (rectangular coordinates, polar coordinates) but they're all part of the same type. However mainstream OOP languages don't support classifying values naturally. The closest thing to that is C/C++'s union, but you need to add an extra field just to know what the heck you put in there.
    – Doval
    Apr 4, 2014 at 19:11
  • 4
    Your experience can be used as anti-example. By not using SOLID nor any kind of modeling methodology, you created unmaintainable and unextensible code.
    – Euphoric
    Apr 4, 2014 at 20:22
  • 1
    @Euphoric I tried hard. I had object-oriented methodology. There is a difference between having a methodology and successfully implementing it, though :)
    – Diane M
    Apr 4, 2014 at 21:13
  • 2
    How is this an example of SOLID principles running contrary to clean code in OOP? It seems more like an example of an incorrect design -- this is orthogonal to OOP!
    – Andres F.
    Apr 4, 2014 at 23:21
  • 1
    Your base ITEM class should have had a number of "canXXXX" boolean methods all defaulting to FALSE. You could then set "canThrow()" to return true in your Javelin class, and canEat() to return true for your Postion class. Apr 7, 2014 at 4:07
2

SOLID principles are good, but KISS and YAGNI takes priority in case of conflicts. The purpose of SOLID is managing complexity, but if applying SOLID itself makes the code more complex it defeats the purpose. To take an example, if you have small program with only few classes, applying DI, interface segregation etc. might very well increase the overall complexity of the program.

The open/closed principle is especially controversial. It basically says you should add features to a class by creating a subclass or a separate implementation of the same interface, but leave the original class untouched. If you are maintaining a library or service used by uncoordinated clients, this makes sense, since you don't want to break behavior which exiting client may rely on. However if you are modifying a class which is only used in your own application, it would often be much simpler and cleaner to simply modify the class.

5
  • Your discussion of OCP ignores the possibility of extending behaviour of a class via composition (e.g. using a strategy object to allow an algorithm used by the class to be changed), which is generally held to be a better way of complying with the principal than subclassing.
    – Jules
    Sep 11, 2015 at 0:53
  • 1
    If KISS and YAGNI always took priority you should still be coding in 1's and 0's. Assemblers are just something else that can go wrong. No KISS and YAGNI point out two of the many costs of design work. That cost should always be balanced against the benefit of any design work. SOLID has benefits that in some cases makes up for the costs. There is no simple way to tell when you've crossed the line. Jan 30, 2016 at 20:27
  • @CandiedOrange: I disagree that machine code is simpler than code in a high-level language. Machine code ends up containing a lot of accidental complexity due to the lack of abstraction, so it is definitely not KISS to decide to write a typical application in machine code.
    – JacquesB
    Feb 1, 2018 at 9:36
  • @Jules: Yes composition is preferable, but it still requires you to design the original class in such a way that composition can be used to customize it, hence you have to make assumptions about what aspects need to be customized in the future and what aspects are not customizable. In some cases this is necessary to do (e.g. you are writing a library for distribution) but it has a cost, hence following SOLID is not always optimal.
    – JacquesB
    Feb 1, 2018 at 9:41
  • 1
    @JacquesB - true. And OCP is at its core oppposed to YAGNI; however you go about achieving it, it requires you to design in extension points to enable later improvement. Finding a suitable balance point between the two ideals is ... tricky.
    – Jules
    Feb 1, 2018 at 17:03
2

His concern was that the large number of classes would translate to a maintenance nightmare. My view was that it would have the exact opposite effect.

I am absolutely on the side of your friend, but it could be a matter of our domains and the types of problems and designs we tackle and especially what types of things are likely to require changes in the future. Different problems, different solutions. I don't believe in right or wrong, just programmers trying to find the best way they can to best solve their particular design problems. I work in VFX which is not too unlike game engines.

But the problem to me that I struggled with in what might at least be called somewhat more of a SOLID-conforming architecture (it was COM-based), might crudely be boiled down to "too many classes" or "too many functions" as your friend might describe. I would specifically say, "too many interactions, too many places that could possibly misbehave, too many places that could possibly cause side effects, too many places that might need to change, and too many places that might not do what we think they do."

We had a handful of abstract (and pure) interfaces implemented by a boatload of subtypes, like so (made this diagram in the context of talking about ECS benefits, disregard the bottom-left comment):

enter image description here

Where a motion interface or a scene node interface might be implemented by hundreds of subtypes: lights, cameras, meshes, physics solvers, shaders, textures, bones, primitive shapes, curves, etc. etc. (and there were often multiple types of each). And the ultimate problem was really that those designs weren't so stable. We had changing requirements and sometimes the interfaces themselves had to change, and when you want to change an abstract interface implemented by 200 subtypes, that's an extremely costly change. We started mitigating that by using abstract base classes in between which reduced the costs of such design changes, but they were still expensive.

So alternatively I started exploring the entity-component system architecture used rather commonly in the gaming industry. That changed everything to be like this:

enter image description here

And wow! That was such a difference in terms of maintainability. The dependencies no longer flowed towards abstractions, but towards data (components). And in my case at least, the data was far more stable and easier to get right in terms of design upfront in spite of the changing requirements (though what we can do with the same data is constantly changing with changing requirements).

Also because the entities in an ECS use composition instead of inheritance, they don't actually need to contain functionality. They are just the analogical "container of components". That made it so the analogical 200 subtypes that implemented a motion interface turn into 200 entity instances (not separate types with separate code) which simply store a motion component (which is nothing but data associated with motion). A PointLight is no longer a separate class/subtype. It's not a class at all. It's an instance of an entity which just combines some components (data) related to where it is in space (motion) and the specific properties of point lights. The only functionality associated with them is inside the systems, like the RenderSystem, which looks for light components in the scene to determine how to render the scene.

With changing requirements under the ECS approach, often there was only a need to change one or two systems operating on that data or just introduce a new system on the side, or introduce a new component if new data was needed.

So for my domain at least, and I'm almost certain it is not for everyone, this made things so much easier because the dependencies were flowing towards stability (things that did not need to change often at all). That wasn't the case in the COM architecture when the dependencies were uniformly flowing towards abstractions. In my case it's so much easier to figure out what data is required for motion upfront rather than all the possible things you could do with it, which often changes a bit over the months or years as new requirements come in.

enter image description here

Are there cases in OOP where some or all of the SOLID principles do not lend themselves to clean code?

Well, clean code I can't say since some people equate clean code with SOLID, but definitely there are some cases where separating data from functionality as the ECS does, and redirecting dependencies away from abstractions towards data can definitely make things a lot easier to change, for obvious coupling reasons, if the data is going to be a lot more stable than the abstractions. Of course dependencies to data can make maintaining invariants difficult, but ECS tends to mitigate that to the minimum with the system organization which minimizes the number of systems that access any given type of component.

It's not necessarily that dependencies should flow towards abstractions as DIP would suggest; dependencies should flow towards things that are very unlikely to need future changes. That may or may not be abstractions in all cases (it certainly wasn't in mine).

  1. Yes, there exist OOP design principles which partially conflict with SOLID
  2. Yes, there exist OOP design principles which completely conflict with SOLID.

I'm not sure if ECS is truly a flavor of OOP. Some people define it that way, but I see it as very different inherently with the coupling characteristics and the separation of data (components) from functionality (systems) and the lack of data encapsulation. If it to be considered a form of OOP, I would think it is very much in conflict with SOLID (at least the strictest ideas of SRP, open/closed, liskov substitution, and DIP). But I hope this is a reasonable example of one case and domain where the most fundamental aspects of SOLID, at least as people would generally interpret them in a more recognizable OOP context, might not be so applicable.

Teeny Classes

I was explaining the architecture of one of my games which, to my friend's surprise, contained many small classes and several abstraction layers. I argued that this was the result of me focusing on giving everything a Single Responsibility and also to loosen the coupling between components.

The ECS has challenged and changed my views a lot. Like you, I used to think the very idea of maintainability is having the simplest implementation for things possible, which implies many things, and furthermore, many interdependent things (even if the interdependencies are between abstractions). It makes the most sense if you are zooming in on just one class or function to want to see the most straightforward, simple implementation, and if we don't see one, refactor it and maybe even decompose it further. But it can be easy to miss what's going on with the outside world as a result, because any time you split anything relatively complex into 2 or more things, those 2 or more things must inevitably interact* (see below) with each other in some way, or something outside has to interact with all of them.

These days I find there's a balancing act between the simplicity of something and how many things there are and how much interaction is required. The systems in an ECS tend to be rather hefty with non-trivial implementations to operate on the data, like PhysicsSystem or RenderSystem or GuiLayoutSystem. However, the fact that a complex product needs so few of them tends to make it easy to step back and reason about the overall behavior of the entire codebase. There's something to it there which might suggest that it might not be a bad idea to lean on the side of fewer, bulkier classes (still performing an arguably singular responsibility), if that means fewer classes to maintain and reason about, and fewer interactions throughout the system.

Interactions

I say "interactions" rather than "coupling" (though reducing interactions implies reducing both), since you can use abstractions to decouple two concrete objects, but they still talk to each other. They could still cause side effects in the process of this indirect communication. And often I find my ability to reason about a system's correctness is related to these "interactions" more than "coupling". Minimizing interactions tends to make things a lot easier for me to reason about everything from a bird's eye view. That means things not talking to each other at all, and from that sense, ECS also tends to really minimize "interactions", and not just coupling, to the barest of minimums (at least I haven't found any other popular architectural design which reduces it any further in my domain), if only because it can capture the design of a complex product with so few systems, and because the systems don't directly talk to each other (just the central "ECS database").

That said, this might be at least partially me and my personal weaknesses. I've found the biggest impediment for me to create systems of enormous scale, and still confidently reason about them, navigate through them, and feel like I can make any potential desired changes anywhere in a predictable fashion, is state and resource management along with side effects. It's the biggest obstacle that starts to arise as I go from tens of thousands of LOC to hundreds of thousands of LOC to millions of LOC, even for code that I authored completely on my own. If something is going to slow me down to a crawl above all else, it's this sense that I can no longer understand what's going on in terms of application state, data, side effects. It's not the robotic time that it requires to make a change that slows me down so much as the inability to understand the full impacts of the change if the system grows beyond my mind's ability to reason about it. And reducing the interactions has been, for me, the most effective way to allow the product to grow much larger with much more features without me personally getting overwhelmed by these things, since reducing the interactions to a minimum likewise reduces the number of places that can even possibly change application state and cause side effects substantially.

It can turn something like this (where everything in the diagram has functionality, and obviously a real-world scenario would have many, many times the number of objects, and this is an "interaction" diagram, not a coupling one, as a coupling one would have abstractions in between):

enter image description here

... to this where only the systems have functionality (the blue components are now just data, and now this is a coupling diagram):

enter image description here

And there are thoughts emerging on all of this and maybe a way to frame some of these benefits in a more conforming OOP context which is more compatible with SOLID, but I haven't quite found the designs and words just yet, and I find it difficult since the terminology I was used to throwing around all related directly to OOP. I keep trying to figure it out reading through people's answers on here and also trying my best to formulate my own, but there are some things very interesting about the nature of an ECS that I haven't been able to perfectly place my finger on that might be more broadly applicable even to architectures that don't use it. I also hope this answer doesn't come off as an ECS promotion! I just find it very interesting since designing an ECS has really changed my thoughts drastically, since it started out by challenging lots of things I thought were essential for maintainability.

1
  • I like the distinction between interactions and coupling. Although your first interaction diagram is obfuscated. It's a planar graph, so no need for crossing arrows.
    – D Drmmr
    Apr 17, 2018 at 19:24
1

In my experience:-

Large classes are exponentially more difficult to maintain as they get larger.

Small classes are easier to maintain and the difficulty of maintaining a collection of small classes increases arithmetically to the number of classes.

Sometimes large classes are unavoidable a big problem sometimes needs a big class, but, they are much more difficult to read and understand. For well named smaller classes just the name can be enough for comprehension you do not even need to look at the code unless there is a very specific problem with the class.

0

I Always find it hard to draw the line between the 'S' of solid and the (may be old fashioned) need to let an object have all the knowledge of itself.

15 years ago I worked with Deplhi developers who had their holy grail to have classes which contained everything. So for a mortgage you could add insurances and payments and income and loans and tax info and you could calculate tax-to-pay, tax-to-deduct and it could serialize itself, persist itself to disk etc. etc.

And now I have the same object devided in lots and lots of smaller classes which have the advantage that they can be unit tested much easier and better but don't give a good overview of the entire businessmodel.

And yes I know you don't want to tie your objects to the database but you can inject a repository in the big mortgage class to solve that.

Besides that it isn't always easy to find good names for classes which only do a tiny thing.

So I'm not sure about the 'S' being always a good idea.

Not the answer you're looking for? Browse other questions tagged or ask your own question.