Klasy w Kotlinie
To jest rozdział z książki Kotlin Essentials.
Spójrz na świat wokół siebie, a prawdopodobnie zauważysz mnóstwo obiektów. Może to być książka, czytnik e-booków, monitor lub kubek kawy. Jesteśmy otoczeni obiektami. Ta obserwacja prowadzi do wniosku, że żyjemy w świecie obiektów, a zatem nasze programy powinny być zbudowane w ten sam sposób. To jest koncepcyjna podstawa programowania obiektowego. Nie wszyscy podzielają ten światopogląd, niektórzy wolą widzieć świat jako miejsce możliwych działań0, co jest podstawą programowania funkcyjnego. Niezależnie od tego, które podejście preferujemy, klasy i obiekty są ważnymi strukturami w programowaniu w Kotlinie i omówimy je w tym rozdziale.
Klasa to szablon, który służy do tworzenia obiektu o konkretnych cechach. Aby utworzyć klasę w Kotlinie, używamy słowa kluczowego class
, a następnie określamy jej nazwę. To dosłownie wszystko, czego potrzebujemy, aby utworzyć najprostszą klasę, ponieważ ciało klasy jest opcjonalne. Aby utworzyć obiekt, który jest instancją klasy, używamy konstruktora, czyli nazwy klasy z nawiasami okrągłymi. W przeciwieństwie do innych języków, takich jak C++ czy Java, w Kotlinie nie używamy słowa kluczowego new
.
Metody klasy
Wewnątrz klas możemy definiować funkcje. Aby to zrobić, najpierw musimy otworzyć nawiasy klamrowe w definicji klasy, aby określić ciało klasy.
W ciele możemy określić funkcje. Funkcje zdefiniowane w ten sposób mają dwie ważne cechy:
- Muszą być wywołane na instancji tej klasy. Oznacza to, że aby wywołać metodę, najpierw musi zostać utworzony obiekt.
- Wewnątrz metod możemy użyć
this
, które jest odniesieniem do instancji klasy, na której wywołaliśmy tę funkcję.
Wszystkie funkcje zdefiniowane w ciele klasy nazywać będziemy funkcje klasy. Są one również metodami, choć metodami są również funkcje rozszerzające, o których pomówimy później.
Koncepcyjnie rzecz biorąc, metody reprezentują to, co obiekt może robić. Na przykład, ekspres do kawy powinien być w stanie produkować kawę, co możemy reprezentować za pomocą metody makeCoffee
w klasie CoffeeMachine
. W ten sposób klasy z metodami pomagają nam modelować świat.
Właściwości
Wewnątrz ciał klas możemy również definiować zmienne. Zmienne zdefiniowane wewnątrz klas nazywane są polami. Istnieje ważna idea związana z "enkapsulacją", która oznacza, że pola nie powinny być używane bezpośrednio spoza klasy, ponieważ w takim przypadku tracimy kontrolę nad ich stanem. Zamiast tego, pola powinny być używane przez akcesory:
- getter - funkcja służąca do pobierania aktualnej wartości pola,
- setter - funkcja służąca do ustawiania nowych wartości pola.
Ten wzorzec jest bardzo powszechny; w projektach Javy można zobaczyć mnóstwo funkcji getterów i setterów. Są one potrzebne do osiągnięcia enkapsulacji, ale sprawiają, że kod jest rozwlekły i mało czytelny. Dlatego twórcy języków wymyślili "właściwości". Właściwość to zmienna w klasie, która jest automatycznie enkapsulowana, a więc używa gettera i settera w sposób niejawny. W Kotlinie wszystkie zmienne zdefiniowane wewnątrz klas są właściwościami, a nie polami.
Niektóre języki, takie jak JavaScript, mają wbudowane wsparcie dla właściwości, ale Java go nie posiada. Dlatego w bajtkodzie Kotlin/JVM generowanym z kodu w Kotlinie zawarte są metody getterów i setterów.
// równoważny kod JavaScript
function User() {
this.name = '';
}
function main(args) {
var user = new User();
user.name = 'Alex';
println(user.name);
}
// równoważny kod Java
public final class User {
@NotNull
private String name = "";
// getter
@NotNull
public final String getName() {
return this.name;
}
// setter
public final void setName(@NotNull String name) {
this.name = name;
}
}
public final class PlaygroundKt {
public static void main(String[] var0) {
User user = new User();
user.setName("Alex"); // wywołanie settera
System.out.println(user.getName()); // wywołanie gettera
}
}
Każda właściwość w Kotlinie ma akcesory, dlatego nie powinniśmy definiować getterów ani setterów za pomocą jawnie określonych funkcji. Jeśli chcesz zmienić domyślny akcesor, istnieje specjalna składnia do tego.
Aby określić niestandardowy getter, używamy słowa kluczowego get
po definicji właściwości. Reszta jest równoznaczna z definiowaniem funkcji bez parametrów. Wewnątrz tej funkcji używamy słowa kluczowego field
, aby odwołać się do pola właściwości. Domyślny getter zwraca wartość field
, ale możemy zmienić to zachowanie tak, aby wartość ta była w jakiś sposób modyfikowana przed jej zwróceniem. Gdy definiujemy getter, możemy użyć składni jednowyrażeniowej lub zwykłego ciała i słowa kluczowego return
.
Getter musi zawsze mieć taką samą widoczność i typ wyniku jak właściwość. Gettery nie powinny zgłaszać wyjątków ani wykonywać intensywnych obliczeń.
Pamiętaj, że wszystkie użycia właściwości to użycia akcesorów. Wewnątrz akcesorów należy używać field
zamiast nazwy właściwości, ponieważ w przeciwnym razie prawdopodobnie skończysz z nieskończoną rekurencją.
Settery można określić w podobny sposób, ale musimy użyć słowa kluczowego set
oraz potrzebujemy jednego parametru reprezentującego wartość, która jest ustawiana. Domyślny setter służy do przypisania nowej wartości do field
, ale możemy zmodyfikować to zachowanie, na przykład, aby ustawić nową wartość tylko wtedy, gdy spełnia ona pewne warunki.
Settery mogą mieć bardziej ograniczoną widoczność niż właściwości, co pokażemy w kolejnym rozdziale.
Jeśli niestandardowe akcesory właściwości nie używają słowa kluczowego field
, pole nie zostanie wygenerowane dla właściwości. Na przykład możemy zdefiniować właściwość reprezentującą pełne imię i nazwisko, które jest obliczane na właściwości reprezentujących imię i nazwisko. Właściwość definiowana przez getter, który nie odnosi się do field
nie zawiera pola i nie musi określać swojej wartości.
Właściwość fullName
potrzebuje tylko gettera, ponieważ jest to właściwość tylko do odczytu val
. Kiedykolwiek poprosimy o wartość tej właściwości, pełne imię i nazwisko będzie obliczane na podstawie name
i surname
. Zauważ, że ta właściwość jest obliczana na żądanie, co stanowi zaletę w porównaniu z użyciem zwykłej właściwości.
Ta różnica jest widoczna tylko wtedy, gdy wartości, z których korzysta nasza właściwość, są zmienne; gdy definiujemy niezmienny obiekt, wartość obliczona w getterze oraz podczas tworzenia klasy zawsze powinna być ta sama. Różnica w takim przypadku polega tylko na wydajności: czy wartość będzie obliczana podczas tworzenia obiektu, czy przy wywołaniu gettera.
Jako kolejny przykład wyobraźmy sobie, że musimy przechować datę urodzenia użytkownika. Początkowo reprezentowaliśmy ją za pomocą Date
z biblioteki standardowej Javy.
Po pewnym czasie zadecydowaliśmy, że Date
nie jest już dobrym sposobem na reprezentowanie tego atrybutu. Być może mamy problemy z serializacją; być może musimy uczynić nasz obiekt wieloplatformowym; a być może musimy reprezentować czas w inny sposób, którego Date
nie obsługuje. Dlatego postanowiliśmy użyć innego typu zamiast Date
. Powiedzmy, że zdecydowaliśmy się użyć właściwości typu Long
do przechowywania milisekund, ale nie chcemy pozbyć się poprzedniej właściwości, ponieważ jest ona używana w wielu innych częściach naszego kodu. Aby mieć ciastko i zjeść ciastko, możemy przekształcić naszą właściwość birthdate
, aby w pełni zależała od nowej reprezentacji. W ten sposób zmieniliśmy sposób reprezentowania daty urodzenia, nie zmieniając wcześniejszego użycia.
W powyższym getterze używam
let
oraz referencji do konstruktora, które są wyjaśnione w książce Funkcyjny Kotlin.
Taka właściwość birthdate
może być również zdefiniowana jako funkcja rozszerzająca, co zostało przedstawione w rozdziale Rozszerzenia.
Konstruktory
Kiedy tworzymy obiekt, często chcemy zainicjować go określonymi wartościami. Do tego używamy konstruktorów. Jak już widzieliśmy, gdy nie są określone żadne konstruktory, generowany jest pusty konstruktor domyślny bez żadnych parametrów.
Aby zdefiniować konstruktor, klasyczny sposób (znany ze starszych języków programowania) polega na użyciu słowa kluczowego constructor
w ciele klasy, a następnie zdefiniowaniu jego parametrów i ciała.
Konstruktory są zwykle używane do ustawiania początkowych wartości naszych właściwości. Aby to uprościć, w Kotlinie wprowadzono specjalny rodzaj konstruktora nazywany konstruktorem głównym (primary constructor). Definiuje się go zaraz po nazwie klasy, a jego parametry można użyć do inicjalizacji właściwości.
Gdy określamy konstruktor główny, użycie słowa kluczowego constructor
jest opcjonalne, więc możemy go po prostu pominąć.
Może być tylko jeden konstruktor główny. Możemy zdefiniować kolejny konstruktor, zwany konstruktorem wtórnym (secondary constructor), ale musi on wywołać konstruktor główny za pomocą słowa kluczowego this
.
Główny konstruktor jest zwykle używany do określania wartości początkowych dla naszych właściwości. Te właściwości często mają te same nazwy co inne parametry, dlatego Kotlin wprowadził specjalną notację: możemy definiować właściwości wewnątrz konstruktora głównego. Jeśli przed parametrem konstruktora głównego użyjemy val
lub var
, to definiujemy właściwość o takiej samej nazwie jak ten parametr, której wartością będzie wartość tego parametru. Takie parametry nazywamy właściwościami konstruktora głównego.
W praktyce rzadko używamy innych rodzajów konstruktorów niż konstruktor główny, a w nim większość parametrów określamy jako właściwości. Także często definiujemy konstruktor główny z domyślnymi wartościami. W poniższym przykładzie tworzymy instancję User
bez podawania argumentu surname
, więc podczas tworzenia obiektu zostanie użyta określona przez nas wartość domyślna.
Klasy reprezentujące dane w Kotlinie i Javie
Porównując klasy zdefiniowane w Kotlinie i Javie, możemy zauważyć, ile powtarzalnego kodu zostało wyeliminowanego dzięki zwięzłej składni Kotlina. W Javie typowa implementacja reprezentacji prostego użytkownika, z imieniem, nazwiskiem i wiekiem, wygląda następująco:
public final class User {
@NotNull
private final String name;
@NotNull
private final String surname;
private final int age;
public User(
@NotNull String name,
@NotNull String surname,
int age
) {
this.name = name;
this.surname = surname;
this.age = age;
}
@NotNull
public String getName() {
return name;
}
@NotNull
public String getSurname() {
return surname;
}
public int getAge() {
return age;
}
}
W Kotlinie definiujemy tę samą klasę w następujący sposób:
Wynik kompilacji jest praktycznie taki sam. Gettery i konstruktory są obecne. Jeśli w to nie wierzysz, sprawdź sam (jak przedstawiłem w sekcji Co kryje się pod maską na JVM?, w rozdziale Twój pierwszy program w Kotlinie). Kotlin to zwięzły, ale potężny język.
Klasy wewnętrzne
W Kotlinie możemy definiować klasy wewnątrz klas. Domyślnie są one statyczne, co oznacza, że nie mają dostępu do funkcji i właściwości klas zewnętrznych, dlatego mogą być tworzone bez odniesienia do klasy zewnętrznej.
Jeśli chcesz, aby Twoja klasa wewnętrzna miała odniesienie do swojej klasy zewnętrznej, musisz uczynić ją wewnętrzną przy pomocy modyfikatora inner
. Jednak tworzenie obiektów z takich klas wymaga instancji klasy zewnętrznej.
Przykłady klas wewnętrznych w bibliotece standardowej to:
- prywatne implementacje iteratorów;
- klasy, w których istnieje ścisłe powiązanie między klasą zewnętrzną a klasą wewnętrzną, a klasa wewnętrzna służy do tego, aby nie definiować kolejnej nazwy w przestrzeni nazw biblioteki.
Podsumowanie
Jak widać, w Kotlinie możemy definiować klasy za pomocą naprawdę zwięzłej składni, a wynik jest bardzo czytelny. Główny konstruktor to niesamowity wynalazek, podobnie jak fakt, że w Kotlinie używamy właściwości zamiast pól. Dowiedziałeś się także o klasach wewnętrznych. Wszystko pięknie, ale jeszcze nie poruszyliśmy tematu dziedziczenia, które jest jakże ważne dla programistów piszących w stylu obiektowym. Omówimy je wraz z interfejsami i klasami abstrakcyjnymi w następnym rozdziale.
Zobacz artykuł "Object-oriented or functional? Two ways to see the world", link: kt.academy/article/oop-vs-fp