Building Classes Part One
As I mentioned in the post about The Lego Principle, I regard the main bulk of classes as self-contained and unitform building blocks, put into a pyramid, and only knowing about classes in the layer immediately below them. As methods can only travel downwards and data only can travel upwards, classes have no need to know about classes above them. This is an extremely important point. A class should be 100% self contained, and only use functionality from classes below it and expose data to classes above.
As methods and data can’t travel horizontally either, classes at the same level can also not be aware of each other. If class B uses class E below it, and class C also uses class E below it, class B cannot be aware of class C, or vice verse.
The common term for this is encapsulation. OOP dictates, that classes should be regarded as black boxes, hiding all the implementation details to the user. This is done in order control access to, for example, data, to improve modularity, to improve ease of use and maintenance, and to reduce overall complexity. However, OOP doesn't strictly talk about restricting method calls, so why bother?.
Methods only travel downwards.
If you look at the application pyramids above, the arrows indicates the direction the method calls go. Imagine that you in the left pyramid, changes some top level functionality in class A. Because class B makes method calls into class A, class B might actually break and require changes. Because B changed, C might break. Then F, E and D. And even worse. The changes to A might not affect B, C and F so much that they actually break, but it might seriously crash E. This leads to cases, where a seemingly harmless functionality change in a top level class, suddenly leads to serious crashes in lower level classes. This is probably the biggest cause for unrelated crashes, and can bring a development cycle to a complete halt, or in worst case make the developer abandon the entire project.
If you on the other hand look at the application pyramid at the right side, changes to class A can ever only affect class A. Nothing else can or will ever break.
You will hopefully also now understand, why circular header references should not be fixed with forward declarations. That is actually one of my only major gripes with C#, because it doesn't give you this warning, and just mindlessly lets you add “Using”. That makes it horribly easy to introduce unwanted class dependencies. Finally, it is obvious, that you will not get any warnings about horizontal calls, if they are unidirectional.
Data only travels upwards.
The exact same thing happens if data not only travel upwards, but also downwards. Some changes to data in class A, might affect class B, and the lavine rolls again. This also leads us to the conclusion, that if data only travels one way, they must all be read-only, and thus immutable seen from outside the class. That is a good thing, because by simply declaring all published class data as read-only, you have effectively fixed the problem.
Immutable data has another great benefit. While not being as restrictive as functional programming dictates, the approach still makes it very easy to make classes thread safe. That is, however, a post for another day.
Travel in practice.
Imagine you are making an adventure top down game, sporting a handsome avatar (let us call him Arthur), walking the earth solving crimes, killing monsters and flirting with men and/or women, according to your personal preferences (you cant’t play it safe enough these days).
The goal is for Arthur to attack and slay wolves, to get the loot.
The first thing to do is to make Arthur aware of the world, by including the RSWorld class. Here he can inquire if there is a wolf near by. Next step is to make Arthur aware of wolves, by including the RSWolf class. Arthur then attacks the wolf, calculates his damage based on his weapons and the wolfs armour, and deals some damage, by calling
if (_wolf != null)
{
_wolf.Attack(_damage);
if (_wolf.Life <= 0)
{
StopAttack(_wolf);
world.CreatureHasDied(_wolf);
}
}
The RSWolf class first responds by playing the”wroof” sound, which in wolfish means: “Hey man, WTF is happening?”, inquires the RWWorld class and is told that an RSArthur class smacked it. The world class might also check if any other wolfs could have heard the wroof, and inform them that a fellow carnivore has been attacked.
foreach (RSWorf wolf in _wolfList)
{
if (wolf.DistanceToCreature(_arthur) < wolf.HearingDistance)
{
wolf.AttackWasHeardAt(_arthur.GetCurrentPosition());
}
}
As RSArthur and RSWolf now both includes each other, this can lead to a circular reference in some languages, but in C# you just happily keeps coding along. The wolf then responds with
if (_target != null)
{
_target.Attack(_damage);
if (_target.Life <= 0)
{
StopAttack(_target);
world.CreatureHasDied(_target);
}
}
The fight then goes on, until the wolf is dead, after which Arthur informs the world that the wolf is dead, and the world removes it from the game table.
This gives us the following application pyramid, where the layers really are irrelevant, because the classes all call each other.
Fixing it.
The first thing to consider is how the classes fit into The Lego Principle, and here it is clear, that RSWorld must know about wolves and Arthurs, and thus be the top class. Because of that, Arthur can never inquire about wolves. Instead the world tells Arthur that a wolf is near by, and asks him what he wants to do about it. Arthur then responds with some data, indicating his intentions. The world then informs the wolf, that it has been attacked.
Next step is to calculate the damage Arthur does. As Arthur can not be aware about the wolf class, the simple solution would be to make the calculations in the world class, and while that will work in smaller scenarios, it is not a great solution in the long run. The reason for this is that you want to keep functionality in the respective classes, and not centralise it. That will eventually lead to god objects.
It is important to note, that there are many different solutions to this problem - the point being - that it is entirely up to you how to solve it, using your preferred style and techniques, as long as you keep The Lego Principle in mind.
One way to fix it, would be to introduce an RSCombat class. Both Arthur and the wolf would need to control it, so it is clear that it has to be placed below those two classes. The combat class would then continue to receive commands from Arthur and the wolf, until the combat was over.
From Arthur’s point of view, the code could then look something like:
combat.Attack();
if (combat.Opponent.Life <= 0)
{
combat.Stop();
}
The final step from the example above would be for the world to remove the wolf when it was dead. Obviously Arthur can’t call a method, as he can’t speak upwards in the pyramid, so the world would have to ask with frequent intervals. In a game that normally isn’t a problem, as many games runs around a continuous game loop, but in other applications, this might not be that case.
So we need a way to pass data changes upwards asynchronously. This is known as callbacks, or event handling, or on embedded systems, interrupts. It is a standardised way of telling a class above you, that something has changed, but again that is a topic for another post.
Takeaway
Building solid classes is essential. If not, changes to a class could spread like wildfire though out the entire application. I use The Lego Principle to clearly define an application pyramid, and rigorously enforces that method calls only makes calls to the level below, and that read-only data are only passed to the level above. That has helped me tremendously in building large and complicated applications. Check the About section for a video demonstrating the nine month beta.
/Lars
Comments
Post a Comment