“Modeling” in Code
(Continued from Part 1) Now there are four very important facets of the code structure that have to do with modeling.
(1) First is the inherent disconnect in how these buttons are classified: there are four appearance classifications but only two behavioral classifications, the latter being implicit in the assignment of the Click event hander assignment, which is why I call it an “informal” classification. To tighten up these relationships, we should really have event handlers for each conceptual button type: digit, memory, operator, and function. At the same time, we don’t want to combine the visual and behavioral classifications: each button should have a visual classification that only affects appearance (for the purpose of visual design) and a behavior classification that only affects function (irrespective of a button’s color, for instance).
(2) Second, the OnWindowKeyDown event handler expresses all the keyboard mappings in code such that they cannot be changed unless program is recompiled. To support a class of applications in a runtime, this has to be converted to a data-driven implementation that can accommodate arbitrary mappings.
(3) Third, the two sets of variables clearly separate those that are external—that is, those that have meaning to a user of a calculator—and those that are strictly internal implementation details. I did this intentionally when rewriting the code because the conceptual behavior of any button can be expressed in terms of these few external variables (Accumulator, Stack, Op, Mem, and AllowRepeatEquals). The internal/state variables, on the other hand, are necessary to create the behavior of specific kinds of buttons, such as digits, operators, or =.
I reworking this code, it took considerable tweaking to get a few behaviors working correctly, such as the repeat = key, overwriting the display value at the appropriate times, and suppressing an operation when an operator key was pressed. The problem was that the original code sample overloads the semantics of its EraseDisplay variable with all of these behavioral implications. It’s used not only in the same capacity as our new OverwriteAccumulator, which has a similar meaning obviously, but is also used to decide whether to suppress computation when an operator key is pressed. It’s also used to ignore a repeated = keypress. As a result, it really complicated my effort to clone the repeat = behavior of the Windows Vista Calculator.
In this process I also realized the extent to which code allows you to very easily hide behavioral semantics without being aware of it. We usually don’t complain because, in the end, the damn thing works and we see no reason to fiddle with it any longer. But in the context of modeling, it means that the model itself ends up being partially explicit and partially implicit. In order to model behavior completely in data, however, all behavior must be explicit in such a way as they can be controlled with nothing more than a variable assignment. This is why I broke out LastKeyWasEquals and LastKeyWasOperator so that those semantics were not buried inside EraseDisplay (which became OverwriteAccumulator).[1]
(4) Finally, in 4c above I noted that the majority of the ProcessOperation method is nothing more than simple variable assignments (from a constant or an expression). There are only a few places where an if statement is used for conditional execution rather than merely conditional assignment.
This is a very important differentiation. In every case of conditional execution, we have to ask whether the resulting behavior is something that should be configurable. Non-configurable behavior is something that all applications we intend to support through our runtime will have in common, such that alternate behavior is simply “unsupported.” For example, if the user presses an operator key after having just pressed another, the new operator replaces the previous one. In our design, all applications will have this behavior.
Configurable behavior, on the other hand, is something like whether to allow a repeat = functionality. Configurable behavior has to therefore be driven by model data. Such data can contain expressions with the ? : operators but not control-flow statements.
This is really the fundamental difference between procedural programming and declarative programming. Procedural programming is all about statements that control the flow of execution. Declarative programming is all about declaring what should happen when various events occur, but leaves control flow to its underlying runtime. That flow might be configured by certain variables, true, but the flow of execution is controlled by assigning values to those variables, period.
I bring this up because the configuration of a model-driven runtime is ideally an exercise in declarative programming: the entire definition of the application must be expressible in data only.[2] This is actually what’s happening with the AllowRepeatEquals variable. This value, which is easily expressed in data, determines whether a repeat = behavior is allowed at all (see line 274 in the code). The variable is, in effect, controlling the conditional execution of certain statements, and this is exactly what we want in a model-driven runtime.
All other instances of conditional execution within the code only create standard behavior for all calculator applications. These cases need not be configurable (for complete details see Appendix 1). One point is worth further mention, though. The ComputeLastOperation method (lines 346-383) is essentially a subroutine because it has the side-effect of modifying Accumulator if Op is valid, or doing nothing if Op is not set. Any method with such a side-effect is also a form of conditional execution and must be considered in the same light as we’ve been discussing.
If such a side-effect was problematic, you’d want to convert the method to be a true function, returning the appropriate values with no side-effects. In the case of ComputeLastOperation, it could return the result instead of assigning it to Accumulator directly. We’d also want to remove the no-op case from the function and use such a check within an expression instead. Thus calls like this, which are essentially a statement with side-effects:
ComputeLastOperation(Convert.ToDouble(Accumulator));
Could be written as this expression:
Accumulator = (Op != String.Empty) ?
ComputeLastOperation(Convert.ToDouble(Accumulator)).ToString()
: Accumulator;
In our case, however, the current implementation is fine.
Changes to the Code: ModelCalc_Stage1b
Now that we’ve beaten these 383 lines of code to death, we know some changes that can be made to better accommodate conversion to a model-driven runtime. This updated code is in the ModelCalc_Stage1b project (see link at the bottom of this post). Here are the primary changes:
- Distinct behavioral classifications (and Click event handlers) are added to differentiate memory, function, and operator buttons that in the 1a code shared the same handler.
- Keyboard handling is now driven from data rather than relying on a switch/case structure in code. This is accomplished using a Dictionary wherein each entry maps a key gesture to the appropriate behavioral classification and the specific function/operation/key involved. That dispatching happens in a KeyDown event handler that picks up non-textual keys like F9 and Delete. See lines 78-151 (class definitions and KeyDown handler) and lines 418-474 (a method to initialize the dictionary).
- Function and memory button handling that was in ProcessOperation is broken out separately into ProcessFunction and ProcessMemory so that common behaviors for each class of behavior is clearly separated (specifically how these classes of behaviors affect OverwriteAccumulator and LastKeyWasOperation). This eliminated some temporary variables that were basically doing some of this separation in a less obvious fashion.
- 4.Similarly, the handling of the +/- button is moved into ProcessKey because it behaves like a digit rather than a function or operator. Again, this clearly isolates the common elements of each behavioral class.
- As I started to implement the changes above, I realized that the LastKeyWasEquals flag is not really named correctly because there are cases where it’s still true after other keys have been pressed. To be specific, if the last key only directly affected Accumulator, or had no effect on it at all, the LastKeyWasEquals flag was left alone. It’s actually only set to false in the case of an operator or the C (clear all) key. A more proper name for these semantics, then, is EqualsCanRepeatLastOperation, so I changed the variable name.
This is the code we’ll now carry forward.
Appendix 1: Cases of Conditional Execution in ModelCalc_Stage1a
Lines 77-165, OnWindowKeyDown: if statements here check for whether a keystroke goes to ProcessKey or ProcessOperation, or check for control-key modifiers. These conditions will be converted in the Stage1b code to be data-driven, so this case will disappear.
Lines 185-230, ProcessKey: the first if statement here handles clearing out Accumulator when it’s appropriate to do so, which is controlled by the OverwriteAccumulator flag. The setting of this flag is inherent to the nature of the type of button pressed (digit, function, operator, etc.) so isn’t something to control separately if button classifications are clear and accurate. The other three if statements control behavior of digits, the decimal point, and the backspace key, all of which are common to all calculators.
There are then three cases within ProcessOperation to examine. First, is the case for operators (lines 253-270):
case "Add":
case "Subtract":
case "Multiply":
case "Divide":
if (!LastKeyWasOperatorTemp && !LastKeyWasEquals)
ComputeLastOperation(Convert.ToDouble(Accumulator));
Op = s;
LastKeyWasOperator = true;
if (!LastKeyWasOperatorTemp)
Stack = Convert.ToDouble(Accumulator);
LastKeyWasEquals = false;
OverwriteAccumulator = true;
break;
The behavior here is that if you enter one operator (setting LastKeyWasOperator to true, with the Temp variable of this name holding the previous value), then immediately enter another (thereby going through this code again), all that happens is that Op gets updated to the new operator. Similarly, if you’d just pressed = (thereby setting LastKeyWasEquals to true) pressing an operator shouldn’t do any more computation but should instead copy Accumulator into Stack in preparation for the next operator. (An aside, the actual code uses a temporary copy of LastKeyWasOperator here so that the real variable can be set to false without an explicit line of code in every other case.)
Next is the conditional execution in the = case (lines 272-282):
case "Equals":
if (LastKeyWasEquals && AllowRepeatEquals)
Stack = Convert.ToDouble(Accumulator);
else
LastEqualsOperand = Convert.ToDouble(Accumulator);
ComputeLastOperation(LastEqualsOperand);
...
break;
This is just a necessary structure for a repeat = behavior. The only possible customization is whether that behavior is allowed or not, and this is already handled by the AllowRepeatEquals variable.
Finally, the +/- handling (lines 298-301) looks like this:
case "Negate":
if (Accumulator != String.Empty)
Accumulator = Accumulator[0] == '-' ? Accumulator.Remove(0, 1)
: "-" + Accumulator;
break;
Checking whether Accumulator is an empty string merely prevents a “-“ from appearing when no value has been entered. There’s no reason this would change for a custom calculator application, so this conditional execution is also perfectly acceptable to leave as-is (though we change the handling of this button in the stage 1b code).
[1] These two LastKey* variables came from a direct analysis of behaviors of subsequent keypresses. I made a grid with every key on each axis and identified those specific sequences that needed special attention, namely cases following an operator or =. The only other combination involved the backspace key, which normally takes the last digit off the accumulator (or the decimal point) unless the last key was an operator, =, or a “function” key (such as 1/x or sqrt), in which case it does nothing. This semantic is entirely consistent within OverwriteAccumulator, so that variable is used to control this behavior within ProcessKey.
[2] A domain-specific language (DSL) that’s parsed and processed at runtime can, of course, support conditional statements if it so chooses. In that case, though, the DSL becomes more of procedural scripting language than a declarative configuration language, and one must be careful to not end up making the job of using that DSL a greater chore than just using a general-purpose language to begin with!
ModelCalc_Stage1b.zip (16.25 kb)