DjangoCon: The One True way to do cons. |
Now that I'm back from DjangoCon Europe 2013 (which was awesome), I get
back to writing about design patterns.
A plain maze game
Recently I've promised you a text game in Python, which would implement the Abstract Factory pattern. The example is taken from the GoF book.
The Maze Game |
Maze Construction
To construct a Maze we'll need methods to add some rooms and to get them by number:class Maze(object): """Maze is a container for rooms.""" def __init__(self): self.rooms = {} def add_room(self, room): """Add a room.""" self.rooms[room.room_number] = room def get_room_by_number(self, room_number): """Get room by its number.""" return self.rooms[room_number]Here's our abstract MapSite:
class MapSite(object): """Just an abstract for maze elements.""" passIn my implementation the sides of a Room are represented by a _sides dictionary with four keys: Direction.NORTH, SOUTH and so on. A Room has also a room_number.
class Room(MapSite): """Represents a room with four sides - north, south, east and west - and a room number.""" def __init__(self, room_number): super(Room, self).__init__() self._sides = { Direction.NORTH: None, Direction.SOUTH: None, Direction.EAST: None, Direction.WEST: None } self.room_number = room_number def get_side(self, direction): """Return a MapSite object for given direction.""" return self._sides[direction] def set_side(self, direction, mapsite_object): """Set a MapSite object for given direction.""" self._sides[direction] = mapsite_object
As said before, each side of a Room can be a Door or a Wall. There's nothing special about the Wall:
class Wall(MapSite): """Represents a Wall - you can't do anything with it.""" passThe Door class is slightly more complicated. It can be opened or closed, we also want to store the information about the Room objects that it leads to. There's also a helper function other_side_from, which - given a room object - simply returns what's on the other side of the door.
class Door(MapSite): """A door joins two rooms and it's got two states: open and closed.""" def __init__(self, room1, room2, is_open=False): super(Door, self).__init__() self._open = is_open self._rooms = { room2.room_number: room1, # from room 2 we can get to the room 1 room1.room_number: room2 # from room 1 we can get to the room 2 } def other_side_from(self, room): """Return the Room object from the other side of the door.""" return self._rooms[room.room_number] @property def is_open(self): """Returns True if the door is opened, False otherwise.""" return self._open def unlock(self): self._open = True print "The door is now unlocked!"
Sample Maze creation
OK, so now let's say we want to create a Maze with two Rooms and one Door. Seems fairly simple, but unfortunately requires quite a lot operations to be made.# simple Maze, requires lots of code from maze import Maze, Room, Door, Direction, Wall def create_maze(): """Series of operations which create our Maze.""" maze = Maze() room1 = Room(1) room2 = Room(2) thedoor = Door(room1, room2) maze.add_room(room1) maze.add_room(room2) room1.set_side(Direction.NORTH, Wall()) room1.set_side(Direction.EAST, thedoor) room1.set_side(Direction.SOUTH, Wall()) room1.set_side(Direction.WEST, Wall()) room2.set_side(Direction.NORTH, Wall()) room2.set_side(Direction.EAST, Wall()) room2.set_side(Direction.SOUTH, Wall()) room2.set_side(Direction.WEST, thedoor) return maze
Moving inside a Maze
To move inside our Maze we could define a method enter(player) in the MapSite class and implement it in all it's inheriting classes. The Player class represents our position in the Maze, so the enter() method would change the player's position. A sample player session could look like this:$ python basic_creator.py --> Player Pinky enters the maze in room WEST SIDE: maze.Wall object at 0xb744550c EAST SIDE: maze.Door object at 0xb744f7cc NORTH SIDE: maze.Wall object at 0xb74454cc SOUTH SIDE: maze.Wall object at 0xb74454ec --> Pinky enters room: 2 WEST SIDE: maze.Wall object at 0xb744550c EAST SIDE: maze.Door object at 0xb744f7cc NORTH SIDE: maze.Wall object at 0xb74454cc SOUTH SIDE: maze.Wall object at 0xb74454ecAbstract Factory is about creating objects so I won't post details about Player's movement here, but you can find the sample implementation at my github - the link is at the end of this post.
The Enchanted Maze comes in
Wotta handsome guy. |
Let's assume that there's also another type of Maze - an enchanted Maze. The Player turns into a Wizard, the Room is now an EnchantedRoom, the Door becomes a DoorNeedingSpell.
The Wizard is an enhanced Player, who can simply cast spells.
EnchantedRoom means that if somebody comes in, then he or she gets under the spell (let's hope there's no Avada Kedavra room!).
class EnchantedRoom(Room): """When the room is enchanted, it casts the spell on the player.""" def __init__(self, room_number, spell): super(EnchantedRoom, self).__init__(room_number) self.spell = spell
DoorNeedingSpell is a separate thing - it means that a Door cannot be opened until a certain spell is cast by a Wizard. It has nothing to do with the spell in EnchantedRoom.
class DoorNeedingSpell(Door): """This door can't be opened manually - a wizard has to cast a spell on it.""" def __init__(self, room1, room2): super(DoorNeedingSpell, self).__init__(room1, room2) def unlock(self, with_spell=False): """Unlocking without a spell doesn't work.""" if not with_spell: print "You need to cast a spell first." else: super(DoorNeedingSpell, self).unlock()
Moving inside an enchanted Maze
Similarly to the plain Maze, we could define a method enter(player) in the MapSite class and just like in Maze implement it in all it's inheriting classes. Wizard derives from Player class, so there's no large difference either. A wizard-player session could look like this:---> Wizard Harry enters the enchanted maze in room 1 WEST SIDE: maze.Wall object at 0xb74cc96c EAST SIDE: enchanted_maze.DoorNeedingSpell object at 0xb74cc92c NORTH SIDE: maze.Wall object at 0xb74cc94c SOUTH SIDE: maze.Wall object at 0xb74cc8ec Harry casts a spell: Alohomora! The door is now unlocked! --> Harry enters room: 2 Harry is under the spell Expelliarmus WEST SIDE: maze.Wall object at 0xb74cc96c EAST SIDE: enchanted_maze.DoorNeedingSpell object at 0xb74cc92c NORTH SIDE: maze.Wall object at 0xb74cc94c SOUTH SIDE: maze.Wall object at 0xb74cc8ecThe sample enchanted Maze movement implementation is also at my github.
Sample enchanted Maze
If you're still here and you're wondering what's my point, then here it is. If we wanted to create tons of different Mazes - an enchanted Maze, a plain Maze, maybe a bombed Maze or underwater Maze with the same structure as the plain Maze, we'd have to almost copy-paste the code from the create_maze() function I've shown you before.
E.g. for enchanted Maze:
# enchanted Maze, copy-paste from plain Maze from maze import Direction, Wall, Maze from enchanted_maze import EnchantedRoom, DoorNeedingSpell def create_enchanted_maze(): """Series of operations which create our Maze.""" maze = Maze() room1 = EnchantedRoom(1) room2 = EnchantedRoom(2) themagicdoor = DoorNeedingSpell(room1, room2) maze.add_room(room1) maze.add_room(room2) room1.set_side(Direction.NORTH, Wall()) room1.set_side(Direction.EAST, themagicdoor) room1.set_side(Direction.SOUTH, Wall()) room1.set_side(Direction.WEST, Wall()) room2.set_side(Direction.NORTH, Wall()) room2.set_side(Direction.EAST, Wall()) room2.set_side(Direction.SOUTH, Wall()) room2.set_side(Direction.WEST, themagicdoor) return maze
And so on.
The meat and potatoes
To prevent this we'll create some Maze factories to standardise the creation process. The basic structure will be like this:
class BasicMazeFactory(object): """Factory producing basic Maze.""" @staticmethod def make_maze(): return Maze() @staticmethod def make_wall(): return Wall() @staticmethod def make_room(room_number): return Room(room_number) @staticmethod def make_door(room1, room2): return Door(room1, room2)
Static methods in BasicMazeFactory
I've made the factory's method static, because the factory itself doesn't use it's contents to instantiate the Maze parts. This can change of course if we want to pass some variable when initializing the BasicMazeFactory, e.g. maximum number of rooms. This could look like:class LimitedMazeFactory(BasicMazeFactory): def __init__(self, max_rooms): self._rooms_left = max_rooms def make_room(self, room_number): if self._rooms_left > 0: self._rooms_left -= 1 return Room(room_number) else: print "Maximum room number reached - you can't add any more Rooms!"
class EnchantedMazeFactory(BasicMazeFactory): """Factory producting Enchanted Maze.""" @staticmethod def make_room(room_number): return EnchantedRoom(room_number, spell="Expelliarmus") @staticmethod def make_door(room1, room2): return DoorNeedingSpell(room1, room2)To use the factories as interchangeable components we need to redefine our create_maze() function. From now on it'll take a factory class as a parameter.
def create_maze(factory): """Series of operations which create our Maze.""" maze = factory.make_maze() room1 = factory.make_room(1) room2 = factory.make_room(2) thedoor = factory.make_door(room1, room2) maze.add_room(room1) maze.add_room(room2) room1.set_side(Direction.NORTH, factory.make_wall()) room1.set_side(Direction.EAST, thedoor) room1.set_side(Direction.SOUTH, factory.make_wall()) room1.set_side(Direction.WEST, factory.make_wall()) room2.set_side(Direction.NORTH, factory.make_wall()) room2.set_side(Direction.EAST, factory.make_wall()) room2.set_side(Direction.SOUTH, factory.make_wall()) room2.set_side(Direction.WEST, thedoor) return maze
The final solution
So finally, how do we create out mazes now? It's very simple:
basic_maze = create_maze(BasicMazeFactory) enchanted_maze = create_maze(EnchantedMazeFactory)And that's it! We've created a reusable function to create our mazes, yay!
The code and references
If you want to see my full code, here's my github: abstract factory implementation.
If you're interested with this topic you can see the GoF book (full PDF), which is my main source of examples and explanations. The Abstract Factory pattern is on the page 99.
Any comments, suggestions or corrections are welcome.