Czym jest wzorzec? Wzorzec jest przepisem na rozwiązanie określonego problemu. Należy jednak podkreślić, że nie jest gotowym i jednoznacznym rozwiązaniem problemu, a jedynie sugestią w jaki najlepszy sposób można go rozwiązać. Dużym błędem popełnianym przez młodych adeptów sztuki programistycznej jest nadmierny zachwyt nad wzorcami. Nie można ślepo wierzyć w niezawodność danego wzorca, gdyż wystarczy czasem jeden parametr projektowy różniący nasze rozwiązanie od opisywanego i cały wzorzec przestaje mieć sens bytu. Inną sprawą jest upychanie miriadów różnych wzorców w projekcie, który wcale tego nie wymaga, gdyż „tak jest modnie”. Dzięki czemu kod rozrasta się tworząc epopeje, które mogłyby występować w postaci nowelek. Tak jak wszędzie i tutaj trzeba umieć wypośrodkować nasze pomysły.

Stosując wzorce trzeba wcześniej zapoznać się ze specyfiką używanego języka. Wiele z algorytmów będzie spójne w każdym języku, jednakże często o wiele bardziej opłacalne będzie wykorzystanie już zaimplementowanych rozwiązań. Poniżej chciałbym zaprezentować przykład jak bardzo semantyka języka może mieć wpływ na stosowanie wzorców projektowych na przykładzie problemów, na które natknąłem się w paru projektach i sposób ich rozwiązania.

Typowym przykładem wzorca gotowego do wykorzystania są iteratory występujące obecnie w większości języków obiektowych, czy dekoratory. Posiadają bogatą dokumentację wraz z przykładami użycia, zainteresowany czytelnik znajdzie informacje w Internecie. Ciekawszym problemem jest sytuacja, gdy należy wzorzec zaimplementować własnoręcznie. Weźmy przykładowo problem ciężkiego obiektu– zawierającego olbrzymią liczbę metod, obiektów źródeł itp. Itd.– który chcemy załadować w sposób leniwy ze względu na jego wagę.:

Obiekt ciężki

class HeavyObject(object): 

def first_method(self):

print ”This is the very first method”

 

def second_method(self):

print ”This is second method”

 

Jako klasyczne rozwiązanie weźmy model VirtualProxy. Zgodnie z opisem należałoby zdefiniować klasę obiektu Proxy, który będzie „udawał” nasz ciężki obiekt, w sposób następujący:

Virtual Proxy

class MyProxy(object): 

 

def __init__(self):

self.__obj = HeavyObject()

 

def first_method(self):

self.__obj.first_method()

 

def second_method(self):

self.__obj.second_method()

 

Widać, że przy dużej liczbie metod pojawia się pomysł na wykorzystanie metody Copy-Pastego, co z definicji przeczy zasadom dobrego programowania oraz dostarczy w przyszłości wielu nerwowych godzin na utrzymanie. W jaki sposób załatwić powyższy problem wykorzystując specyfikę Pythona?

Virtual Proxy ver.2

class MyBetterProxy(object): 

 

def __init__(self):

self.__obj.HeavyObject()

 

def __getattr__(self, name):

return getattr(self.__obj, name)

Koniec. Widać, że wykorzystując pythonowy mechanizm delegata zapewniamy sobie bardzo krótki i przejrzysty kod.

Ciekawym przykładem różnego podejścia koncepcyjnego między językami „klasycznymi” i pytonem jest przykład wzorca konstrukcyjnego jakim jest singleton. Klasycznym rozwiązaniem jest wykorzystanie zmiennej klasowej przechowującej informację o tym, czy istnieje instancja danej klasy:

Singleton klasyczny

class Foo(object): 

_instance = None    # zmienna klasowa przechowująca

# instancję klasy

def __new__(cls, *args, **kwargs):

if not cls._instance:

# blok inicjalizujący klasę


cls._instance = cls.__new__(*args, **kwargs)

return cls._instance

Spróbujmy teraz wykorzystać elementy „magii” w pythonie, jakimi są meta klasy. Informacje o stosowaniu meta klas również są bogato opisane w Internecie, zainteresowanych odsyłam do odpowiednich stron .

Singleton jako metaklasa

class Singleton(type): 

 

def __init__(cls, name, bases, namespace):

super(Singleton, cls).__init__(name, bases, namespace)

cls._instance = None   # !!!


def __call__(cls, *args, **kw):

if cls._instance is None:

cls._instance = \

super(Singleton, cls).__call__(*args, **kw)

return cls._instance

 

class Foo(object):

__metaclass__ = Singleton

def __init__(self):

pass

W paru słowach: fragment kodu _metaclass_ = Singleton oznacza, że klasa Foo jest obiektem klasy Singleton. Czyli definicje zawarte w klasie Singleton przenoszą się na klasę Foo.
Możemy teraz sprawdzić działanie rozwiązania:

>>> a = Foo() 

>>> b = Foo()

>>> a

<__main__.Foo object  n 0x838ae6c>

>>> b

<__main__.Foo object  n 0x838ae6c>

Widać, że obydwa nowe obiekty są tak naprawdę jednym obiektem, co chcieliśmy osiągnąć. A teraz ciekawostka– co się stanie, gdy dodamy klasę Foo2 dziedziczącą po klasie Foo?

>>> class Foo2(Foo): 

...    def __init__(self):

...        pass

>>> d = Foo2()

>>> d

<__main__.Fooinh object  n 0x81aca4c>

Zaprezentowana implementacja wzorca sprawiła, że klasy dziedziczące po danej nie mają cech singletona. Czy takie rozwiązanie spełnia wymagania stawiane przez projekt? Weźmy abstrakcyjną klasę Data Access Object definiującą podstawowe metody dostępowe do jakiegoś źródła danych (na przykład instancja MySQL). Chcemy, żeby maksymalnie jeden obiekt w danym czasie dopytywał o te dane (Nie chcemy pozwolić, żeby kilka klonów danej aplikacji wzajemnie podbierało sobie dane do przetworzenia). Zakładamy, że będzie to cecha wszystkich potomnych obiektów DAO dedykowanych dla różnych źródeł. Następnie tworzymy te obiekty potomne DAO. Przykładowo jeden obiekt odpowiedzialny będzie za operacje na danych użytkowników, a inny za pobieranie ich wpisów blogowych itd. Nagle okazuje się, że to co było bardzo kolorowo opisane w powyższym przykładzie zawodzi i dostajemy olbrzymi ruch na źródle danych, którego nie potrafimy opanować. Error logi naszej aplikacji zaczynają być zasypywane komunikatami, że dany rekord już nie istnieje w bazie (co oczywiste, bo inny klon już go usunął). Nasze ślepe zaufanie do wzorca doprowadziło do katastrofy projektu.
Oczywiście nie ma sytuacji bez wyjścia. Wystarczy przykładowo przerobić powyższą definicję singeltona w sposób następujący:

Inna postać singletona

class Singleton(type): 


_instance = None   # !!!

def __init__(cls, name, bases, namespace):

super(Singleton, cls).__init__(name, bases, namespace)

 

def __call__(cls, *args, **kw):

if cls._instance is None:

cls._instance = \

super(Singleton, cls).__call__(*args, **kw)

return cls._instance

W rezultacie otrzymamy co następuje:

>>>a = Foo() 

>>>b = Foo()

>>>c = Foo2()

>>>a

<__main__.Foo2 object at 0x81ab2ac>

>>>b

<__main__.Foo2 object at 0x81ab2ac>

>>>c

<__main__.Fooinh object at 0x81ab2ac>

Czyli każdy obiekt potomny będzie również singletonem– projekt został uratowany. Zauważmy, że wystarczyło ustawić zmienną _instance jako zmienną klasową (nie obiektu) i w sposób drastyczny zmieniły się własności takiej klasy.

Jakie wnioski z powyższych przykładów?

  1. Zastosowanie wzorca pozwoliło w szybki sposób zmodyfikować jego działanie bez ingerencji w pozostałe elementy kodu (definicje klas Foo i Foo2 pozostały bez zmian). Jeśli chcielibyśmy zdefiniować czysty wzorzec singletona dla każdego dao przy dużej liczbie takich klas musielibyśmy zastosować metodę Copy-Pastego.
  2. Zastosowanie wydawałoby się gotowego wzorca pobranego z jakiegoś źródła wzorców bez przeanalizowania wszystkich jego cech w sposób drastyczny odbiło się na działaniu systemu. Jedna zmienna projektowa modyfikująca pożądane działanie singletona zmieniła jego działanie– chociaż oficjalnie pozostał on singletonem.
  3. Singletonowość obiektu nie dziedziczy się, singletonowość klasy- owszem (czego należałoby oczekiwać)

Na sam koniec dywagacji o wzorcach i ich różnej implementacji jeszcze jeden ciekawy przykład singletona zaproponowany przez samego Guido van Rossuma– twórcy języka python:

Borg- inne podejście do singletona

class Borg(type): 

_instance = None

_shared_state = {}

 

def __init__(cls, name, bases, namespace):

super(Borg, cls).__init__(name, bases, namespace)

 

def __call__(cls, *args, **kw):

cls._instance = super(Borg, cls).__call__(*args, **kw)

cls._instance.__dict__ = cls._shared_state

return cls._instance

 

class FooBorg:

__metaclass__ = Borg

 

def __init__(self):

self.x = 2

Wynik działania tej wersji singletona:

>>> a = FooBorg() 

>>> b = FooBorg()

>>> print a

<__main__.Foo3 object at 0xb764288c>

>>> print b

<__main__.Foo3 object at 0xb76428ec>

>>> setattr(a, 'xx', 1234)

>>> print a.xx

1234

>>> print b.xx

1234

Puryści językowi zakrzykną, że nie jest to singleton– i będą mieli rację. Dlatego został nazwany Borgiem. Jednakże zapewnia pewne cechy dostarczane przez singleton– z punktu widzenia logiki wygląda wszystko tak, jakbyśmy pracowali na jednym obiekcie. Czyli singleton. W praktyce pracujemy na „obiektach–interfejsach” operujących na wspólnej przestrzeni nazw. Od razu uwaga dotycząca ograniczenia stosowalności– nie polecam stosować przy obiektach opisanej powyżej klasy DAO– tak jak w przypadku zastosowania destruktora na klasycznym singletonie można oprogramować tak, żeby zamykał połączenie ze źródłem danych, tak w tym wypadku może się okazać, że zabiliśmy obiekt pozostawiając otwarte połączenie, co prędzej czy później poskutkuje telefonem od administratora źródła danych ze słusznymi skąd inąd pretensjami.

Podsumowując– czy warto stosować wzorce projektowe? Warto. Pomagają one w życiu nie tylko projektantom i architektom systemowym, ale również samym programistom. Pozwalają ograniczać liczbę linii kodu, przyspieszają pracę nad projektem ograniczając liczbę błędów dzięki stosowaniu już opracowanych i przetestowanych rozwiązań. Jednak należy uważać żeby nie przedobrzyć, gdyż wystarczy chwila nieuwagi a potężne narzędzie mające doprowadzić nasz kod do perfekcji obróci się przeciw nam.

 

Tomasz Różański

Programista