Dziedzina DSLExecutora: implementacja

Ok, skoro przedstawiłem już podstawowe zasady dotyczące struktury opisu wykonywanych operacji, mogę zacząć definiować interfejsy i klasy należące do dziedziny DSLExecutora. W poprzednich postach używałem właściwie tylko dwóch pojęć, które mogą trafić do tej dziedziny: wyrażenie i funkcja. Wygląda na to, że to wszystko, czego będę potrzebował do implementacji podstawowego przetwarzania. Zatem:

Wyrażenia

Jak wynika z dotychczasowych ustaleń, wyrażenie jest obiektową reprezentacją tego, czego reprezentacją tekstową jest DSL – czyli operacji do wykonania. (Konwersja reprezentacji tekstowej na obiektową będzie odpowiedzialnością parsera, ale o tym kiedy indziej.) Jedyną cechą wspólną dla wszystkich rodzajów wyrażeń jest fakt posiadania typu wartości zwracanej. Co za tym idzie, interfejs wspólny dla wszystkich wyrażeń wygląda tak:

Plis nołt: dla skrócenia zapisu usuwam z kodu modyfikatory dostępu. Przyjmij, Czytelniku, że domyślnym modyfikatorem dostępu jest public.

interface IExpression
{
  Type ResultType { get; }
}

abstract class Expression<TResult> : IExpression
{
  Type ResultType => typeof(TResult);
}

Właściwość ResultType to właśnie typ wartości zwracanej. Klasa Expression implementuje IExpression, i to po niej dziedziczyć będą konkretne rodzaje wyrażeń. A jeśli już przy nich jesteśmy, to w przykładach podanych w poprzednim poście pojawiają się:

  • wyrażenie reprezentujące stałą wartość,
  • wyrażenie reprezentujące wywołanie funkcji,
  • wyrażenie reprezentujące zbiór wyrażeń.

Mimo że taki zestaw wydaje się być ubogi (brakuje tu np. deklaracji zmiennych), myślę, że można z nim osiągnąć całkiem sporo. Dlatego dziedzina wyrażeń na chwilę obecną zawiera tylko trzy klasy:

ConstantExpression
class ConstantExpression<TValue> : Expression<TValue>
{
  TValue Value { get; set; }
}

Właściwość Value przechowuje wartość wyrażenia.

FunctionExpression
class FunctionExpression<TFunction, TResult>
  : Expression<TResult>
  where TFunction : IFunction<TResult>
{
  Type FunctionType => typeof(TFunction);
  IDictionary<string, IExpression> ArgumentExpressions { get; set; }
}

Właściwość FunctionType reprezentuje wywoływaną funkcję, a ArgumentExpressions – argumenty tej funkcji (w postaci wyrażeń). Kluczem w słowniku ArgumentExpressions jest nazwa parametru, któremu odpowiada argument. (Interfejs IFunction omówię za chwilę.)

BatchExpression
class BatchExpression<TResult> : Expression<TResult>
{
  IEnumerable<IExpression> SideExpressions { get; set; }
  Expression<TResult> ResultExpression { get; set; }
}

Zgodnie z założeniem, zbiór wyrażeń zawiera jedno wyrażenie “zwracające” – jest ono reprezentowane przez właściwość ResultExpression. Pozostałe wyrażenia są jedynie poboczne – stąd SideExpressions.

Jak widać, wszystkie klasy wyrażeń są generyczne. Zapewni to automatyczną kontrolę typów wartości zwracanych i, w przypadku FunctionExpression, wymusi zgodność typu wartości zwracanej wyrażenia z typem wartości zwracanej funkcji.

Funkcje

W przypadku funkcji sprawa wygląda inaczej – mają one być definiowane nie przeze mnie, a przez użytkownika DSLExecutora. Ja mogę jedynie narzucić sposób definiowania sygnatury i ciała (czyli procedury wykonania) funkcji. Chcę aby sposób ten spełniał dwa wymagania:

  • Tworzenie funkcji ma być jak najwygodniejsze dla użytkownika.
  • Definicja sygnatury funkcji ma być niezależna od definicji procedury jej wykonania. Ma to umożliwić umieszczanie sygnatur i procedur w osobnych dllkach, np. w celu dostarczenia różnych implementacji dla tego samego zestawu funkcji.

Okazuje się, że oba wymagania zadowalająco spełnia potraktowanie sygnatury funkcji jako obiektu:

  • Instancja takiego obiektu przechowuje wartości argumentów, z którymi funkcja została wywołana.
  • Procedura wykonania funkcji to po prostu metoda, która przyjmuje ten obiekt jako parametr i zwraca wartość odpowiedniego typu.

Konsekwencją wyboru takiego rozwiązania jest wprowadzenie dwóch interfejsów:

IFunction
interface IFunction<TResult>
{ }

IFunction to interfejs reprezentujący sygnaturę funkcji. TResult to typ jej wartości zwracanej. Parametry funkcji  będą reprezentowane przez właściwości klasy implementującej IFunction – załatwia to jednocześnie deklarację typów i nazw parametrów, jak również przygotowuje miejsce dla wartości argumentów (staną się one wartościami odpowiadających im właściwości).

Przykładowo, sygnatura funkcji realizującej dodawanie dwóch liczb może wyglądać tak:

class AddFunction : IFunction<int>
{
  int A { get; set; }
  int B { get; set; }
}
IFunctionHandler
interface IFunctionHandler<TFunction, TResult>
  where TFunction : IFunction<TResult>
{
  TResult Handle(TFunction function);
}

IFunctionHandler reprezentuje klasę definiującą procedurę wykonania funkcji TFunction. Metoda Handle to właśnie ta procedura.

Przykładowo, definicja procedury wykonania pokazanej wyżej funkcji AddFunction może wyglądać tak:

class AddFunctionHandler
  : IFunctionHandler<AddFunction, int>
{
  int Handle(AddFunction function)
  {
    return function.A + function.B;
  }
}

To wszystko jeśli chodzi o implementację klas reprezentujących wyrażenia i funkcje. Całość kodu źródłowego dostępna jest tutaj.

Podobnie jak w przypadku podejścia omówionego ostatnio, dzisiejsze rozwiązania też nie są pionierskie i rewolucyjne – podobny model definicji i obsługi operacji (obiekt reprezentujący operację + metoda wykonująca tę operację) występuje chociażby w narzędziach związanych z komunikacją opartą na wiadomościach, jak NServiceBus czy EasyNetQ.  Czy mnie to martwi? Nie – użytkownicy DSLExecutora nie będą musieli oswajać się z nowymi koncepcjami. Poza tym pokazuje to, jak bardzo zbliżone koncepcyjnie jest wywoływanie metod obiektów do wymiany wiadomości pomiędzy procesami (np. mikroserwisami) systemu. (To podobieństwo jest dobitnie widoczne w językach takich jak Smalltalk, Objective-C czy Erlang.)

Tak czy inaczej, skoro mamy już dziedzinę DSLExecutora, możemy nareszcie rzucić się w wir implementacji wykonywania wyrażeń, prawda? Ale czy to na pewno wszystko? O niczym nie zapomnieliśmy…?

2 thoughts on “Dziedzina DSLExecutora: implementacja”

  1. Podziwiam w jaki sposób udało Ci się na ‘sucho’ zdefiniować domenę w projekcie. Ciekawe jak ujętę przez Twoją domenę abstrakcje sprawdzą się w boju. Czekam niecierpliwie na implementację i testy :)

    1. Cóż, od czegoś trzeba zacząć, a domena jest najniższą warstwą :). Chociaż definiowałem ją nie do końca na sucho – powstawała równolegle z implementacją i testami, ale łatwiej mi opisywać warstwa po warstwie. W każdym razie, opis implementacji pojawi się na blogu już niedługo!

Comments are closed.