niedziela, 26 lutego 2017

Closed/Closed Principle

(...)The short way of saying this is, OpenClosedPrinciple cannot work well without a good crystal ball. It is sort of like saying, "Stocks are easy, all you have to do is pick the right stock and you can be rich." It's a "tease" principle without teeth(...)

Wstęp

Jest kilka zasad w programowaniu, których się po prostu "nie rusza". Nie podważa, nie analizuje, nie szuka alternatyw.A jeśli 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ą, że 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, że opony zimowe są lepsze od letnich. Na potrzeby artykułu załóżmy, że wszędzie leży śnieg". Czyli "o tutaj mam taki problem, który dobrze rozwiązuje dziedziczenie w javie. Dla potrzeb dyskusji przyjmijmy, że każdy problem tak wygląda". Mam nadzieję, że w tym artykule uda nam się zobaczyć, że rzeczywistość jest bardziej "różnorodna".

Ale zaczniemy od tego, że zazwyczaj przy okazji takich "praw" programowania gdzieś tam w tle pojawia się temat zmiany logiki/wymagań/funkcjonalności. I, że 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. Nawet 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ę, że 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ę właściwość, że 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ą zazwyczaj 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ść, że 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 ciekawe zdanie jest umieszczone na haskellowej wiki : https://wiki.haskell.org/Algebraic_data_type

This is a type where we specify the shape of each of the elements(...)

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, że znowu może to znaczy to a może coś innego. Jaki jest rozpierdziel z definicjami doczytamy w dalszych akapitach zacytowanej strony.

Algebraic Data Type is not to be confused with *Abstract* Data Type, which (ironically) is its opposite, in some sense. The initialism "ADT" usually means *Abstract* Data Type, but GADT usually means Generalized *Algebraic* Data Type.
Także tego...

ADT po raz drugi (i PDT)

Link do opracowania : https://www.cs.utexas.edu/users/wcook/papers/OOPvsADT/CookOOPvsADT90.pdf

Wspomniana 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, że cytowana w poprzednim punkcie wiki Haskella twierdzi, że Abstract Data Types to coś przeciwnego do Algebraic Data Types a autorzy tego opracowania twierdzą, że 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.

ADTs are organized around the observations. Each observation is implemented as an operation upon a concrete representation derived from the constructors. The constructors are also implemented as operations that create values in the representation type. The representation is shared among the operations, but hidden from clients of the ADT.

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
    NILerror
    CELL(x, l ) ⇒ x
  tail(l : list) = case l of
    NILerror
    CELL(x, l ) ⇒ l
  equal(l : list, m : list) = case l of
    NILnull?(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ę, że 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, że uzupełniający się. Będę starał się pokazać, że to nie jest żadne vs ale, że 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, że 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, że mamy dwa możliwe podtypy CheckResult i nic więcej. Teraz pytanie czy to ADT i jeśli 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, że 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, że fanatycznie wszystko polimorfizm i abstrakcje i polimorfizm i abstrakcje. Co więcej jest to ten moment kiedy można użyć Property Based Testing. Zazwyczaj 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 że nikt inny nie jedzie po SOLID : why-every-element-of-solid-is-wrong

Podsumowanie

Wyobraź sobie, że to działa tak:

  1. Najpierw w ogóle nie wiesz, że jest open/close principle...
  2. Potem wiesz, że jest i tego powyżej uważasz za niekompetentnego programistę ale jeszcze nie wiesz, że jest kolejne stadium kiedy to...
  3. Rozumiesz, że open/close nie jest prawem programowania a jedynie zbiorem wygodnych uproszczeń w komunikacji i nauce
No i teoretycznie teraz wchodząc na poziom drugi myślisz, ze to ostatni poziom i nie odróżniasz tych co jeszcze nie ogarnęli o/c (czy innej prostej zasady) od tych którzy wiedzą, że to już nie działa. Oczywiście to może iść dalej bo skąd wiadomo czy nie ma czwartego poziomu gdzie jednak w jakimś kontekście to działa, a potem piątego który ponownie całą koncepcje odrzuca... Także słowo na koniec to : taki system informatyczny to inżynieria wielu zmiennych a nie zestaw prostych zasad gdzie każdy problem da się rozwiązań skończonym zestawem annotacji i hajbernejtem. Cały czas szukaj nowych źródeł wiedzy i podważaj to co już wiesz.