Wstęp
Jest kilka zasad w programowaniu, których się po prostu "nie rusza". Nie podważa, nie analizuje, nie szuka alternatyw.A jeżeli tylko spróbujesz to zostaniesz wykluczony z kręgu towarzyskiego "programistów, którzy rozumieją jak się robi dobre programy". I teraz spróbuj nie dostać kamieniem gdy wejdziesz z opinią, iż open/close principle (po naszemu "ino otworte na rozszerzonie ać z domykiem na modyfikacjom!") może w niektórych miejscach przynieść więcej szkody niż pożytku.
Co jakiś czas można się nadziać na artykuł o zasadzie otwarte/zamknięte gdzie logika argumentacji trochę przebiega jak w przykładzie "a teraz pokaże wam, iż opony zimowe są lepsze od letnich. Na potrzeby artykułu załóżmy, iż wszędzie leży śnieg". Czyli "o tutaj mam taki problem, który dobrze rozwiązuje dziedziczenie w javie. Dla potrzeb dyskusji przyjmijmy, iż każdy problem tak wygląda". Mam nadzieję, iż w tym artykule uda nam się zobaczyć, iż rzeczywistość jest bardziej "różnorodna".
Ale zaczniemy od tego, iż zwykle przy okazji takich "praw" programowania gdzieś tam w tle pojawia się temat zmiany logiki/wymagań/funkcjonalności. I, iż na zmianę trzeba się przygotować. Przygotowanie się na zmianę w miejscu gdzie ona nigdy nie nadejdzie może nas tak naprawdę sporo kosztować i wtedy zastosowanie "o/c" staje się zwykłym antywzorcem - a powiązany kawałek kodu zwykłą fuszerką. No ale jak to zmiana nie nadejdzie? Przecież w chaotycznie prowadzonym projekcie IT zmienia się wszystko! Nie. choćby w tysiącach maksymalnie totalnie chujowo prowadzonych projektach jest kawałek logiki, który nigdy się nie zmienił i pewnie nie zmieni nigdy - i to od niego zaczniemy dalsze rozważania.
170 lat bez zmiany
(Albo 680 łokresów kwartalnych bez Rekłestu dla czendża w tejże logice)
Jeśli wierzyć internetowi (a dlaczego by nie wierzyć) Algebra Boola was introduced by George Boole in his first book The Mathematical Analysis of Logic (1847). Czyli dawno. Bardzo. I nic się nie zmieniło.
Wyobraźmy sobie teraz na chwilę, iż nie ma algebry Boolea (bóla). Ni ma. Ni ma true. Ni ma false. I w tym świecie ktoś rzuca speckę by taki typ zrobić. Potencjalnie można stworzyć popularną javową konstrukcję z "interfejsem, implementacjami , polimorfizm i w ogóle"
interface Bool{ Bool or(Bool o); Bool and(Bool o); } class True implements Bool{ @Override public Bool or(Bool o) { return new True(); } @Override public Bool and(Bool o) { return o; } } class False implements Bool{ @Override public Bool or(Bool o) { return o; } @Override public Bool and(Bool o) { return new False(); } }
I chociaż technicznie taka implementacja powinna działać to jest ona bez sensu. Z bardzo prostego powodu. Chociaż dodanie nowego typu jest możliwe od strony mechaniki języka to od strony funkcjonalności to nie ma szans zadziałać. No bo jak byśmy dodali typ AlmostFalse implements Bool to teraz jak ma zmienić się zachowanie metod and i or?
Ponieważ typ jako taki
jest częścią API
to dając nowy
czeka nas zawrót głowy
W skrócie nie będzie działać. Dlatego tez if w javie od 20 wygląda tak samo. Jest albo true albo false. Nie ma nic innego i nie ma w planach niczego innego. If - i wszystko co bazuje na boolean jako boolean - jest zamknięte na rozbudowę i zamknięte na rozszerzanie. I całe szczęście. Inaczej mało co by działało i reaktory by wybuchały.
W jednym z popularniejszych przykładów Open/Closed jest chyba ten gdzie dodaje się nowe figury geometryczne i tak mając interfejs "Figura" (Katarzyna) z metodą pole , mamy trójkąt i możemy sobie dodać kółko. Przykład jest wygodny i ma tę adekwatność, iż typy nie oddziałują same ze sobą czyli nie ma na tej płaszczyźnie pomiędzy nimi relacji. Relacji w rozumieniu abstrakcyjnym. I właśnie wokół tego słowa i ogólnie pojętej abstrakcji na chwile przejdziemy do mniej praktycznych rozważań.
Nazwy
Jak tam wyżej wspominaliśmy Boole'a" to tam był jeszcze chyba mianownik "kto?co? -> Algebra" . Algebra jest słowem niezwykle abstrakcyjnym i chyba nie atrakcyjnym dla szerokiej masy ludzi, którzy mówiąc "w życiu trzeba spróbować wszystkiego" mają zwykle na myśli narkotyki i skoki z samolotu a nie zrozumienie Analizy Matematycznej (czy jakoś tak szedł ten dowcip).
Dlatego zamiast o abstrakcyjnych pojęciach matematycznych ruszymy łatwiejszą drogą, wkleję zdjęcie czaszki i porozmawiamy o grze.
Gra ciekawa, wydana ze 20 lat temu o tytule "Planescape torment" - główny bohater nie dość, iż nie żyje to nie ma imienia i to właśnie temat "znaczenia imienia/nazwy" jest tam jednym z głównych wątków. Z tego co pamiętam w pewnym momencie spotykało się typka co to przeklinał dzień kiedy odzyskał imię bo od tego dnia właśnie jego wrogowie mogli rzucać w niego klątwami czy coś w tym stylu.
Podobnie możemy podejść do kwestii nazewnictwa przy rozważaniu praw programowania. Być może lepiej zahaczać o pewne pojęcia bez definiowania konkretnej nazwy. Ominiemy w ten sposób pole minowe nafaszerowane subtelnymi niuansami znaczeniowymi i unikniemy jałowych dyskusji w stylu "czy Try to Monada" i takie tam.
ADT po raz pierwszy
Jak już pojawia się słowo Algebra niedaleko padają od jabłoni Algebraiczne Typy Danych i - czego się spodziewaliśmy - tuzin różnych definicji. Można sobie o tym poczytać na wikipedi https://en.wikipedia.org/wiki/Algebraic_data_type . Sam również kiedyś próbowałem to opisać w sposób prawidłowy i zabawny tutaj -> http://pawelwlodarski.blogspot.com/2016/05/typy-danych-ale-algebraiczne.html. Nie wiem czy udało mi się jedno albo drugie.
Skacząc dalej po linkach interesujące zdanie jest umieszczone na haskellowej wiki : https://wiki.haskell.org/Algebraic_data_type
Czyli rozumiejąc tak jak jest mi na tę chwilę wygodnie będzie to typ, gdzie wszystkie elementy są jasno określone. Czyli dla typu Boolean jasno określone są True i False. Tylko, iż znowu może to znaczy to a może coś innego. Jaki jest rozpierdziel z definicjami doczytamy w dalszych akapitach zacytowanej strony.
ADT po raz drugi (i PDT)
Link do opracowania : https://www.cs.utexas.edu/users/wcook/papers/OOPvsADT/CookOOPvsADT90.pdfWspomniana tutaj praca zestawia ze sobą reprezentacje typów programowania obiektowego zwaną dalej PDT - Procedural Data Types z innym ADT - Abstract Data Types. Przyswajania informacji wcale nie ułatwia fakt, iż cytowana w poprzednim punkcie wiki Haskella twierdzi, iż Abstract Data Types to coś przeciwnego do Algebraic Data Types a autorzy tego opracowania twierdzą, iż jest "complementary" do programowania obiektowego.
Praca ma stron 20 dlatego wybierzemy sobie z niej jeden interesujący aspekt odnośnie dwóch różnych podejść do modelowania danych.
PDA is organized around the constructors of the data abstraction. The observations become the attributes, or methods, of the procedural data values. Thus a procedural data value is simply defined by the combination of all possible observations upon it.
Definicje znowu sa bardzo abstrakcyjne ale na szczęście w artykule mamy przykłady konkretnych deklaracji listy zarówno w zgodzie z PDT jak i ADT. Lista koncepcyjnie jako lista będzie też czymś co może zaskoczyć programistów "tylko javy" bo będzie ona reprezentowana jako dwa pod typy "ELEMENT_LISTY" oraz "KONIEC_LISTY"
PDT
Nil = recursive self = record null? = true head = error; tail = error; cons = fun(y) Cell(y, self); equal = fun(m) m.null? end Cell(x, l) = recursive self = record null? = false head = x; tail = l; cons = fun(y) Cell(y, self); equal = fun(m) (not m.null?) and (x = m.head) and l.equal(m.tail) end
Nie mogłem namierzyć info czy to jakiś konkret język czy taki edukacyjny pseudokod ale nie ma to znaczenia bo PDF i tak go nie skompiluje. Ten kawałek tutaj przypomina podejście "polimorfizm ala Java" ino trzeba tam jakiś interface List dodać. Mamy pod-typy i każdy po swojemu przeciąża metodę. Czyli tak jak napisaliśmy wcześniej : "Thus a procedural data value is simply defined by the combination of all possible observations upon it." A teraz zobaczmy to samo inaczej
ADT
adt IntList representation list = NIL | CELL of integer * list operations nil = NIL adjoin(x : integer, l : list) = CELL(x, l) null?(l : list) = case l of NIL ⇒ true CELL(x, l) ⇒ false head(l : list) = case l of NIL ⇒ error CELL(x, l ) ⇒ x tail(l : list) = case l of NIL ⇒ error CELL(x, l ) ⇒ l equal(l : list, m : list) = case l of NIL ⇒ null?(m) CELL(x, l ) ⇒ not null?(m) and x = head(m) and equal(l , tail(m))
Tym razem bardziej to przypomina prosty zestaw typów z jasno zdefiniowanym zbiorem operacji, który można nań wykonać.Zwróć uwagę, iż każda z operacji jest w pełni świadoma jakiego typu dane może dostać i stosuje nań standardowy pattern matching. Czyli tak jak napisalismy wcześniej : "Each observation is implemented as an operation upon a concrete representation derived from the constructors(...)The representation is shared among the operations, but hidden from clients of the ADT."
Praktycznie
Po tej teoretycznej wycieczce po abstrakcjach i ciekawych artykułach skupimy się na słowie, które już wcześniej padło odnośnie ADT i PDT a daje nadzieje na "win-win" czyli complementary - znaczy, iż uzupełniający się. Będę starał się pokazać, iż to nie jest żadne vs ale, iż nasze standardowe podejście javowe może łatwo współpracować z czymś co łamie dobitnie zasadę open/closed.
Na początek zacznijmy ze standardową sytuacją, gdzie faktycznie chcemy dać sobie furtkę do rozszerzania danej logiki przez dodanie kolejnych implementacji "Biznesowej Walidacji". Będzie to mechanizm w zgodzie z O/C principle.
object BusinessLogic{ trait BusinessData trait BusinessValidationPDT{ def isValid(bd:BusinessData):Boolean } def validateProcess(data:BusinessData,checks:Iterable[BusinessValidationPDT]):Boolean = checks.forall(check => check.isValid(data)) }
Czyli tak jak z tymi figurami w oryginalnym przykładzie tak i tutaj mogę sobie dorzucić kolejną implementację. Mamy w tej sytuacji także druga rodzinę typów totalnie zamkniętych na wszelkie rozszerzania czyli już wcześniej wspomniany boolean. Teraz aby to rozróżnienie bardziej unaocznić zastąpmy boolean własnym typem, który po za tym, iż coś się zepsuło niesie informacje co się zepsuło.
object CheckADT{ sealed trait CheckResult final case object CheckOK extends CheckResult final case class CheckFailure(info:List[Throwable]) extends CheckResult def combine[A](checks:Iterable[CheckResult]):CheckResult = checks.foldLeft[CheckResult](CheckOK){ case (CheckOK,CheckOK) => CheckOK case (CheckOK,f : CheckFailure) => f case (f : CheckFailure, CheckOK) => f case (CheckFailure(info1),CheckFailure(info2)) => CheckFailure(info1 ++ info2) } }
Tym razem przy definicji traitu mamy slówko sealed czyli nie możemy dodawać nowych rozszerzeń poza plikiem gdzie ów trait jest zdefiniowany. Oznacza to w praktyce, iż mamy dwa możliwe podtypy CheckResult i nic więcej. Teraz pytanie czy to ADT i jeżeli ADT to Abstract czy Algebraic? Z punktu praktycznego nie ma to żadnego znaczenia i możemy go również nazwać "Andrzeja Dom Tonie" czy coś takiego. To co nas najbardziej interesuje to, iż mamy jasno zdefiniowaną operacje na jasno zdefiniowanym zestawie typów : combine.
W zasadzie nic nie stoi na przeszkodzie by dodac kolejne operacje tak jak do boolean można dodac xora czy inne takie. Czyli cały czas pod pewnym aspektami jesteśmy przygotowani na rozbudowę ale nie kurwa tak, iż fanatycznie wszystko polimorfizm i abstrakcje i polimorfizm i abstrakcje. Co więcej jest to ten moment kiedy można użyć Property Based Testing. zwykle przy okazji jakiejś prezentacji czy warsztatów ze ScalaCheck pada pytanie "no ale jak mam na przykład taką klasę User to jak do tego użyć PBT?". Źle zadane pytanie. Jak masz ADT i operacje na nich operujące wtedy możesz użyć PDT. (masa masa skrótów trzyliterowych, dobre na prezentacje marketingową, jeszcze tylko jakieś zdjęcia ludzi w garniakach ze stocka).
object prop extends Properties("CheckResult"){ val gen:Gen[List[CheckResult]] = ??? property("combine") =forAll(gen){checks=> val initialErrors=checks.collect{case CheckFailure(infos) => infos.length}.reduce(_+_) val reducedErrors=CheckADT.combine(checks) match { case CheckOK => 0 case CheckFailure(infos) => infos.length } initialErrors == reducedErrors } }
Czyli kolejna zaleta NIEstosowania o/c principle -> otwiera się przed nami potężny paradygmat testowania kodu jako zestawu twierdzeń.
No i na koniec jak to zastosować :
object BusinessLogic2{ trait BusinessData trait BusinessValidationPDT{ def isValid(bd:BusinessData):CheckResult } def validateProcess(data:BusinessData,checks:Iterable[BusinessValidationPDT]):CheckResult = CheckADT.combine(checks.map(_.isValid(data))) }
Na razie chyba najlepsza publikacją omawiająca praktyczne podejście do tego co tutaj opisałem znalazłem w:
I jeszcze link do ciekawej prezentacji, żeby nie było iż nikt inny nie jedzie po SOLID : why-every-element-of-solid-is-wrong
Podsumowanie
Wyobraź sobie, iż to działa tak:
- Najpierw w ogóle nie wiesz, iż jest open/close principle...
- Potem wiesz, iż jest i tego powyżej uważasz za niekompetentnego programistę ale jeszcze nie wiesz, iż jest kolejne stadium kiedy to...
- Rozumiesz, iż open/close nie jest prawem programowania a jedynie zbiorem wygodnych uproszczeń w komunikacji i nauce