Grasp, A .NET Analysis Engine – Part 8: Calculation Dependencies
- Part 1: Overview
- Part 2: Variables
- Part 3: Calculations
- Part 4: Runtime
- Part 5: Executable
- Part 6: Validating Calculations
- Part 7: Compiling Calculations
- Part 8: Calculation Dependencies
- Part 9: Dependency Sorting
In part 7, we compiled individual calculations into an executable code in the form of a delegate. In this post, we will take the next step and compile all of the calculations associated with a GraspSchema.
The key difference in compiling multiple calculations is that there may be dependencies between them. A calculation can reference variables, and since variables may represent the results of other calculations, it is possible to have a cascading effect where the output of one calculation turns into the input of another. In this scenario, we need to execute the calculations in the right order to guarantee the correct result.
We can extend our OperatingProfit example to demonstrate this. Let’s say we want to calculate NetProfit, which applies a known tax rate to the OperatingProfit figure. We could use a set of calculations that look like this (namespaces omitted for clarity):
OperatingProfit = TotalIncome – TotalExpenses
NetProfit = OperatingProfit * (1 – TaxRate)
Here, OperatingProfit obviously needs to be available before NetProfit is calculated. We call a cross-calculation reference like this a dependency; we say that NetProfit is dependent upon OperatingProfit. Compiling a set of calculations requires us to identify these dependencies and order the calculations so they are all satisfied.
Completing the Compiler
In part 6, we left one piece of unfinished business: the GraspCompiler.Compile method. We took a detour in part 7 to lay the groundwork for compiling calculations; we can now complete the implementation of Compile:
return new GraspExecutable(_schema, GetCalculator());
We create an instance of GraspExecutable, defined in part 5, and provide it the instance of GraspSchema we are compiling. We also provide a calculator, which is what we call an instance of ICalculator. This second argument is the output of compiling the set of calculations associated with the schema.
The core method on which we build GetCalculator is an overload which takes a CalculationSchema, defined in part 6. This is where we use the CalculationCompiler class, defined in part 7, to create a function which applies a single calculation to a runtime:
return new CalculationCompiler().CompileCalculation(schema);
This visits all of the node in the calculation expression, replaces them with calls to retrieve their values instead, and returns a function which applies the calculation to a runtime. This is the unit of a compiled GraspSchema.
The GetCalculator overload with no parameters is responsible for taking all of the calculations and producing a single calculator which applies them. The first thing we do is attempt to optimize a simple scenario: a schema with a single calculation, by definition, cannot have any dependencies. In this case, we can just create a calculator for it; otherwise, we need to create a calculator which applies a set of calculations:
return _calculations.Count == 1
The GetCalculators method produces an implementation of ICalculator which applies a set of calculators in order. We can use the CompositeCalculator class here, defined in part 4:
return new CompositeCalculator(OrderCalculatorsByDependency());
It encapsulates the individual calculators we create for each calculation, ordered by dependency:
We order the _calculations sequence, defined in part 6, by dependency, then for each one select its calculator using the GetCalculator method. This produces the sequence we pass to the CompositeCalculator. (The syntax works because the C# compiler can infer that the GetCalculator method has the signature Func<CalculationSchema, ICalculator> of the parameter expected by the Select method. This is a simpler syntax than writing out the equivalent lambda expression schema => GetCalculator(schema).)
OrderByDependency is an extension method which operates on a sequence of calculation schemas and returns the same thing. This is similar to the LINQ OrderBy methods, except there is no function parameter because we are encapsulating the sorting logic:
internal static IEnumerable<CalculationSchema>
OrderByDependency(this IEnumerable<CalculationSchema> calculations)
// Next time
This is the entry point to analyzing the dependencies between calculations. We are set up nicely to do the analysis, but it is a decent amount of code and deserves a post of its own.
We identified the concept of cross-calculation dependencies and determined that we must order the calculations so all variable values are available when needed. We also finished the implementation of GraspCompiler and set up a context in which we can perform the ordering.
Next time, we will complete the dependency analysis logic.