Part 2: Getting Started with Object-Oriented Programming in Python
Or. Why is OO Programming So Hard? Part II
Previously, we looked at some of the reasons OO programming is hard. And, we pitched a few strategies for getting started quickly:
Focus on the data.
Start with a Python dataclass.
Add attributes and experiment.
Review and rework the experiment.
Refactor classes to narrow responsibilities.
We want to look at some alternatives to using the dataclasses module and the @dataclass decorator.
Why avoid a dataclass?
Dataclasses are really helpful. As we saw, they define a number of built-in methods.
They're not always the best choice.
Consider the humble playing card.
Does it undergo any state changes? Can the Jack of Hearts morph into the Jack of Diamonds? Clearly, that's absurd.
It's just as absurd as the number 42 somehow transforming into 41.
In Python, numbers (and strings and tuples) are immutable. The value — the internal state — is fixed.
We have two ways to make a playing card class immutable:
Use the
@dataclass(frozen=True)decorator instead of the@dataclassdecorator. This is a minor change, and easy to experiment with.Switch from dataclass to
NamedTuple.
Let's look at the NamedTuple type definition.
NamedTuple instead of dataclass
The NamedTuple base class is defined in the typing module. Since it's a class — not a decorator function — it’s not used the way the @dataclass decorator is used. Here’s an example:
from typing import NamedTuple
class Card(NamedTuple):
rank: int
suit: str
@property
def points(self) -> int:
return 10 if self.rank >= 11 else self.rank
def __str__(self) -> str:
named = {1: "A", 11: "J", 12: "Q", 13: "K"}
rank = named.get(self.rank, str(self.rank))
return f"{rank:>2s}{self.suit}" Note that only two lines of code changed.
from typing import NamedTuple.class Card(NamedTuple):.
The rest of the code looks the same.
The behavior doesn't change in any big, obvious way. This may, however, expose a bug where some part of a big application tried to change the internal state of a card.
(And no, we’re not going to talk much about what a base class is. This is a tutorial on how to get started quickly.)
In this application domain, the distinction between dataclass and named tuple is minor.
In other applications, however, the named tuple does something a simple data class can't do.
Sets, dictionary keys, and immutability
As we get more comfortable with object-oriented design, we start to look at combining objects. This is particularly easy in Python, where we can have lists of objects without having to do any serious programming. Specifically, the Deck class had a list[Card] attribute. This is a type hint, and is optional. While tools can check it for us, sometimes it’s a handy reminder of what the intent behind the variable or attribute is.
There are two other built-in data collections — sets and dictionaries — that have tiny caveats surrounding their use.
Sets can only have immutable objects as members.
Dictionary keys must be immutable objects.
The rule is a bit more nuanced than that. We'll hold off on the complication for a moment.
For card games with a conventional 52-card deck, there are no duplicated cards. We don't have much use for sets or dictionaries that work with ``Card`` objects.
To discover if a hand has a pair or three of a card (called a "pair royal" in Cribbage), a dictionary that maps a rank to a count of cards with that rank is handy. In this case, the rank is an integer; they're immutable. Here’s some code to build the dictionary of ranks and counts:
rank_counts: dict[int, int] = {}
for card in hand.cards:
if card.rank not in rank_counts:
rank_counts[card.rank] = 0
rank_counts[card.rank] += 1 We can then check the value of rank_counts.value() to see if there's a number other than one. For example:
pattern = set(rank_counts.values())The patterns will be sets like {1}, {1, 2}, {1, 3}, {1, 4}, {2, 3}. Note that this particular code example can't distinguish one pair from two pair. It's not great for working with Cribbage hands, but it does show of some Python programming techniques.
These examples work because the int type is immutable. The suit's a string, and the ``str`` type is also immutable. Want to know if the hand's a flush?
suits = {card.suit for card in hand.cards}
flush = len(suits) == 1 We've created a set of suits. If all the cards are the same suit, the set of distinct suits will have one value.
These have been some places where immutability is necessary.
Do we really need immutability?
Immutability isn't needed until the application depends an object being a member of a set or a key to a dictionary. The change from @dataclass class Whatever: to class Whatever(NamedTuple): is minor.
As part of strategy 4, Review and rework the experiment, this is one of the changes that may be necessary. As part of the overall objective — avoid staring at a blank screen wondering how to start — this is something we often need to set aside until we see TypeError: unhashable type: 'Whatever'.
The error uses the word "unhashable". Above, we said "immutable". Immutable objects are hashable. Other objects can be hashable, including dataclasses created with frozen=True. The distinction seems nuanced, but it’s not.
The world of hashable objects includes any class that defines an internal __hash__() method. The tuple and the named tuple classes provide the needed __hash__() method, permitting them to act as dictionary keys and set members. And, a frozen dataclass also provides the required method to make it hashable.
Wait, wait, wait
At this point, folks with some OO experience — or folks frustrated by OO tutorials — will have a question.
"All it takes is a method being defined? What about inheritance and delegation and all that?"
Python's approach is called "Duck Typing". It’s based on this:
"When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck." — James W. Riley
If a class has a __hash__() method, that makes any object of that class hashable. Yes. It's that simple.
And, yes, you can define a __hash__() method for a class that does not have a fixed, immutable state. If the hash method and the equality test methods don’t agree, problems will ensue. (This is a quick start, rely on the built-in features and things will work nicely.)
There’s one other way to collect data, similar to a dataclass and a NamedTuple.
Typed dictionary instead of a dataclass
Typed dictionary?
We have two common varieties of dictionaries:
- Homogenous. Described by dict[KeyType, ValueType], where the keys are all of one type and the values are of another type. Our card-game example used a dictionary with keys that are integer ranks and values that are integer counts of cards with that rank.
- Heterogenous. In this case, each key's value might have a distinct type.
A heterogenous dictionary — where the values have different types is helpful for some things. It's not quite so universally cool as a dataclass.
The syntax looks nearly identical to the NamedTuple class.
from typing import TypedDict
class Card_D(TypedDict):
rank: int
suit: str This also fits with strategy 1, Focus on the data. What’s important is this class defines a specialized kind of dictionary. The initialization and some output will be noticeably different. It's awkward to define methods or properties.
Even the initialization can be a little confusing. One choice is to use explicit parameter names:
>>> c_1 = Card_D(rank=11, suit="♡")
>>> c_1
{'rank': 11, 'suit': '♡'} The parameter names of rank and suit are required to create a Card_D dictionary. The output looks like a dictionary; we can't easily tweak the __str__() method. (Type-checking tools will warn us away from doing it.)
The named parameter style is optional to create a tuple or a dataclass. (It's not required because tuple fields have an ordering; that's part of the point of a tuple definition.) We can switch our previous dataclass and NamedTuple examples to use explicit parameter names. This makes it possible to switch between using dictionaries, tuples, and dataclasses.
Here's the second way to create a typed dict object, starting from another dictionary:
>>> c_2 = Card_D({"rank": 11, "suit": "♡"})
>>> c_2
{'rank': 11, 'suit': '♡'}The Card_D dictionary is built from an existing dictionary. This can be handy when working with JSON or TOML files, or parsing a CSV file.
What's important is that tools like pyright and mypy can check the type hints in the class definition and all the places the class is used. These tools can provide useful feedback. Since this is about getting started without too much design struggle, we won't go further. This is a topic for ongoing study. It's not for a quick start with OO programming.
A dictionary is always mutable. Dictionaries can't be collected into sets. A dictionary can't be used as a key into another dictionary.
Similarities and differences
Typed dictionaries are superficially similar to named tuples and dataclasses. All three give us a quick way to provide a class with attribute names and their datatypes.
The differences?
A dataclass has a variety of options. By default it's mutable; the
frozen=Trueparameter makes the objects immutable. Any mutable object can have additional attribute values assigned to it. Forcing new attributes into an object makes type-hint checking tools like mypy very nervous.A named tuple is immutable. We can never add new field values. Any code that tries to update this will be rejected by type-hint checking tools and ordinary linting tools. Also, the fields of a named tuple have a defined ordering, and can be processed by index as well as by name. The expression
some_card[0]is the same assome_card.rank. Using names seems more clear than using an index.A typed dictionary is a dictionary; we can't easily add methods to it. (We can, but type-checking tools will complain.) It's mutable. The fields can only be referenced using
some_dict[key]syntax. We can trivially add new field values.
We have a spectrum of features. How do we choose?
Strategy 2. Start with a Python dataclass.
- Switch to named tuples to get hash values.
- Switch to typed dictionaries only if the collection of attributes keeps growing and changing and the open-ended flexibility of a dictionary seems helpful.
Above all, remember strategy 4, Review and rework the experiment.
Revisiting refactoring
Strategy 5, Refactor classes to narrow responsibilities, has a secret message behind it. There are many design principles for OO programming. One batch of principles is called SOLID. Because that’s a handy acronym to remember them.
Of these principles, the I principle, "Interface Segregation", is particularly helpful.
The idea is to minimize the external interface a class presents to its collaborators. A class should expose only what is necessary; nothing more.
In our examples, the Deck class has a sequence of Card objects and that's about all there is to this class. Pretty slim. The class avoids any mention of a game, the players, the Hand collection, or anything outside the very narrow space of creating a deck of Card objects and shuffling.
Related to this is the S principle, "Single Responsibility", which is similar in many respects. Some people find it easier to think about the responsibilities of a class than to think about the interface to a class. The interface views the class from the outside, the way collaborators see it. The responsibilities describe the class on the inside, with a focus on the underlying purpose behind the methods and attributes.
The L and O principles, "Liskov Substitution" and "Open/Closed", apply to inheritance. As long as the specialized version has the same interface as the generic version, it follows the Liskov Substitution principle. Since a specialized version can be extended, it's open to extension and closed to modification. This post is a general message about getting started quickly. A more specialized message, extending and adding features to this, will be a separate post.
The D principle, "Dependency Injection" — sometimes called "Dependency Inversion" — is central to these examples.
Dependency questions
One bit of unpleasantness about the various ``Deck`` implementations is the reference to ``Card`` or ``Card_NT`` or ``Card_D`` or whatever variant on the ``Card`` class we have in mind.
@dataclass
class Deck_D:
cards: list[Card_D]
def __init__(self) -> None:
self.cards = [
Card_D(rank=rank, suit=suit)
for rank in range(1, 14)
for suit in "♣♢♡♠"
]
random.shuffle(self.cards) What we don't like is the Card_D(rank=rank, suit=suit) part of this. This Deck_D class depends on another Card_D class in a way that's a potential pain to change.
Sure. We can edit the file. But. Is this line of code the only place we have to make the change? What if we miss some changes? (Hint: It may explode into a debugging nightmare.)
We want to replace a direct dependency with something indirect. We want to replace a very specific "call 555-123-5839" with an indirect "call your mother." The value of mother can be replaced with distinct numbers. We want to inject the class reference when the application runs, so we don't have to edit the code carefully to be sure we've changed **everything** correctly and consistently. (We want to make it more like changing a configuration file, an environment variable, a command-line parameter than changing the code.)
In Python, we can **always** assign an object to another variable. The type hints can get confusing, so we'll drop a few of them in the following example:
from dataclasses import dataclass
import random
from typing import ClassVar
@dataclass
class Deck:
card_class: ClassVar[type] = Card
cards: list
def __init__(self) -> None:
self.cards = [
Deck.card_class(rank=rank, suit=suit)
for rank in range(1, 14)
for suit in "♣♢♡♠"
]
random.shuffle(self.cards) We've introduced a ClassVar. This is an attribute that's part of the class, not part of each object created by the class. It's shared. It's accessed via self.card_class. It can also be accessed via Deck.card_class, which makes it more clear.
Also, we dropped the list[Card] hint, using only list. Why? The class name Card was too specific. We want to use the same type that the card_class uses. We'll get to this below.
Note that we do not have to do anything magical or mysterious to refer to a class. It's another object; an object used to create objects. (Unlike some languages where there are complicated-looking * and & operators involved.)
The worrying Card_D class name is replaced with Deck.card_class. This will create an instance of the class provided as the value for the card_class attribute. The Deck class is tiny, so this is all there is. The idea is that a much more complicated class might have multiple references to ``Card``, we need **all** those references to be consistent. We need each class to encapsulate the responsibility for managing their collaborative relationships.
Generic relationships
The essence of dependency injection is to use generic type names in the definition of a class. We can then replace the generic type with a concrete type to create something useful at run time. In this case, card_class: ClassVar[type] = Card is hopelessly vague. It uses the type type. This means any type. We could use int or str. This seems unhelpful. We know types like those can't work; we’d like to provide a hint that lets a type-hint-checker warn us of potential flaws.
To get useful warnings, we should really have something a tiny bit more specific than type. Something with a hint about the two initialization parameters.
Since all of our variant Card implementations are more-or-less identical, we can borrow the Duck Typing philosophy and specify the minimum features — the walk, swim, quack — of an acceptable class. In Python parlance, this is a protocol.
What does a Card need? It needs a suit, a rank, and a number of points. It has to look like this:
from typing import Protocol
class CardType(Protocol):
rank: int
suit: str
points: int
def __init__(self, rank: int, suit: str) -> None: ... Note the minimalism here: points must be an int. We didn't say whether it's an attribute or a property. This is because we don't much care how it gets done, as long as everyone agrees that it will be of int type.
Also note that we provided the __init__() method. This is an automatically generated part of a dataclass or a named tuple. When we're first starting out, we tried to ignore this. When defining a Protocol, we're not allowed to assume things that may be done automatically. We can't assume it because someone would write the whole class from scratch, avoiding any of the built-in helpers. If we state the features a collaborator must find, tools like pyright and mypy can spot potential problems.
Finally, note that we didn't write out the implementation of __init__(). This is a protocol. We provided the signature and left the details to be .... (And yes, ... is valid Python syntax; it's not a cheat code used for blog postings.)
To fling breadcrumbs to the duck analogy: if we don't specify everything, we may see an animal that doesn't swim like a duck, and is actually a coot.
We can then refactor Deck slightly to name the protocol instead of naming one (and only one) specific class.
@dataclass
class Deck:
card_class: ClassVar[type[CardType]]
cards: list[CardType]
def __init__(self) -> None:
self.cards = [
self.card_class(rank=rank, suit=suit)
for rank in range(1, 14)
for suit in "♣♢♡♠"
]
random.shuffle(self.cards) We've replaced a class with a protocol name. The card_class variable doesn't have an initial value, either.
We can use this generic deck with examples like the following:
>>> from cards_3 import Card, Deck
>>> class CardDeck(Deck):
... card_class = Card
>>> d = CardDeck()This example creates a specialization of Deck, called CardDeck. Every instance of this specialized class will have card_class set to Card. This states, emphatically, what type of Card this class collaborates with.
Anything with the CardType protocol is acceptable. Other types will not be acceptable.
Looking back at the Deck class, this class name appears more than once. We can be sure they will always be consistent when there's a change. And tools like pyright and mypy can confirm that these types are used consistently.
What's next?
We've got two classes working nicely. We can switch among various Card variants. We can move on to refactoring the Hand class to be generic, also.
Then.
We can start looking more closely at the original question. We embarked on this to settle an argument over best ways to play Cribbage. We have a foundation on which we can build a useful simulation.
Perhaps the most important aspect of this is flexibility. We've made numerous changes just trying to get started.
This is the overall summary of the 5 strategies:
Don't waste time on Look Before You Leap. (LBYL)
Or, viewed another way.
It's Easier to Ask Forgiveness Than Permission. (EAFP.)
Python permits EAFP programming. Don't stare at the blank screen; start creating dataclasses and seeing if the interaction is useful and produces the results you need.
About the Author:
Steven Lott has been programming since computers were large, expensive, and rare. Working for decades in high tech has given him exposure to a lot of ideas and techniques, some bad, but most are helpful to others. Since the 1990s, Steven has been engaged with Python, crafting an array of indispensable tools and applications. His profound expertise has led him to contribute significantly to Packt Publishing, penning notable titles like Mastering Object-Oriented Python (Packt, 2019), Modern Python Cookbook (Packt, 2024), and Python Real-World Projects (Packt, 2023). A self-proclaimed technomad, Steven’s unconventional lifestyle sees him residing on a boat, often anchored along the vibrant east coast of the US. He tries to live by the words “Don’t come home until you have a story.”
Lott is currently working on the 5th edition of Python Object-Oriented Programming, due later this year. If you liked what you read in this article and would like to read more while you wait for the new edition, you can check out Python Object-Oriented Programming, 4th Ed. (Packt, 2021).
Here is what some readers have said:






