Multi-strategy __init__()
We may have objects that are created from a variety of sources. For example, we might need to clone an object as part of creating a memento, or freeze an object so that it can be used as the key of a dictionary or placed into a set; this is the idea behind the set
and frozenset
built-in classes.
There are several overall design patterns that have multiple ways to build an object. One design pattern is complex __init__()
that is called multi-strategy initialization. Also, there are multiple class-level (static) constructor methods.
These are incompatible approaches. They have radically different interfaces.
Tip
Avoid clone methods
A clone method that unnecessarily duplicates an object is rarely needed in Python. Using cloning may be an indication of failure to understand the object-oriented design principles available in Python.
A clone method encapsulates the knowledge of object creation in the wrong place. The source object that's being cloned cannot know about the structure of the target object that was built from the clone. However, the reverse (targets having knowledge about a source) is acceptable if the source provides a reasonably well-encapsulated interface.
The examples we have shown here are effectively cloning because they're so simple. We'll expand on them in the next chapter. However, to show ways in which these fundamental techniques are used to do more than trivial cloning, we'll look at turning a mutable Hand
object into a frozen, immutable Hand
object.
The following is an example of a Hand
object that can be built in either of the two ways:
class Hand3: def __init__( self, *args, **kw ): if len(args) == 1 and isinstance(args[0],Hand3): # Clone an existing hand; often a bad idea other= args[0] self.dealer_card= other.dealer_card self.cards= other.cards else: # Build a fresh, new hand. dealer_card, *cards = args self.dealer_card= dealer_card self.cards= list(cards)
In the first case, a Hand3
instance has been built from an existing Hand3
object. In the second case, a Hand3
object has been built from individual Card
instances.
This parallels the way a frozenset
object can be built from individual items or an existing set
object. We look more at creating immutable objects in the next chapter. Creating a new Hand
from an existing Hand
allows us to create a memento of a Hand
object using a construct like the following code snippet:
h = Hand( deck.pop(), deck.pop(), deck.pop() ) memento= Hand( h )
We saved the Hand
object in the memento
variable. This can be used to compare the final with the original hand that was dealt, or we can freeze it for use in a set or mapping too.
More complex initialization alternatives
In order to write a multi-strategy initialization, we're often forced to give up on specific named parameters. This design has the advantage that it is flexible, but the disadvantage that it has opaque, meaningless parameter names. It requires a great deal of documentation explaining the variant use cases.
We can expand our initialization to also split a Hand
object. The result of splitting a Hand
object is simply another constructor. The following code snippet shows how the splitting of a Hand
object might look:
class Hand4: def __init__( self, *args, **kw ): if len(args) == 1 and isinstance(args[0],Hand4): # Clone an existing handl often a bad idea other= args[0] self.dealer_card= other.dealer_card self.cards= other.cards elif len(args) == 2 and isinstance(args[0],Hand4) and 'split' in kw: # Split an existing hand other, card= args self.dealer_card= other.dealer_card self.cards= [other.cards[kw['split']], card] elif len(args) == 3: # Build a fresh, new hand. dealer_card, *cards = args self.dealer_card= dealer_card self.cards= list(cards) else: raise TypeError( "Invalid constructor args={0!r} kw={1!r}".format(args, kw) ) def __str__( self ): return ", ".join( map(str, self.cards) )
This design involves getting extra cards to build proper, split hands. When we create one Hand4
object from another Hand4
object, we provide a split keyword argument that uses the index of the Card
class from the original Hand4
object.
The following code snippet shows how we'd use this to split a hand:
d = Deck() h = Hand4( d.pop(), d.pop(), d.pop() ) s1 = Hand4( h, d.pop(), split=0 ) s2 = Hand4( h, d.pop(), split=1 )
We created an initial h
instance of Hand4
and split it into two other Hand4
instances, s1
and s2
, and dealt an additional Card
class into each. The rules of blackjack only allow this when the initial hand has two cards of equal rank.
While this __init__()
method is rather complex, it has the advantage that it can parallel the way in which fronzenset
is created from an existing set. The disadvantage is that it needs a large docstring to explain all these variations.
Initializing static methods
When we have multiple ways to create an object, it's sometimes more clear to use static methods to create and return instances rather than complex __init__()
methods.
It's also possible to use class methods as alternate initializers, but there's little tangible advantage to receiving the class as an argument to the method. In the case of freezing or splitting a Hand
object, we might want to create two new static methods to freeze or split a Hand
object. Using static methods as surrogate constructors is a tiny syntax change in construction, but it has huge advantages when organizing the code.
The following is a version of Hand
with static methods that can be used to build new instances of Hand
from an existing Hand
instance:
class Hand5: def __init__( self, dealer_card, *cards ): self.dealer_card= dealer_card self.cards = list(cards) @staticmethod def freeze( other ): hand= Hand5( other.dealer_card, *other.cards ) return hand @staticmethod def split( other, card0, card1 ): hand0= Hand5( other.dealer_card, other.cards[0], card0 ) hand1= Hand5( other.dealer_card, other.cards[1], card1 ) return hand0, hand1 def __str__( self ): return ", ".join( map(str, self.cards) )
One method freezes or creates a memento version. The other method splits a Hand5
instance to create two new child instances of Hand5
.
This is considerably more readable and preserves the use of the parameter names to explain the interface.
The following code snippet shows how we can split a Hand5
instance with this version of the class:
d = Deck() h = Hand5( d.pop(), d.pop(), d.pop() ) s1, s2 = Hand5.split( h, d.pop(), d.pop() )
We created an initial h
instance of Hand5
, split it into two other hands, s1
and s2
, and dealt an additional Card
class into each. The split()
static method is much simpler than the equivalent functionality implemented via __init__()
. However, it doesn't follow the pattern of creating a fronzenset
object from an existing set
object.