Moje zdjęcie
Software Craftsman's Blog Po Polsku - Marcin Pieciukiewicz
Praktyczne programowanie w Java i Scala

niedziela, 30 czerwca 2013

Scala - Parametry nazwane i parametry domyślne a dziedziczenie

For English version of this article go here.

Podczas lektury Scala in Depth natknąłem się na interesujący problem związany z parametrami nazwanymi i dziedziczeniem klas. Co się stanie jeżeli w klasie potomnej nadpiszemy metodę i, przy okazji, zmienimy nazwy parametrów tej metody? Jak wpłynie to na sposób wywołania tej metody? A co w przypadku parametrów domyślnych, czy zachowają się podobnie do parametrów nazwanych? Okazuje się że nie, z tego powodu dziedziczenie parametrów domyślnych może skutkować powstaniem trudnych do wykrycia błędów w działaniu aplikacji.

Zacznę od podsumowania sposobów w jaki Scala umożliwia nam przekazywanie parametrów nazwanych oraz parametrów domyślnych do wywoływanych funkcji lub metod. Te funkcjonalności zostały wprowadzone od wersji 2.8 Scali.

Parametry pozycyjne (tak jak w Javie).
Przekazywanie parametrów do funkcji zwykle wykonuje się przez podanie właściwych parametrów w kolejności ich występowania w deklaracji funkcji. Przykład:

scala> def process(x1:Int, x2:Int) = x1 * 2 + x2

scala> process(2, 5)
res0: Int = 9

Parametry nazwane.
Jeżeli zależy nam na zmianie kolejności przekazywanych parametrów, lub na zwiększeniu czytelności wywołania funkcji, bądź przekazaniu tylko części parametrów (jeżeli metoda to dopuszcza), możemy wykorzystać parametry nazwane. Przykład:

scala> def process(x1:Int, x2:Int) = x1 * 2 + x2

// przekazanie parametrów bez zmiany kolejności
scala> process(x1 = 2, x2 = 5) 
res1: Int = 9 

// zmiana parametrów ze zmianą kolejności
scala> process(x2 = 5, x1 = 2) 
res2: Int = 9

Możliwe jest też wywoływanie funkcji łącząc parametry pozycyjne i parametry nazwane. Jednak warunkiem takiego postępowania jest to, aby parametry nazwane (o ile zmieniamy kolejność przekazywanych parametrów) były podawane po parametrach pozycyjnych. Przykład:

scala> process(2, x2 = 5) // tylko część parametrów z nazwą
res3: Int = 9 

// pierwszy parametr nazwany, ALE nie zmieniona kolejność parametrów
scala> process(x1 = 2, 5) 
res4: Int = 9 

// pierwszy parametr nazwany i zmiana kolejności parametrów
scala> process(x2 = 5, 2) 
<console>:9: error: positional after named argument.
              process(x2 = 5, 2)
                              ^

Parametry domyślne.
Jeżeli chcemy, aby użytkownik naszej funkcji nie musiał przekazywać wszystkich parametrów, możemy w deklaracji funkcji zdefiniować domyślną wartość jaką przybierze parametr, jeżeli nie zostanie przekazany podczas wywołania funkcji: Przykład:

scala> def sum(x1:Int, x2:Int, x3:Int = 0) = x1 + x2 + x3 
 
scala> sum(1, 2, 3)
res5: Int = 6 
 
scala> sum(1, 2)
res6: Int = 3

Co ciekawe, parametry z wartościami domyślnymi nie muszą występować na końcu listy parametrów. Jednak w tym przypadku jedyną możliwością wywołania funkcji bez podawania ich wartości jest wykorzystanie parametrów nazwanych:

scala> def sum(x1:Int, x2:Int = 0, x3:Int) = x1 + x2 + x3 
 
// tylko dwa parametry zostały przekazane
scala> sum(1, 3) 
<console>:9: error: not enough arguments for method sum: (x1: Int, x2: Int, x3: Int)Int.
Unspecified value parameter x3.
              sum(1, 3) 
 
// zdefiniowane parametry zostały przekazane po nazwie
scala> sum(x1 = 1, x3 = 3) 
res7: Int = 4


Parametry nazwane, a dziedziczenie klas.
Sprawdźmy teraz jak zachowają się parametry nazwane w przypadku dziedziczenia klas i nadpisywania metod. Jako przykład przyjmijmy następujące klasy:

class Calculator {
  def process(x1:Int, x2:Int) = x1 * 2 + x2
}

class BrokenCalculator extends Calculator {
  override def process(a:Int, b:Int) = super.process(a, b) + 1
}

Należy zwrócić uwagę, że nazwy parametrów metody process zostały zmienione z x1 i x2 na a i b.
Następnie stwórzmy ich instancje, ale w przypadku instancji BrokenCalculator przypiszmy je do referencji różnych typów:

val calculator = new Calculator
val brokenCalculator = new BrokenCalculator
val brokenCalculatorAsCalculator:Calculator = new BrokenCalculator

Czy przypisanie BrokenCalculator do referencji typu Calculator będzie miało wpływ na wynik działania metody? Okazuje się, że zależy to od sposobu przekazywania parametrów:

scala> calculator.process(2, 5)
res8: Int = 9
scala> brokenCalculator.process(2, 5)
res9: Int = 10
scala> brokenCalculatorAsCalculator.process(2, 5)
res10: Int = 10

W tym przypadku, gdy parametry przekazaliśmy pozycyjnie wszystkie wywołania zależą po prostu od klasy obiektu, dlatego brokenCalculator i brokenCalculatorAsCalculator zachowały się tak samo.
Co jeżeli zechcemy wykorzystać parametry nazwane?

scala> calculator.process(x2 = 5, x1 = 2)
res11: Int = 9 
 
// próba wywołania parametrów z nazwami z klasy Claculator
scala> brokenCalculator.sum(x2 = 5, x1 = 2)
<console>:11: error: value sum is not a member of BrokenCalculator
              brokenCalculator.sum(x2 = 5, x1 = 2)
                                       ^ 
 
scala> brokenCalculator.process(b = 5, a = 2)
res12: Int = 10 
 
// próba wywołania parametrów z nazwami z klasy BrokenCalculator
scala> brokenCalculatorAsCalculator.sum(b = 5, a = 2)
<console>:11: error: value sum is not a member of Calculator
              brokenCalculatorAsCalculator.sum(b = 5, a = 2)
                                           ^ 
 
scala> brokenCalculatorAsCalculator.process(x2 = 5, x1 = 2)
res13: Int = 10

Jak widać, w tym przypadku istotne staje się to, jakiego typu referencji używamy. W naszym przykładzie obiekty calculator oraz brokenCalculatorAsCalculator jako parametrów wymagały nazw x1x2, przy próbie przekazania nazw a i b wynikiem był błąd kompilacji. Analogicznie w przypadku obiektu brokenCalculator konieczne było przekazanie parametrów jako a i b, a przekazywanie x1 i x2 także spowodowało błąd kompilacji.

Wydaje mi się, że w tej sytuacji należy uświadomić sobie, że kontrakt  metody (czyli sposób w jaki wywołujemy metodę) jest determinowany przez rodzaj referencji którą wykorzystujemy, dzięki temu już kompilator może zweryfikować poprawność jej wywołania.

Parametry domyślne, a dziedziczenie klas.
Zmodyfikujmy teraz nasze klasy, definiując w ich metodach parametry domyślne. W tym przypadku BrokenCalculator zmienia wartość domyślną parametru x2:

class Calculator {
  def process(x1:Int, x2:Int = 0) = x1 * 2 + x2
}

class BrokenCalculator extends Calculator {
  override def process(x1:Int, x2:Int = 1) = super.process(x1, x2)
} 
Sprawdźmy jak będzie zmieniało się zachowanie metod w zależności od typu obiektu i typu referencji, którą wykorzystamy.

scala> calculator.process(2)
res14: Int = 4 // wartość domyślna 0
scala> brokenCalculator.process(2)
res15: Int = 5 // wartość domyślna 1
scala> brokenCalculatorAsCalculator.process(2)
res16: Int = 5 // wartość domyślna 1

W tym przypadku wyniki są zaskakujące (!), okazuje się, że jeżeli klasa potomna zmienia wartość domyślną prarametru to wartości domyślne parametrów nie zależą od typu referencji. Przy wywoływaniu brokenCalculatorAsCalculator do metody przypisano wartość domyślną 1, chociaż kontrakt z klasy Calculator deklaruje 0. Jak widzimy może to powodować problemy, zwłaszcza, że IDE (w moim przypadku IntelliJ IDEA) wskazuje, że wartością domyślną parametru x2 w wywołaniu brokenCalculatorAsCalculator będzie 0!

Sprawdźmy w takim razie jeszcze jeden przypadek. Co jeżeli klasa BrokenCalculator nie będzie definiowała wartości domyślnej dla parametru x2?

class BrokenCalculator extends Calculator {
  override def process(x1:Int, x2:Int) = super.process(x1, x2)
}

Czy możliwe jest w takiej sytuacji metody process przekazując tylko 1 parametr?

scala> calculator.process(2)
res17: Int = 4
scala> brokenCalculator.process(2)
res18: Int = 4
scala> brokenCalculatorAsCalculator.process(2)
res19: Int = 4

Okazuje się że tak! W tej sytuacji BrokenCalculator odziedziczył wartość domyślną nadawaną parametrowi x2, czyli jeżeli klasa potomna nie definiuje wartości domyślnej parametru, to dziedziczy ona wartość domyślną z klasy macierzystej.

Widać w tym przypadku sporą niekonsekwencję, w projektowaniu Scali. Co jeżeli jeden z programistów będzie korzystał z referencji typu Calculator. Weryfikując kontrakt referencji nie jest on w stanie stwierdzić jaką wartość domyślną przyjmie wywoływana metoda! Musi w tym celu sięgnąć do implementacji faktycznego obiektu jaki otrzyma... ale czasami nie jest możliwe ustalenie tego w czasie pisania kodu, prawda?

Chyba najlepszym sposobem zrozumienia jak działa dziedziczenie wartości domyślnych jest założenie, że klasa potomna zawsze dziedziczy definiowane wartości domyślne, ale może je w każdej chwili zmienić. A przy wywołaniu takich metod wartości domyślne są zależne od konkretnej implementacji klasy, a nie od typu referencji.

piątek, 28 czerwca 2013

Sortowanie list w Scala

Przyglądając się zmaganiom Jacka Laskowskiego z taskassin i programowaniem w Scali postanowiłem podsumować różne sposoby sortowania listy obiektów w Scali.

W przypadku Jacka do posortowania mamy listę zadań zdefinowanych następująco (uprościłem klasę na potrzeby tego wpisu):

case class Task(label: String, due: DateTime)
oraz mamy zdefiniowaną listę obiektów:
val tasks = List(Task("t1", DateTime.now),
                 Task("t2", DateTime.now + 2.hours), 
                 Task("t3", DateTime.now - 2.hours))
Wyjaśnienie: DateTime oraz .hours pochodzą z biblioteki nscala-time opakowującej JodaTime pod kątem Scali.

Na pierwszy rzut oka widzimy, że klasa List oferuje nam trzy metody służące do sortowania:
.sorted - do sortowania obiektów ze zdefiniowaną cechą Ordered
.sortBy - do sortowania obiektów po wybranym polu
.sortWith - do sortowania obiektów po podanej funkcji porównującej

Oczywiście, wybór którą z nich wykorzystamy zależy (lub powienien zależeć) od konteksu biznesowego w którym powstaje nasza aplikacja. Przyjżyjmy się poszczególnym sytuacjom, w których chcielibyśmy sortować obiekty klasy Task:



Przypadek 1. Jeżeli sortowanie zadań będzie zawsze odbywać się jedynie/głównie po polu due, to wykorzystajmy obiektowość i rozszerzmy Task o właściwość Ordered[Task]:

case class Task(label: String, due: DateTime) extends Ordered[Task] {
  def compare(other: Task): Int = this.due.compareTo(other.due)
}
dzięki temu sortowanie listy zadań będzie można wykonać następująco:
val sortedTasks = tasks.sorted
Trudno wyobrazić sobie coś prostrzego z punktu widzenia programisty wykorzystującego obiekty klasy Task.



Przypadek 2.1. Inaczej musimy postąpić, jeżeli przyjmiemy, że zadania można sortować na kilka sposobów, np. dodatkowo po nazwie. W takiej sytuacji można przekazać do metody sorted obiekt klasy Ordering[Task] który zdefiniuje nam sposób porządkowania elementów:

 val sortedTasks = tasks.sorted(new Ordering[Task] {
      def compare(a: Task, b: Task): Int = a.due.compareTo(b.due)
    })

można też wykorzystać metodę upraszczającą tworzenie obiektu Ordering:
val sortedTasks = tasks.sorted(Ordering.fromLessThan[Task](_.due < _.due))

Przypadek 2.2. Powyższe rozwiązanie nie wydaje się być bardzo eleganckie, ale można je wykorzystać do stworzenia wygodnego sposobu sortowania. W tym celu w obiekcie towarzyszącym możemy zdefiniować konkretne sposoby porządkowania wykorzystując Ordering[task]:
case class Task(label: String, due: DateTime)

object Task {
  //pełna składnia dofiniowania funkcji anonimowej
  val orderingByDue = Ordering.fromLessThan((a:Task, b:Task) => a.due < b.due)
  //skrócona notacja sposób definiowania funcji anonimowej
  val orderingByLabel = Ordering.fromLessThan[Task](_.label < _.label) 
}
W takiej sytuacji sortowanie wygląda następująco:
val tasksSortedByDue = tasks.sorted(Task.orderingByDue)
val tasksSortedByLabel = tasks.sorted(Task.orderingByLabel)



Przypadek 3. Jeżeli jednak nie chcemy narzucać programiście jakiego sortowania powinien używać można wykorzystać metodę sortBy, której parametr wskazuje, którego pola klasy należy użyć do sortowania (przez przekazanie settera dla danego pola):

val sortedTasks = tasks.sortBy((t:Task) => t.due)
A jeszcze lepiej użyć skróconej i bardziej czytelnej formy:
val sortedTasks = tasks.sortBy(_.due)
Dodatkowo bardzo zaletą sortBy jest możliwość zdefiniowania większej ilości pól po których ma być sortowana kolekcja (np. sortowanie osób najpierw po nazwisku, a jeżeli są takie same to po imeniu). W tym celu należy przekazać tuple zawierające pola które nas interesują:
val sortedTasks = tasks.sortBy((x) => (x.label, x.due))
W tym przypadku zadania zostaną posortowane według nazwy, a jeżeli kilka zadań będzie miało tę samą nazwę, to zostaną one posortowanie po czasie w ramach tej grupy.



Przypadek 4.1. Jeżeli rozwiązanie z punktu 3. nie jest wystarczające (bo. np sortowanie jest bardziej skomplikowane) metoda sortBy, która przyjmuje funkcję według której zostaną posortowane elementy, będzie jak znalazł:

val sortedTasks = tasks.sortWith((a:Task, b:Task) => a.due < b.due)
val sortedTasks = tasks.sortWith(_.due < _.due)
Przypadek 4.2. Należy także dodać, że w tym przypadku możliwe jest (podobnie jak w przypadku 2.) zdefiniowanie funkcji przekazywanych do sortWith jako elementów obiektu towarzyszącego:
object Task {
  val compareByDue = (a:Task, b:Task) => a.due < b.due
}
I wykorzystanie jej w ten sposób:
val sortedTasks = tasks.sortWith(Task.compareByDue)



Algorytm sortowania Przyglądając się sposobom sortowania list w Scali zacząłem zastanawiać się nad algorytmami wykorzystanymi do sortowania.

Sortowanie sekwencji przez wykorzystanie metod sorted, sortBy i sortWith wykorzystuje algorytm sortowania dostarczany przez JDK, a w szczególności metodę java.util.Arrays.sort(...). Scala przepisuje listę (lub dowolną sekwencję) do zwykłej tablicy i to na niej wykonywane jest sortowanie. Następnie na podstawie tej tablicy tworzona jest sekwencja odpowiedniego typu (np. List).

Metoda sortująca dostarczana przez JDK do sortowania tablicy obiektów wykorzystuje zmodyfikowany algorytm mergesort, który gwarantuje stabilność sortowania oraz złożoność obliczeniową O(n) = n log(n). Dodatkowo w przypadku gdy elementy są już częściowo posortowane wydajność tego algorytmu jest dużo większa.

Należy także zaznaczyć, że java.util.Arrays.sort(...) w przypadku tablic prymitywów wykorzystuje zoptymalizowany algorytm quicksort (inaczej niż w przypadku tablic obiektów, ponieważ w przypadku prymitywów nie musi być on stabilny), jednak aby móc wykorzystać tę możliwość z poziomu Scali należy wywołać tę metodę bezpośrednio, gdyż interfejsy Scali zawsze wywołają metodę dla tablicy obiektów.


Podsumowanie. Osobiście skłaniałbym się ku metodom 1. oraz 2.2. ponieważ umożliwiają zdefiniowanie kontekstu biznesowego, którego programista nie będzie musiał odkrywać za każdym razem, gdy będzie chciał posortować kolekcję (oczywiście mowa o bardzie skomplikowanych przypadkach niż ten w taskassin). Dodatkowo wydaje się że definiują właściwości klasy Task, a nie zestaw zachowań (jak to jest w przypadku pozostałych metod), które mogą jej dotyczyć.

Oczywiście zdaję sobie sprawę, że nie są to wszystkie możliwości sortowania list jakie oferuje Scala, ale myślę, że przedstawiłem te najbardziej użyteczne.