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.

Brak komentarzy:

Prześlij komentarz