Note: This is the ninth post in a series about OOP in Python. The previous post discussed how to write your own comparison rules for objects, and how to override the behavior of symbols as they relate to objects. The next post discusses how to keep the methods in your classes organized.
Many of the methods we write are meant to be called through instances of a class. But sometimes we want to break these methods up into smaller parts, for readability and maintainability. Methods that are only called by other methods are often referred to as helper methods.
In this post we’ll look at the role of helper methods in a class. Helper methods can make your classes more readable, and easier to maintain over time as well.
A simple chess board
As an example, let’s write a small class that models a chessboard. We won’t model a full board; we’ll just use a text string to represent the first row in the starting position of a game.
Here’s the code:
class ChessBoard:
def __init__(self):
self.position = "RNBQKBNR"
def show_position(self):
print(self.position)
board = ChessBoard()
board.show_position()
There are just two methods in this class, __init__()
and show_position()
. We make an instance of ChessBoard
, and then call show_position()
.
Here’s the output:
RNBQKBNR
Public methods
A method that’s meant to be called through an instance, such as board.show_position()
, is commonly referred to as a public method. It’s called by code that exists outside the class. Public methods are often called by people who have never looked at the actual code inside the class.
Some languages have structures that only allow users to call methods that are explicitly declared to be public. Python doesn’t have this kind of protection, but there are conventions that indicate whether a method is meant to be considered public.
In Python, any method that doesn’t start with an underscore is considered a public method. These methods are included in automatically-generated documentation, and users expect their behavior to remain stable and consistent as a project evolves over time.
Fischer Chess
Chess has a long and rich history, and even in the computer age people still love the game. Chess has fascinated people for thousands of years because it has a good balance between a relatively small set of rules, and a huge range of possible positions and games. The number of unique positions on a chess board is comparable to the number of atoms in the universe; this means people can play it their whole lives and always find new and interesting situations in their games.
That said, the number of competitive opening lines is small enough that people who play seriously find that memorization plays a significant role in the game. If you’ve memorized some opening lines that give you an advantage, and your opponent hasn’t memorized those specific lines, then you can get an advantage over them even if they’re otherwise a better player. People have come up with quite a large number of chess variants, many of which aim to take the role of memorization out of chess. Removing the importance of memorization puts the focus back on dynamic strategies that come up as each game develops.
One of the most well-known variants is Fischer Chess, named after Bobby Fischer. The variant has a simple premise: instead of starting with the standard opening position, the position of key pieces is selected randomly at the beginning of each game. This variant is also called Fischer960, because there are 960 possible starting positions.
Supporting Fischer960 in ChessBoard
Let’s modify our class so that the user can select the Fischer960 variant.
Here’s an updated version of ChessBoard
:
from random import shuffle
class ChessBoard:
def __init__(self, variant=""):
self.variant = variant
if self.variant == "fischer960":
pieces = ["R", "N", "B", "Q", "K", "B", "N", "R"]
shuffle(pieces)
self.position = ''.join(pieces)
else:
self.position = "RNBQKBNR"
def show_position(self):
print(self.position)
board = ChessBoard(variant="fischer960")
board.show_position()
The __init__()
method now takes an optional variant
argument. If the variant is "fischer960"
, we use the shuffle()
function from the random
module to mix up the order of the pieces in the back row.1
The result is a randomized sequence of the pieces on the back row in a standard chess game:
BKQNNBRR
This isn’t actually a legal Fischer Chess position, because there are two further restrictions on the starting position that preserve some important dynamics of chess. We’ll address those restrictions in a moment.
Simplifying __init__()
In the current implementation of ChessBoard
, __init__()
is starting to do a lot of work. The __init__()
method should initialize attributes that are needed by instances of the class. If __init__()
is doing a lot of work to initialize any one attribute, that work should probably be moved to a more focused method.
Here’s a cleaner version of ChessBoard
, with the work of setting the starting position moved to a separate method:
from random import shuffle
class ChessBoard:
def __init__(self, variant=""):
self.variant = variant
self.position = self._get_starting_position()
def show_position(self):
print(self.position)
def _get_starting_position(self):
if self.variant == "fischer960":
pieces = ["R", "N", "B", "Q", "K", "B", "N", "R"]
shuffle(pieces)
return ''.join(pieces)
else:
return "RNBQKBNR"
board = ChessBoard(variant="fischer960")
board.show_position()
This version is organized much more effectively than the previous listing. Notice how clear __init__()
is now: it’s just setting the values of two attributes. All the work to set the initial position is implemented in a dedicated method, _get_starting_position()
.
Helper methods
In Python, methods that have a single leading underscore in their name are considered helper methods, sometimes called private methods. These methods are only meant to be called from within the class, like you see here.
There’s nothing that prevents you from calling a method with a leading underscore through an instance. The leading underscore is just an indication to other programmers that the method isn’t meant for public use. While you can call helper methods through instances, it’s not a good idea to call those methods if you’re not maintaining the class. Library maintainers are free to change the implementation of helper methods, as long as their public methods still do what they’re supposed to.
When you call help()
on a class, the output doesn’t include a reference to any helper methods in the class. Here’s an excerpt of the output of help(ChessBoard)
:
class ChessBoard(builtins.object)
| ChessBoard(variant='')
|
| Methods defined here:
|
| __init__(self, variant='')
| Initialize self. See help(type(self)) for accurate signature.
|
| show_position(self)
|
| ------------------------------------------------------------------
| ...
Notice there’s no mention of _get_starting_position()
in this output.
Finishing the Fischer960 implementation
Fischer960 starting positions need to meet two criteria in order to be considered valid:
The two bishops must be on opposite colors. This preserves the essential characteristics of playing with a pair of bishops vs a single bishop.
Castling must be possible. The opportunity to castle or not castle has a huge impact on the nature of any single game, and preserving this possibility retains the richness of the standard starting position.2
To return a correct Fischer960 starting position, we need to write some more code. If we add it to _get_starting_position()
, that method would start to be nested deeply enough to impact readability. So, let’s move the Fischer960-specific code to its own helper method:
class ChessBoard:
def __init__(self, variant=""):
...
def show_position(self):
...
def _get_starting_position(self):
if self.variant == "fischer960":
return self._get_fischer960_position()
else:
return "RNBQKBNR"
@staticmethod
def _get_fischer960_position():
pieces = ["R", "N", "B", "Q", "K", "B", "N", "R"]
shuffle(pieces)
return ''.join(pieces)
If you’ve done any refactoring, this development should look familiar. We have more methods, but each one is doing more focused work. The method _get_starting_position()
only deals with returning starting positions, and _get_fischer960_position()
only deals with Fischer960 positions. It’s a static method, because it doesn’t need information specific to any one attribute in order to do its work. Helper methods can often be made static because they tend to do generalized lower-level work.
Now we can expand _get_fischer960_position()
so it only returns valid positions:
@staticmethod
def _get_fischer960_position():
pieces = ["R", "N", "B", "Q", "K", "B", "N", "R"]
shuffle(pieces)
# Check that Bishops are on different colors.
# Find Bishops in the list:
bishop_indexes = [
index
for index, piece in enumerate(pieces)
if piece == "B"
]
# One Bishop must have an even index, and one
# an odd index. The sum of the indexes
# must be odd. Otherwise, call this
# method again to start over.
if sum(bishop_indexes) % 2 == 0:
return ChessBoard._get_fischer960_position()
# Check that the King is between the Rooks.
king_index = pieces.index("K")
rook_indexes = [
index
for index, piece in enumerate(pieces)
if piece == "R"
]
# King can't be before the first Rook
# or after the second Rook.
if ((king_index < rook_indexes[0]) or
(king_index > rook_indexes[1])):
return ChessBoard._get_fischer960_position()
return ''.join(pieces)
I won’t try to explain this code beyond the comments shown in this listing. The main point here is that all this code is totally appropriate for a method called _get_fischer960_position()
, but it would clutter up the _get_starting_position()
method. And including all of this code in __init__()
would make for a very unreadable and poorly organized __init__()
method!3
Conclusions
Helper methods let you keep your classes well-structured, following the principle that most methods or functions should have one main responsibility. Indicating that a method is a helper method does two things:
It communicates the purpose of the method clearly to anyone reading your code;
It keeps implementation details from cluttering automated documentation like the output of
help()
, and the information that pops up in IDEs when people work with instances of the class.
You could implement your classes using only public methods, with no leading underscores. But your code would be less organized, harder to use based on automated documentation, and harder for others to help maintain.
Use helper methods whenever you need to break a public method into smaller parts. Remember to name them appropriately, with a single leading underscore, so that their purpose and role is clear.
Resources
You can find the code files from this post in the mostly_python GitHub repository.
If you haven’t seen join()
before, this line is worth explaining:
''.join(pieces)
join()
is a string method, even though the focus seems to be on the list that’s passed as an argument. This takes the empty string ''
and uses it to join each element in the list. The result here is all of the elements in the list joined together as a single string.
Castling is often described as “switching the King and Rook.” More accurately, the King is moved two spaces towards the Rook, and the Rook is placed on the other side of the King. You can’t castle while you’re in check, the King can’t pass over a square that’s threatened by an opponent’s piece, and you can’t castle if you’ve already moved your King.
If you want a brief explanation:
We start by shuffling the
pieces
list, just as before.Before returning
''.join(pieces)
, we make two checks:Are the Bishops on opposite colors? If not, we use recursion to call this same method again, starting the search for a valid Fischer960 position all over again.
Is the King between the two Rooks? If not, we start over as well.
By the time we reach the last line of the method, we have a valid Fischer 960 position.
There are other approaches, but this is my first pass at implementing a Fischer960 algorithm. It’s also fun to find a real-world use for recursion! The default recursion limit is 1000, and I don’t think it’s ever going to take 1000 attempts to find a valid position. If that were an issue, I’d convert this to a while
loop.