Nasza strona używa cookies. Dowiedz się więcej o celu ich używania i zmianie ustawień w przeglądarce. Korzystając ze strony, wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki. Rozumiem

Tajemnica mutowalnych kolekcji Kotlina

Uberto Barbini Senior Kotlin Developer / Springer Publishing Company
Sprawdź, jak Kotlin używa kolekcji Javy do implementowania interfejsów List i MutableList.
Tajemnica mutowalnych kolekcji Kotlina

Zacząłem z (całkiem) nieszkodliwym kawałkiem kodu. Chciałem pokazać, dlaczego nie można zakładać, że lista (rozumiana jako List) w Kotlinie jest niemutowalna. Powodem jest to, że MutableList dziedziczy z List:

public interface MutableList<E> : List<E>, MutableCollection<E> {...}


Gdy pomyślisz o konsekwencjach, oznacza to, że możesz przekazać MutableList tam, gdzie jest spodziewana List, ale nie możesz przekazać List tam, gdzie spodziewana jest MutableList.

Interfejs List w Kotlinie jest zdefiniowany jako:

A generic ordered collection of elements. Methods in this interface support only read-only access to the list;
read/write access is supported through the [MutableList] interface.

public interface List<out E> : Collection<E> {...


Łatwo pomyśleć, że coś, co implementuje interfejs List, musi być niemutowalną kolekcją. Jest to jednak nieprawda. To tylko kolekcja, której nie możemy modyfikować. Innymi słowy, jeżeli masz funkcję taką jak ta:

fun eatAll(fruits: List<Fruit>) = ...


…nie deklarlarujesz, że eatAll() będzie przyjmować tylko niemutowalne owoce.

Deklarujesz za to, że eatAll() nie modyfikuje owoców.

Jak na razie wszystko jest jasne. Może to nie jest dla Ciebie odkrycie, ale musiałem to napisać, żeby przejść do bardziej interesujących rzeczy.

Teraz dla przykładu (i jako ostrzeżenie), napisałem taki “niegrzeczny” kod:

fun boringMethodWithAList(list: List<*>){
    println( list::class.java )
    println( "before $list" )
    naughtyFun( list )
    println( "after $list" )
}

fun <T> naughtyFun(list: List<T>) {

    if (list.size > 1 && list is MutableList<T>){
        val e = list[0]
        list[0] = list[1]
        list[1] = e
    }
}
fun main() {
    val mutableList = mutableListOf(1,2,3,4)
    boringMethodWithAList(mutableList)
}


naughtyFun() zamienia pozycję dwóch pierwszych elementów listy.

Patrząc tylko na kod boringMethodWithAList(), możesz myśleć, że obydwa wypisane wyniki będą takie same, ale w rzeczywistości będzie to:

class java.util.ArrayList
[1, 2, 3, 4]
[2, 1, 3, 4]


Właśnie to chciałem pokazać. To zaskakujące, ale z drugiej strony oczekiwane.

Następnym krokiem będzie wykorzystanie niemutowalnej listy i porównanie wypisanych wyników. Zamiast mutableListOf() użyłem listOf() i…

fun main() {
   val list = listOf("albert", "brian", "charlie")
   boringMethodWithAList(list)
}


Czy zgadniesz, co zostanie wypisane?

class java.util.Arrays$ArrayList
[albert, brian, charlie]
[brian, albert, charlie]


Tak więc nasza podobno “niemutowalna lista” implementuje interfejs MutableList i pozwala na zamianę wartości. Mnie to zaskoczyło.

Zauważ, że nie używamy trików takich, jak używanie ukrytych metod z refleksją, czy niebezpiecznych rzutowań. To zwykły kod Kotlina, który respektuje kontrakt narzucony przez interfejs List.


Implementacje List w Kotlinie

Patrząc na pierwszą wypisaną linię, możemy zobaczyć, że listOf() zwraca instancję Arrays$ArrayList, a mutableListOf() zwraca instancję ArrayList.

Każdy zna ArrayList z Javy, bo najłatwiejszą metodą (przynajmniej do Javy 9) na stworzenie listy jest wywołanie new ArrayList(). W Javie interfejs List jest mutowalny, co nie jest niespodzianką.

Arrays$ArrayList jest pewnie nieco mniej znany. Jest to tylko wrapper na tablicę, by zaprezentować ją jako listę. Nie możesz stworzyć go wprost, ale jest zwracany przez Arrays.asList(), a od Javy 9 przez List.of(). Ponieważ jest to tylko nakładka na tablicę, to pozwala odczytywać i zapisywać wartości na określonych pozycjach oryginalnej tablicy, ale nie pozwala na zmianę rozmiaru tablicy. Dlatego set() działa ok, a metody add() i remove() zwracają wyjątek.

Teraz możemy spojrzeć na implementację mutableListOf() i listOf() w kotlin-stdlib-common.

`/**
 * Returns a new [MutableList] with the given elements.
 * @sample samples.collections.Collections.Lists.mutableList
 */
public fun <T> mutableListOf(vararg elements: T): MutableList<T> =
    if (elements.size == 0) ArrayList() else ArrayList(ArrayAsCollection(elements, isVarargs = true))


mutableListOf() tworzy tylko nową instancję javowej ArrayList, jak widzimy wyżej.

Deklaracja listOf() jest bardziej interesująca:

/**
 * Returns a new read-only list of given elements.  The returned list is serializable (JVM).
 * @sample samples.collections.Collections.Lists.readOnlyList
 */
public fun <T> listOf(vararg elements: T): List<T> = if (elements.size > 0) elements.asList() else emptyList()
[...]
/**
 * Returns a [List] that wraps the original array.
 */
public expect fun <T> Array<out T>.asList(): List<T>


Używa słowa kluczowego expect i nie ma kodu. Oznacza to, że na różnych platformach (KotlinJS, KotlinNative, itd.) mogą być tam różne implementacje.

Z dokumentacji Kotlina:

Kotlina zapewnia mechanizm spodziewanych i rzeczywistych deklaracji. Dzięki temu mechanizmowi współdzielony modułu może zdefiniować spodziewane deklaracje, a moduł dla platformy może zapewnić rzeczywiste deklaracje odpowiadające tym spodziewanym.

To gdzie jest prawidziwy kod? Wewnątrz jakiegoś jara przeznaczonego dla JVM musimy znaleźć metodę o tej samej nazwie i ze słowem kluczowym actual.

Znajdziemy go w wygenerowanym pliku _ArraysJvm.kt, który jest częścią kotlin-stdlib.jar

public actual fun <T> Array<out T>.asList(): List<T> {
    return ArraysUtilJVM.asList(this)                       
}


...który odnosi się do javowej klasy w stdlib Kotlina dla JVM:

class ArraysUtilJVM {
    static <T> List<T> asList(T[] array) {
        return Arrays.asList(array);
    }
}


Teraz widzimy, że listOf() zwraca javową, mutowalną listę (java.util.Arrays$ArrayList) pod kotlinowym interfejsem List, który jest zamknięty na modyfikacje (kotlin.collections.List).


Ujawnienie tajemnicy

W tym momencie zdałem sobie sprawę, że w jest tu dość oczywisty, ale przemilczany problem:

Dodawanie interfejsu Kotlina do istniejącej klasy Javy? To nie powinno być możliwe!

To jest prawdą zarówno dla ArrayList, jak i Arrays$ArrayList, które implementują MutableList z Kotlina, a w konsekwencji również List.

W JVM nie ma (niestety) sposobu na dodanie interfejsu do istniejącej klasy, więc mnie to zaciekawiło.

By zobaczyć jak działa ta magia, możemy spojrzeć jak is MutableList jest zaimplementowana w kodzie bajtowym. Jeżeli spojrzymy na ten kawałek kodu w Kotlinie:

if (list.size > 1 && list is MutableList<T>) {...}


Możemy zobaczyć jak jest on przetłumaczony do kodu bajtowego.

LINENUMBER 21 L1
    ALOAD 0
    INVOKEINTERFACE java/util/List.size ()I (itf)
    ICONST_1
    IF_ICMPLE L2
    ALOAD 0
    INVOKESTATIC kotlin/jvm/internal/TypeIntrinsics.isMutableList (Ljava/lang/Object;)Z
    IFEQ L2
   L3


To, co jest tu ważne:

  1. Metody na liście są tak naprawdę wywoływane używając interfejsu java.util.List a nie kotlin.collections.List
  2. Sprawdzenie interfejsu nie używa standardowej dla kodu bajtowego instrukcji INSTANCEOF, ale statycznej metody na klasie TypeIntrisincs


Tak naprawdę kotlin.collections.List jest mockiem. Jest tam, by sprawdzać składnię, ale znika ze skompilowanego kodu, gdzie wszystkie wywołania są mapowane do java.util.List.

Nieważne, co robisz - nie możesz zaimplementować List w Kotlinie.

Pewnie teraz myślisz: “Daj spokój, przecież możesz pyknąć nową klasę w Kotlinie, która będzie implementować List?” Ok, spróbujmy:

data class NonMutableList<T>(private val origList: List<T>): List<T> by origList


Implementacja przez delegację jest fajnym trikiem, który pozwala skrócić kod. Zobaczmy, jak to zadziałało w przypadku naszej nowej klasy NonMutableList. Patrząc na wygenerowany kod bajtowy, możemy zobaczyć:

public final class com/ubertob/immutableCollections/NonMutableList implements java/util/List kotlin/jvm/internal/markers/KMappedMarker {


Interfejs List z Kotlina zniknął! Zaraz przedyskutujemy interfejs KMappedMarker, który został dodany.

Możesz zastanawiać się, co się stało z metodami wywołującymi javowy List. Nie martw się, zostały one wygenerowane przez kompilator Kotlina:

public addAll(Ljava/util/Collection;)Z
    NEW java/lang/UnsupportedOperationException
    DUP
    LDC "Operation is not supported for read-only collection"
    INVOKESPECIAL java/lang/UnsupportedOperationException.<init> (Ljava/lang/String;)V
    ATHROW
    MAXSTACK = 3
    MAXLOCALS = 2


A co z drugim wspomnianym punktem i sprawdzaniem rzutowania?

TypeIntrinsics jest klasą Javy i możemy spojrzeć na kod isMutableList (asMutableList jest podobne)

public static boolean isMutableList(Object obj) {
    return obj instanceof List &&
 (!(obj instanceof KMappedMarker) || obj instanceof KMutableList);
}


Możesz zobaczyć, że sprawdza on dwa specjalne interfejsy, których używa kompilator i które działają jak markery: KMutableList i KMappedMarker. Ich użycie jest zdefiniowane w pliku mutabilityMakerInterfaces.kt. Widzieliśmy też, że KMappedMaker jest automatycznie dodawany do każdej implementacji List napisanej w Kotlinie.

Nie znalazłem zbyt wiele informacji o tych markerach i tym, jak działają w kompilatorze, ale finalny rezultat wygląda tak, jakby wszystkie implementacje List w Javie były rozpoznawane jako MutableList, razem z kotlinowymi klasami, które bezpośrednio implementują interfejs MutableList. Innymi słowy, jeżeli napiszesz w Kotlinie klasę, która implementuje List, to nie będzie ona implementować MutableList.

Tak więc, jeżeli sprawdzimy naszą nowo stworzoną NonMutableList poprzez kod z początku posta, to zobaczymy, że nie implementuje ona MutableList.

class com.ubertob.immutableCollections.NonMutableList
before NonMutableList(origList=[albert, brian, charlie])
later NonMutableList(origList=[albert, brian, charlie])


Wszystko to, co przedyskutowałem, nie dotyczy tylko i wyłącznie List, ale jest też prawdą dla wszystkich klas powiązanych z kolekcjami Javy. Każdy check, rzutowanie czy dostęp na innych interfejsach kolekcji Kotlina, jak Map, Set, Collection, Iterable itd., jest bezpośrednio tłumaczony w czasie kompilacji na ekwiwalent w Javie.

Tak na boku, moim pierwszym testem było wywołanie list.remove(0) zamiast zamiany elementu. W tym wypadku dostałem UnsupportedOpertationException. Dziękuję Nicolaiowi Parlogowi za przypomnienie mi o Arrays$ArrayList.set().

W tym samym wątku na Twitterze, Andrey Breslav (twórca Kotlina) potwierdził, że interfejsy kolekcji w Kotlinie tak naprawdę nie istnieją.


Wnioski

Dużą częścią sukcesu Kotlina jest gładka integracja z kodem Javy. Widzę jak ich wybór, by użyć pod spodem kolekcji Javy, stanowi bardzo pragmatyczny kompromis pomiędzy czystym designem a produktywnością.

Osobiście chciałbym również mieć interfejs ImmutableList, wychodzący z List w bibliotece standardowej Kotlina, z natywną implementacją, która byłaby w stanie lepiej wymusić niemutowalność.

W każdym razie mam nadzieję, że czytało się Wam ten post przynajmniej w połowie tak przyjemnie, jak mi się go pisało. Kotlin to fantastyczny język i to naprawdę interesujące, jak jest zaimplementowany.

Lubisz dzielić się wiedzą i chcesz zostać autorem?

Podziel się wiedzą z 120 tysiącami naszych czytelników

Dowiedz się więcej