Informacje o nowych artykułach oraz akcjach edukacyjnych prosto na Twojej skrzynce e-mail!

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.

public void method() { return; }

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.

public class Multiplication {
    public static int count(int x, int y) {
         int result = x * y;
         x = 0;
         y = 1;
         return result;
    }
 
    public static void main(String[] args) throws Exception {
        int number1 = 10;
        int number2 = 10;

        System.out.println(count(number1, number2));
        System.out.print("number1 = " + number1 + ", number2 = " + number2);
    }
}

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?

public class TestStringBuilder() {
    public static StringBuilder modify(StringBuilder sb1, StringBuilder sb2) {
        sb1.reverse();  
        sb2 = new StringBuilder("Hello");
        return sb2;
    }
 
    public static void main(String[] args) {
        StringBuilder sb1 = new StringBuilder(“sb1”);
        StringBuilder sb2 = new StringBuilder(“sb2”);
        String builder sb3 = modify(sb1, sb2);
                        
        System.out.print("sb1 = " + sb1 + ", sb2 = " + sb2 + ", sb3 = " + sb3);
    }
}

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.

class Car {
    int serialNumber = 10;
}
 
public class Main() {
    void modify(Car car) {
        car.serialNumber += 10;
    }
            
    public static void main(String[] args) {
        Car car = new Car();
        new Main().modify(car);
                        
        System.out.println(car.serialNumber);
    }
}

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?

class Car { 
    public static void start() {
        System.out.print("start");
    }
}
 
public class Driver {
    public static void main(String[] args) {
        Car car = null;
        car.start();
    }
}

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.

void method1() { }
void method1(int a) { }
void method1(int a, int b) { }
void method1(String a, int b) { }
void method1(int b, String a) { }

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.

public class Car {
    Car() { } // Przykład jawnego konstruktora z domyślnym modyfikatorem dostępu (package-private)
}

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

public class Car {
    private Car() { }
}

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

public class Car {
    public void Car() {
        System.out.print("I’m only method");
    }
}

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.

public class Test {
    Test() {
        System.out.println("Hello!");
    }
            
    public static void main(String[] args) {
        new Test();
    }
}

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.

class MyClass {
    public MyClass() { }
    public MyClass(String name) { 
        this(); // Wywołanie bezargumentowego konstruktora MyClass
        System.out.print(name); // Jakaś instrukcja
    }
}

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. 

package my.examPackage;
 
public class Exam {
    public String name;
    protected String lastName;
    private int serialNumber;
    int points; // modyfikator domyślny – package-private
}
 
package my.examPackage.factory;
import my.examPackage.*;
 
public class ExamFactory extends Exam {
    public ExamFactory() {
        name = "Jan"; // modyfikator public więc nie ma błędu
        lastName = "Kowalski"; // modyfikator protected, ale klasa, w której jesteśmy to podklasa Exam więc nie ma błędu 
        serialNumber = "ABC123456"; //brak uprawnień do zapisu tej wartości (modyfikator private)
        points = "10"; //brak uprawnień do zapisu tej wartości (jesteśmy w innym pakiecie)
    }
}

I jeszcze jeden przykład dla utrwalenia.

package room;
 
public class MyRoom {
    public int roomNumber;
    protected String roomName;
    private String roomDescription; 
                        
    static int keyNumber = 1234;
 
    public MyRoom(String name, String desc) {
        roomName = name;
        roomDescription = desc;
    } 
}
 
package House;
import MyRoom.*;
 
public class MyHouse { 
    public static void main(String[] args) {
        System.out.println(MyRoom.keyNumber); //błąd kompilacji – keyNumber nie jest widoczny poza pakietem, modyfikator domyślny (package-private) 
        MyRoom room = new MyRoom("Lukasz", "This is my room"); //jest w porządku bo konstruktor jest publiczny
        System.out.println(room.roomNumber); //jest w porządku bo modyfikator dostępu jest publiczny
        System.out.println(room.roomName); //błąd kompilacji – modyfkator protected
        System.out.println(room.roomDescription); //błąd kompilacji - modyfikator private
    }
}

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.

public class WellEncapsulation {
    private int number;
    private String name;
    private boolean isValidate;
            
    public void setNumber(int number) {
        this.number = number;
    }
 
    public void setName(String name) {
        this.name = name;
    }
 
    public void setValidate(Boolean validate) {
        this.validate = validate;
    }
 
    public int getNumber() {
        return this.number;
    }
 
    public String getName() {
        return this.name;
    }
 
    public isValidate() {
        return this.isValidate;
    }
}

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.

public int method() { 
    return null; 
}

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.

public void method(int… list) { }
public void method(int number, int… list) { }
public void method(String[] values, int[] list) { }

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ę. 

new MyClass().method(1, 2, 3);

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. 

public class MyClass {
    public void method1(int val) { 
        System.out.println("int");
    }
            
    public void method1(int… val) {
        System.out.println("int…");
    }
 
    public static void main(String[] args) {
        MyClass myClass = new MyClass();
        myClass.method1(1);
        myClass.method1(1,2);
        myClass.method1(new Integer("1"));
    }
}

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.

Spodobało się?

Jeśli tak, to zarejestruj się do newslettera aby otrzymywać informacje nowych artykułach oraz akcjach edukacyjnych. Gwarantuję 100% satysfakcji i żadnego spamowania!

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

Dodaj komentarz

Odpowiedz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Pin It on Pinterest