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ć: