Jak stać się lepszym programistą embedded? Przyjdź na webinar i dowiedz się sam!

Wzorce projektowe przydatne w systemach embedded

Wzorce projektowe są bardzo popularnym tematem wśród programistów. Zwykle rozmawia się o nich w kontekście języków obiektowych i dużych systemów. Jednak podobnie jak z innymi zagadnieniami dotyczącymi architektury – część wzorców da się z powodzeniem przenieść na grunt systemów embedded. W dzisiejszym wpisie opowiem o trzech wzorcach z najpopularniejszego katalogu wzorców – książki „Gang of four” – które mogą zrobić najwięcej dobrego w architekturze systemów embedded.

Adapter

Zadaniem Adaptera jest dostosowanie istniejącego komponentu do potrzeb naszej aplikacji. Takim komponentem może być na przykład zewnętrzna biblioteka. Posiada ona pewne API, którego nie możemy zmieniać. Możemy wtedy stosować wywołania tej biblioteki bezpośrednio w naszym kodzie. Negatywnym efektem jest wtedy głębokie uzależnienie się od API biblioteki. Jeżeli na przykład zaktualizujemy ją do nowej wersji, albo postanowimy zamienić na inną – będziemy mieć ogromny problem. Będziemy musieli poprawić wszystkie wywołania funkcji API, a nowa biblioteka pewnie się różni i trzeba dodatkowo przystosować do niej logikę aplikacji.

Dlatego w dobrej architekturze zależność idzie w drugą stronę. To nasza aplikacja powinna jasno zdefiniować interfejs jakiego potrzebuje. Biblioteka z kolei musi się do niego dopasować. Jeżeli jej API odbiega od naszego, musimy utworzyć specjalne funkcje wywołujące pod spodem odpowiednie funkcje biblioteki, czyli wrappery. Jeżeli zmieni się API biblioteki, albo zamienimy ją na inną, zakres wymaganych zmian ograniczamy tylko do tych wrapperów. Główna logika naszej aplikacji w ogóle nie odczuje tej zmiany. Adapter między naszą aplikacją a zewnętrzną biblioteką to właśnie zbiór wrapperów.

Zastosowania wzorca Adapter nie musimy ograniczać do odseparowywania gotowych komponentów. Możemy takiej separacji dokonać we wczesnym stadium powstawania architektury umożliwiając w ten sposób niezależne prace nad obydwoma modułami.

Główne zalety wzorca Adapter:

  • Uniezależnienie się od API komponentów zewnętrznych.
  • Możliwość łatwiejszego wprowadzania zmian.
  • Możliwość łatwej wymiany użytych komponentów.

Fasada

Adaptery zwykle służą do tłumaczenia funkcji, czy metod jeden do jednego. Ewentualnie zawierają jakąś prostą logikę. Czasem jednak mogą urosnąć do monstrualnych rozmiarów. Wtedy już nie mówimy o adapterze, tylko o Fasadzie. Fasada to uproszczony interfejs dla całego podsystemu znajdującego się pod spodem ukrywający skomplikowane szczegóły przed użytkownikiem.

Stosując wzorzec Fasady zapobiegamy rozwijaniu się zależności naszej aplikacji od tego podsystemu. Możemy na przykład ukryć w niej wiedzę o kolejnych krokach potrzebnych do wykonania pewnej większej operacji. Dzięki temu możemy zmniejszyć duplikację i szczegółowość naszego kodu. Większość kodu będzie potrzebowała systemu do wykonania pewnych prostych wysokopoziomowych operacji i użycie pojedynczego wywołania funkcji udostępnionej przez Fasadę będzie dużo prostsze niż ciąg wielu czynności na różnych komponentach podsystemu. Jednocześnie jeżeli jakiemuś komponentowi nie wystarcza uproszczony interfejs Fasady, zawsze może dostać się do funkcji podsystemu. Jednak zwykle oznacza to raczej jakiś błąd w designie. Być może brakuje pewnych funkcji interfejsu w naszej Fasadzie.

Główne zalety wzorca Fasada:

  • Ukrycie szczegółów dużego podsystemu.
  • Większa czytelność kodu korzystającego z Fasady.
  • Szybsze powstawanie kodu klienckiego.

Strategia

Często w naszych systemach mamy do wykonania pewien algorytm, który może być zrealizowany na różne sposoby. Możliwe implementacje różnią się złożonością, szybkością wykonania, czy potrzebną pamięcią. Najprostszym rozwiązaniem jest wybór jednej implementacji i zahardcodowanie jej w systemie. Problem oczywiście pojawia się jeśli zmienimy zdanie i chcemy użyć innego algorytmu, albo w ogóle chcemy korzystać z nich wymiennie i decydować w runtime. Z pomocą przychodzi nam wtedy wzorzec Strategii, znany również pod nazwą Polityki (Policy).

Wzorzec Strategii polega na wydzieleniu naszego algorytmu jako osobny moduł z własnym interfejsem wspólnym dla wszystkich implementacji. Jeżeli chcemy zmienić algorytm, wystarczy użyć innej implementacji tego samego interfejsu. Z punktu widzenia kodu aplikacji wywołującego algorytm nie będzie żadnej zmiany. Możemy więc napisać naszą aplikację tak, aby wywoływała algorytm, który na początku działa prosto. W dalszej fazie projektu możemy ulepszać algorytmy przy minimalnym nakładzie pracy związanym z integracją z istniejącym systemem.

Główne zalety wzorca Strategia:

  • Możliwość zmiany algorytmu w dowolnym momencie.
  • Możliwość dodawania nowych algorytmów wraz z rozwojem projektu.
  • Zwiększenie czytelności poprzez wydzielenie algorytmu.

Podsumowanie

Opisane wzorce pozwalają na oddzielenie logiki naszej aplikacji od szczegółów implementacyjnych takich jak algorytmy, biblioteki zewnętrzne, czy nawet całe podsystemy. Dzięki takiemu zabiegowi zyskujemy wielką elastyczność. Możemy te elementy wymieniać w dowolnym momencie trwania projektu przy minimalnym nakładzie pracy na integrację.

Wszystkie te techniki mają jednak jedną wspólną wadę – wprowadzają dodatkową warstwę abstrakcji zwiększającą złożoność systemu i mogącą negatywnie wpłynąć na wydajność. W dużych projektach otrzymana w zamian elastyczność jest dużo cenniejsza. Jednak analizę należy wykonać zawsze pod kątem konkretnej sytuacji.

10 Comments

  1. Warto byłoby wspomnieć gdzie jest chochlik wydajnościowy. Mi przychodzi do głowy tylko vtable.

    • GAndaLF

      11 lutego 2019 at 18:06

      Tak, vtable i ogólnie wywoływanie dodatkowych funkcji może wpływać negatywnie na wydajność. Wydajność może być też mniejsza, bo mając bezpośredni dostęp do docelowych danych i metod możemy np. zmniejszyć ilość potrzebnych operacji. Jednak ma to znaczenie zadziwiająco rzadko, bo aktualnie kompilatory potrafią sobie dobrze radzić z optymalizacją, inline’ami itp. Poza tym czasem nowoczesne kompilatory mogą wygenerować optymalniejszą binarkę z czystszego kodu.

      • Ja naprawdę nie wiem o co chodzi z tym wielkim problemem z funkcjami wirtualnymi… Odczyt adresu vtable z obiektu, odczyt adresu funkcji z tejże vtable, wywołanie przez wskaźnik i tyle. Gdzie tu jest ten wielki problem?

        Od lat piszę firmware na mikrokontrolery (głównie STM32) w C++11 i nowszych i nigdy nie oszczędzałem „virtual”, pewnie takich funkcji w kodzie są setki. Czy run-time polymorphism jest przydatny czy nie (moim zdaniem oczywiście tak i to bardzo) to osobna kwestia, ale skąd to ciągłe narzekanie na rzekomo niską wydajność tego rozwiązania? Wiadomo że raczej nie użyje się funkcji wirtualnych wewnątrz krytycznej czasowo pętli algorytmu obliczeniowego DSP, ale we wszystkich innych przypadkach doszukiwanie się tu problemu jest typowym „premature optimization”.

        A najważniejsze jest to, że funkcja wirtualna nie jest alternatywą dla funkcji niewirtualnej. Wiem że to szokująca teoria, ale tak właśnie jest [; Funkcje wirtualne są alternatywą dla `if (…) funkcja1(); else if (…) funkcja2(); else if (…) funkcja3(); else funkcja4();`, dla konstrukcji switch-case albo dla wywoływania funkcji przez wskaźnik. I wtedy są albo szybsze albo tak samo szybkie jak alternatywy.

        BTW – jeśli kompilator zna typ obiektu (używamy go przez wartość, a nie wskaźnik/referencję), to nie będzie wywołania wirtualnego, tylko zwyczajne.

        C++ to najbardziej zmitologizowany język jaki istnieje (;

        • GAndaLF

          16 lutego 2019 at 00:26

          Ta wzmianka o wydajności była głównie żeby zachować kompletność. Jakbym tego nie napisał to na pewno ktoś by się przyczepił i faktycznie jakieś pojedyncze instrukcje czasem da się zyskać. Jednak realnych korzyści z tego nie ma, a tylko problem z utrzymaniem. Ten argument z alternatywą dla if else bardzo dobry, sam muszę go zacząć używać w tej walce 😀

          Niektórzy strażnicy performance’u nieraz idą jeszcze dalej i nie chcą używać nawet zwykłych funkcji i zamiast tego copy-paste, bo przecież jedną instrukcję call tak oszczędzimy. Te mity chyba w latach 80-tych/90-tych powstały i są przekazywane następnym pokoleniom.

  2. Czy pojęcie „adapter” jest równoważne „warstwa abstrakcji”? Np mamy bibliotekę „hd44780”, ale wiemy, że może się to zmienić kiedyś, więc już z góry robimy sobie ogólną bibliotekę „LCD” i z niej korzysta logika programu, żeby potem móc łatwiej podmienić peryferium?

    • GAndaLF

      11 lutego 2019 at 19:43

      No adapter na pewno jest warstwą abstrakcji. Inna nazwa tego wzorca – wrapper też wiele wyjaśnia w tej kwestii. A jeżeli chodzi o wdrożenie w praktyce to najlepiej myśleć od drugiej strony – mamy aplikację, która potrzebuje wyświetlacza, tworzymy API zawierające odpowiednie funkcje i potem robimy translację na funkcje biblioteki. Masz wtedy funkcje, które każdy wyświetlacz musi i tak implementować np. printChar, clear itp. a implementacja dla danej biblioteki to czasem będzie wywołanie jednej funkcji i przekazanie dalej parametrów, a czasem jakieś większe kombinacje.

      • „Wrapper” to chyba o wiele popularniejsze słówko – czyli to kolejna nazwa na coś co większość używa, ale nie wie, że to ma swoją nazwę.

        Sam nie lubię właśnie bibliotek z konkretną nazwą układu w nazwie bez nadania na to jakiegoś „ogólnego” API – i potem każda biblioteka wygląda inaczej, a tak są „uszyte” pod ten sam interfejs.
        Fajnym przykładem może być biblioteka „u8glib” – chcemy zmienić LCD – podajemy inny w inicjalizacji i wsio (chociaż nie zawsze – chyba jest tam dane trochę „za dużo możliwości” i można sobie zepsuć ten koncept)

        • GAndaLF

          11 lutego 2019 at 23:32

          Wrapper kojarzy się bardziej z konkretnym typem adaptera – króciutką funkcją opakowującą inną funkcję. Natomiast adapter jako wzorzec jest bardziej ogólny i oryginalnie oznacza obiekt opakowujący całe zachowanie, który może się składać z wielu takich funkcji. Tak samo logika wewnątrz może być trochę bardziej rozbudowana.

          • Fajnie byłoby jakbyś szczególnie przykłady tych architektur poparł jakimś szablonem w C który obrazowałby konkretny przykład. Dobrze widzieć teorię, a później kawałek kodu 😉 Taka propozycja ode mnie.

  3. Radomir Nowak

    28 lipca 2020 at 23:50

    Dziękuję za artykuł! Jest nowy polski katalog wzorców projektowych: https://refactoring.guru/pl/design-patterns

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *