Mastering Object-oriented Python

4.3 (20 reviews total)
By Steven F. Lott
  • Instant online access to over 7,500+ books and videos
  • Constantly updated with 100+ new titles each month
  • Breadth and depth in over 1,000+ technologies
  1. The __init__() Method

About this book

This practical example-oriented guide will teach you advanced concepts of object-oriented programming in Python. This book will present detailed examples of almost all of the special method names that support creating classes that integrate seamlessly with Python's built-in features. It will show you how to use JSON, YAML, Pickle, CSV, XML, Shelve, and SQL to create persistent objects and transmit objects between processes. The book also covers logging, warnings, unit testing, configuration files, and how to work with the command line.

This book is broken into three major parts: Pythonic Classes via Special Methods; Persistence and Serialization; Testing, Debugging, Deploying, and Maintaining. The special methods are broken down into several focus areas: initialization, basics, attribute access, callables, contexts, containers, collections, numbers, and more advanced techniques such as decorators and mixin classes.

Publication date:
April 2014
Publisher
Packt
Pages
634
ISBN
9781783280971

 

Chapter 1. The __init__() Method

The __init__() method is profound for two reasons. Initialization is the first big step in an object's life; every object must be initialized properly to work properly. The second reason is that the argument values for __init__() can take on many forms.

Because there are so many ways to provide argument values to __init__(), there is a vast array of use cases for object creation. We take a look at several of them. We want to maximize clarity, so we need to define an initialization that properly characterizes the problem domain.

Before we can get to the __init__() method, however, we need to take a look at the implicit class hierarchy in Python, glancing, briefly, at the class named object. This will set the stage for comparing default behavior with the different kinds of behavior we want from our own classes.

In this chapter, we take a look at different forms of initialization for simple objects (for example, playing cards). After this, we can take a look at more complex objects, such as hands that involve collections and players that involve strategies and states.

 

The implicit superclass – object


Each Python class definition has an implicit superclass: object. It's a very simple class definition that does almost nothing. We can create instances of object, but we can't do much with them because many of the special methods simply raise exceptions.

When we define our own class, object is the superclass. The following is an example class definition that simply extends object with a new name:

class X:
    pass

The following are some interactions with our class:

>>> X.__class__
<class 'type'>
>>> X.__class__.__base__
<class 'object'>

We can see that a class is an object of the class named type and that the base class for our new class is the class named object.

As we look at each method, we also take a look at the default behavior inherited from object. In some cases, the superclass special method behavior will be exactly what we want. In other cases, we'll need to override the special method.

 

The base class object __init__() method


Fundamental to the life cycle of an object are its creation, initialization, and destruction. We'll defer creation and destruction to a later chapter on more advanced special methods and only focus on initialization for now.

The superclass of all classes, object, has a default implementation of __init__() that amounts to pass. We aren't required to implement __init__(). If we don't implement it, then no instance variables will be created when the object is created. In some cases, this default behavior is acceptable.

We can always add attributes to an object that's a subclass of the foundational base class, object. Consider the following class that requires two instance variables but doesn't initialize them:

class Rectangle:
    def area( self ):
        return self.length * self.width

The Rectangle class has a method that uses two attributes to return a value. The attributes have not been initialized anywhere. This is legal Python. It's a little strange to avoid specifically setting attributes, but it's valid.

The following is an interaction with the Rectangle class:

>>> r= Rectangle()
>>> r.length, r.width = 13, 8
>>> r.area()
104

While this is legal, it's a potential source of deep confusion, which is a good reason to avoid it.

However, this kind of design grants flexibility, so there could be times when we needn't set all of the attributes in the __init__() method. We walk a fine line here. An optional attribute is a kind of subclass that's not formally declared as a proper subclass. We're creating polymorphism in a way that could lead to confusing and inappropriate use of convoluted if statements. While uninitialized attributes may be useful, they could be the symptom of a bad design.

The Zen of Python poem (import this) offers the following advice:

"Explicit is better than implicit."

An __init__() method should make the instance variables explicit.

Tip

Pretty Poor Polymorphism

There's a fine line between flexibility and foolishness.

We may have stepped over the edge off flexible into foolish as soon as we feel the need to write:

if 'x' in self.__dict__:

Or:

try:
    self.x
except AttributeError:

It's time to reconsider the API and add a common method or attribute. Refactoring is better than adding if statements.

 

Implementing __init__() in a superclass


We initialize an object by implementing the __init__() method. When an object is created, Python first creates an empty object and then calls the __init__() method for that new object. This method function generally creates the object's instance variables and performs any other one-time processing.

The following are some example definitions of a Card class hierarchy. We'll define a Card superclass and three subclasses that are variations of the basic theme of Card. We have two instance variables that have been set directly from argument values and two variables that have been calculated by an initialization method:

class Card:
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= rank
        self.hard, self.soft = self._points()
class NumberCard( Card ):
    def _points( self ):
        return int(self.rank), int(self.rank)
class AceCard( Card ):
    def _points( self ):
        return 1, 11
class FaceCard( Card ):
    def _points( self ):
        return 10, 10

In this example, we factored the __init__() method into the superclass so that a common initialization in the superclass, Card, applies to all the three subclasses NumberCard, AceCard, and FaceCard.

This shows a common polymorphic design. Each subclass provides a unique implementation of the _points() method. All the subclasses have identical signatures: they have the same methods and attributes. Objects of these three subclasses can be used interchangeably in an application.

If we simply use characters for suits, we will be able to create Card instances as shown in the following code snippet:

cards = [ AceCard('A', 'â™ '), NumberCard('2','â™ '), NumberCard('3','â™ '), ]

We enumerated the class, rank, and suit for several cards in a list. In the long run, we need a much smarter factory function to build Card instances; enumerating all 52 cards this way is tedious and error prone. Before we get to the factory functions, we take a look at a number of other issues.

 

Using __init__() to create manifest constants


We can define a class for the suits of our cards. In blackjack, the suits don't matter, and a simple character string could work.

We use suit construction as an example of creating constant objects. In many cases, our application will have a small domain of objects that can be defined by a collection of constants. A small domain of static objects may be part of implementing a Strategy or State design pattern.

In some cases, we may have a pool of constant objects created in an initialization or configuration file, or we might create constant objects based on command-line parameters. We'll return to the details of initialization design and startup design in Chapter 16, Coping with the Command Line.

Python has no simple formal mechanism for defining an object as immutable. We'll look at techniques to assure immutability in Chapter 3, Attribute Access, Properties, and Descriptors. In this example, it might make sense for the attributes of a suit to be immutable.

The following is a class that we'll use to build four manifest constants:

class Suit:
    def __init__( self, name, symbol ):
        self.name= name
        self.symbol= symbol

The following is the domain of "constants" built around this class:

Club, Diamond, Heart, Spade = Suit('Club','♣'), Suit('Diamond','♦'), Suit('Heart','♥'), Suit('Spade','♠')

We can now create cards as shown in the following code snippet:

cards = [ AceCard('A', Spade), NumberCard('2', Spade), NumberCard('3', Spade), ]

For an example this small, this method isn't a huge improvement over single character suit codes. In more complex cases, there may be a short list of Strategy or State objects that can be created like this. This can make the Strategy or State design patterns work efficiently by reusing objects from a small, static pool of constants.

We do have to acknowledge that in Python these objects aren't technically constant; they are mutable. There may be some benefit in doing the extra coding to make these objects truly immutable.

Tip

The irrelevance of immutability

Immutability can become an attractive nuisance. It's sometimes justified by the mythical "malicious programmer" who modifies the constant value in their application. As a design consideration, this is silly. This mythical, malicious programmer can't be stopped this way. There's no easy way to "idiot-proof" code in Python. The malicious programmer has access to the source and can tweak it just as easily as they can write code to modify a constant.

It's better not to struggle too long to define the classes of immutable objects. In Chapter 3, Attribute Access, Properties, and Descriptors, we'll show ways to implement immutability that provides suitable diagnostic information for a buggy program.

 

Leveraging __init__() via a factory function


We can build a complete deck of cards via a factory function. This beats enumerating all 52 cards. In Python, we have two common approaches to factories as follows:

  • We define a function that creates objects of the required classes.

  • We define a class that has methods for creating objects. This is the full factory design pattern, as described in books on design patterns. In languages such as Java, a factory class hierarchy is required because the language doesn't support standalone functions.

In Python, a class isn't required. It's merely a good idea when there are related factories that are complex. One of the strengths of Python is that we're not forced to use a class hierarchy when a simple function might do just as well.

Note

While this is a book about object-oriented programming, a function really is fine. It's common, idiomatic Python.

We can always rewrite a function to be a proper callable object if the need arises. From a callable object, we can refactor it into a class hierarchy for our factories. We'll look at callable objects in Chapter 5, Using Callables and Contexts.

The advantage of class definitions in general is to achieve code reuse via inheritance. The function of a factory class is to wrap some target class hierarchy and the complexities of object construction. If we have a factory class, we can add subclasses to the factory class when extending the target class hierarchy. This gives us polymorphic factory classes; the different factory class definitions have the same method signatures and can be used interchangeably.

This class-level polymorphism can be very helpful with statically compiled languages such as Java or C++. The compiler can resolve the details of the class and methods when generating code.

If the alternative factory definitions don't actually reuse any code, then a class hierarchy won't be helpful in Python. We can simply use functions that have the same signatures.

The following is a factory function for our various Card subclasses:

def card( rank, suit ):
    if rank == 1: return AceCard( 'A', suit )
    elif 2 <= rank < 11: return NumberCard( str(rank), suit )
    elif 11 <= rank < 14:
        name = { 11: 'J', 12: 'Q', 13: 'K' }[rank]
        return FaceCard( name, suit )
    else:
        raise Exception( "Rank out of range" )

This function builds a Card class from a numeric rank number and a suit object. We can now build cards more simply. We've encapsulated the construction issues into a single factory function, allowing an application to be built without knowing precisely how the class hierarchy and polymorphic design works.

The following is an example of how we can build a deck with this factory function:

deck = [card(rank, suit)
    for rank in range(1,14)
        for suit in (Club, Diamond, Heart, Spade)]

This enumerates all the ranks and suits to create a complete deck of 52 cards.

Faulty factory design and the vague else clause

Note the structure of the if statement in the card() function. We did not use a catch-all else clause to do any processing; we merely raised an exception. The use of a catch-all else clause is subject to a tiny scrap of debate.

On the one hand, it can be argued that the condition that belongs on an else clause should never be left unstated because it may hide subtle design errors. On the other hand, some else clause conditions are truly obvious.

It's important to avoid the vague else clause.

Consider the following variant on this factory function definition:

def card2( rank, suit ):
    if rank == 1: return AceCard( 'A', suit )
    elif 2 <= rank < 11: return NumberCard( str(rank), suit )
    else:
        name = { 11: 'J', 12: 'Q', 13: 'K' }[rank]
        return FaceCard( name, suit )

The following is what will happen when we try to build a deck:

deck2 = [card2(rank, suit) for rank in range(13) for suit in (Club, Diamond, Heart, Spade)]

Does it work? What if the if conditions were more complex?

Some programmers can understand this if statement at a glance. Others will struggle to determine if all of the cases are properly exclusive.

For advanced Python programming, we should not leave it to the reader to deduce the conditions that apply to an else clause. Either the condition should be obvious to the newest of n00bz, or it should be explicit.

Tip

When to use catch-all else

Rarely. Use it only when the condition is obvious. When in doubt, be explicit and use else to raise an exception.

Avoid the vague else clause.

Simplicity and consistency using elif sequences

Our factory function, card(), is a mixture of two very common factory design patterns:


  • An if-elif sequence

  • A mapping

For the sake of simplicity, it's better to focus on just one of these techniques rather than on both.

We can always replace a mapping with elif conditions. (Yes, always. The reverse is not true though; transforming elif conditions to a mapping can be challenging.)

The following is a Card factory without the mapping:

def card3( rank, suit ):
    if rank == 1: return AceCard( 'A', suit )
    elif 2 <= rank < 11: return NumberCard( str(rank), suit )
    elif rank == 11:
        return FaceCard( 'J', suit )
    elif rank == 12:
        return FaceCard( 'Q', suit )
    elif rank == 13:
        return FaceCard( 'K', suit )
    else:
        raise Exception( "Rank out of range" )

We rewrote the card() factory function. The mapping was transformed into additional elif clauses. This function has the advantage that it is more consistent than the previous version.

Simplicity using mapping and class objects

In some cases, we can use a mapping instead of a chain of elif conditions. It's possible to find conditions that are so complex that a chain of elif conditions is the only sensible way to express them. For simple cases, however, a mapping often works better and can be easy to read.

Since class is a first-class object, we can easily map from the rank parameter to the class that must be constructed.

The following is a Card factory that uses only a mapping:

def card4( rank, suit ):
    class_= {1: AceCard, 11: FaceCard, 12: FaceCard,
        13: FaceCard}.get(rank, NumberCard)
    return class_( rank, suit )

We've mapped the rank object to a class. Then, we applied the class to the rank and suit values to build the final Card instance.

We can use a defaultdict class as well. However, it's no simpler for a trivial static mapping. It looks like the following code snippet:

defaultdict( lambda: NumberCard, {1: AceCard, 11: FaceCard, 12: FaceCard, 12: FaceCard} )

Note that the default of a defaultdict class must be a function of zero arguments. We've used a lambda construct to create the necessary function wrapper around a constant. This function, however, has a serious deficiency. It lacks the translation from 1 to A and 13 to K that we had in previous versions. When we try to add that feature, we run into a problem.

We need to change the mapping to provide both a Card subclass as well as the string version of the rank object. What can we do for this two-part mapping? There are four common solutions:

  • We can do two parallel mappings. We don't suggest this, but we'll show it to emphasize what's undesirable about it.

  • We can map to a two-tuple. This also has some disadvantages.

  • We can map to a partial() function. The partial() function is a feature of the functools module.

  • We can also consider modifying our class definition to fit more readily with this kind of mapping. We'll look at this alternative in the next section on pushing __init__() into the subclass definitions.

We'll look at each of these with a concrete example.

Two parallel mappings

The following is the essence of the two parallel mappings solution:

class_= {1: AceCard, 11: FaceCard, 12: FaceCard, 13: FaceCard }.get(rank, NumberCard)
rank_str= {1:'A', 11:'J', 12:'Q', 13:'K'}.get(rank,str(rank))
return class_( rank_str, suit )

This is not desirable. It involves a repetition of the sequence of the mapping keys 1, 11, 12, and 13. Repetition is bad because parallel structures never seem to stay that way after the software has been updated.

Tip

Don't use parallel structures

Two parallel structures should be replaced with tuples or some kind of proper collection.

Mapping to a tuple of values

The following is the essence of how mapping is done to a two-tuple:

class_, rank_str= {
    1:  (AceCard,'A'),
    11: (FaceCard,'J'),
    12: (FaceCard,'Q'),
    13: (FaceCard,'K'),
    }.get(rank, (NumberCard, str(rank)))
return class_( rank_str, suit )

This is reasonably pleasant. It's not much code to sort out the special cases of playing cards. We will see how it could be modified or expanded if we need to alter the Card class hierarchy to add additional subclasses of Card.

It does feel odd to map a rank value to a class object and just one of the two arguments to that class initializer. It seems more sensible to map the rank to a simple class or function object without the clutter of providing some (but not all) of the arguments.

The partial function solution

Rather than map to a two-tuple of function and one of the arguments, we can create a partial() function. This is a function that already has some (but not all) of its arguments provided. We'll use the partial() function from the functools library to create a partial of a class with the rank argument.

The following is a mapping from rank to a partial() function that can be used for object construction:

from functools import partial
part_class= {
    1:  partial(AceCard,'A'),
    11: partial(FaceCard,'J'),
    12: partial(FaceCard,'Q'),
    13: partial(FaceCard,'K'),
    }.get(rank, partial(NumberCard, str(rank)))
return part_class( suit )

The mapping associates a rank object with a partial() function that is assigned to part_class. This partial() function can then be applied to the suit object to create the final object. The use of partial() functions is a common technique for functional programming. It works in this specific situation where we have a function instead of an object method.

In general, however, partial() functions aren't helpful for most object-oriented programming. Rather than create partial() functions, we can simply update the methods of a class to accept the arguments in different combinations. A partial() function is similar to creating a fluent interface for object construction.

Fluent APIs for factories

In some cases, we design a class where there's a defined order for method usage. Evaluating methods sequentially is very much like creating a partial() function.

We might have x.a().b() in an object notation. We can think of it as . The x.a() function is a kind of partial() function that's waiting for b(). We can think of this as if it were .

The idea here is that Python offers us two alternatives for managing a state. We can either update an object or create a partial() function that is (in a way) stateful. Because of this equivalence, we can rewrite a partial() function into a fluent factory object. We make the setting of the rank object a fluent method that returns self. Setting the suit object will actually create the Card instance.

The following is a fluent Card factory class with two method functions that must be used in a specific order:

class CardFactory:
    def rank( self, rank ):
        self.class_, self.rank_str= {
            1:(AceCard,'A'),
            11:(FaceCard,'J'),
            12:(FaceCard,'Q'),
            13:(FaceCard,'K'),
            }.get(rank, (NumberCard, str(rank)))
        return self
    def suit( self, suit ):
        return self.class_( self.rank_str, suit )

The rank() method updates the state of the constructor, and the suit() method actually creates the final Card object.

This factory class can be used as follows:

card8 = CardFactory()
deck8 = [card8.rank(r+1).suit(s) for r in range(13) for s in (Club, Diamond, Heart, Spade)]

First, we create a factory instance, then we use that instance to create Card instances. This doesn't materially change how __init__() itself works in the Card class hierarchy. It does, however, change the way that our client application creates objects.

 

Implementing __init__() in each subclass


As we look at the factory functions for creating Card objects, we see some alternative designs for the Card class. We might want to refactor the conversion of the rank number so that it is the responsibility of the Card class itself. This pushes the initialization down into each subclass.

This often requires some common initialization of a superclass as well as subclass-specific initialization. We need to follow the Don't Repeat Yourself (DRY) principle to keep the code from getting cloned into each of the subclasses.

The following is an example where the initialization is the responsibility of each subclass:

class Card:
    pass
class NumberCard( Card ):
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= str(rank)
        self.hard = self.soft = rank
class AceCard( Card ):
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= "A"
        self.hard, self.soft =  1, 11
class FaceCard( Card ):
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= {11: 'J', 12: 'Q', 13: 'K' }[rank]
        self.hard = self.soft = 10

This is still clearly polymorphic. The lack of a truly common initialization, however, leads to some unpleasant redundancy. What's unpleasant here is the repeated initialization of suit. This must be pulled up into the superclass. We can have each __init__() subclass make an explicit reference to the superclass.

This version of the Card class has an initializer at the superclass level that is used by each subclass, as shown in the following code snippet:

class Card:
    def __init__( self, rank, suit, hard, soft ):
        self.rank= rank
        self.suit= suit
        self.hard= hard
        self.soft= soft
class NumberCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( str(rank), suit, rank, rank )
class AceCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( "A", suit, 1, 11 )
class FaceCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( {11: 'J', 12: 'Q', 13: 'K' }[rank], suit, 10, 10 )

We've provided __init__() at both the subclass and superclass level. This has the small advantage that it simplifies our factory function, as shown in the following code snippet:

def card10( rank, suit ):
    if rank == 1: return AceCard( rank, suit )
    elif 2 <= rank < 11: return NumberCard( rank, suit )
    elif 11 <= rank < 14: return FaceCard( rank, suit )
    else:
        raise Exception( "Rank out of range" )

Simplifying a factory function should not be our focus. We can see from this variation that we've created rather complex __init__() methods for a relatively minor improvement in a factory function. This is a common trade-off.

Tip

Factory functions encapsulate complexity

There's a trade-off that occurs between sophisticated __init__() methods and factory functions. It's often better to stick with more direct but less programmer-friendly __init__() methods and push the complexity into factory functions. A factory function works well if you wish to wrap and encapsulate the construction complexities.

 

Simple composite objects


A composite object can also be called a container. We'll look at a simple composite object: a deck of individual cards. This is a basic collection. Indeed, it's so basic that we can, without too much struggle, use a simple list as a deck.

Before designing a new class, we need to ask this question: is using a simple list appropriate?

We can use random.shuffle() to shuffle the deck and deck.pop() to deal cards into a player's Hand.

Some programmers rush to define new classes as if using a built-in class violates some object-oriented design principle. Avoiding a new class leaves us with something as shown in the following code snippet:

d= [card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade)]
random.shuffle(d)
hand= [ d.pop(), d.pop() ]

If it's that simple, why write a new class?

The answer isn't perfectly clear. One advantage is that a class offer a simplified, implementation-free interface to the object. As we noted previously, when discussing factories, a class isn't a requirement in Python.

In the preceding code, the deck only has two simple use cases and a class definition doesn't seem to simplify things very much. It does have the advantage of concealing the implementation's details. But the details are so trivial that exposing them seems to have little cost. We're focused primarily on the __init__() method in this chapter, so we'll look at some designs to create and initialize a collection.

To design a collection of objects, we have the following three general design strategies:

  • Wrap: This design pattern is an existing collection definition. This might be an example of the Facade design pattern.

  • Extend: This design pattern is an existing collection class. This is ordinary subclass definition.

  • Invent: This is designed from scratch. We'll look at this in Chapter 6, Creating Containers and Collections.

These three concepts are central to object-oriented design. We must always make this choice when designing a class.

Wrapping a collection class

The following is a wrapper design that contains an internal collection:

class Deck:
    def __init__( self ):
        self._cards = [card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade)]
        random.shuffle( self._cards )
    def pop( self ):
        return self._cards.pop()

We've defined Deck so that the internal collection is a list object. The pop() method of Deck simply delegates to the wrapped list object.

We can then create a Hand instance with the following kind of code:

d= Deck()
hand= [ d.pop(), d.pop() ]

Generally, a Facade design pattern or wrapper class contains methods that are simply delegated to the underlying implementation class. This delegation can become wordy. For a sophisticated collection, we may wind up delegating a large number of methods to the wrapped object.

Extending a collection class

An alternative to wrapping is to extend a built-in class. By doing this, we have the advantage of not having to reimplement the pop() method; we can simply inherit it.

The pop() method has the advantage that it creates a class without writing too much code. In this example, extending the list class has the disadvantage that this provides many more functions than we truly need.

The following is a definition of Deck that extends the built-in list:

class Deck2( list ):
    def __init__( self ):
        super().__init__( card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade) )
        random.shuffle( self )

In some cases, our methods will have to explicitly use the superclass methods in order to have proper class behavior. We'll see other examples of this in the following sections.

We leverage the superclass's __init__() method to populate our list object with an initial single deck of cards. Then we shuffle the cards. The pop() method is simply inherited from list and works perfectly. Other methods inherited from the list also work.

More requirements and another design

In a casino, the cards are often dealt from a shoe that has half a dozen decks of cards all mingled together. This consideration makes it necessary for us to build our own version of Deck and not simply use an unadorned list object.

Additionally, a casino shoe is not dealt fully. Instead, a marker card is inserted. Because of the marker, some cards are effectively set aside and not used for play.

The following is Deck definition that contains multiple sets of 52-card decks:

class Deck3(list):
    def __init__(self, decks=1):
        super().__init__()
        for i in range(decks):
            self.extend( card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade) )
        random.shuffle( self )
        burn= random.randint(1,52)
        for i in range(burn): self.pop()

Here, we used the __init__() superclass to build an empty collection. Then, we used self.extend() to append multiple 52-card decks to the shoe. We could also use super().extend() since we did not provide an overriding implementation in this class.

We could also carry out the entire task via super().__init__() using a more deeply nested generator expression, as shown in the following code snippet:

( card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade) for d in range(decks) )

This class provides us with a collection of Card instances that we can use to emulate casino blackjack as dealt from a shoe.

There's a peculiar ritual in a casino where they reveal the burned card. If we're going to design a card-counting player strategy, we might want to emulate this nuance too.

 

Complex composite objects


The following is an example of a blackjack Hand description that might be suitable for emulating play strategies:

class Hand:
    def __init__( self, dealer_card ):
        self.dealer_card= dealer_card
        self.cards= []
    def hard_total(self ):
        return sum(c.hard for c in self.cards)
    def soft_total(self ):
        return sum(c.soft for c in self.cards)

In this example, we have an instance variable self.dealer_card based on a parameter of the __init__() method. The self.cards instance variable, however, is not based on any parameter. This kind of initialization creates an empty collection.

To create an instance of Hand, we can use the following code:

d = Deck()
h = Hand( d.pop() )
h.cards.append( d.pop() )
h.cards.append( d.pop() )

This has the disadvantage that a long-winded sequence of statements is used to build an instance of a Hand object. It can become difficult to serialize the Hand object and rebuild it with an initialization such as this one. Even if we were to create an explicit append() method in this class, it would still take multiple steps to initialize the collection.

We could try to create a fluent interface, but that doesn't really simplify things; it's merely a change in the syntax of the way that a Hand object is built. A fluent interface still leads to multiple method evaluations. When we take a look at the serialization of objects in Part 2, Persistence and Serialization we'd like an interface that's a single class-level function, ideally the class constructor. We'll look at this in depth in Chapter 9, Serializing and Saving - JSON, YAML, Pickle, CSV, and XML.

Note also that the hard total and soft total method functions shown here don't fully follow the rules of blackjack. We return to this issue in Chapter 2, Integrating Seamlessly with Python – Basic Special Methods.

Complete composite object initialization

Ideally, the __init__() initializer method will create a complete instance of an object. This is a bit more complex when creating a complete instance of a container that contains an internal collection of other objects. It'll be helpful if we can build this composite in a single step.

It's common to have both a method to incrementally accrete items as well as the initializer special method that can load all of the items in one step.

For example, we might have a class such as the following code snippet:

class Hand2:
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.cards = list(cards)
    def hard_total(self ):
        return sum(c.hard for c in self.cards)
    def soft_total(self ):
        return sum(c.soft for c in self.cards)

This initialization sets all of the instance variables in a single step. The other methods are simply copies of the previous class definition. We can build a Hand2 object in two ways. This first example loads one card at a time into a Hand2 object:

d = Deck()
P = Hand2( d.pop() )
p.cards.append( d.pop() )
p.cards.append( d.pop() )

This second example uses the *cards parameter to load a sequence of Cards class in a single step:

d = Deck()
h = Hand2( d.pop(), d.pop(), d.pop() )

For unit testing, it's often helpful to build a composite object in a single statement in this way. More importantly, some of the serialization techniques from the next part will benefit from a way of building a composite object in a single, simple evaluation.

 

Stateless objects without __init__()


The following is an example of a degenerate class that doesn't need an __init__() method. It's a common design pattern for Strategy objects. A Strategy object is plugged into a Master object to implement an algorithm or decision. It may rely on data in the master object; the Strategy object may not have any data of its own. We often design strategy classes to follow the Flyweight design pattern: we avoid internal storage in the Strategy object. All values are provided to Strategy as method argument values. The Strategy object itself can be stateless. It's more a collection of method functions than anything else.

In this case, we're providing the game play decisions for a Player instance. The following is an example of a (dumb) strategy to pick cards and decline the other bets:

class GameStrategy:
    def insurance( self, hand ):
        return False
    def split( self, hand ):
        return False
    def double( self, hand ):
        return False
    def hit( self, hand ):
        return sum(c.hard for c in hand.cards) <= 17

Each method requires the current Hand as an argument value. The decisions are based on the available information; that is, on the dealer's cards and the player's cards.

We can build a single instance of this strategy for use by various Player instances as shown in the following code snippet:

dumb = GameStrategy()

We can imagine creating a family of related strategy classes, each one using different rules for the various decisions a player is offered in blackjack.

 

Some additional class definitions


As noted previously, a player has two strategies: one for betting and one for playing their hand. Each Player instance has a sequence of interactions with a larger simulation engine. We'll call the larger engine the Table class.

The Table class requires the following sequence of events by the Player instances:

  • The player must place an initial bet based on the betting strategy.

  • The player will then receive a hand.

  • If the hand is splittable, the player must decide to split or not based on the play strategy. This can create additional Hand instances. In some casinos, the additional hands are also splittable.

  • For each Hand instance, the player must decide to hit, double, or stand based on the play strategy.

  • The player will then receive payouts, and they must update their betting strategy based on their wins and losses.

From this, we can see that the Table class has a number of API methods to receive a bet, create a Hand object, offer a split, resolve each hand, and pay off the bets. This is a large object that tracks the state of play with a collection of Players.

The following is the beginning of a Table class that handles the bets and cards:

class Table:
    def __init__( self ):
        self.deck = Deck()
    def place_bet( self, amount ):
        print( "Bet", amount )
    def get_hand( self ):
        try:
            self.hand= Hand2( d.pop(), d.pop(), d.pop() )
            self.hole_card= d.pop()
        except IndexError:
            # Out of cards: need to shuffle.
            self.deck= Deck()
            return self.get_hand()
        print( "Deal", self.hand )
        return self.hand
    def can_insure( self, hand ):
        return hand.dealer_card.insure

The Table class is used by the Player class to accept a bet, create a Hand object, and determine if theinsurance bet is in play for this hand. Additional methods can be used by the Player class to get cards and determine the payout.

The exception handling shown in get_hand() is not a precise model of casino play. This may lead to minor statistical inaccuracies. A more accurate simulation requires developing a deck that reshuffles itself when empty instead of raising an exception.

In order to interact properly and simulate realistic play, the Player class needs a betting strategy. The betting strategy is a stateful object that determines the level of the initial bet. The various betting strategies generally change the bet based on whether the game was a win or a loss.

Ideally, we'd like to have a family of betting strategy objects. Python has a module with decorators that allows us to create an abstract superclass. An informal approach to creating Strategy objects is to raise an exception for methods that must be implemented by a subclass.

We've defined an abstract superclass as well as a specific subclass as follows to define a flat betting strategy:

class BettingStrategy:
    def bet( self ):
        raise NotImplementedError( "No bet method" )
    def record_win( self ):
        pass
    def record_loss( self ):
        pass

class Flat(BettingStrategy):
    def bet( self ):
        return 1

The superclass defines the methods with handy default values. The basic bet() method in the abstract superclass raises an exception. The subclass must override the bet() method. The other methods can be left to provide the default values. Given the game strategy in the previous section plus the betting strategy here, we can look at more complex __init__() techniques surrounding the Player class.

We can make use of the abc module to formalize an abstract superclass definition. It would look like the following code snippet:

import abc
class BettingStrategy2(metaclass=abc.ABCMeta):
    @abstractmethod
    def bet( self ):
        return 1
    def record_win( self ):
        pass
    def record_loss( self ):
        pass

This has the advantage that it makes the creation of an instance of BettingStrategy2, or any subclass that failed to implement bet(), impossible. If we try to create an instance of this class with an unimplemented abstract method, it will raise an exception instead of creating an object.

And yes, the abstract method has an implementation. It can be accessed via super().bet().

 

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.

 

Yet more __init__() techniques


We'll take a look at a few other, more advanced __init__() techniques. These aren't quite so universally useful as the techniques in the previous sections.

The following is a definition for the Player class that uses two strategy objects and a table object. This shows an unpleasant-looking __init__() method:

class Player:
    def __init__( self, table, bet_strategy, game_strategy ):
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table= table
    def game( self ):
        self.table.place_bet( self.bet_strategy.bet() )
        self.hand= self.table.get_hand()
        if self.table.can_insure( self.hand ):
            if self.game_strategy.insurance( self.hand ):
                self.table.insure( self.bet_strategy.bet() )
        # Yet more... Elided for now

The __init__() method for Player seems to do little more than bookkeeping. We're simply transferring named parameters to same-named instance variables. If we have numerous parameters, simply transferring the parameters into the internal variables will amount to a lot of redundant-looking code.

We can use this Player class (and related objects) as follows:

table = Table()
flat_bet = Flat()
dumb = GameStrategy()
p = Player( table, flat_bet, dumb )
p.game()

We can provide a very short and very flexible initialization by simply transferring keyword argument values directly into the internal instance variables.

The following is a way to build a Player class using keyword argument values:

class Player2:
    def __init__( self, **kw ):
        """Must provide table, bet_strategy, game_strategy."""
        self.__dict__.update( kw )
    def game( self ):
        self.table.place_bet( self.bet_strategy.bet() )
        self.hand= self.table.get_hand()
        if self.table.can_insure( self.hand ):
            if self.game_strategy.insurance( self.hand ):
                self.table.insure( self.bet_strategy.bet() )
        # etc.

This sacrifices a great deal of readability for succinctness. It crosses over into a realm of potential obscurity.

Since the __init__() method is reduced to one line, it removes a certain level of "wordiness" from the method. This wordiness, however, is transferred to each individual object constructor expression. We have to add the keywords to the object initialization expression since we're no longer using positional parameters, as shown in the following code snippet:

p2 = Player2( table=table, bet_strategy=flat_bet, game_strategy=dumb )

Why do this?

It does have a potential advantage. A class defined like this is quite open to extension. We can, with only a few specific worries, supply additional keyword parameters to a constructor.

The following is the expected use case:

>>> p1= Player2( table=table, bet_strategy=flat_bet, game_strategy=dumb)
>>> p1.game()

The following is a bonus use case:

>>> p2= Player2( table=table, bet_strategy=flat_bet, game_strategy=dumb, log_name="Flat/Dumb" )
>>> p2.game()

We've added a log_name attribute without touching the class definition. This can be used, perhaps, as part of a larger statistical analysis. The Player2.log_name attribute can be used to annotate logs or other collected data.

We are limited in what we can add; we can only add parameters that fail to conflict with the names already in use within the class. Some knowledge of the class implementation is required to create a subclass that doesn't abuse the set of keywords already in use. Since the **kw parameter provides little information, we need to read carefully. In most cases, we'd rather trust the class to work than review the implementation details.

This kind of keyword-based initialization can be done in a superclass definition to make it slightly simpler for the superclass to implement subclasses. We can avoiding writing an additional __init__() method in each subclass when the unique feature of the subclass involves simple new instance variables.

The disadvantage of this is that we have obscure instance variables that aren't formally documented via a subclass definition. If it's only one small variable, an entire subclass might be too much programming overhead to add a single variable to a class. However, one small variable often leads to a second and a third. Before long, we'll realize that a subclass would have been smarter than an extremely flexible superclass.

We can (and should) hybridize this with a mixed positional and keyword implementation as shown in the following code snippet:

class Player3( Player ):
    def __init__( self, table, bet_strategy, game_strategy, **extras ):
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table= table
        self.__dict__.update( extras )

This is more sensible than a completely open definition. We've made the required parameters positional parameters. We've left any nonrequired parameters as keywords. This clarifies the use of any extra keyword arguments given to the __init__() method.

This kind of flexible, keyword-based initialization depends on whether we have relatively transparent class definitions. This openness to change requires some care to avoid debugging name clashes because the keyword parameter names are open-ended.

Initialization with type validation

Type validation is rarely a sensible requirement. In a way, this might be a failure to fully understand Python. The notional objective is to validate that all of the arguments are of a proper type. The issue with trying to do this is that the definition of proper is often far too narrow to be truly useful.

This is different from validating that objects meet other criteria. Numeric range checking, for example, may be essential to prevent infinite loops.

What can create problems is trying to do something like the following in an __init__() method:

class ValidPlayer:
    def __init__( self, table, bet_strategy, game_strategy ):
        assert isinstance( table, Table )
        assert isinstance( bet_strategy, BettingStrategy )
        assert isinstance( game_strategy, GameStrategy )

        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table= table

The isinstance() method checks circumvent Python's normal duck typing.

We write a casino game simulation in order to experiment with endless variations on GameStrategy. These are so simple (merely four methods) that there's little real benefit from inheritance from the superclass. We could define the classes independently, lacking an overall superclass.

The initialization error-checking shown in this example would force us to create subclasses merely to pass the error check. No usable code is inherited from the abstract superclass.

One of the biggest duck typing issues surrounds numeric types. Different numeric types will work in different contexts. Attempts to validate the types of arguments may prevent a perfectly sensible numeric type from working properly. When attempting validation, we have the following two choices in Python:

  • We write validation so that a relatively narrow collection of types is permitted, and someday the code will break because a new type that would have worked sensibly is prohibited

  • We eschew validation so that a broad collection of types is permitted, and someday the code will break because a type that would not work sensibly was used

Note that both are essentially the same. The code could perhaps break someday. It either breaks because a type was prevented from being used even though it's sensible or a type that's not really sensible was used.

Tip

Just allow it

Generally, it's considered better Python style to simply permit any type of data to be used.

We'll return to this in Chapter 4, The ABCs of Consistent Design.

The question is this: why restrict potential future use cases?

And the usual answer is that there's no good reason to restrict potential future use cases.

Rather than prevent a sensible, but possibly unforeseen, use case, we can provide documentation, testing, and debug logging to help other programmers understand any restrictions on the types that can be processed. We have to provide the documentation, logging, and test cases anyway, so there's minimal additional work involved.

The following is an example docstring that provides the expectations of the class:

class Player:
    def __init__( self, table, bet_strategy, game_strategy ):
        """Creates a new player associated with a table, and configured with proper betting and play strategies

        :param table: an instance of :class:`Table`
        :param bet_strategy: an instance of :class:`BettingStrategy`
        :param  game_strategy: an instance of :class:`GameStrategy`
        """
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table= table

The programmer using this class has been warned about what the type restrictions are. The use of other types is permitted. If the type isn't compatible with the expected type, then things will break. Ideally, we'll use too like unittest or doctest to uncover the breakage.

Initialization, encapsulation, and privacy

The general Python policy regarding privacy can be summed up as follows: we're all adults here.

Object-oriented design makes an explicit distinction between interface and implementation. This is a consequence of the idea of encapsulation. A class encapsulates a data structure, an algorithm, an external interface, or something meaningful. The idea is to have the capsule separate the class-based interface from the implementation details.

However, no programming language reflects every design nuance. Python, typically, doesn't implement all design considerations as explicit code.

One aspect of a class design that is not fully carried into code is the distinction between the private (implementation) and public (interface) methods or attributes of an object. The notion of privacy in languages that support it (C++ or Java are two examples) is already quite complex. These languages include settings such as private, protected, and public as well as "not specified", which is a kind of semiprivate. The private keyword is often used incorrectly, making subclass definition needlessly difficult.

Python's notion of privacy is simple, as follows:

  • It's all essentially public. The source code is available. We're all adults. Nothing can be truly hidden.

  • Conventionally, we'll treat some names in a way that's less public. They're generally implementation details that are subject to change without notice, but there's no formal notion of private.

Names that begin with _ are honored as less public by some parts of Python. The help() function generally ignores these methods. Tools such as Sphinx can conceal these names from documentation.

Python's internal names begin (and end) with __. This is how Python internals are kept from colliding with application features above the internals. The collection of these internal names is fully defined by the language reference. Further, there's no benefit to trying to use __ to attempt to create a "super private" attribute or method in our code. All that happens is that we create a potential future problem if a release of Python ever starts using a name we chose for internal purposes. Also, we're likely to run afoul of the internal name mangling that is applied to these names.

The rules for the visibility of Python names are as follows:

  • Most names are public.

  • Names that start with _ are somewhat less public. Use them for implementation details that are truly subject to change.

  • Names that begin and end with __ are internal to Python. We never make these up; we use the names defined by the language reference.

Generally, the Python approach is to register the intent of a method (or attribute) using documentation and a well-chosen name. Often, the interface methods will have elaborate documentation, possibly including doctest examples, while the implementation methods will have more abbreviated documentation and may not have doctest examples.

For programmers new to Python, it's sometimes surprising that privacy is not more widely used. For programmers experienced in Python, it's surprising how many brain calories get burned sorting out private and public declarations that aren't really very helpful because the intent is obvious from the method names and the documentation.

 

Summary


In this chapter, we have reviewed the various design alternatives of the __init__() method. In the next chapter, we will take a look at the special methods, along with a few advanced ones as well.

About the Author

  • Steven F. Lott

    Steven F. Lott has been programming since the 70s, when computers were large, expensive, and rare. As a contract software developer and architect, he has worked on hundreds of projects, from very small to very large. He's been using Python to solve business problems for almost 20 years. He's currently leveraging Python to implement cloud management tools. His other titles with Packt Publishing include Python Essentials, Mastering Object-Oriented Python, Functional Python Programming, and Python for Secret Agents. Steven is currently a technomad who lives in various places on the east coast of the U.S.

    Browse publications by this author

Latest Reviews

(20 reviews total)
I buy tree ebooks about Python, but I couldn't download source files, My ******* username (email address) are not being recognized by packt portal and support insist telling me that it is my problem!!. Keep those ******* files for ya!! and fix your software!
The language can be a bit obtuse, but so far it is clarifying and building my understanding of classes with Python.
Cannot download code files.

Recommended For You

Python 3 Object-Oriented Programming - Third Edition

Uncover modern Python with this guide to Python data structures, design patterns, and effective object-oriented techniques

By Dusty Phillips