tifyty

pure Java, what else ?

Havi archívumok: október 2012

Commenting is evil

Mi a fontosabb: a kommentezés vagy a unit teszt?

Hülye kérdése, mert mind a kettő fontos, és az egyik nem helyettesíti a másikat. (Pedig mindig azt szoktam mondani, hogy hülye kérdés nincs, csak hülye válasz.) A unit tesztekről már beszélgettünk a múltkor, most beszélgessünk a dokumentációról, és azon belül is a kommentekről.

Java programokban általában javadoc-ot írunk. Ez a dokumentáció részévé válik és azt kell leírni a dokumentált egységről, hogy azt a program más részeiből hogyan kell használni. Mi az amit csinál, milyen paramétereket kell átadni a metódusnak, milyen metódusok vannak az osztályban, milyen osztályok vannak a csomagban, mi a visszatérési érték stb. Dokumentálunk így csomagot, osztályt, metódust, változót (kihagytam valamit?).

A javadoc-nak nem feladata, hogy azt írjuk le, hogy hogyan működik a program. Arra ott van a kód, meg a kódsorok közötti dokumentáció. Vagy nem?

Annak idején (nem írok évszámot, mert nem emlékszem pontosan, meg mert rám szóltak, hogy 198-cal kezdődő évszámot írni ilyen szövegkörnyezetben nagyképűségnek tűnik, pedig nem az, csak kor) …

Szóval annak idején Pongor tanár úr írt egy olyan PASCAL programot, ami ellenőrizte, hogy a beadott, szintén PASCAL házi feladatok legalább fele komment sor legyen. És akkor ez volt a követelmény, és nagyon meg is felelt a világképemnek.

Az első ennek ellentmondó pofon pár éve volt, amikor az Atlassian JIRA forráskódját néztem. Semmi komment. Javadoc sem. Iszonyat mennyiségű (gondoltam akkor), normális mennyiségű (gondolom ma) unit teszt, de komment semmi. A következő pofon a soapUI forráskódja volt, amelyik szintén hasonló. Mind a kettő évek óta fejlesztett sikeres termékek. Akkor pedig ez így jó. A puding próbája, hogy megeszik.

Azután egyszer csak elém került Martin Fowler cikke. Aztán teltek az évek, és megbarátkoztam a dologgal. Az persze egyből lejött, hogy hát persze, hogy igaza van, de nem olyan egyszerű átformálni az embernek a világképét. És jöttek más tapasztalatok is, amik egyre jobban elhitették velem, hogy a kódsorok közötti komment, ami leírja, hogy hogyan működik a kód az maga az ördög.

A kód legyen olyan tiszta, világos és szép, hogy elolvasva látni lehessen, hogy mit csinál. Ha kommentet akarsz írni egy kód belsejébe, ami azt magyarázza, hogy hogyan működik, akkor az a kód túl bonyolult: re kell faktorálni, szétdarabolni, leegyszerűsíteni, más változó és metódus neveket használni satöbbi. Ha kommentet írsz a kódba az lehet rosszabb, mint ha nincs ott semmi. Mert aki meg akarja érteni, hogy hogyan működik a kód, a kommentet fogja elolvasni. És ha a program nem működ, hanem igazi, akkor a debugger szeme átsiklik a hibán, mert nem a kódot nézi, hanem a kommentet.

Ennek legdurvább esete egy ilyen kód volt:

// this variable is true to ensure 
// that the connection is tested well
final boolean testConnection=false;

Fél napomba került, mire megláttam, hogy false van oda írva.

Ezért elfogadtam, hogy nem írunk magyarázó kommentet a kódba. Ehelyett egyre tisztább kódot írok. De azt, hogy a soapUI vagy a JIRA forráshoz hasonlóan javadoc se legyen, azt nem tudtam megemészteni.

Nincs felesleges teszt

Most nem lesz mintakód, és nem lesznek nepáli kecskepásztorok. Most csak arról fogok elménckedni, hogy mi is igazából a unit teszt.

Amikor Java-ról beszélünk, akkor unit teszt alatt leginkább a JUNIT teszteket értünk, amelyekkel metódusokat, osztályokat tesztelünk. Általában egy osztály egy junit teszt osztály a megfeleltetés, és minden egyes ellenőrizendő (public) metódusra legalább egy teszt metódus készül. Az osztály működése során a használt más osztályokat pedig általában mókoljuk, amire ott az EasyMock, a Mockito, vagy a PowerMock … Van sok. Az én kedvencem a Mockito, mert szimpatikus az apija. Az EasyMock esetében éreztem néhány helyen, hogy nem thread safe. Nem másztam bele, csak olyan hívási konstrukciók voltak, amelyek szaglottak valahogy. Ott sem mindenki mos lábat.

Elég sok unit tesztet írok. De csak olyankor ha csapatban dolgozom. Ha valami hobbi projekt ragad magával, akkor a legritkább esetben írok unit tesztet. Minek is írnám? A hobbi projektek jellemzője, hogy nem csapatmunka, egy ember készíti, magának, így aztán minden egyes modul pontosan illeszkedik egymáshoz, és nem kell állandóan refaktorálni a változó igények miatt, hiszen minden egy ember kezében van. (Persze ennek megfelelően ne legyen illúziónk a hobbi projektek valós és közvetlen értékével kapcsolatban sem.) Ha pedig minden egy kézben van, akkor a ‘unit’ lehet magasabb szinten, írhatok rögtön integrációs tesztet, ahol az egyes osztályok egymást és nem egymás mókjait hívják. Ettől persze ez még ugyanúgy JUNIT, és néhol zümmög benne egy-egy Mockitó is nyáresti éjszakákon.

Az ilyen integrációs tesztnek az a hátránya, hogy ha megváltozik és eltörik egy ‘A’ osztály, amelyiket használ a ‘B’, akkor szinte biztosan el fog törni a ‘B’ tesztje is, hiszen a ‘B’ a tesztek közben is használja az ‘A’-t. Ha meg mégsem törik el, akkor nem eléggé teszteli az integrációt a teszt. És ha eltört ‘A’ is, meg ‘B’ is, akkor lehet keresni, hogy ki a hunyó. Csapatmunkában több a lehetőség az egymásra mutogatásra.

A unit tesztnek pedig pont az ellenkezője a hátránya: lefut ‘A’ is, meg ‘B’ is, csak éppen már nem működnek együtt. Szóval az integrációs teszt elkerülhetetlen. Szerencsére a mókolás nem olyan egyszerű, és ezért a fejlesztők hajlamosak unit teszt helyett integrációs teszteket írni, így sok esetben a dolog automatikusan megvalósul. És ha van elég integrációs teszt, akkor a unit teszt nem is hiányzik. Feltéve, hogy elegendően jó az együttműködés a csapatban megkeresni a törött kódot legyen az ‘A’-ban, avagy ‘B’-ben. Elvileg az kellene, hogy legyen, hiszen egy agilis csapatban a kód mindenkié, nincs olyan, hogy ezt a részt te javítod, mert te írtad, azt meg én, mert azt én írtam, és letöröm a kezed ha belenyúlsz.

És ha már teszteket írunk, akkor jön a kérdés, hogy írtunk-e elegendő tesztet. A válasz persze egyszerű: nem. Ha ezt a választ adom, akkor igen jó eséllyel sikerül eltalálnom a helyes választ, hiszen hibátlan szoftver nincs, és ha előjön akár üzemelés közben egy hiba, akkor az a funkció, amelyik törött bizony nem volt eléggé tesztelve. Vagyis nem írtunk elég tesztet.

Az élet azonban nem ilyen egyszerű, mert ha ilyen egyszerű lenne, akkor most is még az első assembly programot tesztelnénk, hiszen még mindig lehet benne hiba. A kérdés az, hogy elegendő sok tesztet írtunk-e ahhoz, hogy ha még egy tesztet írok, akkor a szoftver kevesebbel több hasznot hoz, mint amennyibe a teszt elkészítése kerül. Megtérül-e a az újabb unit teszt határköltsége? Ha nem térül meg, akkor minden további teszt írás veszteséget okoz.

Az a szuper ezekben a megközelítésekben, hogy olyan egyszerűek, és átláthatóak, és annyira triviálisak. Csak éppen ki mondja meg, hogy mennyivel több hasznot hoz a szoftver. Az első évben? Az első két évben? Tíz év alatt? És mennyi üzleti eredményt jelent egy elégedett látogató a web oldalon? Vagy mennyivel kevesebbet egy frusztrált? Milyen eloszlásban? És milyen kamatlábbal kell rediszkontálni arra az időpillanatra, amikor a plusz egy teszt megírásának a költsége felmerül? Ilyenkor persze jön a sacimetria, amivel ugyanúgy bekötött szemmel lövünk célba a sötétben, de legalább nem előbb lövünk, és utána rajzoljuk a koncentrikus köröket a luk köré. Egy kicsit nagyobb az esélye, hogy nem lőjük le a nagymamát, de azért a gyerek fején az almát nem próbálnám meg eltalálni így sem. És még szóba sem jött, hogy mibe kerül a plusz egy teszt megírása…

Szóval akkor jön a jó öreg “így szoktuk”, meg a “nálunk így szokás”, és mérjük a kódlefedettséget és elérjük a 60%, 80% meg 98%-ot. Jó esetben. Sajnos az a tapasztalatom, hogy amikor azt kérdezem a mai magyar IT iparban dolgozó szakemberektől, akik jönnek hozzám interjúra, hogy írnak-e unit tesztet, akkor többnyire az a válasz, hogy “hallottam már róla, hogy van olyan”. Lefedettség? Hol élsz?

Mennyi is legyen? 60% már jó? Vagy legyen 80%? Esetleg célozzuk meg a 100%-os lefedettséget? Mit mond a cég policy? Nálatok mit mond? Van?

Amikor elkezdtem az EMMAecl eszközzel nézni a lefedettséget azt találtam, hogy a lefedettség mérése nem is azért olyan jó, mert mutatja, hogy mennyire sok tesztet írtam, sokkal inkább azért, mert a piros, sárga és zöld sorok az Eclipse-ben pillanat alatt megmutatják, hogy mely eseteket nem teszteltem le. Direkben persze a sorokat mutatják, amerre a teszt futkorászott, vagy éppen nem, de a fejlesztő abból pillanat alatt látja, hogy melyek azok a funkciók, amiket még ki kell próbálni, és melyek azok, amelyek már le vannak fedve. Végül is: a kód struktúrája és a funkcionalitás összefügg. És persze minek letesztelni a generált settereket, gettereket? Nem kell. Azaz: nem kellene.

Aztán ahogy elkezdett nőni a projekt, és néztem, hogy melyik csomag mennyire van tesztelve, és amelyikben sok getter, meg setter, meg egyéb generált kód volt és ezért alacsony volt a lefedettsége az hátrébb került, és egyszer csak azt vettem észre, hogy ezek között megbújt egy olyan csomag is, amelyiknek bizony kellett volna több teszt. És akkor lefedtem a settereket, és a gettereket is általánosan, reflection-nel, csak azért, hogy ne tudjon elbújni egy kevéssé tesztelt csomag. Ez az amikor a fapapuccsal verjük be a szöget. Csak éppen most nem volt kalapács. Persze értem, hogy nem ez lenne a jó megoldás, hanem az, ha meg tudnám jelölni, hogy mely sorokat vegyen be a lefedettség mérésbe, és a lefedettség mérés azt modaná meg, hogy hány százaléka van lefedve a lefedendő kódnak. De ilyen tool nincs. És nem csak nincs, ismereteim szerint, hanem kérdéses az is, hogy mennyire lenne használható. Megjelölni a le nem fedendő programrészeket majdnem akkora munka, mint megírni a generált kódra a generált tesztet. És a tesztnek van egy előnye, ami ha ritkán is (nekem eddig egy alkalommal) de kifizetődik.

Történt egy bús délután, hogy valami funkcionalitás belekerült egy getterbe. És el lett rontva. És persze nem az volt, hogy meg volt jelölve, hogy ezt nem kell tesztelni, mert akkor nem lett volna tesztelve, no meg mert, mint arról épp az imént volt szó nem ismerek olyan túlt ami ezt tudná. Volt rá teszt. És elhasalt. Én meg kapkodtam a fejemhez, és levontam a tanulságot, hogy nincs felesleges teszt.

Az is egy jó kérdés, hogy miért a kód lefedettséget nézzük. A unit teszt az white-box vagy black-box teszt? Ha white-box, akkor rendben van a dolog, de akkor miért nem teszteljük a privát metódusokat? Ha viszont black-box, azaz nem érdekel minket, hogy hogyan működik, akkor miért a kód lefedettséget mérjük? Miért nem a funkcionális lefedettséget nézzük? Na, ez a kérdés is meg szokta akasztani a fiatal kollégákat. A válasz pedig egyszerű: mert erre van egyszerűen használható és könnyen kezelhető eszköz. Lehet ugyan mérni a funkcionális lefedettséget, de ahhoz formálisan le kellene írni a funkciókat, és valamilyen módon azt is, hogy melyik teszt melyik funkciót teszteli. Persze vannak ilyen eszközök, de ez inkább teszt eset adminisztrációs túl, mintsem mérés.

Ezért aztán a kód lefedettséget mérjük. Nem olyan nagy baj ez. Annak idején, amikor a BME-n voltam óraadó, és az egyik hallgató panaszkodott, hogy rossz jegyet kapott, pedig tudja a tárgyat csak nem jól oldotta meg a feladatot: nem jól méri a ZH a tudást és ez nem igazságos — azt feleltem neki, hogy tudok róla. Az élet nem igazságos, és nem is szándékozom a tudását mérni, mert ugyan jó lenne, csak éppen nem vagyok rá képes. Persze arra vagyok kíváncsi, de mérni nem tudom. Helyette mérek valami mást. És reménykedem, hogy amit mérek, és amire kíváncsi vagyok, azok összefüggnek egymással. Vagy legalább korrelálnak. Sok esetben még azt sem. Ha korrelálnak, akkor már szerencsés vagyok. És ez az élet más területein is így van, kezdve a házasságtól a parlamenti választásokig. Valamit látunk, mérünk, és az alapján döntünk, aztán reménykedünk, hogy jó legyen.

A tesztelés sem más. Mérjük a lefedettséget, és azt gondoljuk, hogy ha jó a kód lefedettség, akkor jó lesz a funkcionális lefedettség is. Garancia nincs. Sok esetben jól járunk, sok esetben nem. De a kódlefedettség és a funkcionális lefedettség legalább korrelál.

Lehet olyan, hogy nem teljes a funkcionális lefedettség, de a kódlefedettség teljes. Ilyenkor kell a hiba kiderülése után új unit teszteket írni, és persze javítani a kódot (ebben a sorrendben). De az is lehet, hogy a funkcionális lefedettség teljes és a kód lefedettség mégsem éri el a 100%-ot. Ilyenkor van az, hogy felesleges kód van a programban. Vagy olyat fejlesztettünk, ami YAGNI (you are not going to need it). Ez is megért egy misét.

Szóval: nincs felesleges teszt. Írd meg a tesztjeidet, javítsd ki a bugokat, ragaszd meg a törött bildet, és aludj jól!

Többszörös öröklődés

Java nyelvben nincs többszörös öröklődés. Miért nincs? Az első válasz, ami definitív az az, hogy azért, mert a nyelv specifikációja nem engedi meg: persze ez a válasz nem elégít ki sok embert, és mint a hároméves elkezdi kérdezgetni, hogy “demiértt?”.

Innentől kezdve a válasz már csak találgatás, stílus, ízlés és hitvita kérdése. Persze azért nem engedi meg a szabvány a többszörös öröklődést, mert nem egyértelmű. Mi van akkor, ha két ősosztályban is definiálva van ugyanaz a metódus? Akkor az öröklő osztályban melyik lesz örökölve. Persze más nyelvekben erre is van megoldás, csak éppen nem biztos, hogy ez a megoldás olvasható, jól karbantartható kódot eredményez. Mert ha azt mondom, hogy például legyen az, amelyik az extends kulcsszó után az első helyen szerepel, akkor már szigorúan számítani fog a sorrend (most persze nincs, mert csak egy osztály neve lehet ott, de például az implements után lehet több interfész, és mindegy a sorrend). És ha egy osztály csak egy másik osztálytól örökölt, és valamiért felveszünk még egyet, és az más implementációt ad egy korábbi metódusra, akkor kereshetjük a hibát sokáig.

Lehetne azt is mondani, hogy ebben az esetben nem lehet ettől a két osztálytó örökölni. Ez viszont gondokat okozna a fordítónak, és a runtime környezetnek, mert nem feltétlenül ugyanazok az osztály implementációk vannak fordításkor és futtatáskor. Ezt ugyan valamennyire ellenőrzi a JVM, hogy ne legyen ellentmondás, de végig kellene alaposan gondolni, hogy milyen gondok lehetnek ebből.

Van olyan megközelítés is, ahol mind a két metódust örökli a leszármazott osztály, és annak függvényében kerül egyik, vagy másik metódus meghívásra, hogy a hívási helyen melyik apa osztály típusára cast-olom éppen a kifejezésemet.

Na ezekre mondta azt a Java, hogy NEM. Ezekkel mind nem akarunk megküzdeni, mert csak nehézségeket okoz, akármelyik megközelítést is használnánk a nyelvben mindig lenne vita, hogy miért az és miért nem a másik, sok hiba forrása lenne, hogy nem egyértelmű az öröklődés, és ami a legfontosabb: tök fölösleges az egész. (Vagy nem. Ez is lehet vita tárgya, de a vita a kisebb gond, a nagyobb a hibás kódok tömkelege.)

Elékezzünk rá, hogy amikor a Java elindult, akkor azzal kezdett megküzdenie, hogy elfogadja-e az ipar, vagy sem. A létéért küzdött a fejlesztő csapat, és nem volt mögötte olyan monopolisztikus nyomás, mint a .NET mögött, amelyik azt mondta volna, hogy erre van előre és aki nem erre jön, az mehet amerre lát, de arra út nincs. Ezért minden olyan konstrukció (például többszörös öröklődés), amelyik a hibás használatot, a több hibás kódot növelte volna kikerült a nyelvből. Nem akarták, hogy a Java a “hibásan működő program” szinonímája legyen. Ezért is lett például a StringBuffer szinkronizált, és csak nagyon sokára jelent meg a StringBuilder amelyik hatékonyabb.

No és mi van az interfészkekkel? Mi van akkor, ha két interfészket is implementál az osztályunk, és mondjuk mind a kettő deklarál ugyanolyan nevű és ugyanolyan szignatúrájú metódust? Melyiket kelteti ki, implementálja az osztályunk?

A válasz egyszerű: mind a kettőt egy metódusban, hiszen az interfész nem definiál, nem mondja meg, hogy hogyan kell működnie a metódusnak, csak deklarál. Az persze más kérdés, ha a JavaDoc és az elvárásaink ellentmondásosak, de ezzel nem tud mit kezdeni a fordító. Ebben az esetben a programozónak kell odafigyelnie, és valószínűleg refaktorálnia valamelyik interfészt: ha ugyanaz a metódus neve, de egészen mást kell csinálnia, akkor valószínűleg az a név nem jó.

És mi a helyzet akkor, ha a metódus visszatérési értéke nem kompatibilis, az egyik interfészben mást ad vissza a metódus, mint a másikban, de a neve, szignatúrája azonos. Akkor nem írhatom ki, hogy implementálja az osztályom mind a két interfészt?

És akkor itt állj meg és gondolkodj el ezen, mielőtt tovább olvasod!

A válasz már nem olyan egyszerű, de logikus, és nagyon Java. A fordító megengedi, hogy deklaráljad: implementálod mind a két interfészt. Csak éppen nem fogod tudni. Vagy az egyik, vagy a másik interfész kerül implementálásra a kódodban. Hát ez van.

Miért kell a lusta egyke

Ugye a Bill Pugh féle lazy singleton implementáció:

public class Singleton {
        // Private constructor prevents instantiation from other classes
        private Singleton() { }
 
        /**
        * SingletonHolder is loaded on the first execution of Singleton.getInstance() 
        * or the first access to SingletonHolder.INSTANCE, not before.
        */
        private static class SingletonHolder { 
                public static final Singleton INSTANCE = new Singleton();
        }
 
        public static Singleton getInstance() {
                return SingletonHolder.INSTANCE;
        }
}

(Forrás, WikiPedia.)

Ez csak akkor hozza létre a singleton instance-ot, amikor arra először szükségünk van. És persze felmerül a kérdés, hogy miért kell a belső osztállyal nyűglődni, és miért nem jó a szokásos singleton, amelyik már a getInstance() meghívása előtt, az osztály betöltődésekor elkészíti azt az egy példányt. A szokásos válasz az, hogy sok esetben a példány létrehozása költséges, és nem is biztos, hogy minden futtattásnál szükséges. Rossz válasz, vagy ha sok 9gag-et olvasok, akkor “Bich please…”.

Ha már olyan sokáig tart létrehozni, akkor nem jobb ha akár egy aszinkron szálon a szerver indításakor végigviszem a létrehozás költséges taskjait? Inkább az első olyan kérést akasszam meg, amelyik kiszolgálásához kell a singleton? Vagy komolyan el tudja valaki képzelni, hogy egy komoly szerver alkalmazás esetében van olyan singleton, amelyik hónapokig tartó futás közben a következő maintenance reboot-ig nem kell egyszer sem? Akkor azért az nagyon szaglik, valaki nem mosott lábat!

Az ilyen kérdések ott motoszkálnak az ember fejében, aztán egyszer csak szembejön a válasz, amikor nem is várná, többnyire egy Null Pointer Exception személyében.

Történt egy szép napsütéses keddi délután, hogy kellett nekem egy olyan szingleton, amelyik egy Neo4j db factory-t ad vissza. Persze a Neo4j-nek, lokális adatbázis esetén kell egy könyvtár, ahol az adatbázis van, de ez nem probléma. Ehhez egy static public setter metódust deklaráltam, és …

A unit teszt olyan NPE-t produkált, mint annak rendje és módja. Mert igaz ugyan, hogy teszt első néhány sora az volt, hogy

        final String dbPath = "./target/db/";
        SingletonDbFactory.setDbPath(dbPath);
        SingletonRepositoryFactory<User> factory = (SingletonRepositoryFactory<User>) SingletonRepositoryFactory
                .getInstance();

de ez azt is jelentette, hogy mielőtt a setDbPath meghívódik, az osztály betöltődik, és létrejön az egyetlen instance ami bizony még nincs konfigurálva. Utána be lenne konfigurálva, és vissza is adná a getInstance() a még konfigurálatlanul létrehozott instance-ot, de persze az nem jött létre, mert beakasztott neki az NPE.

Na erre kell Bill Pugh megoldása. Nem a fenti hablaty.