by Zoran Horvat
Steve Freeman and Nat Pryce remind us of distinction between interface and the calling protocol, saying that "an interface describes whether two components will fit together, while a protocol describes whether they will work together." (Steve Freeman, Nat Pryce, "Growing Object-Oriented Software, Guided by Tests", Addison-Wesley, 2010, p 58.) Calling protocols pose a serious problem in modern programming. Programming languages are still not up to the task of defining calling protocols precisely, and hence the room opens for defects in code.
What is the calling protocol, we might ask. That is the set of constraints and couplings between calls one class makes on another. Should one call be forgotten or made twice instead of once, the whole operation would fail. If we are lucky, the operation will fail by throwing an exception. If we ran out of luck, the operation would fail silently and we will have to struggle with non-obvious defect later, when suspicious traces of the defect are discovered.
In this article we will tackle the problem of calling protocols. One specific solution will be proposed, which can be used in many practical cases. As you will see on an example, putting the protocol under strict syntactic control has great effects on readability and maintainability of code.
We are facing the calling protocol every time when we have to perform calls on a class in a specific order. Failing to do so will cause failure in execution. Take a look at the following class:
class Engine
{
private readonly double WorkingTemperature = 90.0;
private double temperature;
public void Ignite(double ambientTemperature)
{
this.temperature = ambientTemperature;
}
public void RunFor(TimeSpan interval)
{
this.temperature +=
(this.WorkingTemperature - this.temperature) *
(1 - 3 (3 + interval.TotalMinutes));
}
public double GetCurrentTemperature()
{
return this.temperature;
}
}
This class models a car engine. It can be used to simulate the engine running for specified amount of time and then reading its current temperature.
Engine can be started by calling the Ignite method. This method sets the ambient temperature as the current engine temperature. After that, we can call the RunFor method, which will gradually increase the temperature so that it approaches the engine’s nominal running temperature. Finally, after running the engine for some time, we can read its current temperature by calling the GetCurrentTemperature method.
It is easy to use this class. For example, we can see how the engine behaves when it runs for ten minutes:
void Simulate(Engine engine)
{
engine.Ignite(20);
for (int i = 0; i < 10; i++)
{
engine.RunFor(TimeSpan.FromMinutes(1));
Console.WriteLine("{0,2} min. -> {1:0.00} C",
i + 1, engine.GetCurrentTemperature());
}
}
...
Simulate(new Engine());
The simple loop in the Simulate method lets the engine run ten times by one minute and reads the temperatures after each run. The method is simply invoked with new Engine instance. Here is what the method prints out:
1 min. -> 37.50 C
2 min. -> 50.63 C
3 min. -> 60.47 C
4 min. -> 67.85 C
5 min. -> 73.39 C
6 min. -> 77.54 C
7 min. -> 80.66 C
8 min. -> 82.99 C
9 min. -> 84.74 C
10 min. -> 86.06 C
This simulation shows the rate at which engine temperature approaches its working limit. Everything works fine, but there is one slight problem - we had to obey the calling protocol.
What would happen if we just forgot to ignite the engine in the previous example? Take a look at the modified code:
void Simulate(Engine engine)
{
//engine.Ignite(20);
for (int i = 0; i < 10; i++)
{
engine.RunFor(TimeSpan.FromMinutes(1));
Console.WriteLine("{0,2} min. -> {1:0.00} C",
i + 1, engine.GetCurrentTemperature());
}
}
This time we are not igniting the engine and the code compiles just fine. Compiler has no complaints about the missing call to the Ignite method - that is not the syntax error and the compiler feels no reason to report that. But the program output reveals the defect:
1 min. -> 22.50 C
2 min. -> 39.38 C
3 min. -> 52.03 C
4 min. -> 61.52 C
5 min. -> 68.64 C
6 min. -> 73.98 C
7 min. -> 77.99 C
8 min. -> 80.99 C
9 min. -> 83.24 C
10 min. -> 84.93 C
This time, the application displays lower running temperatures. This is because the ambient temperature was not set - that is precisely what the Ignite method must be used for.
Net result is the application which builds but doesn't work well. This example reveals the typical problem with calling protocols. Caller can involuntarily violate the protocol, and the application will still build and run as if everything was right. But the result of running the application will be incorrect.
Basic problem with calling protocols is that we are allowed to violate them, at least from the compiler's perspective. If we wanted to solve the problem, we had to look for a design which forbids invalid calls.
When applied to the Engine class example, this would mean that the type we use should make the RunFor method inaccessible until Ignite method has been called. Similarly, GetCurrentTemperature method should not be accessible until RunFor has been called. These constraints form the calling protocol for the Engine’s public interface. As we have already pointed out, the problem is that this protocol is implicit.
The first step towards solving the issues is to introduce a separate interface which only exposes the Ignite method:
interface IStopped
{
void Ignite(double ambientTemperature);
}
The purpose of this interface is to constrain the Engine operations. This interface only lets us ignite the engine, not to run it. Engine class can now implement this interface:
class Engine: IStopped
{
...
}
The trick here is that the Simulate method should now be spared from knowing about the whole Engine interface. The method should be fully operational if it only received IStopped:
void Simulate(IStopped stoppedEngine)
{
stoppedEngine.Ignite(20);
...
}
This completes the first step in the protocol – Engine must be ignited before use. The fact that we are only passing the IStopped interface, rather than the whole public interface of the Engine class, makes sure that the user will ignite the engine. That is the only operation it can invoke, in the end.
But now we have another problem - Simulate method can ignite the engine, but cannot run it. The IStopped interface turns to be too restrictive. It doesn't let the caller access the RunFor method. The problem will be solved by first providing an interface for running the engine and then making that interface accessible from the IStopped. Here is the complete change in interfaces and the concrete class:
interface IStopped
{
IIgnited Ignite(double ambientTemperature);
}
interface IIgnited
{
void RunFor(TimeSpan interval);
}
class Engine: IStopped, IIgnited
{
private readonly double WorkingTemperature = 90.0;
private double temperature;
public IIgnited Ignite(double ambientTemperature)
{
this.temperature = ambientTemperature;
return this;
}
...
}
This change has pushed us one step closer to the complete solution. It lets the caller ignite and then run the engine. Although the solution is still not complete, it shows the point. The point is to force the caller invoke Ignite method before RunFor method. We are separating the two methods into two interfaces.
We can continue with the idea that was started by introducing IStopped and IIgnited interfaces. Having the engine running for some time is the prerequisite for picking up the temperature. Once the temperature has been read, we can run it further in order to read the next temperature point.
This is the complete calling protocol. We will complete its implementation by adding another interface which can only be reached after the engine has been running for some time.
Here is the complete solution:
interface IStopped
{
IIgnited Ignite(double ambientTemperature);
}
interface IIgnited
{
IRunning RunFor(TimeSpan interval);
}
interface IRunning: IIgnited
{
double GetCurrentTemperature();
}
class Engine: IStopped, IIgnited, IRunning
{
private readonly double WorkingTemperature = 90.0;
private double temperature;
public IIgnited Ignite(double ambientTemperature)
{
this.temperature = ambientTemperature;
return this;
}
public IRunning RunFor(TimeSpan interval)
{
this.temperature +=
(this.WorkingTemperature - this.temperature) *
(1 - 3 (3 + interval.TotalMinutes));
return this;
}
public double GetCurrentTemperature()
{
return this.temperature;
}
}
The three interfaces defined in this solution are modeling the three states of the engine – not ignited, ignited but didn’t run yet, and ignited and was running for some time.
In order to measure running temperatures, the Simulate method must now obey the rules defined by the interfaces it has at hand:
void Simulate(IStopped stoppedEngine)
{
IIgnited ignited = stoppedEngine.Ignite(20);
for (int i = 0; i < 10; i++)
{
ignited = SimulateIteration(i + 1, ignited);
}
}
IIgnited SimulateIteration(int minutes, IIgnited ignited)
{
IRunning running = ignited.RunFor(TimeSpan.FromMinutes(1));
Console.WriteLine("{0,2} min. -> {1:0.00} C",
minutes, running.GetCurrentTemperature());
return running;
}
With this modification, Simulate and SimulateIteration methods are forced to strictly obey the calling protocol. Otherwise, these methods wouldn’t be able to access the methods they wish to invoke.
The problem with the last implementation is that the caller may keep reference to the interface it has once obtained. That interface refers to the same instance as other interfaces that were obtained later. This means that the Simulate method can ignite the same engine two times, which is again in violation of the calling protocol:
void Simulate(IStopped stoppedEngine)
{
IIgnited ignited = stoppedEngine.Ignite(20);
for (int i = 0; i < 10; i++)
{
ignited = SimulateIteration(i + 1, ignited);
stoppedEngine.Ignite(45);
}
}
The second call to the Ignite method will totally spoil the results. Here is the output produced by this method:
1 min. -> 37.50 C
2 min. -> 56.25 C
3 min. -> 56.25 C
4 min. -> 56.25 C
5 min. -> 56.25 C
6 min. -> 56.25 C
7 min. -> 56.25 C
8 min. -> 56.25 C
9 min. -> 56.25 C
10 min. -> 56.25 C
This issue can be solved by making the class immutable. In previous implementation, all methods were returning this pointer. That was the mechanism that can lead to having different interface types reference the same instance, which then opens space for aliasing bugs.
We can solve this problem entirely by producing a fresh instance every time a method returning certain interface implementation is invoked. Here is the complete solution to the problem of the calling protocol on the Engine class:
class Engine: IStopped, IIgnited, IRunning
{
private readonly double WorkingTemperature = 90.0;
private double temperature;
public Engine()
{
}
private Engine(double temperature)
{
this.temperature = temperature;
}
public IIgnited Ignite(double ambientTemperature)
{
return new Engine(ambientTemperature);
}
public IRunning RunFor(TimeSpan interval)
{
double newTemperature = this.temperature +
(this.WorkingTemperature - this.temperature) *
(1 - 3 (3 + interval.TotalMinutes));
return new Engine(newTemperature);
}
public double GetCurrentTemperature()
{
return this.temperature;
}
}
In this implementation, Engine object is never reused. If client makes a mistake and uses the old reference to some interface again, there will be no visible result. This is because any old reference is stale and points to the object which is not used anymore.
Solution above addresses the issue of accessing certain methods that should not be accessible until another method was called. But the Engine class can still be used in a way that should not be allowed. Take a look at this example:
Engine engine = new Engine();
engine
.RunFor(TimeSpan.FromMinutes(10))
.GetCurrentTemperature();
This sequence of calls is clearly illegal and should not be accessible. We have succeeded to read the running temperature of the engine without igniting it. That is in violation of the calling protocol.
The root cause of the problem here is that we were allowed to obtain reference to the entire Engine interface. At the same time, the calling protocol specifies that Ignite method must be called first. In other words, IStopped interface is the entry point to the engine simulation. Engine class’s public interface should not be used at any point because it does not limit the caller's options properly.
The solution is in hiding the public constructor and providing another entry point. The easiest way to achieve this is to provide a static factory method and to hide the constructor. Here is the solution:
class Engine: IStopped, IIgnited, IRunning
{
private Engine() { }
public static IStopped Create()
{
return new Engine();
}
...
}
In this way, when we want to simulate the engine, we can only start from the fresh engine that was not ignited. That is precisely as the calling protocol defined it. The fact that the factory method returns IStopped interface, rather than the entire Engine’s public interface, means that the caller’s actions will be restricted from the very beginning.
In this article we have addressed the problem which arises when there are specific constraints on the order and number of calls that can be placed on specific methods of a class. Constraints like these form a calling protocol, which complicates its clients and often leads to subtle bugs.
To solve the issue entirely, we apply a set of modifications to the design. These modifications are not complicated. Here is the list of changes that were made to support the calling protocol:
When these guidelines are followed, the resulting structure ensures that the class can only be used in the correct way. Any incorrect use that we could try would simply not compile.
Having the compiler on your side is the great achievement. We are most happy when semantic errors can be lowered down to the level of syntactical errors. Then it all boils down to listen for the failed build. And if the build passed well, we can rest assured that the application is not only correct on the syntactical level, but on the semantic level as well.
If you wish to learn more, please watch my latest video courses
In this course, you will learn the basic principles of object-oriented programming, and then learn how to apply those principles to construct an operational and correct code using the C# programming language and .NET.
As the course progresses, you will learn such programming concepts as objects, method resolution, polymorphism, object composition, class inheritance, object substitution, etc., but also the basic principles of object-oriented design and even project management, such as abstraction, dependency injection, open-closed principle, tell don't ask principle, the principles of agile software development and many more.
More...
In this course, you will learn how design patterns can be applied to make code better: flexible, short, readable.
You will learn how to decide when and which pattern to apply by formally analyzing the need to flex around specific axis.
More...
This course begins with examination of a realistic application, which is poorly factored and doesn't incorporate design patterns. It is nearly impossible to maintain and develop this application further, due to its poor structure and design.
As demonstration after demonstration will unfold, we will refactor this entire application, fitting many design patterns into place almost without effort. By the end of the course, you will know how code refactoring and design patterns can operate together, and help each other create great design.
More...
In four and a half hours of this course, you will learn how to control design of classes, design of complex algorithms, and how to recognize and implement data structures.
After completing this course, you will know how to develop a large and complex domain model, which you will be able to maintain and extend further. And, not to forget, the model you develop in this way will be correct and free of bugs.
More...
Zoran Horvat is the Principal Consultant at Coding Helmet, speaker and author of 100+ articles, and independent trainer on .NET technology stack. He can often be found speaking at conferences and user groups, promoting object-oriented and functional development style and clean coding practices and techniques that improve longevity of complex business applications.