25 lutego 2013

wpis match { case BlogEntry("Klasy przypadku aka case classes") => true }

Jaki jest Twój rytuał tworzenia klas? David Pollak w swojej książce Beginning Scala (Apress, 2009) napisał, że jego obejmuje pisanie metod toString, hashCode oraz equals (wszystkie z java.lang.Object). Jako powód powołał się na Joshua Bloch, który w Effective Java (Prentice Hall, 2008) napisał, że jest to dobra praktyka, która umożliwia obiektom uczestniczyć w kontrakcie określonym przez tablice haszujące (ang. hash tables) oraz przy ich wypisywaniu. Słusznie (aczkolwiek moje skromne programowanie nie obejmowało takich szczegółów).

Może Wasze również? Jeśli tak, warto przyjrzeć się mechanizmowi klas przypadków (ang. case classes), dla których kompilator Scali generuje implementacje wspomnianych metod z pudełka oraz, co z pewnością ważniejsze, uczestniczą w mechaniźmie dopasowywania wzorców (ang. pattern matching).

Załóżmy, że mamy taką klasę X w Scali.
case class X(x: Int)
Na pierwszy rzut oka, jakby znajoma konstrukcja (szczególnie dla programistów javowych) do tworzenia definicji klas. Co rzuca się w oczy, to brak nawiasów klamrowych po definicji klasy, jeśli ciało będzie odpowiadało temu, które utworzy kompilator Scali (poniżej wyjaśnienie, czego należy oczekiwać).

Po kompilacji otrzymujemy zestaw użytecznych metod w klasie bez konieczności pisania ich własnoręcznie (!)
$ scalac X.scala

$ ls X*
X$.class X.class X.scala

$ javap -classpath . X
Compiled from "X.scala"
public class X implements scala.Product,scala.Serializable {
  public static <A extends java/lang/Object> scala.Function1<java.lang.Object, A> andThen(scala.Function1<X, A>);
  public static <A extends java/lang/Object> scala.Function1<A, X> compose(scala.Function1<A, java.lang.Object>);
  public int a();
  public X copy(int);
  public int copy$default$1();
  public java.lang.String productPrefix();
  public int productArity();
  public java.lang.Object productElement(int);
  public scala.collection.Iterator<java.lang.Object> productIterator();
  public boolean canEqual(java.lang.Object);
  public int hashCode();
  public java.lang.String toString();
  public boolean equals(java.lang.Object);
  public X(int);
}
Tworzenie obiektów klas przypadków jest możliwe bez słówka kluczowego new oraz domyślnie wszystkie parametry konstruktora stają się atrybutami obiektu jedynie do odczytu. Klasa przypadku jest niezmienna.
scala> case class X(x: Int)
defined class X

scala> val x = new X(5)
x: X = X(5)

scala> x
res0: X = X(5)

scala> val y = X(5)
y: X = X(5)

scala> y
res1: X = X(5)

scala> x == y
res2: Boolean = true

scala> x equals y
res3: Boolean = true

scala> x.equals(y)
res4: Boolean = true
I kontynuując dalej poszukiwania udogodnień związanych z klasami przypadku w Scala REPL:
scala> case class X(x: Int)
defined class X

scala> X.[wciśnij TAB]
andThen apply asInstanceOf compose isInstanceOf toString unapply        

scala> val x = X(5)
x: X = X(5)

scala> x.
asInstanceOf canEqual copy isInstanceOf productArity productElement productIterator productPrefix toString x

scala> x.x
res0: Int = 5

scala> x.toString
res1: String = X(5)

scala> x.hashCode
res2: Int = -1267080172

scala> x.equals(x)
res3: Boolean = true

scala> x.equals(X(5))
res4: Boolean = true
Siłę klas przypadku można dostrzeć przy dopasowywaniu wzorców, z którymi stanowią nierozerwalną parę w Scali.

Martin Odersky, Lex Spoon oraz Bill Venners w swojej książce Programming in Scala, Second Edition (Artima, 2011) napisali, że klasy przypadku są odpowiedzią Scali na umożliwienie dopasowywania wzorców (znanego z języków funkcyjnych) na obiektach bez konieczności pisania nużącego, ale niezbędnego kodu wspierającego. Wystarczy przed nazwą klasy napisać "case" i po krzyku.

Tak na marginesie, jakby się tak zastanowić nad potencjalną genezą nazwy "case classes", to przy znajomości dopasowywania wzorców w Scali - konstrukcja match-case - możnaby wysnuć wniosek, że one są dla siebie stworzone. Nawet nazwy je łączą. O pomyłkę trudno.
scala> X(10) match {
     |   case X(10) => "Obiekt X z 10 wewnątrz"
     |   case _ => "Coś bliżej niezidentyfikowanego"
     | }
res5: String = Obiekt X z 10 wewnątrz

scala> X(3) match {
     |   case X(num) => s"Obiekt X z ${num}"
     |   case _ => "Coś bliżej niezidentyfikowanego"
     | }
res6: String = Obiekt X z 3
I inne temu podobne, które dotyczą dopasowywania wzorców. Ciekawy mechanizm.

Na zakończenie warto wspomnieć o uproszczeniach w tworzeniu nowych obiektów na bazie istniejących z utworzoną metodą copy.
scala> case class Y(a: Int, b: String, c: X)
defined class Y

scala> val y = Y(5, "Five", x)
y: Y = Y(5,Five,X(5))

scala> y.copy(a=500)
res6: Y = Y(500,Five,X(5))

scala> y.copy(a=500, b="Five Hundred")
res7: Y = Y(500,Five Hundred,X(5))

scala> y.copy(c=X(500))
res8: Y = Y(5,Five,X(500))
Niskim kosztem (tworzenia klas przypadków) otrzymujemy stosunkowo bogatą funkcjonalność. To musi się podobać!

Warto przeczytać:

7 komentarzy:

  1. Odpowiedzi
    1. Jaki programista, takie szczegóły :-) A Ty jak piszesz swoje implementacje? Korzystasz z Apache Commons, czy coś innego? Uchyl rąbka tajemnicy, proszę.

      Usuń
    2. Nie jestem, nie byłem i nigdy nie bede programista javy, ale nawet ja wiem ze hashCode i equals to so podstawowe podstawy obiektow w javie, rzeczy ktore po prostu trzeba znac. Mam nadzieje ze jedyne co robisz to wyklikiwanie aplikacji bo bałbym sie jakiegokolwiek Twojego kodu.

      Usuń
    3. Mój kod możesz obejrzeć na https://github.com/jaceklaskowski. Nie ma tam wiele projektów, ale chociaż te istniejące mogą dać Ci pogląd, ile należałoby zmienić. Może zechciałbyś wskazać miejsca do poprawki? Chętnie poprawię.

      A może masz może pomysł na projekt, który zechciałbyś zweryfikować/porównać do swojego (niechby to był inny język programowania) ku poprawieniu mojego/naszego warsztatu programistycznego?! Właśnie rzucam rękawicę. Podniesiesz? ;-)

      Usuń
  2. "domyślnie wszystkie parametry konstruktora stają się atrybutami obiektu jedynie do odczytu"

    Dotyczy to jedynie pierwszej listy parametrów.

    scala> case class X(x:Int)(y:Int)
    defined class X

    scala> val x = X(5)(2)
    x: X = X(5)

    scala> x.x
    res0: Int = 5

    scala> x.y
    :11: error: value y is not a member of X
    x.y
    ^

    OdpowiedzUsuń
    Odpowiedzi
    1. Dzięki Grzegorz! Moja wiedza scalowa na razie nie skaluje się tak efektywnie i niestety mój umysł nie obejmuje tego :( Wiem, że wielokrotne listy parametrów mają pomóc kompilatorowi we wnioskowaniu typów (tyle teoria). Czy w tym przypadku również? Możesz podać przykład, który unaoczniłby mi to? Proszę...

      Usuń
    2. Kilka możliwych zastosowań wielu list parametrów:
      - listę z jednym parametrem można w wywołaniu objąć w nawiasy klamrowe zamiast okrągłych co może dać ładniejszą składnię wywołania
      - jeśli mamy argument funkcyjny, może być wygodnie (z powodu składni znowu) mieć go w osobnej liście parametrów
      - możliwość zastosowania wielu parametrów powtórzonych/domyślnych
      - lista parametrów przekazywanych niejawnie (listy ze słowem kluczowym implicit)
      - o wnioskowaniu typów sam już wspomniałeś
      Akurat powyższy przykład z klasą przypadku jest sztuczny, teoretyczny, mający jedynie coś pokazać.
      Ale jak coś jest możliwe to może i zastosowanie się kiedyś znajdzie.

      Usuń