Wiosenna WYPRZEDAŻ

Most

Znany też jako: Bridge

Cel

Most jest strukturalnym wzorcem projektowym pozwalającym na rozdzielenie dużej klasy lub zestawu spokrewnionych klas na dwie hierarchie — abstrakcję oraz implementację. Nad obiema można wówczas pracować niezależnie.

Wzorzec projektowy Most

Problem

Abstrakcja? Implementacja? — brzmi strasznie? Spokojnie, zacznijmy od prostego przykładu.

Załóżmy, że mamy klasę Figura wraz z dwiema podklasami: Okrąg i Kwadrat. Chcesz rozszerzyć tę hierarchię klasową, aby zawierała kolory, więc zamierzasz stworzyć podklasy dla figur Czerwonych i Niebieskich. Jednak ponieważ już są dwie podklasy, konieczne byłoby stworzenie czterech kombinacji, między innymi NiebieskiOkrąg i CzerwonyKwadrat.

Problem dla wzorca Most

Ilość kombinacji klas wzrasta w postępie geometrycznym.

Dodawanie do hierarchii nowych typów figur oraz kolorów spowoduje jej wykładniczy wzrost. Przykładowo, aby dodać figurę trójkąt, musisz dodać dwie podklasy — po jednej na każdy kolor. Dodanie nowego koloru wymagałoby stworzenia trzech podklas — po jednej na każdą figurę. Im dalej, tym gorzej.

Rozwiązanie

Problem ten powstaje, ponieważ próbujemy poszerzyć klasy figur w dwóch niezależnych wymiarach: kształt oraz kolor. Jest to bardzo częsty problem przy dziedziczeniu klas.

Wzorzec Most próbuje rozwiązać ten problem poprzez przestawienie się z dziedziczenia na kompozycję obiektów. Oznacza to, że ekstrahujemy jeden z tych wymiarów i tworzymy osobną hierarchię klas, przez co pierwotne klasy będą posiadały odniesienie do obiektów z nowej hierarchii, zamiast przechowywać wszystkie swoje stany i zachowanie wewnątrz klasy.

Rozwiązanie proponowane przez wzorzec Most

Możesz zapobiec eksplozji hierarchii klas do wielkich rozmiarów poprzez przekształcenie jej na kilka powiązanych hierarchii.

Stosując to podejście, możemy zebrać kod dotyczący kolorów i umieścić go w swojej własnej klasie z dwiema podklasami: Czerwony i Niebieski. Klasa Figura następnie zyskuje pole przechowujące odniesienie do jednego z obiektów-kolorów. W efekcie figura geometryczna może oddelegować wszelkie działania związane z kolorami do odpowiedniego obiektu-koloru. Odniesienie to pełni rolę mostu pomiędzy klasami Figura a Kolor. Od teraz, dodawanie nowych kolorów nie będzie wymagało zmiany hierarchii figur — i odwrotnie.

Abstrakcja i implementacja

Książka GoF  wprowadza terminy Abstrakcji i Implementacji jako elementy definicji mostu. Moim zdaniem, pojęcia te brzmią zbyt fachowo i tworzą wrażenie, że wzorzec ten jest przesadnie skomplikowany. Przeczytawszy nasz prosty przykład o figurach i kolorach, spróbujmy rozszyfrować znaczenie niepokojącej terminologii.

Abstrakcja (zwana też interfejsem) stanowi wysokopoziomową warstwę umożliwiającą kontrolę nad czymś. Nie ma ona wykonywać konkretnych prac, ale delegować zadania do warstwy implementacyjnej (zwanej też platformą).

Zwróć uwagę, że nie mówimy tu o znanych ci z języków programowania interfejsach i klasach abstrakcyjnych — chodzi o coś innego.

Mówiąc o prawdziwych aplikacjach, za abstrakcję można uznać graficzny interfejs użytkownika (GUI), zaś implementację stanowi znajdujący się poniżej interfejs programowania aplikacji (API). Użytkownik za pomocą interfejsu użytkownika wydaje polecenia niższej warstwie.

Ogólnie mówiąc, taką aplikację można rozwijać w dwóch niezależnych kierunkach:

  • Posiadać wiele różnych interfejsów użytkownika (wersje dla zwykłych użytkowników oraz dla administratora)
  • Współpracować z wieloma różnymi API (możliwość uruchomienia na systemie Windows, Linux oraz macOS).

W najgorszym przypadku aplikacja będzie przypominała spaghetti, gdzie setki instrukcji warunkowych łączą różne interfejsy użytkownika z różnymi interfejsami programowania aplikacji.

Zarządzanie zmianami jest znacznie prostsze w kodzie modularnym

Dokonywanie nawet najmniejszych zmian w kodzie monolitycznym jest trudne, ponieważ konieczne jest dobre rozumienie całości. Wprowadzanie zmian w małych, dobrze określonych modułach jest znacznie prostsze.

Można zaprowadzić trochę ładu wśród tego chaosu ekstrahując kod związany z kombinacjami interfejs-platforma i umieszczając go w osobnych klasach. Wkrótce jednak odkryjesz, że takich klas powstanie mnóstwo. Hierarchia klasowa rozrośnie się wykładniczo, ponieważ dodanie obsługi nowego GUI lub wsparcia dla nowego API będzie wymagać tworzenia wciąż to nowych klas.

Spróbujmy rozwiązać problem stosując wzorzec Most. Według jego założeń, dzielimy klasy na dwie hierarchie:

  • Abstrakcja: warstwa graficznego interfejsu użytkownika aplikacji.
  • Implementacja: interfejs programowania aplikacji systemu operacyjnego.
Architektura wieloplatformowa

Jeden ze sposobów ustrukturyzowania aplikacji wieloplatformowej.

Obiekt abstrakcyjny steruje wyglądem aplikacji, delegując faktyczne zadania do powiązanego z nim obiektu implementacyjnego. Różne implementacje są wymienne, o ile zachowują zgodność ze wspólnym interfejsem, umożliwiając w ten sposób stworzenie jednolitego graficznego interfejsu użytkownika i pod Windows i pod Linux.

W rezultacie, możemy zmieniać klasy GUI bez konieczności modyfikacji klas odnoszących się do API. Co więcej, dodanie obsługi kolejnego systemu operacyjnego wymaga jedynie utworzenia podklasy w hierarchii implementacyjnej.

Struktura

Wzorzec projektowy MostWzorzec projektowy Most
  1. Abstrakcja obejmuje logikę kontrolną wysokiego poziomu. Potrzebuje obiektu implementacyjnego by wykonywać faktyczne działania.

  2. Implementacja deklaruje interfejs, który jest wspólny dla wszystkich konkretnych implementacji. Abstrakcja może komunikować się z obiektem implementacyjnym wyłącznie za pomocą zadeklarowanych tu metod.

    Abstrakcja może zawierać listę tych samych metod, co implementacja, ale zazwyczaj deklaruje bardziej złożone działania, na które składa się wiele prostszych działań deklarowanych przez implementację.

  3. Konkretne implementacje zawierają kod specyficzny dla danej platformy.

  4. Wzbogacona Abstrakcja udostępnia warianty logiki kontrolnej. Tak jak ich klasa-rodzic, współdziałają z różnymi implementacjami poprzez ogólny interfejs implementacyjny.

  5. Zazwyczaj, Klienta interesuje tylko współpraca z abstrakcją. Ale to zadaniem klienta jest połączenie obiektu abstrakcji z jednym z obiektów implementacji.

Pseudokod

Poniższy przykład ilustruje jak wzorzec Most może pomóc podzielić monolityczny kod aplikacji zarządzającej urządzeniami i pilotami do nich. Klasy Urządzenie stanowią implementację, natomiast Piloty — abstrakcję.

Struktura przykładu wzorca Most

Pierwotna hierarchia klas podzielona na dwie części: urządzenia i piloty zdalnego sterowania.

Bazowa klasa pilota deklaruje pole z odniesieniem do obiektu urządzenia. Wszystkie piloty sterują urządzeniami za pośrednictwem uogólnionego interfejsu, który pozwala jednemu pilotowi obsługiwać wiele typów urządzeń.

Można pracować nad klasą pilota zdalnego sterowania niezależnie od klas urządzeń. Potrzeba jedynie nowej podklasy pilota. Na przykład, podstawowy pilot miałby tylko dwa przyciski, ale można by go było wzbogacić o dodatkowe funkcje, jak dodatkowa bateria, lub ekran dotykowy.

Klient dobiera pożądany typ pilota z konkretnym obiektem urządzenia za pośrednictwem konstruktora pilota.

// "Abstrakcja" definiuje interfejs dla części "kontrolującej"
// obu hierarchii klas. Posiada odniesienie do obiektu z
// hierarchii "implementacyjnej" i deleguje temu obiektowi
// faktyczną pracę.
class RemoteControl is
    protected field device: Device
    constructor RemoteControl(device: Device) is
        this.device = device
    method togglePower() is
        if (device.isEnabled()) then
            device.disable()
        else
            device.enable()
    method volumeDown() is
        device.setVolume(device.getVolume() - 10)
    method volumeUp() is
        device.setVolume(device.getVolume() + 10)
    method channelDown() is
        device.setChannel(device.getChannel() - 1)
    method channelUp() is
        device.setChannel(device.getChannel() + 1)


// Można rozszerzać klasy należące do hierarchii abstrakcyjnej
// niezależnie od klas urządzeń.
class AdvancedRemoteControl extends RemoteControl is
    method mute() is
        device.setVolume(0)


// Interfejs "implementacji" deklaruje metody wspólne dla
// wszystkich konkretnych klas implementacji. Nie musi zgadzać
// się z interfejsem abstrakcji. Co więcej, oba interfejsy mogą
// być zupełnie różne. Zazwyczaj interfejs implementacji posiada
// tylko proste działania, podczas gdy abstrakcja definiuje
// działania wysokopoziomowe oparte na tych podstawowych.
interface Device is
    method isEnabled()
    method enable()
    method disable()
    method getVolume()
    method setVolume(percent)
    method getChannel()
    method setChannel(channel)


// Wszystkie urządzenia są zgodne pod względem interfejsu.
class Tv implements Device is
    // ...

class Radio implements Device is
    // ...


// Gdzieś w kodzie klienckim.
tv = new Tv()
remote = new RemoteControl(tv)
remote.togglePower()

radio = new Radio()
remote = new AdvancedRemoteControl(radio)

Zastosowanie

Stosuj wzorzec Most gdy chcesz rozdzielić i przeorganizować monolityczną klasę posiadającą wiele wariantów takiej samej funkcjonalności (na przykład, jeśli klasa ma współpracować z wieloma serwerami bazodanowymi).

 Im większa staje się klasa, tym ciężej zrozumieć jej działanie, a dodawanie kolejnych zmian staje się coraz bardziej czasochłonne. Zmiana jednego wariantu funkcjonalności może wymagać dokonania zmian na przestrzeni całej klasy, a to z kolei wiąże się z wprowadzaniem błędów lub przegapieniem jakichś krytycznych efektów ubocznych.

Wzorzec Most pozwala rozdzielić monolityczną klasę w wiele hierarchii klas. Możemy wówczas zmieniać klasy w jednej z hierarchii niezależnie od klas w drugiej. Podejście to upraszcza utrzymanie kodu i pozwala zminimalizować ryzyko zepsucia istniejącego kodu.

Użyj tego wzorca gdy chcesz rozszerzyć klasę na kilku niezależnych płaszczyznach.

Most proponuje ekstrakcję osobnej hierarchii klas dla każdej z takich płaszczyzn rozbudowy. Pierwotna klasa deleguje pracę obiektom należącym do tych hierarchii zamiast wykonywać ją sama.

Most pozwala spełnić wymóg możliwości wyboru implementacji w trakcie działania programu.

Chociaż jest to opcjonalne, Most umożliwia zamianę obiektu implementacji znajdującego się w abstrakcji. Jest to tak proste, jak przypisanie polu klasy nowej wartości.

Tak przy okazji, ostatnia cecha Mostu jest głównym powodem, dla którego wiele ludzi myli Most ze wzorcem Strategia. Pamiętaj, że wzorzec to więcej niż pewien sposób strukturyzowania klas. Może bowiem też sugerować pewien zamiar i wskazać rozwiązanie jakiegoś problemu.

Jak zaimplementować

  1. Określ płaszczyzny swoich klas. Takie niezależne koncepcje to na przykład: abstrakcja/platforma, domena/infrastruktura, front-end/back-end, interfejs/implementacja.

  2. Określ jakie operacje są klientowi potrzebne i zdefiniuj je w bazowej klasie abstrakcji.

  3. Określ zakres operacji dostępnych na wszystkich platformach. Zadeklaruj te, których abstrakcja potrzebuje w ogólnym interfejsie implementacji.

  4. Stwórz konkretne klasy implementacji dla wszystkich obsługiwanych platform. Upewnij się jednak, aby wszystkie były zgodne z interfejsem implementacji.

  5. Wewnątrz klasy abstrakcji, dodaj pole odnoszące się do typu implementacji. Abstrakcja będzie delegować większość zadań temu obiektowi implementacji.

  6. Jeśli masz wiele wariantów wysokopoziomowej logiki, stwórz wzbogacone abstrakcje dla każdego z wariantów poprzez rozszerzenie bazowej klasy abstrakcji.

  7. Kod kliencki powinien przekazywać obiekt implementacji konstruktorowi abstrakcji, aby skojarzyć obie hierarchie ze sobą. Po tym, klient może zapomnieć o implementacji i korzystać tylko z obiektu abstrakcji.

Zalety i wady

  • Możesz tworzyć niezależne od platformy klasy i aplikacje.
  • Kod klienta działa na wyższym poziomie abstrakcji. Nie musi mieć do czynienia ze szczegółami platformy.
  • Zasada otwarte/zamknięte. Możesz wprowadzać nowe abstrakcje i implementacje niezależnie od siebie.
  • Zasada pojedynczej odpowiedzialności. W abstrakcji możesz skupić się na wysokopoziomowej logice, zaś w implementacji na szczegółach platformy.
  • Kod może stać się bardziej skomplikowany gdy zastosuje się ten wzorzec w przypadku wysoce zwartej klasy.

Powiązania z innymi wzorcami

  • Most zazwyczaj wykorzystuje się od początku projektu, by pozwolić na niezależną pracę nad poszczególnymi częściami aplikacji. Z drugiej strony, Adapter jest rozwiązaniem stosowanym w istniejącej aplikacji w celu umożliwienia współpracy pomiędzy niekompatybilnymi klasami.

  • Most, Stan, Strategia (i w pewnym stopniu Adapter) mają podobną strukturę. Wszystkie oparte są na kompozycji, co oznacza delegowanie zadań innym obiektom. Jednak każdy z tych wzorców rozwiązuje inne problemy. Wzorzec nie jest bowiem tylko receptą na ustrukturyzowanie kodu w pewien sposób, lecz także informacją dla innych deweloperów o charakterze rozwiązywanego problemu.

  • Fabryka abstrakcyjna może być stosowana wraz z Mostem. Takie sparowanie jest użyteczne gdy niektóre abstrakcje zdefiniowane przez Most mogą współdziałać wyłącznie z określonymi implementacjami. W tym przypadku, Fabryka abstrakcyjna może hermetyzować te relacje i ukryć zawiłości przed kodem klienckim.

  • Możliwe jest połączenie wzorców Budowniczy i Most: klasa kierownik pełni rolę abstrakcji, zaś poszczególni budowniczy stanowią implementacje.

Przykłady kodu

Most w języku C# Most w języku C++ Most w języku Go Most w języku Java Most w języku PHP Most w języku Python Most w języku Ruby Most w języku Rust Most w języku Swift Most w języku TypeScript