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…?
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
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!