PollenFlyweight czy Pyłek jest to tymczasowy komponent, który jest sprytną referencją do czegoś większego.

Ten wzorzec projektowy najczęściej jest używany, gdy masz dużą ilość podobnych do siebie obiektów i chcesz ograniczyć ilość zajmowanej pamięci poprzez wydzielenie powtarzających się wartości do jednego obiektu, do którego wszystkie inne obiekty będą się referować.

Spójrz na poniższy przykład

Miliard kontraktów z wartościami, które będą się powtarzać

Wyobraź sobie, że mamy aplikację, w której mamy milion użytkowników i każdy z nich ma po 20 różnych kontraktów. Już na podstawie tej informacji wiemy, że pewne informacje będą się powtarzać. 

Imiona i nazwiska jak "Jan Kowalski" są bardzo popularne, więc i tu będą powtórzenia, nawet gdy mówimy o osobnych użytkownikach względem wszystkich kontraktów.

Ciągle też dochodzą inne typy umów więc ich wszystkich nigdy nie zdefiniowaliśmy, ale wiemy, że się powtarzają wśród wszystkich użytkowników.

Co możemy z tym zrobić? Pomyśl w teorii obecnie istnieje nawet szansa powtórzenia kontraktu, dla którego klient ma imie i nazwisko "Jan Kowalski" i ma typ umów "QWERT SMALL 1289".

Oczywiście, żeby wyróżnić unikatowych kontrakt dla konkretnego klient, to powinniśmy mieć jakiś jeszcze unikatowy identyfikator jak PESEL.

My jednak mówimy tutaj o problemie związany z tym, że te wszystkie wartości, które się powtarzają będą zajmować miejsce w pamięci.

Tradycyjnie taką klasę byśmy napisali tak:

public class Contract
{
    public string CustomerFullName { get;  }

    public string TypeDeal { get; }

    public Contract(string fullname, string typedeal)
    {
        CustomerFullName = fullname;
        TypeDeal = typedeal;
    }
}

Przy użyciu wzorca "FlyWeight" możemy taką klasę napisać inaczej. 

public class Contract2
{
    private static List<string> _cachedNames = new List<string>();
    private int[] flyweightsNames;

    private static List<string> _cachedTypeDeal = new List<string>();
    private int[] flyweightsTypeDeal;

Możemy wszystkie imiona i typy umów, które będą latać w naszej aplikacji przechowywać w statycznej liście. Co więcej, my chcemy zachować poszczególne wyraz w imionach i nazwiskach oraz typach umów i szukać tam duplikatów.

Znaczy to, że będziemy mogli przechowywać w jednym miejscu w pamięci wszystkich Janów i Darków niezależnie od tego, jakie mają nazwisko.

To samo tyczy się typów umów. Dla typu kontraktu "QWERT SMALL 1289" i "HALO SMALL" będziemy mogli współdzielić wyraz "SMALL".

Czym jest "Flyweight" w tym kontekście?

Jest nim tablica indeksów, która będzie referować się do specyficznych wartości w tych statycznych listach. Tablica indeksów typu Int32 będzie zajmować dużo mniej pamięci niż całe wyrazy wewnątrz naszego konkretnego kontraktu.

public Contract2(string fullname, string typedeal)
{
    int getOrAdd(string s, List<string> cache)
    {
        int idx = cache.IndexOf(s);
        if (idx != -1) return idx;
        else
        {
            cache.Add(s);
            return cache.Count - 1;
        }
    }

    flyweightsNames = fullname.Split(' ')
        .Select(s => getOrAdd(s, _cachedNames)).ToArray();

    flyweightsTypeDeal = typedeal.Split(' ')
        .Select(s => getOrAdd(s, _cachedTypeDeal)).ToArray();
}

W trakcie tworzenia naszego kontraktu będziemy umieszczać wydzielone imiona i nazwiska do naszej listy statycznej.

Nasze poprzednie właściwości będą teraz wyciągać imiona i nazwiska oraz typy umów z tych statycznych list.

public string CustomerFullName => string.Join(" ",
    flyweightsNames.Select(i =>_cachedNames[i]));

public string TypeDeal => string.Join(" ",
flyweightsTypeDeal.Select(i => _cachedTypeDeal[i]));

To wszystko. Rodzi się oczywiście pytanie, czy to rzeczywiście zajmuje mniej pamięci.

Próbowałem paru metod mierzenia pamięci, ale ostatecznie musiałem skorzystać z płatnego narzędzia dotMemory w wersji Trial.

Napisałem taki kod, aby sprawdzić, która z wersji będzie zajmować mniej pamięci. 

List<Contract> list;
{
    List<string> firstNames = new List<string>();

    List<string> surNames = new List<string>();

    List<string> typeDealPart1 = new List<string>();

    List<string> typeDealPart2 = new List<string>();

    list = new List<Contract>();

    for (int i = 0; i < 10; i++)
    {
        var a1 = RandomString();
        var a2 = RandomString();
        var a3 = RandomString();
        var a4 = RandomString();

        for (int j = 0; j < 10; i++)
        {
            list.Add(new Contract
                ($"{a1} {a2}"
                , $"{a3} {a4}"));
        }
        Console.WriteLine(i);
    }

    Console.WriteLine();
}
Console.WriteLine("Done");

Każdy napis będzie składać się z 10 losowych znaków. Dla imion i nazwisk oraz typów kontraktów będziemy mieć przynajmniej 10 powtórzeń.

string RandomString()
{
    Random rand = new Random();
    return new string(
    Enumerable.Range(0, 10)
    .Select(i => (char)('a' + rand.
    Next(26))).ToArray());
}

Pozostało uruchomić ten program w wersji Trial i zobaczyć, jakie będą wyniki.

Użycie dotmemory w Visual Studio 2022

DotMemory

Parametry testu są takie : mamy pół miliona kontraktów i mamy 10 powtórzeń wartości.Czyli co 50.000 kontrakt ma podobne parametry.

Dla tradycyjnego kontraktu wynik wygląda tak.

Wyniki dla Contract bez wzorca

Natomiast wynik dla wzorca FlyWeight wygląda tak.

Wyniki dla Contract z wzorcem FlyWeight

Jak widać nas wzorzec dla takich parametrów zajął tyle samo pamięci.

Ten niby zysk pamięci wynikający z ilość powtórzeń jakoś został odjęty od ilość pamięci, która jest potrzebna do stworzenia tablicy liczb całkowitych, które referują się do przetrzymywanych powtarzających się wartości.

Co trzeba zrobić , aby zobaczyć pozytywny rezultat tego wzorca ? Trzeba zrobić więcej potworzeń wartości.

Warto zaznaczyć, że kod ze wzorcem FlyWeight jest dużo wolniejszy od tradycyjnego podejścia. Śmiałoby powiedział, że ten kod jest 5-10 razy wolniejszy od tradycyjnego podejścia, chociaż nie mierzyłem tego ze stoperem.

Jak widać użycie tego wzorca musi być przemyślane.

List<Contract2> list;
{
    List<string> firstNames = new List<string>();

    List<string> surNames = new List<string>();

    List<string> typeDealPart1 = new List<string>();

    List<string> typeDealPart2 = new List<string>();

    list = new List<Contract2>();

    for (int i = 0; i < 50; i++)
    {
        var a1 = RandomString();
        var a2 = RandomString();
        var a3 = RandomString();
        var a4 = RandomString();

        for (int j = 0; j < 100; j++)
        {
            list.Add(new Contract2
                ($"{a1} {a2}"
                , $"{a3} {a4}"));
        }
        Console.WriteLine(i);
    }

    Console.WriteLine("Done");
    Console.Read();
}

Patrząc na poprzedni przykład możemy zauważyć, że wyniki w Megabajtach  nam dużo nie mówią dlatego zmniejszy rozmiar generowanej kolekcji, abyśmy mogli zobaczyć różnice wyników na podstawie poszczególnych bajtów.

Poza tym testowanie pół milionowej kolekcji dla wzorca FlyWeight trwało dobre 45 minut. Taka informacja dla Ciebie, gdybyś chciał testować podobny kod.

Parametry testu są takie : mamy 5000 kontraktów i mamy 100 powtórzeń wartości. Czyli co 50 kontrakt ma podobne parametry.

Teraz taki wynik mamy dla kontraktu.

Gorsze wyniki dla Contract bez wzorca

A taki dla naszego wzorca FlyWeight

Lepsze wyniki dla Contract z wzorce FlyWeight

Czego się więc nauczyliśmy? Faktu, że trzeba używać tego wzorca z głową, gdy masz pewność, że liczba powtarzających się wartości w całym systemie przekroczy przynajmniej liczbę 10.

Dodatkowo wzorzec FlyWeight mógłby zajmować jeszcze więcej pamięci niż tradycyjne podejście, jeślibyśmy nie mielibyśmy potworzeń.

FlyWeight ma sens na pewno, gdy wiesz, że twój wskaźnik zajmuje mniej pamięci niż cały obiekt, do którego on się referuje.

Wzorzec FlyWeight też ma inne zastosowanie.

FlyWeight jako token modyfikujący : Przykład z formatowaniem tekstu

Wzorzec FlyWeight wymaga często głębokiej analizy, jakie wartości możemy ztokenizować tak, aby nie tworzyć kolejnych instancji pewnego obiektu, aby osiągnąć trochę inny cel przy niewielkiej modyfikacji parametrów.

Wyobraź sobie obiekt, który przechowuje tekst, który ma z milion znaków. W nim także mamy meta informację :

  • O wielkości znaków w danym obszarze napisu. Przykładowo określamy, że znaki od 15 do 20 w napisie mają być pisane wielką literą.
  • O kolorze napisu w danym obszarze tego napisu. Przykładowo określamy, że znaki od 15 do 20 w napisie mają być pisane na czerwono.

W czym jest problem? Spójrz na tą klasę, która reprezentuje taki obiekt.

public class FormattedText
{
    private string plainText;

    public FormattedText(string plainText)
    {
        this.plainText = plainText;
        _capitalize = new bool[plainText.Length];
        _colorize = new bool[plainText.Length];
    }
    public void Capitalize(int start, int end)
    {
        for (int i = start; i <= end; ++i)
            _capitalize[i] = true;
    }

    public void Colorize(int start, int end)
    {
        for (int i = start; i <= end; ++i)
            _colorize[i] = true;
    }

    public void SetColor(string c)
    {
        _color = c;
    }

    private bool[] _capitalize;
    private bool[] _colorize;
    private string _color;

Tak będziemy generować napis.

public void Write()
{
    for (var i = 0; i < plainText.Length; i++)
    {
        var c = plainText[i];

        if (_capitalize[i])
        {
            c = char.ToUpper(c);
        }
        if (_colorize[i])
        {
            if (_color == "Red")
                Console.ForegroundColor = ConsoleColor.Red;
            if (_color == "Green")
                Console.ForegroundColor = ConsoleColor.Green;     
        }

        Console.Write(c);
        Console.ResetColor();
    }
}

Teraz chce zmienić identyczny napis na dwa różne sposoby. Niestety z tego powodu muszę stworzyć dwie instancje swojej klasy. Co oczywiście zajmuje więcej pamięci.

var ft = new FormattedText
    ("In the land of the blind, the one-eyed man is king\n");
ft.Capitalize(19, 24);
ft.Colorize(19, 24);
ft.SetColor("Red");
ft.Write();

var ft2 = new FormattedText
    ("In the land of the blind, the one-eyed man is king\n");
ft2.Colorize(19, 24);
ft2.SetColor("Green");
ft2.Write();

Obrazowanie problemu

Co możemy z tym zrobić?

Tablica znaków, czyli string wypadałoby wydzielić od informacji o tym, jak tekst powinien być w modyfikowany w danych obszarach.

To dam możliwość wielokrotnej modyfikacji tego samego napisu bez tworzenia kolejny instancji podobnych obiektów.

FlyWeight daje nam moc takich tokenów, które modyfikują część mechanizmu bez tworzenia go całkowicie od nowa jeszcze raz.

Oto nasz obiekt, który da nam możliwość modyfikacji formatowania.

public class TextRange
{
    public int Start, End;
    public bool Capitalize;
    public bool Colorize;
    public string Color;
    public bool ChooseRange(int position)
    {
        return position >= Start && position <= End;
    }
}

Nasza klasa też się zmieniła. Mamy teraz możliwość dodania wielu takich modyfikacji obszarowych na raz dla jednego napisu.

public class BetterFormattedText
{
    private readonly string plainText;
    private readonly List<TextRange> formatting
    = new List<TextRange>();

    public BetterFormattedText(string plainText)
    {
        this.plainText = plainText;
    }
    public TextRange GetRange(int start, int end)
    {
        var range = new TextRange { Start = start, End = end };
        formatting.Add(range);
        return range;
    }

Tak teraz wygląda metoda Write().

public void Write()
{
    for (var i = 0; i < plainText.Length; i++)
    {
        var c = plainText[i];
        foreach (var range in formatting)
        {
            
            if (range.ChooseRange(i) && range.Capitalize)
            {
                c = char.ToUpper(c);
            }
            if (range.ChooseRange(i) && range.Colorize)
            {
                if (range.Color == "Red")
                    Console.ForegroundColor = ConsoleColor.Red;
                if (range.Color == "Green")
                    Console.ForegroundColor = ConsoleColor.Green;        
            }

            Console.Write(c);
            Console.ResetColor();
        }
    }
}

A tak wygląda użycie naszej klasy. Jak widzisz dla drugiej modyfikacji nie muszę tworzy od nowa całego obiektu, czyli tworzyć napisu dwa razy. Co jest istotne, gdybyśmy mówili o tekście, który ma milion znaków.

var bft = new BetterFormattedText
    ("In the land of the blind, the one-eyed man is king\n");
var range = bft.GetRange(19, 24);
range.Color = "Green";
range.Capitalize = true;
range.Colorize = true;

bft.Write();
range.Color = "Red";
range.Capitalize = false;
range.Colorize = true;
bft.Write();

Przykład może nie jest idealny, ale chciałem Ci zobrazować jak tokenizacja pewnych mechanizmów może Ci oszczędzić miejsce w pamięci.

FlyWeight jako token modyfikujący  Przykład z formatowaniem tekstu

Podsumowanie:

Flyweight jest to technika, której celem jest oszczędność pamięci komputera. Czasem jego użycie jest jawne. Przykładowo aplikacja zwraca Ci token, który później pozwoli ci modyfikować cokolwiek jest do tego tokenu podpięte. Zrobiliśmy taki trik z naszym edytorem tekstowym.

Gdy Flyweight jest niejawne to wtedy klient nawet nie wie, że taki wzorzec jest użyty. Ten trik zobaczyłeś, gdy tworzyliśmy Kontrakty.

W frameworku .NET taki wzorzec już istnieje i jest nim typ generyczny Span<T>. Span<T> przechowuje w sobie informacje o punkcie początkowym, jak i długość pewnych tablic.

Jest to bardzo zaawansowana technika, ale rzeczywiście, jeśli myślisz o optymalizacji swojego programu gdzie krążą napisy lub inne duże obiekty to warto poczytać o Span<T>.