Dziedziczenie w Kotlinie
To jest rozdział z książki Kotlin Essentials.
Starożytni filozofowie zauważyli, że otaczające nas obiekty i stworzenia można podzielić na klasy o wspólnych cechach. Arystoteles dokonał nawet dość precyzyjnej kategoryzacji dla znanych mu zwierząt na rodzaje. Na przykład wszystkie ssaki mają włosy lub futro, są stałocieplne i karmią swoje młode mlekiem0. Podobnie jednak można powiedzieć, że wszystkie kubki do kawy albo suszarki do włosów mają wspólne cechy. W programowaniu reprezentujemy takie relacje za pomocą dziedziczenia.
Gdy klasa dziedziczy z innej klasy, posiada wszystkie jej funkcje i właściwości. Klasa, która dziedziczy, jest znana jako podklasa klasy, z której dziedziczy, zwanej nadklasą. Nazywane są również dzieckiem i rodzicem.
W Kotlinie wszystkie klasy są domyślnie zamknięte, co oznacza, że nie można z nich dziedziczyć. Aby otworzyć klasę, używamy słowa kluczowego open
, co pozwala na dziedziczenie z niej. Aby dziedziczyć z klasy, umieszczamy dwukropek po głównym konstruktorze (lub po nazwie klasy, jeśli nie definiujemy głównego konstruktora), a następnie wywołujemy konstruktor nadklasy. W poniższym przykładzie klasa Dog
dziedziczy po klasie Mammal
. Ponieważ Mammal
nie ma określonego konstruktora, używamy jego konstruktora domyślnego, a więc wywołujemy go bez argumentów (Mammal()
). W ten sposób klasa Dog
dziedziczy wszystkie właściwości i metody z klasy Mammal
.
Koncepcyjnie traktujemy podklasy tak, jakby były swoimi nadklasami: więc jeśli Dog
dziedziczy po Mammal
, mówimy, że Dog
jest Mammal
. Dlatego wszędzie tam, gdzie oczekujemy Mammal
, możemy użyć instancji Dog
. Biorąc to pod uwagę, dziedziczenie powinno być stosowane tylko wtedy, gdy istnieje prawdziwa relacja "jest" między dwiema klasami.
Nadpisywanie elementów
Domyślnie podklasy nie mogą nadpisywać elementów zdefiniowanych w nadklasach, ponieważ Kotlin domyślnie traktuje wszystkie elementy jako zamknięte. Aby nadpisanie było możliwe, te elementy muszą być "otwarte" na nadpisanie, co określają przy pomocy modyfikatora open
. Wtedy elementy podklasy mogą nadpisywać implementację elementów swoich rodziców, co wygląda tak jak definiowanie tych funkcji lub właściwości w dzieciach, ale z modyfikatorem override
(ten modyfikator jest wymagany w Kotlinie).
Rodzice z niepustymi konstruktorami
Dotychczas dziedziczyliśmy tylko z klas o pustych konstruktorach, więc gdy określaliśmy nadklasę, używaliśmy pustych nawiasów. Jeśli jednak nadklasa ma parametry konstruktora, musimy zdefiniować w tych nawiasach pewne argumenty.
Możemy użyć właściwości konstruktora głównego jako argumentów konstruktora nadklasy lub do konstruowania tych argumentów.
Wywołanie super
Gdy klasa rozszerza inną klasę, przejmuje zachowanie z nadklasy, ale także dodaje pewne zachowanie specyficzne dla podklasy. Dlatego nadpisywanie metod często wymaga uwzględnienia użycia metod, które są nadpisywane. W tym celu przydatne jest wywołanie implementacji nadklasy w tych metodach podklasy. Robimy to za pomocą słowa kluczowego super
, po którym następuje kropka, a następnie wywołujemy metodę, którą chcemy nadpisać.
Spójrz na klasy Dog
i BorderCollie
, które są przedstawione w poniższym przykładzie. Domyślne zachowanie dla psa to merdanie ogonem, gdy widzi psiego przyjaciela. Border Collie powinny zachowywać się tak samo, ale dodatkowo się kłaść. W tym przypadku, aby wywołać implementację nadklasy musimy użyć super.seeFriend()
.
Klasa abstrakcyjna
Ssak to grupa zwierząt, a nie konkretny gatunek. Definiuje zestaw cech, ale nie może istnieć jako taki. Aby zdefiniować klasę, która może być używana jedynie jako nadklasa innych klas, ale nie może tworzyć obiektu, używamy słowa kluczowego abstract
przed jej definicją klasy. Innymi słowy, modyfikator open
można interpretować jako "można dziedziczyć z tej klasy", podczas gdy abstract
jako "trzeba dziedziczyć z tej klasy, aby jej użyć". Klasy abstrakcyjne są otwarte, więc nie ma potrzeby używania modyfikatora open
, gdy klasa ma już modyfikator abstract
.
Gdy klasa jest abstrakcyjna, może mieć abstrakcyjne funkcje i właściwości. Takie funkcje nie mają ciała, a każda podklasa musi je nadpisać. Dzięki temu, gdy mamy obiekt, którego typem jest klasa abstrakcyjna, możemy wywołać jego abstrakcyjne funkcje, ponieważ bez względu na rzeczywistą klasę tego obiektu, musi ona zdefiniować te funkcje.
Klasa abstrakcyjna może również mieć metody nieabstrakcyjne, które mają swoje ciało. Takie metody mogą być używane przez inne metody. Dlatego klasy abstrakcyjne mogą być używane jako szablony z częściową implementacją dla innych klas. Spójrz na poniższą klasę abstrakcyjną CoffeeMachine
, która określa, jak przygotować latte lub doppio, ale potrzebuje podklasy, która nadpisze abstrakcyjne metody prepareEspresso
i addMilk
. Jest to więc pewnego rodzaju szablon dla klas reprezentujących ekspresy do kawy, dostarcza implementację tylko dla niektórych metod i wymaga zdefiniowania innych.
Kotlin nie obsługuje wielokrotnego dziedziczenia, więc klasa może dziedziczyć tylko z jednej klasy otwartej. Nie uważam tego za problem, ponieważ dziedziczenie nie jest obecnie tak popularne, zamiast tego częściej implementowane są interfejsy.
Interfejsy
Interfejs definiuje zestaw właściwości i metod, które powinna posiadać klasa. Interfejsy definiujemy za pomocą słowa kluczowego interface
, nazwy oraz ciała z oczekiwanymi właściwościami i metodami.
Gdy klasa implementuje interfejs, musi ona zastąpić wszystkie elementy zdefiniowane przez ten interfejs. Dzięki temu możemy traktować instancję klasy jako instancję interfejsu. Interfejsy implementujemy podobnie do rozszerzania klas, ale bez wywoływania konstruktora, ponieważ interfejsy nie mogą mieć konstruktorów.
Jak już wspomniano, interfejsy mogą określać, że oczekują, iż klasa będzie mieć określoną właściwość. Takie właściwości mogą być zdefiniowane jako zwykłe właściwości lub mogą być zdefiniowane przez akcesory (getter dla val
lub getter i setter dla var
).
Właściwość tylko do odczytu val
może zostać nadpisana właściwością do odczytu i zapisu var
. Wynika to z faktu, że właściwość val
oczekuje gettera, a właściwość var
dostarcza gettera oraz settera.
Klasa może implementować wiele interfejsów.
Interfejsy mogą określać domyślne ciała dla swoich metod. Takie metody nie muszą (ale mogą) być implementowane przez klasy implementujące takie interfejsy.
Możemy wywołać to domyślne ciało, używając słowa kluczowego super
i zwykłego wywołania metody6.
Gdy dwa interfejsy definiują metodę o tej samej nazwie i parametrach, klasa implementująca oba te interfejsy musi nadpisać tę metodę. Aby wywołać domyślne ciała tych metod, musimy użyć super
wraz z nazwą klasy, której chcemy użyć, w nawiasach ostrokątnych. Czyli aby wywołać start
z Boat
, użyj super<Boat>.start()
. Aby wywołać start
z Car
, użyj super<Car>.start()
.
Widoczność
Projektując nasze klasy, staramy się ujawnić jak najmniej informacji3. Jeśli nie ma powodu, dla którego element miałby być widoczny4, wolimy go ukryć. Dlatego, jeśli nie ma dobrego powodu, aby mieć mniej restrykcyjny typ widoczności, dobrym zwyczajem jest nadanie klasom i elementom jak najbardziej restrykcyjnej widoczności. Robimy to za pomocą modyfikatorów widoczności.
Dla elementów klasy możemy użyć 4 modyfikatorów widoczności:
public
(domyślny) - widoczny wszędzie przez funkcje mogące zobaczyć deklarującą klasę.private
- widoczny tylko przez funkcje tej samej klasy.protected
- widoczny tylko przez funkcje tej samej klasy i jej podklas.internal
- widoczny wewnątrz tego samego modułu przez funkcje mogące zobaczyć deklarującą klasę.
Elementy plików mają 3 modyfikatory widoczności:
public
(domyślny) - widoczny przez wszystkie funkcje.private
- widoczny przez funkcje w tym samym pliku.internal
- widoczny przez funkcje wewnątrz tego samego modułu.
Zauważ, że moduł nie jest tym samym co pakiet. W Kotlinie moduł jest definiowany jako pliki kompilowane razem. Może to oznaczać:
- moduł Gradle,
- projekt Maven,
- moduł IntelliJ IDEA,
- zestaw plików kompilowanych za pomocą jednego wywołania zadania Ant.
Przyjrzyjmy się kilku przykładom, zaczynając od domyślnej widoczności public
, która sprawia, że elementy są widoczne wszędzie.
Modyfikator private
można interpretować jako "widoczny w zakresie tworzenia", więc jeśli zdefiniujemy element w klasie, będzie on widoczny tylko w tej klasie, jeśli zdefiniujemy element w pliku, będzie on widoczny tylko w tym pliku.
Modyfikator protected
można interpretować jako "widoczny w klasie i jej podklasach". protected
ma sens tylko dla elementów zdefiniowanych w klasach. Jest podobny do private
, ale chronione elementy są również widoczne w podklasach klasy, w której te elementy są zdefiniowane.
Modyfikator internal
sprawia, że elementy są widoczne w tym samym module. Jest to przydatne dla twórców bibliotek, którzy używają modyfikatora internal
dla elementów, które mają być widoczne w ich projekcie, ale nie chcą ich udostępniać użytkownikom tych bibliotek. Jest również przydatny w projektach wielomodułowych, aby ograniczyć dostęp do pojedynczego modułu. Jest bezużyteczny w projektach jednomodułowych5.
Jeśli twój moduł może być używany przez inny moduł, zmień widoczność publicznych elementów, których nie chcesz udostępniać, na internal
. Jeśli element jest przeznaczony do dziedziczenia i używamy go tylko tej samej w klasie i podklasach, nadaj mu modyfikator protected
. Jeśli używasz elementu tylko w tym samym pliku lub klasie, nadaj mu modyfikator private
.
Zmiana widoczności właściwości oznacza zmianę widoczności jej akcesorów. Pole właściwości zawsze jest prywatne. Aby zmienić widoczność settera, umieść modyfikator widoczności przed słowem kluczowym set
. Getter musi mieć taką samą widoczność jak właściwość.
Any
Jeśli klasa nie ma wyraźnego rodzica, jej domyślnym rodzicem jest Any
, który jest nadklasą wszystkich klas w Kotlinie. Oznacza to, że gdy oczekujemy parametru typu Any?
, akceptujemy wszystkie możliwe wartości jako argumenty.
Możesz myśleć o Any
jak o otwartej klasie z trzema metodami: toString
, equals
i hashCode
. Zostaną one lepiej wyjaśnione w następnym rozdziale, Data klasy. Nadpisywanie metod zdefiniowanych przez Any
jest opcjonalne, ponieważ każda z nich to otwarta funkcja z domyślnym ciałem.
Podsumowanie
W tym rozdziale nauczyliśmy się, jak korzystać z dziedziczenia w Kotlinie. Zapoznaliśmy się z otwartymi i abstrakcyjnymi klasami, interfejsami oraz modyfikatorami widoczności. Są one przydatne, gdy chcemy reprezentować hierarchie klas.
Zamiast używać klas do reprezentowania hierarchii, możemy również używać ich do przechowywania danych; w tym celu bardzo przydatny jest modyfikator data
, który jest przedstawiony w następnym rozdziale.
Ciekawostką dla właścicieli psów czy kotów jest to, że, podobnie jak wszystkie ssaki, mają one pępek; jednak często nie jest łatwo go znaleźć, ponieważ jest mały i czasami ukryty pod futrem.
Głębsze wyjaśnienie powodów stojących za tą ogólną zasadą programowania przedstawione jest w Efektywny Kotlin, Temat 30: Minimalizuj widoczność elementów.
Widoczność określa, gdzie można używać danego elementu. Jeśli element nie jest widoczny, nie będzie sugerowany przez IDE i nie można go używać.
Jednakże widziałem przypadki, gdy zespoły używały modyfikatora widoczności internal
jako substytutu dla modyfikatora package-private w Java. Pomimo że ma on inne zachowanie, niektórzy programiści traktują ten modyfikator jako formę dokumentacji, która powinna być interpretowana jako "ten element nie powinien być używany w różnych pakietach". Nie jestem fanem takich praktyk.
Domyślne metody sprawiają, że interfejsy są czymś więcej niż to, co uważano za interfejs w tradycyjnym ujęciu. Umożliwiają one interfejsom definiowanie zachowań, które są dziedziczone przez klasy implementujące te interfejsy. Koncepcja, która reprezentuje zbiór metod, które można wykorzystać do rozszerzenia funkcjonalności klasy, jest znana w programowaniu jako trait. Dlatego we wczesnych wersjach Kotlina używaliśmy słowa kluczowego trait
zamiast słowa kluczowego interface
. Jednak wersja 8 Javy wprowadziła domyślne ciała dla metod interfejsów, więc twórcy Kotlina założyli, że społeczność JVM poszerzyła znaczenie pojęcia interfejsu i dlatego teraz używamy słowa kluczowego interface
. Koncepcja traitów jest używana w Kotlinie. Przykład można znaleźć w moim artykule Traits for testing in Kotlin, który można znaleźć pod adresem kt.academy/article/traits-testing
.