Welcome to week 1 of Programming Feedback for Advanced Beginners.
In this series I review a program sent to me by one of my readers. I analyze their code, highlight the things that I like, and discuss the things that I think could be better. Most of all, I suggest small and big changes that the author could make in order to bring their program up to a professional standard.
Over the next few weeks we’re going to ponder a program sent to me by Tiffany Tiffleberry (not her real name) (obviously) (can you imagine?). Tiffany has written a version of Programming Projects for Advanced Beginners #3: Tic-Tac-Toe. Her program is written in Python, but the lessons we’re going to draw from it will be equally applicable in any language. Her code is clean and sensibly structured. She’s written 4 increasingly sophisticated AI players, as well as a thrilling gauntlet program in which they fight each other to the death.
Nonetheless, I’ve spotted several subtle ways in which I think she could make her code even cleaner. This week we’re going to talk about how she could better define the “boundaries” between the different components of her program, and why this would be a good idea.
To get the most out of this post, take a look at Tiffany’s code on GitHub. It’s only 200 lines long, and is quite pleasant to read.
Tiffany’s tic-tac-toe program is a command-line application. Each tic-tac-toe-turn it prints the current state of the board to the terminal and asks the user to input their moves by typing in co-ordinates. Since a tic-tac-toe board is 3x3 in size, each co-ordinate is between 1 and 3.
1 2 3 -------- 1:X X - 2:- O - 3:O - O X co-ordinate? 3 Y co-ordinate? 1
Internally, Tiffany’s program stores the state of the board in a 2-dimensional list, or list of lists:
board = [ ["X", "X", None], [None, "O", None], ["O", None, "X"] ]
This allows her to reference squares on her board using an intuitive co-ordinate-style syntax:
x = 2 y = 0 board[x][y] = "X"
Since lists are 0-indexed (i.e. to access the first element you pass in an index of
mylist) and a tic-tac-toe board is 3-by-3 in size, her program accesses her board data-structure using co-ordinates with values between 0 and 2.
As we’ve seen, Tiffany’s UI deals in numbers between 1 and 3 (we’ll call this 1-3-form, a term that I just made up). However, the internals of her program deal in numbers between 0 and 2 (0-2-form, another term that I just made up). This means that the program needs to translate the player’s input into something that its internals can understand.
Fundamentally this translation is nothing more than subtracting 1 from the co-ordinates that the user inputs. However, the subtleties of choosing the best place to do this subtraction are a concise and instructive illustration of how to think about organizing logic in your code. The way that Tiffany’s program currently does the translation is a little ragged. Let’s see how we can improve it, and why we should bother.
Take a look at the code for the
update function, which adds a move to a board:
def update(self, coord, player): """ Sets a position on a board for a player. No check if position is legal done at this point """ # EDITOR'S NOTE: here we subtract 1 from each # co-ordinate in order to convert from 1-3-form to # 0-2-form. This is required so that we can enter # the move into our board. x_coord = coord - 1 y_coord = coord - 1 if self.board[x_coord][y_coord] is None: self.board[x_coord][y_coord] = player else: raise ValueError("position already filled")
Like most other methods in the program,
update expects to receive co-ordinate arguments in 1-3-form. It is then itself responsible for converting these arguments into 0-2-form.
This approach produces perfectly correct and working code. However, because these methods expect their co-ordinate arguments to be in 1-3-form, they are forever having to translate arguments from 1-3-form into 0-2-form themselves. This is a shame, because it means that the internals of the program are being forced to care about the form in which the user inputs their moves. This makes the internal board code more complex, fiddly, and difficult to reason about (“OK so I know that this variable is a co-ordinate, but is it a 0-2 or a 1-3?”). Plus, if we wanted to change the way that the user’s inputted their moves, or the way in which we internally represented the state of the game, we would have to update both the code that gets input from the user, and the internal logic. The two parts of the program are coupled together unnecessarily.
I would like us to make the executive decision that every method in the program is only allowed to communicate with the rest of the program using 0-2-form. The code that gets input from users should receive co-ordinates in the 1-3-form that is most intuitive to humans, but then immediately and permanently translate this raw input into 0-2-form for consumption by the rest of the code. This means that rest of the program neither knows nor cares that the input it received was originally provided in 1-3-form.
Get user input in *any* form | v Translate into 0-2-form | v Pass 0-2-form into rest of program, which never sees the original form
Making this change doesn’t require any complex code changes, just a reorganizing of the logic that we’re already doing. We have to change this (simplified pseudo-code):
def update_board(board, co_ords, player): """Accepts 1-3-form co-ordinates, and then immediately translates to 0-2-form.""" x = co_ords - 1 y = co_ords - 1 # ... update the board ... def get_user_coordinates(): """Returns 1-3-form co-ordinates""" x_inp = input("X co-ordinate?: ") y_inp = input("Y co-ordinate?: ") return (x_inp, y_inp) player = "X" co_ords = get_user_coordinates() update_board(board, co_ords, player)
to something more like this:
def update_board(board, co_ords, player): """Now accepts 0-2-form co-ordinates, meaning that we don't have to do any translation.""" x = co_ords y = co_ords # ... update the board ... def get_user_coordinates(): """Now returns 0-2-form co-ordinates""" x_inp = input("X co-ordinate?: ") y_inp = input("Y co-ordinate?: ") return (x_inp - 1, y_inp - 1) player = "X" co_ords = get_user_coordinates() update_board(board, co_ords, player)
This approach has several benefits:
get_user_coordinatesfunction so that it accepts chess-squares from the user, but immediately translates these squares into the same type of numerical co-ordinates that it was returning before. The logic component can stay entirely unchanged. Making change’s like this without clean boundaries between components is much, much harder.
This might seem like an absurd amount of fuss over the exact right place to add and subtract a 1. But this kind of detail, repeated enough times, is what dictates whether a 100,000 line program will be gratifying or grisly to work with and understand.
Another example - in a video game, the game’s internal engine code almost certainly never refers to specific buttons. It doesn’t think
if button == "A" then move_character_up(). What if the player remapped the buttons on their controller to different commands? Or what if the game was ported to an entirely different platform that didn’t have an “A” button? Instead, the game has a translation component between the user input and the engine, which turns button presses into commands and then passes those commands into the engine. The structure is conceptually closer to
if button == "A" then command = "JUMP" and then
if command == "JUMP" then move_character_up(). If you want to port the game to PlayStation then you just change the input component to
if button == "X" then command = "JUMP", and leave the game engine untouched.
Next week we’ll talk about how we can split up Tiffany’s
Board class into several smaller classes with more tightly-defined responsibilities. Don’t miss it. In the meantime: