Spis treści:

Kategoria:C#


Generowanie obiektów z realnymi danymi w C#

Generowanie losowych obiektów - ikona magicznego kapelusza

Potrzeba wiarygodnych obiektów

Chyba każdy poważniejszy program wymaga testów. Metoda zapisująca dane pracowników będzie wymagała obiektu pracownika, metoda do obsługi faktur będzie operowała na obiektach reprezentujących faktury. Jest to przecież programowanie obiektowe. Zwykle obiekty takie tworzone są poprzez ręczne uzupełnianie wszystkich potrzebnych pól, a takie uzupełnianie rozsiane jest w różnych miejscach. O ile pojedyncze testy i pojedyncze, może kilkukrotne wypełnienie takiego obiektu nie jest kosztowne, o tyle wypełnianie tego samego kilkanaście razy może już być męczące. Jeszcze ina sytuacja to testy wydajnościowe. Chcemy przecież, aby nasze testy miały dużo danych. O ile ich zawartość nie ma wpływu na wydajność, można w pętli tworzyć taki sam obiekt. Wiele jednak testów wydajnościowych wymaga pewnej różnorodności danych. Widziałem już próby zastępowania ciągów tekstowych globalnymi identyfikatorami, ale i to ma swoją wadę. Wyobraźmy sobie, że jakaś zdalna usługa podmieniona jest na wersję lokalną, atrapę (ang. mock) i na tej podstawie tworzymy interfejs użytkownika. Znacznie lepiej jest wyświetlać prawdziwe imię i nazwisko niż jakiś GUID. Kolejny problem dotyczy algorytmów, w których wymagany jest jakiś statystyczny rozkład danych. Na przykład wzrost osób powinien oscylować w granicach 175 cm, z nielicznymi wyjątkami sięgającymi 150 cm i 200 cm. Takie wymogi stawiane są algorytmom eksploracji danych (ang. data mining).

A gdyby tak obiekty uzupełniały się same, według odpowiednich wskazówek definiowanych dla typu? To właśnie tym tematem postanowiłem się dzisiaj zająć.

Tworzenie obiektów ma być bajecznie proste

Idea programowania obiektowego zakłada istnienie klas, które realizują konkretne zadania. Implementację można ukryć - ważny jest interfejs. I tu pojawia się najważniejsza decyzja. Jak zamierzamy korzystać z obiektu? Uznałem, że najwygodniej będzie użyć następującej składni:

var person = generator.Create<Person>();

Obiekt generator to planowana klasa. Wiadomo, że nie wszystkich zadowoli typ ogólny. Jeżeli piszemy kod ręcznie, wtedy przekazanie nazwy typu jest wygodniejsze. Jeżeli zadanie ma być automatyzowane, a generator wywoływany dynamicznie, wtedy przekazanie obiektu klasy Type wyda się łatwiejsze:

var person = generator.Create(typeof(Person));

Jak się wkrótce okaże, generator będzie posiadał dwie podobnie skonstruowane przeciążenia metody Fill służące do uzupełniania gotowych, już skonstruowanych obiektów. Jak on to będzie robił? Pierwszym pomysłem było zwykłe rekurencyjne zejście w postaci:

  1. Pobierz z obiektu wszystkie właściwości typów prostych i uzupełnij je,
  2. pobierz z obiektu wszystkie właściwości typów złożonych i wykonaj tworzenie rekurencyjnie.

Pojawia się jednak problem rozpoznawania zawartości. Jeżeli wszystkie właściwości typu string miałyby przyjmować losowy wyraz, a każda właściwość typu int losową wartość z zakresu - wszystko byłoby proste. Chciałbym jednak, aby te wartości coś reprezentowały. Aby w polu reprezentującym imiona znajdowały się imiona, w polu reprezentującym wiek liczby z zakresu 1-95, a w polu reprezentującym wzrost realne wartości, najlepiej zgodne z rozkładem Gaussa.

Tu pojawił się pomysł atrybutów. Każda właściwość mogłaby mieć odpowiedni atrybut definiujący przeznaczenie tej właściwości. Właściwość FirstName miałaby atrybut oznaczający, że cały mechanizm ma uzupełnić to pole wartościami z puli imię, właściwość Height miałaby inny atrybut, być może sparametryzowany, sugerujący, że tutaj należy wstawić losowe wartości z przedziału. To podejście zbliża nas do rozwiązania, ale również ma wady. Jeżeli korzystamy z obiektów obcych i nie możemy zmodyfikować ich kodu źródłowego, wtedy dodanie atrybutów może być bardzo trudne.

Piszę to wszystko celowo, aby pokazać, że planowanie całego mechanizmu składa się z szeregu decyzji i rozważenia listy potencjalnych problemów. Jak to z algorytmami. Nie zawsze pierwszy pomysł jest doskonały.

Taka iteracyjna analiza doprowadziła do rozwiązania wystarczająco elastycznego, choć początkowo uciążliwego. Sposób generowania obiektów będzie definiowany poprzez API konfiguracyjne dla typu. Będziemy łączyć typ, właściwość i klasę generatora. Gdy mechanizm będzie musiał stworzyć obiekt wskazanego typu, pobierze wszystkie jego właściwości i wyszuka w konfiguracji odpowiednie wpisy. Jeżeli klasa generatora zostanie odnaleziona, zostanie ona wywołana, a jej wartość przypisana do właściwości. Jeżeli klasa generatora nie będzie istniała, mechanizm taką właściwość pominie.

Cały mechanizm polega na jednokrotnej konfiguracji i wielokrotnym użyciu.

Schemat działania

Zdaję sobie sprawę, że taki opisowy wywód może być niezrozumiały. Postaram się przedstawić to na rysunku:

Schemat działania mechanizmu konfiguracji
Schemat działania mechanizmu konfiguracji

Każdy typ w generatorze ma swoje drzewo budowane przed pierwszym utworzeniem obiektu. Tworząc takie drzewo definiujemy wartości, które znajdą się we właściwościach nowego obiektu, w tym przypadku typu Person. Mechanizm tworzący można sobie wyobrazić jako prawie równoważną instrukcję C#:

var person = new Person()
{
    FirstName = FirstNameGenerator.GetValue(),
    LastName = LastNameGenerator.GetValue(),
    Age = RangeGenerator.GetValue(),
    Height = GaussGenerator.GetValue(),
    Address = ObjectGenerator.GetValue()
};

A wracając do konfiguracji - należy stworzyć API, które pozwoli na, w miarę łatwe i bezpieczne, utworzenie drzewa.

API konfiguracyjne

Konfigurację można zrealizować na kilka sposobów. Najprostsze w implementacji jest wykorzystanie słownika i przekazywanie nazw właściwości, które chcemy uzupełniać. Podanie właściwości w postaci tekstowej nie jest jednak bezpieczne. Po pierwsze bardzo łatwo popełnić literówkę, a po drugie, bardzo uciążliwe staje się modyfikowanie kodu, w szczególności nazw właściwości generowanych klas.

Postanowiłem, że właściwości będą wyciągane z wyrażeń lambda przekazanych w postaci parametru. Wykorzystany zostanie mechanizm typów ogólnych, który przy okazji załatwi kontrolę poprawności typów.

Wspomniałem wcześniej, że każdy typ w generatorze ma swoją konfigurację, swoje drzewo. Generator będzie posiadał metodę Configure przyjmującą typ konfigurowanego obiektu:

readonly Dictionary<Type, GeneratorConfiguration> _generators = new Dictionary<Type, GeneratorConfiguration>();

public GeneratorConfiguration<T> Configure<T>()
{
    var typedGenerator = new GeneratorConfiguration<T>();
    _generators.Add(typeof(T), typedGenerator);
    return typedGenerator;
}

Do tej pory wszystko jest proste. Trochę ciekawsze rzeczy dzieją się w samej konfiguracji, to jest w klasie GeneratorConfiguration. Przypomnę, że klasa konfiguracji ma w ostatecznym kształcie ustawiać wartości pól obiektów wskazanego typu. To dlatego powinna mieć jakąś metodę do tego przeznaczoną:

public class GeneratorConfiguration
{
    public virtual void Set(object obj, Generator generator)
    {

    }
}

Pierwszym parametrem jest obiekt, który należy ustawić. Drugim jest generator wykorzystywany do tworzenia obiektu. Do czego ten drugi parametr jest potrzebny? Założyłem, że możliwe będzie współistnienie kliku generatorów z różnymi konfiguracjami. Zakładam też, że dla bardziej skomplikowanych zastosowań może się okazać konieczne przekazanie do funkcji tworzącej dodatkowych informacji, pewnego kontekstu wywołania. Okaże się również wkrótce, że przekazywany generator przyda się do uproszczenia całego mechanizmu.

Nie wspomniałem o jednej rzeczy: pokazana powyżej klasa GeneratorConfiguration nie ma typu. Przyglądając się jednak metodzie Configure możemy dostrzec, że zwracane jest GeneratorConfiguration<T>. Ten typ pozwoli nam przerzucić pracę sprawdzania poprawności wyrażeń na kompilator. Jak? Popatrzmy na poziom definiowania właściwości.

Drzewa wyrażeń sposobem na właściwości

Gdy piszemy kod programu, to jest instrukcje języka wysokopoziomowego, kompilator musi je jakoś przetworzyć na język maszynowy. Jak on to robi? Chyba wszystkie najpopularniejsze kompilatory budują drzewa składniowe reprezentujące poszczególne instrukcje. Dla nas proste wyrażenie 2+3*4 jest na tyle oczywiste, że nie dostrzegamy tu żadnych drzew. Dla kompilatora to dwupoziomowe drzewo. Na pierwszym poziomie jest operacja sumy, gdzie lewym argumentem jest 2, a prawym... wynik kolejnego wyrażenia. To wyrażenie to iloczyn, gdzie lewym argumentem jest 3, a prawym 4. Drzewo wyliczane jest od liści. Warto zwrócić uwagę na budowę drzewa dla podobnego wyrażenia 2*3+4 i kolejność operatorów - drzewo będzie wyglądało inaczej.

Podobnie działa LINQ dla SQL. Piszemy zestaw instrukcji from, where, select i oczekujemy, że zostaną one przetłumaczone na język SQL. Jak to się dzieje? Właśnie dzięki mechanizmowi drzew wyrażeń. Jakis kod, napisany przez zespół .NET przetwarza takie drzewa i na ich podstawie tworzy odpowiednie instrukcje. Generator obiektów również takie drzewo przetworzy, ale tylko w bardzo ograniczonym zakresie. Jedynym wyrażeniem na jakie zezwoliłem będzie odwołanie się do właściwości. Omówienie całego zagadnienia drzew wyrażeń jest zbyt skomplikowane, ale mam nadzieję, że pokazany poniżej krótki fragment da się zrozumieć:

public class GeneratorConfiguration<T> : GeneratorConfiguration
{
    private readonly Dictionary<string, PropertyContext> _attributeGenerators = new Dictionary<string, PropertyContext>();

    public GeneratorConfiguration<T> AddRule<TU>(Expression<Func<T, TU>> propertyExpression, BaseGenerator propertyGenerator)
    {
        var body = propertyExpression.Body as MemberExpression;
        if (body != null)
        {
            var expression = body.Member as PropertyInfo;
            if (expression != null)
            {
                _attributeGenerators.Add(expression.Name,
                    new PropertyContext { PropertyInfo = expression, PropertyGenerator = propertyGenerator, Type = expression.PropertyType });
                return this;
            }
        }
        throw new ArgumentException("Only property expressions allowed.");
    }

Wewnętrznie przechowywany jest słownik nazw właściwości oraz powiązany z nimi kontekst. Ten kontekst będzie bardzo prostą klasą zawierającą informacje o ustawianej właściwości. Popatrzmy na listing:

public class PropertyContext
{
    public Type Type;
    public PropertyInfo PropertyInfo { get; set; }
    public BaseGenerator PropertyGenerator { get; set; }
}

Wróćmy jednak do sposobu uzupełniania tego słownika. Pojedynczy wpis będzie realizowany przy pomocy metody AddRule. Będzie to tzw. reguła uzupełniania właściwości. Pierwszy parametr to wspomniane drzewo wyrażeń, rozpoznawalne po typie Expression. Jest ono reprezentowane przez funkcję przyjmującą konfigurowany typ obiektu oraz zwracającą typ wykrywany przez kompilator, będący naszą właściwością. Wywołanie będzie przypominało definicję elementów input w ASP.NET:

configuration jest typu GeneratorConfiguration<Person>
configuration.AddRule(p => p.FirstName, new FirstNameGenerator);

Zapis jest bardzo zwięzły, ale daje nam pełną kontrolę typów. No, prawie pełną.

Co się jednak dzieje w środku metody AddRule? Nasze wyrażenie lambda p => p.FirstName konwertowane jest przez kompilator na drzewo wyrażeń już na etapie przyjmowania parametru. Dzięki temu dostajemy możliwość przetwarzania tego drzewa wewnątrz. Pragnę jeszcze raz uspokoić - naszym drzewem będzie tylko odwołanie się do właściwości, a takie drzewo ma bardzo prostą budowęDrzewa wyrażeń mogą zawierać wszystkie operacje dające się wyrazić w języku C#. W praktyce pozwalają na osiągnięcie nawet tego, czego w języku C# wyrazić się nie da.. Ciało całego wyrażenia znajduje się we właściwości Body drzewa. Tam powinien znajdować się typ MemberExpression. Reprezentuje on odwołanie się do składowej klasy, to jest pola lub właściwości. Jeżeli tak nie jest, pojawia sie wyjątek - nie dopuszczamy skomplikowanych wyrażeń. Jeżeli znaleźliśmy odwołanie do składowej, możemy zagłębiać się dalej. Klasa MemberExpression ma właściwość Member, która zawiera obiekt typu MemberInfo, a w zasadzie jedną z klas pochodnych:

  • obiekt typu FieldInfo jeżeli składową jest zwykłe pole,
  • obiekt typu PropertyInfo jeżeli składową jest właściwość.

Metodę bardzo łatwo rozszerzyć tak, aby przyjmowała również zwykłe pola. Ja postanowiłem ją ograniczyć tylko do właściwości.

Jeżeli próba pozyskania obiektu klasy PropertyInfo powiodła się, uzupełniamy wewnętrzny słownik. Można w tym miejscu generować jakieś niestworzone struktury danych, wstępnie kompilować kod generujący w celu uzyskania wysokiej wydajności, ale mogłoby to niepotrzebnie skomplikować kodMożna oczywiście, co wydaje się nawet rozsądniejsze, skompilować przy pierwszym odwołaniu całą funkcję tworzącą nowy obiekt.. Dla celów demonstracyjnych takie rozwiązanie uznałem za najlepsze.

Ustawianie właściwości obiektu

Po przeanalizowaniu drzewa samo ustawianie wydaje się już bardzo łatwe. Popatrzmy na poniższy listing:

public class GeneratorConfiguration<T> : GeneratorConfiguration
{
    ...

    public override void Set(object obj, Generator generator)
    {
        foreach (var generatorConfig in _attributeGenerators.Values)
        {
            generatorConfig.PropertyInfo.SetValue(obj,
                generatorConfig.PropertyGenerator.Generate(new GeneratorContext(generatorConfig) { Generator = generator }));
        }
    }

Gdy otrzymamy obiekt do uzupełnienia, pobieramy wszystkie ustawienia z wewnętrznego słownika i dla każdego z tych ustawień wywołujemy metodę Generate przekazanego generatora. Nie wspominałem do tej pory szerzej o obiektach generatorów i tak pozostanie. Ich zadaniem jest tylko i wyłącznie wygenerować odpowiednią wartość, bazując na danych z kontekstu. Zostanie to opisane w oddzielnym podpunkcie.

Na tym w zasadzie kończy się cały mechanizm konfiguracji. To, co warto jeszcze napisać, znajduje się w metodzie AddRule. Zwracany jest tam wskaźnik this, co nie jest przypadkowe. Pozwala to budować tzw. płynne API (ang. fluent API), znane z wielu bibliotek, nie tylko tych w języku C#, ale także Java, JavaScript, C++. Kto choć raz pisał coś w jQuery z pewnością wie o co chodzi. Możliwe jest wykorzystanie między innymi takiego zapisu:

configuration
    .AddRule(a => a.FirstName, new FirstNameGenerator())
    .AddRule(a => a.Age, new RangeGenerator { Min = 18, Max = 95 });

Chcemy przecież, aby nasza konfiguracja była jak najłatwiejsza.

Zestaw generatorów

Mamy już mechanizm konfiguracji, mamy też już jakąś bazę generowania nowych obiektów. Czas przyjrzeć się temu, co nadaje ton całemu procesowi - generatorom. Na poprzednich listingach dało się już dostrzec ślad generatorów w postaci klas BaseGenerator, FirstNameGenerator oraz RangeGenerator. Czas im się przyjrzeć z bliska.

Bazową klasą dla wszystkich generatorów jest BaseGenerator. To zwykła abstrakcyjna klasa, której istnienie podyktowane jest tylko koniecznością wymuszenia pewnego wspólnego interfejsu dla reszty klas - jest to metoda Generate, której zadaniem jest wygenerować dane. Analiza kliku klas pochodnych zmusiła mnie do drobnej zmiany i dodania do klasy bazowej jeszcze jednej składowej - generatora liczb pseudolosowych. Prawie każdy generator dziedziczący z BaseGenerator miał coś wspólnego z losowością. Popatrzmy na ostateczny kształt bazowej klasy BaseGenerator zaprezentowanej poniżej:

public abstract class BaseGenerator
{
    public static Random Random { get; private set; }

    static BaseGenerator()
    {
        Random = new Random();
    }

    public abstract object Generate(GeneratorContext generatorContext);
}

Teraz dopiero zaczyna się cała zabawa w rozszerzanie. Budowa różnych obiektów jest na tyle różna, że będziemy potrzebować całej grupy generatorów. Generator imion powinien generować losowe wartości ze zbioru imion, generator wieku powinien generować losowe wartości z zakresu, generator całych obiektów będzie z pewnością uzależniony od rodzaju generowanego obiektu. Jeszcze inaczej powinien się zachowywać generator tablic i kolekcji. Nie zamierzam przedstawiać tutaj wszystkich możliwości. Skupię się tylko na tych najważniejszych, pokazując przy okazji moc całego mechanizmu. Okazuje się bowiem, że otrzymaliśmy bardzo ogólny, łatwo rozszerzalny mechanizm.

Generator losowych liczb z zakresu

Pierwszy pokazany generator będzie chyba najprostszy ze wszystkich. Idealnie nadaje się do generowania identyfikatorów, wieku osoby, liczby sztuk zakupionych towarów, numeru mieszkania. Nazwałem go sobie RangeGenerator.

public class RangeGenerator : BaseGenerator
{
    public int Min { get; set; }
    public int Max { get; set; }

    public RangeGenerator()
    {
        Max = 100;
    }

    public override object Generate(GeneratorContext generatorContext)
    {
        return Random.Next(Min, Max);
    }
}

Generator pokazuje przy okazji sposób przekazywania parametrów. O ile nie podamy tych parametrów jawnie, klasa przyjmie 0 dla wartości minimalnej oraz 100 jako ograniczenie górne. Metoda Generate zwraca losową wartość z zakresu.

Generator wartości tekstowych ze zbioru

Generator tego typu ma bardzo szerokie zastosowanie. To na jego podstawie można zrealizować wybieranie losowego imienia ze zbioru, losowego nazwiska, losowej nazwy miasta. Popatrzmy na przykład:

public class FirstNameGenerator : BaseGenerator
{
    static readonly string[] Names = { "Adam", "Andrzej", "Bogumił", "Czesław", "Damian", "Dariusz", "Dorota", "Emil", "Franciszek", "Grzegorz", "Hugo", "Igor", "Jan", "Paweł" };

    public override object Generate(GeneratorContext generatorContext)
    {
        return Names[Random.Next(Names.Length)];
    }
}

Znów, sama implementacja metody Generate jest trywialna. Wybieramy losowy element kolekcji posługując się losową wartością z zakresu. Klasa jest na tyle powszechna, że wytworzenie z niej samej klasy bazowej dla innych wydaje się dobrym rozwiązaniem. Pokazana klasa ma z góry zdefiniowaną kolekcję wartości. Nic nie stoi na przeszkodzie, aby tę kolekcję wczytywać z pliku konfiguracyjnego lub nawet z jakiejś zewnętrznej bazy danych.

Bardzo podobne rozwiązanie przyjęte jest w klasie generującej towary w jakimś potencjalnym, hipotetycznym sklepie:

public class ItemGenerator : BaseGenerator
{
    private static readonly string[] Adjectives = { "Mały", "Duży", "Profesjonalny", "Czerwony", "Wielozadaniowy" };
    private static readonly string[] Nouns = { "odkurzacz", "młotek", "śrubokręt", "klej", "gips" };

    public override object Generate(GeneratorContext generatorContext)
    {
        return string.Format("{0} {1}", Adjectives[Random.Next(Adjectives.Length)], Nouns[Random.Next(Nouns.Length)]);
    }
}

Zasada jest bardzo podobna, ale nie identyczna. Z dwóch zbiorów wybierane są losowe elementy, które wystarczy tylko skleić. Otrzymujemy w ten sposób wartości postaci Mały odkurzacz, Profesjonalny klej i tym podobne. Otrzymujemy dzięki temu Adjectives.Length * Nouns.Length różnych wartości. Niektórzy dostrzegą pewnie problemy z gramatyką, na przykład pary typu Mały piłka, ale i to da się rozwiązać posługując się tylko tym, co otrzymuje generator.

Generator typu wyliczeniowego

Nieco trudniejszy na pierwszy rzut oka wydaje się generator wartości typu wyliczeniowego. To tutaj po raz pierwszy przydadzą się informacje zapisane w kontekście generatora, a w szczególności typ ustawianej właściwości. Popatrzmy na przykładową implementację pokazaną poniżej:

public class EnumGenerator : BaseGenerator
{
    public override object Generate(GeneratorContext generatorContext)
    {
        var array = generatorContext.Type.GetEnumValues();
        return array.GetValue(Random.Next(array.Length));
    }
}

Znów, pełna implementacja wymagałaby kilku dodatkowych linijek potrzebnych do obsługi błędów, być może jakiegoś bufora dodanego dla celów optymalizacyjnych. Pozostawiłem tylko to, co stanowi serce operacji.

Generator z możliwością tworzenia rozkładów normalnych Gaussa

Pokazane powyżej generatory nie były zbyt finezyjne, co nie umniejsza ich szerokiego zastosowania. Tym razem pokażę coś ciekawszego. Wiemy, że rozkład wielu danych nie jest liniowy. Wzrost, waga, zarobki - to wszystko wartości, których prawdopodobieństwo wystąpienia nie jest liniowe. Znacznie więcej dorosłych osób ma wzrost w przedziale 170 - 180 cm niż w przedziale 190 - 200 cm. Co nie znaczy, że w tej drugiej grupie nikogo nie ma. Są takie osoby, ale pojawiają się rzadziej. Taki nieliniowy rozkład również można generować a jedna z implementacji pokazana jest poniżej:

public class GaussGenerator : BaseGenerator
{
    private double _z1, _z2;
    private bool _generate;
    public double Variance { get; set; }
    public double Mean { get; set; }

    public GaussGenerator(double mean, double variance)
    {
        Mean = mean;
        Variance = variance;
    }

    public override object Generate(GeneratorContext generatorContext)
    {
        _generate = !_generate;
        if (!_generate)
            return Cast(_z2 * Variance + Mean, generatorContext.Type);

        double u1, u2;
        do
        {
            u1 = Random.NextDouble();
            u2 = Random.NextDouble();
        } while (u1 <= double.Epsilon);

        _z1 = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Cos(2 * Math.PI * u2);
        _z2 = Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2 * Math.PI * u2);
        return Cast(_z1 * Variance + Mean, generatorContext.Type);
    }

    private object Cast(double value, Type type)
    {
        return Convert.ChangeType(value, type);
    }
}

Nie zamierzam opisywać szczegółów tego algorytmu, bo nie o tym jest ten artykuł. Znany jest on pod nazwą przekształcenia Boxa-Mullera (transformacji Boxa-Mullera).

Generator obiektów

Generowanie tylko typów prostych nie stanowiło wyzwania. Co jednak wtedy, gdy nasz generator napotka właściwość będącą reprezentantem czegoś bardziej złożonego, na przykład struktury lub klasy? Co to za generator bez możliwości generowania klas? Jak miałaby wyglądać konfiguracja czegoś takiego? Okazuje się, że wszystko jest bardzo proste:

public class ObjectGenerator : BaseGenerator
{
    public override object Generate(GeneratorContext generatorContext)
    {
        return generatorContext.Generator.Create(generatorContext.Type);
    }
}

Co tu się dzieje? Znów korzystamy z kontekstu, który przetrzymuje referencję do bieżącego generatora. Mam tutaj na myśli generator globalny, nie generatory właściwości. To ten generator globalny, który konfigurujemy. Skąd ten generator wie, jak uzupełnić obiekt wewnętrzny? A z konfiguracji! Konfiguruje się kolejny typ. Załóżmy, że mamy klasę Person, która posiada właściwość typu Address. Przykładowa konfiguracja mogłaby wyglądać następująco:

var generator = new Generator();
generator.Configure<Person>()
    .AddRule(a => a.FirstName, new FirstNameGenerator())
    ...
    .AddRule(a => a.Address, new ObjectGenerator());
generator.Configure<Address>()
    .AddRule(a => a.City, new CityGenerator())
    .AddRule(a => a.Country, new CountryGenerator());

W ten sposób możliwe staje się generowanie nie tylko klasy Address jako składowej innej klasy, ale również jako klasy samodzielnej!

Generowanie losowych tablic

Niniejszy podpunkt dotyczy tablic, ale zastosowane tutaj rozwiązanie można z łatwością rozszerzyć na dowolne kolekcje (typy IList oraz IEnumerable), a nawet słowniki (typ IDictionary). Pokazany poniżej generator będzie łączył kilka technik, między innymi parametry (do określenia długości, podawanej jako zakres) oraz wykorzystanie innych generatorów (do generowania elementów w tablicy). Takie podejście pozwala między innymi na utworzenie tablicy z elementami pozostającymi w relacji dziedziczenia z typem bazowym tablicy. Jak mogłaby wyglądać taka implementacja? Popatrzmy na poniższy listing:

public class ArrayGenerator : BaseGenerator
{
    BaseGenerator ItemGenerator { get; set; }
    public int MinLength { get; set; }
    public int MaxLength { get; set; }

    public ArrayGenerator(BaseGenerator itemGenerator, int minLength = 1, int maxLength = 4)
    {
        ItemGenerator = itemGenerator;
        MinLength = minLength;
        MaxLength = maxLength;
    }

    public override object Generate(GeneratorContext generatorContext)
    {
        if (!generatorContext.PropertyInfo.PropertyType.IsArray)
            throw new ArgumentException("Array generator could only be used on array element: {0}", generatorContext.PropertyInfo.Name);

        var itemType = generatorContext.PropertyInfo.PropertyType.GetElementType();
        var array = Array.CreateInstance(itemType, Random.Next(MinLength, MaxLength));
        var newGeneratorContext = new GeneratorContext(generatorContext)
        {
            Type = itemType,
            PropertyGenerator = ItemGenerator
        };
        for (int i = 0; i < array.GetLength(0); i++)
        {
            array.SetValue(ItemGenerator.Generate(newGeneratorContext), i);
        }
        return array;
    }
}

Fragment wymaga chyba drobnych wyjaśnień. Najpierw pobieramy typ elementów tablicy metodą GetElementType, by następnie stworzyć nową tablicę o długości z zakresu MinLength - MaxLength. Potem trzeba zrobić coś dodatkowego - zmodyfikować kontekst. Nowym typem staje się typ elementu (zamiast typu będącego tablicą), a nowym generatorem staje się generator elementu (zamiast generatora tablicy). Do każdej komórki tablicy wstawiany jest nowy obiekt wygenerowany przy pomocy przekazanego generatora. Tak utworzona tablica staje się nowym obiektem, którą ogólny mechanizm przypisze do przetwarzanej właściwości.

Nowe generatory sposobem na rozszerzenie możliwości

Mam nadzieję, że udało mi się pokazać moc generatorów. Stosując podobne techniki można generować praktycznie dowolne struktury z różnymi wartościami. Wspomniałem o możliwości generowania losowych klas ze wspólną klasą bazową, ale nie napisałem jak. A nie jest to trudne - wystarczy napisać swoją klasę dziedziczącą z klasy BaseGenerator, a w środku stworzyć, w zależności od pobranej losowej wartości jedną spośród dostępnych klas pochodnych. Można to napisać na piechotę, ale można też przyjąć kolekcję generatorów w postaci parametrów konstruktora. Otrzymamy listę generatorów podobną do listy imion! Wystarczy już tylko wybrać jeden losowy generator i wywołać jego metodę Generate.

To, że generator jest zwykłą klasą, otwiera nam drzwi do nieograniczonej wręcz funkcjonalności. Może on stosować buforowanie, łączyć się z bazą danych. Generator może być współdzielony przez wszystkie pola i przechowywać stan. Dokładnie tak, jak każda inna klasa C#. Może zapewniać unikalność, generować sekwencyjne identyfikatory. Jak? Wystarczy umieścić w nowym generatorze właściwość przechowującą ostatnio wygenerowany identyfikator - na początku ustawić go na 0 i zwiększać przy każdym wywołaniu funkcji Generate.

Kontekst generatora

W żadnym z pokazanych przykładów nie byłem zmuszony do generowania wartości w zależności od wartości innych pól obiektu. A i taka potrzeba może się prędzej czy później pojawić. Przypomnę w tym miejscu, że pomiędzy kolejnymi wywołaniami kolejnych generatorów przekazywany jest kontekst. Tam jest informacja o ustawianej właściwości, jej typie oraz wykorzystywanym generatorze. A gdyby tak w tym kontekście przekazywać obiekt, który jest uzupełniany? Klasy dziedziczące z BaseGenerator mogłyby dostać się do tego obiektu, odczytać już ustawione wartości i na tej podstawie podjąć decyzję o generowaniu wartości z jednego lub drugiego zbioru. Mogłoby to być wykorzystywane podczas generowania wzrostu u osób - inny rozkład pojawiałby się dla kobiet, inny dla mężczyzn.

Zdarza się również, że obiekty mają tzw. zależności cykliczne. Obiekt A ma referencję do obiektu B, a obiekt B do obiektu A. Co się stanie? Mechanizm podczas tworzenia obiektu A odkryje, że musi zrobić obiekt B. Zacznie go robić i dostrzeże, że trzeba mu obiektu A. Zacznie go robić i... zaraz, zaraz! Otrzymamy pętlę nieskończoną. Rozwiązaniem tego problemu może być rozszerzenie klasy generatora, który również przekazywany jest w kontekście. W ramach jednej sesji tworzenia obiektu mógłby zapamiętywać referencje do obiektów wskazanych typów i wykorzystywać je ponownie przy próbie uzupełnienia kolejnej właściwości tego typu.

Przykład użycia

Na koniec wypadałoby pokazać jak cały mechanizm może być wykorzystany. Popatrzmy na przykładową konfigurację i wywołanie metody:

var generator = new Generator();
generator.Configure<Person>()
    .AddRule(a => a.FirstName, new FirstNameGenerator())
    .AddRule(a => a.Age, new RangeGenerator { Min = 18, Max = 95 })
    .AddRule(a => a.Status, new EnumGenerator())
    .AddRule(a => a.Height, new GaussGenerator(175, 10))
    .AddRule(a => a.Items, new ArrayGenerator(new ItemGenerator()))
    .AddRule(a => a.Address, new ObjectGenerator())
    .AddRule(a => a.Addresses, new ArrayGenerator(new ObjectGenerator(), 2, 2));
generator.Configure<Address>()
    .AddRule(a => a.City, new CityGenerator())
    .AddRule(a => a.Country, new CountryGenerator());

for (int i = 0; i < 15; i++)
{
    var person = generator.Create<Person>();
    Console.WriteLine(person);
}

CountryGenerator oraz CityGenerator zbudowane są podobnie do FirstNameGenerator. Przykładowe uruchomienie takich instrukcji może wygenerować następujące klasy (klasy posiadają nadpisaną metodę ToString w celu lepszego pokazania rezultatów):

Grzegorz,     42,  178,     Active, {Katowice, Białoruś}
    [Mały młotek, Profesjonalny odkurzacz]

Franciszek,   30,  177,   Rejected, {Otwock, Polska}
    [Mały śrubokręt, Czerwony młotek, Czerwony śrubokręt]

Grzegorz,     86,  179,     Active, {Kraków, Czechy}
    [Duży odkurzacz, Mały młotek, Czerwony śrubokręt]

Paweł,        54,  169,   Rejected, {Radzymin, Łotwa}
    [Mały klej, Profesjonalny odkurzacz, Wielozadaniowy młotek]

Damian,       48,  174,     Active, {Wrocław, Czechy}
    [Wielozadaniowy odkurzacz, Mały gips, Mały gips]

Adam,         94,  191,   Rejected, {Kraków, Łotwa}
    [Duży klej]

Grzegorz,     22,  160,   Rejected, {Cisna, Niemcy}
    [Czerwony klej, Czerwony gips, Mały klej]

Adam,         23,  187,     Active, {Cisna, Białoruś}
    [Czerwony młotek]

Hugo,         58,  184,     Signed, {Kraków, Białoruś}
    [Czerwony gips, Profesjonalny gips, Duży odkurzacz]

Hugo,         19,  175,     Signed, {Radzymin, Białoruś}
    [Wielozadaniowy klej, Czerwony gips]

Paweł,        77,  187,     Active, {Cisna, Niemcy}
    [Mały odkurzacz, Profesjonalny odkurzacz]

Jan,          20,  173,     Active, {Cisna, Rosja}
    [Czerwony gips]

Paweł,        55,  160,     Active, {Katowice, Słowacja}
    [Czerwony klej]

Emil,         44,  172,     Signed, {Staszów, Łotwa}
    [Profesjonalny młotek, Mały gips, Profesjonalny odkurzacz]

Grzegorz,     76,  173,     Signed, {Otwock, Polska}
    [Duży odkurzacz, Mały klej, Wielozadaniowy klej]

Dane, gdyby ktoś nie mógł odgadnąć, prezentowane są w postaci: imię, wiek, wzrost, status, obiekt adresu, a w drugiej linii tablica zakupionych towarów.

Podsumowanie

Projekt wygląda na niedokończony - i tak rzeczywiście jest. O ile koncepcja, plan i architektura zostały starannie przemyślane, o tyle implementacja została odroczona. Potrzeba powstawania nowych klas generujących będzie się pojawiała w miarę... potrzeby uzupełniania różnych klas. To z kolei doprowadzi do wyłapania pewnych wspólnych wzorców i być może rozszerzenia drzewa dziedziczenia. Konfiguracja, obecna w tej chwili w kodzie programu, mogłaby być przeniesiona do plików konfiguracyjnych, sam kod mógłby być lepiej zabezpieczony przez nieprawidłowymi i niespodziewanymi sytuacjami. Pełny kod źródłowy wykorzystany w artykule można pobrać spod adresu umieszczonego na końcu.

Mile widziane są wszelkie sugestie i pytania. Zachęcam do dzielenia się zarówno wątpliwościami jak i sposobami ulepszeń. Bardzo cenne byłyby informacje o typach i strukturach (a także potrzebnych generatorach), które pojawiają się w Państwa projektach, a które nie zostały tutaj poruszone.

Kategoria:C#

, 2015-10-15

Brak komentarzy - bądź pierwszy

Dodaj komentarz
Wyślij
Ostatnie komentarze
chcę dodać kolumnę, która będzie połączeniem dwóch innych istniejących już kolumn, jak powinien wyglądać scrypt?
Przydałyby się jeszcze 2 rzeczy do cz. 3 i byłoby superanckie.
1. Na starcie sortuje wg jakiejś kolumny i tam jest już strzałeczka. Widok takiej strzałeczki daje znać użytkownikowi, że taką tabele można sortować, a na razie pojawia się ona tylko po kliknięciu.
2. Uwzględnienie polskich znaków, bo np. przy sortowaniu Nazwisk i Imion jest to bardzo uciążliwe.
Ogólnie bardzo fajnie i prosto.
PS. Jest ten artykuł z jQuery już dostępny.
bardo ciekawe , można dzięki takim wpisom dostrzec wędkę..
Bardzo dziękuję za jasne tłumaczenie z dobrze dobranym przykładem!