-1

I haven't found any clear articles on this, but I was wondering about why polymorphism is the recommended design pattern over exhaustive switch case / pattern matching. I ask this because I've gotten a lot of heat from experienced developers for not using polymorphic classes, and it's been troubling me. I've personally had a terrible time with polymorphism and a wonderful time with switch cases, the reduction in abstractions and indirection makes readability of the code so much easier in my opinion. This is in direct contrast with books like "clean code" which are typically seen as industry standards.

Note: I use TypeScript, so the following examples may not apply in other languages, but I think the principle generally applies as long as you have exhaustive pattern matching / switch cases.

List the options

If you want to know what the possible values of an action, with an enum, switch case, this is trivial. For classes this requires some reflection magic

// definitely two actions here, I could even loop over them programmatically with basic primitives
enum Action {
  A = 'a',
  B = 'b',
}

Following the code

Dependency injection and abstract classes mean that jump to definition will never go where you want

function doLetterThing(myEnum: Action) {
  switch (myEnum) {
    case Action.A:
      return;
    case Action.B;
      return;
    default:
      exhaustiveCheck(myEnum);
  }
}

versus

function doLetterThing(action: BaseAction) {
  action.doAction();
}

If I jump to definition for BaseAction or doAction I will end up on the abstract class, which doesn't help me debug the function or the implementation. If you have a dependency injection pattern with only a single class, this means that you can "guess" by going to the main class / function and looking for how "BaseAction" is instantiated and following that type to the place and scrolling to find the implementation. This seems generally like a bad UX for a developer though.

(small note about whether dependency injection is good, traits seem to do a good enough job in cases where they are necessary (though either done prematurely as a rule rather than as a necessity seems to lead to more difficult to follow code))

Write less code

This depends, but if have to define an extra abstract class for your base type, plus override all the function types, how is that less code than single line switch cases? With good types here if you add an option to the enum, your type checker will flag all the places you need to handle this which will usually involve adding 1 line each for the case and 1+ line for implementation. Compare this with polymorphic classes which you need to define a new class, which needs the new function syntax with the correct params and the opening and closing parens. In most cases, switch cases have less code and less lines.

Colocation

Everything for a type is in one place which is nice, but generally whenever I implement a function like this is I look for a similarly implemented function. With a switch case, it's extremely adjacent, with a derived class I would need to find and locate in another file or directory.

If I implemented a feature change such as trimming spaces off the ends of a string for one type, I would need to open all the class files to make sure if they implement something similar that it is implemented correctly in all of them. And if I forget, I might have different behaviour for different types without knowing. With a switch the co location makes this extremely obvious (though not foolproof)

Conclusion

Am I missing something? It doesn't make sense that we have these clear design principles that I basically can only find affirmative articles about but don't see any clear benefits, and serious downsides compared to some basic pattern matching style development

philosopher
  • 131
  • 1
  • 8
  • Just commenting that currently the answers I've received are "do it our way because it's better" and cited scripture, without addressing the concerns I've raised. I have used the switch case pattern above for a long time, and I've used classes for a long time before that, and I've had a clear benefit from using the switches for all the reasons listed in the 2 answers provided so far – philosopher Sep 04 '20 at 08:24
  • 1
    This question is related to the [expression problem](https://stackoverflow.com/questions/3596366/what-is-the-expression-problem). – jaco0646 Sep 08 '20 at 15:14
  • It's actually orthogonal to the expression problem. The expression problem is more for like libraries where you can implement an interface to use to use the library, e.g. shapes. I think switches are more useful for handling edge cases. e.g. cities in a country, if you add a new city, you want to implement similar logic per city and localising by function makes more sense. There are limited number of cities, and you might want to "list all cities" easily. "do we treat different cities differently?" - how would you answer that with polymorphism? – philosopher Sep 09 '20 at 12:19

3 Answers3

1

Consider the , in particular OCP and DI.

  • To extend a switch case or enum and add new functionality in the future, you must modify the existing code. Modifying legacy code is risky and expensive. Risky because you may inadvertently introduce regression. Expensive because you have to learn (or re-learn) implementation details, and then re-test the legacy code (which presumably was working before you modified it).

  • Dependency on concrete implementations creates tight coupling and inhibits modularity. This makes code rigid and fragile, because a change in one place affects many dependents.

In addition, consider scalability. An abstraction supports any number of implementations, many of which are potentially unknown at the time the abstraction is created. A developer needn't understand or care about additional implementations. How many cases can a developer juggle in one switch, 10? 100?

Note this does not mean polymorphism (or OOP) is suitable for every class or application. For example, there are counterpoints in, Should every class implement an interface? When considering extensibility and scalability, there is an assumption that a code base will grow over time. If you're working with a few thousand lines of code, "enterprise-level" standards are going to feel very heavy. Likewise, coupling a few classes together when you only have a few classes won't be very noticeable.

Benefits of good design are realized years down the road when code is able to evolve in new directions.

jaco0646
  • 15,303
  • 7
  • 59
  • 83
  • Do people actually find this beneficial down the line? Again, having tried a lot of things, I actually haven't seen these magical benefits. To address your points specifically, extending in a well typed language is equally reliable as classes, also I find most of the time is spent modifying behaviour rather than extending anyways. Dependency wise I can see the direction, but the difference between passing an enum or not is negligible A developer can juggle many cases in a switch I don't see the issue (not that I've seen cases go above 10 often except for wide APIs) – philosopher Sep 04 '20 at 08:16
  • Yes: extension is always more reliable than modification, loose coupling is always more maintainable than tight coupling, and there is a (small) limit to what any developer can fit in their brain at a time. If you have not faced these issues, you've been lucky enough to work on relatively small, relatively simple applications. Perhaps the applications have been over engineered, but that is not a fault of the design principles. – jaco0646 Sep 04 '20 at 17:08
  • Also consider that polymorphism is critical to the practice of OOP... but you don't have to do OOP. Developers have begun to realize that OOP is just one tool, to be used where it fits. Perhaps the domain you work in is more suited to Procedural or Functional programming. These paradigms are indifferent to polymorphism. – jaco0646 Sep 04 '20 at 17:13
  • I disagree with the loose coupling statement. Loose coupling means if you change something upstream you don't know what it will affect downstream, which usually makes upstream changes scary. A switch doesn't mean a developer needs to fit more things in their brain, the opposite actually, that if a developer wants to know the implementations, they need to store all the implementations in their brain. I work at a big tech company you've heard of and there's a mix of polymorphism and pattern matching. Design principles are should have caveats if they aren't meant to be applied everywhere – philosopher Sep 05 '20 at 08:37
  • I think here is your wrong assumption; "if a developer wants to know the implementations, they need to store all the implementations in their brain". This is why "interface"s exist. They are the contract so developers never need to know all implementations. It is only required to know an interface and its behaiours. But in your switch statement I always see all implementations for no reason. I don't need to know/see/read all implementations, knowing the interface is enough for me to add a new functionality. – cmlonder Sep 06 '20 at 15:32
  • Yes so you're saying developers should not know the implementations. I've personally needed to know the implementations for debugging and understanding in my own work and reading other's work, but I understand that's not the goal of this design principle – philosopher Sep 08 '20 at 12:45
0

I think you are missing the point. The main purpose of having a clean code is not to make your life easier while implementing the current feature, rather it makes your life easier in future when you are extending or maintaining the code.

In your example, you may feel implementing your two actions using switch case. But what happens if you need to add more actions in future? Using the abstract class, you can easily create a new action type and the caller doesn't need to be modified. But if you keep using switch case it will be lot more messier, especially for complex cases.

Also, following a better design pattern (DI in this case) will make the code easier to test. When you consider only easy cases, you may not find the usefulness of using proper design patterns. But if you think broader aspect, it really pays off.

stinepike
  • 54,068
  • 14
  • 92
  • 112
  • I want to be clear that the diff for adding a new action is ``` + case Action.C; + return; ``` The diff for adding a new class is a new file + new code, with no reference to previous code to compare to. This doesn't make sense to me how this is the preferred, where is the benefit? Testing is extremely easy here, I just need to stick a mock or a spy in (if it has side effects). If it has no side effects, I probably don't need to make any change – philosopher Sep 04 '20 at 07:59
0

"Base class" is against the Clean Code. There should not be a "Base class", not just for bad naming, also for composition over inheritance rule. So from now on, I will assume it is an interface in which other classes implement it, not extend (which is important for my example). First of all, I would like to see your concerns:

Answer for Concerns

This depends, but if have to define an extra abstract class for your base type, plus override all the function types, how is that less code than single line switch cases

I think "write less code" should not be character count. Then Ruby or GoLang or even Python beats the Java, obviously does not it? So I would not count the lines, parenthesis etc. instead code that you should test/maintain.

Everything for a type is in one place which is nice, but generally whenever I implement a function like this is I look for a similarly implemented function.

If "look for a similarly" means, having implementation together makes copy some parts from the similar function then we also have some clue here for refactoring. Having Implementation class differently has its own reason; their implementation is completely different. They may follow some pattern, lets see from Communication perspective; If we have Letter and Phone implementations, we should not need to look their implementation to implement one of them. So your assumption is wrong here, if you look to their code to implement new feature then your interface does not guide you for the new feature. Let's be more specific;

interface Communication {
   sendMessage()
}

Letter implements Communication {

   sendMessage() {
     // get receiver
     // get sender
     // set message
     // send message
  }

}

Now we need Phone, so if we go to Letter implementation to get and idea to how to implement Phone then our interface does not enough for us to guide our implementation. Technically Phone and Letter is different to send a message. Then we need a Design pattern here, maybe Template Pattern? Let's see;

interface Communication {
   default sendMessage() {
     getMessageFactory().sendMessage(getSender(), getReceiver(), getBody())
   }

   getSender()
   getReceiver()
   getBody()
}

Letter implements Communication {

   getSender() { returns sender }
   getReceiver() {returns receiver }
   getBody() {returns body}
   getMessageFactory {returns LetterMessageFactory}
}

Now when we need to implement Phone we don't need to look the details of other implementations. We exactly now what we need to return and also our Communication interface's default method handles how to send the message.

If I implemented a feature change such as trimming spaces off the ends of a string for one type, I would need to open all the class files to make sure if they implement something similar that it is implemented correctly in all of them...

So if there is a "feature change" it should be only its implemented class, not in all classes. You should not change all of the implementations. Or if it is same implementation in all of them, then why each implements it differently? It should be kept as the default method in their interface. Then if feature change required, only default method is changed and you should update your implementation and test in one place.

These are the main points that I wanted to answer your concerns. But I think the main point is you don't get the benefit. I was also struggling before I work on a big project that other teams need to extend my features. I will divide benefits to topics with extreme examples which may be more helpful to understand:

Easy to read

Normally when you see a function, you should not feel to go its implementation to understand what is happening there. It should be self-explanatory. Based on this fact; action.doAction(); -> or lets say communication.sendMessage() if they implement Communicate interface. I don't need to go for its base class, search for implementations etc. for debugging. Even implementing class is "Letter" or "Phone" I know that they send message, I don't need their implementation details. So I don't want to see all implemented classes like in your example "switch Letter; Phone.." etc. In your example doLetterThing responsible for one thing (doAction), since all of them do same thing, then why you are showing your developer all these cases?. They are just making the code harder to read.

Easy to extend

Imagine that you are extending a big project where you don't have an access to their source(I want to give extreme example to show its benefit easier). In the java world, I can say you are implementing SPI (Service Provider Interface). I can show you 2 example for this, https://github.com/apereo/cas and https://github.com/keycloak/keycloak where you can see that interface and implementations are separated and you just implement new behavior when it is required, no need to touch the original source. Why this is important? Imagine the following scenario again;

  • Let's suppose that Keycloak calls communication.sendMessage(). They don't know implementations in build time. If you extend Keycloak in this case, you can have your own class that implements Communication interface, let's say "Computer". Know if you have your SPI in the classpath, Keycloak reads it and calls your computer.sendMessage(). We did not touch the source code but extended the capabilities of Message Handler class. We can't achieve this if we coded against switch cases without touching the source.
cmlonder
  • 2,370
  • 23
  • 35
  • So for clarification, implement abstract base classes (that's fine, same point); write less code - at least its the same for switches then; look for similarity - you're saying that if you have similar implementations, then switches are better, this could be a rule, only use polymorphisms for sufficiently different things; – philosopher Sep 05 '20 at 08:53
  • easier to read - perhaps a matter of taste but you're saying the developer should not be able to read the implementations whether my `sendMessage(communication)` versus your `communication.sendMessage()` these are roughly the same at this abstraction, but the first one I have a switch / pattern match, and the second is the implemented interface. So with your second style, you're telling the developer, "do not read this" where with a switch its, "if you want to read this you can" easy to extend - I fully accept that this is necessary for libraries and frameworks, search for "prematurely" – philosopher Sep 05 '20 at 08:58