Java class design #4

Use the instanceof operator and casting

Java zachęca nas do tak zwanego programowania dla interfejsów. W dużym skrócie chodzi o to, abyśmy tworząc referencję konkretnych obiektów, posługiwali się ogólnym typem (interfejsem), a nie konkretnym typem obiektu, który tworzymy. Kod jest wtedy bardziej uniwersalny. Z tym rozwiązaniem ściśle powiązane jest rzutowanie typów, które opiszę w tym artykule.


Na skróty:

Rzutowanie jawne i niejawne [Opis]
Operator instanceof [Opis]

Repozytorium z przykładami [GitHub]


Wstęp

Załóżmy, że w naszej aplikacji występuje wiele pojazdów różnego typu. W myśl dobrych praktyk i zasad projektowania obiektowego, hierarchię tych pojazdów przedstawimy jako „rodzinę” interfejsów, obiektów i ich dziedziców. Przykładowo Ferrari jest samochodem, samochód jest pojazdem. WSK 175 jest motocyklem, motocykl jest pojazdem, ale nie jest samochodem.

Odpowiednio, każdy pojazd możemy zatankować, w każdym samochodzie możemy otworzyć drzwi, ale nie każdym samochodem możemy szpanować tak jak Ferrari.

Zatem w takiej sytuacji:

public class Main {
  public static void main(String[] args) {	
    Vehicle vehicle = new Ferrari();
    showOf(vehicle); //nie skompiluje się
  }
  
  public static void showOf(Ferrari ferrari){
    System.out.println("Look at my car loosers!!!");
  }
}

interface Vehicle {}
class Car implements Vehicle {}
class Ferrari extends Car {}

Jak powiedzieć kompilatorowi, że mamy Ferrari i mamy pełne prawo do szpanu, skoro kompilator wie jedynie, że mamy JAKIŚ pojazd?

Rzutowanie jawne i niejawne

W Javie występuje rzutowanie jawne (ang. explicit) oraz niejawne (ang. implicit). Z tym drugim, mamy do czynienia dość często i jak sama nazwa wskazuje raczej tego nie widzimy.

Vehicle vehicle = new Car();

Tutaj mamy referencję typu Vehicle, oraz obiekt typu Car. Możemy bez problemu i bez żadnych dodatkowych zabiegów przypisać obiekt typu Car, do referencji Vehicle, ponieważ kompilator wie, że Car implementuje interfejs Vehicle, samochód (Car) zawsze będzie pojazdem (Vehicle), nie ma od tego wyjątków, dlatego kompilator potrafi sobie niejawnie, w tle wytłumaczyć, że do samochodu można się odwoływać jak do pojazdu.

Problem (dla kompilatora) pojawia się kiedy mamy sytuację odwrotną.

Vehicle vehicle = new Ferrari();
showOf(vehicle); //nie skompiluje się

Wcześniej, przypisaliśmy obiekt typu Ferrari do referencji typu Vehicle. W tym momencie MY wiemy, że pojazdem jest samochód marki Ferrari, ale kompilator wie tylko, że ma do czynienia z pojazdem, dla niego może to być równie dobrze motocykl. Dlatego kompilator wyraźnie zaprotestuje, jeśli spróbujemy przekazać referencję vehicle, do metody showOf(), która przyjmuje jako argument tylko i wyłącznie Ferrari. Ale jak to? Panie kompilatorze, przecież tam w tej zmiennej, pod tą referencją jest najprawdziwsze Ferrari, nie możesz protestować! A no może… Ale my też nie jesteśmy bezsilni w tej sytuacji.

Możemy powiedzieć kompilatorowi, że tam na prawdę jest samochód marki Ferrari, a jeśli się pomylimy to bierzemy to na siebie:

Vehicle vehicle = new Ferrari();
Ferrari ferrari = (Ferrari) vehicle; //rzutujemy pojazd na Ferrari
showOf(ferrari);

Teraz w trakcie kompilacji wszystko będzie ok, kompilator uwierzy nam, że pojazd, który przekazujemy do metody showOf() to w trakcie pracy programu na prawdę będzie Ferrari. I w tym przykładzie faktycznie tak będzie, nie ma innej możliwości. Jednak w większej aplikacji, może się okazać, że się przeliczyliśmy, że gdzieś wcześniej coś się zamieszało i teraz w referencji vehicle tak naprawdę znajduje się obiekt reprezentujący motocykl, mimo, że obiecaliśmy, że będzie to samochód. W takim wypadku zostaniemy potraktowani wyjątkiem ClassCastException. Jest to o tyle niebezpieczna sytuacja, że jest to tzw. unchecked exception. Czyli kompilator nie wymaga od nas odgórnego zabezpieczenia się przed nim, na przykład blokiem try catch. Jeśli nie przewidzieliśmy i nie przeciwdziałaliśmy takiej ewentualności, aplikacja się nam po prostu wyłoży w trakcie działania i już sama nie wstanie.

Wyjątek ClassCastException może być rzucony tylko przy jawnym rzutowaniu. Przy nie jawnym nie ma takiej możliwości, kompilator rzutuje niejawnie, tylko jeśli jest pewien sukcesu.

Przy rzutowaniu jawnym jest jeszcze jeden niuans.

Każdą klasę, która nie jest final, możemy rzutować na dowolny interfejs, nawet jeśli klasa ta faktycznie nie implementuje tego interfejsu. Zasada ta nie dotyczy rzutowania na inną klasę, w takim przypadku, ta klasa na prawdę musi być widoczna w drzewie dziedziczenia.

Czyli, bazując na poprzednich przykładach, gdybyśmy mieli interfejs MilitaryVehicle to moglibyśmy rzutować na niego obiekt typu Car. Kompilator by nie zaprotestował mimo, że Car implementuje tylko interfejs Vehicle i żaden inny. Oczywiście, już w trakcie pracy aplikacji dostalibyśmy ClassCastException. Mimo to, dlaczego kompilator nas nie opluł błędem? Ponieważ jest to celowe, małe oszustwo wprowadzone z myślą o dziedzicach klasy Car. Sama klasa Car nie implementuje interfejsu MilitatyVehicle, ale możemy utworzyć klasę MilitaryPickup:

public class Main {

  public static void main(String[] args) {
    Car normalCar = new Car();
    MilitaryVehicle militaryVehicle = (MilitaryVehicle) normalCar;//rzuci wyjątkiem przy uruchomieniu
    MilitaryVehicle pickup = new MilitaryPickup();
  }
  
  public static void showOf(Ferrari ferrari){
    System.out.println("Look at my car loosers!!!");
  }
}

interface Vehicle {}
class Car implements Vehicle {}
class Ferrari extends Car {}
interface MilitaryVehicle {}
class MilitaryPickup extends Car implements MilitaryVehicle{}

MilitaryPickup rozszerza klasę Car oraz implementuje interfejs MilitaryVehicle. Dzięki wspomnianemu oszustwu możemy odwoływać się do klasy MilitaryPickup poprzez referencję typu MilitaryVehicle, mimo, że klasa Car nie implementuje tego interfejsu.

Możemy też spotkać się z potrzebą wywołania metody, która jest dostępna w konkretnej klasie, do której odwołujemy się przez bardziej ogólną referencję:

public class Main {
  public static void main(String[] args) {
    Vehicle vehicle = new Ferrari();
    vehicle.showOfWithFerrari(); //nie skompiluje sie
    ((Ferrari) vehicle).showOfWithFerrari();
    (Ferrari) vehicle.showOfWithFerrari();// nie skompiluje sie
  }
}

interface Vehicle {}
class Car implements Vehicle {}
class Ferrari extends Car {
  public void showOfWithFerrari(){
    System.out.println("Look at my awfuly expensive Ferrari!");
  }
}

W powyższym przykładzie mamy obiekt klasy Ferrari przypisany do referencji typu Vehicle. Niestety metoda showOfWithFerrari() dostępna jest tylko dla klasy Ferrari, zatem próbując odwołać się do niej z poziomu klasy Vehicle zostaniemy okrzyczani, że w Vehicle nie ma takiej metody. Rozwiązaniem jest oczywiście rzutowanie.

Kluczowe jest tutaj tylko zrozumienie, dlaczego tak, a nie inaczej zastosowaliśmy nawiasy. Ponieważ operator kropki ma w Javie większy priorytet niż operator nawiasu, musimy właśnie poprzez dodatkowe nawiasy powiedzieć, że najpierw ma się wykonać rzutowanie, a potem wywołanie metody. Inaczej, gdybyśmy zapisali to tak:

(Ferrari) vehicle.showOfWithFerrari();// nie skompiluje sie

To Java próbowałaby najpierw wywołać metodę, a później, to co zostanie zwrócone- rzutować na klasę Car. Co oczywiście w tym przypadku nie miałoby absolutnie żadnego sensu.

Na koniec jeszcze drobna kwestia rzutowania null.

Możemy rzutować null na dowolny typ. Nie zostanie to potraktowane jako błąd, ani podczas kompilacji, ani w trakcie wykonywania programu.

vehicle = (Ferrari) null; //absolutnie nic się nie stanie, po za tym, że referencja vehicle nie będzie już wskazywała na żaden obiekt.

Podsumowując:

  • Obiekt może być jawnie rzutowany na dowolną klasę istniejącą w drzewie dziedziczenia tego obiektu.
  • Obiekt klasy, która nie jest final może być jawnie rzutowany na dowolny interfejs, nawet jeśli go nie implementuje.
  • Jeśli w trakcie pracy programu okaże się, że w próbowaliśmy na daną klasę rzutować obiekt, który nie jest obiektem tej klasy, dostaniemy wyjątek ClassCastException.
  • Jeśli w trakcie pracy programu okaże się, że w próbowaliśmy na daną klasę rzutować interfejs, który w rzeczywistości nie implementuje, dostaniemy wyjątek ClassCastException.
  • ClassCastException jest wyjątkiem typu unchecked, kompilator nie ostrzeże nas, że taki wyjątek może się pojawić w tym miejscu. Sami musimy pamiętać o nim i najlepiej w ogóle nie dopuścić do powstania tego wyjątku.

Operator instanceof

Ponieważ wspomniany już wcześniej wyjątek ClassCastException jest wyjątkiem typu unchecked exception, to znaczy, że jego wystąpienie raczej nie traktujemy jako dopuszczalny błąd, po którym możemy bez problemu się pozbierać. W końcu jeśli spodziewamy się mieć do dyspozycji pojazd typu samochód, a dostajemy pojazd typu hulajnoga, to nasze plany wyjazdu na wakacje na drugi koniec kraju delikatnie się komplikują, prawda? 🙂

Dlatego Java udostępnia nam operator, za pomocą którego możemy sprawdzić czy pod tą niekiedy dość ogólną referencją kryje się konkretny pożądany przez nas obiekt.

Vehicle vehicle = new Car();
if(vehicle instanceof Ferrari){
  System.out.println("Fajne Ferrari!");
}else{
  System.out.println("Co to jest?");
}

Warto pamiętać, że

Operator instanceof jest słowem kluczowym Javy i jak wszystko słowa kluczowe pisany jest w całości małymi literami, a nie camel case.

Ponadto, instanceof zwraca tylko true lub false.

Nigdy nie wyrzuci żadnego wyjątku, nawet ClassCastException, ponieważ nie skompiluje się, jeśli spróbujemy użyć go dla dwóch niespokrewnionych typów.

MilitaryVehicle pickup2 = new MilitaryPickup();
if(pickup2 instanceof Integer){  //nie skompiluje sie
  System.out.println("to cud!");
}

W tym temacie to wszystko, w kolejnym artykule będziemy kontynuować podstawy projektowania klas w Javie.