Tiszta kód – 3.rész: függvénystruktúra

Bevezető

Ebben a részben tovább vizsgáljuk, hogy mitől lesz egy függvény “tiszta”. Hány paramétere lehet egy függvénynek? Milyen paraméterei lehetnek egy függvénynek? Hogyan rendezzük a függvényeinket az osztályokon belül? Mi a probléma a switch szerkezetekkel? Milyen problémákat vet fel az értékadás? Milyen nehézséget okozhatnak a brake és a korai return utasítások. Többek között ezekkel a témákkal foglalkozunk ebben a részben.

fractal1

Függvényparaméter

Paraméterek száma

Az általános szabály a függvény paramétereinek számára, hogy minél kevesebb, annál jobb. Egy függvénynek ne legyen háromnál több paramétere! Ennek két praktikus oka is van.

  • Minél több van, annál nehezebb rájönni, hogy mire is valók.
  • Minél több van, annál nehezebb fejben tartani a helyes sorrendjüket.

A sok paraméterű függvények gyakran csoportosan fordulnak elő, és a paramétereik is azonosak. Ilyenkor érdemes megfontolni, hogy egy struktúrába csomagoljuk a paramétereket, hiszen annyira összetartoznak, hogy meg is érdemlik. A másik lehetőség, hogy egy osztály lapul a függvények között, és az átadott paraméterek pedig az osztály adattagjai. Ilyenkor pedig a függvények közvetlenül elérhetik, nincs is szükség paraméterátadásra.

A “maximum három paraméter” szabály érvényes a konstruktorra is. Ha három paraméter nem elég, érdemes elgondolkozni a Builder tervezési minta használatán.

Paraméterek típusa

Ne adjunk át bool paramétert!

A bool paraméter általában azt jelzi, hogy az adott függvény két dolgot csinál. Az egyiket az igaz esetre, a másikat a hamisra. Ezért gyakran az a legjobb megoldás, hogy két függvényt írunk, egyiket az igaz értékre, a másikat a hamisra.
Vegyünk egy példát!

saveEventId(id,true);

A fenti kódnál vajon mit jelent a true? Nehéz kideríteni csak ránézésre. Ha utánamegyünk, megtaláljuk a kódban, hogy igaz ágon az eseményazonosítót nem csak egy listába, hanem például egy statisztikai modulba is elmentjük. Ez a mellékhatás nem derül ki a függvény nevéből sem.

Sokszor segít az olvashatóságban az enumok használata. (Az enumok problémáiról később.) Nézzük a következő példát.

String status = module.getStatus(true);

Vajon mit jelenthet az a true paraméter? A modul a státuszát egy JSON struktúrába ágyazva adja vissza. Igaz ágon ez a JSON formázott, hamis ágon pedig kompakt, behúzások nélküli.
Valamivel jobb így:

String status = module.getStatus(INDENTED);

De a probléma a régi, igazából a függvényen belül ugyanaz az elágazás van, és ugyanúgy két dolgot csinál. Talán így a legjobb:

String status = module.getIndentedStatus(); // getCompactStatus();

Van olyan helyzet, amikor elfogadhatjuk a bool paramétereket, de itt is érdemes figyelni. Az alábbi kódot nehéz szeparálni két függvényre úgy, hogy ne legyen számottevő kódismétlés bennük.

public Booking book (Customer aCustomer, boolean isPremium) {
    lorem().ipsum();
    dolor();
    if(isPremium)
        sitAmet();
    consectetur();
    if(isPremium)
        adipiscing().elit();
    else {
        aenean();
        vitaeTortor().mauris();
    }
}

Ilyen esetben érdemes priváttá tenni ezt a függvényt és két publikus függvényt írni, melyek ezt hívják megfelelő paraméterekkel. Így:

public Booking regularBook(Customer aCustomer) {
    return hiddenBookImpl(aCustomer, false);
}

public Booking premiumBook(Customer aCustomer) {
    return hiddenBookImpl(aCustomer, true);
}
private Booking hiddenBookImpl(Customer aCustomer,  boolean isPremium)
{
    ...
}

bool beállítók

// Általában jobb ez a megoldás
void setOn();
void setOff();

// De ha inkább ilyen kontextusban használnánk:
if (aValue)
    setOn();
else
    setOff();

// Akkor praktikusabb így 
setSwitch(aValue);

Kimeneti paraméterek

Ne használjunk kimeneti paramétereket. A kód olvasója nem számít arra, hogy információ jön kifelé egy függvény paraméterén keresztül. Használjuk inkább a visszatérési értéket erre a célra. Ha több értéket kell visszaadni, használjunk struktúrát.

Null átadása

A természete hasonló a bool átadásához. Két dolgot fog a függvény csinálni. Az egyiket a null esetre, a másikat pedig a nem null esetre. Ehelyett inkább két függvény kéne. A nem nullos verzió, és egy olyan ami nem is használja a paramétert.

Ráadásul defenzív programozásra sarkall. A kódunkban rengeteg trycatch és null ellenőrzésre lesz szükség.

Megjegyzés: Publikus API esetén szükséges a kódot null paraméterekre is felkészíteni.

Osztályon belüli rendezés

Hasznos, ha a kódunk, függvényeink szerkesztésénél hasonlóan járunk el, mint egy újságíró. Egy cikk felépítése általában a következő három részből áll:

  • cím
  • bevezető
  • részletek

A osztályainkat érdemes így strukturálni:

  • név
  • publikus függvények
  • privát függvények, változók

Mind a két esetben az olvasó jár jól. Addig olvassa, amíg érdekli.
Érdemes megfontolni a “lépcső szabályt” is. Ez azt mondja ki, hogy a függvényhívások iránya mindig lefele kell mutasson, vagyis a kódban egy lentebb lévő függvény ne hívjon olyat, ami felette van.

Megjegyzés: Java-ban a konvenció az, hogy az adattagok egy osztály kódjában a legfelső elemek. Ettől a szokástól való eltérés zavaró lehet.

Mi a baj a switch-el?

Ha A modul használja B-t, akkor, ha B-n változtatunk A-t is újra kell fordítani (telepíteni). Ez azért van, mert a forráskód függési iránya megegyezik a vezérlés irányával. A switch-el pont ez a probléma. Gyakorlatilag minden ágától függésben van. Ráadásul ha előfordulnak, nagyon sokszor ugyanaz a blokk többször is szerepelhet, ez pedig kódismétlést jelent.
Ezért a switch nem mondható OO megoldásnak. (Hasonló a helyzet többszörös elágazásokkal is.) Mégis mit lehet tenni?

  • Tegyük olyan helyre, ahol nem zavaró, például egy gyárba (Factory).
  • Használjunk polimorfizmust helyette, például így:
class Bird {
  //...
  double getSpeed() {
    switch (type) {
      case EUROPEAN:
        return getBaseSpeed();
      case AFRICAN:
        return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
      case NORWEGIAN_BLUE:
        return (isNailed) ? 0 : getBaseSpeed(voltage);
    }
    throw new RuntimeException("Should be unreachable");
  }
}

Polimorfizmust használva így néz ki.

abstract class Bird {
  //...
  abstract double getSpeed();
}

class European extends Bird {
  double getSpeed() {
    return getBaseSpeed();
  }
}
class African extends Bird {
  double getSpeed() {
    return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
  }
}
class NorwegianBlue extends Bird {
  double getSpeed() {
    return (isNailed) ? 0 : getBaseSpeed(voltage);
  }
}

// Valahol a kliens kódjában...
speed = bird.getSpeed();

Ez a megoldás azért is jó, mert innentől kezdve a különböző “madarakat” külön lehet fordítani, telepíteni és nem utolsó sorban fejleszteni. A kliens kódja sincs függésben, csak az őstől, így őt sem kell újrafordítanunk. Így hozható létre plug-in architektúra.

Értékadás problémája

Nézzük a következő kódot. Mit fog kiírni?

int r1 = a.getResult();
int r2 = a.getResult();
if(r1 == r2)
    System.out.println("1");
else
    System.out.println("2");

Szeretnénk hinni, hogy mindenképpen “1”-et. De nem lehetünk biztosak. Ha például a getResult() függvény változtat valamit a program állapotán, előfordulhat, hogy “2”-t fog kiírni a program. Egy nem túl értelmes, de könnyen érthető implementáció ami más eredményt ad, a következő:

public int getResult()
{
    return ++count; // tagváltozó.
}

A probléma az értékadásból és az abból fakadó mellékhatásokból adódik. (Funkcionális nyelveknél ilyen probléma nincsen.)
Mégis kimondhatjuk, hogy szívesebben dolgoznánk olyan kódbázisban, ahol a két hívás ugyanazzal tér vissza.

Mellékhatások

Gyakran párban találhatók ilyen függvények. Például:

  • getset
  • openclose
  • newdelete

Ezekben az is a veszélyes, hogy helyes sorrendben kell őket meghívni. Ez az ún. időbeli csatolás (temporal coupling). A fenti példák még könnyen érthetőek, de vannak nehezebb esetek is. A problémára megoldás lehet bizonyos esetekben a blokk átadása, vagy a parancs (Command) minta alkalmazása. Ilyenkor azoknak a hívásoknak a sorrendjét rögzíteni tudjuk, ahol ez a sorrend kritikus. Egyszerű példa az alábbi:

public void open(File f, FileCommand c){
    f.open();
    c.process(f);
    f.close();
}

open(myFile, new FileCommand() {
    public process(File f) {
        //... Fájl feldolgozása itt.
    }
});

Parancs és lekérdezés szétválasztása (Command Query Separation (CQS))

Ennek a szabálynak a követése leveheti a terhelés egy részét az agyunkról. Röviden így szól:

  • Azok a függvények, amelyek állapotot változtatnak, ne adjanak vissza semmit.
  • Azok a függvények, amelyek visszatérnek valamivel, ne változtassanak a rendszer állapotán.
void setName(string name); // van mellékhatás
int getResult(); // nincs mellékhatás

Egy általános példa a fenti szabályok megszegésére a következő:

User u = authorizer.login(username, password);

Miért kaptuk vissza a felhasználót?

  • Valamikor nekünk kell logout-ot hívni?
  • Vagy ellenőriznünk kell, hogy sikerült-e a bejelentkezés?

Inkább dobjunk kivételt! A kód így sokkal egyértelműbb:

User u = authorizer.getUser(username);

Utasíts, ne kérdezgess! (Tell don’t ask!)

Objektumainknak parancsot adjunk! Ha olyat látunk, hogy egy objektum állapotára kérdezünk, majd ennek függvényében parancsot adunk neki, az valószínűleg hiba. Az objektum ismeri a saját állapotát és meg tudja hozni a szükséges döntést, köszöni szépen.

Demeter törvénye

Nem jó, ha egy függvény a rendszer egészét vagy legalábbis nagy részét ismeri.

o.getX().getY().getZ().doSomething();

Ezzel csak az egyik probléma az, hogy a programozónak tudnia kell, hogy “o”-nak van “x”-e, “x”-nek van “y”-ja, “y”-nak “z”-je és arra hívható a doSomething() függvény. A másik gond, hogy ha például “y”-on változtatunk, az hatással lehet erre kódrészre is. Azon objektum függvényeit hívhatjuk meg, melyeket:

  • paraméterül kapunk
  • lokálisan hoztuk létre
  • példányváltozók
  • globálisok

Viszont másik függvény visszatérési értékeként kapott objektumokra nem tanácsos függvényt hívni. Persze itt is kivétel erősíti a szabályt

break & korai return

Összetett függvények megértését nagyon nehézkessé tehetik, hiszen sértik a strukturált programozás elvét, miszerint egy blokknak egy be- és egy kimenete lehet. Ilyen esetben felértékelődik a rövid függvények szabálya.
Ugyanakkor meg kell jegyezni, hogy nagyon jó szolgálatot tehetnek, ha nehezen megfejthető beágyazott elágazásokat szeretnénk tisztogatni. Például:

void someFunction()
{
	boolean test1 = ...;
	if (test1) {
		boolean test2 = ...;
		if(test2) {
			boolean test3 = ...;
			if(test3) {
				// csináld!
			} else {
				// valami hiba
			}
		} else {
			// valami más
		}
	} else {
		// megint más
	}
}

void someFunction()
{
	boolean test1 = ...;
	if(!test1) {
		// megint más
		return;
	}
	boolean test2 = ...;
	if(!test2) {
		// valami más
		return;
	}
	boolean test3 = ...;
	if(!test3) {
		// valami hiba
		return;
	}
	// csináld!
}

Források

Flag argumentumok
Switch és polimorfizmus
Beágyazott elágazások refaktorálása
Clean Code — A Handbook of Agile Software Craftsmanship — Martin, Robert C. Prentice Hall, 2009
Clean Code – Function Structure

Tiszta kód – 3.rész: függvénystruktúra

Tiszta kód – 2. rész: függvények

Bevezető

Minden valamirevaló kódsor végső soron egy függvényben köt ki. Valójában ez a kódunk rendezésének első szintje. Ezért nagyon fontos néhány szempontot figyelembe venni.
Mitől lesz tiszta egy függvény? Ez a poszt erről fog szólni.

Első szabály

A függvények legyenek rövidek!

Második szabály

Még annál is rövidebbek!
A nyolcvanas években a kimondatlan szabály az volt, hogy : “Férjen el egy képernyőre.”
Ez manapság már nem helytálló. Akkoriban ugyanis egy képernyő még csak 20 sort tudott megjeleníteni. Manapság ha megdöntjük a képernyőt és kellően kicsire állítjuk a betűméretet, akár 100 sor is elfér a képernyőn.
Mit mondanak a profik?

  • Uncle Bob: “A 4-5 soros függvények még rendben vannak. A 10 soros már nincsen.”
  • Bjarne Stroustrup: „A ’nem fér a képernyőre’ egy elég gyakorlatias megfogalmazása annak, hogy túl hosszú a függvény. Az 1-5 soros függvényeket tekinthetjük normálisnak.”

Sokan gondolhatjátok, hogy ez túlzás, de tény, hogy a nagyon rövid függvényeknek vannak előnyei. Hogy mik ezek?

  • Gyakorlatilag nincs behúzás, ezért a kód olvasásakor kisebb “vermet” kell felépíteni agyunkban, ezért könnyebben átlátjuk a kódot.
  • Egy for vagy while ciklus magjában gyakorlatilag csak 1 sort enged meg a szabály, ezért ha annak az egysoros függvénynek, ami a ciklus magjában van jó nevet adunk, könnyű átlátni, hogy mit is csinál a ciklus.
  • Ha könnyen érthető a logika, akkor a hibák is nehezebben bújnak el a sorok között.
  • Nem kell a kódban görgetni, a navigációban az IDE segít. (Általában Ctrl katt.)

Saját kódjaimban igyekszem tartani az 5-6 soros szabályt, de én nem vagyok annyira radikális, hogy a kapcsos zárójeleket is beleszámítsam.

Miért szeret(jük/tük) a hosszú függvényeket?

A programozók nagy része férfi. Mi férfiak pedig igen csak büszkék vagyunk a tájékozódó képességünkre.
A nagy függvényeket ha megdöntjük, nagyon hasonlítanak egy hegyekkel, völgyekkel szabdalt terepre. Nekünk férfiaknak pedig arra van kitalálva az agyunk, hogy terepen tájékozódjunk. Az alábbi képen az Alpok hegységet és egy kódrészletet láthatunk. (A hasonlóság nem a véletlen műve.)

alpok_kod
Továbbá a kódunkat ismerjük. Tudjuk, ha változtatni kell rajta, azt adott esetben a második legnagyobb hegy utáni kis dombon kell megtenni. Tudjuk, hogy egy adott hibajegy a harmadik nagy while ciklusunk magjának végén kerül kiírásra.
Kényelmesen átlátjuk, hiszen a kód fejlődése és írása közben memorizáljuk a kód szerkezetét is.

Mi a probléma mégis?

A probléma akkor kerül felszínre, amikor nem ismerjük a kód szerkezetét. Talán olyan régen írtuk, hogy már nem emlékszünk, vagy mások levetett hegyvonulatait örököltük meg.
Ilyenkor gyakran hosszas fejvakargatás után az egész függvényt nulláról újraírjuk. A problémák nagyon gyakran tehát nem is fejlesztési időben, hanem a karbantartás során jönnek elő.

A hosszú függvények kórtörténete

Minden függvény rövidnek indul. Általában az a mentalitás duzzasztja, hogy a fejlődése során mindig csak egy – két sort adunk hozzá, azért pedig “nem érdemes új függvényt írni”.
Jellemzően tehát megírjuk azt a pár sort, és ellátjuk egy megjegyzéssel.

Produktivitás problémája

Egy új csapattársnak igen nehéz egy hosszú függvényben tájékozódni. Ha még megjegyzések sincsenek, majdnem lehetetlen a küldetése. Mindenesetre sok időre van szüksége ahhoz, hogy produktívvá tudjon válni. Ellenben ha mindenhol jól elnevezett függvényekkel találkozik, az olyan, mintha jelzőtáblákkal találkozna út közben. Hamar rájön, hogy a kódnak melyik része érdekes a feladata kapcsán, és hamar produktívvá tud válni. Csapatmunkánál tehát sokszorosan felértékelődik a rövid függvények szabálya.

Gyakori ellenvetések

Elveszünk a sok apró függvényben.

Ha jó neveket választunk,inkább segítenek a tájékozódásban és nem gátolnak. Beszélni fogunk arról is, hogy a fájlok és osztályok méretét is szabályoznunk kell, ez is segíteni fog.

Illusztrálva a jelenséget, attól tartunk, hogy az első ábrán látható információ rengetegben találjuk magunkat. Egy hosszú függvényben azonban csak azt látjuk, hogy fentről indul és lefelé tart. Létezik azonban arany középút is.

A függvény hívások lassítják a programot.

Igen. A 70-es években ez a plusz idő mikroszekundumokban volt mérhető, manapság nanoszekundumos nagyságrendben van. Beágyazott rendszerek esetén ugyan lehet kritikus, de a legtöbb alkalmazás esetén nem helytálló ez az érv. Ráadásul a mai fordítók olyan jók, hogy lehet, hogy ki is optimalizálják a kódból a problémás részt.
A kódot tehát érdemes inkább olvashatóságra optimalizálni. (Beágyazott rendszereknél pedig a főciklusban lehet in-line-olni.)

Sok időbe telik és nehéz így kódolni

Egy függvény kiemelése kíván némi időt, de a mai fejlesztő eszközökben létezik a “metódus kiemelése (extract method)” refaktorálási lehetőség, így ez az időköltség is minimális. Hosszú távon pedig rengeteg időt spórolunk meg.

funcextract

Az angol “methodize” szó jelentése szó szerint rendszerezést jelent. Ha az Interneten rákeresel erre a szóra, ilyesmi képeket találsz. Pont ebbe a posztba illenek. Te melyikben éreznéd jobban magad?

bathroom-closet-before-after1

Egy hosszú függvényben osztály(ok) lapulhat(nak) meg.

hosszu_fuggvenyMi egy osztály nagyon leegyszerűsítve? Egy kupac függvény, melyek közös változókon keresztül kommunikálnak egymással. Másrészt egy hosszú függvény több funkcionális részből áll, melyeket a főbb indentálások jelölnek, és azért hosszú, mert ezek a funkcionális részek azonos adathalmazt kell manipuláljanak. Ezért tehát a legtöbb hosszú függvény egy, vagy több osztályra bontható.

Egy dolgot csináljon!

Fontos szabály, hogy egy függvény egy dolgot csináljon. Ha több funkcionális részre bontható, akkor több dolgot csinál. Ha több absztrakciós szinten dolgozik, szintén több dolgot csinál.

Honnan tudjuk biztosan hogy több dolgot csinál?

  • Ha új metódust lehet kiemelni, akkor definíció szerint több dolgot csinál.
  • Uncle Bob : “Ha kapcsos zárójelet kell használni a kódban, az már lehetőséget rejt új függvény kiemelésére.”

Mi a teendő?

Addig emeljünk ki új függvényeket, amíg csak értelmesen tudunk. (Extract till you drop!)

Lássunk már kódot!!!

A következő kód egy elképzelt képfeldolgozó funkcióval is rendelkező szoftver részletét mutatja be. Az init függvény célja, hogy példányosítson egy képfeldolgozó objektumot. Néha előfordulhat, hogy valami osztott erőforrás zárolása miatt nem sikerül létrehozni a példányt. Ezt az eshetőséget kezelni kell. Ezen kívül az képfeldolgozást több szálon szeretnénk futtatni, tehát több ilyen objektumot kell példányosítani. Tegyük fel, hogy a szálkészlet tervezési mintát alkalmazzuk. Ehhez készítünk feldolgozó (worker) osztályt.
A kód valós környezetből kerül ide, a problémát általánosítottam, hogy ne legyen felismerhető.

void ImageProcessingWorker::init()
{
    int try_count = 0;
    int max_try_count = 10;
    bool imageProcessingInited = false;
    while (!imageProcessingInited && (max_try_count > try_count) )
    {
        try
        {
            try_count++;
            ip = new ImageProcessor();
            int maxlen = 1024;
            char* errorstring = new char[maxlen];
            memset(errorstring, 0, maxlen);
            int errorcode = 0;
            ip_geterror(&errorcode, errorstring, maxlen-1);
            if ( !ip->IsValid() || (errorcode != ip::IP_NOERR))
            {
                cout << "ImageProcessor module creation FAILED "
                     << ", error code is: " <<  errorcode
                     << ", error string is: " << errorstring << endl;
                delete ip;
                ip = NULL;
            }
            else
            {
                imageProcessingInited = true;
            }
            delete[] errorstring;
            errorstring = NULL;
        }
        catch(IpException &e)
        {
            logIpError("init", e);
            imageProcessingInited = false;
        }
        if (!imageProcessingInited)
        {
            usleep(500000); //sleep 500ms
        }
    }
}

Ezzel a kóddal sok probléma van. Egyrészt ez a függvény nagyon hosszú. Másrészt több absztrakciós szinten dolgozik. Van itt minden; számlálástól kezdve hibakezelésen át az alacsony szintű (C-ből örökölt) memset-ig. Az ip_geterror metódus léte tervezési hibára utalhat, hiszen a használt API nem kényszeríti ki ennek a metódusnak a hívását. (Vajon mi történik, ha kétszer hívjuk egymás után???)
Ha vesszük a fáradtságot és végigböngésszük a kódot, a következőket deríthetjük ki.

  • A fő ciklus maximum 10 alkalommal fut le.
  • A célja, hogy sikeresen példányosítson egy FaceRecognizer objektumot.
  • Kezeli a hibákat.

Mennyivel könnyebb ezt a kódot megérteni:

void ImageProcessingWorker::init()
{
    for (int i = 0; i < 10; i++)
    {
        tryToCreateImageProcessor();
        if (imageProcessorCreationOk())
        {
            return;
        }
        imageProcessor.clear();
        usleep(500000);
    }
    throw TimeoutException("Failed to create image processor.");
}

Az eredeti kód kis munkával átalakítható erre. A teljes megoldást a poszt végén találjátok. Röviden összefoglalva a következő észrevételeket tehetjük:

  • A hibakód kezelése saját függvényt kapott
  • A nehézkesen értelmezhető while ciklusunk egy egyszerű for ciklusra cserélhető.
  • A try-catch blokk elegánsan leválasztható.
  • A kód egy kivétellel jelzi, ha tízszeri próbálkozásra sem sikerül az osztály példányosítása. Az eredeti változatban ez hiányzott, a program ilyenkor érvénytelen állapotba került.

A tisztesség kedvéért megjegyzem, hogy a refaktorált kód nem teljesen ekvivalens az eredetivel. Az egyik változtatás, hogy több helyen a Qt okos mutatóit használom. Ez a kódot átláthatóvá és biztonságossá teszi.

A kód fenti refaktorált verziója még közel sem az optimális megoldás, de már megmutatja a függvénykiemelésben rejlő lehetőségeket.

//
// ImageProcessingWorker.cpp
//

void ImageProcessingWorker::init()
{
    for (int i = 0; i < 10; i++)
    {
        tryToCreateImageProcessor();
        if (imageProcessorCreationOk())
        {
            return;
        }
        imageProcessor.clear();
        usleep(500000);
    }
    throw TimeoutException("Failed to create image processor.");
}

void ImageProcessingWorker::tryToCreateImageProcessor()
{
    try
    {
        imageProcessor = 
            QSharedPointer<ImageProcessor> (new ImageProcessor());
    }
    catch(IpException &e)
    {
        logIpException("init error", e);
    }
}
 
bool ImageProcessingWorker::hasIpCreateError()
{
    IpCreationResult result = IpCreationResult.getLatest();
    if(result.isError())
    {
        logIpCreationError(result);
    }
    return result.isError();
}

void ImageProcessingWorker::logIpCreationError(IpCreationResult result)
{
    cerr << "ImageProcessor module creation FAILED "
         << ", error code is: " <<  result.errorCode
         << ", error string is: " << result.errorMessage << endl;
}

bool ImageProcessingWorker::imageProcessorCreationOk()
{
    return (imageProcessor->IsValid() && !hasIpCreateError());
}

//
// IpCreationResult.hpp
//

class IpCreationResult
{
private:
    IpCreationResult(int _errorCode, std::string _errorMessage)
    {
        errorCode = _errorCode;
        errorMessage = _errorMessage;
    }

public:
    static IpCreationResult getLatest()
    {
        const int maxlen = 1024;
        QScopedArrayPointer<char> errorStr(new char[maxlen]);
        memset(errorStr.data(), 0, maxlen);
        int errorCode = 0;
        ip_geterror(&errorcode, errorStr.data(), maxlen-1);
        std::string errorMessage;
        errorMessage.assign(errorStr.data());
        return IpCreationResult(errorCode, errorMessage);
    }

    bool isError()
    {
        return errorCode != ip::ip_NOERR;
    }

    int errorCode;
    std::string errorMessage;
};

Most akkor tényleg 40 sorból csináltunk 80-at?

Felmerülhet a kérdés, hogy miért jó egy negyven soros kódot nyolcvan sorossá refaktorálni. A kérdésre két rövid válasz van.

  • A refaktorált kódot úgy is sokkal könnyebb megérteni, hogy hosszabb, hiszen nem egy rejtvény.
  • Ha minket nem a teljes működési logika érdekel fejlesztés közben, akkor nem is kell elolvasni mind a 80 sort. A rendszerezetlen kódrészletet, ahhoz, hogy tovább tudjuk fejleszteni el kell olvasni és meg is kell érteni. Igen, az egészet.

Források

  1. Clean Code: Functions
  2. Clean Code – A Handbook of Agile Software Craftsmanship — Martin, Robert C. Prentice Hall, 2009
  3. C++ Core Guidelines
Tiszta kód – 2. rész: függvények

Szembenézni az ürességgel – 2. rész: Nullable

Korábban láttuk, miért lehet a null érték idegesítő problémaforrás. Bizonyos esetekben viszont kifejezetten szeretnénk, hogy egy változó, amely különben nem lehetne null, mégis null értéket vehessen fel.

Sorozatunkban az üresség különböző megjelenési formáit vizsgáljuk a C# nyelvben: a null referenciát, a Nullable és a void típusokat, illetve módszereket a nullal kapcsolatos problémák kivédésére.

zpp-zp8hyg0-nadine-shaabana

Képzeljünk el például egy weboldalt, amely minden felhasználóhoz tárolja az utolsó belépés idejét.

public class User
{
    public string FullName { get; set; }
    public string Email { get; set; }
    public string PasswordHash { get; set; }
    public DateTime LastLoginTime { get; set; }
}

Ha egy felhasználó épp most regisztrált, de még nem lépett be, azt szeretnénk, hogy a LastLoginTime tulajdonság valahogyan üres legyen.

var user = new User();
user.LastLoginTime = null; // fordítási idejű hiba

A tulajdonságnak hiába is próbálnánk null értéket adni. A DateTime értéktípus, ezért a fenti kód le sem fordul.

Kitüntetett értékek

Első ötletünk a probléma megodására, hogy az üresség kifejezésére a DateTime típus valamely, egyébként érvényes értékét jelöljük ki. Használhatjuk például a DateTime.MinValue értéket.

var user = new User();
user.LastLoginTime = DateTime.MinValue;

Ez így működik, de magából a kódból egyáltalán nem látszik, mit jelent ez az érték. Ilyenkor mindig jó ötlet egy konstanst bevezetni, amely megmagyarázza a literális érték jelentését, ezért mi is így fogunk tenni.

private readonly DateTime Never = DateTime.MinValue;

// később, egy metódusban
var user = new User();
user.LastLoginTime = Never;

Ez már egész jól érthető. De csupán a User osztályt megnézve fogalmunk sincs, hogy a LastLoginTime tulajdonság esetén a DateTime.MinValue érték különleges jelentéssel bír. Ezt persze dokumentálhatjuk egy XML-kommentben, de ha igazán őszinték vagyunk magunkhoz, beláthatjuk, hogy ezt senki sem fogja elolvasni. Ezen kívül, bár olyan biztosan nem fog előfordulni, hogy valaki Kr. u. 1-ben belépett a rendszerünkbe, és azóta soha nem tért vissza, ez csak egy szerencsés véletlen. A megoldásunk távolról sem általános. Sok olyan eset lehetséges, amikor a kérdéses értéktípus teljes értékkészletére szükségünk van, és az üresség kifejezésére egy érték sem marad.

Logikai mezők

Ez elvezet a második ötletünkhöz. Kitüntetett érték használata helyett az “üres” állapotot egy különálló bool tulajdonságba helyezzük ki.

public DateTime LastLoginTime { get; set; }
public bool HasLastLoginTime { get; set; }

Ezután már explicit módon jelezhetjük, ha a felhasználónak nincs utolsó belépési ideje; ez a lehetőség a User osztály nyilvános felületén is látszik.

var user = new User();
user.HasLastLoginTime = false;

Ez a közvetlen problémát megoldja, de egy másik továbbra is jelen van. A User osztály kliensének emlékeznie kell, hogy ne nyúljon a LastLoginTime-hoz, amíg a HasLastLoginTime értékét meg nem vizsgálta. Ezt az ellenőrzést valahogy ki kell kényszerítenünk.

Burkoló referenciatípus

Harmadik ötletünk pontosan ezt oldja meg. Ha tényleg azt szeretnénk, hogy a tulajdonság referenciatípusként viselkedjen, miért nem csinálunk belőle referenciatípust? Így ha a kliens egy null érték használatát kísérelné meg, azonnal NullReferenceExceptiont kapna, amely bár kellemetlen, pontosan kijelöli a probléma helyét, és nem hagy lehetőséget hibás működésre.

public object LastLoginTime { get; set; }

Használatjuk akár az object típust is, de így a típusbiztosságról teljesen lemondunk. Ha már statikusan típusos nyelvet használunk, ne adjuk fel a típusrendszer használatát az első kis nehézség láttán.

Sokkal szebb megoldás, ha egy burkolóosztályt – egy referenciatípust – definiálunk, mely egy DateTime értéket tárol.

public class NullableDateTime
{
    public DateTime Value { get; }

    public NullableDateTime(DateTime value)
    {
        Value = value;
    }

    public static explicit operator DateTime(NullableDateTime ndt)
    {
        if (ndt == null)
            throw new NullReferenceException(
                "Cannot convert null to DateTime");

        return ndt.Value;
    }

    public static implicit operator NullableDateTime(DateTime dt) =>
        new NullableDateTime(dt);
}

A konverziós operátorok miatt nagyon kellemes lesz a típus használata. Ha módosítjuk a User osztályt, hogy az új NullableDateTime osztályt használja a LastLoginTime tulajdonság típusaként,

public NullableDateTime LastLoginTime { get; set; }

akkor DateTime típusú értékre minden további nélkül beállíthatjuk, mert ebben az irányban létezik implicit konverzió. Ha a tulajdonság értékét adnánk egy DateTime típusú változónak, ezt csak explicit átalakítás (cast) segítségével tehetjük meg. Így figyelmeztetjük a hívót arra, hogy ez nem egy egyszerű DateTime példány, és így sugalljuk, hogy érdemes előbb a null értéket ellenőriznie.

var user = new User();
user.LastLoginTime = DateTime.Now;

DateTime lastLoginTime;
if (user.LastLoginTime != null)
{
    lastLoginTime = (DateTime)user.LastLoginTime;
}

Ez már egészen jó elgondolás, az eddigi ötleteinknél mindenképp jobb. Mégis van egy kellemetlen hiányossága. Ha nullértékű egészekre (int), lebegőpontos számokra (float, double) és más hasonló értéktípusokra van szükségünk a kódban, mindegyikükhöz definiálnunk kell ugyanezt a burkoló osztályt.

Generikus burkoló osztály

Erre egyszerű megoldást biztosít a generikus típusosság.

public class Nullable<T> where T : struct
{
    public T Value { get; }

    public Nullable(T value)
    {
        Value = value;
    }

    public static explicit operator T(Nullable<T> nullable)
    {
        if (nullable == null)
            throw new NullReferenceException(
                "Cannot convert null to " + typeof(T).Name);

        return nullable.Value;
    }

    public static implicit operator Nullable<T>(T value) =>
        new Nullable<T>(value);
}

Ha mindenhova, ahol korábban a DateTime-ot használtuk, a T generikus típusparamétert helyettesítjük, a fenti, meglepően jól működő megoldást kapjuk. Vegyük észre, hogy T-re a struct, vagyis értéktípus megszorítást alkalmaztuk. Ez azt is megelőzi, hogy a Nullable<Nullable<int>>-hez hasonló értelmetlen típusdeklarációkat írjunk, mert a Nullable referenciatípus.

Ha a LastLoginTime tulajdonság típusát a generikus típusra cseréljük,

public Nullable<DateTime> LastLoginTime { get; set; }

változatlanul használhatjuk tovább, viszont végtelen számú további nullértékű típust is deklarálhatunk. Az Equals és a GetHashCode metódusok megvalósításával igazán tökéletesre csiszolhatjuk az osztályt, és valóban, ez a megoldás már éles feladatokhoz is sikeresen felhasználható lenne.

Generikus burkoló struktúra

Ha sok helyen használjuk a Nullable osztályt, ahol korábban értéktípusokkal dolgoztunk, kiderülhet, hogy az új objektumpéldányok okozta többletterhelés teljesítményproblémákat okoz programunkban. Ezt valahogy meg kell előznünk. Ha kombináljuk korábbi kudarcos próbálkozásunkat a bool típusú tulajdonságok bevezetésére a burkoló osztály ötletével, a Nullable típust értéktípussá alakíthatjuk, melynek sokkal kedvezőbb a teljesítménye.

public struct Nullable<T> where T : struct
{
    private T value;

    public Nullable(T value)
    {
        this.value = value;
        HasValue = true;
    }       

    public bool HasValue { get; private set; }

    public T Value
    {
        get
        {
            if (!HasValue)
                throw new NullReferenceException();
            return value;
        }
    }

    public override bool Equals(object other)
    {
        if (!HasValue)
            return other == null;
        if (other == null)
            return false;

        return value.Equals(other);
    }

    public override int GetHashCode() =>
        HasValue ? value.GetHashCode() : 0;

    public override string ToString() =>
        HasValue ? value.ToString() : string.Empty;

    public static implicit operator Nullable<T>(T value) =>
        new Nullable<T>(value);

    public static explicit operator T(Nullable<T> value) =>
        value.Value;
}

Beláthatjuk, hogy e struktúra alapértékében a hasValue mező értéke false, épp ahogy szeretnénk. Ez majdnem tökéletes, egyetlen korábban említett esetben van még probléma. Ha ez egy értéktípus, a struct megszorítás többé nem véd meg minket az egymásba ágyazott nullértékű deklarációktól (pl. Nullable<Nullable<int>>).

Támogatás a C# nyelvben

Eljött az ideje, hogy beismerjem, egészen idáig megtévesztettem az Olvasót. A .NET keretrendszerben már létezik egy, a fentihez kísértetiesen hasonlóan megvalósított Nullable<T> típus, amelyből nem lehet egymásba ágyazott példányokat létrehozni. Érdemes megnézni a forráskódját!

Ezt a C# nyelv tervezői némi csalással érték el: a where T: struct megszorítást az alábbi módon definiálták:

A típusargumentum értéktípus kell, hogy legyen. Bármely nem nullértékű típus megadható. (The type argument must be a value type. Any value except Nullable can be specified.)

A nyelv egy kényelmes rövidítést is biztosít, ha nullértékű típusokkal szeretnénk dolgozni. Nullable<T> helyett T?-et írhatunk.

public DateTime? LastLoginTime { get; set; }

A C# nyelv a Nullable típust további képességekkel is felruházza, amely így inkább egy nullobjektumhoz hasonlít, mint a null referenciához. Ezt az úgynevezett operátor-beemelés (operator lifting) segítségével éri el. Bármely, a mögöttes típusra definiált operátor a nullértékű típuson is használható, azzal a megkötéssel, hogy ha bármely operandus értéke null, az eredmény is null lesz.

int? a = 2;
int? b = 3;
int? c = null;

int? d = a + b; // d == 5
int? e = b * c; // e == null

További hasznos eszköz a ?? operátor, mely az első operandusát adja vissza, ha az nem null, egyébként pedig a másodikat.

int? a = null;
int? b = 5;

int c = a ?? 10; // c == 10
int d = b ?? 10; // d == 5

Az operátor érdekes tulajdonsága, hogy láncolható.

int? x = null;
int? y = null;

int z = x ?? y ?? 10; // z == 10

Fontos kiegészítés, hogy a ?? operátor referenciatípusokkal is használható.

Az utolsó nyelvi szintű funkció a bool? típus kivételes viselkedése. Íme a logikai és és vagy műveletek igazságtáblája, ha azokat nullértékű típusokra alkalmazzuk:

x y x && y x || y
true true true true
true false false true
true null null true
false true false true
false false false false
false null false null
null true null true
null false false null
null null null null

Támogatás a futtatókörnyezetben

A Nullable<T> típus összes eddig bemutatott funkcióját vagy a keretrendszer, vagy a fordító biztosította. Ezek a futtatókörnyezettől teljesen függetlenek. Van azonban egy sajátos működés, nevezetesen a nullértékű típusok be- és kidobozolása (boxing, unboxing), amelyhez már a CLR támogatása is szükséges. Ennek bemutatása túlmutat a cikk keretein, de akit érdekel, ebben a Stack Overflow válaszban egy kiváló magyarázatot olvashat.

Befejezés

Eredeti problémánkat tehát annyival elintézhetjük, hogy egy kérdőjelet biggyesztünk a DateTime típus után az alábbi sorban:

public DateTime? LastLoginTime { get; set; }

Ám közben tettünk egy hatalmas utazást, és láttuk, mennyi ötlet és alkotó mérnöki munka áll egyetlen karakter mögött.

Források

A cikk elkészítése során rendkívül sokat segítettek az alábbi könyvek. Mindenkinek szívből ajánlom őket.

Szembenézni az ürességgel – 2. rész: Nullable

Tiszta kód – 1. rész: nevek

Bevezető

A nevek mindenhol szerepelnek a kódunkban. Nevet adunk a változóknak, függvényeknek, osztályoknak, enumoknak, fájloknak, stb. Talán érdemes írni róla, hogy mitől lesznek a nevek jók. A következő pontokban ezt igyekszem összefoglalni. Ne feledjük: a neveket azért adjuk, hogy kommunikáljunk velük.

wtf-per-minute

A példákat a Tiszta kód sorozatból válogattam. A post elolvasása nem helyettesíti az élményt!

Érthető cél

Minden névnek az a célja, hogy kommunikálja írója szándékát.

int d; // elapsed time in days

int elapsedTimeInDays;
int daysSinceCreation;
int daysSinceModification;
int fileAgeInDays;

A fenti példában látható d név nem túl informatív. Ha a változót sok helyen használjuk, előfordulhat, hogy a kontextusból bizonyos helyeken nem lehet érteni, hogy mit is takar az valójában. Fontos szabály, hogy egy név kommunikálja célját, és ne kelljen a kód más részeit átfésülni azért, hogy egy adott változó vagy név jelentését kiderítsük. Ha nem érthető azonnal egy név jelentése, akkor az a név rossz, jobbat kell választani. Az utána következők célravezetőbbek.

Vajon az alábbi kód milyen programban található?

list1 = []
for x in theList:
	if x[0] == 4:
		list1 += x;
return list1

Ha csak annyit változtatunk rajta, hogy értelmes neveket adunk fogalmainknak, már sokkal egyértelműbb a dolog.

flaggedCells = []
for cell in theBoard:
	if cell[STATUS_VALUE] == FLAGGED:
		flaggedCells += cell
return flaggedCells

Igen. Ez egy aknakereső kódjának részlete. De lehet még fokozni egy kicsit.

flaggedCells = []
for cell in theBoard:
	if cell.isFlagged():
		flaggedCells += cell
return flaggedCells

Ez már valóban jól olvasható. Magyar fordításban:

“Iteráljunk végig a tábla összes celláján. Ha egy cella “zászlós”, rakjuk be a tárolónkba, végül adjuk vissza a tárolót a hívónak.”

Kerüljük a félrevezetést

Ne vezesd félre a programozót!

Ha ezt a változót látod valahol egy kódban…

accountList;

akkor joggal várhatod el, hogy az tényleg egy lista legyen. Vagyis a következő függvényeknek az adott objektumra hívhatónak kell lenniük:

  • append()
  • erase()
  • insert()
  • C++ esetében például a sort() is

Így ha a fenti változó például csak egy egyszerű tömböt jelöl, a választott név nem jó. Jobb választás pl. az accounts név.

Hasonló változónevek

A szemünk hajlandó átverni, ha nagyon hasonló neveket használunk. Boldog hibakeresést!

XYZControllerForEfficientHandlingOfStrings
XYZControllerForEfficientStorageOfStrings

Radáusl léetizk egy iylen jleneésg is. Szvóal fnoots, hgoy ne hszalánj hsaolnó szvaakat.

1 szó/fogalom

Egy szót egy fogalommal társítsunk. Egy rossz példa az alábbi.

//1.
 void LoadSingleData();
 void FetchDataFiltered();
 void GetAllData();
//2.
 void SetDataToView();
 void SetObjectValue(int value);

Az első szakaszban a load, fetch, get szavak ugyanarra a fogalomra utalnak, míg a második szekcióban különböző fogalmak vannak azonos névvel jelölve. Ez így nem jó. Helyesen így festene:

//1.
 void GetSingleData();
 void GetDataFiltered();
 void GetAllData();
//2.
 void LoadDataToView();
 void SetObjectValue(int value);

Absztraktság szabálya

Az osztályok neve legyen annyira absztrakt, mint maga az osztály. Például a Protocol név absztrakt osztályt kell takarjon.

Átnevezés szabálya

Tegyük fel, hogy lelkiismeretes programozónk találó nevet adott egy függvénynek. A követelmények változása miatt a függvény törzse is változik, általában többet csinál mint azelőtt. Ilyenkor jó ha újraolvassuk a függvényt hívó kód részletét. Ha nem fedi az újdonsült igazságot, akkor át kell nevezni. Sok debug időt spórolhatunk meg, ha a nevek mindig aktualizálva vannak és az igazságot kommunikálják.

Kiejthető nevek

Vajon mit takarnak a következő nevek?

class DtaRcrd102 {
    private Date genymdhms;
    private Date modymdhms;
    private final String pszqint = "102";
};

Nem jobb így?

class Customer {
    private Date generationTimestamp;
    private Date modificationTimestamp;
    private final String recordId = "102";
};

Mellőzzük a magyar jelölést!

Ezt a jelölést annak idején ifjabbik Simonyi Károly (Charles Simonyi) fejlesztette ki a C nyelvhez. Akkoriban nehéz volt visszafejteni, hogy az egyes változóknak mi a típusa, ezért a változó nevébe kódolta.

Például a m_pszName egy struktúra saját változója volt (m_), ami egy nullvégződésű (z) sztringre (s) mutató pointer (p) volt és minden bizonnyal valamilyen névre mutatott.

Hogy miért is mellőzzük ezt a jelölést? Hát többek között ezért:

#include "sy.h"
extern int *rgwDic;
extern int bsyMac;
struct SY *PsySz(char sz[])
{
	char *pch;
	int cch;
	struct SY *psy, *PsyCreate();
	int *pbsy;
	int cwSz;
	unsigned wHash=0;
	pch=sz;
	while (*pch!=0)
		wHash=(wHash<>11+*pch++);
	cch=pch-sz;
	pbsy=&rgbsyHash[(wHash&077777)%cwHash];
	for (; *pbsy!=0; pbsy = &psy->bsyNext)
	{
		char *szSy;
		szSy= (psy=(struct SY*)&rgwDic[*pbsy])->sz;
		pch=sz;
		while (*pch==*szSy++)
		{
			if (*pch++==0)
				return (psy);
		}
	}
	cwSz=0;
	if (cch>=2)
		cwSz=(cch-2/sizeof(int)+1;
	*pbsy=(int *)(psy=PsyCreate(cwSY+cwSz))-rgwDic;
	Zero((int *)psy,cwSY);
	bltbyte(sz, psy->sz, cch+1);
	return(psy);
}

Ma már olyan fejlesztőeszközök vannak, hogy a változó típusát hamar megtudhatod, ha érdekel. Általában elég csak az egeret a változó fölé húzni és az IDE nagyon szívesen megmondja neked annak típusát.

Nevek és szófajok

Az alábbi táblázat szerint érdemes figyelni a nevek szófajára.

Nevek típusai szófaj példa
Változók, osztályok főnevek Vehicle, customer, employee
Metódusok igék bird.fly(), item.getPrice()
Bool változók, metódusok kifejezések list.isEmpty(),
buffer.hasTask(),
worker.isBusy()
Enum (Státuszt, vagy leírót tartalmaznak) gyakran melléknevek enum Color{Red, Green, Blue}
enum Status{Pending, Closed, Cancelled}

Ha jól figyelsz a szófajra, olvasmányos lesz a kódod.

if(employee.isLate()){
	employee.fire();
}

A rosszul választott szófaj bizonytalanságot szülhet.

if(set(username,"zsolt")){
	...
}

Vajon arra vagyunk kíváncsiak, hogy zsolt a username értéke, vagy beállítjuk zsolt-ra és azt vizsgáljuk, hogy sikeres volt-e a művelet? Ha az if-en belül például az “isSet” kifejezés lenne, fel sem merülne a kérdés.

Scope szabály

Változók

Kis scope-on használt változóknak rövid nevet érdemes adni, nagyobb scope-on használtaknak pedig bátran adhatunk hosszabb, kifejező nevet.

for (Cell cellToEvaluate: getLivingCells()) {
	int numberOfLivingNeighbors = countLivingNeighbors(cellToEvaluate);
	if (numberOfLivingNeighbors == 2 || numberOfLivingNeighbors == 3) {
		ng.add(cellToExamine);
	}
}

A fenti kódban zavaró, hogy nem tudjuk mit is takar az ng változó. Annak deklarációja sokkal feljebb található a kódban. Kevésbé zavaró, de szemet szúrhat, hogy a numberOfLivingNeighbours lokális változó összesen két sorban van használva. Ennek ellenére a neve nagyon hosszú, olvasása kissé kényelmetlen. Hasonló a helyzet a cellToEvaluate névvel is. A kód tisztább verziója így néz ki:

for (Cell cell: getLivingCells()) {
	int count = countLivingNeighbors(cell);
	if (count == 2 || count == 3) {
		nextGeneration.add(cell);
	}
}

A fenti kódrészletet alapján a témában jártas programozó felismerheti a Conway féle életjátéknak részletét.

Függvények, osztályok

Mivel a publikus függvényeket sok helyen használjuk, praktikusabb rövid, találó nevet adni nekik. A privát függvényeink kaphatnak hosszú nevet, ami jól leírja mit is csinál. Általában privát függvényeket amúgy sem hívunk túl sok helyről, nem lesz zavaró a hosszú név.

Az osztályok esetében egy leszármazott neve általában hosszabb elődjénél, hiszen működése sokkal konkrétabb. Ezért a neve is pontosításra szorul. Gyakran ősének valamilyen toldalékolt változata.

Források

  1. Clean Code: Names
  2. Clean Code – A Handbook of Agile Software Craftsmanship — Martin, Robert C. Prentice Hall, 2009
  3. Életjáték
Tiszta kód – 1. rész: nevek

Tiszta kód – Bevezető

Velem történt

coverAmikor friss diplomásként kiléptem a munkaerőpiacra, szerencsémre olyan céghez kerültem, amely nagy hangsúlyt helyezett a munkaerő képzésére. Többek között szinte kötelező jelleggel el kellett végezni a Clean Code kurzust. Ez egy videósorozat, melynek főszereplője, Uncle Bob lépésről lépésre mesélte el vicces, könnyed hangulatban, hogy mi is tesz egy programozót jó programozóvá.

Nagyrészt ezt az anyagot, illetve a filmet megelőző könyvet szeretném feldolgozni. Úgy érzem, nekem nagyon sokat adott, remélem mindenkinek hasznára fog válni.

Ajánlás

Neked ajánlom a sorozatot, ha:

  • nehezen tudod régi kódjaidat megfejteni
  • sok megjegyzésre van szükséged kódolás közben
  • programjaidhoz nagyon nehéz új funkciót illeszteni
  • új funkciók hozzáadása után programod más részén érdekes dolgok történnek
  • az öröklődést elsősorban a kódismétlés elkerülésére használtad

Tudtad?

  • A kód tisztasága fontosabb a céged számára, mint az, hogy helyesen működik.
  • Egy átlagos programozó a munkaidejének 90%-át kód olvasásával tölti.
  • Egy átlagos programozó sokkal több időt tölt hibakereséssel, mint fejlesztéssel.

Idézetek szabad fordításban

“Bármilyen bolond képes olyan kód írására, amit egy gép megért. A jó programozók kódját más emberek is megértik.” — Martin Fowler

“Szeretem, ha a kódom elegáns és hatékony. A kód logikája egyértelmű kell legyen, hogy nehezen rejtőzzenek hibák a sorok között. A függőségek legyenek minimálisak, mert ez megkönnyíti a karbantartást. A hibakezelés legyen teljes és a kód teljesítménye közel optimális, így nem kísérti az olvasóját további optimalizálásra, amellyel a tisztaságát veszélyezteti. A tiszta kód egy dolgot csinál, és azt jól.” — Bjarne Stroustrup

“A tiszta kód direkt és egyszerű. A tiszta kód olyan könnyen olvasható, mint egy próza.” — Grady Booch

Tiszta kód – Bevezető

Szembenézni az ürességgel – 1. rész: A nullobjektum

Sorozatunkban az üresség különböző megjelenési formáit vizsgáljuk a C# nyelvben: a null referenciát, a Nullable és a void típusokat, illetve módszereket a nullal kapcsolatos problémák kivédésére. Az első rész a nullobjektum (Null Object) tervezési mintát mutatja be.

photo-1473777785975-efb668f9ea99

A null referenciát olyan jól ismerjük, programozói életünk mindennapjait annyira áthatja, hogy nehéz lenne elképzelnünk nélküle a világot. Viszont rengeteg frusztrációt és konkrét anyagi veszteséget okozhat, ezért érdemes megvizsgálnunk, bizonyos esetekben hogyan kerülhetjük el a használatát.

De mi is pontosan a probléma?

Képzeljünk el egy olyan rendszert, amelyben az ügyfelek megrendeléseket (Order) adhatnak le. A rendelések sorokból (LineItem) állnak, melyek a mennyiséget és egy, a megrendelt termékre mutató referenciát tárolnak. Ezen kívül az árat is ki tudják számolni az egységár és a megadott mennyiség alapján.

public class LineItem
{
    public Product Product { get; }
    public decimal Quantity { get; }
    public decimal Price => Product.UnitPrice * Quantity;

    public LineItem(Product product, decimal quantity)
    {
        if (quantity <= 0)
             throw new ArgumentOutOfRangeException(nameof(quantity));
        if (product == null)
             throw new ArgumentNullException(nameof(product));

        Product = product;
        Quantity = quantity;
    }
}

Rendelésenként egy, de többféle típusú kedvezményt alkalmazhatunk. A kedvezmények kalkulációs logikáját a stratégia (Strategy) mintával kapcsoltuk le az Order osztályról.

public interface IDiscount
{
    decimal Calculate(decimal orderTotal);
}

public class CouponDiscount : IDiscount
{
    public CouponDiscount(string couponCode, decimal rate) { /* ... */ }
    public decimal Calculate(decimal orderTotal) => orderTotal * rate;
}

public class FrequentBuyerDiscount : IDiscount {/* ... */}

A rendelés végösszegéből a Discount objektum a csökkentett árat a saját szabályai szerint számítja ki.

Az Order osztály metódusokat biztosít sorok és egy kedvezmény felvételére, és egy tulajdonság segítségével lekérdezhető a kedvezmény alkalmazása utáni végösszeg.

public class Order
{
    public IEnumerable<LineItem> LineItems => lineItems.AsReadOnly();
    public decimal Total => discount.Calculate(totalBeforeDiscount);

    private decimal totalBeforeDiscount => lineItems.Sum(i => i.Price);
    private IDiscount discount;
    private List lineItems;

    public Order()
    {
        lineItems = new List<LineItem>();
    }

    public void AddLineItem(LineItem lineItem)
    {
        if (lineItem == null)
            throw new ArgumentNullException(nameof(lineItem));

        lineItems.Add(lineItem);
    }

    public void AddDiscount(IDiscount discount)
    {
        if (discount == null)
            throw new ArgumentNullException(nameof(discount));

        this.discount = discount;
    }
}

A fenti osztály teljesen ártalmatlannak tűnhet. Nincs itt hiba, hiszen mindent jól csináltunk, még arra is gondoltunk, hogy az argumentumok nem lehetnek null értékűek. Mégis nagyon valószínű, hogy a kód NullReferenceExceptiont fog dobni.

var order = new Order();
order.AddLineItem(new LineItem(product1, quantity: 2));
order.AddLineItem(new LineItem(product2, quantity: 1));
Console.WriteLine($"Total: {order.Total}"); // BUMM!!!

Hát persze! Elfelejtettünk kedvezményt rögzíteni.

De várjunk csak. Az teljesen normális, ha egy rendeléshez egyáltalán nem tartozik kedvezmény. Nem gond, erre is gyorsan felkészülhetünk.

public decimal Total =>
    discount == null
        ? totalBeforeDiscount
        : discount.Calculate(totalBeforeDiscount);

Ez a kód jól működik, bár szépnek semmiképp nem mondhatjuk. De az, hogy minden alkalommal, amikor a discount mezőhöz nyúlunk, fel kell készülnünk a null értékre, akkor is problémás, ha az esztétikai megfontolásokat félretesszük. Ha csak egyszer is elfelejtjük, egy NullReferenceException a jutalmunk. Ráadásul az alapértelmezett logikát, vagyis a totalBeforeDiscount értékének visszaadását minden alkalommal le kell másolnunk. Ez a logika nincs egységbe zárva.

Szerencsére van jobb megoldás. Nézzük meg, hogyan kezeltük pontosan ugyanezt a helyzetet a sorok felvételekor. A lineItems mezőt egy biztonságos alapértékre, az üres listára inicializáltuk a konstruktorban.

public Order()
{
    lineItems = new List<LineItem>();
}

Így már nem kellett felkészülnünk a null értékre az AddLineItem metódusban.

lineItems.Add(lineItem);

El tudunk képzelni hasonló alapértéket a kedvezménynek is? Természetesen. Ez egy olyan kedvezménytípus lesz, amely nem módosítja az átadott végösszeget.

public class NoDiscount : IDiscount
{
    public decimal Calculate(decimal orderTotal) => orderTotal;
}

Az ilyen alapérték-objektumokat nevezzük nullobjektumnak (Null Object). Ezt már használhatjuk kezdőértéknek a konstruktorban,

public Order()
{
    lineItems = new List<LineItem>();
    discount = new NoDiscount();
}

és megszabadulhatunk a problémás nullvizsgálattól a Total tulajdonságban.

public decimal Total => discount.Calculate(totalBeforeDiscount);

Búcsúzóul azért szükséges leszögeznünk, hogy a nullobjektumok alkalmazásakor érdemes önmérsékletet gyakorolni. Amikor az alapértelmezett viselkedés ártalmatlan és kiszámítható, a minta igen elegáns megoldásokhoz vezet. Ha azonban a nullobjektum viselkedése nem egyértelmű a kliens számára, akkor bizony egy NullReferenceException sokkal hasznosabb, mint a rosszul megtervezett nullobjektum alattomos működése.

Vélemények a cikkről a prog.hu-n

Szembenézni az ürességgel – 1. rész: A nullobjektum