C# Tech. interjú – SOLID refaktor

Az előző cikkben refaktoráltuk a parser-eket, hogy egységes felülettel rendelkezzenek. Ennek köszönhetően a nagy switch blokkot jelentősen egyszerűsíteni lehetett, illetve magát a switch-et ki lehetett mozgatni egy factory metódusba. A megoldás nem lett olyan rossz, sőt, egy 90 perces technikai interjún jónak számítana. Lehet azonban tovább javítani rajta.

Ez a cikk nagymértékben támaszkodik a SOLID interjú kérdések alatt tárgyalt fogalmakra.

Single Responsibility

A gond a parser-ek jelenlegi felépítésével az, hogy két teljesen eltérő dologgal foglalkoznak. Az egyik az adatforrás nyitása, a másik pedig az adatforrás értelmezése. Mivel két nagyon különböző dologról van szó, jó eséllyel külön irányba változnak. Az előző cikkben említettük például az adatforrás hálózatról való olvasásának igényét.

Mondhatnánk, hogy egyelőre csak az az igény, hogy a parser-ek file-ról olvassanak, és a YAGNI szellemében nem akarunk előre felkészülni például hálózati olvasásra. Ez az indok talán elfogadható lenne, ha csak egy parser-ünk lenne – ebből ráérnénk kimozgatni a file nyitást az igény megjelenésekor. A helyzet azonban az, hogy már jelenleg két parser van, feladat egy harmadik létrehozása, és feladatkiírásban olvasható, hogy a jövőben további formátumok jöhetnek szóba. Emiatt rövidesen három különböző osztályban lesz az adatforrást megnyitó kód, a jövőben pedig további osztályokban – azaz ugyanaz a funkció sokszorozódik. Ez a jelenség Needless Repetition néven ismert, és a SOLID elvek betartásával kiküszöbölhető.

További indok a refaktorálás mellett, hogy várhatólag nem jelent nagy módosítást, hogy a parser-ek a .NET univerzális adatforrását, a Stream osztályt használják a munkájukhoz file név helyett – ezért nincsen szó nagy előredolgozásról sem.

Design Patterns és a helyi szokások

A file megnyitás kiszervezésével a parser osztályok kívülről nézve már csak egy dologgal foglalkoznak: végighaladnak az adatforráson és visszaadják az adatforrásba kódolt Person-okat. A “dolgokon való végighaladás” eszünkbe juttathatja az Iterátor Design Pattern-t.

Ez a pattern egy Aggregate osztályt nevez meg egyik szereplőnek. Az Aggregate osztály nevének megfelelően “valamiknek” az összességét kezeli. Ez lehet egy adatszerkezetben tartott adatok/objektumok halmaza, de bármi más is, amiből sok van, mint röptében generált értékek, vagy egy adatbázis lekérdezés eredménye. Ez az Aggregate egy createIterator() hívással visszaad egy olyan eszközt (objektumot), aminek a segítségével végig lehet venni az Aggregate által képviselt összes elemet. A végigvétel nem feltétlenül jelenti azt, hogy valaha elérünk az utolsó elemig – az aggregate “tartalmazhatja” a pi tizedesjegyeit is, vagy lehet egy véletlenszám generátor.

A .NET használja az iterátor patternt, csak éppen átnevezett résztvevőkkel. Az Aggregate itt egy IEnumerable, az iterátor pedig az IEnumerator. Ízlés kérdése melyik a jobb név, szerintem a .NET választása kifejezőbb, ugyanakkor egy Design Pattern egyik lényege pont a közös nyelv lenne, hogy mindenki azonnal felismerje, miről beszél a másik, illetve mit csinál a kód.

Az interfészek felépítése is kicsit át lett dolgozva a .NET-ben, szintén ízlés függő, véleményem szerint itt is jobb a .NET választása. Az eredeti interfészen a next() hívás egyből visszaadta a következő elemet, ami aztán elérhető volt a currentItem-ként is – duplikálva egy funkciót. A .NET az eredeti pattern isDone() funkcionalitását átmozgatta a next() (ami .NET-ben MoveNext()) visszatérési értékébe, így az enumerált értékek csak a currentItem-en (.NET-ben Current) keresztül érhetőek el. Azonban a .NET-es implementációban is vannak kivetni való dolgok.

Interface Segregation Principle és az enumerátor

Mind a GoF iterátor pattern-nek, mind a .NET implementációnak van egy first()/reset() metódusa. Elsőre nem tűnik nagy dolognak, de ez így kevéssé SOLID. A helyzet ugyanis az, hogy nem minden dolog, ami leszámlálható, egyben reset-elhető. Ennek lehetnek akár elvi okai, akár gyakorlati megvalósítási nehézségei. Ez utóbbira példa lehet, ha az iterátor mögött egy hálózati adatfolyam van, és a forrás (pl egy web-es szerviz) nem rendelkezik olyan funkcióval, ami visszaállna az adatok elejére.

Elvi probléma, ha mondjuk az egér pozíció lekérdezéséhez használunk valamiért egy IEnumerator-t megvalósító osztályt – a MoveNext() mindig az aktuális pozíciót kapja el. Ezen az adatsoron nem lehet visszaállni az első elemre, mert nincs is értelmezve, mi az első elem. A lényeg tehát, hogy nem minden enumerable dolog “visszaállítható”, szóval nem lehet mindig jól implementálni egy first()/reset() hívást.

Másik oldalról, nem minden kliens kíváncsi a reset-re, mert nem használja, viszont feleslegesen látja. Miért gond ez? Tegyük fel, hogy a .NET tervezői figyelembe vették az itt taglaltakat, és van IEnumerator és IResetableEnumerator, ami mellesleg az IEnumerator-ból származik. Ekkor, ha egy kliens olyan algoritmust használ, ahol kell a reset, akkor paraméterként IResetableEnumerator-t vár. Jelenleg, mivel csak IEnumerator van, egy ilyen algoritmusnak a programozó bedobhat olyan enumerator-t, ami dob egy not supported exception-t a reset-re, vagy csak szimplán lenyeli a hívást. Ekkor, ha egy algoritmus számít arra, hogy a reset hatására a korábban leszámlált adatok ismétlődnek ugyanabban a sorrendben, akkor hibásan fog működni – itt jön képbe a Liskov Substitution Principle.

Az is előfordulhat, hogy a kliens eddig IEnumerable-t használt, a jövőben egy algoritmusváltás miatt viszont IResetableEnumerable kell neki. Ha volna két interfész, akkor a refactor után, ha a kód IEnumerable-vel próbálja hívni az IResetableEnumerable-t váró algoritmust, a kód le sem fordul. Ellenben a jelenlegi .NET-es implementációban lefordul – majd dobja a not supported exception-t, vagy éppen nem csinál semmit, csendben működésképtelenné téve az algoritmust. Ez a megoldás így törékeny (fragile).

Single Responsibility és a .NET enumerátor

Az a fő motivációnk, hogy a parser-ekből kimozgassuk az adatforrás létrehozását, hogy a parser a jövőben csak az adat feldolgozásával foglalkozzon – kielégítve ezzel a Single Responsibility-t. Van azonban egy furcsaság, ami részben keresztbe fogja húzni a számításainkat. Ez a GoF iterátor pattern-en, illetve a .NET 1.0-ás enumerátorokon még nem látszik – itt feltételezhetően nem számítottak arra, hogy az iterátorokat bonyolultabb helyzetekben is lehet használni, minthogy egy láncolt listán, vagy egyéb memóriában tárolt adatszerkezeten végiggyaloglunk.

Az aggregate és az iterátor között nyilván van valamiféle kapocs, hiszen az iterátor az aggregate által hordozott dolgokra hivatkozik. Adatszerkezeteknél ez a kapocs valamiféle index, vagy referencia, tehát olyan, ami nem kerül sokba. De ha az aggregate csak valamiféle proxy, és a valós adatok valahol máshol, például adatbázisban vagy más jellegű szerveren vannak, akkor könnyen előfordulhat, hogy az iteráció idejére drága erőforrásokat kell allokálni. Ez esetben valahogy jelezni kell, ha már nincs szükség az iterátorra.

Erre alapvetően két lehetőség van. Az egyik, hogy a createIterator() párjaként bevezetésre kerül egy destroyIterator() jellegű metódus. A másik, hogy magának az iterátornak kell jelezni, hogy nincs szükség már rá, azaz az iterátor kell hogy kapjon valami finish() jellegű metódust.

Hogy melyik megoldás a jó, ahhoz figyelembe kell venni, hogy hogyan szoktak iterátorokat implementálni. Az iterátor pattern motivációja kettős. Az egyik, hogy az aggregate implementációs részleteitől független legyen az, ahogyan az aggregált elemeket végigvesszük – azaz egy algoritmus számára mindegy legyen, hogy az adatok egy láncolt listában vannak a memóriában, vagy egy adatbázisból jönnek 5 rétegen keresztül. A másik motivációja az iterátor patternnek, hogy az aggregate tehermentesítve legyen a leszámlálás műveletétől. A tehermentesítés azt jelenti, hogy az iterátor felelőssége karbantartani azt az állapotot, ami ahhoz szükséges, hogy az aggregált elemek közül a következőt megtaláljuk.

Ennek a kiszervezésnek a következménye, hogy párhuzamosan több iterátor működhet – és ennek nem csak többszálú környezetben van értelme, hanem sokkal hétköznapibb helyzetekben is. Elég egy egymásba ágyazott for ciklusra (vagy foreach-re) gondolni.

Most, hogy látjuk, hogy az iterátor felelőssége karbantartani az iterációhoz szükséges állapotot, talán nem tűnik olyan elvetemült ötletnek, hogy a karbantartásba a takaritást is beleértsük – SRP ide vagy oda. Ekkor az iterátor kap egy finish() jellegű műveletet.

A .NET megoldása a fenti logikát követi, és .NET 2.0-vel kapott IEnumerator<T> egyben megvalósítja az IDisposable-t is. Most újra elő lehet venni az Interface Segregation Principle-t, mivel az iterátorok zömmel nem igényelnek Dispose() hívást. Azonban itt más a helyzet, mint a first()/reset() esetében. A first()/reset() ugyanis egy lehetőséget KINÁL a kliensnek, viszont ez a lehetőség nem szolgálható ki minden iterátor implementációban. A finish() – .NET-ben Dispose() – viszont az egy KÖVETELMÉNY a kliens felé, amit a kliensnek be kell tartani. Ráadásul a Dispose() az iterátor viselkedése szempontjából konzisztensen implementálható minden esetben, még ha a háttérben nem is csinál semmit.

foreach és az IEnumerator<T>

Eljutottunk oda, hogy a .NET-es generikus iterátor, az IEnumerator<T> rendelkezik Dispose()-zal, amit meg kellene hívnunk. Mégis, valószínű nagyon kevesen vannak azok, akik valaha explicit módon meghívták ezt a Dispose()-t.

Ennek az az oka, hogy az iterátor pattern-t C#-ban többnyire egy foreach szerkezetben használjuk. Nem meglepő, hogy a foreach jól ismeri a .NET-es iterátor implementációt, emiatt ő gondoskodik arról, hogy a ciklus végén a Dispose() meg legyen hívva. Amikor egy programban leírjuk a következőt:

IEnumerable<Person> persons = GetPersons();

foreach (var person in persons)
{
   ...
}

Akkor a C# fordító valójában egy ehhez hasonló kódot generál:

var enumerator = persons.GetEnumerator();

try
{
    while (enumerator.MoveNext())
    {
        person = enumerator.Current;
        ...
    }
}
finally
{
    enumerator.Dispose();
}

Parser-ek mint enumerátorok

A fenti eszmefuttatásokra két okból volt szükség. Az egyik, hogy lássuk az irányt, hogy a parser-ek IEnumerator<T> implementációk lesznek. A másik, hogy előre mossuk kezünket amiatt, hogy lesz egy – kényszerből – csúnya Reset() megvalósításunk, illetve amiatt, hogy bár a Single Responsibility jegyében kiszervezzük az adatforrás megnyitását, mégis meghagyjuk az adatforrás bezárását.

A refaktorálás innen már mechanikusan megy. Az eddigi parser kódját csak át kell copy-paste-elgetni az IEnumerator<T> metódusaiba, illetve a konstruktor nem string-et kap file névvel, hanem egy előkészített Stream-et. Az XmlParser így alakul át:

internal class XmlEnumerator : IEnumerator<Person>
{
    private static readonly string ItemTitle = "Person";

    private Stream source;
    private XmlReader xmlReader;
    private XmlSerializer xmlSerializer;

    private Person current;

    internal XmlEnumerator(Stream source)
    {
        this.source = source;

        this.xmlReader = XmlReader.Create(this.source);

        this.xmlReader.MoveToContent();
        this.xmlReader.ReadToDescendant(XmlEnumerator.ItemTitle);

        this.xmlSerializer = new XmlSerializer(typeof(Person));
    } // XmlEnumerator()

    public Person Current
    {
        get 
        {
            return this.current;
        } // get
    } // Current

    public void Dispose()
    {
        if (this.xmlReader != null)
        {
            this.xmlReader.Close();

            this.xmlReader = null;
            this.source = null;
            this.current = null;
        } // if
    } // Dispose()        

    public bool MoveNext()
    {
        if (this.xmlReader == null)
        {
            throw new ObjectDisposedException(this.GetType().Name);
        } // if

        this.current = this.xmlSerializer.Deserialize(this.xmlReader) as Person;

        return this.current != null;            
    } // MoveNext()

    public void Reset()
    {           
        throw new NotSupportedException();           
    } // Reset()

    object System.Collections.IEnumerator.Current
    {
        get
        {
            return this.Current;
        } // get
    } // IEnumerator.Current
} // XmlEnumerator

A bináris parser ehhez hasonlóan mechanikusán átalakítható. A kódban láthatjuk a felesleges Reset()-et, illetve az adatforrást lezáró Dispose()-t is, amiről korábban beszéltünk. (az XmlReader alapesetben zárja az alatta levő Stream-et). Természetesen az előző cikkben megírt ParserFactory-t is módosítani kell:

static internal class EnumeratorFactory
{
    static internal IEnumerator<Person> Create(Stream source, string formatType)
    {
        switch (formatType)
        {
            case "bin": return new BinaryEnumerator(source);
            case "xml": return new XmlEnumerator(source);
        } // switch

        throw new NotSupportedException("cannot create enumerator for " + formatType);
    } // Create()
} // class EnumeratorFactory

Mi lesz az Aggregate?

Abból, amit eddig csináltunk, két oldalról is hiányzik valami. Egyrészt, az eredeti parser-ekből kiszerveztük az adatforrás megnyitását. Így most hiányzik valami, ami megnyitja az adatforrást, és odaadja a refaktorált parser-nak, amit most már enumerátornak hívunk. Másrészt, az iterátor pattern-ből még nem valósítottuk meg az aggregate-et.

Afelé haladunk tehát, hogy az aggregate lesz az az osztály, amelyik megnyitja a file-t az enumerátornak. Az aggregate a mi esetünkben egy file-ban található adatokat aggregálja, így nevezhetjük például FileBasedDataset-nek. Az osztályt következőképpen tervezzük használni:

var dataset = new FileBasedDataset(path, formatType);

foreach (var person in dataset) ...

Ahhoz, hogy az aggregate a fenti módon működjön, a FileBasedDataset-nek a következőket kell tudnia:

  • Meg kell tudnia nyitni a file-t, mivel az enumerátorok (mint XmlEnumerator) stream-mel működnek.
  • forrástípus (xml, bináris) alapján létre kell hoznia a megfelelő típusú enumerátort.

Kicsit zavaró, hogy két dolgot soroltunk fel, erről újra bevillan a Single Responsibility. De nézzük meg az implementációt:

internal class FileBasedDataset : IEnumerable<Person>
{
    private string path;
    private string formatType;

    internal FileBasedDataset(string path, string formatType)
    {
        this.path = path;
        this.formatType = formatType;
    }

    public IEnumerator<Person> GetEnumerator()
    {
        var source = File.OpenRead(this.path);
        return EnumeratorFactory.Create(source, this.formatType);
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator();
    }
}

A lényeges metódus a GetEnumerator(). Nem látszik bonyolultnak, inkább az gondolkodtat el, hogy két felelősség szorult ide: az adatforrás megnyitása, és az enumerátor létrehozása. Miért van ez? Az egyik felelősség azért került bele, mert kiszerveztük a parser-ekből a file nyitását. A másik felelősséget az iterátor pattern helyezte ide. Gond-e az, hogy két felelősség van itt, illeve megérné-e kimozgatni az egyiket?

Mivel az aggregate felelőssége létrehozni az iterátort, így csak a file megnyitását mozdíthatjuk ki. Megpróbálhatunk készíteni egy általánosabb aggregate-et, ami ráül egy stream-re, amit valaki más korábban valahogy létrehozott. Ekkor lesz egy ilyesmink:

internal class StreamBasedDataset : IEnumerable<Person>
{
    private Stream stream;
    private string formatType;

    internal StreamBasedDataset(Stream stream, string formatType)
    {
        this.stream = stream;
        this.formatType = formatType;
    }

    public IEnumerator<Person> GetEnumerator()
    {
        return EnumeratorFactory.Create(this.stream, this.formatType);
    }

    ...
}

Vajon működik ez az osztály rendesen? Persze, hogy nem! Két oka is van, hogy nem működik:

Az egyik, hogy korábban – egyébként megindokolt módon – a stream lezárását rábíztuk az iterátorra. Ez azt jelenti, hogy amint eldobják az egyik iterátort, amit az aggregátor elkészített, az lezárja az egyetlen stream-et. Így üt vissza a felelősségi körök, mint az életciklus management átcsoportosítgatása.

De van itt egy másik gond is: a stream nincs arra felkészítve, hogy egyszerre többen – több iterátor – olvasgassa. Így ha az iterátor nem zárná a stream-et, az elképzelés akkor sem működne, nem lehetne több iterátort létrehozni.

Visszatérve tehát a FileBasedDataset implementációjához, igazából nincs két felelősségi kör abban a kódban. Az iterátor pattern egyik lényege, hogy az iterátor olyan állapottal rendelkezzen, hogy követni tudja, hol tart az aggregált elemek között. Az “aggregált elemek” itt egy file. A .NET alatt pedig a Stream (FileStream) példány hordozza azt az állapotot, amely megmondja, hogy éppen hol tartunk a file olvasása közben. Bizonyos szempontból nem file nyitásról van itt szó, hanem egy olyan állapotváltozó létrehozásáról, ami rááll az első elemre.

Hogy áll most a fő ciklus?

Az eredeti csúnya nagy switch már eddig is sokat javult, az iterátor pattern alkalmazásával azonban a kódot még tovább lehet tömöríteni. A .NET-es osztályok intenzíven kihasználják az iterátor patternt, és nagyon sok minden tud fogadni aggregate-eket, azaz IEnumerable megvalósításokat. Többek között az a lista is, ami a Person példányokat gyűjtögeti file-ról file-ra. Ezek alapján a kód a következőképpen módosul:

var filesToProcess = this.GetFileList();
var sourceType = this.GetSelectedSourceType();

var persons = new List<Person>();

foreach (var file in filesToProcess)
{
    var dataset = new FileBasedDataset(file, sourceType);

    persons.AddRange(dataset);
} // foreach

Tovább is van, mondjam még?

Most már van egy egész flexibilis infrastruktúránk ahhoz, hogy új és új adatformátumokat illesszünk be a programunkba. Egy dologra kell figyelni az új adatforma beillesztésénél: nem szabad elfelejteni bővíteni az EnumeratorFactory-t. Bár tesztelésnél gyorsan fény derül a mulasztásra, mégis, érdemes elgondolkodni azon, hogyan lehetne még kényelmesebbé tenni az EnumeratorFactory-t. A következő cikkben erre térünk ki, és utána végre elkezdhetjük megoldani a feladatokat – talán beleférünk még a 90 percbe.

  1. C# Tech. interjú – Convention Based Factory - pro C# tology - devPortal

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: