The best way to learn object-oriented design is to look at patterns
for common solutions to ubiquitous problems. These patterns are often
described with a synopsis that gives you several essential features. The
writer of a pattern will describe a programming context, the specific
problem, the forces that lead to various kinds of solutions, a solution
that optimizes the competing forces, and any consequences of choosing this
solution.
There are a number of outstanding books on patterns. We'll pick a
few key patterns from one of the books, and develop representative classes
in some depth. The idea is to add a few additional Python programming
techniques, along with a number of class design techniques.
When we add subclasses to a class hierarchy, we may also need to
rearrange the statements where objects are created. To provide a
flexible implementation, it is generally a good idea to centralize all
of the object creation statements into a single method of class. When we
extend the subclass hierarchy we can also create a relevant subclass of
the centralized object creation class.
The design pattern for this kind of centralized object creator can
be called a Factory. It contains the details for creating an instance of
each of the various subclasses.
In the next section, Part IV, “Components, Modules and Packages”, we'll look
at how to package a class hierarchy in a module. Often the classes and
the factory object are bundled into one seamless module. Further, as the
module evolves and improves, we need to preserve the factory which
creates instances of other classes in the module. Creating a class with
a factory method helps us control the evolution of a module. If we omit
the
Factory Method
, then everyone who
uses our module has to rewrite their programs when we change our class
hierarchy.
Extending the Card Class Hierarchy. We'll extend the Card
class hierarchy,
introduced in the section called “Inheritance”. That
original design had three classes: Card
,
FaceCard
and
AceCard
.
While this seems complete for basic Blackjack, we may need to
extend these classes. For example, if we are going to simulate a common
card counting technique, we'll need to separate 2-6 from 7-9, leading to
two more subclasses. Adding subclasses can easily ripple through an
application, leading to numerous additional, sometimes complex changes.
We would have to look for each place where the various subclasses of
cards were created. The
Factory
design
pattern, however, provides a handy solution to this problem.
An object of a class based on the
Factory
pattern creates instances of other
classes. This saves having to place creation decisions throughout a
complex program. Instead, all of the creation decision-making is
centralized in the factory class.
For our card example, we can define a
CardFactory
that creates new instances of
Card
(or the appropriate subclass.)
class CardFactory( object ):
def newCard( self, rank, suit ):
if rank == 1:
return Ace( rank, suit )
elif rank in [ 11, 12, 13 ]:
return FaceCard( rank, suit )
else:
return Card( rank, suit )
We can simplify our version of Deck
using
this factory.
class Deck( object ):
def __init__( self ):
factory= CardFactory()
self.cards = [ factory.newCard( rank+1, suit )
for suit in range(4)
for rank in range(13) ]
Rest of the class is the same
Centralized Object Creation
While it may seem like overhead to centralize object creation in
factory objects, it has a number of benefits.
First, and foremost, centralizing object creation makes it easy
to locate the one place where objects are constructed, and fix the
constructor. Having object construction scattered around an
application means that time is spent searching for and fixing things
that are, in a way, redundant.
Additionally, centralized object creation is the norm for larger
applications. When we break down an application into the data model,
the view objects and the control objects, we find at least two kinds
of factories. The data model elements are often created by fetching
from a database, or parsing an input file. The control objects are
part of our application that are created during initialization, based
on configuration parameters, or created as the program runs based on
user inputs.
Finally, it makes evolution of the application possible when we
are creating a new version of a factory rather than tracking down
numerous creators scattered around an application. We can assure
ourselves that the old factory is still available and still passes all
the unit tests. The new factory creates the new objects for the new
features of the application software.
Exetending the Factory. By using this kind of Factory method design pattern, we can more
easily create new subclasses of Card. When we create new subclasses,
we do three things:
-
Extend the Card
class hierarchy to
define the additional subclasses.
-
Extend the CardFactory
creation rules
to create instances of the new subclasses. This is usually done by
creating a new subclass of the factory.
-
Extend or update Deck
to use the new
factory. We can either create a new subclass of
Deck
, or make the factory object a parameter
to Deck
.
Let's create some new subclasses of Card
for card counting. These will subdivide the number cards into low,
neutral and high ranges. We'll also need to subclass our existing
FaceCard
and Ace
classes
to add this new method.
class CardHi( Card ):
"""Used for 10."""
def count( self ): return -1
class CardLo( Card ):
"""Used for 3, 4, 5, 6, 7."""
def count( self ): return +1
class CardNeutral( Card ):
"""Used for 2, 8, 9."""
def count( self ): return 0
class FaceCount( FaceCard ):
"""Used for J, Q and K"""
def count( self ): return -1
class AceCount( Ace ):
"""Used for A"""
def count( self ): return -1
A counting subclass of Hand
can sum the
count
values of all Card
instances to get the count of the deck so far.
Once we have our new subclasses, we can create a subclass of
CardFactory
to include these new subclasses of
Card
. We'll call this new class
HiLoCountFactory
. This new subclass will define a
new version of the newCard
method that creates
appropriate objects.
By using default values for parameters, we can make this factory
option transparent. We can design Deck
to use the
original CardFactory
by default. We can also
design Deck
to accept an optional
CardFactory
object, which would tailor the
Deck
for a particular player strategy.
class Deck( object ):
def __init__( self, factory=CardFactory() ):
self.cards = [ factory.newCard( rank+1, suit )
for suit in range(4)
for rank in range(13) ]
Rest of the class is the same
The Overall Main Program. Now we can have main programs that look something like the
following.
d1 = Deck()
d2 = Deck(HiLoCountFactory())
In this case, d1
is a
Deck
using the original definitions, ignoring the
subclasses for card counting. The d2
Deck
is built using a different factory and has
cards that include a particular card counting strategy.
We can now introduce variant card-counting schemes by introducing
further subclasses of Card
and
CardFactory
. To pick a particular set of card
definitions, the application creates an instance of one of the available
subclasses of CardFactory
. Since all subclasses
have the same newCard
method, the various objects
are interchangeable. Any CardFactory
object can
be used by Deck
to produce a valid deck of
cards.
This evolution of a design via new subclasses is a very important
technique of object-oriented programming. If we add features via
subclasses, we are sure that the original definitions have not been
disturbed. We can be completely confident that adding a new feature to a
program will not break old features.