C# Tech. interjú – Convention Based Factory

Az előző cikkben rendbetettük azt a kódot, ami különböző formátumú állományokból képes felolvasni a Person példányokat. Ennek a kódnak a része egy factory, amely egy formatType alapján képes létrehozni egy megfelelő enumerátort. Ez a factory így néz ki:

internal static class EnumeratorFactory
{    
    internal static 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

Az a kifogás ezzel a factory-val kapcsolatban, hogy minden új enumerátor esetén ide is be kell illeszteni a megfelelő case ágat. Ezzel szemben mi egy automata megoldást szeretnénk.

Coding by Convention

Az elmúlt pár évben divattá vált a konfigurációval és egyéb explicit beállításokkal szemben a Coding by Convention megközelítés. Ez annyit jelent, hogy a keretrendszer bizonyos konvenciók alapján maga próbálja kitalálni, hogy melyik objektumoknak melyik másikkal kell együttműködni. Nem véletlen a népszerűség, a konfiguráció sok esetben mechanikus, és így unalmas, könnyű eltéveszteni és könnyű elfelejteni.

Ahhoz, hogy egy konvenció alapú megoldással álljunk elő, első lépésben ki kell találni, hogy mi legyen a konvenció. Az előző részben az eredeti parser-eket átalakítottuk enumerátorokká, és az osztályok neve így xyEnumerator-ra változott. Az xy az adat formátumára utal. Amit szeretnénk, hogy az inputként kapott formatType-ból (mint “XML”) be tudjuk azonosítani az enumerátort (mint az XmlEnumerator), de úgy, hogy ne kelljen azt kézzel – switch vagy egyéb módon – összerendelni. Jelenleg a két formatType és a hozzájuk tartozó enumerátor:

  • “xml” – XmlEnumerator
  • “bin” – BinaryEnumerator

Azért, hogy legyen egy egyszerű konvenciónk, a BinaryEnumerator osztályt át kellene nevezni BinEnumerator-nak (vagy a formatType-ot “binary”-nak). Ebben az esetben, ha egy osztály Enumerator-ra végződik, akkor ő az “Enumerator” előtti string által megnevezett típust képes feldolgozni. Igen, ez kicsit törékeny konvenció, mert az Enumerator mint név túl általános, így könnyen illik olyan osztályra is, amire nem kellene. A kicsi kódunkban viszont egyelőre működni fog.

Az enumerátorok keresése

Az alkalmazásunk egyetlen assembly-ből áll, így nem kell összetett logikát kidolgozni arra, hogy milyen assembly-kben hol kutakodjunk. Egyszerűen azt az assembly-t vesszük, amelyikben a kód éppen fut. Bonyolultabb esetben végigmehetnénk például az alkalmazás könyvtárának összes assembly-jén.

Ha meg van az assembly, végigvesszük a benne található összes típust, és ellenőrizzük, hogy a típus neve megfelel-e a konvenciónak.

Az eddig elmondottak alapján az auto konfiguráció a következő ciklusra épül:

...
static readonly string pattern = "^(?<format>.*)Enumerator$";
static readonly Regex convention = new Regex(pattern, RegexOptions.Compiled);
...

// az összes típus abból az assembly-ből, amiben a kód fut:
foreach (var type in Assembly.GetExecutingAssembly().GetTypes())
{
    // név konvenció ellenőrzése
    var match = convention.Match(type.Name);

    if (match.Success)
    {
        // Meg van egy enumerátor, és a következő adatformátumot támogatja:
        var formatType = match.Groups[1].Value;
        ...
    } // if
} // foreach

Az előző cikkben az enumerátorokat úgy építettük fel, hogy a Stream-et, amin dolgoznak, konstruktor paraméterként kapják meg. Emiatt csak az a jelölt jöhet szóba az autokonfigurációnál, amelynek van egy Stream paramétert váró konstruktora. Ezt az információt könnyen megszerezhetjük a type-object-en keresztül:

var construtorInfo = 
        type.GetConstructor(
                BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, 
                Type.DefaultBinder, 
                new Type[] { typeof(Stream) }, 
                null);

if (construtorInfo != null)
{
  ...
}

Ha sikerült megszerezni a constructorInfo-t, az azt jelenti, hogy létre tudjuk hozni az enumerátor típus egy példányát dinamikusan. A fenti kódban a GetConstructor hívás a publikus és nem publikus konstruktorokat is keresi, ez azért van, mert az előzőekben az enumerátorokat internál láthatóságúnak készítettük.

Aktivátorok

.Net beépített aktivátor

A készítendő factory használata közben olyan működésre van szükségünk, hogy kapunk egy formatType-ot, mint “XML”, és ez alapján létre kell hozni egy enumerátor-t, mint XmlEnumerator. Ha meg van a létrehozni kívánt osztály típusa (typeof(XmlEnumerator)), akkor az Activator osztály segítségével könnyű létrehozni egy példányt.

Ehhez a fenti ciklusban, amely végigvesz minden típust, el kell tárolni, hogy melyik típus nevét melyik formátumra sikerült illeszteni:

static Dictionary<string,Type> typeMap = 
          new Dictionary<string,Type>(StringComparer.OrdinalIgnoreCase);
...

if (construtorInfo != null)
{
    typeMap[formatType] = type;
}

Így később a kreátor metódusból elő tudjuk szedni az adott formátum típushoz, hogy milyen osztályt kell létrehozni:

internal static IEnumerator<Person> Create(string formatType, Stream source)
{
    var instance = Activator.CreateInstance(
                        typeMap[formatType], 
                        BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, 
                        null,
                        new object[] {source},
                        CultureInfo.CurrentCulture);

    return instance as IEnumerator<Person>;
} // Create()

Hogy fut le a keresés?

Az automatikus konfiguráció része, hogy a kereső kód “magától” lefusson. Azt szeretnénk, hogy mire meghívjuk a factory-n a Create() metódust, addigra a typeMap már fel legyen töltve, és a factory osztályt ne kelljen explicit hívással inicializálni, hiszen az könnyen elfelejthető. Egy típus statikus konstruktora lefut, mielőtt a típus elkezdjük használni. Így az enumerátorok keresésére jó hely a EnumeratorFactory típus statikus konstruktora. A teljes átdolgozott EnumeratorFactory így néz ki:

internal static class EnumeratorFactory
{
    static readonly string pattern = "^(?<format>.*)Enumerator$";
    static readonly Regex convention = new Regex(pattern, RegexOptions.Compiled);

    static Dictionary<string,Type> typeMap = 
             new Dictionary<string,Type>(StringComparer.OrdinalIgnoreCase);

    static EnumeratorFactory()
    {
        foreach (var type in Assembly.GetExecutingAssembly().GetTypes())
        {
            var match = convention.Match(type.Name);

            if (match.Success)
            {
                var formatType = match.Groups[1].Value;

                var construtorInfo =
                        type.GetConstructor(
                            BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
                            Type.DefaultBinder,
                            new Type[] { typeof(Stream) },
                            null);

                if (construtorInfo != null)
                {
                    typeMap[formatType] = type;
                } // if
            } // if
        } // foreach
    } // EnumeratorFactory()

    internal static IEnumerator<Person> Create(string formatType, Stream source)
    {
        var instance = 
                Activator.CreateInstance(
                    typeMap[formatType],
                    BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
                    null,
                    new object[] { source },
                    CultureInfo.CurrentCulture);

        return instance as IEnumerator<Person>;
    } // Create()
} // class EnumeratorFactory

Expression Tree alapú aktivátor

A fenti megoldás működik ugyan, de az Activator class híresen lassú. Ebben az alkalmazásban ez nem okoz nagy gondot, így meg sem érné új megoldás után kutatni éles helyzetben. A tanulás kedvéért viszont érdemes tovább játszani. Régóta terjed egy legenda egy reflection emit alapú aktivátorról.

A nekem tetsző megoldást azonban csak pár hónapja fedeztem fel az MVC4 forráskódjában – bár ezután rákeresve kiderült, szintén régi trükkről van szó. Erre az alapelvre építjük rá most a Factory osztályunkat.

Lambda kifejezések és anonim metódusok

Ha c#-ban hagyományos módon rakunk össze egy activator-t, akkor azt megoldhatjuk például a következőképpen:

public class GoldenAlma : IAlma
{
    public GoldenAlma(int a)
    {
    }
}

...

Func<int, IAlma> activator = a => new GoldenAlma(a);
...            
var alma = activator(12);

Az “activator” delegate-et ezután körbeadogathatjuk a programban, a hívójának nem kell tudni pontosan, hogy hogyan és milyen konkrét típusú példány jön létre az IAlma interfész mögött.

Az aktivátort egy lambda kifejezéssel írjuk le. Ezt a kifejezést a C# fordító átalakítja egy metódussá egy generált (C#-ban nem is legális) névvel. Körülbelül a következőnek megfelelő kód fog előállni (feltéve, hogy a lambdát a Main metódusban írtuk le):

static IAlma <Main>b_0(int a)
{
    return new GoldenAlma(a);
}

...
Func<int, IAlma> activator = new Func<int, IAlma>(<Main>b_0);
...
var alma = activator(12);

A gondunk az, hogy a készítendő factory osztályunkban nem tudjuk előre, hogy milyen típust kell létrehozni new-val a lambda kifejezésben – hiszen a típusokat futás közben keressük meg. Csak olyan aktivátort tudunk gyártani, ami előre beállított típust hoz létre. Az lenne a jó, ha futás közben, igény szerint rakhatnánk össze, hogy milyen típus kerüljön a “new” kulcsszó mögé.

Szerencsére erre megoldást adnak az Expression Tree-k. Mik az Expression Tree-k? Első lépésként vegyük észre, hogy bár a saját programozási stílusomat követve szinte mindig var-t használok egy lokális változó deklarálásánál, a fenti kódban az activator esetében nem ezt tettem. Mi történne, ha a következőket írnám le:

var activator = a => new GoldenAlma(a);

Ez a kód nem fordul, és ennek oka van. A jobboldalon egy lambda expression szerepel, a fordító ennek a típusából kellene, hogy kitalálja, milyen típust kell a var helyén használnia. Miért nem teszi ezt meg? Azért, mert a lambda expression-nek nincs típusa. Miért nincs? Azért, mert a fenti szintaktikával két dolgot fejezhetek ki. Egyrészt leírhatók anonim metódusok. Ezt tettük meg korábban, ahol az anonimitásból végül a <Main>b__0 nevű metódus kerekedett.

A C#-ban azonban ugyanezzel a szintaktikával leírhatunk expression tree-ket is. A fordító azért nem fogadja el a var-t, mert nem tudja, hogy mire gondolunk, csak ha a látja az értékadás bal oldalát is.

Lambda kifejezések és Expression Tree-k

Fordítsuk le most a következő sort:

Expression<Func<int, IAlma>> activator = a => new GoldenAlma(a);

Ekkor a C# fordító a baloldal típusából (Expression<T>) látja, hogy nem egy metódust, hanem egy expression tree-t akartunk leírni. Ekkor nem fog egy <Main>b__0 nevű metódust generálni, mint korábban. Helyette a következő kódnak megfelelő IL kódot generálja (valójában a constructorInfo-t hatékonyabban szerzi meg, de az C#-ból nem megoldható):

ParameterExpression parameter = Expression.Parameter(typeof(int), "a");
ConstructorInfo construtorInfo = typeof(Alma).GetConstructor(...)

Expression<Func<int, IAlma>> activator = 
    Expression.Lambda<Func<int, IAlma>>(
        Expression.New(
            construtorInfo , 
            new Expression[] { parameter }), 
        new ParameterExpression[] { parameter  });

Mit látunk? A fordító olyan kódot generált, ami felépít egy adatszerkezetet az Expression osztály használatával. Ez az adatszerkezet írja le azt a lambda kifejezést, amit mi a C# forrásban megadtunk. A lambda kifejezés miatt a legkülső hívás az Expression.Lambda(). Akár mit is akar készíteni az Expression.Lambda() hívás a memóriában, az biztos, hogy kell neki az, amit az Expression.New()-hoz létre, ami nyilván a “new” kulcsszó által jelölt részét képviseli a C# kódban leírt lambda kifejezésnek. Ez az Expression.New() hívás szintén kap paramétereket, és egy komplexebb lambda kifejezésnél ez így menne tovább. A mi példánk kevéssé magyarázza meg, de ezek az Expression hívások sokszor logikailag egy fa szerkezetben hivatkozzák egymást, innen az expression tree elnevezés. A fenti kód valami hasonlót hoz létre a memóriában:

LikeStatExpressionGraph

Miért jó egy expression tree? A jósága két részképességéből adódik. Egyrészt dinamikusan össze lehet állítani őket futás közben. Ahogy a C# fordító felépített egy kódot a lambda kifejezésünk alapján, mi magunk is megtehetjük ezt, ugyanilyen hívásokkal, csak mindig más ConstructorInfo-t használva. Másrészt, az összerakott expression tree alapján IL kód generálható futás közben, azaz konkrétan egy metódust kapunk, ami azt fogja csinálni, amit a fával leírtunk.

Ha a fenti expression tree elkészülte után lefuttatjuk a következő sort:

Func<int, IAlma> activatorAsDelegate = activator.Compile() as Func<int, IAlma>;

Akkor ezt már nyugodtan használhatjuk szokásos delegate-ként:

var alma = activatorAsDelegate(12);

Most már csak annyi a dolgunk, hogy olyan aktivátort készítsünk, ami a számunkra kedvező típusű enumerátort gyártja le. Ez a fentiek tükrében nem olyan bonyolult, a következőt kell beilleszteni a félbehagyott kódunkba:

if (construtorInfo != null)
{
    var ctorParam = Expression.Parameter(typeof(Stream));

    Func<Stream, IEnumerator<Person>> activator =
        Expression.Lambda(
            Expression.New(
                construtorInfo,
                new Expression[] { ctorParam }),
            ctorParam).Compile() as Func<Stream, IEnumerator<Person>>;
     ...
}

A fenti kód csupán annyiban más a C# fordító által generált kódhoz képest, hogy mindig az éppen megtalált típushoz való constructorInfo-t tartalmazza. Ennek az activátornak a sebessége megközelíti a new kulcsszó használatának a sebességét.

Mit csináljunk az elkészített aktivátorral? Akkor lesz rá szükség, amikor az EnumeratorFactory.Create() metódust hívva egy adott formatType-hoz tartozó aktivátort kell lefuttatni. Amit tenni kell tehát, hogy az aktivátorokat a formátum leíró stringekhez kell map-pelni egy Dictionary-ben. A teljes kód végül így néz ki:

internal static class EnumeratorFactory
{
    static readonly string pattern = "^(?<format>.*)Enumerator$";
    static readonly Regex convention = new Regex(pattern, RegexOptions.Compiled);

    static Dictionary<string,Func<Stream, IEnumerator<Person>>> activators = 
        new Dictionary<string,Func<Stream,IEnumerator<Person>>>(StringComparer.OrdinalIgnoreCase);
     
    static EnumeratorFactory()
    {
        foreach (var type in Assembly.GetExecutingAssembly().GetTypes())
        {
            var match = convention.Match(type.Name);

            if (match.Success)
            {
                var formatType = match.Groups[1].Value;

                var construtorInfo = 
                        type.GetConstructor(
                            BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, 
                            Type.DefaultBinder, 
                            new Type[] { typeof(Stream) }, 
                            null);

                if (construtorInfo != null)
                {
                    var ctorParam = Expression.Parameter(typeof(Stream));

                    Func<Stream, IEnumerator<Person>> activator =
                            Expression.Lambda(
                                Expression.New(
                                    construtorInfo,
                                    new Expression[] { ctorParam }),
                                ctorParam)                           
                            .Compile() as Func<Stream, IEnumerator<Person>>;

                                                                           
                    activators[formatType] = activator;                     
                } // if
            } // if
        } // foreach
    } // EnumeratorFactory()

    internal static IEnumerator<Person> CreateEnumeratorFor(string formatType, Stream source)
    {
        Func<Stream, IEnumerator<Person>> activator;

        if (activators.TryGetValue(formatType, out activator))
        {
            return activator(source);
        }
        else
        {
            throw new NotSupportedException("cannot create enumerator for " + formatType); 
        } // else            
    } // CreateEnumeratorFor()
} // class EnumeratorFactory

Ezt a kódot érdemes még refaktorálni, hiszen nagyon sok felelőségi kör lett belezsúfolva egyetlen metódusba. De innentől többet nem kell törődnünk azzal, hogyan jön létre egy új típusú enumerátor. A keretrendszer automatikusan bekonfigurálja nekünk például az elkészítendő CsvEnumerator oszrályt, amíg tartjuk a név konvenciót.

  1. #1 by kazatkazdothu on 2013. April 15. - 19:35

    Azért itt nem igazán indokolt az Activator.CreateInstance helyett az Expression alapú megoldás. T.i. nagyon kétlem, hogy az idő nagy része a parser osztály létrehozása lenne.
    Ha nem hiszed, akkor légyszi futtasd le az alábbi kódot. Az eredmények:
    1000000 calls of direct constructor call takes 74 ms
    1000000 calls of constructor call using expression takes 83 ms
    1000000 calls of constructor call using Activator.CreateInstance takes 145 ms
    1000000 calls of constructor call using Activator.CreateInstance takes 169 ms
    1000000 calls of direct constructor call takes 87 ms
    1000000 calls of constructor call using expression takes 78 ms
    1000000 calls of constructor call using Activator.CreateInstance takes 1880 ms

    Azaz paraméter nélküli konstruktor esetén minimális a veszteség (2x annyi idő kell hozzá), egyéb esetben 1 nagyságrend (20x annyi idő kb.), de így is 1 millió objektum létre hozható 2 másodperc alatt.

    Kód:
    using System;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.IO;
    using System.Linq;
    using System.Linq.Expressions;

    public static class Program
    {
    public static void Main(string[] args)
    {
    Measure0(1000000);
    Measure1(1000000);
    }

    private static void Measure0(int count)
    {
    Func creator0 = () => new ClassWithDefaultConstructor();
    Func creator1 =
    Expression.Lambda<Func>(Expression.New(typeof(ClassWithDefaultConstructor).GetConstructors().Single())).Compile();
    Func creator2 = () => (ClassWithDefaultConstructor)Activator.CreateInstance(typeof(ClassWithDefaultConstructor));
    Func creator3 = Activator.CreateInstance;
    Measure(“direct constructor call”, count, creator0);
    Measure(“constructor call using expression”, count, creator1);
    Measure(“constructor call using Activator.CreateInstance”, count, creator2);
    Measure(“constructor call using Activator.CreateInstance”, count, creator3);
    }

    private static void Measure1(int count)
    {
    var streamParameter = Expression.Parameter(typeof(Stream));
    Func creator0 = stream => new ClassWithOneParameterConstructor(stream);
    Func creator1 =
    Expression.Lambda<Func>(
    Expression.New(typeof(ClassWithOneParameterConstructor).GetConstructors().Single(), streamParameter), streamParameter).Compile();
    Func creator2 =
    stream => (ClassWithOneParameterConstructor)Activator.CreateInstance(typeof(ClassWithOneParameterConstructor), stream);
    Measure(“direct constructor call”, count, creator0, new MemoryStream());
    Measure(“constructor call using expression”, count, creator1, new MemoryStream());
    Measure(“constructor call using Activator.CreateInstance”, count, creator2, new MemoryStream());
    }

    private static void Measure(string title, int count, Func creator, TParameter parameter)
    {
    List result = new List(count);
    creator(parameter);
    GC.Collect(2, GCCollectionMode.Forced);
    GC.WaitForFullGCComplete(1000);
    GC.WaitForPendingFinalizers();
    var stopwatch = Stopwatch.StartNew();
    for (int i = 0; i < count; i++)
    {
    result.Add(creator(parameter));
    }

    stopwatch.Stop();
    Console.WriteLine("{0} calls of {1} takes {2} ms", count, title, stopwatch.ElapsedMilliseconds);
    }

    private static void Measure(string title, int count, Func creator)
    {
    List result = new List(count);
    creator();
    GC.Collect(2, GCCollectionMode.Forced);
    GC.WaitForFullGCComplete(1000);
    GC.WaitForPendingFinalizers();
    var stopwatch = Stopwatch.StartNew();
    for (int i = 0; i < count; i++)
    {
    result.Add(creator());
    }

    stopwatch.Stop();
    Console.WriteLine("{0} calls of {1} takes {2} ms", count, title, stopwatch.ElapsedMilliseconds);
    }
    }

    public sealed class ClassWithOneParameterConstructor
    {
    private readonly Stream stream;

    public ClassWithOneParameterConstructor(Stream stream)
    {
    if (stream == null)
    {
    throw new ArgumentNullException("stream");
    }

    this.stream = stream;
    }
    }

    public sealed class ClassWithDefaultConstructor
    {
    private Stream stream;
    }

  1. Try Pattern - 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: