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: Table,
bet_strategy: BettingStrategy,
game_strategy: GameStrategy
) -> None:
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())
# etc. (omitted for now)

The __init__() method for Player seems to do little more than bookkeeping. We're simply transferring named parameters to instance variables with same name. In many cases, the @dataclass decorator can simplify this.

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(Player):

def __init__(self, **kw) -> None:
"""Must provide table, bet_strategy, game_strategy."""
self.bet_strategy: BettingStrategy = kw["bet_strategy"]
self.game_strategy: GameStrategy = kw["game_strategy"]
self.table: Table = kw["table"]

def game(self) -> None:
self.table.place_bet(self.bet_strategy.bet())
self.hand = self.table.get_hand()

This sacrifices some readability for succinctness. Each individual instance variable now requires an explicit type hint, because the parameters don't provide any information. 

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. In effect, we provide type hints and parameter names in each of the object initialization expressions.

Here's how we must provide the required parameters, as shown in the following code snippet:

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

This syntax also works with the Player class, as shown in the preceding code. For the Player2 class, it's a requirement. For the Player class, this syntax is optional. 

Using the ** construct to collect all keywords into a single variable does have a potential advantage. A class defined like this is easily extended. We can, with only a few specific concerns, supply additional keyword parameters to a constructor.

Here's an example of extending the preceding definition:

class Player2x(Player):

def __init__(self, **kw) -> None:
"""Must provide table, bet_strategy, game_strategy."""
self.bet_strategy: BettingStrategy = kw["bet_strategy"]
self.game_strategy: GameStrategy = kw["game_strategy"]
self.table: Table = kw["table"]
self.log_name: Optional[str] = kw.get("log_name")

We've added a log_name attribute without touching the class definition. This could 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. The other initialization was not changed.

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

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: Table,
bet_strategy: BettingStrategy,
game_strategy: GameStrategy,
**extras,
) -> None:
self.bet_strategy = bet_strategy
self.game_strategy = game_strategy
self.table = table
self.log_name: str = extras.pop("log_name", self.__class__.__name__)
if extras:
raise TypeError(f"Extra arguments: {extras!r}")

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

The known parameter values are popped from the extras dictionary. After this is finished, any other parameter names represent a type error. 

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset