Rég írtam, valóban. Hogy újra rávezessem magam a kedvenc munkán kívüli elfoglaltságomhoz, most egy egyszerűbb témát vetek fel, ami mégis okozott pár kobak-koppanást az asztalon. Nevezetesen arról szándékozom írni, hogy a Java hogyan sorrendezi egy objektum inicializálásában résztvevő kódokat. Mert bizony több lehet, és nem egyértelmű a lefutási sorrend, ahogy azt a követkető kódrészlet szemlélteti:
public class AB{
public static abstract class A{
public A(){
doSomething();
}
protected abstract void doSomething();
}
public static class B extends A{
final String s = "1234";
final Object o = "1234";
public B(){
super();
}
@Override
protected void doSomething(){
System.out.println(s.getClass());
System.out.println(o.getClass());
}
}
public static void main(String[] args){
new B();
}
}
A példát igyekeztem egyszerűnek tartani, de talán megérdemel egy rövid magyarázatot: Két osztályt definiáltam, az egyik leszármazottja a másiknak. Érdekesség még, hogy az ős konstruktora kódot hív meg a leszármazottból absztrakt metóduson keresztűl. Egy “B” típusú objektum létrehozásához láthatóan három helyről kell kódot hívni: Az “A” és a “B” konstruktora, továbbá a “B” osztályban lévő, konstruktoron kívüli inicializálás. A probléma ezen három kódrészlet lefutásának sorrendje.
Első ránézésre a kóddal semmi probléma nincs azon kívül, hogy teljesen haszontalan. Mindkét mező final, így gondolnánk a konstruktor elött inicializálódnak, tehát nem okozhat problémát. Azonban a kód futtatásakor egy csinos NullPointerException kacsint vissza ránk. A futás kimenete:
class java.lang.String
Exception in thread "main" java.lang.NullPointerException
at AB$B.doSomething(AB.java:22)
at AB$A.
at AB$B.
at AB.main(AB.java:27)
Láthatóan a B.doSomething() első sora lefut hiba nélkül, a probléma utána keletkezik. Tehát az “s” változó már létezik, az “o” viszont nem. A konstruktorok lefutásának sorrendje tehát: s=..,A(),o=..,B(). A két mező között csupán az a különbség, hogy az “s” mező típusa megegyezik a futásidejű értékének típusával. Ez meghatározza, hogy a mező a szülő osztály konstruktora előtt vagy után kap értéket. Érdemes kipróbálni, ha az “s” mező elöl kivesszük a final kulcsszót, az azt okozza, hogy az is a szülő konstruktor hívása után kap értéket. Vajon hogy határozódik meg a pontos sorrend, mitől függ, hogy hova ütemezi be a java a mezők értékadását?
Nyílván valahogy igyekszik meghatározni, hogy az adott értékadás kiértékelhető-e az osztály örökölt részének inicializálása nélkül vagy sem. Ennek tesztelésére tettem még egy kísérletet, kicsit módosítva a példát:
public class AB{
public static abstract class A{
protected String a;
public A(){
a = "abc";
doSomething();
}
protected abstract void doSomething();
}
public static class B extends A{
final String s = "1234"+a;
final Object o = "1234";
public B(){
super();
}
@Override
protected void doSomething(){
System.out.println(s.getClass());
System.out.println(o.getClass());
}
}
public static void main(String[] args){
new B();
}
}
Ez a kód is szépen elszáll, méghozzá a B.doSomething() első sorában! Azaz, az explicit hivatkozás egy szülő objektum-beli elemre azt eredményezi, hogy a mező értékadását a szülő konstruktor utánra ütemezi. Elég intelligensnek tűnik a dolog, de mégis beleütközik az ember, mert másra számít.
Persze elkerülhető a dolog ha a konstruktorból hívott absztrakt metódusokat anti-pattern-nek kiáltjuk ki, bár gyakran jól jön. Mégis pontosan mi határozza meg a sorrendet, és lehet-e befolyásolni valahogyan? Van valaki aki nálam sikeresebben guglizott?
A második példád elvileg is lehetetlen, hogy lefusson – kivéve, ha a Java megszakítaná a konstruktor futását.
Ugyanis az s változó értékét nem tudja kiszámolni, amíg az ősosztály konstruktora nem futott le (abban kap értéket), míg az absztrakt hívás használná (ami az A konstruktorában kap helyet).
Az első példádnál meg szerintem az történik, hogy az s változó értékét behelyettesíti a konstans “1234” stringgel, és azt helyettesíti be minden használatnál – efféle optimalizációról konkrétan hallottam.
Mindenesetre követtem debuggerrel a program futását, és úgy egy módosított lefutási sorrenddel találkoztam (kikapcsolva az absztrakt hívást):
belépés B konstruktorába -> super hívás -> belépés A konstruktorába -> a lokális változóinak inicializálása (felvettem) -> A konstruktorának lefutása -> B lokális változóinak inicializálása -> B konstruktorának futtatása
Ezt én úgy értelmezem, hogy ez a normál futtatási sorrend (annak tűnik számomra), és az, hogy az első példádnál mégsem az első, hanem csak a második hívásra szállt el, az az én szememben compiler optimalizációnak néz ki…
Valóban, ez megmagyarázza a jelenséget. Őszintén, eszembe se jutott, hogy a ritka esetek, mikor a dolog működik az csak a compiler optimalizáció mellékhatása.
Utólag villant be a kérdés: miért van szükség erre az absztrakt metódusra?
Tudjuk, hogy egyszerűen az ősosztály konstruktora után meghívódik a leszármazotté, és akkor lehet kezelni a megfelelően specifikus dolgokat.
Vagy ha tényleg roppantmód indokolt az absztrakt metódus, akkor pedig gondoskodni kell róla, hogy a metódus inicializálja a használt dolgait…
nos, mondhatjuk, hogy roppantmód indokolt a dolog, illetve így egyszerűbb. A meghívott metódus visszatérési értéke kell az ősosztály konstruktorának.
Igen, odafigyeléssel elkerülhető a dolog, de megtévesztő a szintaxis.