W drugim odcinku przyjrzyjmy się dwóm przykładom, które kryją pułapki związane z upraszczaniem kodu, skracaniem zapisu oraz chęcią pochopnego przyspieszenia procesu implementacji (i późniejszemu nadrabianiu podczas testowania).
Upraszczanie kodu niesie zwykle jeden cel: jest krótszy w zapisie. Proces ten ma jednak tę wadę, że kod wynikowy jest trudniejszy w analizie – szczególnie przez osoby trzecie. Nasuwa się zatem pytanie, jaki jest cel tak zwanej "optymalizacji" skoro w obu przypadkach bytecode jest identyczny?
Przyjrzyjmy się następującemu przykładowi, w którym programista wykorzystał
funkcję log()
, aby nie pisać tego samego kodu dwa razy. Sam
pomysł godny polecenia, ale implementacja ma jedno ale:
public class Instance { private Instance[] inst = { new Instance() {}, new Instance() {} }; public static void main(String[] args) { System.out.println(new Instance()); } private String log() { StringBuffer sb = new StringBuffer(); for (Instance i : inst) sb.append(i); return (sb.toString()); } public String toString() { return "["+log()+"]"; } }
Jakie? Co zostanie wydrukowane na wyjście, biorąc pod uwagę fakt, że
tablica inst
zawiera dwa elementy?
Przyjrzyjmy się kolejnemu kawałkowi kodu. Tym razem programista zastosował operator warunkowy, by wartość jego mogła być obliczona na etapie kompilacji. Czy poniższa klasa się skompiluje, czy tylko skomplikuje? Spójrzmy:
public class Echo { static class MyThrowable extends Throwable { public int echo() { return 2; } } static class MyException extends Exception { public int echo() { return 1; } } public static void main(String[] args) { System.out.println((true ? new MyThrowable() : new MyException()).echo()); } }
Metoda main()
próbuje stworzyć dwa obiekty i wywołać na
jednym z nich metodę echo()
. Powinien zostać zwrócony wynik
w postaci liczby 2. Okazuje się jednak, że pomimo iż obie klasy implementują
metodę echo()
, kompilator protestuje i nie potrafi skompilować
kodu właśnie z powodu tej metody. Gdzie jest źródło problemu?
Przykład pierwszy:
W metodzie log()
użyto klasy StringBuffer
,
która pozwala optymalnie dodawać łańcuchy znakowe. Niestety zastosowano
metodę append()
tejże klasy, która wywołuje metodę
toString()
obiektu przekazanego jako referencję. W konsekwencji
metoda log()
wywołuje w pętli for
metodę
toString()
, która znowu wywołuje metodę log()
.
Powstaje rekurencja nieskończona. Uruchomienie zakończy się wyjątkiem
StackOverFlow()
.
Przykład drugi:
Kompilator nie pozwoli skompilować tak napisanego kodu, gdyż operator warunkowy będzie niejawnie rzutował wynik swojej operacji na pierwszy wspólny typ, jaki znajdzie w hierarchii dziedziczenia. Wynika to z faktu, że dopiero na etapie uruchomienia wiadomo będzie, co będzie wynikiem operacji, a tym samym, jaki typ będzie zwrócony przez operator. Kompilator jest zbyt leniwy i nie policzy wyniku tej operacji (mimo, że mógłby) – decyzję pozostawi maszynie wirtualnej.
Przykładowo wspólnym przodkiem dla klasy Float
i Integer
jest klasa Number
, dla klasy String
i Serializable
interface Serializable
, ale klasa String
i Integer
nie mają wspólnego przodka, zatem rzutowanie będzie na klasę Object
.
Biorąc pod uwagę, że pierwszym, a zarazem jedynym przodkiem klas
MyThrowable
i MyException
jest klasa Object
,
ten typ będzie właśnie wynikiem operacji. I tutaj pojawia się problem –
klasa Object
nie implementuje metody echo()
.
Zatem zapis można by rozbić na dwie linijki:
System.err.println(o.echo()); Object o = true ? new MyThrowable() : new MyException();
Teraz widać dokładnie, dlaczego kompilator zgłosił błąd. Gdyby klasa
MyException
rozszerzała klasę MyThrowable
,
wtedy kompilator nie zgłosiłby błędu, a wynikiem operacji byłaby wspomniana
liczba 2.
W zaprezentowanym przykładzie w prosty sposób można wykryć błąd, gdyż zrobił to kompilator. Nadużywanie operatora warunkowego może jednak spowodować, że program nie zachowa się w sposób, który byłby od niego oczekiwany.
Source: http://www.javaexpress.pl/article/show/Express_killers_cz_II