Originally Posted at eriksCodeSpace
Interfaces define the ways objects interact with each other. Properly designing interfaces not only help conceptualize a system, but aid in testing and maintainability of our systems.
What Are Interfaces?
This article is going to go beyond the concept of just using the principle of least privilege. We’re learning about this in the context of good design so you can write maintainable and testable code. The examples in this article are going to be in C# because I believe the syntax is optimal for explaining these concepts, but the concepts themselves transcend languages and apply to all OOP designs.
For now, put aside the idea of interface
as a programming language construct; right now we’re going to use it in its regular sense. An “interface” is the point in which two entities meet and interact. For example, your kitchen sink’s interface is the hot and cold handles plus the faucet.
There are two entities: the sink and you. The sink provides a lot of functionality, but most of it is hidden from you. For example, you don’t need to know how the sink magically summons the water it spits out of the faucet; you don’t need to know how the sink is heating that water; and you definitely don’t need to know how the sink drains the water after it’s washed over your hands and dishes.
All you need to know as a user of the sink is how to turn the water on and off, and which dial gives you what kind of water. You, the calling entity, don’t know the inner workings of the sink, the subject entity. To put this back into a programming point of view, the sink interface’s functional signature would take in the hot and cold dials as arguments and return water.
Imagine if you had to know everything about plumbing just to wash your hands. Not only is this unrealistic, but it makes changing things nearly impossible. If the city implemented a new draining system, you’d have to relearn how that works before you can soak your dishes.
Likewise, this is exactly what happens when you have objects that are too tightly coupled. The calling object that knows too much about its subject is like the plumber washing their hands. The public
interface of an object needs to be simple, and needs to change as infrequently as possible.
That last sentence is basically the main concept behind designing good interfaces: public interfaces should rarely, if ever, change.
Let’s Do an Example
Like I said at the beginning of this article, our examples will use the C# programming language. I think the syntax for objects and their interfaces is the best one to illustrate the necessary examples.
We’re going to make a very simple command line RPG style fighting simulator. We’re going to have a Hero
that attacks different types of Enemies
. Let’s start by writing an abstract Enemy
class:
namespace PubPriExample2
{
public abstract class Enemy
{
public string name { get; set; }
public int hitPoints { get; set; }
public int strength { get; set; }
public int defense { get; set; }
public bool alive { get; set; }
public Enemy(string enemyName, int hp, int str, int def)
{
name = enemyName;
hitPoints = hp;
strength = str;
defense = def;
alive = true;
}
public abstract void takeDamage(int attackDamage);
public abstract bool isAlive();
public abstract void die();
}
}
What do we have here? This abstract class defines the base class for all enemies. Each enemy will have a name, hit points that represent how much damage it can take, strength which will control its attack damage, defense which controls how it takes damage, and a boolean variable called alive
that will be set to false when the hit points fall below zero.
We also have a few methods. Besides the constructor, we have takeDamage
, isAlive
, and die
. These are methods that every enemy must have because it is the only thing we’ll do with enemies in this game. All enemies will be attacked, will be alive or dead, and do something when they die.
Let’s make our Hero
class. Most of this class will look like it should be extending the Enemy
class, but we’re not going to do that. If this were to evolve as a full on RPG, Hero
would probably also evolve into an abstract class used by multiple customizable heroes. For now, this class will just help us interact with the Enemy
classes.
using System;
namespace PubPriExample2
{
class Hero
{
public string name { get; set; }
public int hitPoints { get; set; }
public int strength { get; set; }
public int defense { get; set; }
public Hero(string heroName, int hp, int str, int def)
{
name = heroName;
hitPoints = hp;
strength = str;
defense = def;
}
public void attack(Enemy enemy)
{
Console.WriteLine(name + " attacks " + enemy.name);
enemy.takeDamage(strength);
}
}
}
We haven’t made any takeDamage
type methods for this class because to illustrate the points I’m trying to make, they are not necessary. The one thing I do want you to note here is that is that attack
takes in an Enemy
object, and uses the enemy’s takeDamage
method to interact with it. Hence, the takeDamage
method is Hero
‘s interface to Enemy
.
Let’s make a couple of concrete enemy classes. First, we’ll make RegularEnemy
:
using System;
namespace PubPriExample2
{
class RegularEnemy : Enemy
{
public RegularEnemy(string enemyName, int hp, int str, int def) : base(enemyName, hp, str, def)
{
}
public override void takeDamage(int heroStrength)
{
int damage = calculateDamage(heroStrength);
hitPoints -= damage;
Console.WriteLine(name + " takes " + damage + " points of damage");
if (isAlive()) die();
}
public override bool isAlive() => hitPoints > 0;
public override void die()
{
alive = false;
Console.WriteLine(name + " has died!");
}
private int calculateDamage(int attackStrength)
{
int damage = attackStrength - defense;
if (damage > 0)
{
return damage;
}
else
{
return 0;
}
}
}
}
NOTE: If the =>
syntax is throwing you off, don’t worry, it’s just some C# syntactic sugar. public override bool isAlive() => hitPoints > 0;
is exactly the same as
public override bool isAlive()
{
return hitPoints > 0;
}
Here, we’ve overridden the base class’s abstract methods takeDamage
, isAlive
, and die
. The one we’re going to pay attention to is the takeDamage
method because, remember, it is the public interface at this moment.
Why, if we have takeDamage
, do we also have calculateDamage
? Why can’t we put the logic for calculatign the damage in the takeDamage
method? We could, but this is part of what we’re learning.
First of all, calculating the damage within the same method in which we apply the damage would violate the Single Responsibility Principle of SOLID Object Oriented Design. We want each method to have a single responsibility, and the takeDamage
method’s responsibility is applying damage, not calculating it.
Secondly, takeDamage
is the interface that the Hero
class is going to use to interact with Enemy
, so we want it to be relatively uniform across all Enemy
sub types. We will make other enemies that can also be attacked, but will take damage in different ways. calculateDamage
is therefore the private method that will do the sub-type-specific operations to figure out how much our hero has damaged the enemy.
Speaking of, let’s make our second Enemy
class, the ArmoredEnemy
:
using System;
namespace PubPriExample2
{
class ArmoredEnemy : Enemy
{
public ArmoredEnemy(string enemyName, int hp, int str, int def) : base(enemyName, hp, str, def)
{
}
public override void takeDamage(int heroStrength)
{
int damage = calculateDamage(heroStrength);
hitPoints -= damage;
Console.WriteLine(name + " takes " + damage + " points of damage");
if (!isAlive()) die();
}
public override bool isAlive() => hitPoints < 0;
public override void die()
{
alive = false;
Console.WriteLine(name + " has died; the armor shatters");
}
private int calculateDamage(int attackStrength)
{
// Enemy has 20% change of deflecting the attack
Random random = new Random();
int deflect = random.Next(1, 6);
if (deflect == 1)
{
deflectAttack();
return 0;
}
else if ((attackStrength - defense) > 0)
{
return (attackStrength - defense);
}
else
{
return 0;
}
}
private void deflectAttack()
{
Console.WriteLine(name + " deflects the attack!");
}
}
}
Notice here that the ArmoredEnemy
takes damage a different way. The enemy’s armor may deflect our hero’s attack, and thus the calculateDamage
method essentially rolls the dice to find out if the attack was deflected or not.
Now, let’s see some actual use of these methods:
using System;
namespace PubPriExample2
{
class Program
{
static void Main(string[] args)
{
Hero hero = new Hero("Erik", 100, 10, 5);
RegularEnemy imp = new RegularEnemy("Imp", 50, 3, 2);
ArmoredEnemy armorImp = new ArmoredEnemy("Armor Imp", 30, 4, 4);
while (imp.alive)
{
hero.attack(imp);
}
while (armorImp.alive)
{
hero.attack(armorImp);
}
Console.ReadLine();
}
}
}
Run this and you should get something similar to the following output:
Erik attacks Imp
Imp takes 8 points of damage
Erik attacks Imp
Imp takes 8 points of damage
Erik attacks Imp
…truncated
Imp takes 8 points of damage
Imp has died!
Erik attacks Armor Imp
Armor Imp deflects the attack!
Armor Imp takes 0 points of damage
… truncatedErik attacks Armor Imp
Armor Imp deflects the attack! Armor Imp takes 0 points of damage Erik attacks Armor Imp Armor Imp takes 6 points of damage …truncated
Erik attacks Armor Imp
Armor Imp takes 6 points of damage
Armor Imp has died; the armor shatters
Now consider we wanted to add even a third type of enemy, one that counterattacks. The code would mostly look the same, the new enemy would still have a takeDamage
method, but there would also be a private method for implementing the counterattack. The interface for the enemy wouldn’t be any different, the hero can still only attack
, but the implementation of that interface would be different, which would mean it should be hidden inside a private class of the new sub type.
When Things Change
What if we wanted to add deflection capabilities to our RegularEnemy
class? Just because an enemy isn’t armored doesn’t mean it can’t deflect an attack, right? Right. So we want to add deflection capabilities to RegularEnemy
but we want these enemies to have a 5% chance of deflecting rather than 20%.
In theory, this new feature should not require any changes to our public interfaces. In fact, it doesn’t:
private int calculateDamage(int attackStrength)
{
// Enemy has 5% change of deflecting the attack
Random random = new Random();
int deflect = random.Next(1, 21);
if (deflect == 1)
{
deflectAttack();
return 0;
}
else if ((attackStrength - defense) > 0)
{
return (attackStrength - defense);
}
else
{
return 0;
}
}
private void deflectAttack()
{
Console.WriteLine(name + " deflects the attack!");
}
Nothing needed to change in order for this new feature to be added because we had a good separation of our public and private methods.
Interfaces Representing Roles
It’s kind of funny to have spent all this time talking about interfaces and not once using the keyword interface
huh? Let’s go ahead and do that, but let’s think it through first.
What is it about Enemy
types that unify them? They all have stats and a name. They can also all be attacked, but in the context of a video game is that really a common trait? For example, consider a game like Zelda, where our hero Link can not only attack bad guys with his sword, but also grass and bushes. The grass and bushes don’t need stats like hit points and strength, but they’re still attackable.
Attackable. That’s definitely not a real word, but it segues well into deciding when to use an abstract class vs when to use interfaces. Enemies are attackable. In Zelda, bushes are also attackable. So if we wanted to implement something similar in our game, like a tree our Hero
could chop down with a sword, do we want the tree to extend the Enemy
abstract class?
Conceptually, no. We could, of course do it this way, but making it work would look super shady. Likewise, any functionality we’d want to add to our sentient enemies would also get added to our non-sentient trees. In this case, trees and enemies don’t exactly share traits, but they do share roles. Specifically, they share the “attackable” role, which is what we’re going to name our interface. We’re going to name our interface IAttackable
because that’s how you name interfaces in C#, sorry 🤷
namespace PubPriExample2
{
interface IAttackable
{
void takeDamage(int attackDamage);
bool isAlive();
void die();
}
}
Now, head over to the Enemy
abstract class to implement this interface:
namespace PubPriExample2
{
public abstract class Enemy : IAttackable
{
// Truncated
What has this done for us? Haven’t we really just added some constraints on ourselves, forcing us to always write these three methods for any class implementing this interface?
Well, yes. And trust me, I get it. It does look like a bit of unnecessary self-restraint, but try to look at it from a bigger picture. Imagine you’re developing this game and you decided you wanted to add attackable
trees a few months after you finished the last Enemy
sub type. Will you remember which methods you’ve been using for these objects? In this case, probably yes because it’s such a small project right now, but imagine the project grows to a couple thousand lines of code.
Even better, imagine you are in a large company with multiple teams working on the game. Another team has been tasked with implementing trees that our Hero
can cut down. Luckily, they already have this interface and know exactly how similar functionality has been added to the game before. It lets everything that is attackable
in the game have the same interface.
Finally, it gives us a little something we might neglect. It helps us understand the system better. Think about RegularEnemy
and ArmoredEnemy
and how they both extend the Enemy
class. Any time we look at a class, regardless of its name, if we see that it extends Enemy
, we know that this is something the player will fight with.
Likewise, for any class implementing the Attackable
interface, we will know it takes damage from the Hero
. This example might make this benefit seem inconsequential, but in large systems with business processes less easy to conceptualize, this benefit can go a long way. The programmer’s main job is managing complexity, and things like proper naming and good interface design help us do that.
A Note on Testability
We’ve learned that public interfaces should rarely change, and private ones are the real workhorses of classes. You might think then that the private methods of a class are the ones that need to be tested the most, but the opposite is true.
Private methods cannot be implemented by other classes, and the nuggets of functionality they provide are really just in support of their public methods. Therefore, thoroughly testing a class’s public interface essentially tests its private methods as well.
Similarly, as we’ve already learned, public interfaces should not change much. That means that testing of them should also not change very frequently, except when new functionality is added that changes the object’s behavior. For example, our ArmoredEnemy
class implemented two private methods when taking damage. Testing the public takeDamage
tests both methods satisfactorily. If we added even a third private method to the takeDamage
method, we may need to change a few expected values here and there in our tests, but ultimately, test maintenance should be very low.
So not only does the proper design of interfaces help us conceptualize our systems better, it also helps cut down on maintenance costs while our tests still give us the same level of confidence they always have.
Summary
Today we learned a little bit about good object oriented design. We learned that public interfaces, not necessarily interface
methods, are designed to be stable. Keeping these methods constant and hiding complexity in private methods gives us the ability to add or modify functionality almost at a whim. These concepts give us simple public interfaces and help our test suite stay maintainable and effective.
For further reading, I strongly suggest checking out Sandi Metz’s book, Practical Object-Oriented Design: An Agile Primer Using Ruby. She does a much better job at explaining this concept in chapter 4. The rest of the book is excellent as well. No this isn’t an affiliate link, I really think this book is worth reading if you’re interested in good software design.
Top comments (0)