Jak można nazwać języka jak ketchup? Ano można, a dziś pierwszy wpis na temat Kotlina, czyli języka od JetBrains działającego na JVM. Przez pewien czas zastanawiałem się jak podejść do tematu i co na początek pokazać, żeby było ciekawie. Z jednej strony są data class, które są fajne. Z drugiej specyficzne modyfikatory dostępu, które w świecie JVM mają sens. Jednak koniec końców stwierdziłem, że zacznę od czegoś co w Javie nie występuje, ale było by bardzo przydatne, czyli od przeciążania operatorów.
Problem wersjonowania
Załóżmy, że mamy sobie klasę Report, która trzyma sobie wersję, która to wersja będzie podnoszona i tylko podnoszona. W javie realizacja tego zadania polega najprościej na wprowadzeniu klasy Version (bo gołe longi są chujowe), która będzie niosła w sobie odpowiednie informacje.
Kotlin pozwala na zrobienie tego trochę inaczej. Na początek zdefiniujmy sobie data class (taki semantyczny value object) Version, która będzie miała tylko jedno pole:
Listing 1. Klasa Version w Kotlin
data class Version(val version: Long) { init { if (version < 0) throw IllegalArgumentException("Version - ${version} - is less than 0"); } }
Teraz chciałbym móc w jakiś łatwy sposób podbijać numer wersji. Można dodać metodę tak jak w Javie, ale po co skoro można przeciążyć operator? Mechanizm przeciążania operatorów w Kotlinie jest dość prosty. Jest lista zdefiniowanych funkcji, które należy dopisać do klasy w odpowiedni sposób i już mamy możliwość użycia go w naszym kodzie. Dorzućmy wiec do naszej klasy wsparcie dla operatora +a:
Listing 2. Klasa Version z operatorem
data class Version(val version: Long) { init { if (version < 0) throw IllegalArgumentException("Version - ${version} - is less than 0"); } operator fun unaryPlus(): Version { return copy(version + 1); } }
Metoda unaryPlus pozwala na użycie operatora + w stosunku do naszego obiektu. Przykładowy test sprawdzający nasz pomysł:
Listing 3. Test działania operatora + w klasie Version
class VersionTest() { @Test fun shouldUnaryPlusIncreaseVersionByOne(){ val v0:Version = Version(0); val v1:Version = +v0; Assertions.assertThat(v1.version).isEqualTo(1L); Assertions.assertThat(v1).isNotSameAs(v0); } }
Wszystko ładnie, ale to nie jest tak oczywisty zapis jak użycie dobrze znanego ++. W tym celu dodajmy do naszej klasy kolejną metodę - inc:
Listing 4. Definiowanie operatora ++ w klasie Version
data class Version(val version: Long) { //... operator fun inc():Version{ return copy(version + 1); } }
Tyle tylko, że ten operator działa inaczej niż + co spowoduje mały problem.
Listing 5. Test działania operatora ++ w klasie Version
class VersionTest() { @Test fun shouldIncIncreaseVersionByOne(){ val v0:Version = Version(0); val v1:Version = v0++; Assertions.assertThat(v1.version).isEqualTo(1L); Assertions.assertThat(v1).isNotSameAs(v0); } }
Ten kod się nie skompiluje ponieważ operator ++ wartość zmiennej zatem zmieńmy val na var
Listing 6. Test działania operatora ++ w klasie Version
class VersionTest() { @Test fun shouldIncIncreaseVersionByOne(){ var v0:Version = Version(0); val v1:Version = v0++; Assertions.assertThat(v1.version).isEqualTo(1L); Assertions.assertThat(v1).isNotSameAs(v0); } }
i zobaczmy co się stanie:
Listing 7. Wyniki testu
org.junit.ComparisonFailure: Expected :1L Actual :0L
I jest to rozsądne ponieważ najpierw przypisaliśmy wartość do v1 równą v0, a potem podbiliśmy v0. Czyli wiemy już jak działa postinkrementacja w kotlinie (jak działa w Szepczącym Lesie? oto jest pytanie). Zmieńmy nasz kod na preinkrementację:
Listing 8. Preinkrementacja
class VersionTest() { @Test fun shouldIncIncreaseVersionByOne(){ var v0:Version = Version(0); val v1:Version = ++v0; Assertions.assertThat(v1.version).isEqualTo(1L); Assertions.assertThat(v1).isNotSameAs(v0); } }
Odpalamy i otrzymujemy
Listing 9. Wyniki testu preinkrementacji
java.lang.AssertionError: expected not same:<Version(version=1)>
I to też jest zrozumiałe ponieważ najpierw podbiliśmy v0, a następnie przypisaliśmy wynik do v1. Czyli preinkrementacja działa też w rozsądny sposób. Zatem nasz kod testu w wersji finalnej będzie wyglądał tak
Listing 10. Działajkacy test
class VersionTest() { @Test fun shouldIncIncreaseVersionByOne(){ var v0:Version = Version(0); val v1:Version = v0; v1++ Assertions.assertThat(v0.version).isEqualTo(0L); Assertions.assertThat(v1.version).isEqualTo(1L); Assertions.assertThat(v1).isNotSameAs(v0); } }
Możemy tu zaobserwować ważną rzecz. Operator ++ (oraz --) dotyka tylko zmiennej na rzecz której został wywołany. Obiekt reprezentowany przez tą zmienną nie podlega podmianie chyba, że funkcja inc (albo dec) explicite zmienia coś w obiekcie. Oznacza to też, że funkcje te muszą zwracać obiekt o co najmniej tym samym typie co typ w którym zostały zdefiniowane, mogą oczywiście zwrócić podtyp.
W przypadku operatora +a (i innych) możemy zwrócić cokolwiek. Reszta w dokumentacji.
Podsumowanie
Kotlin pozwala na przeciążanie istniejących operatorów. To duży plus ponieważ pozwala to na uproszczenie zapisu pewnych fragmentów kodu. Jednocześnie nie możemy definiować własnych operatorów np. próba zdefiniowania :: albo → nie przejdzie. Jest to zachowanie pośrednie pomiędzy czystą Javą gdzie nie można było przeciążać operatorów, a Scalą gdzie można przeciążać operatory jak i definiować nowe (technicznie są to "dziwne" nazwy metod).
3 myśli na temat “Programowalny ketchup – operatory”