Zaawansowane techniki obiektowe
Rok akademicki: 2026/27
Instrukcja laboratoryjna – Część 2: Wzorce
Strukturalne, Kompozycja Architektoniczna, Metody Rozszerzające i
Optymalizacja Pamięci (CoW)
Prowadzący: Tomasz Goluch
Wersja: 1.0
1. Wprowadzenie do Wzorców Strukturalnych
1.1. Cel i Istota Wzorców Strukturalnych
Celem drugiej części laboratorium jest poznanie i praktyczna implementacja wzorców strukturalnych (ang. Structural Patterns) w języku C# (standard C# 12 / .NET 8+) oraz środowisku mieszanym (C++/CLI). Wzorce strukturalne dotyczą struktury klas lub obiektów — koncentrują się na kompozycji klas i obiektów, czyli na tym, jak poszczególne byty są ze sobą powiązane i w jaki sposób tworzą większe, elastyczne struktury architektoniczne przy zachowaniu niskiego sprzężenia (Loose Coupling).
Podczas gdy wzorce kreacyjne (omówione w Części 1) odpowiadały na pytanie: „Jak powołać obiekt bez uzależniania kodu od konkretnej klasy?”, wzorce strukturalne odpowiadają na pytanie: „Jak połączyć niezależne klasy w spójny system bez wywoływania eksplozji podklas i bez łamania zasad SOLID?”.
1.2. Logiczna Kolejność Wzorców Strukturalnych
W niniejszej instrukcji wzorce strukturalne zostały uporządkowane w logiczny ciąg dydaktyczny i architektoniczny, dzielący je na dwie spójne grupy:
Grupa I – Wzorce Opakowujące i Adaptujące Interfejsy (Wrappers):
- Adapter (2.1): Punkt wyjścia (jedyny wzorzec w zestawieniu posiadający zarówno wariant klasowy, jak i obiektowy). Rozwiązuje problem niezgodności interfejsów w relacji \(1:1\), tłumacząc wywołania klienta na wywołania obcego komponentu (np. natywnej biblioteki C++).
- Dekorator (2.2): Podobnie jak Adapter „owija” pojedynczy obiekt (\(1:1\)), ale zachowuje jego oryginalny interfejs. Zamiast tłumaczyć wywołania, pozwala kaskadowo i dynamicznie dokładać nowe zachowania zgodnie z zasadą Open/Closed (OCP).
- Pełnomocnik / Proxy (2.3): Ma strukturę niemal bliźniaczą do Dekoratora (ten sam interfejs, relacja \(1:1\)), lecz zupełnie inną intencję biznesową: nie rozszerza funkcjonalności, ale kontroluje dostęp do obiektu docelowego (autoryzacja, leniwa inicjalizacja, audyt).
- Fasada (2.4): Przenosi ideę opakowywania z
pojedynczego obiektu na cały złożony podsystem (\(1:N\)). Dostarcza jeden prosty,
wysokopoziomowy interfejs, ukrywając pod spodem skomplikowaną
orkiestrację wielu klas (np.
DbContextw Entity Framework Core).
Grupa II – Wzorce Dekompozycji, Struktur Drzewiastych i Optymalizacji Pamięci:
- Most / Bridge (3.1): Zamiast opakowywać istniejące klasy po fakcie (jak Adapter), projektuje architekturę od podstaw, oddzielając warstwę abstrakcji od warstwy implementacji za pomocą agregacji. Redukuje liczbę klas z iloczynu kartezjańskiego \(N \times M\) do sumy \(N + M\).
- Kompozyt (3.2): Pozwala budować rekurencyjne
struktury drzewiaste (część–całość), w których pojedyncze
liście i całe gałęzie są traktowane przez klienta identycznie. W
połączeniu z interfejsem
IEnumerable<T>oraz metodami rozszerzającymi C# eliminuje eksplozję kombinatoryczną przeciążeń metod. - Pyłek / Flyweight i Copy-on-Write (3.3): Gdy struktury obiektowe (np. drzewa w Kompozycie lub masowe kolekcje) rozrastają się do setek tysięcy instancji, pojawia się problem wyczerpania pamięci RAM. Pyłek oddziela stan współdzielony (intrinsic) od kontekstowego (extrinsic), a w synergii z poznanym w Części 1 Prototypem tworzy mechanizm Copy-on-Write (CoW).
┌──────────────────────────────────────────────┐
│ WZORCE STRUKTURALNE (GoF) │
└──────────────────────┬───────────────────────┘
│
┌─────────────────────────────┴─────────────────────────────┐
▼ ▼
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ GRUPA I: WZORCE OPAKOWUJĄCE │ │ GRUPA II: DEKOMPOZYCJA I OPTYMALIZACJA │
│ (WRAPPERS & FACADES) │ │ (TREES, BRIDGES & MEMORY) │
├─────────────────────────────────────────┤ ├─────────────────────────────────────────┤
│ 2.1. Adapter (Zmiana interfejsu 1:1)│ │ 3.1. Most (Separacja osi N*M -> N+M│
│ 2.2. Dekorator (Rozszerzanie OCP 1:1) │ │ 3.2. Kompozyt (Drzewa + IEnumerable<T>)│
│ 2.3. Pełnomocnik (Kontrola dostępu 1:1) │ │ 3.3. Pyłek (Współdzielenie RAM + CoW│
│ 2.4. Fasada (Uproszczenie sys. 1:N)│ │ wraz z Prototypem) │
└─────────────────────────────────────────┘ └─────────────────────────────────────────┘
2. Wzorce Opakowujące i Adaptujące Interfejsy (Wrappers)
2.1. Adapter (Wrapper – Zakres Klasowy oraz Obiektowy)
Wzorzec Adapter – zwany też
Wrapperem – pozwala na współpracę dwóch niezależnych
interfejsów, które normalnie nie mogłyby ze sobą działać z powodu
niezgodności sygnatur lub typów danych. Jest to realizowane przez klasę
pośredniczącą (Adapter), która „owija” jeden obiekt,
udostępniając interfejs, którego oczekuje klient (ITarget),
jednocześnie delegując pracę do oryginalnego, niezgodnego obiektu
(Adaptee).
Wyróżniamy dwa warianty wzorca:
- Klasowy (Zakres Klasowy): Wykorzystuje mechanizm
dziedziczenia – klasa adaptera implementuje interfejs
oczekiwany przez klienta i jednocześnie dziedziczy po klasie adaptowanej
(Adaptee). W języku C# (z uwagi na brak wielodziedziczenia
klas) jest to możliwe tylko wtedy, gdy cel (
ITarget) jest interfejsem, a klasa adaptowana nie jest oznaczona jakosealed. - Obiektowy (Zakres Obiektowy): Wykorzystuje kompozycję – przechowuje prywatną referencję (lub wskaźnik) do obiektu, który ma zostać zaadaptowany. Jest znacznie bardziej elastyczny, gdyż pozwala adaptować również podklasy obiektu adaptowanego.
A. Przykład 1: Porównanie Adaptera Klasowego i Obiektowego w C#
// Interfejs oczekiwany przez kod kliencki
public interface ITarget
{
string GetRequest();
}
// Istniejąca klasa o niekompatybilnym interfejsie (np. z zewnętrznej biblioteki)
public class Adaptee
{
public string GetSpecificRequest() => "Specyficzne dane z Adaptee.";
}
// 1. ADAPTER KLASOWY (dziedziczy po Adaptee, implementuje ITarget)
public class ClassAdapter : Adaptee, ITarget
{
public string GetRequest() => $"[ClassAdapter]: {base.GetSpecificRequest()}";
}
// 2. ADAPTER OBIEKTOWY (agreguje/komponuje instancję Adaptee)
public class ObjectAdapter : ITarget
{
private readonly Adaptee _adaptee;
public ObjectAdapter(Adaptee adaptee)
{
_adaptee = adaptee;
}
public string GetRequest() => $"[ObjectAdapter]: {_adaptee.GetSpecificRequest()}";
}
B. Przykład 2: Adapter Obiektowy C++/CLI (Kod zarządzany C# ↔︎ Kod niezarządzany C++)
Poniższy przykładowy kod (UnmanagedClass i
ManagedClass) prezentuje praktyczny adapter
obiektowy używany do łączenia kodu zarządzanego
(C#) z natywnym kodem niezarządzanym
(C++). Klasa ManagedClass (napisana w
dialekcie C++/CLI) jest adapterem, który posiada surowy wskaźnik
m_Impl do obiektu UnmanagedClass na stercie
natywnej i udostępnia zarządzane metody oraz właściwości w C#, tłumacząc
wywołania oraz typy danych (System::String^ ↔︎
LPCWSTR) na natywne wywołania C++.
Plik
nagłówkowy UnmanagedClass.h oraz kod klasy
UnmanagedClass.cpp (Natywny C++):
// UnmanagedClass.h
#pragma once
#include <Windows.h>
class UnmanagedClass {
public:
LPCWSTR GetPropertyA();
void MethodB(LPCWSTR);
};
// UnmanagedClass.cpp
#include "UnmanagedClass.h"
#include <iostream>
LPCWSTR UnmanagedClass::GetPropertyA()
{
return L"empty string";
}
void UnmanagedClass::MethodB(LPCWSTR theNativeString)
{
std::wcout << theNativeString << std::endl;
}
Kod klasy adaptera
ManagedClass.h (C++/CLI):
#pragma once
#include "UnmanagedClass.h"
#include <vcclr.h> // Wymagane dla PtrToStringChars
using namespace System;
public ref class ManagedClass {
public:
// Alokacja obiektu natywnego na stercie C++ w konstruktorze
ManagedClass() : m_Impl(new UnmanagedClass) {}
// Zwolnienie obiektu natywnego w destruktorze (kompilowanym do IDisposable::Dispose)
~ManagedClass() {
this->!ManagedClass();
}
protected:
// Zwolnienie obiektu natywnego w finalizatorze (gdy programista zapomni o Dispose/using)
!ManagedClass() {
if (m_Impl != nullptr) {
delete m_Impl;
m_Impl = nullptr;
}
}
public:
property String^ PropertyA {
String^ get() {
return gcnew String(m_Impl->GetPropertyA());
}
}
void MethodB(String^ theString) {
// Przypięcie wskaźnika (pinning), aby Garbage Collector nie przesunął napisu w pamięci
pin_ptr<const WCHAR> str = PtrToStringChars(theString);
m_Impl->MethodB(str);
}
private:
UnmanagedClass* m_Impl;
};
💡 Uwaga dotycząca bezpieczeństwa pamięci w C++/CLI:
W języku C++/CLI zapis~ManagedClass()generuje metodęIDisposable.Dispose(), natomiast!ManagedClass()generuje finalizatorFinalize()uruchamiany przez Garbage Collector. Wywołaniethis->!ManagedClass()wewnątrz~ManagedClass()oraz wyzerowanie wskaźnika (m_Impl = nullptr) zabezpiecza aplikację przed błędem podwójnego zwolnienia pamięci (Double Free), a kompilator C++/CLI automatycznie dopisujeGC::SuppressFinalize(this).
Użycie w kodzie C#:
using ManagedClass mc = new();
mc.MethodB("some string");
var p = mc.PropertyA;
Console.WriteLine(p);
Przykładowe wyjście:
some string
empty string
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
Kontener Autofac posiada wbudowane, dedykowane
wsparcie dla wzorca Adapter poprzez metodę
RegisterAdapter, która automatycznie
opakowuje zarejestrowane usługi w docelowy interfejs:
// 1. Natywne wsparcie dla Adaptera w Autofac (RegisterAdapter):
builder.RegisterType<Adaptee>();
// Autofac automatycznie wykrywa potrzebę adaptacji Adaptee -> ITarget:
builder.RegisterAdapter<Adaptee, ITarget>(adaptee => new ObjectAdapter(adaptee));
// 2. Odpowiednik w Microsoft.Extensions.DependencyInjection:
services.AddTransient<Adaptee>();
services.AddTransient<ITarget>(sp => new ObjectAdapter(sp.GetRequiredService<Adaptee>()));
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Przezroczystość dla klienta: Klasa biznesowa żąda w
konstruktorze wyłącznie
ITargeti nie ma pojęcia, że pod spodem kontener w locie opakowuje niekompatybilny serwisAdaptee. - Automatyczna adaptacja kolekcji w Autofac: Jeśli
zarejestrujemy kilka różnych obiektów typu
Command, jedno wywołaniebuilder.RegisterAdapter<Command, ToolbarButton>(cmd => new ToolbarButton(cmd))automatycznie wygeneruje kolekcjęIEnumerable<ToolbarButton>dla wszystkich komend w systemie.
2.2. Dekorator (Decorator – Zakres Obiektowy)
Wzorzec Dekorator pozwala na dynamiczne dodawanie nowych odpowiedzialności do obiektów w czasie wykonywania programu (runtime), bez modyfikacji ich kodu źródłowego. Stanowi to bezpośrednią realizację zasady Open/Closed Principle (OCP) z SOLID oraz elastyczną alternatywę dla dziedziczenia podklas. Dekoratory „opakowują” oryginalny komponent i dodają do niego własne zachowanie przed lub po delegacji pracy do opakowanego komponentu.
Wyróżniamy następujące elementy tego wzorca:
- Komponent (
IComponent): Definiuje wspólny interfejs dla obiektów, które mogą mieć dynamicznie dodawane odpowiedzialności. - Konkretny Komponent
(
ConcreteComponent): Podstawowy obiekt domenowy, do którego można dokładać nowe zachowania. - Abstrakcyjny Dekorator (
Decorator): Przechowuje referencję do obiektu typuIComponenti sam implementuje interfejsIComponent. - Konkretny Dekorator (
ConcreteDecoratorA,ConcreteDecoratorB): Dodaje odpowiedzialność do komponentu, wywołując jego operację bazową i wzbogacając jej wynik.
Interfejs
IComponent wraz z implementującymi go klasami konkretnego
komponentu i abstrakcyjnego dekoratora:
public interface IComponent
{
string Operation();
}
public class ConcreteComponent : IComponent
{
public string Operation()
{
return "ConcreteComponent";
}
}
public abstract class Decorator : IComponent
{
protected IComponent _component;
public Decorator(IComponent component)
{
this._component = component;
}
// Dekorator deleguje całą podstawową pracę do opakowanego komponentu.
public virtual string Operation()
{
if (this._component != null)
{
return this._component.Operation();
}
else
{
return string.Empty;
}
}
}
Kod
konkretnych klas dekoratorów (ConcreteDecoratorA oraz
ConcreteDecoratorB):
// Konkretne dekoratory wywołują opakowany obiekt i modyfikują jego wynik.
public class ConcreteDecoratorA : Decorator
{
public ConcreteDecoratorA(IComponent comp) : base(comp)
{
}
// Dekoratory mogą wywoływać implementację klasy bazowej (base.Operation()),
// zamiast odwoływać się do pola _component bezpośrednio.
public override string Operation()
{
return $"ConcreteDecoratorA({base.Operation()})";
}
}
public class ConcreteDecoratorB : Decorator
{
public ConcreteDecoratorB(IComponent comp) : base(comp)
{
}
public override string Operation()
{
return $"ConcreteDecoratorB({base.Operation()})";
}
}
Użycie w kodzie:
IComponent simple = new ConcreteComponent();
Console.WriteLine("Client: I get a simple component:");
Console.WriteLine("RESULT: " + simple.Operation());
// Zwróć uwagę, że dekoratory mogą opakowywać nie tylko proste komponenty,
// ale również inne dekoratory, tworząc kaskadowy łańcuch (tzw. "cebulkę"):
ConcreteDecoratorA decorator1 = new ConcreteDecoratorA(simple);
ConcreteDecoratorB decorator2 = new ConcreteDecoratorB(decorator1);
Console.WriteLine("Client: Now I've got a decorated component:");
Console.WriteLine("RESULT: " + decorator1.Operation());
Console.WriteLine("RESULT: " + decorator2.Operation());
Przykładowe wyjście:
Client: I get a simple component:
RESULT: ConcreteComponent
Client: Now I've got a decorated component:
RESULT: ConcreteDecoratorA(ConcreteComponent)
RESULT: ConcreteDecoratorB(ConcreteDecoratorA(ConcreteComponent))
🧅 Dla dociekliwych: Wzorzec Dekoratora w klasie System.IO.Stream w .NET (Kliknij, aby rozwinąć)
Jednym z najlepszych przykładów wzorca Dekorator w bibliotece
standardowej .NET (BCL) jest hierarchia strumieni
System.IO.Stream.
Gdyby twórcy .NET chcieli za pomocą dziedziczenia obsłużyć wszystkie
kombinacje strumieni plikowych, sieciowych, buforowanych, kompresowanych
i szyfrowanych, powstałyby dziesiątki sztywnych klas (np.
BufferedGZipEncryptedFileStream). Zamiast tego zastosowano
wzorzec Dekoratora:
- Komponent abstrakcyjny:
Stream(definiujeRead(),Write(),Flush()). - Konkretne komponenty (źródła danych):
FileStream,MemoryStream,NetworkStream. - Konkretne dekoratory (przekształcenia w locie):
BufferedStream,GZipStream,CryptoStream.
// 1. Konkretny komponent bazowy (zapis surowych bajtów do pliku)
using Stream fileStream = new FileStream("raport.bin", FileMode.Create);
// 2. Dekorator 1: Buforowanie zapisów w pamięci RAM (poprawa IOPS)
using Stream bufferedStream = new BufferedStream(fileStream, bufferSize: 4096);
// 3. Dekorator 2: Szyfrowanie strumienia w locie algorytmem AES
using Stream cryptoStream = new CryptoStream(bufferedStream, aes.CreateEncryptor(), CryptoStreamMode.Write);
// 4. Dekorator 3: Kompresja danych algorytmem GZip przed zaszyfrowaniem
using Stream gzipStream = new GZipStream(cryptoStream, CompressionMode.Compress);
// Klient operuje wyłącznie na abstrakcji Stream – dane przechodzą przez wszystkie warstwy!
using var writer = new StreamWriter(gzipStream);
await writer.WriteLineAsync("Tajne dane telemetryczne.");
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
W aplikacjach korporacyjnych wzorzec Dekoratora jest podstawowym
sposobem realizacji zagadnień przekrojowych (Cross-Cutting
Concerns): logowania, pomiaru czasu wykonania, obsługi pamięci
podręcznej (Caching) oraz ponawiania prób (Retry).
Kontener Autofac wspiera ten wzorzec natywnie za pomocą
metody RegisterDecorator:
// 1. Natywne kaskadowe dekorowanie w Autofac:
builder.RegisterType<ConcreteComponent>().As<IComponent>();
// Dekoratory owijają komponent dokładnie w kolejności rejestracji:
builder.RegisterDecorator<ConcreteDecoratorA, IComponent>();
builder.RegisterDecorator<ConcreteDecoratorB, IComponent>();
// 2. Wariant warunkowy w Autofac (dekoruj tylko w określonym kontekście):
builder.RegisterDecorator<ConcreteDecoratorB, IComponent>(
condition: ctx => ctx.ImplementationType == typeof(ConcreteDecoratorA));
// 3. Odpowiednik w Microsoft DI (z pakietem Scrutor lub jawnie):
services.AddTransient<ConcreteComponent>();
services.AddTransient<IComponent>(sp =>
new ConcreteDecoratorB(
new ConcreteDecoratorA(
sp.GetRequiredService<ConcreteComponent>())));
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Całkowita separacja od kodu biznesowego: Aby dodać
warstwę buforowania (
CachingDecorator) lub telemetrii (LoggingDecorator) do istniejącego serwisu, nie modyfikujemy ani jednej linijki w serwisie ani w kontrolerze – dodajemy tylko jedną linijkęRegisterDecoratorw Composition Root. - Łatwe włączanie/wyłączanie warstw z konfiguracji:
Na środowisku deweloperskim możemy wstrzykiwać czysty serwis, a na
produkcyjnym owijać go w dekorator metryk na podstawie flagi z
appsettings.json.
2.3. Pełnomocnik (Proxy – Zakres Obiektowy)
Wzorzec Pełnomocnik (znany powszechnie pod angielską nazwą Proxy) dostarcza obiekt zastępczy (ang. surrogate) lub kontroler dostępu do innego obiektu rzeczywistego (RealSubject).
W zależności od przeznaczenia wyróżniamy cztery klasyczne rodzaje pełnomocników:
- Pełnomocnik Ochronny (Protection / Security Proxy): Kontroluje uprawnienia i autoryzuje klienta przed przekazaniem wywołania do obiektu docelowego.
- Pełnomocnik Wirtualny (Virtual Proxy): Opóźnia moment utworzenia kosztownego obiektu docelowego aż do chwili, gdy jego metoda zostanie faktycznie wywołana po raz pierwszy (Lazy Initialization).
- Pełnomocnik Zdalny (Remote Proxy): Reprezentuje lokalnie obiekt znajdujący się w innej przestrzeni adresowej lub na zdalnym serwerze (np. klient gRPC lub WCF).
- Pełnomocnik Logujący / Sprytne Odniesienie (Smart Reference / Logging Proxy): Rejestruje audyt wywołań, zlicza referencje lub zarządza blokadami synchronizacyjnymi.
Przykład implementacji (Pełnomocnik Ochronny i Wirtualny):
- Subject (Temat –
IDataAccess): Interfejs wspólny dla Pełnomocnika i Rzeczywistego Obiektu. Dzięki temu klient nie wie, czy rozmawia z oryginałem, czy z pośrednikiem. - RealSubject (Rzeczywisty Obiekt –
DatabaseAccess): Obiekt, który wykonuje właściwą pracę (często ciężki w inicjalizacji lub wymagający ochrony). - Proxy (Pełnomocnik –
SecurityProxy): Zawiera referencję doRealSubjecti weryfikuje uprawnienia przed delegacją zadania.
// 1. Subject (Wspólny interfejs)
public interface IDataAccess
{
void GrantAccess(string user);
}
// 2. RealSubject (Właściwa, chroniona implementacja)
public class DatabaseAccess : IDataAccess
{
public void GrantAccess(string user)
{
Console.WriteLine($"Dostęp do bazy danych przyznany użytkownikowi: {user}");
}
}
// 3. Proxy (Kontroluje dostęp do RealSubject)
public class SecurityProxy : IDataAccess
{
private DatabaseAccess _realSubject;
private string _allowedUser = "Admin";
public SecurityProxy(DatabaseAccess realSubject)
{
_realSubject = realSubject;
}
public void GrantAccess(string user)
{
if (CheckAccess(user))
{
_realSubject.GrantAccess(user); // Delegacja do RealSubject
}
else
{
Console.WriteLine($"Odmowa dostępu! Użytkownik {user} nie ma uprawnień.");
}
}
private bool CheckAccess(string user)
{
return user == _allowedUser;
}
}
Użycie w kodzie:
var realAccess = new DatabaseAccess();
IDataAccess proxy = new SecurityProxy(realAccess);
proxy.GrantAccess("Admin"); // Dostęp dozwolony
proxy.GrantAccess("Guest"); // Dostęp zabroniony
Przykładowe wyjście:
Dostęp do bazy danych przyznany użytkownikowi: Admin
Odmowa dostępu! Użytkownik Guest nie ma uprawnień.
🛡️ Dla dociekliwych: Dynamiczny Pełnomocnik (DynamicProxy) i Programowanie Aspektowe (AOP) w .NET (Kliknij, aby rozwinąć)
Ręczne pisanie klas Proxy dla dziesiątek interfejsów w dużym systemie
prowadzi do powielania szablonowego kodu (boilerplate).
Platforma .NET udostępnia wbudowaną klasę
System.Reflection.DispatchProxy (a w
ekosystemie zewnętrznym bibliotekę
Castle.Core), które potrafią wygenerować
klasę Pełnomocnika dynamicznie w pamięci w czasie działania
programu (runtime):
using System.Reflection;
public class AuditLoggingProxy<T> : DispatchProxy where T : class
{
private T _target = default!;
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
Console.WriteLine($"[AUDIT PROXY] Wywołanie metody: {targetMethod?.Name}");
// Wywołanie metody na obiekcie rzeczywistym przez refleksję:
return targetMethod?.Invoke(_target, args);
}
public static T Create(T target)
{
object proxy = Create<T, AuditLoggingProxy<T>>();
((AuditLoggingProxy<T>)proxy)._target = target;
return (T)proxy;
}
}
// Użycie dla dowolnego interfejsu bez pisania dedykowanej klasy Proxy:
IDataAccess auditedAccess = AuditLoggingProxy<IDataAccess>.Create(new DatabaseAccess());
auditedAccess.GrantAccess("Admin");
Na dokładnie tym samym mechanizmie (DynamicProxy) działają wszystkie popularne biblioteki do tworzenia atrap w testach jednostkowych (Moq, NSubstitute, FakeItEasy) oraz mechanizm Lazy Loading Proxies w Entity Framework Core!
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
W ekosystemie Autofac (z rozszerzeniem
Autofac.Extras.DynamicProxy) wzorzec Pełnomocnika realizuje
się za pomocą przechwytywaczy (Interceptors),
co pozwala na wdrożenie programowania zorientowanego aspektowo
(AOP):
// 1. Rejestracja Pełnomocnika Ochronnego jako usługi zastępującej oryginał:
services.AddScoped<DatabaseAccess>();
services.AddScoped<IDataAccess, SecurityProxy>();
// 2. Dynamiczny Pełnomocnik (Interceptor) w Autofac + Castle.Core:
builder.RegisterType<SecurityInterceptor>(); // Implementuje IInterceptor
builder.RegisterType<DatabaseAccess>()
.As<IDataAccess>()
.EnableInterfaceInterceptors()
.InterceptedBy(typeof(SecurityInterceptor));
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Uniwersalność (jeden Pełnomocnik dla stu
interfejsów): Zamiast pisać
OrderSecurityProxy,UserSecurityProxyiPaymentSecurityProxy, tworzymy jedenSecurityInterceptor, który kontener Autofac automatycznie podpina pod wybrane serwisy. - Brak znajomości implementacji przez klienta: Klient
nie może przypadkowo wywołać
new DatabaseAccess()z pominięciemSecurityProxy, ponieważ klasaDatabaseAccessmoże być oznaczona jakointernal, a na zewnątrz modułu udostępniany jest wyłącznie kontraktIDataAccess.
2.4. Fasada (Facade – Zakres Obiektowy)
Wzorzec Fasada dostarcza uproszczony, zunifikowany interfejs do skomplikowanego podsystemu składającego się z wielu klas, bibliotek i operacji. Fasada ukrywa całą złożoność podsystemu przed klientem, ułatwiając jego użycie, a jednocześnie nie blokuje bezpośredniego dostępu do klas podsystemu, jeśli zaawansowany klient tego potrzebuje.
W poniższym przykładzie klasa Facade upraszcza
współpracę między Subsystem1 i Subsystem2,
wykonując złożoną sekwencję ich operacji za pomocą pojedynczej metody
Operation().
Kod
klas reprezentujących dwa podsystemy (Subsystem1 i
Subsystem2):
// Podsystem może przyjmować żądania zarówno z fasady, jak i bezpośrednio od klienta.
// W każdym przypadku dla podsystemu Fasada jest po prostu kolejnym klientem
// i nie stanowi integralnej części samego podsystemu.
public class Subsystem1
{
public string operation1()
{
return "Subsystem1: Ready!\n";
}
public string operationN()
{
return "Subsystem1: Go!\n";
}
}
// Niektóre fasady mogą koordynować pracę wielu podsystemów jednocześnie.
public class Subsystem2
{
public string operation1()
{
return "Subsystem2: Get ready!\n";
}
public string operationZ()
{
return "Subsystem2: Fire!\n";
}
}
Kod klasy fasady
(Facade):
// Klasa Fasady dostarcza prosty interfejs do złożonej logiki jednego lub
// kilku podsystemów. Deleguje żądania klienta do odpowiednich obiektów
// wewnątrz podsystemu oraz odpowiada za zarządzanie ich cyklem życia.
public class Facade
{
protected Subsystem1 _subsystem1;
protected Subsystem2 _subsystem2;
// Konstruktor umożliwia wstrzyknięcie istniejących instancji podsystemów (zgodnie z DIP)
// lub utworzenie domyślnych egzemplarzy, jeśli klient ich nie przekazał.
public Facade(Subsystem1 subsystem1 = null, Subsystem2 subsystem2 = null)
{
this._subsystem1 = subsystem1 ?? new Subsystem1();
this._subsystem2 = subsystem2 ?? new Subsystem2();
}
// Metody Fasady są wygodnymi skrótami do zaawansowanej funkcjonalności
// podsystemów. Klienci korzystają jednak tylko z wycinka ich możliwości.
public string Operation()
{
string result = "Facade initializes subsystems:\n";
result += this._subsystem1.operation1();
result += this._subsystem2.operation1();
result += "Facade orders subsystems to perform the action:\n";
result += this._subsystem1.operationN();
result += this._subsystem2.operationZ();
return result;
}
}
Użycie w kodzie:
// Kod klienta może posiadać już utworzone niektóre obiekty podsystemu.
// W takim przypadku warto zainicjalizować Fasadę tymi obiektami,
// zamiast pozwalać Fasadzie na tworzenie nowych instancji.
Facade facade = new Facade();
Console.Write(facade.Operation());
Przykładowe wyjście:
Facade initializes subsystems:
Subsystem1: Ready!
Subsystem2: Get ready!
Facade orders subsystems to perform the action:
Subsystem1: Go!
Subsystem2: Fire!
Praktyczne
zastosowanie w ekosystemie .NET: DbContext i
SaveChanges() w Entity Framework Core
Doskonałym produkcyjnym przykładem wzorca Fasady w ekosystemie .NET
jest klasa DbContext oraz metoda
SaveChanges() z biblioteki Entity
Framework Core:
Klient (Serwis Biznesowy)
│
└──► dbContext.SaveChanges() ◄─── [ FASADA: DbContext ]
│
┌───────────────────────────────┼───────────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ ChangeTracker │ │ Query / Command │ │ ADO.NET Core │
│ (Wykrywanie zmian│ │ SQL Generator │ │ • DbConnection │
│ Added/Modified/ │ │ (Budowa drzew AST│ │ • DbTransaction │
│ Deleted) │ │ i parametrów @p)│ │ • DbCommand │
└──────────────────┘ └──────────────────┘ └──────────────────┘
- Złożoność pod spodem: Aby zapisać dane w bazie
relacyjnej bez EF Core, programista musiałby ręcznie: otworzyć
połączenie (
DbConnection), zarządzać transakcją (DbTransaction), wygenerować zapytania SQL (DbCommand), obsłużyć mapowanie parametrów chroniące przed SQL Injection oraz śledzić, które obiekty w pamięci uległy zmianie (Change Tracker). - Fasada
DbContext: Klasa ta udostępnia prosty zestaw właściwości i metod (DbSet<T>,Add,Remove), które pozwalają operować na obiektach domenowych w pamięci RAM. - Rola
SaveChanges(): Metoda ta działa jako główny punkt wejścia fasady, który „pod spodem” koordynuje cały skomplikowany proces: skanuje graf obiektów, otwiera transakcję, generuje zoptymalizowany wsadowy kod SQL dla wszystkich zmian jednocześnie, przesyła go do bazy i zatwierdza transakcję (Commit). Klient wywołuje tylko jedną komendę, nie martwiąc się o wewnętrzną mechanikę dostępu do danych. Jednocześnie, jeśli zaawansowany klient potrzebuje niskopoziomowego dostępu do połączenia lub transakcji, Fasada nie blokuje mu drogi (udostępniając np.dbContext.Database.BeginTransaction()).
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
W architekturze opartej na wstrzykiwaniu zależności Fasada pomaga
zwalczać antywzorzec Constructor Over-injection
(sytuację, w której klasa kontrolera przyjmuje w konstruktorze 8–10
drobnych serwisów podsystemu). Zamiast tego grupujemy powiązane usługi w
Fasadę (lub wykorzystujemy
Autofac.Extras.AggregateService):
// 1. Klasyczna rejestracja Fasady z wstrzykiwaniem podsystemów (MS DI):
services.AddScoped<ISubsystem1, Subsystem1>();
services.AddScoped<ISubsystem2, Subsystem2>();
services.AddScoped<ISystemFacade, SystemFacade>();
// 2. Automatyczna Fasada Agregująca w Autofac (AggregateService):
// Wystarczy zdefiniować sam interfejs gromadzący podsystemy – Autofac sam wygeneruje klasę!
public interface IOrderSubsystemFacade
{
IInventoryService Inventory { get; }
IPaymentService Payment { get; }
IShippingService Shipping { get; }
}
builder.RegisterAggregateService<IOrderSubsystemFacade>();
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Zgodność z DIP i pełna testowalność: W pierwowzorze
konstruktor
new Subsystem1()wewnątrzFacadena sztywno wiązał fasadę z konkretnymi klasami. Wstrzyknięcie interfejsów podsystemów przez kontener pozwala łatwo przetestować Fasadę za pomocą mocków. - Redukcja sprzężenia w warstwie prezentacji: Kontroler ASP.NET Core wstrzykuje tylko jedną Fasadę zamiast dziesięciu serwisów pomocniczych.
3. Wzorce Dekompozycji, Struktur Drzewiastych i Optymalizacji Pamięci
3.1. Most (Bridge – Zakres Obiektowy)
Wzorzec Most (ang. Bridge) polega na rozdzieleniu abstrakcji od jej implementacji w taki sposób, aby oba te wymiary mogły być rozwijane i modyfikowane całkowicie niezależnie od siebie. Jest to realizowane poprzez agregację (kompozycję) zamiast dziedziczenia, co promuje luźne powiązania. Wzorzec ten bezpośrednio wspiera piątą zasadę SOLID – Dependency Inversion Principle (DIP) (moduły wyższego poziomu zależą od abstrakcji implementatora).
Problem eksplozji podklas (Class Explosion): \(N \times M \rightarrow N + M\)
Wzorzec Most rozwiązuje fundamentalny problem tzw. eksplozji podklas, który pojawia się, gdy próbujemy rozszerzać hierarchię klas w dwóch (lub więcej) niezależnych wymiarach jednocześnie poprzez dziedziczenie:
- Problem dziedziczenia (\(N \times
M\)): Jeśli mamy \(N\)
rodzajów wiadomości (np.
SystemMessage,UserMessage,MarketingMessage) i chcemy je wysyłać przez \(M\) różnych kanałów komunikacji (np.Email,SMS,Push), to stosując klasyczne dziedziczenie, musielibyśmy utworzyć \(N \times M\) konkretnych podklas (np.SystemEmailMessage,SystemSmsMessage,UserEmailMessageitd.). Dla \(N = 5\) typów wiadomości i \(M = 5\) kanałów daje to aż \(5 \times 5 = 25\) klas! - Rozwiązanie przez Most (\(N +
M\)): Wzorzec ten zastępuje dziedziczenie między
wymiarami kompozycją. „Wycinamy” implementację wysyłki do osobnego
interfejsu
IMessageSenderi wstrzykujemy go do klasyMessage. - Zaleta: Liczba potrzebnych klas redukuje się z
iloczynu do sumy: \(N + M\) (w
powyższym przykładzie z \(25\) do
zaledwie \(5 + 5 = 10\) klas). Zmiana
kanału wysyłki nie wymaga tworzenia nowej klasy wiadomości – wystarczy
wstrzyknąć inny obiekt implementujący
IMessageSender.
❌ PODEJŚCIE OPARTYM NA DZIEDZICZENIU (Iloczyn N * M klas):
[ Message ]
/ \
[SystemMessage] [UserMessage]
/ \ / \
[SystemEmail] [SystemSms] [UserEmail] [UserSms] ... (N * M klas!)
✅ PODEJŚCIE Z WZORCEM MOST / BRIDGE (Suma N + M klas):
ABSTRAKCJA (N) IMPLEMENTACJA (M)
┌─────────────────┐ Most ┌────────────────────┐
│ Message │ ───(_sender)─────► │ IMessageSender │
└────────▲────────┘ (Kompozycja) └─────────▲──────────┘
│ │
┌────────┴────────┐ ┌─────────┴──────────┐
│ • SystemMessage │ │ • EmailSender │
│ • UserMessage │ │ • SmsSender │
└─────────────────┘ └────────────────────┘
🔗 Powiązanie ze wzorcem Budowniczy (z Części 1):
Kreacyjny wzorzec Budowniczy (Builder) jest strukturalnie powiązany ze wzorcem Most: klasaDirectorpełni w nim rolę Abstrakcji, natomiast interfejsIBuilderoraz konkretni budowniczowie pełnią rolę Implementacji wymienianych pod spodem.
Przykład implementacji (Komunikacja między platformami):
- Implementor (Implementator –
IMessageSender): Interfejs definiujący niskopoziomowe operacje dostarczania wiadomości. - ConcreteImplementor (
EmailSender,SmsSender): Konkretne, wymienne implementacje platform komunikacyjnych. - Abstraction (Abstrakcja –
Message): Klasa wysokopoziomowa, która utrzymuje referencję doIMessageSender. - RefinedAbstraction (Udoskonalona Abstrakcja –
SystemMessage): Rozszerza klasęMessageo specyficzną logikę biznesową.
// 1. Implementor (Interfejs dla platform komunikacyjnych)
public interface IMessageSender
{
void SendMessage(string subject, string body);
}
// 2. ConcreteImplementorA (Implementacja dla Email)
public class EmailSender : IMessageSender
{
public void SendMessage(string subject, string body)
{
Console.WriteLine($"Wysłano wiadomość Email: '{subject}' - {body}");
}
}
// 3. ConcreteImplementorB (Implementacja dla SMS)
public class SmsSender : IMessageSender
{
public void SendMessage(string subject, string body)
{
Console.WriteLine($"Wysłano wiadomość SMS: '{subject}' - {body}");
}
}
// 4. Abstraction (Klasa bazowa Wiadomości)
public abstract class Message
{
protected IMessageSender _sender;
// Bridge: Ustanawianie mostu do implementacji przez konstruktor
public Message(IMessageSender sender)
{
_sender = sender;
}
public abstract void Send();
}
// 5. RefinedAbstraction (Wiadomość Systemowa)
public class SystemMessage : Message
{
private string _subject;
private string _body;
public SystemMessage(IMessageSender sender, string subject, string body)
: base(sender)
{
_subject = subject;
_body = body;
}
public override void Send()
{
// Abstrakcja deleguje fizyczną wysyłkę przez Most do Implementatora:
_sender.SendMessage($"SYSTEM: {_subject}", _body);
}
}
Użycie w kodzie:
var email = new SystemMessage(new EmailSender(), "Awaria", "Serwer niedostępny.");
email.Send();
var sms = new SystemMessage(new SmsSender(), "Awaria", "Serwer niedostępny.");
sms.Send();
Przykładowe wyjście:
Wysłano wiadomość Email: 'SYSTEM: Awaria' - Serwer niedostępny.
Wysłano wiadomość SMS: 'SYSTEM: Awaria' - Serwer niedostępny.
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
W nowoczesnych aplikacjach .NET Most jest naturalnym rezultatem
wstrzykiwania zależności. Wybór konkretnego „Implementatora” po drugiej
stronie mostu realizuje się poprzez usługi kluczowane (Keyed
Services w .NET 8+) lub indeksowane
(IIndex<TKey, TValue> w
Autofac):
// 1. Rejestracja Implementatorów Mostu w Microsoft DI (.NET 8+):
services.AddKeyedTransient<IMessageSender, EmailSender>("Email");
services.AddKeyedTransient<IMessageSender, SmsSender>("Sms");
// 2. Rejestracja w Autofac z fabryką parametryzowaną:
builder.RegisterType<EmailSender>().Keyed<IMessageSender>(Channel.Email);
builder.RegisterType<SmsSender>().Keyed<IMessageSender>(Channel.Sms);
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Dynamiczne zestawianie Mostu w runtime: Klasa
wyższego poziomu może otrzymać w konstruktorze
IIndex<Channel, IMessageSender>i wybierać kanał transmisji dynamicznie na podstawie preferencji użytkownika lub stanu awaryjnego sieci.
3.2. Kompozyt (Composite – Zakres Obiektowy) i Idiomy Kolekcji C#
Wzorzec Kompozyt umożliwia traktowanie obiektów indywidualnych oraz złożonych grup obiektów (kompozycji) w jednolity, polimorficzny sposób. Struktura ta tworzy hierarchiczne struktury drzewiaste typu część–całość.
Elementy wzorca:
- Komponent (
IUnitComponent): Abstrakcyjny interfejs dla wszystkich obiektów w strukturze (zarówno liści, jak i kompozytów). - Liść (Leaf –
Soldier): Reprezentuje pojedyncze obiekty końcowe w drzewie, które nie mogą posiadać elementów podrzędnych. - Kompozyt (Composite –
CommanderComposite): Reprezentuje obiekty złożone, które mogą posiadać dzieci (inne liście lub kolejne kompozyty) i implementuje operacje z interfejsu komponentu poprzez delegowanie zadania w dół hierarchii.
A. Przykład 1: Hierarchia militarna (Klasyczny Kompozyt GoF)
Wydanie rozkazu Move() dla dowódcy
(CommanderComposite) powoduje, że dowódca sam wykonuje
ruch, a następnie rekurencyjnie przekazuje rozkaz wszystkim swoim
podległym jednostkom z kolekcji _children (zarówno
pojedynczym żołnierzom, jak i podległym dowódcom niższych szczebli).
// Interfejs, po którym dziedziczy każdy komponent w drzewie
public interface IUnitComponent
{
void Move();
}
// Liść (Leaf)
public class Soldier : IUnitComponent
{
public void Move()
{
Console.WriteLine("I move after my commander");
}
}
// Kompozyt (Composite)
public class CommanderComposite : IUnitComponent
{
// Lista wszystkich dzieci przypisanych do dowódcy
private ICollection<IUnitComponent> _children = new List<IUnitComponent>();
// Rozkaz do ruszenia się delegowany w dół drzewa
public void Move()
{
Console.WriteLine("I'm a commander and i go first");
foreach (var item in _children)
{
item.Move();
}
}
public void AddChild(IUnitComponent entity) => _children.Add(entity);
public void RemoveChild(IUnitComponent entity) => _children.Remove(entity);
public ICollection<IUnitComponent> GetChildren() => _children;
}
Użycie w kodzie:
// Powołanie do życia 1. dowódcy i dodanie podległych żołnierzy
CommanderComposite commander1 = new CommanderComposite();
Soldier soldier1 = new();
commander1.AddChild(soldier1);
commander1.AddChild(new Soldier());
// Powołanie do życia 2. dowódcy (nadrzędnego), któremu podlega 1. dowódca i dwaj żołnierze
CommanderComposite commander2 = new CommanderComposite();
commander2.AddChild(commander1);
commander2.AddChild(new Soldier());
commander2.AddChild(new Soldier());
Console.WriteLine("Wydanie rozkazu do poruszenia jednego żołnierza");
soldier1.Move();
Console.WriteLine("Wydanie rozkazu do poruszenia 1 dowódcy i wszystkich jego żołnierzy");
commander1.Move();
Console.WriteLine("Wydanie rozkazu do poruszenia 2 dowódcy i wszystkich jego żołnierzy");
commander2.Move();
Przykładowe wyjście:
Wydanie rozkazu do poruszenia jednego żołnierza
I move after my commander
Wydanie rozkazu do poruszenia 1 dowódcy i wszystkich jego żołnierzy
I'm a commander and i go first
I move after my commander
I move after my commander
Wydanie rozkazu do poruszenia 2 dowódcy i wszystkich jego żołnierzy
I'm a commander and i go first
I'm a commander and i go first
I move after my commander
I move after my commander
I move after my commander
I move after my commander
B.
Przykład 2: Konstrukcja
class ClassA : IEnumerable<ClassA> oraz Metody
Rozszerzające a Zasada OCP
Stosowanie interfejsu IEnumerable<T> na poziomie
pojedynczej klasy (która sama w sobie jest pojedynczym liściem, a nie
typową kolekcją) to zaawansowany idiom języka C# pozwalający na
ujednolicenie traktowania pojedynczych obiektów i ich grup.
Rozważmy model sieci neuronowej, w której występują pojedyncze
neurony (Neuron) oraz całe warstwy neuronów
(NeuronLayer). Chcemy umożliwić łączenie ze sobą elementów
we wszystkich możliwych kombinacjach:
Neuron→NeuronNeuron→NeuronLayerNeuronLayer→NeuronNeuronLayer→NeuronLayer
Bez wspólnego mianownika wymagałoby to napisania \(2 \times 2 = 4\) przeciążeń metody
ConnectTo (a przy 3 typach jednostek już \(3 \times 3 = 9\) metod!).
Klasa
reprezentująca pojedynczy neuron (Neuron) oraz warstwę
(NeuronLayer):
using System.Collections;
using System.Collections.ObjectModel;
public class Neuron : IEnumerable<Neuron>
{
public ICollection<Neuron> In { get; set; }
public ICollection<Neuron> Out { get; set; }
public Neuron()
{
In = new List<Neuron>();
Out = new List<Neuron>();
}
// Jeśli pozostawimy tę metodę w klasie Neuron, będzie miała ona pierwszeństwo
// dla przypadku Neuron -> Neuron. Jeśli ją usuniemy, wszystkie 4 przypadki
// obsłuży jedna uniwersalna metoda rozszerzająca!
public void ConnectTo(Neuron other)
{
Out.Add(other);
other.In.Add(this);
}
// Pojedynczy obiekt (liść) "udaje" jednoelementową kolekcję zwracającą samą siebie:
public IEnumerator<Neuron> GetEnumerator()
{
yield return this;
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
public class NeuronLayer : Collection<Neuron>
{
public NeuronLayer(int count = 0)
{
while (count-- > 0)
Add(new Neuron());
}
}
Metoda
rozszerzająca ConnectTo a zasada Open/Closed (OCP):
Zasada Open/Closed (Otwarte-Zamknięte) głosi, że elementy systemu powinny być otwarte na rozszerzanie, ale zamknięte na modyfikację. Metody rozszerzające (ang. extension methods) są w środowisku .NET jednym z najbardziej eleganckich narzędzi do realizacji tego postulatu:
- Rozszerzanie bez modyfikacji: Pozwalają na
„doklejanie” nowych funkcjonalności do istniejących klas lub interfejsów
(np.
IEnumerable<T>) bez konieczności edytowania ich kodu źródłowego, dziedziczenia po nich czy ponownej kompilacji oryginalnych modułów. - Czystość architektury: Unikamy tzw. „pęcznienia” klas bazowych o metody potrzebne tylko w specyficznych kontekstach.
public static class NeuronExtensions
{
public static void ConnectTo(
this IEnumerable<Neuron> self, IEnumerable<Neuron> other)
{
if (ReferenceEquals(self, other)) return;
foreach (var from in self)
foreach (var to in other)
{
from.Out.Add(to);
to.In.Add(from);
}
}
}
🔍 Priorytet wiązania metod w kompilatorze C#:
Zwróć uwagę, że jeśli w klasieNeuronpozostawimy metodę instancyjnąpublic void ConnectTo(Neuron other), kompilator C# nada jej wyższy priorytet niż metodzie rozszerzającej przy wywołaniuneuronA.ConnectTo(neuronB). Jeśli jednak usuniemy metodęConnectToz wnętrza klasyNeuron, wówczas jedna statyczna metoda rozszerzającaConnectToobsłuży wszystkie 4 kombinacje połączeń.
Korzyści architektoniczne z tego podejścia:
- Polimorfizm kolekcji: Pojedynczy obiekt (liść)
zachowuje się jak jednoelementowa kolekcja
(
yield return this). - Redukcja złożoności (Combinatorial
Explosion): Zamiast \(N^2\) przeciążeń metod piszemy dokładnie
jedną metodę operującą na
IEnumerable<Neuron>. - Przezroczystość dla klienta: Kod kliencki nie musi
sprawdzać instrukcją
if, czy pracuje z jednym elementem, czy z całym grafem obiektów.
Użycie w kodzie:
Neuron a = new();
Neuron b = new();
a.ConnectTo(b); // 1. Neuron -> Neuron
var layer = new NeuronLayer(4);
b.ConnectTo(layer); // 2. Neuron -> Layer
NeuronLayer layer2 = new(4);
Neuron c = new();
layer2.ConnectTo(c); // 3. Layer -> Neuron
layer.ConnectTo(layer2); // 4. Layer -> Layer
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
Kontener Autofac posiada dedykowaną metodę
RegisterComposite, która pozwala
zarejestrować wzorzec Kompozytu tak, aby automatycznie zbierał wszystkie
zarejestrowane implementacje interfejsu (liście) i wstrzykiwał je do
obiektu kompozytu:
// 1. Natywna rejestracja Kompozytu w Autofac (RegisterComposite):
builder.RegisterType<FileLogger>().As<ILogger>();
builder.RegisterType<DatabaseLogger>().As<ILogger>();
// CompositeLogger przyjmuje w konstruktorze IEnumerable<ILogger> (wszystkie liście),
// ale sam nigdy nie tworzy cyklu rekurencyjnego sam ze sobą!
builder.RegisterComposite<CompositeLogger, ILogger>();
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Automatyczna agregacja bez pętli rekurencyjnej: Bez
RegisterCompositepróba zarejestrowania klasyCompositeLogger : ILogger, która przyjmuje w konstruktorzeIEnumerable<ILogger>, zakończyłaby się błędem przepełnienia stosu (StackOverflowException/ Circular Dependency), ponieważ kontener próbowałby wstrzyknąćCompositeLoggerdo samego siebie.RegisterCompositerozwiązuje ten problem natywnie.
3.3. Pyłek (Flyweight – Zakres Obiektowy) i Mechanizm Copy-on-Write (CoW)
A. Wzorzec Pyłek (Flyweight)
Wzorzec Pyłek jest używany do radykalnej minimalizacji zużycia pamięci RAM poprzez współdzielenie (ang. sharing) instancji obiektów reprezentujących ten sam stan. Polega on na rygorystycznym podziale danych obiektu na dwie kategorie:
- Stan wewnętrzny (Intrinsic State):
Niezmienny (immutable), niezależny od kontekstu i
współdzielony przez tysiące obiektów (np. model siatki
3D drzewa, tekstura kory, nazwa gatunku). Przechowywany wewnątrz klasy
Pyłka (
TreeType). - Stan zewnętrzny (Extrinsic State): Zmienny, unikalny dla każdego egzemplarza (np. współrzędne \((X, Y)\) pojedynczego drzewa na mapie). Przechowywany na zewnątrz i przekazywany do metod Pyłka jako parametr wywołania.
100 000 obiektów drzew na mapie (Forest) Pula Pyłków w RAM (TreeTypeFactory)
┌──────────────────────────────────┐ ┌─────────────────────────────────┐
│ Drzewo #1: X=10, Y=5 ───────┼────────────────────►│ TreeType "Dąb" │
│ Drzewo #2: X=15, Y=6 ───────┼────────────────────►│ • Tekstura (4 MB) │
│ ... │ │ • Kolor: Green │
│ Drzewo #9999: X=20, Y=8 ───────┼───────────┐ └─────────────────────────────────┘
└──────────────────────────────────┘ │ ┌─────────────────────────────────┐
(Tylko 8 bajtów na współrzędne └────────►│ TreeType "Sosna" │
+ 8 bajtów na wskaźnik referencji!) │ • Tekstura (4 MB) │
└─────────────────────────────────┘
Przykład implementacji:
- ConcreteFlyweight (
TreeType): Zawiera stan wewnętrzny (niezmienny, współdzielony). - FlyweightFactory (
TreeTypeFactory): Zarządza pamięcią podręczną (cache) obiektów Pyłka, gwarantując utworzenie co najwyżej jednej instancji dla danego klucza. - Client (
Forest): Przechowuje stan zewnętrzny \((X, Y)\) i deleguje rysowanie do pyłków.
// 1. & 2. Flyweight (Współdzielony obiekt z wewnętrznym stanem)
public class TreeType
{
// Stan wewnętrzny - niezmienny, współdzielony
private readonly string _name;
private readonly ConsoleColor _color;
private readonly string _texture;
public TreeType(string name, ConsoleColor color, string texture)
{
_name = name;
_color = color;
_texture = texture;
}
// Metoda przyjmuje stan zewnętrzny (pozycja X, Y)
public void Draw(int x, int y)
{
Console.ForegroundColor = _color;
Console.WriteLine($"Rysowanie {_name} o teksturze {_texture} w pozycji ({x}, {y}).");
Console.ResetColor();
}
}
// 3. FlyweightFactory (Zarządzanie pulą obiektów współdzielonych)
public class TreeTypeFactory
{
private readonly IDictionary<string, TreeType> _cache = new Dictionary<string, TreeType>();
public TreeType GetTreeType(string name, ConsoleColor color, string texture)
{
string key = $"{name}_{color}_{texture}";
if (!_cache.ContainsKey(key))
{
Console.WriteLine($"Tworzenie nowego obiektu TreeType: {name}");
_cache.Add(key, new TreeType(name, color, texture));
}
return _cache[key];
}
}
// 4. Client (Użycie i zarządzanie stanem zewnętrznym)
public class Forest
{
private readonly ICollection<(TreeType Type, int X, int Y)> _trees = new List<(TreeType, int, int)>();
private readonly TreeTypeFactory _factory = new();
public void PlantTree(string name, ConsoleColor color, string texture, int x, int y)
{
// Używamy Pyłka z fabryki – stan wewnętrzny jest dzielony
TreeType type = _factory.GetTreeType(name, color, texture);
// Stan zewnętrzny jest przechowywany na liście (X, Y)
_trees.Add((type, x, y));
}
public void DrawForest()
{
foreach (var tree in _trees)
{
tree.Type.Draw(tree.X, tree.Y);
}
}
}
Użycie w kodzie:
Forest forest = new Forest();
// 1. Dąb - nie ma go w pamięci, więc zostanie stworzony
forest.PlantTree("Dąb", ConsoleColor.Green, "Gruby", 10, 5);
// 2. Sosna - nie ma jej w pamięci, więc zostanie stworzona
forest.PlantTree("Sosna", ConsoleColor.DarkGreen, "Cienki", 20, 8);
// 3. Kolejny Dąb - zostanie pobrany z pamięci podręcznej (brak komunikatu o tworzeniu)
forest.PlantTree("Dąb", ConsoleColor.Green, "Gruby", 15, 6);
// 4. Kolejna Sosna - zostanie pobrana z pamięci podręcznej (brak komunikatu o tworzeniu)
forest.PlantTree("Sosna", ConsoleColor.DarkGreen, "Cienki", 25, 1);
// Rysujemy cały las
forest.DrawForest();
Przykładowe wyjście:
Tworzenie nowego obiektu TreeType: Dąb
Tworzenie nowego obiektu TreeType: Sosna
Rysowanie Dąb o teksturze Gruby w pozycji (10, 5).
Rysowanie Sosna o teksturze Cienki w pozycji (20, 8).
Rysowanie Dąb o teksturze Gruby w pozycji (15, 6).
Rysowanie Sosna o teksturze Cienki w pozycji (25, 1).
B. Mechanizm Copy-on-Write (CoW): Synergia Pyłka i Prototypu
Mechanizm Copy-on-Write (Kopiowanie przy zapisie) to zaawansowana strategia optymalizacji, która łączy oszczędność pamięci wzorca Pyłek z elastycznością modyfikacji danych wzorca Prototyp (poznanego w Części 1).
Polega on na tym, że dopóki wiele obiektów korzysta z tego samego
zasobu (typu referencyjnego) wyłącznie w trybie odczytu, współdzielą one
jedną instancję Pyłka w pamięci. Dopiero w momencie, gdy któryś
z obiektów spróbuje zmodyfikować swój stan, system wykonuje głęboką
kopię (Clone) i odłącza zmodyfikowany egzemplarz od
współdzielonego pyłka.
Jak współpracują te trzy mechanizmy?
- Pyłek (Flyweight) jako baza: Wydziela stan wewnętrzny, który domyślnie jest współdzielony przez tysiące obiektów w pamięci podręcznej (cache).
- Prototyp (Prototype) jako mechanizm
kopiowania: Dostarcza metodę
Clone()pozwalającą powielić obiekt bazowy bez wiązania kodu z jego konkretną klasą. - Strategia Copy-on-Write (CoW) jako koordynator: Zarządza logiką „kiedy kopiować” – odracza alokację nowej pamięci do ułamka przypadków, w których faktycznie dochodzi do zapisu.
public class TreeModel : ICloneable
{
public string Species { get; set; }
public ConsoleColor LeafColor { get; set; }
public TreeModel(string species, ConsoleColor leafColor)
{
Species = species;
LeafColor = leafColor;
}
// Wzorzec Prototyp: Kopia na potrzeby odłączenia stanu
public object Clone() => new TreeModel(Species, LeafColor);
}
public class CoWTreeInstance
{
private TreeModel _sharedOrPrivateModel;
public bool IsDetachedFromFlyweight { get; private set; } = false;
public int X { get; }
public int Y { get; }
public CoWTreeInstance(TreeModel sharedFlyweight, int x, int y)
{
_sharedOrPrivateModel = sharedFlyweight;
X = x;
Y = y;
}
// Strategia Copy-on-Write: Klonujemy dopiero w momencie mutacji!
public void MutateColor(ConsoleColor newAutumnColor)
{
if (!IsDetachedFromFlyweight)
{
_sharedOrPrivateModel = (TreeModel)_sharedOrPrivateModel.Clone();
IsDetachedFromFlyweight = true;
}
_sharedOrPrivateModel.LeafColor = newAutumnColor;
}
public TreeModel CurrentModel => _sharedOrPrivateModel;
}
⚡ Dla dociekliwych: Wzorzec Pyłek w sercu środowiska wykonawczego .NET CLR – String Interning (Kliknij, aby rozwinąć)
Środowisko wykonawcze .NET CLR wykorzystuje wzorzec Pyłek (Flyweight)
do zarządzania literałami tekstowymi string za pomocą tzw.
Puli Internowania Napisów (String Intern
Pool):
string s1 = "ZaawansowaneTechnikiObiektowe";
string s2 = "ZaawansowaneTechnikiObiektowe";
// Zwróci TRUE! Oba wskaźniki pokazują na ten sam adres fizyczny w puli Pyłków CLR:
bool sameMemoryAddress = ReferenceEquals(s1, s2);
// Dynamicznie utworzony napis możemy ręcznie dołączyć do puli Pyłków:
string dynamicString = new StringBuilder("Zaawansowane").Append("TechnikiObiektowe").ToString();
string internedString = string.Intern(dynamicString);
Console.WriteLine(ReferenceEquals(s1, internedString)); // TRUE!
Ponieważ typ string w C# jest całkowicie
niemutowalny (immutable), wszystkie identyczne
stałe tekstowe w programie mogą bezpiecznie współdzielić jedną instancję
na stercie. Z kolei każda próba modyfikacji napisu (np.
s1.Replace(...)) działa w oparciu o semantykę
Copy-on-Write: pozostawia oryginalny pyłek nienaruszony
i alokuje nowy ciąg znaków.
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
W aplikacjach wielowątkowych klasyczny
Dictionary<string, TreeType> z pierwowzoru rzuci
wyjątek przy równoczesnym dostępie z wielu wątków. W nowoczesnym .NET
fabrykę pyłków rejestruje się jako bezpieczny wątkowo
Singleton oparty na
ConcurrentDictionary<TKey, TValue> lub systemowym
IMemoryCache:
// Bezpieczna wątkowo Fabryka Pyłków rejestrowana jako Singleton w DI:
public class ThreadSafeTreeTypeFactory
{
private readonly ConcurrentDictionary<string, TreeType> _cache = new();
public TreeType GetTreeType(string name, ConsoleColor color, string texture)
{
string key = $"{name}_{color}_{texture}";
// Atomowe pobranie lub utworzenie pyłka w środowisku współbieżnym:
return _cache.GetOrAdd(key, _ => new TreeType(name, color, texture));
}
}
// Rejestracja w Microsoft DI / Autofac:
services.AddSingleton<ThreadSafeTreeTypeFactory>();
builder.RegisterType<ThreadSafeTreeTypeFactory>().SingleInstance();
4. Podsumowanie Wzorców Strukturalnych i Porównanie Architektoniczne
4.1. Jak nie pomylić wzorców opakowujących? (Adapter vs Dekorator vs Pełnomocnik vs Fasada)
Cztery wzorce strukturalne polegają na „owijaniu” (wrapping) innych obiektów, przez co na pierwszy rzut oka ich kod wydaje się podobny. Różnią się jednak fundamentalnie intencją architektoniczną oraz wpływem na interfejs:
1. ADAPTER: [ Klient ] ──► [ ITarget (Adapter) ] ──► [ Adaptee (Inny interfejs!) ]
Cel: Konwersja niekompatybilnego interfejsu (1:1).
2. DEKORATOR: [ Klient ] ──► [ IService (Dekorator) ] ──► [ IService (Oryginał) ]
Cel: Ten sam interfejs + dynamiczne dodanie nowych zachowań (OCP).
3. PEŁNOMOCNIK: [ Klient ] ──► [ IService (Proxy) ] ───?──► [ IService (Oryginał) ]
Cel: Ten sam interfejs + kontrola dostępu / leniwa inicjalizacja.
4. FASADA: [ Klient ] ──► [ Prosta Fasada ] ──┬──► [ Podsystem A ]
├──► [ Podsystem B ]
└──► [ Podsystem C ]
Cel: Nowy, uproszczony interfejs ukrywający złożony podsystem (1:N).
4.2. Zestawienie Wzorców Strukturalnych GoF z Nowoczesnymi Kontenerami IoC/DI
| Klasyczny Wzorzec GoF | Zakres | Odpowiednik w Kontenerze IoC/DI i .NET | Korzyść architektoniczna w nowoczesnym .NET |
|---|---|---|---|
| Adapter | Klasowy / Obiektowy | builder.RegisterAdapter<TFrom, TTo>()
(Autofac), AutoMapper / Mapster, C++/CLI
Wrapper. |
Automatyczna translacja niekompatybilnych serwisów w locie bez modyfikacji konsumenta. |
| Dekorator | Obiektowy | builder.RegisterDecorator<TDec, TService>()
(Autofac), Scrutor, DelegatingHandler. |
Kaskadowe dokładanie logowania, cache'u i telemetrii zgodnie z OCP w jednej linijce konfiguracji. |
| Pełnomocnik (Proxy) | Obiektowy | DispatchProxy,
Castle.DynamicProxy,
EnableInterfaceInterceptors() w Autofac. |
Centralna kontrola uprawnień (AOP) i leniwe ładowanie bez pisania ręcznych klas pośredniczących. |
| Fasada | Obiektowy | DbContext (EF Core),
RegisterAggregateService<T>() w Autofac, serwisy
aplikacyjne. |
Ukrycie skomplikowanych podsystemów i eliminacja Constructor Over-injection w kontrolerach. |
| Most (Bridge) | Obiektowy | Wstrzykiwanie abstrakcji implementatora
przez konstruktor, Keyed Services, IIndex<K,V>. |
Redukcja liczby klas z \(N \times M\) do \(N + M\); niezależny rozwój abstrakcji i platformy. |
| Kompozyt | Obiektowy | builder.RegisterComposite<TComp, TService>()
(Autofac), IEnumerable<T> + metody
rozszerzające. |
Jednolite traktowanie liści i drzew oraz eliminacja eksplozji przeciążeń metod (\(N^2 \rightarrow 1\)). |
| Pyłek (Flyweight) | Obiektowy | Singleton z
ConcurrentDictionary, IMemoryCache,
string.Intern w CLR + strategia CoW. |
Wielokrotna redukcja zużycia pamięci RAM przy setkach tysięcy obiektów współdzielących stan. |
5. Zadania Laboratoryjne
Zadania laboratoryjne do Części 2 zostaną udostępnione w osobnym
dokumencie:
📄
Zadanie Laboratoryjne do Części 2: Architektura Wzorców Strukturalnych, Kompozycji i Optymalizacji Pamięci (zad_lab_2_wzorce_strukt.md)
6. Literatura i Źródła
- Refactoring Guru: Wzorce projektowe w C# – Wzorce Strukturalne – https://refactoring.guru/pl/design-patterns/structural-patterns
- Piotr Łukasiewicz: Wzorzec projektowy Kompozyt (Composite) – https://www.plukasiewicz.net/Artykuly/DesignPatternComposite
- Cezary Walenciuk: Composite – Kompozycja – Wzorce Projektowe C# – https://cezarywalenciuk.pl/blog/programing/composite-kompozycja-wzorce-projektowe-c
- Infotraining: Wzorce projektowe w C-Sharp – https://infotraining.bitbucket.io/cs-dp/
- Dokumentacja Autofac: Adapters, Decorators, Composites & Interceptors (AOP) – https://autofac.readthedocs.io/en/latest/advanced/adapters-decorators.html
- Dokumentacja Microsoft Learn: C++/CLI Migration and Interop (Wrapper / Adapter Pattern) – https://learn.microsoft.com/en-us/cpp/dotnet/dotnet-programming-with-cpp-cli