Wzorzec projektowy Interpreter - okładka

Wzorzec projektowy Interpreter

Opublikowano Kategorie Czysty kodCzas czytania 8min

Wzorzec projektowy Interpreter to jeden z behawioralnych wzorców projektowych opisanych przez Gang of Four. W tym artykule poznasz specyfikę tego wzorca oraz jego przykłady zastosowań. Przykłady kodu w artykule przygotowane zostały w TypeScripcie.

Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce projektowe, to koniecznie sprawdź mój wpis o wzorcach projektowych.

Interpreter w teorii

Gang of Four definiuje cel wzorca następująco:

Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language.

Interpreter jest wzorcem o dość ścisłym przeznaczeniu. Wykorzystuje się go w aplikacjach przyjmujących dane wejściowe o zdefiniowanej strukturze. Mogą to być proste wyrażenia np. komendy, ale też może to być aplikacja definiująca specyficzny dla niej język (Domain-Specific Language, DSL). Interpreter odpowiada za przełożenie intencji z przekazanej wiadomości na strukturę obiektów w kodzie. Obiekty są komponowane w taki sposób, by ich wykorzystanie odpowiadało intencji użytkownika.

By wykorzystać Interpreter w praktyce, trzeba podjąć następujące kroki:

  1. Zdefiniować interpretowaną składnię/język. Może to być np. zdefiniowanie listy poleceń, formuł czy składni DSL. Składnia bezpośrednio przekłada się na zestaw klas.
  2. Zdefiniować kod przetwarzający język na obiekty i powiązania między nimi w kodzie. To nie jest częścią Interpretera, jednak jest to konieczne, by móc go wykorzystać. Przeparsowanie wyrażenia w zdefiniowanym języku powoduje powstanie zestawu obiektów tworzących AST (Abstract Syntax Tree). Powstałe AST jest jednocześnie Kompozytem.
  3. Zinterpretować otrzymany rezultat. Klasy powinny implementować wspólny interfejs z metodą, która oczekuje określonych danych wejściowych i zwracającą określoną wartość. Może to być np. metoda interpret(), calculate() czy jakakolwiek inna pasująca do kontekstu aplikacji.

Wady i zalety

Podejście wykorzystane w Interpreterze pozwala na czytelne zdefiniowanie reguł języka. Oparcie nazw klas o elementy języka pozwala czytelniej przełożyć go na kod niż bezpośrednie parsowanie inputu. Każda reguła języka opisana jest przez co najmniej jedną klasę.

Modularne podejście pozwala łatwo dodawać nowe reguły. Dodanie nowej reguły, wymaga jedynie stworzenia nowej klasy zgodnej z wcześniej zdefiniowanym interfejsem. Modularność sprawia też, że każda klasa może być testowana oddzielnie.

Mając omówione plusy czas przyjrzeć się minusom. W przypadku interpretacji rozbudowanego i złożonego języka zarządzanie klasami może stać się problematyczne. Każda kolejna reguła języka powoduje konieczność stworzenia nowej klasy lub klas. Wraz z rozwojem języka ich liczba będzie rosnąć. Problem z ich utrzymaniem może wtedy wynikać nawet nie tyle z ich złożoności, ile z ich liczby.

Problemem może okazać się również niska wydajność aplikacji wynikająca np. z liczby powstałych obiektów czy złożoności reguł interpretowanego języka.

Prostszym i efektywniejszym rozwiązaniem może być naiwny parser szukający wzorców w tekście i przetwarzający go według zdefiniowanych reguł. Nie ma wtedy potrzeby tworzenia boilerplate związanego z implementacją Interpretera, klas i interfejsów. „Zategowanie” kodu ifami i elsami może być wystarczające, a przy okazji wpisze się w KISS i YAGNI.

Na sam koniec zostawiłem problem samego parsowania języka naturalnego i budowania AST. By móc zaimplementować w praktyce ten wzorzec konieczne jest przygotowanie mechanizmu parsującego tekst w zdefiniowanym języku na AST. Problem leży w tym, że w przypadku prostych struktur zwykłe parsowanie tekstu będzie prostsze i szybsze. Z kolei w przypadku skomplikowanych reguł językowych, czas potrzebny na przygotowanie kodu budującego AST może być niewspółmierny do korzyści. Doskonale podsumowuje to Jefferey Kegler w swoim artykule:

[…] creating a parser for anything but the simplest languages has been a time-consuming effort, and one of a kind known for disappointing results. In fact, language development efforts run a real risk of total failure.

How did the Go4 deal with this? They defined the problem away. They stated that the parsing issue was separate from the Interpreter Pattern, which was limited to what you did with the AST once you’d somehow come up with one.

But AST’s don’t (so to speak) grow on trees. You have to get one from somewhere. In their example, the Go4 simply built an AST in their code, node by node. In doing this, they bypassed the BNF and the problem of parsing. But they also bypassed their language and the whole point of the Interpreter Pattern.

[…] There was no easy, general, and practical way to generate AST’s.

(Teoretyczne) przykłady wykorzystania

Definicja wzorca określa przypadki, gdzie może on znaleźć zastosowanie. Są to aplikacje definiujące DSL lub pozwalające na używanie zdefiniowanej składni np. w postaci poleceń czy formuł. W teorii nie jest trudno znaleźć przypadki, gdzie potencjalnie można by wykorzystać Interpreter:

W praktyce problemy, które opisałem wcześniej, limitują jego użycie, a często wręcz dyskwalifikują ten wzorzec z użycia. Sam osobiście nie spotkałem się z implementacją tego wzorca w praktyce poza typowo szkolnymi i edukacyjnymi przykładami.

Przykład w kodzie

Do pokazania Interpretera w praktyce przygotowałem implementację prostego kalkulatora w kodzie. Kalkulator przyjmuje input od użytkownika w postaci tekstu i zwraca wynik w postaci liczby. Dodatkowo przygotowałem prosty parser wyciągający tokeny z przekazanego tekstu. Przykład jest specjalnie uproszczony, by skupić się na implementacji wzorca, a nie na szczegółach implementacyjnych kalkulatora.


interface IExpression {
  interpret(): number;
}

class NumberExpression implements IExpression {
    constructor( private readonly _value: number ) {}

    interpret(): number {
        return this._value;
    }
}

class AddExpression implements IExpression {
    constructor(
      private readonly _left: IExpression,
      private readonly _right: IExpression
    ) {}

    interpret(): number {
      return this._left.interpret() + this._right.interpret();
    }
}

class SubtractExpression implements IExpression {
    constructor(
      private readonly _left: IExpression,
      private readonly _right: IExpression
    ) {}

    interpret(): number {
      return this._left.interpret() - this._right.interpret();
    }
}

function parseExpression( expression: string ): IExpression {
  const tokens = expression.split( ' ' );
  let current: IExpression = new NumberExpression( parseInt( tokens[ 0 ] ) );

  for ( let i = 1; i < tokens.length; i += 2 ) {
    const operator = tokens[ i ];
    const next = new NumberExpression( parseInt( tokens[ i + 1 ] ) );

    if ( operator === '+' ) {
      current = new AddExpression( current, next );

      continue;
    }
        
    if ( operator === '-' ) {
      current = new SubtractExpression( current, next );
    }
  }

  return current;
}

const expression = '10 + 2 - 8';
const parsedExpression = parseExpression( expression );
const result = parsedExpression.interpret();

console.log( `${ expression } = ${ result }` ); // 10 + 2 - 8 = 4

Klasy NumberExpression, AddExpression oraz SubtractExpression definiują elementy „języka” i poszczególne operacje matematyczne. Wszystkie implementują wspólny interfejs IExpression. Wejściowy string dzielony jest przez parser na tokeny, które przekładane na odpowiednie klasy.

Podany przykład jest typowo edukacyjny. Uwzględnia tylko operacje dodawania i odejmowania. Gdyby rozszerzyć go o kolejne operacje, np. implementujące kolejność wykonywania działań, mnożenie, dzielenie i zamykanie fragmentów wyrażenia w nawiasach powstałoby kilka kolejnych klas. Chcąc zaimplementować podstawowy kalkulator, konieczne by było stworzenie kolejnych kilkunastu/kilkudziesięciu klas.

Sam parser jest również bardzo naiwny. Wspiera jedynie kilka operacji i jest wrażliwy na błędy. Przykładowo obecnie tokeny oddzielane są spacją. Wyrażenia 10+2-8 oraz 10 + 2 - 8 powinny być interpretowane jednakowo.

Po więcej przykładów implementacji Interpretera odsyłam do artykułu na Dot Net Tutorials, gdzie autorzy pokazali m.in. przykład z implementacją rzymskiego systemu zapisu liczb czy przykład konwersji składni podobnej do Markdown na HTML.

Podsumowanie

Patrząc na specyfikę wzorca Interpreter i problemy z nim związane, nie dziwię się, że nie miałem jeszcze okazji wykorzystać go w praktyce. Jestem ogromnie ciekaw czy Tobie się to udało. Jeśli tak, to daj znać, jak w Twoim przypadku sprawdził się ten wzorzec i w jakim typie aplikacji go wykorzystałeś(aś). Zachęcam też do sprawdzenia źródeł i materiałów dodatkowych.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Odbierz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer i realnie zwiększ swoje szanse na rozmowie rekrutacyjnej! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Notify of
guest

2 komentarzy
Most Voted
Newest Oldest
Inline Feedbacks
View all comments
Marcin
2 days ago

Wprawdzie w opisie paczki nie ma nic o wzorcu Interpreter, ale samo zachowanie i możliwość rozbudowy paczka ze światka PHP (Symfony) https://symfony.com/doc/current/components/expression_language.html#usage wpasuje się w ten mechanizm.

Pozdrawiam, Marcin!