Jakiś czas temu była dyskusja pod którymś postem na mikro na temat tego, czy control flow ma być explicite, czy też należy zastępować wszelkie conditionale polimorfizmem. Ktoś twierdził, że polimorfizm podnosi jakość kodu, że kod bez polimorfizmu jest write-only, a kod z polimorfizmem może być fajny. Co więcej nawet nie jest to element debaty OOP vs. FP, gdyż polimorfizm miał być także podstawą FP.
Cóż, wygląda na to, że nawet twórcy języków programowania już nie zgadzają się z takim podejściem. Jeśli chodzi o C#: https://github.com/dotnet/csharplang/discussions/3107 W 2020 roku ówczesny członek teamu C# napisał wprost: przechodzą "from internal dispatch to external dispatch", gdzie "internal dispatch to jest po ichniemu polimorfizm, a "external dispatch" to jest switch
.
Moje zdanie pozostaje niezmienne: polimorfizm jest OK, potrzebny, przydatny, pod warunkiem, że używa się go z umiarem. Polimorfizm ma swoje koszta, po prostu zaciemnia kod i sprawia, że control flow staje się trudny do prześledzenia, przez co kod staje się nieczytelny.
Co więcej, nadużywanie polimorfizmu może stwarzać problemy z logiką. Np. pamiętam kiedyś próbowałem zrobić hobbystycznie grę i podszedłem do tego obiektowo, miałem tablicę Efektów (buffy, debuffy i inne takie), na interfejsie IEffect była metoda Apply, no i mieliśmy foreach(var effect in effects) effect.Apply()
. Stwarzało to liczne trudności. Przykładowo: Mamy obliczanie obrażeń. Na obliczanie obrażeń wpływa, czy broniący się blokuje oraz czy atakujący ma buffa pozwalającego częściowo ominąć zbroję przeciwnika. Pokazało się, że Apply'owanie ich obu po kolei nie ma sensu. Nie wchodząc w szczegóły, należało użyć zupełnie innego wzoru arytmetycznego jeśli oba buffy wpływały na wynikowe obrażenia i zupełnie innego wzoru, jeśli tylko jeden z nich wpływał.
Nie działało:
var damage = blabla;
foreach(var effect in Effects)
damage = effect.Apply(damage);
Działało:
var damage = blabla;
if(attacker.ArmorBypass != null && defender.Block == null)
damage = /*wzór*/
else if(attacker.ArmorBypass == null && defender.Block != null)
damage = /*inny wzór*/
else if(attacker.ArmorBypass != null && defender.Block != null)
damage = /*jeszcze inny wzór, który jest RÓŻNY od po prostu applyBlock(applyArmorBypass(damage))*/
Co gorsza, używanie polimimorfizmu ukryło błąd. Gdyby cała logika liczenia obrażeń od początku była w funkcji liczącej obrażenia, a nie rozkichana po klasach implementujących buffy i debuffy, to dużo trudniej byłoby nie zauważyć problemu, że się wzory matematyczne nie komponują.
Ktoś powie, "a co jeśli chcesz dodać kolejnego buffa, bez polimorfizmu musisz zmieniać wszystkie metody, które liczą cokolwiek, na co ten buff ma mieć wpływ". Bardzo dobrze, tak właśnie ma być - bo wtedy i tylko wtedy od razu widać, czy nowy buff nie psuje przypadkiem już istniejących buffów / debuffów, których funkcjonalność się zazębia.
Oczywiście nie wszystko także jest sens robić switchami i ifami. Polimorfizm była potrzebny. Ale polimorfizmu używać należy wtedy, jeśli się musi, a domyślnie pisać jawną logikę, nie na odwrót.
Co do języków funkcyjnych, nie wiem. Haskell oczywiście ma polimorfizm, są typeclassy. Rust jest już może mniej funkcyjny, ale bardziej funkcyjny jednak niż C#, Rust ma traity. Jednak zarówno Haskell, jak i Rust mają sumy rozłączne (algebraiczne typy danych). WYDAJE MI SIĘ, z naciskiem na "wydaje mi się", że w Ruście czy Haskellu polimorfizmu za pomocą typeclass czy traitów używa się tylko od czasu do czasu, natomiast absolutnie nikt nie uważa za zdrożne pisania wyrażeń switchopodobnych operujących na tych algebraicznych typach danych.
Przykładowo, gdybyśmy w Haskellu mieli kod tego rodzaju:
data Expression = Addition Expression Expression | Substraction Expression Expression | Value Int
compute :: Expression -> Int
compute (Addition e1 e2) = (compute e1) + (compute e2)
compute (Substraction e1 e2) = (compute e1) - (compute e2)
compute (Value val) = val
To chyba nikt nie uważałby takiego kodu za zdrożny i nie kazał zamienić tego na jakieś typeclassy, nawet jeśli można?
I analogiczne w Ruście (W Ruście to nawet mielibyśmy tutaj match
, który działa podobnie do C#-powego switch
) - też chyba każdy uważałby taki kod za OK i nie kazałby tego przepisać na polimorfizm z użyciem traitów.
Ale tutaj bardziej się pytam, niż twierdzę - nie znam prawie w ogóle 'dobrych praktyk' w tamtych językach, więc w sumie nie wiem, czy jest tam presja, zeby pchać wszędzie polimorfizm, czy też kod taki jak wyżej byłby OK?