Java class design #3

Overload constructors and methods.

Podobnie jak w przypadku nadpisywania metod (override), przeciążanie (overload) jest przydatne kiedy chcemy zrobić to samo, ale przy pomocy innych narzędzi (czytaj, innych typów argumentów przekazanych do metody).


Na skróty:

Zasady przeciążania metod w Javie [Opis]
Potencjalne pułapki [Opis]
Przeciążanie konstruktorów [Opis]

Repozytorium z przykładami [GitHub]


Idea przeciążania metod

Wyobraźmy sobie, że mamy aplikację, która zapisuje do pliku obiekty. Dla każdego typu obiektu musi to być plik o innej nazwie i w innym katalogu, ale sam mechanizm zapisu w gruncie rzeczy jest taki sam: utwórz plik o określonej nazwie i w określonym miejscu i zapisz do niego stan obiektu. Do tego zadbaj o obsługę wszystkich wyjątków.

Oczywiście moglibyśmy utworzyć sobie klasę FileSaver, a w niej metody dla każdego możliwego typu obiektu: saveObject1(), saveObject2() itd… Każda z tych metod w gruncie rzeczy byłaby identyczna, tylko przyjmowała by inny typ argumentu. Rozwiązanie nie mające żadnego związku z dobrymi praktykami programowania obiektowego:

public class FileSaver {
  public void saveObject1(Object1 obj){
    //preparing object1
    //saving
  }
  public void saveObject2(Object2 obj){
    //preparing object2 - step1
    //preparing object2 - step2
    //saving
  }
}

Tak nie robimy!

O wiele ładniej i przede wszystkim bardziej zrozumiale dla kogoś, kto być może przejmie po nas kod, będzie tak:

public class FileSaver {
  public static void save(Object1 obj){
    //preparing object1
    //saving:
    save(obj, "/obj/obj1.csv");
  }
  public static void save(Object2 obj){
    //preparing object2 - step 1
    //preparing object1 - step 2
    //saving:
    save(obj, "/obj/data/obj2.txt");
  }
  private static void save(Object obj, String path){
    //saving object into given path
  }
}

Mamy trzy metody save(). Każda z nich przyjmuje inne argumenty na wejściu. W zależności od tego, jakie podamy argumenty, zostaną wykonane zupełnie inne kroki. Jednak z punktu widzenia kogoś kto będzie tworzył te zapisywane obiekty ważne jest tylko wywołanie metody FileSaver.save() i podanie jej utworzonego obiektu. W ten sposób w kodzie rozdziela się odpowiedzialność. W przyszłości może się zmienić typ zapisywanego obiektu, może się zmienić miejsce docelowe, kroki wykonane przed zapisem lub sposób obsługi błędów- ale to będzie nie istotne dla programisty przygotowującego obiekt do zapisu.

Zasady przeciążania metod w javie

W dużym skrócie- metody przeciążone znajdują się w tej samej klasie i mają taką samą nazwę, ale przyjmują różne argumenty.

Różnice te mogą polegać na:

  • Ilości przyjmowanych argumentów.
  • Typie przyjmowanych argumentów.
  • Kolejności przyjmowanych argumentów.

Przy czym, Java nie rozróżnia argumentów po nazwie, a jedynie po typie. Jeśli utworzymy metodę dziel(int liczba1, int liczba2), to kompilator nie pozwoli nam na utworzenie metody dziel(int liczba2, int liczba1).

Przejdźmy po kolei przez te warunki.

Ilość przyjmowanych argumentów

Nie ma tutaj większej filozofii, w jednej klasie dozwolony będzie taki zapis:

public void load(String file){};
public void load(String file,String encoding){};
public void load(String file,String encoding,String locale){};

Metody mają taką samą nazwę, ale każda z nich ma inną liczbę argumentów na wejściu. Jest to wystarczające rozróżnienie dla kompilatora.

Typ przyjmowanych argumentów

Java dopuszcza w jednej klasie kilka metod o tej samej nazwie i ilości argumentów, pod warunkiem, że metody te można jednoznacznie rozróżnić na podstawie typu (klasy) argumentów.

public void update(String file, String locale){};
public void update(File file, String locale){};

W powyższym przykładzie obie metody mają po dwa argumenty, w obu przypadkach drugim argumentem jest String, ale za to na pierwszym miejscu mamy do wyboru typ String lub File.

Kolejność przyjmowanych argumentów

Jeżeli argumenty różnią się typem, możemy dowolnie zmieniać ich kolejność:

public void copy(File file1, String file2){};
public void copy(String file1, File file2){};

Dla kompilatora są to dwie różne metody.

Jednak, jeśli typ argumentów jest ten sam, zamiana kolejności nie zadowoli kompilatora:

public void copy(String file2, String file1){};
public void copy(String file1, String file2){};//nie skompiluje sie

Ponadto, jeżeli dwie metody mają identyczną listę argumentów (ten sam typ, ilość i kolejność) to zmiana ich typu zwracanego nic nam nie da:

public void copy(File file1, String file2){};
public boolean copy(File file1, String file2){return true};

Nie skompiluje się! Ale dopuszczalna jest zmiana typu zwracanego, jeśli argumenty również są inne. To się skompiluje:

public void copy(File file1, String file2){};
public boolean copy(String file1, String file2){return true;};

Również zmiana samego modyfikatora dostępu nie jest uważana jako przeciążenie metody i nie skompiluje się:

public void copy(File file1, String file2){};
private void copy(File file1, String file2){}; //nie skompiluje się

Natomiast wystarczy dodatkowo zmienić jeden z argumentów i będzie to jak najbardziej poprawne:

public void copy(File file1, String file2){};
private void copy(String file1, String file2){};

Pułapki i niuanse

Zasady, które opisałem wydają się proste i oczywiste. Mimo to istnieją pewne wyjątki, kiedy teoretycznie spełniamy reguły, ale kompilator i tak protestuje.

Zapamiętaj: „To, która z przeciążonych metod zostanie wywołana, jest ustalane w trakcie kompilacji, a nie działania programu” .

Weźmy przykład z zamianą kolejności argumentów:

public static void printNumbers(int number1, double number2){
  System.out.println(number1 + " , " + number2);
}

public static void printNumbers(double number1, int number2){
  System.out.println(number1 + " : " + number2);
}

Dwie, wzorowo przeciążone metody. Jak najbardziej skompilują się. Jednak co się stanie, jeśli w kodzie umieścimy takie wywołanie:

printNumbers(10,10);

Java potrafi sobie rzutować podaną „z palca” liczbę na taki typ, jaki akurat jest potrzebny, czyli 10 może być potraktowane zarówno jako typ int jak i typ double.

Przy tym zapisie kompilator nie jest w stanie jednoznacznie określić, którą metodę ma wywołać, czy liczbę 10 ma sobie rzutować najpierw na int, potem na double, czy na odwrót?

Kompilator tego nie wie. Aby rozwiać jego wątpliwości wystarczy przykładowo takie wywołanie:

printNumbers(10,(double)10);

lub wcześniej ustawić typy wartości:

int i =10;
double j = 10;
printNumbers(i,j);

W ten sposób pozbawiamy kompilator wszelkich wątpliwości, po którą metodę ma sięgnąć.

Na kolejnym przykładzie mamy klasę LuxCarClub, a w niej metodę addMember przeciążoną tak, że przyjmuje do klubu tylko samochody marki Ferrari oraz Lamburgini.

W metodzie main tworzymy sobie dwa obiekty klasy Ferrari, która dziedziczy po klasie Car. Obiekty różnią się tym, że jeden z nich jest przypisany do referencji typu Ferrari, drugi do Car.

public class LuxCarClub {
    public void addMember(Ferrari ferrari){};
    public void addMember(Lamburgini lamburgini){};
    
    public static void main(String[] args){
        LuxCarClub club = new LuxCarClub();
        
        Ferrari ferrari1 = new Ferrari();
        Car ferrari2 = new Ferrari();
        club.addMember(ferrari1);
        club.addMember(ferrari2);//nie skompiluje się
    }

}

class Car{}
class Ferrari extends Car{}
class Lamburgini extends Car{}

Mimo, że w trakcie wykonywania programu, obiekt o nazwie ferrari2 będzie obiektem klasy Ferrari i klasa ta jest akceptowana jako argument metody addMember to program ten się NIE skompiluje. Ponieważ w trakcie kompilacji, dla kompilatora pewne jest tylko to, że ferrari2 będzie typu Car lub dowolnego dziedzica Car, ale nie ma pewności, czy nie będzie to na przykład jakiś stary Fiat, który tylko ukrywa się pod nazwą ferrari2.

Przeciążanie konstruktorów

Tak samo jak zwykłe metody, przeciążać możemy również konstruktory. Obowiązują tu te same zasady.

class Ferrari extends Car{
  public Ferrari(){}
  public Ferrari(String model){}
  private Ferrari(String model, String color){}
}
  • Lista argumentów musi być unikalna pod względem typu, ilości i kolejności.
  • Dozwolone są inne modyfikatory dostępu, pod warunkiem, że nie jest to jedyna różnica względem innych konstruktorów.

Warto tutaj przypomnieć dodatkowe reguły, którymi rządzą się konstruktory, ponieważ nie do końca są to zwykłe metody:

  • Konstruktor może wywołać inny konstruktor, ale takie wywołanie może odbyć się tylko w jego pierwszej linii i tylko raz (jeden z przeciążonych konstruktorów).
  • Wywołanie innego konstruktora jest za pomocą słowa kluczowego this(), a nie poprzez nazwę klasy/konstruktora.
  • Jeśli klasa dziedziczy po jakiejś klasie, to zawsze najpierw, niejawnie wykona się konstruktor domyślny klasy nadrzędnej.
  • Jeżeli jawnie wywołaliśmy konstruktor klasy nadrzędnej, wywołując super(), to nie możemy już wywołać this();

Przykład klasy z przeciążonymi konstruktorami:

class Ferrari extends Car{
  
  public Ferrari(){
    super();
//		this("testarossa");//nie skompiluje sie
  }
  public Ferrari(String model){
    this();
//		this("testarossa","red");//nie skompiluje się
    
  }
  private Ferrari(String model, String color){
    this("testarossa");
  }
}

W tym temacie to wszystko. W kolejnym artykule zajmiemy się rzutowaniem typów.