Rozszerzenia w Kotlinie
To jest rozdział z książki Kotlin Essentials.
Najbardziej intuicyjnym sposobem definiowania metod i właściwości jest umieszczanie ich wewnątrz klas. Takie elementy nazywane są elementami klasy.
Z drugiej strony, Kotlin daje jeszcze jeden sposób definiowania funkcji i właściwości, które są wywoływane na instancji: rozszerzenia. Funkcje rozszerzające są definiowane jak zwykłe funkcje, ale dodatkowo określają typ rozszerzany (poprzez nazwę typu i kropkę przed nazwą funkcji). W poniższym przykładzie funkcja dzwon
jest zdefiniowana jako funkcja rozszerzenia dla Telefon
, więc musi być wywoływana na instancji tego typu.
Zarówno funkcje elementów, jak i funkcje rozszerzające nazywane są metodami.
Funkcje rozszerzające można definiować na typach, których nie definiujemy sami, na przykład String
. Daje nam to możliwość rozszerzania zewnętrznych interfejsów API o nasze własne funkcje.
Spójrz na powyższy przykład. Zdefiniowaliśmy funkcję rozszerzającą remove
na String
, więc musimy wywołać tę funkcję na obiekcie typu String
. Wewnątrz funkcji odwołujemy się do tego obiektu za pomocą słowa kluczowego this
, tak jak wewnątrz funkcji klasy. Słowo kluczowe this
można również używać niejawnie.
Słowo kluczowe this
jest znane jako receiver. Typ, który rozszerzamy za pomocą funkcji rozszerzenia, nazywany jest typem receivera (receiver type).
Funkcje rozszerzające zachowują się bardzo podobnie do funkcji z klas. Kiedy deweloperzy się o nich uczą, często martwią się o bezpieczeństwo obiektów, ale to nie jest problem, ponieważ rozszerzenia nie mają żadnego specjalnego dostępu do elementów klasy. Jedyna różnica między funkcjami rozszerzającymi a zwykłymi funkcjami z dodatkowym parametrem zamiast receivera polega na tym, że są one wywoływane "na" instancji, zamiast z instancją jako standardowym argumentem. Aby zrozumieć to lepiej, przyjrzyjmy się bliżej funkcjom rozszerzającym.
Funkcje rozszerzające pod maską
Aby zrozumieć funkcje rozszerzające, użyjmy ponownie narzędzia "Tools > Kotlin > Show Kotlin Bytecode" i przycisku "Decompile" (jak wyjaśniłem w rozdziale Twój pierwszy program w Kotlinie, w sekcji Co kryje się pod maską na JVM?). Skompilujemy i zdekompilujemy do Java naszą definicję funkcji remove
oraz jej wywołanie:
W rezultacie powinieneś zobaczyć następujący kod:
public final class PlaygroundKt {
@NotNull
public static final String remove(
@NotNull String $this$remove,
@NotNull String value
) {
// sprawdzenie, czy parametry nie są nullami
return StringsKt.replace$default(
$this$remove,
value,
""
// oraz wartości domyślne
);
}
public static final void main(@NotNull String[] args) {
// sprawdzenie, czy parametr nie jest nullem
String var1 = remove("A B C", " ");
System.out.println(var1);
}
}
Zauważ, co stało się z typem odbiorcy: stał się parametrem. Możesz także zobaczyć, że pod maską remove
nie jest wywoływane na obiekcie. To po prostu zwykła statyczna funkcja.
Definiując funkcję rozszerzenia, naprawdę nie dodajesz nic do klasy. To tylko cukier składniowy. Porównajmy dwie następujące implementacje funkcji remove
.
Pod maską są niemal identyczne. Różnica polega na tym, jak się je wywołuje w Kotlinie. Zwykłe funkcje otrzymują wszystkie swoje argumenty na zwykłych pozycjach argumentów. Funkcje rozszerzające są wywoływane "na" wartości.
Właściwości rozszerzające
Rozszerzenie nie może przechowywać stanu, więc nie może mieć pól. Ale przecież właściwości nie potrzebują pól, mogą być definiowane przez gettery i settery, czyli akcesory. Dlatego możemy definiować właściwości rozszerzające, jeśli nie potrzebują one pola i są w zupełności definiowane przez akcesory.
Właściwości rozszerzające są bardzo popularne w Androidzie, gdzie dostęp do różnych usług jest jednocześnie powtarzalny i skomplikowany. Definiowanie właściwości rozszerzających pozwala nam zaoszczędzić sporo pracy.
Właściwości rozszerzające mogą definiować zarówno gettera, jak i settera. Oto właściwość rozszerzenia, która dostarcza inną reprezentację daty urodzenia użytkownika:
Rozszerzenia kontra elementy klasy
Największa różnica między elementami klasy a rozszerzeniami z punktu widzenia użytkownika polega na tym, że rozszerzenia muszą być importowane. Z tego powodu mogą być umieszczone w dowolnym pakiecie czy nawet w innym module niż ten, w którym zdefiniowany jest rozszerzany typ. Ten fakt jest wykorzystywany, gdy nie mamy kontroli nad typem, do którego chcemy dodać funkcję lub właściwość. Jest również wykorzystywany w projektach mających na celu oddzielenie danych i zachowań. Właściwości z polami muszą być umieszczone w klasie, ale metody można umieścić oddzielnie, o ile mają dostęp do publicznego API klasy.
Dzięki temu, że rozszerzenia muszą być importowane, możemy mieć wiele rozszerzeń o tej samej nazwie dla tego samego typu. To dobrze, ponieważ różne biblioteki mogą dostarczać dodatkowe metody bez powodowania konfliktów. Z drugiej strony, niebezpieczne byłoby posiadanie dwóch rozszerzeń o tej samej nazwie, ale o różnych zachowaniach. Jeśli widzisz taką sytuację, jest to code smell i wskazówka, że ktoś nadużył możliwości funkcji rozszerzających.
Inną istotną różnicą jest to, że rozszerzenia nie są wirtualne, co oznacza, że nie mogą być zredefiniowane w klasach pochodnych. Co za tym idzie, jeśli masz rozszerzenie zdefiniowane zarówno dla nadtypu, jak i podtypu, kompilator decyduje, która funkcja jest wybierana na podstawie tego, jak zmienna jest typowana, a nie jaka jest jej rzeczywista klasa.
Zachowanie funkcji rozszerzenia różni się od funkcji elementów. Funkcje elementów są wirtualne, więc rzutowanie w górę typu obiektu nie wpływa na wybór funkcji klasy.
To zachowanie jest wynikiem tego, że funkcje rozszerzające są kompilowane pod maską do normalnych funkcji, w których odbiorca funkcji rozszerzającej jest umieszczony jako pierwszy argument:
Kolejną konsekwencją tego, czym są rozszerzenia, jest to, że definiujemy rozszerzenia dla typów, a nie dla klas. Daje nam to większą swobodę. Na przykład możemy zdefiniować rozszerzenie dla typów nullowalnych lub generycznych:
Ostatnia ważna różnica polega na tym, że rozszerzenia nie są wymieniane jako elementy w referencji do klasy. To powoduje, że nie są uwzględniane przez procesory adnotacji; dlatego też, gdy przetwarzamy klasę za pomocą przetwarzania adnotacji, nie możemy wyodrębnić elementów, które powinny być przetworzone w rozszerzeniach. Z drugiej strony, jeśli wyodrębnimy elementy nieistotne jako rozszerzenia, nie musimy się martwić, że zostaną one zauważone przez te procesory. Nie musimy ich ukrywać, ponieważ i tak nie są w klasie, którą rozszerzają.
Funkcje rozszerzeń dla deklaracji obiektów
Możemy zdefiniować rozszerzenia dla deklaracji obiektów.
Aby zdefiniować funkcję rozszerzenia dla companion obiektu, musimy użyć rzeczywistej nazwy tego obiektu. Jeśli ta nazwa nie jest ustawiona jawnie, domyślną jest "Companion". Aby zdefiniować funkcję rozszerzającą companion obiekt, taki obiekt musi istnieć. Dlatego niektóre klasy definiują companion obiekty bez ciał.
Funkcje rozszerzające zdefiniowane wewnątrz klas
Możliwe jest definiowanie funkcji rozszerzających wewnątrz klas.
Tego typu funkcje rozszerzające są uważane za złą praktykę i powinniśmy unikać ich stosowania, jeśli nie mamy ku temu dobrego powodu. Aby uzyskać bardziej szczegółowe wyjaśnienie, zobacz Effective Kotlin, Temat 46: Unikaj definiowania funkcji rozszerzających wewnątrz klas.
Przypadki użycia
Najważniejszym zastosowaniem rozszerzeń jest dodawanie metod i właściwości do API, nad którymi nie mamy kontroli. Dobrym przykładem jest wyświetlanie toasta lub ukrywanie widoku w systemie Android. Obydwie te operacje są niepotrzebnie skomplikowane, więc lubimy definiować rozszerzenia, aby je uprościć.
Jednak istnieją także przypadki, gdy wolimy używać rozszerzeń zamiast definiowania elementów w klasie. Weźmy pod uwagę interfejs Iterable
, który zawiera tylko jedną funkcję, iterator
. Ma za to wiele metod, które są zdefiniowane w bibliotece standardowej jako rozszerzenia1, takie jak onEach
czy joinToString
. Fakt, że są one tak zdefiniowane, pozwala zachować minimalistyczny, zwięzły interfejs. Ma to sens, ponieważ onEach
czy joinToString
nie są esencjonalną częścią interfejsu Iterable
, ale są raczej pewnymi narzędziami, które mogą być używane z każdym iterowalnym typem.
Funkcje rozszerzeń są również bardziej elastyczne niż zwykłe funkcje. Wynika to głównie z faktu, że są one definiowane dla typów, więc możemy definiować rozszerzenia dla typów takich jak Iterable<Int>
czy Iterable<T>
.
W większych projektach często mamy podobne klasy dla różnych części naszej aplikacji. Załóżmy, że implementujesz backend dla sklepu internetowego i masz klasę Product
, która reprezentuje wszystkie produkty.
Masz również podobną (ale nie identyczną) klasę o nazwie ProductJson
, która jest używana do reprezentowania obiektów, które używasz w odpowiedziach API swojej aplikacji lub które odczytujesz z żądań API.
Instancje Product
są używane w twojej aplikacji, a instancje ProductJson
są używane w API. Te obiekty muszą być oddzielone, ponieważ zdecydowałeś wcześniej, że nie chcesz zmieniać odpowiedzi API, gdy zmieniasz nazwę właściwości w klasie wewnętrznej. Często jednak musimy przekształcać pomiędzy Product
a ProductJson
. W tym celu możemy zdefiniować funkcję klasy toProduct
.
Nie każdemu spodoba się to rozwiązanie, ponieważ sprawia, że ProductJson
jest większy i bardziej skomplikowany. Nie jest to również przydatne przy przekształcaniu z Product
na ProductJson
, ponieważ w większości nowoczesnych architektur nie chcemy, aby klasy domenowe (takie jak Product
) były świadome takich szczegółów jak ich reprezentacja API. Lepszym rozwiązaniem jest zdefiniowanie zarówno toProduct
, jak i toProductJson
jako funkcji rozszerzających, a następnie umieszczenie ich obok klasy ProductJson
. Dobrze jest umieścić te funkcje przekształcające obok siebie, ponieważ mają wiele wspólnego.
Wygląda na to, że jest to popularny wzorzec zarówno na backendzie, jak i w Androidzie.
Podsumowanie
W tym rozdziale poznaliśmy rozszerzenia - potężną funkcjonalność Kotlina, która często jest używana do tworzenia wygodnych i przydatnych narzędzi oraz do lepszej kontroli naszego kodu. Jednak wraz z wielką mocą przychodzi wielka odpowiedzialność. Nie powinniśmy się obawiać używania rozszerzeń, ale powinniśmy używać ich świadomie i tylko tam, gdzie mają sens.
W następnym rozdziale w końcu przedstawimy kolekcje, a więc omówimy listy, sety, mapy i tablice.
Roman Elizarov (obecny lider zespołu rozwijającego język Kotlin) określa to pojęciem extension-oriented design. Źródło: elizarov.medium.com/extension-oriented-design-13f4f27deaee