Wykonywanie wyrażeń: funkcje (cz. 2)

Uff, po przerwie przeznaczonej na święta, załatwianie zaległych spraw i witanie wiosny, czas wrócić do blogowania. Przerwa wypadła w mało fortunnym momencie, bo w połowie opisu wykonywania wyrażeń reprezentujących funkcje. W związku z tym, dla lepszego zrozumienia dzisiejszego posta, polecam zajrzenie do jego pierwszej części. Po tej lekturze zapraszam dalej.

Jak pisałem poprzednio, sygnatura metody wykonującej funkcję wygląda tak:

 
TResult ExecuteFunction<TFunction, TResult>
  (IDictionary<string, IExpression> argumentExpressions)
  where TFunction : IFunction<TResult>

A jej ciało, poglądowo, tak:

  1. Utworzenie obiektu funkcji.
  2. Wypełnienie właściwości tego obiektu wartościami uzyskanymi poprzez wykonanie wyrażeń będących argumentami funkcji.
  3. Uzyskanie instancji FunctionHandlera odpowiadającego funkcji.
  4. Przekazanie obiektu funkcji FunctionHandlerowi i zwrócenie zwróconej przez niego wartości.

Także po kolei:

Utworzenie obiektu funkcji

Krok pierwszy tymczasowo jest trywialny:

 
var f = Activator.CreateInstance<TFunction>(); 

Rozwiązanie jest tymczasowe, ponieważ metoda Activator.CreateInstance nie jest zbyt wydajna – szybszym sposobem stworzenia obiektu byłoby zawołanie jego konstruktora przy użyciu refleksji lub zbudowanie i skompilowanie NewExpression. Jednakże na razie nie skupiam się za bardzo na wydajności.

Poza tym rozwiązanie zakłada, że konstruktor funkcji jest bezparametryczny. Póki co nie przewiduję innej możliwości, ale w przyszłości może pojawić się potrzeba umożliwienia użytkownikowi samodzielnego konstruowania obiektów funkcji – wtedy w konfiguracji DSLExecutora pojawi się odpowiednia opcja. Ale na razie nie mam pomysłu do czego taka konfigurowalność mogłaby być przydatna, więc zostaje Activator.

Wypełnienie parametrów

Kolejnym krokiem jest wypełnienie parametrów funkcji. Jak pisałem w poprzednich postach, argumenty podawane są w formie wyrażeń (IExpression), a wartości przez nie zwrócone staną się wartościami właściwości obiektu funkcji (właściwości funkcji reprezentują jej paramety).

No właśnie, aby wykonać wyrażenie reprezentujące funkcję, muszę najpierw wykonać wyrażenia reprezentujące jej argumenty. Skądś już znamy podobne wyzwanie, prawda? Tak jak w przypadku wykonywania zbiorów wyrażeń, mamy do czynienia z rekurencją.

Składając powyższe do kupy, wypełnienie argumentów funkcji wygląda tak:

 
var props = typeof(TFunction).GetProperties();
foreach (var p in props)
{ 
  var ae = argumentExpressions[p.Name];
  var arg = _expressionExecutorFactory.Value.Execute(ae);
  
  p.SetValue(f, arg); 
} 

(Objaśnienie, czym jest _expressionExecutorFactory: tutaj.)

I w tym przypadku implementacja mogłaby być bardziej wydajna – zamiast PropertyInfo.SetValue, również mogłem użyć skompilowanego Expression. Ale nie skupiajmy się tak bardzo na wydajności – w tym rozwiązaniu coś innego jest warte uwagi. Otóż zauważmy, że nie wymagam aby argument (wartość zwrócona przez ae) był dokładnie tego samego typu, co parametr (właściwość klasy TFunction) – wystarczy, że po nim dziedziczy. (Przykładowo, jeśli funkcja ma parametr typu object, to jej argument może być wyrażeniem zwracającym int.) Dlaczego zwracam na to uwagę? Ponieważ w ten prosty sposób DSLExecutor wspiera polimorfizm!

Uzyskanie instancji FunctionHandlera

Ok, skoro mamy już zainicjalizowany obiekt funkcji, to należy tę funkcję wykonać, tzn. wywołać odpowiedni FunctionHandler. Ale skąd wziąć instancję FunctionHandlera? Na razie wybrałem rozwiązanie najprostsze z możliwych, a jednocześnie pozwalające w miarę sprawnie używać DSLExecutora:

  1. Wymagam od użytkownika dostarczenia słownika [typ funkcji] -> [typ jej Handlera].
  2. Typ Handlera wyciągam ze słownika i przekazuję metodzie Activator.CreateInstance (tak, mogłem to zrobić wydajniej), aby dostać jego instancję.
 
var handlerType = _functionToHandlerMap[typeof(TFunction)]; 
var handler = (IFunctionHandler<TFunction, TResult>) 
                Activator.CreateInstance(handlerType); 

Rozwiązanie to pozwoliło mi w miarę wygodnie przetestować DSLExecutor, ale ma istotne wady:

  • Użytkownik musi samodzielnie wypełnić słownik – w przypadku małego zestawu funkcji nie jest to uciążliwe, ale przy większej ich liczbie byłoby trudne w utrzymaniu.
  • Podobnie jak w przypadku konstrukcji obiektów funkcji, rozwiązanie zakłada, że konstruktor Handlera jest bezparametryczny. O ile nie spodziewam parametrycznych konstruktorów funkcji, o tyle Handlerów – jak najbardziej. Handlery funkcji można porównać do klas obsługujących wiadomości z kolejek lub metod kontrolerów – a one prawie zawsze korzystają z zależności.

Dlatego w przyszłości postaram się usprawnić konstrukcję obiektów FunctionHandler, np. przyjmując delegat [typ funkcji] => [instancja jej Handlera] zamiast wspomnianego słownika. Wtedy użytkownik będzie mógł łatwo zrzucić odpowiedzialność tworzenia Handlerów na kontener Dependency Injection (functionType => container.Resolve(functionType)).

Użycie FunctionHandlera i zwrócenie wyniku funkcji

Koniec wieńczy dzieło. Skoro mamy już funkcję i jej Handler, aby zakończyć metodę ExecuteFunction, wystarczy zawołać:

 
return handler.Handle(f); 

Całą metodę ExecuteFunction można zobaczyć tutaj (kroki 2 i 3 wydzieliłem do osobnych klas).

Et włala! Trzon DSLExecutora, mimo zastosowania kilku rozwiązań tymczasowych, uważam za ukończony. Da się już wykonywać wszystkie rodzaje wyrażeń (sprawdź sam, Czytelniku!). Teraz mogę skupić się na funkcjonalności, która będzie działać bardzo blisko użytkownika końcowego – parsowaniu DSLa do postaci wyrażeń. Brzmi obiecująco? Zapraszam!