Gdy aplikacja tworzy setki tysięcy podobnych obiektów, problemem szybko przestaje być sam czas konstrukcji. Znacznie częściej ograniczeniem okazuje się pamięć, zwłaszcza gdy każdy obiekt przechowuje te same dane, takie jak tekstura, nazwa, konfiguracja albo metadane. W tym artykule pokazuję, jak działa wzorzec flyweight pattern, jak rozdzielić stan współdzielony od unikalnego oraz jak zaimplementować takie rozwiązanie w C# bez komplikowania kodu.
Współdziel dane, które naprawdę się powtarzają
- Flyweight ogranicza zużycie pamięci przez współdzielenie niezmiennego stanu między wieloma obiektami.
- Stan wewnętrzny jest wspólny, a stan zewnętrzny pozostaje unikalny dla konkretnego użycia.
- Fabryka przechowuje obiekty w cache i zwraca istniejącą instancję zamiast tworzyć kolejną.
- Wzorzec ma sens głównie przy dużej liczbie podobnych obiektów, a nie przy kilku instancjach.
- Najważniejsze zabezpieczenie to niemutowalność współdzielonych danych.
Co rozwiązuje wzorzec Flyweight
Flyweight należy do wzorców strukturalnych. Jego zadaniem jest ograniczenie liczby przechowywanych kopii tych samych danych, a nie samo przyspieszenie wywołania konstruktora. Jeżeli milion obiektów zawiera identyczną nazwę, kolor i konfigurację, trzymanie tych wartości milion razy jest zwyczajnie niepotrzebne.
Najprościej wyobrazić sobie planszę z milionem drzew. Każde drzewo ma inne współrzędne, ale wiele z nich korzysta z tego samego gatunku, modelu 3D i obrazu tekstury. Flyweight przechowuje wspólny opis gatunku, natomiast pozycja, skala i stan konkretnego drzewa pozostają poza współdzielonym obiektem.
Przykładowo, jeśli powtarzające się dane zajmują umownie 200 bajtów, milion osobnych kopii oznacza około 200 MB samych danych. W realnym środowisku dochodzi jeszcze narzut obiektów, referencji i struktur zarządzanych przez runtime, więc rzeczywisty koszt może być wyższy.
Stan wewnętrzny i zewnętrzny
Cała idea opiera się na podziale właściwości obiektu na dwie grupy. Ten podział jest ważniejszy niż sama fabryka, bo źle wybrany stan może sprawić, że współdzielenie stanie się niebezpieczne albo po prostu nie przyniesie oszczędności.
| Rodzaj stanu | Znaczenie | Przykłady |
|---|---|---|
| Wewnętrzny | Nie zależy od konkretnego użycia i może być współdzielony. | Nazwa gatunku, tekstura, kolor, rozmiar czcionki |
| Zewnętrzny | Zależy od miejsca, czasu lub konkretnego obiektu. | Pozycja, indeks, aktualny stan, właściciel, kierunek |
Stan wewnętrzny powinien być niezmienny po utworzeniu. Gdy jeden użytkownik zmieni współdzielony kolor, zmiana nie może przypadkiem wpłynąć na tysiące innych elementów. To jedna z tych zasad, które na diagramie wyglądają niewinnie, a w działającej aplikacji decydują o tym, czy rozwiązanie będzie stabilne.
Jak wygląda współpraca obiektów
Typowa implementacja składa się z trzech elementów. Flyweight przechowuje wspólny stan, kontekst przechowuje dane konkretnego użycia, a fabryka zarządza pulą istniejących instancji. Klient nie powinien za każdym razem tworzyć współdzielonego obiektu bezpośrednio, bo wtedy traci się najważniejszą korzyść wzorca.
- Flyweight udostępnia operacje wykorzystujące stan współdzielony.
- Context przechowuje dane unikalne, na przykład współrzędne lub identyfikator.
- Factory wyszukuje obiekt po kluczu i tworzy go tylko wtedy, gdy nie ma go jeszcze w cache.
- Client dostarcza stan zewnętrzny podczas użycia obiektu.
W praktyce flyweight nie reprezentuje już kompletnego obiektu biznesowego. Jest raczej współdzielonym opisem albo szablonem, który razem z kontekstem daje pełny obiekt widoczny z punktu widzenia aplikacji.
To rozróżnienie dobrze widać w systemie renderowania. `TreeType` może przechowywać nazwę i teksturę, natomiast `Tree` przechowuje pozycję. Ten sam `TreeType` może obsługiwać tysiące drzew, ale każde z nich ma własny kontekst.
Implementacja w C# na praktycznym przykładzie
Poniższy przykład pokazuje katalog typów drzew. Klasa `TreeType` jest niemutowalna, a fabryka korzysta ze słownika, który pełni rolę cache. Klucz zawiera wyłącznie dane potrzebne do rozpoznania, czy dwa typy są identyczne.
using System;
using System.Collections.Generic;
public sealed class TreeType
{
public string Name { get; }
public string TexturePath { get; }
public string Color { get; }
public TreeType(string name, string texturePath, string color)
{
Name = name;
TexturePath = texturePath;
Color = color;
}
public void Render(int x, int y)
{
Console.WriteLine(
$"Renderuję {Name} w ({x}, {y}) z teksturą {TexturePath}");
}
}
public sealed class TreeTypeFactory
{
private readonly Dictionary _types = new();
public TreeType Get(string name, string texturePath, string color)
{
var key = $"{name}|{texturePath}|{color}";
if (_types.TryGetValue(key, out var existing))
{
return existing;
}
var created = new TreeType(name, texturePath, color);
_types[key] = created;
return created;
}
}
public sealed class Tree
{
private readonly int _x;
private readonly int _y;
private readonly TreeType _type;
public Tree(int x, int y, TreeType type)
{
_x = x;
_y = y;
_type = type;
}
public void Render()
{
_type.Render(_x, _y);
}
} Użycie wygląda wtedy tak, że fabryka zwraca tę samą instancję dla identycznego zestawu danych. Unikalny pozostaje tylko obiekt `Tree` oraz jego pozycja.
var factory = new TreeTypeFactory();
var oak = factory.Get(
"Dąb",
"textures/oak.png",
"green");
var firstTree = new Tree(10, 20, oak);
var secondTree = new Tree(100, 80, oak);
firstTree.Render();
secondTree.Render();W aplikacji wielowątkowej prosty `Dictionary` trzeba zastąpić synchronizacją albo strukturą współbieżną. W ASP.NET Core fabrykę zwykle rejestruję jako singleton, ale tylko wtedy, gdy przechowywane obiekty są bezpieczne do współdzielenia i nie zawierają danych żądania użytkownika.
Warto też uważać na budowanie klucza przez konkatenację. Dla prostego przykładu jest czytelne, lecz w większym systemie lepiej użyć rekordu wartościowego, na przykład `record struct TreeKey`, aby uniknąć kolizji wynikających z separatorów lub niejednoznacznych wartości.
Gdzie ten wzorzec ma sens w .NET
Najlepsze zastosowania pojawiają się tam, gdzie aplikacja tworzy bardzo wiele podobnych elementów, a ich część pozostaje taka sama. Nie chodzi o abstrakcyjne użycie wzorca dla samego wzorca. Najpierw szukam powtarzających się danych i sprawdzam, czy zajmują istotną część pamięci.
Renderowanie i grafika
Silnik gry może przechowywać jeden opis tekstury, modelu i materiału, a osobno pozycję każdego obiektu na scenie. Podobnie działa wizualizacja mapy, graf dokumentów albo interfejs z dużą liczbą ikon. Współdzielenie zasobów graficznych ogranicza pamięć i może zmniejszyć liczbę kosztownych operacji inicjalizacyjnych.
Edytory tekstu i dokumenty
W dokumencie wiele znaków korzysta z tych samych informacji o kroju, rozmiarze i stylu. Pozycja znaku oraz jego fragment dokumentu są zewnętrzne, ale opis stylu można współdzielić. Przy dużych dokumentach taka separacja jest znacznie bardziej opłacalna niż tworzenie pełnego obiektu formatowania dla każdego znaku.
Systemy konfiguracji i metadanych
W aplikacjach biznesowych wiele rekordów może wskazywać na ten sam typ produktu, kraj, kategorię lub definicję reguły. Zamiast kopiować kompletny opis przy każdym rekordzie, można przechowywać jeden współdzielony obiekt i referencję do niego. Trzeba tylko dopilnować, aby dane słownikowe były tylko do odczytu.
Cache wartości
Niektóre biblioteki używają rozwiązań podobnych do flyweight, zwracając wcześniej utworzone obiekty dla często powtarzających się wartości. Dobrym przykładem jest interning napisów w środowisku .NET, choć nie należy traktować go jako uniwersalnego cache dla dowolnych stringów. Zbyt agresywne utrzymywanie wartości może zwiększyć zużycie pamięci zamiast je zmniejszyć.
Kiedy lepiej wybrać inne rozwiązanie
Flyweight nie jest automatycznym lekarstwem na dużą liczbę obiektów. Jeżeli aplikacja ma kilkaset instancji, koszt fabryki, kluczy i synchronizacji może być większy niż oszczędność. Zanim wdrożę ten wzorzec, sprawdzam profil pamięci i liczbę duplikujących się wartości.
| Rozwiązanie | Co współdzieli | Kiedy je wybrać |
|---|---|---|
| Flyweight | Niezmienny fragment stanu wielu obiektów | Gdy podobnych obiektów są tysiące lub miliony |
| Object pool | Gotowe instancje wielokrotnego użycia | Gdy koszt tworzenia i usuwania obiektów jest wysoki |
| Cache | Wyniki albo dane pobrane wcześniej | Gdy chcemy ograniczyć koszt obliczeń lub dostępu do źródła |
| Zwykła klasa | Nic automatycznie | Gdy liczba obiektów jest mała, a kod ma pozostać prosty |
Najczęstszy błąd polega na pomyleniu flyweight z pulą obiektów. Pool oddaje tę samą instancję do ponownego użycia, zwykle po wcześniejszym wyczyszczeniu jej stanu. Flyweight pozwala wielu klientom korzystać jednocześnie z tego samego niemutowalnego fragmentu danych.
Przeczytaj również: var w JavaScripcie - zakres, hoisting i pułapki starego kodu
Typowe pułapki
- Współdzielony stan jest mutowalny, więc zmiana jednego klienta wpływa na pozostałych.
- Fabryka ma nieskończony cache i nigdy nie usuwa rzadko używanych wartości.
- Klucz jest zbyt szczegółowy, dlatego prawie każde żądanie tworzy nowy obiekt.
- Do cache trafiają dane zależne od użytkownika, żądania lub transakcji.
- Wzorzec zostaje dodany bez pomiaru, mimo że problemem nie jest pamięć, lecz czas CPU.
Jeżeli liczba możliwych kluczy jest bardzo duża, potrzebna może być polityka wygaszania, na przykład limit rozmiaru albo usuwanie najdawniej używanych elementów. W przeciwnym razie optymalizacja pamięci lokalnej zamieni się w nowe źródło wycieku pamięci.
Jak ocenić, czy wdrożenie się opłaca
Zaczynam od pomiaru liczby obiektów, rozmiaru powtarzających się danych i czasu życia instancji. Pomagają w tym profilery pamięci, ślady GC oraz benchmarki uruchamiane na reprezentatywnych danych. Sama obserwacja, że obiekt wygląda na ciężki, nie wystarcza do podjęcia dobrej decyzji.
Praktyczny test można oprzeć na trzech pytaniach. Czy tworzę dużo podobnych obiektów? Czy ich wspólne dane są niezmienne? Czy oszczędność pamięci będzie większa niż koszt słownika, kluczy i dodatkowych referencji?
Jeżeli odpowiedź na wszystkie pytania brzmi „tak”, wzorzec ma dobry punkt wyjścia. Jeżeli tylko obiekty są podobne, ale każdy ma inny stan, lepiej uprościć projekt i pozostawić zwykłe klasy. Prostota też jest zasobem, którego nie warto bez potrzeby oddawać.
Najważniejsza decyzja dotyczy granicy współdzielenia
Flyweight działa najlepiej wtedy, gdy granica między stanem wspólnym a unikalnym jest oczywista. Współdzielę dane stabilne, często powtarzające się i bezpieczne do odczytu, a do kontekstu przenoszę wszystko, co zależy od konkretnego użycia.
W aplikacji .NET zacząłbym od prostego pomiaru, małej fabryki i niemutowalnego obiektu. Dopiero gdy profil pamięci potwierdzi korzyść, rozbudowywałbym rozwiązanie o współbieżność, wygaszanie cache lub bardziej zaawansowane klucze. Najlepszy flyweight to taki, który rozwiązuje konkretny problem pamięci, a nie taki, który tylko komplikuje model obiektowy.
