Part 1: Getting Started with Object-Oriented Programming in Python
It's daunting. There's a lot to learn. We have sympathy. And some strategies.
This is Part 1 of 2. In Part 2, we’ll look at immutability, hashability, and dependency management: when to switch from @dataclass
to NamedTuple
or frozen=True
, how TypedDict
fits a schema-first workflow, and how Protocols enable painless dependency injection.
Why is OO Programming So Hard?
OO programming is filled with new terms: inheritance, composition, delegation, encapsulation, attribute, method, and other stuff that look like it came from a fake-word generator. There has to be an easier way, right?
Well.
Maybe pause a moment.
Programming is actually difficult. One cause is the way programming spans so many orders of magnitude. From individual bits to megabytes and terabytes. From clock cycles measured in hundreds of nanoseconds to software services that are expected to run flawlessly for years. One of the reason for so many new terms is because there are so many patterns for organizing software. It’s common practice to borrow words and apply them to software design, exacerbating the level of confusion.
Euclid may have told Ptolemy there was no “royal road” to learning geometry. This is every bit as true of creating software.
There’s no shortcut, but there are some ways to get started that help create useful solutions without burning quite so many brain calories on language details and patterns for designing software.
What’s Most Important?
The very first steps of a project often amount to staring at a blank screen. For a lot of folks, the tutorials all made perfect sense. But, as they consider the actual problem they want to solve, it starts to feel like the tutorials skipped over some kind of “how do I start thinking about this?” step.
Here’s a strategy we often use:
Strategy 1. Focus on the data.
Some folks hear this and ask, “Data first? What about user interactions?”
Consider the work required to switch from some old application to a shiny, new application. The data is what we move into the new application. The “user experience” of screens, buttons, scripts, and commands aren’t as precious as the information in files and databases.
It can help to start with quick notes gathered outside the world of application software development. Any old random editor is fine for collecting a summary of what the information sources are and what that information will be used for.
Python modules start with a docstring. It’s usually a big triple-quoted string. I start my files with
"""
Some notes.
"""
It’s easy to jot down anything and everything inside these triple-quoted strings. Some of us draw pictures: they let us annotate lists of data elements with boxes and arrows. There are a lot of possible diagramming techniques. Formalisms don’t matter as much as capturing a few details to help get started.
Python is very flexible. Detailed blueprints and gloriously sophisticated UML diagrams aren’t required. It’s often harder to start with diagrams than it is to start with code.
So, let’s get to some code as quickly as we can.
Start with Class Definitions
There are several choices for easy-to-write class definitions. A good first choice is to use a dataclass.
Strategy 2. Start with a Python dataclass.
Before you worry about patterns, name things and move data around. We’ll use
@dataclass
here because it gets you moving quickly. In Part 2, we’ll keep the same example and show when@dataclass
stops pulling its weight—and what to swap in.
The idea is to put something into the code editor to avoid staring at a blank screen for too long.
Start with this:
from dataclasses import dataclass
@dataclass
class MyFirstIdea:
...
From this, it’s possible to explore ideas. Note that this kind of thing is also likely to get deleted when better ideas come along. This is, after all, a blog post on how to get started.
The class names should be Capitalized, and WordsRunTogether
. This is sometimes called SnakeCase
because the word has a serpentine outline.
Add Attributes
The next step is to fill in the attributes of each object. These are the individual fields that define the state of the object. These are details of the objects in the problem domain.
This isn’t actually easy. It’s common to make mistakes. What important is the investment is tiny, so reworking a mistake is easy.
Strategy 3. Add attributes and experiment.
The format for the data elements is name: type_hint
. Python has a lot of built-in types: bool
, str
, int
, float
, complex
to name a few. Some types are composed of others: list[
T]
, set[
K]
, dict[
K,
T]
are three examples. Use any type for T. A list of numbers might be list[float]
or list[int]
, depending on which kind of number’s involved. The set and dictionary keys have some limitations that we’ll avoid for a moment.
Names for attributes are generally lower_case
, and use words_with_underscores
. This isn’t a law. It’s merely a common practice.
Let’s say we’re trying to settle an argument over best ways to play Cribbage. We need to do some simulations. That means we need cards, and decks, and hands.
from dataclasses import dataclass
@dataclass
class Card:
rank: int
suit: str
points: int
This seems to capture the essence of a card. What about a deck of cards? That’s 52 cards.
@dataclass
class Deck:
cards: list[Card]
That’s a start.
We haven’t done much, but, we have some elements that let us create working code.
Work With The Objects
We’ll start with simple interactions with objects. It’s important to avoid stumbling over too many details of methods and how methods should be understood and defined.
A data class will have a handful of methods defined for us. One of the most important is the __init__()
method which initializes the object’s state. It’s often handy to have this written for us. (As we’ll see later, it’s not always ideal, but it’s a good start.)
I often write little functions with unhelpful names like demo_1()
or make_cards()
to make sure the class really is useful.
We can add something like this to the file we’re working on:
def make_cards():
for rank in range(1, 14):
for suit in "♣♢♡♠":
c = Card(rank, suit, rank if rank < 11 else 10)
print(c)
make_cards()
This function creates 52 Card
objects, printing them as they’re built.
(No, the Card
objects not preserved anywhere. We’ll get to that when we design the Deck
class. Each time we create a new Card
, we put the variable name c
on the object. This means any previous object with the name c
isn’t being used anymore and the storage can be recovered. Garbage collection is automatic.)
The Unicode suit characters are sometimes tricky to find on most keyboards. Instead of struggling with various key combinations, consider this.
SUITS = "\N{BLACK CLUB SUIT}\N{WHITE DIAMOND SUIT}\N{WHITE HEART SUIT}\N{BLACK SPADE SUIT}"
This string has the four Unicode characters we want. It can help to use SUITS
instead of "♣♢♡♠"
in the examples.
Save the file with a handy name like cards.py
. We can then run this script to see the code in action. A lot of IDE’s have a run button to run the file we’re working on. Others of us will have a window open to edit the text file and a terminal window open to run the file.
This little cards.py
script doesn’t do much. But, it helps confirm we’re on course with the class definition.
Well.
Adjacent to the right course.
The points
attribute initialization is less than desirable. That rank if rank < 11 else 10
computation seems out of place. It should be a feature of the Card
class definition. It doesn’t seem like it should be a feature of the function that makes a deck of cards.
Strategy 4. Review and rework the experiment.
There’s a set of OO design principles, with the initials SOLID. This pushes at the I — Interface Segregation Principle — a bit. The requirement to compute the points outside the card feels like it imposes too many details on a collaborating class. There are some ways to make it simpler.
Refactor a Computation
It’s a bit more polite for Card
objects to derive the number of points from the rank. This frees the Deck
object from having to know too much about how Card
objects are built.
We’ll need to insert a method to compute a derived attribute value.
There are a bunch of ways to do this.
Deep into the bay of “Not Obvious”, there’s a special method called
__post_init__()
which can be used to compute derived values. This also requires marking thepoints
attribute as a field that is not initialized.Out in plain sight is a method with a name like
points()
that does the computation. This introduces a slightly different syntax for points. For rank and suit, we use the attribute name:card.rank
orcard.suit
. But for a method, we have to call it like a functionLcard.points()
. It’s only a pair of ()’s. But. It’s also a shallow spot in the water we have to navigate around.Which leads us to a property. This is a method without the extra syntax.
This seems helpful.
from dataclasses import dataclass
@dataclass
class Card:
rank: int
suit: str
@property
def points(self) -> int:
return 10 if self.rank >= 11 else self.rank
Since this is about getting started, we’re going to avoid too much discussion of how a property works. The self
parameter is the reference to the object; allowing the method access to all of the attributes and methods defined by class. When we write starter_card.points
in our code, this treated as if we’d written starter_card.points()
, which is essentially Card.points(starter_card)
. The argument value for self
is the object.
For simple attributes, we use name: type
. For methods we use def name(self, parameters -> type:…
. There’s more, but that goes beyond getting started.
Now, we can have this.
def make_cards():
for rank in range(1, 14):
for suit in "♣♢♡♠":
c = Card(rank, suit)
print(c, c.points)
That seems much nicer. We can run it and see that we have built objects and displayed their state.
This little demo function reflects the heart of object-oriented programming. We defined a class of objects. We wrote collaborating code to build instances of that class of objects and interrogate the internal state of the objects.
The value of points is “encapsulated” by the class definition.
Let’s extend this a little. We’re back to strategies 3 and 4 — experiment and rework.
The Deck of Cards
The deck of cards undergoes a number of transformations. It’s shuffled. Cards are dealt into hands. For Cribbage, two hands of six cards must be extracted from the deck, leaving 40 behind. After creating the crib, a “starter card” is flipped.
The Deck
is a container of 52 Card
objects. We decided to use list[Card]
as the type hint, suggesting a stateful list of Card
objects would be a good implementation.
We can leverage Python’s list comprehension to create a list of Card
objects to initialize a Deck
.
the_deck = Deck(
[Card(rank, suit)
for rank in range(1, 14)
for suit in "♣♢♡♠"
]
)
This builds a list, [Card(rank, suit) for rank in range(1, 14) for suit in "♣︎♢♡♠︎"]
; then it initializes a Deck
object with the list.
This seems like it doesn’t really belong outside the definition of the Deck
class. Also. The cards need to be shuffled. We want a better initialization for a Deck
object. While the @dataclass
decorator creates an initialization method, in this case, we don’t actually want it.
Our Own Initialization
Writing our own initialization method means defining a special method named __init__(). This doesn’t return a value; it sets the attribute values.
from dataclasses import dataclass
import random
@dataclass
class Deck:
cards: list[Card]
def __init__(self) -> None:
self.cards = [
Card(rank, suit)
for rank in range(1, 14)
for suit in "♣♢♡♠"
]
random.shuffle(self.cards)
This seems to cover some of the use cases for a Deck
object.
We can see it in action like this:
def demo_1():
deck = Deck()
hand_1 = deck.cards[:6]
hand_2 = deck.cards[6:12]
starter = deck.cards[12]
print("dealer", hand_1)
print("pone", hand_2)
print("starter", starter)
This creates two 6-card hands and a starter card. (Yes, the two players are “dealer” and “pone”; the opponent is called “pone” in Cribbage.)
Mid-Point Review
Where did we start? Where are we now? Where are we headed?
From a blank screen, we followed a couple of strategies to gather our thoughts in the form of data structures, written out as Python data classes. We added attributes, and experimented. The experiments suggested some methods that would simplify the interface to each class.
We’ve got working object-oriented programming without very much struggle. The use of a dataclass means that a number of methods were created, and those methods made experimentation simpler.
Let’s look at some more methods that make this a little nicer to work with.
Cards are Ugly
The a subtle problem with the way cards are displayed. Here’s the last line of output from the demo_1()
function. (Your output may not match; the shuffling is randomized.)
starter Card(rank=11, suit='♣')
This isn’t generally what we expect. The method created for us by the @dataclass
decorator is useful in many ways, but not ideal for what we’re doing.
A Python object has two ways to make a string value for display:
The
__str__()
method, which makes a “pretty” string.The
__repr__()
method, which makes a string that — frequently — is an expression that will recreate the object. The stringCard(rank=1, suit='♣')
will create a newCard
instance.
It’s not clear which method is used when we display an object at the REPL prompt. We can figure out what’s going on with two built-in functions, str()
and repr()
. The str()
function appeals to an object’s __str__()
method to create a string.
We’ll switch to the interactive Python just to show another exploration technique.
>>> from cards import *
>>> deck = Deck()
>>> str(deck.cards[0])
"Card(rank=3, suit='♢')"
>>> repr(deck.cards[1])
"Card(rank=6, suit='♠')"
The internal __str__()
and __repr__()
methods are both doing the same thing. We need to override the __str__()
method to produce “pretty” output.
Here’s one way to do the string conversion:
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}"
The named
dictionary maps ranks to name strings for those few cards with names. The second argument value to named.get()
is an object to use if there’s no matching key in the dictionary. The output is a formatted string with rank and suit.
After this change, we’ll see this when we fire up interactive Python.
>>> str(deck.cards[0])
' 3♢'
This is much nicer. It’s not the whole story, though.
Sadly, the way lists are printed doesn’t really do what we want. Take a look at this:
>>> hand_1 = deck.cards[:6]
>>> print(hand_1)
[Card_P(rank=3, suit='♢'), Card_P(rank=6, suit='♠'), Card_P(rank=7, suit='♢'), Card_P(rank=1, suit='♠'), Card_P(rank=6, suit='♢'), Card_P(rank=10, suit='♡')]
This is still kind of awful.
What can we do?
We have two choices:
Write some function to use an expression like
', '.join(str(c) for c in hand_1)
to use the str() function when displaying a hand of cards.Pause a moment. (No. I don’t mean put up with ugly output.)
Let’s take a step back from this problem. What are we trying to do?
Finding New Classes
We need to format the hand of cards. When we started we used dealer_hand = deck.cards[:6]
to put six cards into the dealer’s hand.
What’s the type hint for dealer_hand
? It’s list[Card]
.
This is Python’s strength as well as a source of potential weakness: we have so many built-in types, we often forget what they are.
A Hand
— like a Deck
— is more than a list of cards.
We’ve uncovered a new class. We need to refactor our code to replace the built-in class with a new class.
Strategy 5. Refactor classes to narrow responsibilities.
In this case, the built-in list[Card]
isn’t right, and requires the collaborator to do the formatting. It seems better to define a Hand
class that does this in a single, tidy package.
Something like this:
@dataclass
class Hand:
cards: list[Card]
def __str__(self) -> str:
return ', '.join(str(c) for c in self.cards)
The class only has one special method.
Interestingly, it looks somewhat like the Deck
class. Sometimes, this is a significant overlap, and perhaps the two classes really do share some common code. In this case, it looks more like a coincidence. There are profound differences:
The
Deck
class builds its own set of 52 cards and shuffles them.The
Hand
class is given 6 cards, and will donate 2 cards to the crib. There’s no compelling reason to shuffle aHand
of cards. Indeed, because of the way Cribbage is played, there will be numerous analyses and decisions around these cards.
What’s Next?
We started with a blank screen, filled in some dataclasses, and ran some experiments.
We would up with a tidy little object model that seems to capture some important parts of the problem domain. We could depict it like this, using a tool like PlantUML.
This kind of UML diagram can help someone visualize the relationships among the classes. There isn’t a great way to depict properties in UML, so we included a stereotype, «property»
, which might be helpful.
One potential next step is going to be discarding two cards from a Hand
to make the dealer’s crib. This is a common step after the deal and before play and scoring.
Early on, we described the problem without any useful details. “…trying to settle an argument over best ways to play Cribbage.” What argument? Before doing any more programming, we need to spend a moment detailing the argument. What’s the state of play? What are the alternative strategies? What information do we need to understand the outcomes?
It’s time to go back to the triple-quoted docstring at the time of the cards.py
module and jot down some more ideas. Sometimes it helps to try and organize the ideas.
This part of the work is not object-oriented programming. These are brain calories being burned to understand the problem domain. Understanding these details — and then figuring a way to model them in software — is one of the most difficult aspects of programming. Creating an object-oriented implementation with dataclasses isn’t quite as difficult as understanding the problem and providing information a person can use to maximize their cribbage scores.
In Part 2 we will cover:
Immutability choices:
@dataclass(frozen=True)
vsNamedTuple
—what changes and why it matters.Hashability in practice: why sets and dict keys force the issue; reading “TypeError: unhashable type” as a design hint.
TypedDict
for schema-first code: when a heterogenous record beats a class.Python’s duck typing, properly used: Protocols to describe behavior, not lineage.
Dependency injection without frameworks: class attributes + Protocols to remove hard-wired types (our
Deck
→Card
refactor).Tooling that pays off: mypy/pyright to lock interfaces without slowing you down.
We’ll finish by generalising Hand
and Deck
, then return to the Cribbage simulation with a design that’s easier to extend and test.
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 Ed. 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: