Design Patterns in Ruby - The Abstract Factory
In my last article I introduced this series and here’s the first pattern: the Abstract Factory
The app
At first I didn’t know exactly what to build but, thankfully, the authors of Design Patterns furnished me with a good idea from their own example: a text based game where we traverse randomly generated dungeons. The player will input their commands from a list of options and will traverse different rooms that might have traps, treasures, enchantments, and whatever other nicities our hearts might desire.
For starters, I just want to be able to create a ruby module where I can say DungeonMaker.new_dungeon
and it will give me a pristine new dungueon for my player to just enter and begin his adventure.
Not all rooms are created equal
So, clearly I have the following situation: I want to generate, in a random manner, different kinds of rooms that will have, on each side (for now, we deal with four) either a wall or a door, also these of different kinds.
We need to be more explicit, though. What are all possible kinds of rooms, doors and walls? For now I’ll go with three:
- Trapped
- Enchanted
- Ordinary
A trapped entity, when activated, will inflict a certain negative effect (ideally a random effect picked from a list of possible effects). An enchanted one will cast a spell, which can either be good or bad. Finally, ordinary entities have a chance at hidden items or treasure.
I’ll first try to do it in a straightforward fashion, so that the problem will manifest itself. It is when the dragon rears it’s ugly head that we must strike. Once we see what the problem is, then I’ll present the solution.
Finding the dragon’s lair
So, first, I want my API to be simple. I want whoever uses my code (which will be a gem) to be able to just say DungeonMaker.new_dungeon
and they’ll get a new dungeon, fully set up with random rooms and the whole deal. Optionally, they can pass the number of rooms they want in their dungeon (default will be 10).
So I start by creating my dungeon_maker
gem:
$ bundler gem dungeon_maker
After this, in order to be able to build the gem with bundler, you’ll need to configure some required options in the .gemspec
file. I won’t go through it here since this information is readily accessible on the internet. Either way, once you cd
into the project, this is what the file tree will look like:
Now, what I’ll I want to do is loop through the number of rooms and, for each room, instantiate a room of a random type and give for each of it’s sides either a wall or a door of a random type as well. Seems quite convoluted, and it is. I bet there are maybe better algorithms than this, but I just want to illustrate the problem of instantiating different types of objects, not the best algorithm for assembling randomly generated dungeons. Not for now, at least.
So, first order of business is to add our new_dungeon
method to our DungeonMaker
module in dungeon_maker.rb
:
# frozen_string_literal: true
require_relative "dungeon_maker/version"
module DungeonMaker
def self.new_dungeon(number_of_rooms: 10)
end
end
Now we need a way to store our types of objects so that they can be retrieved in a random fashion when we want. Ruby’s Array
class has a #sample
method that is perfect for this, so I’ll just make a simple array of the object types I want and create the dungeon
object while I’m at it:
# frozen_string_literal: true
require_relative "dungeon_maker/version"
module DungeonMaker
def self.new_dungeon(number_of_rooms: 10)
types = [:trapped, :enchanted, :ordinary]
dungeon = Dungeon.new(number_of_rooms)
end
end
You might be asking yourself, like I did, “Hey! Where’d you get that Dungeon
class, mister!? That’s cheating!”. Not quite, Timmy. Whenever I can I write my code this way, I do, because it helps me ignore matters of how to implement things and focus on what I want my program to do. I’ll worry about the how tos and the why fors later.
I might need to change these constructors, but I don’t want implementation detail to guide me on how my algorithm works. Rather, I want my algorithm to inform (in the original sense of the word ) my implementation.
Moving on.
So now I want to loop through the dungeon’s rooms and make them be of any random type:
NOTE: I use #map! because I think it’s more readable. It’s not usually the preferred way of doing things but, since this is the only place I modify this dungeon, it should be fine
# frozen_string_literal: true
require_relative "dungeon_maker/version"
module DungeonMaker
def self.new_dungeon(number_of_rooms: 10)
types = [:trapped, :enchanted, :ordinary]
dungeon = Dungeon.new(number_of_rooms)
dungeon.rooms.map! do |room|
type = types.sample
room = case type
when :trapped
TrappedRoom.new
when :enchanted
EnchantedRoom.new
when :ordinary
OrdinaryRoom.new
else
OrdinaryRoom.new
end
end
end
end
Pretty straightforward code, right? I sample one of the types, match against it and instantiate the kind of room that I want. The else clause is there for completeness, but it should never be run. If it is, however, I just make an OrdinaryRoom
. One possibility is to make some BuggyRoom
type just so you know this weirdness is going on. Wouldn’t know if players would like it, but hey, there are no bugs, just happy little features.
Finally, we loop through the rooms’ sides and do the same deal as before, except we want to randomly assign a wall or a door. Thankfully, Ruby’s got our back again with Kernel#rand
, so we get:
# frozen_string_literal: true
require_relative "dungeon_maker/version"
module DungeonMaker
def self.new_dungeon(number_of_rooms: 10)
types = [:trapped, :enchanted, :ordinary]
dungeon = Dungeon.new(number_of_rooms)
dungeon.rooms.map! do |room|
type = types.sample
room = case type
when :trapped
TrappedRoom.new
when :enchanted
EnchantedRoom.new
when :ordinary
OrdinaryRoom.new
else
OrdinaryRoom.new
end
room.sides.map! do |side|
make_wall = rand() >= 0.5
if make_wall
type = types.sample
side = case type
when :trapped
TrappedWall.new
when :enchanted
EnchantedWall.new
when :ordinary
OrdinaryWall.new
else
OrdinaryWall.new
end
else
type = types.sample
side = case type
when :trapped
TrappedDoor.new
when :enchanted
EnchantedDoor.new
when :ordinary
OrdinaryDoor.new
else
OrdinaryDoor.new
end
end
end
room
end
dungeon
end
end
Done. Well, of course, we still need to actually write all those classes, but we don’t need to write them to spot the dragon that has emerged from the depths called complexity.
First problem is this code isn’t as readable as it could be due to all of the case
statements. Second, and this is the actual problem, suppose one day we decide to add 3 new types of doors, 7 new types of walls, 2 new types of rooms and, God forbid, we decide we want our rooms to have any number of sides between 3 and 8 and that sides can have both doors and walls. You’d have cases within if else statements nested under more cases. The dragon is real and is rearing it’s head. Time to pull out our sword engraved with the powerful Abstract Factory
enchantment.
NOTE: The next code snippets will already have the changes needed due to the addition of the necessary
Door
,Wall
, andRoom
classes. If you want to see how these were added and required, checkout the commits for the project
How our sword is forged
So the problem is laid before us: we have different kinds of objects that need to be instantiated and we want our program to be able to, at runtime, randomly select a type of object to be created without polluting our algorithm’s logic with how these objects are instantiated.
The pattern that solves this pickle is the Abstract Factory. It will define a common interface (methods that our program can call) for our DungeonMaker
to interact with and we’ll move the logic concerning which kind of objects to instantiate elsewhere.
On the other side of the Abstract Factory we have our concrete implementations. So, in our case, Abstract Factory can be a class that defines methods for making rooms, doors and walls and we create three different concrete factories, one for each type of object we want, that will implement each of these methods:
TrappedDungeonFactory
EnchantedDungeonFactory
OrdinaryDungeonFactory
Classically, and if you read the Design Patterns book they illustrate it this way, we’d inject the concrete factory we want at runtime and our program would use that implementation only. In our case, we have an extra twist where we want to mix and match the different possible kinds (Hmm… wonder if there’s a pattern for that…) so that we don’t just get either Ordinary Dungeons or Enchanted Dungeons, but a dungeon that can have rooms, doors and walls of any kind.
Smiting our foe
So we’d like our abstract factory to have methods that allow us to create rooms, doors and walls of each kind. Technically, I could just make a RandomDungeonFactory
that’ll randomly pick one of the types and just return the new object, but I want some flexibility. Maybe, in the future, I want to give my gem’s user the ability to generate a purely enchanted maze, who knows? It’s best to have the randomization logic seperated from my factories.
Returning to the methods our abstract factory should have. I can identify 3 at the very least: #make_door
, #make_wall
, #make_room
. Here we hit a design decision particular to Ruby. If this were in languages like Java, I’d declare an abstract class
, or maybe a simple interface
, that my concrete factories would have to extend or implement. Ruby being Ruby though, I have 2 options:
- I can create an
AbstractFactory
class which my concrete implementations will inherit and override - I can use the so called “duck typing” available to me due to how Ruby does it’s typing: if it quacks like a factory, it is a factory. With this option, there’s no need to create a seperate
AbstractFactory
class. I just need to create the Factories I want and make sure they all have the 3 methods I want
I’ll go with the second option because I feel that adding an extra class just to be overriden seems unnecessary.
Also, I need to change my DungeonMaker#new_dungeon
method to use these factories. I’ll also keep the randomization logic in it since I don’t see a reason to move it elsewhere yet. Therefore, our classes should look like:
trapped_dungeon_factory.rb
require "dungeon_maker/doors"
require "dungeon_maker/walls"
require "dungeon_maker/rooms"
module DungeonMaker
module Factories
class TrappedDungeonFactory
def make_door
Doors::TrappedDoor.new
end
def make_wall
Walls::TrappedWall.new
end
def make_room
Rooms::TrappedRoom.new
end
end
end
end
enchanted_dungeon_factory.rb
require "dungeon_maker/doors"
require "dungeon_maker/walls"
require "dungeon_maker/rooms"
module DungeonMaker
module Factories
class EnchantedDungeonFactory
def make_door
Doors::EnchantedDoor.new
end
def make_wall
Walls::EnchantedWall.new
end
def make_room
Rooms::EnchantedRoom.new
end
end
end
end
ordinary_dungeon_factory.rb
require "dungeon_maker/doors"
require "dungeon_maker/walls"
require "dungeon_maker/rooms"
module DungeonMaker
module Factories
class OrdinaryDungeonFactory
def make_door
Doors::OrdinaryDoor.new
end
def make_wall
Walls::OrdinaryWall.new
end
def make_room
Rooms::OrdinaryRoom.new
end
end
end
end
And our new_dungeon
method now looks like this:
require "dungeon_maker/dungeon"
require "dungeon_maker/factories"
module DungeonMaker
def self.new_dungeon(number_of_rooms: 10)
factories = [Factories::TrappedDungeonFactory.new,
Factories::EnchantedDungeonFactory.new, Factories::OrdinaryDungeonFactory.new]
dungeon = Dungeon.new(number_of_rooms)
dungeon.rooms.map! do
factory = factories.sample
room = factory.make_room
room.sides.map! do
factory = factories.sample
make_wall = rand >= 0.5
make_wall ? factory.make_wall : factory.make_door
end
room
end
dungeon
end
end
So much better. And just to prove it works, I also have console output:
There’s much more that needs to be done to get our game working, but I believe we’re off to a good start. Maybe not all the patterns in the book I’ll implement into this game, but it will definitely come up in future articles.