Elementarz Java #6 – Metody i hermetyzacja

Wstęp

Powoli wkraczamy w coraz ciekawsze tematy związane z Javą. W poprzednich artykułach opisywałem różnego rodzaju, dość podstawowe mechanizmy i sposoby działania popularnych konstrukcji programistycznych. Dzisiaj zagadnienie trochę większego kalibru, a to tylko wstęp do kolejnego wpisu. W tym artykule poznasz za to wiele ciekawych mechanizmów związanych z hermetyzacją danych i ogólnie programowaniem zorientowanym obiektowo. Zapraszam do lektury!

Tworzenie metod z argumentami oraz typami zwracanymi

Mając metodę, która ma zwracać int, zwracamy int (lub Integer), jeśli metoda zwraca String zwracamy String lub null. Jedyne co może sprawiać tutaj kłopoty to wszystkie metody o typie zwracanym void. Przeanalizujmy poniższy kod.

Czy powyższy fragment zostanie skompilowany poprawnie? Otóż, tak. Na pierwszy rzut oka wydawać by się mogło, że kod ten jest błędny. Mamy przecież użyte słówko kluczowe return, które stosujemy do zwracania określonych wartości. Należy jednak tutaj odnieść się do specyfikacji Javy, która w żadnym miejscu nie zakazuje stosowania instrukcji return w metodach o typie zwracanym void. Jeśli „return” nie zwraca żadnej wartości, wszystko będzie w porządku.

Drugim elementem związanym z typami zwracanymi oraz przekazywanymi do metod argumentami jest kwestia modyfikacji danych. Jeśli wywołamy jakąś metodę i przekażemy do niej argumenty w postaci jakiś zmiennych. To metoda ta nie będzie działała na „oryginalnych” wartościach, ale stworzy sobie ich kopię, na której będzie wykonywała wszystkie swoje instrukcje. Zobaczmy prosty przykład.

Jaki będzie efekt działania powyższego programu? Otrzymamy następujące wartości: 100number1 = 10number2 = 10. Jak widać pomimo tego, że przekazane w metodzie main argumenty zostały zmodyfikowane w metodzie count to finalnie nie wpłynęło to na ich pierwotną wartość. Pobawiliśmy się trochę prymitywami, ale co z obiektami? Czy będą się zachowywały tak samo?

Mamy metodę modify, do której przekazujemy dwa obiekty typu StringBuilder. Na jednym z przekazanych obiektów wywołujemy jego metodę o nazwie reverse (odwrócenie ciągu znaków). Dalej do drugiego przekazanego obiektu przypisujemy nowego StringBuilder’a i finalnie zwracamy go. Jakie otrzymamy wartości na ekranie komputera? Tutaj może pojawić się nieco zamieszania. Otrzymamy taki rezultat: sb1 = 1bssb2 = sb2sb3 = Hello. Jak widać, wartość zmiennej sb2 nie została zmodyfikowana poza metodą modify, łatwo się też domyśleć, że metoda ta zwróci nam string „Hello”, który zostanie przypisany do sb3. Wszystko się więc zgadza. Pytanie jakie może się pojawić to to dlaczego wartość zmiennej sb1 został zmodyfikowana również w metodzie wywołującej? Tutaj warto nadmienić, że w momencie w którym przekazujemy do jakiejś metody obiekty, metoda ta może zmodyfikować stan tego obiektu poprzez wywołanie jego własnych metod. W takim przypadku zmodyfikowana wartość znajduje swoje odzwierciedlenie również w metodzie wywołującej. Jak widać metoda modify, wywołuje na obiekcie sb1 jego własną metodę reverse, co skutkuje tym, że wartość tego obiektu zmieni się również poza tą metodą. Inna sytuacja jest natomiast w przypadku obiektu sb2 gdzie przypisywany jest nowy ciąg znaków. Tutaj Java potraktuje to identycznie jak typ prymitywny. Przenalizujmy nieco inny przykład.

Tutaj do metody modify przekazujemy obiekt typu Car. Jak widać, zawiera on pole serialNumber, które w momencie utworzenia instancji inicjalizowane jest wartością 10. W metodzie modify, zmieniamy wartość tego pola na 20. Przy czym cały czas operujemy na tym samym obiekcie. Jeśli teraz wypiszemy zawartość sierialNumber z poziomu metody wywołującej to otrzymamy liczbę 20. Dlaczego? Zadziała tutaj ta sama zasada, o której pisałem nieco wyżej. Co prawda nie wywołujemy tutaj żadnej wewnętrznej metody klasy Car, ale cały czas działamy w kontekście tej samej instancji tego obiektu.

Metody statyczne

Ciekawa rzecz przy wywoływaniu metod ma miejsce, kiedy operujemy na metodach statycznych. Popatrz na poniższy kod i zastanów się przez chwilę, dlaczego zadziała on poprawnie?

Mamy tutaj obiekt typu Car, do którego przypisujemy wartość null. Następnie na tym samym obiekcie wywołujemy statyczną metodę start. Zadając pytanie czy ten kod zadziała poprawnie, już trochę zasugerowałem odpowiedź. Ten przykład jest jednak trochę „trikowy”. Mamy tutaj przypisanie wartości null do pewnego obiektu, a zaraz potem wywołanie metody. Wydawać by się mogło, że w trakcie działania aplikacji powinien wyskoczyć wyjątek NullPointerException. I to właśnie ma sugerować ten kod. Rzeczywistość jest jednak nieco inna. Gdyby w miejsce Car wstawić tutaj String albo Integer to czy po przypisaniu do takiej zmiennej null’a, przestaje ona być Stringiem lub Integerem? No nie. To samo jest w tym zadaniu. To, że do obiektu typu Car przypisaliśmy wartość null to nie znaczy, że ten obiekt zmienił swój typ. Nie stał się przecież jakimś „nullem”. Jeśli więc dalej jest typu Car to przecież możemy wywoływać na nim statyczne metody. Wszystko więc zadziała bez żadnych problemów, a na ekranie komputera zobaczymy napis „start”.  Zupełnie inna sytuacja była by w przypadku metod niestatycznych. Wtedy do ich wywołania potrzebowalibyśmy instancję obiektu Car, a po przypisaniu null’a takiej instancji nie mamy. Doszło by więc do wspomnianego wcześniej błędu.

Przeciążanie metod

Metody przeciążone (ang. overloaded methods), mogą być definiowane poprzez podanie innej listy argumentów. Może się ona różnić następującymi elementami:

  • liczbą przekazywanych argumentów,
  • typem przekazywanych argumentów,
  • inną pozycją przekazywanych argumentów.

Metody nie mogą być definiowane jako przeciążone, jeżeli różnią się tylko typem zwracanym lub modyfikatorem dostępu.

Popatrzymy na przykład przeciążonej metody method1.

Konstruktor domyślny i konstruktor tworzony jawnie

Domyślny konstruktor definiowany jest przez Jave tylko w przypadku, kiedy programista nie zaimplementuje własnego innego konstruktora w danej klasie. Oznacza to, że w momencie w którym nie zaimplementujemy bezargumentowego konstruktora, a stworzymy jakiś inny, to po przy próbie wywołania w nim instrukcji this() otrzymamy błąd kompilacji (na temat instrukcji this oraz super piszę nieco więcej w materiale poświęconym dziedziczeniu). Modyfikator dostępu dla konstruktora domyślnego jest taki sam jak modyfikator dostępu jego klasy. Dla klas publicznych Java stworzy domyślny publiczny konstruktor dla klas bez podanego modyfikatora dostępu konstruktor będzie miał modyfikator package-private.

Przy tworzeniu jawnego konstruktora w klasie warto wiedzieć czym tak naprawdę odróżnia się on od innych metod. Jedną najważniejszą rzeczą jest to, że konstruktor ma tą samą nazwę co klasa, w której został stworzony oraz nie ma wyspecyfikowanego typu zwracanego (w tym typu void). Co więcej konstruktor możemy zdefiniować używając modyfikatorów dostępu takich jak: publicprotectedpackage-private (default) oraz private.

Jak wspomniałem konstruktor może być również prywatny.

Jeśli dodamy do niego typ zwracany, to wtedy taki konstruktor nie jest traktowany jak konstruktor, ale jak zwykła metoda.

Warto również dodać dość oczywistą rzecz o tym, kiedy konstruktor zostaje wywoływany. Dzieje się to w momencie tworzenia nowej instancji danej klasy. Dopiszę jeszcze to o czym już pisałem w artykule na temat podstaw Javy, że jeśli mamy w danej klasie zarówno bloki inicjalizujące jak i konstruktor, to w pierwszej kolejności zostaną uruchomione bloki inicjalizujące, a w drugiej konstruktor.

Przeciążone konstruktory

Konstruktory tak samo jak metody możemy również przeciążać. Obowiązują tutaj następujące reguły:

  • muszą one zostać zdefiniowane z inną listą argumentów,
  • nie mogę one różnić się jedynie modyfikatorem dostępu,
  • mogą być zdefiniowane przy użyciu różnych modyfikatorów dostępu.

Jeden konstruktor może wywoływać inny przy pomocy konstrukcji this (piszę o niej nieco więcej w materiale na temat dziedziczenia). Warto jednak nadmienić, że instrukcja ta musi być pierwszym wyrażeniem w konstruktorze. Popatrzmy na przykład.

Modyfikatory widoczności

Java umożliwia korzystanie z czterech modyfikatorów dostępu: publicprotectedpackage-private (modyfikator domyślny), private. Co ciekawe, tylko dwa modyfikatory, czyli public i package-private są dostępne dla klas.

public – dostęp do metody/pola z dowolnego miejsca (modyfikator dostępny dla klas),

protected – oznacza, że mamy dostęp do metody/pola w ramach tego samego pakietu oraz podklasach,

package-private (modyfikator domyślny) – kiedy nie podamy, żadnego modyfikatora dostępu, dostęp do metody/pola będzie możliwy tylko w tym samym pakiecie (modyfikator dostępny dla klas),

private – oznacza, że mamy dostęp do metody/pola tylko w danej klasie, w której znajduje się ten element.

Popatrzmy na mały przykład, który ilustruje działanie modyfikatorów dostępu w Java. 

I jeszcze jeden przykład dla utrwalenia.

Taka ciekawostka, jeśli mamy klasę, która nie ma podanego modyfikatora dostępu to znaczy ma domyślny modyfikator package-private. To, jeśli stworzymy w jej ciele metodę z modyfikatorem public lub protected to i tak nie będzie to miało żadnego znaczenia. Metoda taka będzie traktowana tak jak by była stworzona z modyfikatorem domyślnym. Nie ma sensu podawać mniej restrykcyjnych modyfikatorów dostępu dla metod niż modyfikator przypisany do danej klasy.

Hermetyzacja elementów klasy

Dobrą praktyką programistyczną jest hermetyzacja elementów klasy. Polega ona na uniemożliwieniu bezpośredniego zewnętrznego dostępu do części elementów obiektu, przy jednoczesnym zdefiniowanych metod, które umożliwiających interakcję z ukrytymi elementami klasy. Obiekt, który nie jest dobrze zahermetyzowany powoduje ryzyko przypisania niepoprawnych wartości do swoich składowych. Aby zdefiniować dobrze zahermetyzowaną klasę stosuje się następujące reguły:

  • definiujemy prywatne instancyjne zmienne wewnątrz klasy,
  • definiujemy publiczne metody, które umożliwiają manipulowaniem ukrytymi elementami.

Popatrzmy na krótki przykład.

Przekazywanie parametrów do klas (typów prostych i obiektowych)

O typach prostych i obiektowym pisałem już nieco więcej w materiale poświęconym typom danych w języku Java. Nie wspomniałem jednak o kilku ciekawych sytuacjach z którymi możemy się spotkać przy okazji definicji metod oraz typów prostych i obiektowych. Popatrzmy na pierwszy przykład.

Czy powyższy kod zostanie skompilowany poprawnie? Odpowiedź na to pytanie jest prosta. Nie zostanie. Dlaczego? Typem zwracanym metody method jest int, a jak wiemy, int nie przyjmuje null’a. Mamy więc błąd kompilacji.

Czy zdefiniowanie takich trzech metod w jednej klasie spowoduje błąd kompilacji? Otóż, nie. Wszystko będzie w porządku do momentu, w którym będziemy chcieli wywołać jedną z nich. Kompilator nie będzie w stanie rozróżnić o którą nam chodzi. No bo jak przykładowo zinterpretować taką konstrukcję. 

Nie wiemy czy ma zostać wywołana metoda method(int… list) czy method(int number, int… list). Więcej na ten temat pisałem trochę wyżej przy okazji przeciążania metod. 

Jeśli w naszej klasie mamy metodę, która przyjmuje jeden argument typu int oraz jej przeciążoną wersję, przyjmującą listę int, to kod taki będzie działał poprawnie. Przy próbie wywołania metody z jednym argumentem, kompilator będzie wiedział, że chodzi o wersję, która przyjmuje jeden argument. To samo będzie, jeśli podamy kilka wartości typu int. Kompilator będzie wiedział, że ma skorzystać z przeciążonej wersji tej metody przyjmującej listę int. Przy wywołaniu tej samej metody, ale z wartością Integer (typem obiektowym) dojdzie do unboxingu, a argument ten będzie potraktowany jak zwykły int. Rezultatem uruchomienia powyższego kodu będzie więc: intint…int.

Podsumowanie

Na dzisiaj to wszystko co przygotowałem. Tradycyjnie zachęcam do lektury pozostałych materiałów z tej serii (linki w spisie treści na początku artykułu) oraz zapraszam za dwa tygodnie na kolejny artykuł na temat dziedziczenia i tematów pochodnych.

Przeczytaj również

, , , , , , , , , , , , , , , , , , ,

Dodaj komentarz

guest
0 komentarzy
Inline Feedbacks
View all comments

Pin It on Pinterest