ISP stated that clients should not be forced to depend upon methods that they do not use. Interface belongs to clients, not to hierarchies.
The story: Implement different game types for tictactoe, Human vs. Human, Human vs. Computer. In order to do this, it is useful to create a Player interface and every type of player, human, smart AI, dumb AI should all conform to this interface.
This is one such interface I wrote in my first version of tictactoe in Java
public interface Player {
String getSymbol();
int obtainValidCellSelection();
void addSymbol(String symbol);
void addOpponentSymbol(String opponentSymbol);
void addRules(Rules rules);
}
public class HumanPlayer implements Player {
@Override
public String getSymbol() {}
@Override
public int obtainValidCellSelection() {}
@Override
void AddSymbol() {}
@Override
void addOpponentSymbol() {}
@Override
void addRules() {}
}
public class AIPlayer implements Player {
@Override
public String getSymbol() {}
@Override
public int obtainValidCellSelection() {}
@Override
void AddSymbol() {}
@Override
void addOpponentSymbol() {}
@Override
void addRules() {}
}
This Player interface is a violation of ISP. While both AIPlayer and HumanPlayer conform to this interface, only the AI player needs to calculate its next move with methods like addOpponentSymbol() and addRules(). The AI needs to know about the rules, and the opponent’s symbol, but the Human Player does not need these methods. In this case, Human Player is forced to implement addOpponentSymbol() and addRules() even though it does not use the information. The problem with this interface is that it miscommunicates the logic of the code. A programmer reading this code would assume that all the methods implemented by HumanPlayer are there for some business logic purposes. Because of the way HumanPlayer implements the Player interface with all of its extra methods, it might lead the program to believe that HumanPlayer has something to do with the rules or the opponent’s symbol. In this case of our tictactoe game, we are already familiar with the domain and have a lot of knowledge about the algorithm. However, imagine a developer jumping into a new codebase with little to no prior domain knowledge and have to study the code to figure out how things work. Such bloated interface would be very difficult to understand.
One solution to this “fat” interface problem is to split that interface into multiple smaller interfaces and have our classes conform to one or more interfaces depends on their needs. In this case, that means separate the Player interface into a Player interface and an AI interface
public interface Player {
String getSymbol();
int obtainValidCellSelection();
}
public interface AI {
void addSymbol(String symbol);
void addOpponentSymbol(String opponentSymbol);
void addRules(Rules rules);
}
public class HumanPlayer implements Player {
@Override
public String getSymbol() {}
@Override
public int obtainValidCellSelection() {}
}
public class AIPlayer implements AI, Player {
@Override
public String getSymbol() {}
@Override
public int obtainValidCellSelection() {}
@Override
void AddSymbol() {}
@Override
void addOpponentSymbol() {}
@Override
void addRules() {}
}
The HumanPlayer class now does not need to implement AI methods anymore. This solution solved the violation of ISP in our code. Our interfaces are more coherent. It is clear that our HumanPlayer does not depend on the AI logic that involves knowing about the game rules or the opponent’s symbol.
The whole purpose of having one Player interface is so that we can instantiate both an AIPlayer and a HumanPlayer as type Player so we can inject them into our games. After we refactor our Player interface into 2 interfaces. We can try to create 2 players again.
public class Main {
public static void main(String[] args) {
Player human = new HumamPlayer();
Player ai = new AIPlayer();
// This throws an error saying that it cannot find `addRules()`
ai.addRules();
}
}
Although AIPlayer implements both AI and Player interfaces, depending on which type we instantiate the instance as, it only has the methods in that interface. Since we instantiate ai as a Player, and not as AI, it can only access the methods in the Player interface. We can instantiate ai as an AI, but then it would not have the method of Player. This is true at least for Java. It might or might not be true in other languages. If we can’t call all the methods we need for the ai, then it’s no good for our algorithm.
In this case, another solution is to use dependency injection. Whatever information that we need for the ai and not for the human player, we can obtain them by dependency injeection, instead of making a function call. Things like the rules, and the opponent’s symbol that only the AI player needs, we can just inject them into its constructor. This way, we keep our interface focused with only the methods that both classes use and our instances have all the information they need for the algorithm to work properly. If that was a little abstract. Here is the code:
public interface Player {
String getSymbol();
int obtainValidCellSelection();
}
public class HumanPlayer implements Player {
private String symbol;
private IO io;
private UserInputValidator validator;
public HumanPlayer(IO io, UserInputValidator validator, String symbol) {
this.io = io;
this.validator = validator;
this.symbol = symbol;
}
@Override
public String getSymbol() {
return this.symbol;
}
@Override
public int obtainValidCellSelection() {}
public class AIPlayer implements Player {
private Board board;
private Rules rules;
private String selfSymbol;
private Minimax algorithm;
public AIPlayer (Rules rules, Board board, String selfSymbol, String opponentSymbol) {
this.rules = rules;
this.board = board;
this.selfSymbol = selfSymbol;
algorithm = new Minimax(rules, board, selfSymbol, opponentSymbol);
}
@Override
public String getSymbol() {
return this.selfSymbol;
}
@Override
public int obtainValidCellSelection() {}
}