Czytając między innymi to forum, spotykam się ze stwierdzeniami dotyczącymi języków funkcyjnych/z dobrymi systemami typów (wybaczcie, nie wiem o czym mówię), że lepiej pozwalają wyrażać ograniczenia, które normalnie są sprawdzanie w runtime, dzięki czemu kompilator pozwala wyłapywać więcej błędów.
Generalnie kompilator jest w stanie sprawdzać ograniczenia w czasie kompilacji dzięki statycznym typom. Im bardziej masz rozbudowany system typów, tym więcej jesteś w stanie wprowadzić tych ograniczeń. Bogatszy system typów pozwala na wprowadzanie bezpiecznych abstrakcji, które nie byłby bezpieczne przy uboższym systemie typów.
Konkretne przykłady:
-
Buildery, które sprawdzają reguły za pomocą systemu typów, a więc końcowa metoda .build() się nie wywala w czasie wykonania:
https://japgolly.blogspot.com/2018/07/enforcing-rules-at-compile-time-example.html
http://jim-mcbeath.blogspot.com/2009/09/type-safe-builder-in-scala-part-4.html
-
Ad-hoc polymorphism za pomocą implicitów/ typeclasses
Jeśli nasza metoda M wymaga od parametru określonych funkcjonalności to mamy następujące rozwiązania:
- zaimplementować odpowiedni interfejs we wszystkich klasach, które wrzucamy do naszej metody M - czyli np chcemy porównywać elementy, więc wymagamy interfejsu Comparable i parametry muszą implementować Comparable - wada jest taka, że musimy mieć możliwość zmiany tych klas oraz możemy zaimplementować nową funkcjonalność na tylko jeden sposób jednocześnie
- zrobić gdzieś statyczną metodę, która sprawdza typ elementów i na podstawie tego porównuje, czyli robimy drabinkę ifów z instanceofami i obsługujemy mały wycinek klas - wada jest taka, że kompilator teraz w ogóle nie sprawdza czy typy da się porównać (dowiadujemy się dopiero gdy program wchodzi w drabinkę ifów), a ponadto statyczna metoda może być tylko jedna, a więc nie można np wybrać sobie komparatora
- dodać tą funkcjonalność do osobnej klasy B i wymagać podawania instancji klasy B razem z instancją klasy A - czyli np dla parametru klasy X podajemy instancję
Comparator<X>
- wadą jest to, że trzeba ten komparator explicite wszędzie przekazywać
- zrobić podobnie jak wyżej, ale uczynić parametr B implicit - Scala umożliwia tworzenie parametrów implicit, które kompilator sam wstawia w odpowiednie miejsce, a więc można stworzyć Comparatora, sprawić by był widoczny lokalnie i nie musieć go wprost podawać
W tym przypadku ten parametr typu B to w rzeczywistości typeclass. Dzięki temu, że implementację typeclassy dla typu A robi się poza typem A można dodawać operacje do typów nad którymi nie mamy kontroli (tak jak przy implementowaniu interfejsu Comparator dla np klasy z której nie da się dziedziczyć). Typeclassy mogą zastąpić klasyczne dziedziczenie i tak np jest w Ruście, Haskellu, itp Scala oferuje zarówno dziedziczenie jak i typeclassy. Ponadto w Scali można mieć wiele implementacji tej samej typeclassy dla jednego typu - można sterować ich widocznością za pomocą importów albo można je podawać explicite.
- Path dependent types + abstract types
Mogę zrobić następującą klasę:
abstract class Klasa {
type T
def producer: T
def consumer(item: T): Unit
}
oraz np 2 implementacje:
class IntKlasa extends Klasa {
override type T = Int
def producer = 5
def consumer(item: Int) = println(8 + item)
}
class StringKlasa extends Klasa {
override type T = String
def producer = "ala"
def consumer(item: String) = println(item + " ma kota")
}
Następnie mogę zrobić kolekcję takich implementacji oraz metodę, która bez rzutowań i z precyzyjnym typowaniem korzysta z producera i consumera:
val klasy = Seq(new IntKlasa, new StringKlasa)
klasy.foreach { klasa =>
var item = klasa.producer // typ: klasa.T
// item = "bob" // nie skompiluje się, bo typ klasa.T nie redukuje się do String
klasa.consumer(item)
// klasa.consumer(8.0) // nie skompiluje się, bo typ klasa.T nie redukuje się do Double
}
Pisałem na szybko, więc pewnie trochę zamotałem, w szczególności jeśli chodzi o typeclasses.