Calculator Walkthrough: Part 3
Adding the services and user interface, and dealing with disaster
Last updated
Was this helpful?
Adding the services and user interface, and dealing with disaster
Last updated
Was this helpful?
In this post, I'll continue developing a simple pocket calculator app.
In the , we completed a first draft of the design, using only types (no UML diagrams!). and in the , we created an initial implementation that exercised the design and revealed a missing requirement.
Now it's time to build the remaining components and assemble them into a complete application
We have a implementation. But the implementation depends on some services, and we haven't created the services yet.
In practice though, this bit is very easy and straightforward. The types defined in the domain enforce constraints such there is really only one way of writing the code.
I'm going to show all the code at once (below), and I'll add some comments afterwards.
Some comments:
I have created a configuration record that stores properties that are used to parameterize the services, such as the decimal separator.
The configuration record is passed into the createServices
function, which in turn passes the configuration on those services that need it.
All the functions use the same approach of returning one of the types defined in the design, such as UpdateDisplayFromDigit
or DoMathOperation
.
There are only a few tricky edge cases, such as trapping exceptions in division, or preventing more than one decimal separator being appended.
For the user interface, I'm going to use WinForms rather than WPF or a web-based approach. It's simple and should work on Mono/Xamarin as well as Windows. And it should be easy to port to other UI frameworks as well.
As is typical with UI development I spent more time on this than on any other part of the process! I'm going to spare you all the painful iterations and just go directly to the final version.
The CalculatorForm
is a subclass of Form
, as usual.
There are two parameters for its constructor. One is initState
, the function that creates an empty state, and calculate
, the function that transforms the state based on the input. In other words, I'm using standard constructor based dependency injection here.
There are two mutable fields (shock horror!).
One is the state itself. Obviously, it will be modified after each button is pressed.
The second is a function called setDisplayedText
. What's that all about?
Well, after the state has changed, we need to refresh the control (a Label) that displays the text.
The standard way to do it is to make the label control a field in the form, like this:
and then set it to an actual control value when the form has been initialized:
But this has the problem that you might accidentally try to access the label control before it is initialized, causing a NRE. Also, I'd prefer to focus on the desired behavior, rather than having a "global" field that can be accessed by anyone anywhere.
By using a function, we (a) encapsulate the access to the real control and (b) avoid any possibility of a null reference.
The mutable function starts off with a safe default implementation (fun text -> ()
), and is then changed to a new implementation when the label control is created:
The buttons are laid out in a grid, and so I create a helper function getPos(row,col)
that gets the physical position from a logical (row,col) on the grid.
Here's an example of creating the buttons:
And since all the digit buttons have the same behavior, as do all the math op buttons, I just created some helpers that set the event handler in a generic way:
I also added some keyboard support:
Button clicks and keyboard presses are eventually routed into the key function handleInput
, which does the calculation.
As you can see, the implementation of handleInput
is trivial. It calls the calculation function that was injected, sets the mutable state to the result, and then updates the display.
So there you have it -- a complete calculator!
Let's start with a simple test. Try entering 1
Add
2
Equals
. What would you expect?
I don't know about you, but what I wouldn't expect is that the calculator display shows 12
!
What's going on? Some quick experimenting shows that I have forgotten something really important -- when an Add
or Equals
operation happens, any subsequent digits should not be added to the current buffer, but instead start a new one. Oh no! We've got a showstopper bug!
Remind me again, what idiot said "if it compiles, it probably works".*
* Actually, that idiot would be me (among many others).
So what went wrong then?
Well the code did compile, but it didn't work as expected, not because the code was buggy, but because my design was flawed.
In other words, the use of the types from the type-first design process means that I do have high confidence that the code I wrote is a correct implementation of the design. But if the requirements and design are wrong, all the correct code in the world can't fix that.
We'll revisit the requirements in the next post, but meanwhile, is there a patch we can make that will fix the problem?
Let's think of the circumstances when we start a new set of digits, vs. when we just append to the existing ones. As we noted above, a math operation or Equals
will force the reset.
So why not set a flag when those operations happen? If the flag is set, then start a new display buffer, and after that, unset the flag so that characters are appended as before.
What changes do we need to make to the code?
First, we need to store the flag somewhere. We'll store it in the CalculatorState
of course!
With this change made, compiling the CalculatorImplementation
code now breaks everywhere a new state is created.
Actually, that's what I like about using F# -- something like adding a new field to a record is a breaking change, rather than something that can be overlooked by mistake.
We'll make the following tweaks to the code:
For updateDisplayFromDigit
, we return a new state with allowAppend
set to true.
For updateDisplayFromPendingOp
and addPendingMathOp
, we return a new state with allowAppend
set to false.
Most of the services are fine. The only service that is broken now is initState
, which just needs to be tweaked to have allowAppend
be true when starting.
The CalculatorForm
class continues to work with no changes.
But this change does raise the question of how much the CalculatorForm
should know about the internals of the CalculatorDisplay
type.
Should CalculatorDisplay
be transparent, in which case the form might break every time we change the internals?
Or should CalculatorDisplay
be an opaque type, in which case we will need to add another "service" that extracts the buffer from the CalculatorDisplay
type so that the form can display it?
For now, I'm happy to tweak the form if there are changes. But in a bigger or more long-term project, when we are trying to reduce dependencies, then yes, I would make the domain types opaque as much as possible to reduce the fragility of the design.
Does it work now?
Yes. Entering 1
Add
2
Equals
results in 3
, as expected.
So that fixes the major bug. Phew.
But if you keep playing around with this implementation, you will encounter other bugs undocumented features too.
For example:
1.0 / 0.0
displays Infinity
. What happened to our divide by zero error?
You get strange behaviors if you enter operations in unusual orders. For example, entering 2 + + -
shows 8
on the display!
So obviously, this code is not yet fit for purpose.
At this point, you might be saying to yourself: "if only he had used TDD this wouldn't have happened".
It's true -- I wrote all this code, and yet I didn't even bother to write a test that checked whether you could add two numbers properly!
If I had started out by writing tests, and letting that drive the design, then surely I wouldn't have run into this problem.
Well in this particular example, yes, I would probably would have caught the problem immediately. In a TDD approach, checking that 1 + 2 = 3
would have been one of the first tests I wrote! But on the other hand, for obvious flaws like this, any interactive testing will reveal the issue too.
To my mind, the advantages of test-driven development are that:
it drives the design of the code, not just the implementation.
it provides guarantees that code stays correct during refactoring.
So the real question is, would test-driven development help us find missing requirements or subtle edge cases? Not necessarily. Test-driven development will only be effective if we can think of every possible case that could happen in the first place. In that sense, TDD would not make up for a lack of imagination!
Now I'm not saying that I am against automated testing. In fact, I do use it all the time to verify certain requirements, and especially for integration and testing in the large.
So, for example, here is how I might test this code:
I messed up! As I said earlier, the implementation itself was not the problem. I think the type-first design process worked. The real problem was that I was too hasty and just dived into the design without really understanding the requirements.
How can I prevent this from happening again next time?
One obvious solution would be to switch to a proper TDD approach. But I'm going to be a bit stubborn, and see if I can stay with a type-first design!
I won't show all the code, as it is about 200 lines (and you can see it in the ), but here are some highlights:
Let's try it now -- get the code from this and try running it as a F# script.
(This might seem like a good solution for now, but using flags like this is really a design smell. In the next post, I'll use a which doesn't involve flags)
Let's try out the patched version now (you can get the code for the patched version from this ).
And if do have good requirements, then hopefully we can design the types to and then we won't need the tests to provide correctness guarantees.
And of course, this would be easily adapted to using .
, I will stop being so ad-hoc and over-confident, and instead use a process that is more thorough and much more likely to prevent these kinds of errors at the design stage.
The code for this post is available on GitHub in and .