wtorek, 26 sierpnia 2014

Expression problem nie tylko dla orzełów

Zagadnienie, które będzie poniżej plastycznie opisane kwiecistym językiem ma się tak do codziennego programowania jak zawody formuły 1 do jazdy osobówką po mieście. Niby nie widzimy na ulicach formułek a jednak stanowią one ciekawy poligon dla rozwiązań technicznych, które po pewnym czasie lądują w normalnych samochodach

Z drugiej jednak strony fakt, że nie mam pojęcia o zawodach formuły 1 może sprawić, że powyższe porównanie jest nieprawdziwe i bez sensu. Tak czy inaczej najtrudniejszą część - czyli wstęp - mam już za sobą i można jechać dalej...

Więcej niż "polimorfizm"

Przez lata moje rozumienie słowa "polimorfizm" sprowadzało się do "jest interfejs i jego kilka implementacji" i było swoistym uwiecznieniem dobrego projektu obiektowego. Pierwsza ciekawa nowinka(dla mnie) - można to samo uzyskać bez żadnego interfejsu zwykłymi generykami - a dwa - nie ma się czym podniecać bo jak wszystko w życiu ma to swoje wady i zalety.

Na wzmiankę o wadach takiego rozwiązania natrafimy w miejscu, którego się nie do końca spodziewamy - czyli w rozdziale zdaje się szóstym "Clean Kodu" o Obiektach i Strukturach danych. Właśnie tam opisany jest ciekawy przypadek kiedy to użycie niesławnego instanceof może nam bardziej pomóc niż zaszkodzić. Gdy mamy sytuację gdzie dochodzą dochodzą nam nowe typy to rozwiązanie z interfejsem ładnie w to wchodzi ale już przy nowych operacjach, na typach jednak wbrew intuicji i powszechnej poprawności projektowej - instance of sprawdza się lepiej.

I to jest moment na wzmiankę o ...

Expression problem

Genezę problemu można sobie znaleźć tutaj -> http://en.wikipedia.org/wiki/Expression_problem lub tutaj : Expression Problem. Co więcej możemy legalnie ściągnąć profesjonalną pracę naukową samego Oderskiego -> PDF Oderskiego

Niestety albo stety prace naukowe mają do siebie, że brak tam rysunków i animacji rodem z "head first" albo też multimedialnych książek kucharskich. Aby więc było łatwiej zamiast używać oryginalnego problemu z rozwijaniem języka wyrażeń naukowych skupimy się na przykładzie z "Clean Code" - kółka i kwadraty (bo "trójkąty i kwadraty" to już chyba będzie plagiat).

  • M1 - tworzymy interfejs kształt i klasę kółko
  • M2 - dodajemy nowy typ kwadrat - tu nie ma schodów
  • M3 - dodajemy nową operację na kółku - tu są schody
  • M4 - a tutaj tę nową operację dodajemy też do kwadratu

M1

//1
object ExpressionsBlog extends M1{
  //2
 val circle:Shape=new Circle(5)            //> circle  : com.wlodar.expressions.poligon.ExpressionsBlog.Shape = com.wlodar.
                                                  //| expressions.poligon.nablog.M1$Circle@72067564
}

package nablog{
 //3
 trait M1{
  
  trait Shape{
   def area:Double
  }
  //4
  class Circle(r:Int) extends Shape{
   def area=Math.PI*r*r
  }
  //5
  def sum(s1:Shape,s2:Shape)=s1.area+s2.area
 }
}

  1. Traity w Scali najłatwiej wytłumaczyć porównując je do interfejsów w Javie ale tutaj widać także ich trochę inną naturę - w tym miejscu trait M1 wzbogaca moduł/obiekt ExpressionsBlog w nowe zachowanie i nowe typy(tutaj Shape i przykładowa implementacja Circle)
  2. Ponieważ kompilator Scali sam zgaduje typ zmiennej, więc aby uogólnić ów typ do Shape musimy jawnie zadeklarować kółko jako właśnie Shape
  3. Mamy dwa traity : M1 i Shape - widzimy (mam nadzieję) także dwoistą naturę tego konceptu - "Interfejs" i "Moduł". Możemy sobie wewnątrz deklarować klasy itd itp
  4. Klasa jak klasa - standardowe rozszerzenie Shape z implementacją metody
  5. A tutaj mamy metodę sum, która będzie służyła do ilustracji kilku ciekawych faktów i eksperymentów okołopolimorfizmowych.

M2

//1
trait M2 extends M1{
  class Square(a:Int) extends Shape{
   def area=a*a
  }
 }

//2
trait M2Rectangle extends M2{
  class Rectangle(a:Int,b:Int) extends Shape {
   def area=a*b
  }
 }
  1. Dodanie nowego typu jest naturalne i nie wymaga żadnego "wzorca" - co podkreślam by czytelnik dopuścił do siebie fakt, iż wzorzec pomimo "efektu aureoli" cudnego rozwiązania może być zwykłym nadmiarowym szumem
  2. No i Traity można rozwijać niezależnie
//1
object ExpressionsBlog extends M2 with M2Rectangle{  
 val circle:Shape=new Circle(5)            //> circle  : com.wlodar.expressions.poligon.ExpressionsBlog.Shape = com.wlodar.
                                                  //| expressions.poligon.nablog.M1$Circle@57a220c2
   //2                      
 val square=new Square(3)                  //> square  : com.wlodar.expressions.poligon.ExpressionsBlog.Square = com.wlodar
                                                  //| .expressions.poligon.nablog.M2$Square@23557525
 //2
 val rectangle=new Rectangle(3,4)          //> rectangle  : com.wlodar.expressions.poligon.ExpressionsBlog.Rectangle = com.
                                                  //| wlodar.expressions.poligon.nablog.M2Rectangle$Rectangle@164af41d
 
}
  1. Przykład dodania dwóch traitów do modułu
  2. Dzięki temu mamy dostęp zarówno do kwadratu jak i prostokąta

M3

//1
trait M3 extends M1{
//2
  trait Shape extends super.Shape{
   def perimeter:Double
  }
//3
  class Circle(r:Int) extends super.Circle(r) with Shape{
   def perimeter=2*Math.PI*r
  }
 }
  1. Przy dodawaniu nowej operacji zaczynają się schody - nie da się wykonać tego tak naturalnie jak w przypadku dodawania nowych typów
  2. Aby zrodzić iluzję dodanej metody przesłaniamy trait Shape z M1
  3. To samo robimy z klasą ale tutaj jeszcze dodatkowo implementujemy przesłonięty Shape
//1
object ExpressionsBlog extends M3 with M2Rectangle{
  
 val circle:Shape=new Circle(5)            //> circle  : com.wlodar.expressions.poligon.ExpressionsBlog.Shape = com.wlodar.
                                                  //| expressions.poligon.nablog.M3$Circle@331f9cee
   //2
   circle.perimeter                               //> res0: Double = 31.41592653589793
   circle.area                                    //> res1: Double = 78.53981633974483
 
}
  1. Cały czas implementujemy dwa niezależne rozszerzenia M1 - za chwilę stanie się z tym coś ciekawego.
  2. Kółko dostało obydwie metody area i perimeter

M4

//1
trait M4 extends M2 with M3{
//2
  class Square(a:Int) extends super.Square(a) with Shape{
   def perimeter=4*a
  }
 }
  1. Scalamy oba kierunki w jeden moduł - tego chyba nie da się zrobić w Javie - nawet ósemce z "defaultowymi metodami" bo jest konflikt nazw
  2. Standardowe Przesłoniećie klasy - klasę bazową bierzemy z jednego modułu a nową operację z drugiego
Niby problem się rozwiązał ale teraz zaczną się dopiero ciekaw rzeczy....

Niebezpieczeństwo

W opracowaniu Oderskiego wykorzystany jest dodatkowo typ abstrakcyjny- na początku zupełnie nie rozumiałem po co skoro wszystko działało i bez tego. No i to był problem bo ... działało za bardzo. Zerknijmy na to co jest w M1

 trait M1{
  //....
  def sum(s1:Shape,s2:Shape)=s1.area+s2.area
 }

I teoretycznie mogę tutaj przekazać coś co implementuje Shape z M1 jak i Shape z M3. Ale co jeśli to jest niedobre, bardzo bardzo złe i nie chcę tego? No to teraz czas włąśnie na...

Typ Abstrakcyjny

Aby pokazać pełną abstrakcję typu abstrakcyjnego nazwiemy go pomidor
trait M1 {
    //...
    //1
    type pomidor <: Shape
    //2
    def sum(s1: pomidor, s2: pomidor) = s1.area + s2.area
  }
  1. Najtrudniejszym elementem w tym miejscu jest chyba ten znaczek : "<:" wzięty prosto z gry PACMAN - a oznacza on zwyczajnie, że pomidor będzie podtypem kształtu.
  2. Nasza metoda nie przyjmuje teraz kształtów ale dwa pomidory. Wydaje się, że za wiele zmian nie zobaczymy w porównaniu do zwykłego dziedziczenia ale teraz tak naprawdę mogę dokładnie sterować czym jest pomidor - i jak będę chciał to nie będzie miał nic wspólnego z Shape z M1
rait M3 extends M1 {
    //1
    trait Shape extends super.Shape {
      def perimeter: Double
    }
    //2
    type pomidor = Shape
    //....
  }
  1. Pamiętamy, że Shape z M1 jest przesłonięty przez nowy Shape
  2. I ustalamy, że pomidor to właśnie nasz nowy Shape czyli to co nie implementuje Shape z M3 nie ma wstępu do metody
Czyli to się nie skompiluje :
object Testy extends M4 with M2Rectangle {
  def test = {
    val circle: Shape = new Circle(5)
    val rec = new Rectangle(3, 4)
//rec i circle to dwa różne szejpy!
    sum(circle, rec)
  }
}

Polimorfizm inaczej

Metoda sum zdefiniowana w M1 dodaje pola dwóch figur. Stwórzmy sobie sztuczny problem i załóżmy, że po dodaniu nowej metody w M3 chcielibyśmy mieć kontrolę nad tym jak to sum jest liczone :

trait M1 {
   //....
    type pomidor <: Shape
//2
    trait Summarizer{
     def add(s1:pomidor,s2:pomidor):Double
    }
    object AreaSummarizer extends Summarizer{
     def add(s1:pomidor,s2:pomidor)=s1.area+s2.area
    }
//1
    def sum(s1: pomidor, s2: pomidor)(s:Summarizer) = s.add(s1,s2)
  }
  1. Definiujemy taką niby strategię - sposób dodawania pomidorów jest zdefiniowany poza metodą sum
  2. Summarizer to zwykły interfejs
I teraz w M3 mogę sobie zdefiniować sumowanie po obwodzie :
 trait M3 extends M1 {
//................................
    type pomidor = Shape   
    object PerimeterSummarizer extends Summarizer{
     def add(s1:pomidor,s2:pomidor)=s1.perimeter+s2.perimeter
    }
  }

sum(circle, sq)(PerimeterSummarizer)
Jest jeden mały problem - API troszeczkę ssie pałkę. Zobaczmy co można z tym zrobić :
//1
def sum(s1: pomidor, s2: pomidor)(implicit s:Summarizer) = s.add(s1,s2)
//2
implicit val summarizer=PerimeterSummarizer
//3
sum(circle, sq)
  1. Dodajemy słówko "implicit" co oznacza, że summarizer będzie wyszukiwany przez kompilator i jak go znajdzie to sam go wstawi jako parametr (a jak nie to będzie błąd). Uwagę czytelnika niech zwróci także druga para nawiasów.
  2. Gdzies tam w module deklarujemy domyślnego summarizera
  3. API jest znowu czyste - jak chcemy to cały czas możemy przekazać kolejny argument, który będzie różny od implicit
I uwaga można z tym iść jeszcze dalej :
trait M1 {
    //....
    type pomidor <: Shape
    
//1
    trait Summarizer[A]{
     def add(s1:A,s2:A):Double
    }
//2
    object AreaSummarizer extends Summarizer[pomidor]{
     def add(s1:pomidor,s2:pomidor)=s1.area+s2.area
    }
//3
    def sum[A](s1: A, s2: A)(implicit s:Summarizer[A]) = s.add(s1,s2)
  }
  1. Summarizer ma generyka A
  2. AreaSummarizer określa generyka A jako pomidor
  3. I sum ma tez generyka A
Ej i teraz obadajcie to :
trait M3 extends M1 {
//....
    type pomidor = Shape
//1
    implicit object PerimeterSummarizer extends Summarizer[pomidor]{
     def add(s1:pomidor,s2:pomidor)={println("pomidor");s1.perimeter+s2.perimeter}
    }
  }


 trait M2Rectangle extends M2 {
//2
  implicit val summarizer=new Summarizer[Rectangle]{
     def add(s1:Rectangle,s2:Rectangle)={println("rectangle");s1.area+s2.area}
    }
  
    class Rectangle(a: Int, b: Int) extends Shape {
      def area = a * b
    }
  }

def test = {
    val circle = new Circle(5)
    val rec = new Rectangle(3, 4)
    val sq = new Square(6)
    
    sum(circle, sq) // pomidor
    sum(rec, rec) // rectangle
  }

Normalnie kompilator sam wstrzykuje to co trzeba na podstawie typu! W "1" mamy obsługę Shape w "2" prostokąta ze starym shape z M1 i wio. Ale jest jeszcze bardziej pojechany mechanizm...

Typy Fantomowe

Najpierw ogólna koncepcja :
//1
package phantoms1{
 sealed trait NotValidated
 sealed trait Validated
 sealed trait Normal
 sealed trait Admin
 
 case class User[A,B]
}
//2
def validate[B](u:User[NotValidated,B])=user.copy[Validated,B]
def secret[B](u:User[Validated,B])=println("sekrety")

//3
val user=new User[NotValidated,Normal]
val validatedUser=validate(user)

//4
secret(validatedUser)                           //> sekrety
//5
secret(user)

  1. Deklarujemy serię traitów, która tak naprawdę nigdzie nie będzie deklarowana - dlatego własnie fantomy
  2. Tutaj jest istota przykładu - mamy taką niby walidację w czasie kompilacji poprzez sprawdzenie czy user ma odpowiednie generyki a może je mieć tylko wtedy jeśli przeszedł metodę "validate"
  3. Na początku user miał generyka NotValidated
  4. Jeśli przeszedł walidację i generyk zmienił się na Validated to wyswietli sekret
  5. A jak nie to wywali sie kompilacja
Jak to wykorzystać w głównym przykładzie?
trait Summarizer[A,B]{
     def add(s1:A,s2:A):Double
    }
    trait ZwyklySummarizer
    implicit object AreaSummarizer extends Summarizer[pomidor,ZwyklySummarizer]{
     def add(s1:pomidor,s2:pomidor)={println("shape z M1");s1.area+s2.area}
    }
    def sum[A](s1: A, s2: A)(implicit s:Summarizer[A,ZwyklySummarizer]) = s.add(s1,s2)

//////////
trait NiezwyklySummarizer
    implicit object PerimeterSummarizer extends Summarizer[pomidor,NiezwyklySummarizer]{
     def add(s1:pomidor,s2:pomidor)={println("pomidor z M3");s1.perimeter+s2.perimeter}
    }
////
implicit val summarizer=new Summarizer[Rectangle,ZwyklySummarizer]{
     def add(s1:Rectangle,s2:Rectangle)={println("rectangle");s1.area+s2.area}
    }

//
   sum(circle, sq) //shape z M1
    sum(rec, rec) //rectangle
No i "NiezwykłySummarizer" z M3 został olany i w jego miejsce wszedł "Zwykly" z M1. Przykład trochę na siłę ale stworzony w celach czysto edukacyjnych.

Podsumowanie

  • W programowaniu czeka na nas wiele ciekawych rzeczy
  • Warto czasem czytać referaty i opracowania naukowe i zwalczyć naturalną niechęć do tego typu prac wykształcony w nas przez system szkolnictwa polskiego.
  • a tu jest cały kod
*   *   *

Brak komentarzy:

Prześlij komentarz