32
\$\begingroup\$

I teach programming, and I currently teach my class about inheritance, abstract classes, and mixins.

I wrote this code as an example, and I want to be sure it is as good as possible before I release it as a code example.

Few assumptions:

  1. The code should only handle piece movements and not manage an entire game.
  2. The code shouldn't handle special moves like en-passant, castling, or pawn promotion.
  3. The code shouldn't force the king to move if another piece threatens it.
from abc import ABC, abstractmethod


class Color:
    BLACK = 0
    WHITE = 1

    def enemy_of(color):
        if color == Color.BLACK:
            return Color.WHITE
        return Color.BLACK


class Board:
    BOARD_SIZE = (8, 8)

    def __init__(self):
        self.reset()

    def get_square(self, row, col):
        if not self.is_valid_square((row, col)):
            return None
        return self.board[row][col]

    def set_square(self, row, col, piece):
        self.board[row][col] = piece

    def is_valid_square(self, square):
        return (
            square[0] in range(self.BOARD_SIZE[0])
            and square[1] in range(self.BOARD_SIZE[1])
        )

    def is_empty_square(self, square):
        return self.get_square(*square) is None

    def _generate_first_row(self, color):
        row_by_color = {Color.BLACK: 0, Color.WHITE: self.BOARD_SIZE[0] - 1}
        row = row_by_color[color]

        order = (Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook)
        params = {'color': color, 'row': row}
        return [order[i](col=i, **params) for i in range(self.BOARD_SIZE[0])]

    def _generate_pawns_row(self, color):
        row_by_color = {Color.BLACK: 1, Color.WHITE: self.BOARD_SIZE[0] - 2}
        row = row_by_color[color]
        params = {'color': color, 'row': row}
        return [Pawn(col=i, **params) for i in range(self.BOARD_SIZE[0])]

    def get_pieces(self, color=None):
        for row in self.board:
            for col in row:
                if col is not None and (color is None or col.color == color):
                    yield col

    def get_possible_moves(self, color, with_king=False):
        """Return all player's possible moves."""
        pieces = self.get_pieces(color=color)
        if not with_king:
            pieces = [p for p in pieces if not isinstance(p, King)]

        for piece in pieces:
            for move in piece.get_valid_moves(self):
                yield move

    def reset(self):
        self.board = [
            self._generate_first_row(Color.BLACK),
            self._generate_pawns_row(Color.BLACK),
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            self._generate_pawns_row(Color.WHITE),
            self._generate_first_row(Color.WHITE),
        ]

    def move(self, source, destination):
        piece = self.get_square(*source)
        return piece.move(board=self, destination=destination)

    def __str__(self):
        printable = ""
        for row in self.board:
            for col in row:
                if col is None:
                    printable = printable + " ▭ "
                else:
                    printable = printable + f" {col} "
            printable = printable + '\n'
        return printable


class Piece(ABC):
    def __init__(self, color, row, col, **kwargs):
        super().__init__(**kwargs)
        self.color = color
        self.row = row
        self.col = col

    def is_possible_target(self, board, target):
        is_target_valid = board.is_valid_square(target)
        is_empty_square = board.is_empty_square(target)
        is_hitting_enemy = self.is_enemy(board.get_square(*target))
        return is_target_valid and (is_empty_square or is_hitting_enemy)

    @abstractmethod
    def get_valid_moves(self, board):
        pass

    def get_position(self):
        return self.row, self.col

    def is_enemy(self, piece):
        if piece is None:
            return False
        return piece.color == Color.enemy_of(self.color)

    def move(self, board, destination):
        if not self.is_possible_target(board, destination):
            return False
        if destination not in self.get_valid_moves(board):
            return False

        board.set_square(*self.get_position(), None)
        board.set_square(*destination, self)
        self.row, self.col = destination
        return True

    @abstractmethod
    def __str__(self):
        pass


class WalksDiagonallyMixin:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if not hasattr(self, 'directions'):
            self.directions = set()
        self.directions.update({
            (-1, -1),          (1, -1),

            (-1,  1),          (1,  1),
        })


class WalksStraightMixin:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        if not hasattr(self, 'directions'):
            self.directions = set()
        self.directions.update({
                      (0, -1),
            (-1,  0),          (1,  0),
                      (0,  1),
        })


class WalksMultipleStepsMixin:
    def get_valid_moves(self, board):
        for row_change, col_change in self.directions:
            steps = 1
            stop_searching_in_this_direction = False
            while not stop_searching_in_this_direction:
                new_row = self.row + row_change * steps
                new_col = self.col + col_change * steps
                target = (new_row, new_col)
                is_valid_target = self.is_possible_target(board, target)
                if is_valid_target:
                    yield target
                    steps = steps + 1
                    is_hit_enemy = self.is_enemy(board.get_square(*target))
                if not is_valid_target or (is_valid_target and is_hit_enemy):
                    stop_searching_in_this_direction = True


class Pawn(Piece):
    DIRECTION_BY_COLOR = {Color.BLACK: 1, Color.WHITE: -1}

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.moved = False
        self.forward = self.DIRECTION_BY_COLOR[self.color]

    def _get_regular_walk(self):
        src_row, src_col = self.get_position()
        return (src_row + self.forward, src_col)

    def _get_double_walk(self):
        src_row, src_col = self.get_position()
        return (src_row + self.forward * 2, src_col)

    def _get_diagonal_walks(self):
        src_row, src_col = self.get_position()
        return (
            (src_row + self.forward, src_col + 1),
            (src_row + self.forward, src_col - 1),
        )

    def is_possible_target(self, board, target):
        is_valid_move = board.is_valid_square(target)
        is_step_forward = (
            board.is_empty_square(target)
            and target == self._get_regular_walk()
        )
        is_valid_double_step_forward = (
            board.is_empty_square(target)
            and not self.moved
            and target == self._get_double_walk()
            and self.is_possible_target(board, self._get_regular_walk())
        )
        is_hitting_enemy = (
            self.is_enemy(board.get_square(*target))
            and target in self._get_diagonal_walks()
        )
        return is_valid_move and (
            is_step_forward or is_valid_double_step_forward or is_hitting_enemy
        )

    def move(self, **kwargs):
        is_success = super().move(**kwargs)
        self.moved = True
        return is_success

    def get_valid_moves(self, board):
        targets = (
            self._get_regular_walk(),
            self._get_double_walk(),
            *self._get_diagonal_walks(),
        )
        for target in targets:
            if self.is_possible_target(board, target):
                yield target

    def __str__(self):
        if self.color == Color.WHITE:
            return '♙'
        return '♟'


class Bishop(WalksDiagonallyMixin, WalksMultipleStepsMixin, Piece):
    def __str__(self):
        if self.color == Color.WHITE:
            return '♗'
        return '♝'


class Rook(WalksStraightMixin, WalksMultipleStepsMixin, Piece):
    def __str__(self):
        if self.color == Color.WHITE:
            return '♖'
        return '♜'


class Queen(
    WalksStraightMixin, WalksDiagonallyMixin, WalksMultipleStepsMixin, Piece,
):
    def __str__(self):
        if self.color == Color.WHITE:
            return '♕'
        return '♛'


class Knight(Piece):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.directions = [
            (-2, 1), (-1, 2), (1, 2), (2, 1),  # Upper part
            (-2, -1), (-1, -2), (1, -2), (2, -1),  # Lower part
        ]

    def get_valid_moves(self, board):
        for row_change, col_change in self.directions:
            row, col = self.get_position()
            target = (row + row_change, col + col_change)
            if self.is_possible_target(board, target):
                yield target

    def __str__(self):
        if self.color == Color.WHITE:
            return '♘'
        return '♞'


class King(WalksStraightMixin, WalksDiagonallyMixin, Piece):
    def _get_threatened_squares(self, board):
        enemy = Color.enemy_of(self.color)
        enemy_moves = list(board.get_possible_moves(enemy, with_king=False))
        enemy_pieces = board.get_pieces(color=enemy)
        king = next(p for p in enemy_pieces if isinstance(p, King))
        for move in king.get_squares_threatens(board):
            yield move
        for move in enemy_moves:
            yield move

    def is_possible_target(self, board, target):
        is_regular_valid = super().is_possible_target(board, target)
        threatened_squares = self._get_threatened_squares(board)
        return is_regular_valid and target not in threatened_squares

    def get_valid_moves(self, board):
        for add_row, add_col in self.directions:
            target = (add_row + self.row, add_col + self.col)
            if self.is_possible_target(board, target):
                yield target

    def get_squares_threatens(self, board):
        for direction in self.directions:
            row, col = self.get_position()
            row = row + direction[0]
            col = col + direction[1]
            if board.is_valid_square((row, col)):
                yield (row, col)

    def __str__(self):
        if self.color == Color.WHITE:
            return '♔'
        return '♚'

Things I know I can improve, but I leave as-is because of my current student's knowledge:

  1. I can use yield from instead of for x in y: yield x.
  2. Color can inherit enum.Enum and use enum.auto() for the class variables.
  3. I can raise exceptions instead of returning True or False.
\$\endgroup\$
2
  • 3
    \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. Feel free to post a new question with the revised code, self-answer indicating what you improved and why or share your code in another way. Feel free to add links. But don't add it to the question itself, please. Thank you! \$\endgroup\$
    – Mast
    Commented Jul 9, 2020 at 6:18
  • \$\begingroup\$ Regarding your last point, Returning True or False is likely better than raising errors anyways \$\endgroup\$
    – user
    Commented Jul 9, 2020 at 12:45

5 Answers 5

39
\$\begingroup\$

I would include a comment at the top of the file indicating the version of the relevant software you've used. A quick comment stating "Tested with Python 3.6 (installed through Anaconda)" or something to that effect is nice to make sure everyone is on the same page.


Since this is intended as teaching example, I will focus on minimizing the current code. I think that it is a reasonable assumption that more code gives more room for potential confusion.

class Board:
    BOARD_SIZE = (8, 8)

Will you ever have a non-square board? Can this be a simple int? Changing this reduces the overall amount of code by a non-trivially amount.

    def get_square(self, row, col):
        if not self.is_valid_square((row, col)):
            return None
        return self.board[row][col]

    def set_square(self, row, col, piece):
        self.board[row][col] = piece

Getters and setters are rare in Python, and since the board is public facing (it isn't prefixed with an underscore like later functions are), the setter doesn't really add much to the code. The getter smells a little bit, since a getter returning None is unexpected, and none of the provided code that uses the getter checks for None. I would remove both.

    def is_valid_square(self, square):
        return (
            square[0] in range(self.BOARD_SIZE[0])
            and square[1] in range(self.BOARD_SIZE[1])
        )

This function is not pleasant to debug if it is used incorrectly. An example of this is the error given if the input parameter 'square' is empty.

>>> board.is_valid_square([])
Traceback (most recent call last):
...
    square[0] in range(self.BOARD_SIZE[0])
IndexError: list index out of range

Which list is indexed out of range? There are two index operations on the same line. There are also two different uses of the word range, each with different meanings. That could be confusing to a beginner.

Strictly speaking, the parameter square can be any size, but we expect it to be two elements big. I would make this assumption explicit with code through either an unpacking, an assert, or by changing the function signature.

def is_valid_square(self, row, col):
    return row in range(self.BOARD_SIZE) and col in range(self.BOARD_SIZE)

def _generate_first_row(self, color):
    row_by_color = {Color.BLACK: 0, Color.WHITE: self.BOARD_SIZE[0] - 1}
    row = row_by_color[color]

    order = (Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook)
    params = {'color': color, 'row': row}
    return [order[i](col=i, **params) for i in range(self.BOARD_SIZE[0])]

As a small thing, I would change the name to _generate_back_row. I think that is a slightly more clear name. A quick wikipedia search tells me that the exact term to use would be first-rank or back-rank, but that might might not be well enough known.

This function has a lot going on in it. I think this could be simplified a little, taking advantage of the fact there are only two colours. The dictionary lookup and expanding kwargs from a dictionary are overkill (but are both great things to teach, I would leave them in _generate_pawn). The code could look something like

def _generate_back_row(self, color):
    row = 0 if color == Color.BLACK else self.BOARD_SIZE - 1

    order = (Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook)
    return [
        order[i](col=i, row=row, color=color)
        for i in range(self.BOARD_SIZE[0])
    ]

def get_pieces(self, color=None):
    for row in self.board:
        for col in row:
            if col is not None and (color is None or col.color == color):
                yield col

I think the variable col should be named square. What does color=None mean? Get both colours pieces? The feature isn't used anywhere in the code. I think this function should be made simpler, removing the default parameter. I think it would be more reasonable for the code to look like

def get_color_pieces(self, color):
    for row in self.board:
        for square in row:
            if square is not None and square.color == color:
                yield square

def get_possible_moves(self, color, with_king=False):
            """Return all player's possible moves."""

The comment is a little confusing. Which player are we talking about? What does with_king mean? I would have expected all possible moves to include those of the king by default. I would suggest something like below, which flips the default, including the possible king moves, but highlighting that the function can optionally not include them.

def get_possible_moves(self, color, exclude_king=False):
    """Return all possible moves using the pieces of the specified color."""

def is_possible_target(self, board, target):
    is_target_valid = board.is_valid_square(target)
    is_empty_square = board.is_empty_square(target)
    is_hitting_enemy = self.is_enemy(board.get_square(*target))
    return is_target_valid and (is_empty_square or is_hitting_enemy)

This is a good function. The names of the functions it calls make the logic clear and easy to follow. I would consider changing the definition to return is_target_valid and not is_hitting_self, since that would be less work for the computer, but overall this looks really good.

def is_enemy(self, piece):
    if piece is None:
        return False
    return piece.color == Color.enemy_of(self.color)

This could be slightly more obvious by ending with return piece.color != self.color.


def get_valid_moves(self, board):
    for row_change, col_change in self.directions:
        steps = 1
        stop_searching_in_this_direction = False
        while not stop_searching_in_this_direction:
            new_row = self.row + row_change * steps
            new_col = self.col + col_change * steps
            target = (new_row, new_col)
            is_valid_target = self.is_possible_target(board, target)
            if is_valid_target:
                yield target
                steps = steps + 1
                is_hit_enemy = self.is_enemy(board.get_square(*target))
            if not is_valid_target or (is_valid_target and is_hit_enemy):
                stop_searching_in_this_direction = True

I would make some small changes to the logic of this function. It has quite a bit of complexity (3 indents, a yield, and an if statement that directly affects the next if statement), so giving it more space whitespace, and inverting some of the booleans might make it a little cleaner and more importantly, easier to parse.

The first thing to change is to move the inner logic to its own function. This has two benefits, it makes the code a little easier to parse, and it allows the inner logic to stop whenever it needs to, rather than tracking the loop condition explicitly.

def get_valid_moves(self, board):
    for row_change, col_change in self.directions:
        for move in moves_in_a_direction(self, row_change, col_change):
            yield move

def moves_in_a_direction(self, row_change, col_change):
    steps = 1
    stop_searching_in_this_direction = False
    while not stop_searching_in_this_direction:
        new_row = self.row + row_change * steps
        new_col = self.col + col_change * steps
        target = (new_row, new_col)
        is_valid_target = self.is_possible_target(board, target)
        if is_valid_target:
            yield target
            steps = steps + 1
            is_hit_enemy = self.is_enemy(board.get_square(*target))
        if not is_valid_target or (is_valid_target and is_hit_enemy):
            stop_searching_in_this_direction = True

is_hit_enemy is only set in the first if statement, it doesn't even exist before then. I would try and keep the logic to the one place (and change the name to has_hit_enemy, as that would be more accurate). To do this, invert the condition to make it a guard clause

if not is_valid_target:
    return

yield target
steps += 1
has_hit_enemy = ...
...

This facilitates the removal of stop_searching_in_this_direction, as it was only used to stop the loop. Since we can return, it becomes unnecessary.

def moves_in_a_direction(self, row_change, col_change):
    steps = 1
    while True:
        new_row = self.row + row_change * steps
        new_col = self.col + col_change * steps
        target = (new_row, new_col)
        is_valid_target = self.is_possible_target(board, target)
        if not is_valid_target:
            return

        yield target
        steps = steps + 1

        has_hit_enemy = self.is_enemy(board.get_square(*target))
        if has_hit_enemy:
            return

def _get_regular_walk(self):
    src_row, src_col = self.get_position()
    return (src_row + self.forward, src_col)

This looks ok, but src doesn't really mean anything here. I'd say drop it

def _get_regular_walk(self):
    row, col = self.get_position()
    return row + self.forward, col

In fact, since each piece knows its own row and column, why do we need self.get_position() anyway? It might be a candidate for deletion.

def _get_regular_walk(self):
    return self.row + self.forward, self.col

def is_possible_target(self, board, target):
    is_valid_move = board.is_valid_square(target)
    is_step_forward = (
        board.is_empty_square(target)
        and target == self._get_regular_walk()
    )
    is_valid_double_step_forward = (
        board.is_empty_square(target)
        and not self.moved
        and target == self._get_double_walk()
        and self.is_possible_target(board, self._get_regular_walk())
    )
    is_hitting_enemy = (
        self.is_enemy(board.get_square(*target))
        and target in self._get_diagonal_walks()
    )
    return is_valid_move and (
        is_step_forward or is_valid_double_step_forward or is_hitting_enemy
    )

The logic looks good, but it is hard to find it in amongst the code. The more I see is_valid_square, the less I like the name. Consider other names that let you know what the function checks for, such as is_within_bounds or is_inside. I have also noticed that every function which returns a boolean has been prefixed with is_, to an almost pathological degree. There are other prefixes which would be much better suited, like has, can, will, or simply leaving out the prefix. With a guard clause, and changing the prefixes to make more sense, the code might look like

def is_possible_target(self, board, target):
    is_valid_move = board.is_valid_square(target)
    if not is_valid_move:
        return False

    can_step_forward = (
        board.is_empty_square(target)
        and target == self._get_regular_walk()
    )

    can_double_step_forward = (
        can_step_forward and
        not self.moved and
        board.is_empty_square(target) and
        target == self._get_double_walk()
    )

    can_capture = (
        self.is_enemy(board.get_square(*target))
        and target in self._get_diagonal_walks()
    )

    return can_step_forward or can_double_step_forward or can_capture

class King(WalksStraightMixin, WalksDiagonallyMixin, Piece):
    def _get_threatened_squares(self, board):
        enemy = Color.enemy_of(self.color)
        enemy_moves = list(board.get_possible_moves(enemy, with_king=False))
        enemy_pieces = board.get_pieces(color=enemy)
        king = next(p for p in enemy_pieces if isinstance(p, King))
        for move in king.get_squares_threatens(board):
            yield move
        for move in enemy_moves:
            yield move

This is okay, but not as clear as it could be. Rearranging the lines and renaming king to enemy king improves the code.

class King(WalksStraightMixin, WalksDiagonallyMixin, Piece):
    def _get_threatened_squares(self, board):
        enemy = Color.enemy_of(self.color)
        enemy_moves = list(board.get_possible_moves(enemy, exclude_king=True))
        for move in enemy_moves:
            yield move

        enemy_pieces = board.get_pieces(color=enemy)
        enemy_king = next(p for p in enemy_pieces if isinstance(p, King))
        for move in enemy_king.get_squares_threatens(board):
            yield move

But this brings up the question of "Why is the enemy king treated differently?" Surely it is just another enemy piece that has a set of possible moves, each of which threatens this king? If there is something of note here, a comment explaining it would be helpful.

\$\endgroup\$
3
  • 10
    \$\begingroup\$ You are awesome. Thank you for the non-trivial amount of effort and the good spirit. I'm sure I'll take many of your advice. \$\endgroup\$
    – Infinity
    Commented Jul 6, 2020 at 1:50
  • \$\begingroup\$ Will you ever have a non-square board? Will US zip codes ever be more than 5 digits? Will there ever be a need to specify century in dates? Will a person or place name ever be more than arbitrary number here characters? Will the customer ever want empty number fields to be null instead of zero? Will a social security number ever be legitimately reused? \$\endgroup\$
    – radarbob
    Commented Jul 10, 2020 at 17:54
  • \$\begingroup\$ @radarbob Zipcodes can be nine digits long, called ZIP+4's, used when specifying a street address or Post Office Box. \$\endgroup\$
    – Linny
    Commented Jul 11, 2020 at 20:59
19
\$\begingroup\$

Since @spyr03's extensive (and awesome) answer did not include it, here are some small comments.

You want this to be an example for your students of how code should look like. You should include a docstring with every class, method and function to detail what it does and what it's arguments and return value are. Although your code is rather self-documenting, this sets the precedent for when they write their own code. If you consistently do it (and require it of them), some may learn to do it.

Don't forget to teach them about writing (good) tests either, at least eventually. Especially the pieces would be a good easy candidate for tests. They have complex non-trivial behavior which you could mess up when changing something, so having full test coverage on them would be very helpful.

On a practical note, I was slightly surprised when I came to the Pawn class. First, you define these nice mixins for the movement types. But then, the Pawn class does not to use any of them! I understand that the pawn is probably the first piece you want to define, and also that it is a bit hard to use the mixins in this case, but I would consider if it wouldn't be better to start with a piece that actually uses one of the mixins. Or define them later, when you actually need them.

\$\endgroup\$
14
\$\begingroup\$

When I think back to my student days, the most crucial point for me to understand code was always the entry point. In my experience, it takes a lot of experience to understand a code concept as a whole. The untrained is used to step by step thinking and evaluation through step by step progression. I would not have understood that code because it describes the game and does not PLAY the game. I understood, that the code is not meant to play. But a clearly marked start() function initializing the board and doing some sample moves so the student can see and visualize how the code come together and what it can actually do, would help a lot. At least it would have helped me.

\$\endgroup\$
1
  • 3
    \$\begingroup\$ "Yes, and..." If you take that code that "initializes the board and does some sample moves" and then add a few asserts at the end — ta-da, you have your first unit test! Unit tests can help the reader understand the behavior of the code, and its intended usage, and serve as, well, tests, all at the same time. Highly recommended. \$\endgroup\$ Commented Jul 7, 2020 at 3:55
7
\$\begingroup\$

One thing I don't see mentioned in spyr03's excellent review: I think it's unnecessarily inconsistent (and thus confusing) for you to use mixin classes to implement 100% of the get_valid_moves routine for Rook, Bishop, and Queen, but then reuse only half of that code for King (and open-code the other half). If you're going to write

class Queen(
    WalksStraightMixin, WalksDiagonallyMixin, WalksMultipleStepsMixin, Piece,
):                                            #######################
    def __str__(self):
        if self.color == Color.WHITE:
            return '♕'
        return '♛'

then you should also write

class King(
    WalksStraightMixin, WalksDiagonallyMixin, WalksSingleStepMixin, Piece,
):                                            ####################

It's gratuitously confusing to have WalksStraightMixin and WalksDiagonallyMixin set values into self.directions that are then read by King itself. This is a tightly coupled dependency between the mixins and the implementation of King; consider how many places in the code you'd have to change if you wanted to rename directions to possibleDirections, or something like that.


In real life, btw, I would consider your mixin idea to be much too complicated. We could "keep it simple" by manually implementing get_valid_moves for each class individually:

class Piece:
    straight_directions = [...]
    diagonal_directions = [...]
    all_directions = straight_directions + diagonal_directions
    def get_single_step_moves_impl(directions): ...
    def get_multistep_moves_impl(directions): ...

class Rook(Piece):
    def get_valid_moves(self):
        return self.get_multistep_moves_impl(Piece.straight_directions)

class Queen(Piece):
    def get_valid_moves(self):
        return self.get_multistep_moves_impl(Piece.all_directions)

class King(Piece):
    def get_valid_moves(self):
        return self.get_single_step_moves_impl(Piece.all_directions)

Here, rather than inheriting from mixins — that might conceivably affect the behavior of the whole class — we limit our "different" effects to the smallest possible scope. The difference between Queen's use of get_multistep_moves_impl and King's use of get_single_step_moves_impl is clearly restricted to get_valid_moves only; Queen and King clearly don't differ in anything other than the behavior of get_valid_moves (not as presented above, anyway). This limitation-of-possible-effects makes it easier for the reader to reason about the code.

\$\endgroup\$
3
\$\begingroup\$

It has been some time since I asked the question. I used your advice to improve the code and gave it as an exercise to my students. It's been a tremendous success.

I will detail some of the improvements as a follow-up answer to my question. Thank you for the excellent answers. What a great community :)

  1. I have added docstrings to all the functions & classes.

  2. is_valid_square follows the conventions in the code: 2 parameters, one for a row and one for a column, instead of a single tuple. It also uses two variables that store two booleans to make it easier to debug the function.

    Old:

        def is_valid_square(self, square):
            return (
                square[0] in range(self.BOARD_SIZE[0])
                and square[1] in range(self.BOARD_SIZE[1])
            )
    

    New:

        def is_valid_square(self, row, column):
            """Return True if square in board bounds, False otherwise."""
            row_exists = row in range(self.BOARD_SIZE[0])
            column_exists = column in range(self.BOARD_SIZE[1])
            return row_exists and column_exists
    
  3. The name of generate_first_row changed to generate_back_row.

  4. The piece now contains the attributes moved and direction. I found moved might make it easier to manage pawns/castling using this data, and I think it might benefit future legendary pieces. Creating direction as an empty set on the instance's initiation makes it much easier to manage and inherit from.

  5. is_enemy updated to the suggestion of @spyr03:

    Old:

        def is_enemy(self, piece):
            if piece is None:
                return False
            return piece.color == Color.enemy_of(self.color)
    
        def is_enemy(self, piece):
            """Return if the piece belongs to the opponent."""
            if piece is None:
                return False
            return piece.color != self.color
    
  6. I have added a get_squares_threatens to simplify pieces that have a different way of hitting other pieces (e.g., pawns):

        def get_squares_threatens(self, board):
            """Get all the squares which this piece threatens.
    
            This is usually just where the piece can go, but sometimes
            the piece threat squares which are different from the squares
            it can travel to.
            """
            for move in self.get_valid_moves(board):
                yield move
    
  7. I've changed the order of the classes to highlight the use of the mixins. The Rook and the Queen are now defined before the Pawn.

  8. I've added get_squares_threatens to the Piece parent class. The King uses the class to check if it can travel to a specific square. It dramatically simplifies the _get_threatened_squares method.

There are probably some additional improvements I forgot to mention, so with this attached the updated code :)

from abc import ABC, abstractmethod


class Color:
    """Describe the game pieces' color"""
    BLACK = 0
    WHITE = 1

    def enemy_of(color):
        """Return the opponent color."""
        if color == Color.BLACK:
            return Color.WHITE
        return Color.BLACK


class Board:
    """Create and maintain the game board."""

    # Some functions below will not work well with altered board size.
    BOARD_SIZE = (8, 8)

    def __init__(self):
        self.reset()

    def get_square(self, row, col):
        """Return the game piece by its position on board.

        If there is no piece in this position, or if the position does
        not exist - return False.
        """
        if self.is_valid_square(row, col):
            return self.board[row][col]

    def set_square(self, row, col, piece):
        """Place piece on board."""
        self.board[row][col] = piece

    def is_valid_square(self, row, column):
        """Return True if square in board bounds, False otherwise."""
        row_exists = row in range(self.BOARD_SIZE[0])
        column_exists = column in range(self.BOARD_SIZE[1])
        return row_exists and column_exists

    def is_empty_square(self, square):
        """Return True if square is unoccupied, False otherwise.

        An empty square is a square which has no game piece on it.
        If the square is out of board bounds, we consider it empty.
        """
        return self.get_square(*square) is None

    def _generate_back_row(self, color):
        """Place player's first row pieces on board."""
        row_by_color = {Color.BLACK: 0, Color.WHITE: self.BOARD_SIZE[0] - 1}
        row = row_by_color[color]

        order = (Rook, Knight, Bishop, Queen, King, Bishop, Knight, Rook)
        params = {'color': color, 'row': row}
        return [order[i](col=i, **params) for i in range(self.BOARD_SIZE[0])]

    def _generate_pawns_row(self, color):
        """Place player's pawns row on board."""
        row_by_color = {Color.BLACK: 1, Color.WHITE: self.BOARD_SIZE[0] - 2}
        row = row_by_color[color]
        params = {'color': color, 'row': row}
        return [Pawn(col=i, **params) for i in range(self.BOARD_SIZE[0])]

    def get_pieces(self, color=None):
        """Yield the player's pieces.

        If color is unspecified (None), yield all pieces on board.
        """
        for row in self.board:
            for square in row:
                if square is not None and (color in (None, square.color)):
                    yield square

    def reset(self):
        """Set traditional board and pieces in initial positions."""
        self.board = [
            self._generate_back_row(Color.BLACK),
            self._generate_pawns_row(Color.BLACK),
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            [None] * self.BOARD_SIZE[0],
            self._generate_pawns_row(Color.WHITE),
            self._generate_back_row(Color.WHITE),
        ]

    def move(self, source, destination):
        """Move a piece from its place to a designated location."""
        piece = self.get_square(*source)
        return piece.move(board=self, destination=destination)

    def __str__(self):
        """Return current state of the board for display purposes."""
        printable = ""
        for row in self.board:
            for col in row:
                if col is None:
                    printable = printable + " ▭ "
                else:
                    printable = printable + f" {col} "
            printable = printable + '\n'
        return printable


class Piece(ABC):
    """Represent a general chess piece."""

    def __init__(self, color, row, col, **kwargs):
        super().__init__(**kwargs)
        self.color = color
        self.row = row
        self.col = col
        self.moved = False
        self.directions = set()

    def is_possible_target(self, board, target):
        """Return True if the move is legal, False otherwise.

        A move is considered legal if the piece can move from its
        current location to the target location.
        """
        is_target_valid = board.is_valid_square(*target)
        is_empty_square = board.is_empty_square(target)
        is_hitting_enemy = self.is_enemy(board.get_square(*target))
        return is_target_valid and (is_empty_square or is_hitting_enemy)

    @abstractmethod
    def get_valid_moves(self, board):
        """Yield the valid target positions the piece can travel to."""
        pass

    def get_position(self):
        """Return piece current position."""
        return self.row, self.col

    def is_enemy(self, piece):
        """Return if the piece belongs to the opponent."""
        if piece is None:
            return False
        return piece.color != self.color

    def move(self, board, destination):
        """Change piece position on the board.

        Return True if the piece's position has successfully changed.
        Return False otherwise.
        """
        if not self.is_possible_target(board, destination):
            return False
        if destination not in self.get_valid_moves(board):
            return False

        board.set_square(*self.get_position(), None)
        board.set_square(*destination, self)
        self.row, self.col = destination
        self.moved = True
        return True

    def get_squares_threatens(self, board):
        """Get all the squares which this piece threatens.

        This is usually just where the piece can go, but sometimes
        the piece threat squares which are different than the squares
        it can travel to.
        """
        for move in self.get_valid_moves(board):
            yield move

    @abstractmethod
    def __str__(self):
        pass


class WalksDiagonallyMixin:
    """Define diagonal movement on the board.

    This mixin should be used only in a Piece subclasses.
    Its purpose is to add possible movement directions to a specific
    kind of game piece.
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.directions.update({
            (-1, -1),          (1, -1),

            (-1,  1),          (1,  1),
        })


class WalksStraightMixin:
    """Define straight movement on the board.

    This mixin should be used only in a Piece subclasses.
    Its purpose is to add possible movement directions to a specific
    kind of game piece.
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.directions.update({
                      (0, -1),
            (-1,  0),          (1,  0),
                      (0,  1),
        })


class WalksMultipleStepsMixin:
    """Define a same-direction, multiple-step movement on the board.

    This mixin should be used only on a Piece subclasses.
    Its purpose is to allow a piece to travel long distances based on a
    single-step pattern.

    For example, the bishop can move diagonally up to 7 squares per
    turn (in an orthodox chess game). This mixin allows it if the
    `directions` property is set to the 4 possible diagonal steps. It
    does so by overriding the get_valid_moves method and uses the
    instance `directions` property to determine the possible step for
    the piece.
    """

    def get_valid_moves(self, board, **kwargs):
        """Yield the valid target positions the piece can travel to."""
        for row_change, col_change in self.directions:
            steps = 1
            stop_searching_in_this_direction = False
            while not stop_searching_in_this_direction:
                new_row = self.row + row_change * steps
                new_col = self.col + col_change * steps
                target = (new_row, new_col)
                is_valid_target = self.is_possible_target(board, target)
                if is_valid_target:
                    yield target
                    steps = steps + 1
                    is_hit_enemy = self.is_enemy(board.get_square(*target))
                if not is_valid_target or (is_valid_target and is_hit_enemy):
                    stop_searching_in_this_direction = True


class Bishop(WalksDiagonallyMixin, WalksMultipleStepsMixin, Piece):
    """A classic Bishop chess piece.

    The bishop moves any number of blank squares diagonally.
    """
    def __str__(self):
        if self.color == Color.WHITE:
            return '♗'
        return '♝'


class Rook(WalksStraightMixin, WalksMultipleStepsMixin, Piece):
    """A classic Rook chess piece.

    The rook moves any number of blank squares straight.
    """
    def __str__(self):
        if self.color == Color.WHITE:
            return '♖'
        return '♜'


class Queen(
    WalksStraightMixin, WalksDiagonallyMixin, WalksMultipleStepsMixin, Piece,
):
    """A classic Queen chess piece.

    The queen moves any number of blank squares straight or diagonally.
    """
    def __str__(self):
        if self.color == Color.WHITE:
            return '♕'
        return '♛'


class Pawn(Piece):
    """A classic Pawn chess piece.

    A pawn moves straight forward one square, if that square is empty.
    If it has not yet moved, a pawn also has the option of moving two
    squares straight forward, provided both squares are empty.
    Pawns can only move forward.

    A pawn can capture an enemy piece on either of the two squares
    diagonally in front of the pawn. It cannot move to those squares if
    they are empty, nor to capture an enemy in front of it.

    A pawn can also be involved in en-passant or in promotion, which is
    yet to be implemented on this version of the game.
    """
    DIRECTION_BY_COLOR = {Color.BLACK: 1, Color.WHITE: -1}

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.forward = self.DIRECTION_BY_COLOR[self.color]

    def _get_regular_walk(self):
        """Return position after a single step forward."""
        return self.row + self.forward, self.col

    def _get_double_walk(self):
        """Return position after a double step forward."""
        src_row, src_col = self.get_position()
        return (src_row + self.forward * 2, src_col)

    def _get_diagonal_walks(self):
        """Returns position after a diagonal move.

        This only happens when hitting an enemy.
        It could also happen on "en-passant", which is
        unimplemented feature for now.
        """
        src_row, src_col = self.get_position()
        return (
            (src_row + self.forward, src_col + 1),
            (src_row + self.forward, src_col - 1),
        )

    def is_possible_target(self, board, target):
        """Return True if the Pawn's move is legal, False otherwise.

        This one is a bit more complicated than the usual case.
        Pawns can only move forward. They also can move two ranks
        forward if they have yet to move. Not like the other pieces,
        pawns can't hit the enemy using their regular movement. They
        have to hit it diagonally, and can't take a step forward if the
        enemy is just in front of them.
        """
        is_valid_move = board.is_valid_square(*target)
        is_step_forward = (
            board.is_empty_square(target)
            and target == self._get_regular_walk()
        )
        is_valid_double_step_forward = (
            board.is_empty_square(target)
            and not self.moved
            and target == self._get_double_walk()
            and self.is_possible_target(board, self._get_regular_walk())
        )
        is_hitting_enemy = (
            self.is_enemy(board.get_square(*target))
            and target in self._get_diagonal_walks()
        )
        return is_valid_move and (
            is_step_forward or is_valid_double_step_forward or is_hitting_enemy
        )

    def get_squares_threatens(self, board, **kwargs):
        """Get all the squares which the pawn can attack."""
        for square in self._get_diagonal_walks():
            if board.is_valid_square(*square):
                yield square

    def get_valid_moves(self, board, **kwargs):
        """Yield the valid target positions the piece can travel to.

        The Pawn case is a special one - see is_possible_target's
        documentation for further details.
        """
        targets = (
            self._get_regular_walk(),
            self._get_double_walk(),
            *self._get_diagonal_walks(),
        )
        for target in targets:
            if self.is_possible_target(board, target):
                yield target

    def __str__(self):
        if self.color == Color.WHITE:
            return '♙'
        return '♟'


class Knight(Piece):
    """A classic Knight chess piece.

    Can travel to the nearest square not on the same rank, file, or
    diagonal. It is not blocked by other pieces: it jumps to the new
    location.
    """
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.directions.update({
            (-2, 1), (-1, 2), (1, 2), (2, 1),  # Upper part
            (-2, -1), (-1, -2), (1, -2), (2, -1),  # Lower part
        })

    def get_valid_moves(self, board, **kwargs):
        super().get_valid_moves(board, **kwargs)
        for add_row, add_col in self.directions:
            target = (add_row + self.row, add_col + self.col)
            if self.is_possible_target(board, target):
                yield target

    def __str__(self):
        if self.color == Color.WHITE:
            return '♘'
        return '♞'


class King(WalksStraightMixin, WalksDiagonallyMixin, Piece):
    """A classic King chess piece.

    Can travel one step, either diagonally or straight.
    It cannot travel to places where he will be threatened.
    """

    def _get_threatened_squares(self, board):
        """Yield positions in which the king will be captured."""
        enemy = Color.enemy_of(self.color)
        for piece in board.get_pieces(color=enemy):
            for move in piece.get_squares_threatens(board):
                yield move

    def is_possible_target(self, board, target):
        """Return True if the king's move is legal, False otherwise.

        The king should not move to a square that the enemy threatens.
        """
        is_regular_valid = super().is_possible_target(board, target)
        threatened_squares = self._get_threatened_squares(board)
        return is_regular_valid and target not in threatened_squares

    def get_valid_moves(self, board, **kwargs):
        super().get_valid_moves(board, **kwargs)
        for add_row, add_col in self.directions:
            target = (add_row + self.row, add_col + self.col)
            if self.is_possible_target(board, target):
                yield target

    def get_squares_threatens(self, board):
        """Get all the squares that this piece may move to.

        This method is especially useful to see if other kings fall
        into this piece's territory. To prevent recursion, this
        function returns all squares we threat even if we can't go
        there.

        For example, take a scenario where the White Bishop is in B2,
        and the Black King is in B3. The White King is in D3, but it is
        allowed to go into C3 to threaten the black king if the white
        bishop protects it.
        """
        for direction in self.directions:
            row, col = self.get_position()
            row = row + direction[0]
            col = col + direction[1]
            if board.is_valid_square(row, col):
                yield (row, col)

    def __str__(self):
        if self.color == Color.WHITE:
            return '♔'
        return '♚'
```
\$\endgroup\$
3
  • \$\begingroup\$ If the black king is on B3, the white king is not allowed to go on C3. \$\endgroup\$ Commented Dec 5, 2020 at 8:28
  • \$\begingroup\$ That's good. This is how chess works :) \$\endgroup\$
    – Infinity
    Commented Dec 5, 2020 at 14:23
  • \$\begingroup\$ Yet your documentation of get_squares_threatens says that the white king may go on C3. The king is not allowed to go there, even if it "looks" protected by the bishop. \$\endgroup\$ Commented Dec 5, 2020 at 21:12

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.