„Mały” polimorfizm ad hoc z rozszerzeniami w Kotlinie
Grozą powiało z tytułu. Na początek dwa słowa wyjaśnienia. Polimorfizm ad hoc jest forma polimorfizmu, która pozwala na wywołanie metody bez dokładnej wiedzy, jakiego typu jest obiekt, na którym metodę wywołujemy. W uproszczeniu można powiedzieć, że znany ze scali mechanizm implicit conversion, jest tu dobrym przykładem. Jego działanie można opisać w następujący sposób:
Listing 1. Przykład działania implicit conversion
class Person (val name:String){}
class Node(val value:String) {}
def printNode(n:Node) = {
n.value
}
implicit def toNode(p:Person) = new Node(p.name)
printNode(new Person("Staś"))
- Wywołaj metodę printNode, ale
- Jako parametr podano jednak obiekt typu Person, ale
- Istnieje funkcja toNode, która zamieni Person na Node.
I dlatego kompilacja projektu w Scali jest tak potwornie długa. Kompilator musi poszukać odpowiednich konwersji. Jednocześnie metoda printNode jest polimorficzna, bo jako argument możemy przekazać jej obiekt dowolnego typu pod warunkiem, że gdzieś istnieje odpowiednia konwersja. Zostanie ona wykonana za nas. Dlatego też nazywa się to polimorfizmem ad hoc. Typ Person nie wie, że jest pożeniony z typem Node.
Po co mi to?
Kotlin nie ma mechanizmu niejawnych konwersji. Szkoda, bo jest to bardzo przydatna rzecz. Co prawda może spowodować ciekawe fakapy w kodzie, a i masakrycznie spowalnia, ale zawsze. Zamiast tego posiada mechanizm nazwany extension. Działa on inaczej, ale na podstawowym poziomie ma podobne możliwości i daje podobne efekty. Zanim się mu dokładnie przyjrzymy popatrzmy, do czego możemy go wykorzystać.
Załóżmy, że mamy prostą aplikację, w której mamy dwie data class (DC):
Listing 2. Przykładowa aplikacja
data class Person(val name: String, val boss: Person?)
data class Workflow(val name: String, val owner: Person)
fun main(args: Array<String>) {
val ania = Person("Ania", null);
val ela = Person("Ela", ania);
val kawusia = Workflow("kawusia", ela);
}
Teraz chcemy wypisać nasz Workflow w postaci JSONa. Metod jest kilka. Jednak te najbardziej oczywiste w świecie Javowym łączą się z koniecznością modyfikacji kodu naszych DC. Względnie możemy pokombinować z interfejsem i domyślnymi metodami. Jak by nie kombinował zawsze będzie źle. Dodatkowym minusem jest „zmuszanie” DC do posiadania wiedzy o swojej reprezentacji. Pisząc Lottomat mieliśmy do czynienia z tym problemem.
Dodawanie metod do klas
Uwaga: jeżeli mówię o metodach w klasie, to oczywiście są to funkcje. Jednak jak pisałem wczoraj – funkcja w klasie, powinna być traktowana jak metoda.
Extensions to nic innego niż mechanizm pozwalający na dodanie do klasy metody bez ingerowania w kod klasy. Zacznijmy od dodania funkcji zmiany na JSONa do klasy Workflow:
Listing 3. Nowa metoda w Workflow
fun Workflow.toJson(): String {
return """{
name: $name,
owner: $owner
}"""
}
I teraz możemy napisać:
Listing 4. Wypisanie JSONa
data class Person(val name: String, val boss: Person?)
data class Workflow(val name: String, val owner: Person)
fun main(args: Array<String>) {
val ania = Person("Ania", null);
val ela = Person("Ela", ania);
val kawusia = Workflow("kawusia", ela);
println(kawusia.toJson())
}
I wszystko będzie ok. Na tej samej zasadzie możemy dodawać pola.
Dlaczego „mały”?
Jest jednak pewien „mały” problem. Extensions są rozwiązywane statycznie. Oznacza to, że metoda do wywołania jest wskazywana na podstawie typu określonego przez wywołującego, a nie na podstawie rzeczywistego typu obiektu, na którym wywołujemy daną funkcję. Na przykładzie:
Listing 5. Statyczne wyznaczenie wywołanej metody
data class Person(val name: String, val boss: Person?)
data class Workflow(val name: String, val owner: Person)
fun main(args: Array<String>) {
val ania = Person("Ania", null);
val ela = Person("Ela", ania);
val kawusia = Workflow("kawusia", ela);
printJson(kawusia)
}
fun <T> printJson(t:T){
println(t?.toJson())
}
fun <T> T.toJson():String = ""
fun Workflow.toJson(): String {
return """{
name: $name,
owner: $owner
}"""
}
Wypisze pusty ciąg znaków. Dlaczego tak? W momencie, gdy określamy jakiego typu jest parametr printJson to otrzymujemy typ T. Następnie szukamy pasującego rozszerzenia T.toJson. Zatem to ono zostanie użyte. Rozszerzenie Workflow.toJson zostanie zignorowane, ponieważ Workflow nie jest RÓWNY T. I to boli. Najzwyczajniej w świecie boli, ponieważ nie daje takiej elastyczności jak w przypadki konwersji niejawnych.
Dlatego też użyłem określenia „mały”. Nie jest to w pełnoprawny polimorfizm ad hoc. Funkcja musi coś niecoś wiedzieć o tym, co zostało jej przekazane. Jak sobie z tym poradzić? Na przykład w ten sposób:
Listing 6. Wypisanie JSONa – kompletny program
data class Person(val name: String, val boss: Person?)
data class Workflow(val name: String, val owner: Person)
fun main(args: Array<String>) {
val ania = Person("Ania", null);
val ela = Person("Ela", ania);
val kawusia = Workflow("kawusia", ela);
jsonPrinter(kawusia)
}
fun <T> jsonPrinter(t: T) {
fun Person.toJson(): String {
return """{
name: ${name},
boss: ${boss?.toJson()}
}"""
}
fun Workflow.toJson(): String {
return """{
name: $name,
owner: ${owner.toJson()}
}"""
}
when (t) {
is Workflow -> println(t.toJson())
else -> ""
}
}
Choć nadal nie jest to najlepsze rozwiązanie, ponieważ musi być zachowana kolejność definiowania rozszerzeń.
Podsumowanie
Mechanizm rozszerzeń jest bardzo ciekawym i przydatnym rozwiązaniem. Jednocześnie ze względu na swój statyczny charakter nie pozwala w pełni wykorzystać możliwości polimorfizmu ad hoc. Dlatego też mam obawę, że będzie on źródłem naprawdę porytego kodu.