Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 10

Object-Oriented Programming: Foundations

Classes, instances, and the basic mechanics of state and behaviour.

10.1 Classes and Objects

A class is a blueprint — it describes what data and behavior something of that kind will have, but doesn't itself represent any particular one. An object (or instance) is one concrete thing built from that blueprint, with its own independent copy of the data the blueprint describes. Every value seen since chapter 1 (int, list, dict, ...) is already an object of some class — this chapter is about defining new ones.

In [1]10 lines
class Dog:
    pass   # the simplest possible class - a blueprint with nothing on it yet

d1 = Dog()   # calling the class creates a new instance
d2 = Dog()

print(type(d1))            # <class '__main__.Dog'>
print(isinstance(d1, Dog))   # True
print(d1 is d2)               # False -> two separate objects, not the same one
print(id(d1) != id(d2))      # True -> distinct identities (2.4)
<class '__main__.Dog'>
True
False
True

In practice — this is literally what a Django or SQLAlchemy model is. A real e-commerce backend's User, Order, and Product classes are exactly this pattern — a blueprint for one kind of domain entity, with each instance holding one specific record's own data — just with a framework layered on top that also maps each class to a database table (18.7) instead of only living in memory.

Class docstrings — a string literal placed first in the class body becomes its __doc__, readable via ClassName.__doc__ or the interactive help():

In [2]5 lines
class A:
    """This is the doc string."""

print(A.__doc__)   # This is the doc string.
# help(A) shows the same text, formatted alongside the class's attributes
This is the doc string.

What self actually refers to — inside a method, self isn't a special keyword, just a reference (2.4) to whichever instance the method was called on. It's the exact same object as the one the caller is holding — id(obj) == id(self), for that call:

In [3]12 lines
class Dog:
    def __init__(self, name):
        self.name = name

    def show_self_identity(self):
        return id(self)

d1 = Dog("Rex")
d2 = Dog("Fido")

print(id(d1) == d1.show_self_identity())   # True -> self, during this call, IS d1
print(id(d1) == d2.show_self_identity())   # False -> during THIS call, self is d2 instead
True
False

10.2 Attributes: Instance, Class and Dynamic

Inside a class, a name can be one of three kinds:

Kind Where it lives Varies per object?
Local variable inside a method/constructor, with no self./class-name prefix n/a — gone the moment that call ends
Instance attribute on self (usually in __init__, 10.4) yes — every instance has its own
Class attribute directly in the class body no — one copy, shared by every instance

Local variables are the simple case — they work exactly like a local variable in any other function (6.8.3), attached to nothing but that one call:

In [4]16 lines
class Dog:
    def __init__(self, name):
        self.name = name

    def greet(self):
        greeting = f"Woof, I'm {self.name}"   # local variable - no self., no class name
        print(greeting)

d = Dog("Rex")
d.greet()          # Woof, I'm Rex
print(vars(d))   # {'name': 'Rex'} -> "greeting" never became part of the object at all

try:
    print(d.greeting)
except AttributeError as e:
    print("AttributeError:", e)   # 'Dog' object has no attribute 'greeting'
Woof, I'm Rex
{'name': 'Rex'}
AttributeError: 'Dog' object has no attribute 'greeting'

Instance and class attributes need more care, since both survive past a single call and a name can even mean either one depending on where it's looked up from. Looking up obj.name checks the instance's own attributes first; only if it's not found there does Python fall back to the class:

d1.species
    │
    ▼
 in d1's own __dict__?
   ├── yes → use that value
   └── no  → look on the class instead (Dog.__dict__)
In [5]15 lines
class Dog:
    species = "Canis familiaris"   # class attribute - one copy, shared

    def __init__(self, name):
        self.name = name             # instance attribute - unique per object

d1 = Dog("Rex")
d2 = Dog("Fido")

print(d1.name, d2.name)                  # Rex Fido
print(d1.species, d2.species)              # Canis familiaris Canis familiaris
print(d1.species is d2.species)          # True -> same shared object

d1.species = "Wolf-adjacent"   # this creates a NEW instance attribute on d1
print(d1.species, d2.species)   # Wolf-adjacent Canis familiaris -> d1 now shadows the class attribute
Rex Fido
Canis familiaris Canis familiaris
True
Wolf-adjacent Canis familiaris

Common mistake — a mutable class attribute. Reassigning d1.species above created a new instance attribute, leaving the shared one alone. Mutating a shared mutable object, though, changes it for everyone — there's only one list, no matter how many instances point at it:

In [6]20 lines
class Team:
    members = []   # BUG: one list, shared by every instance

    def add(self, name):
        self.members.append(name)

t1, t2 = Team(), Team()
t1.add("Ada")
print(t2.members)   # ['Ada'] -> t2 sees t1's addition too, since it's the SAME list

class TeamFixed:
    def __init__(self):
        self.members = []   # each instance builds its OWN list

    def add(self, name):
        self.members.append(name)

f1, f2 = TeamFixed(), TeamFixed()
f1.add("Ada")
print(f2.members)   # [] -> independent now
['Ada']
[]

Dynamic attributes — by default, an object's set of attributes isn't fixed by the class; new ones can be attached at any time, from anywhere, not just inside the class body. Each instance's attributes live in its own __dict__ — vars(obj) is just a shorthand for reading it:

In [7]8 lines
rex = Dog("Rex")
print(vars(rex))                  # {'name': 'Rex'}

rex.age = 3   # never declared anywhere in the class - added on the fly
print(vars(rex))                  # {'name': 'Rex', 'age': 3}
print(rex.__dict__ is vars(rex))   # True -> vars() is just a shortcut for __dict__

print(list(Dog.__dict__.keys()))   # the class's own dict never gets "age" - only rex has it
{'name': 'Rex'}
{'name': 'Rex', 'age': 3}
True
['__module__', '__firstlineno__', 'species', '__init__', '__static_attributes__', '__dict__', '__weakref__', '__doc__']

__slots__ is the opt-out of that dynamic behavior. Declaring __slots__ = (...) in a class body tells Python the exact, fixed set of attribute names instances of this class are allowed to have — nothing else can be attached later, not even from inside the class's own methods. As a trade-off, instances no longer get a __dict__ at all (their attributes are stored in a fixed-size internal layout instead), which is also why __slots__ classes use less memory per instance:

In [8]19 lines
class Vector:
    __slots__ = ("x", "y")   # only these two attribute names are allowed, ever

    def __init__(self, x, y):
        self.x = x
        self.y = y

v = Vector(1, 2)
print(v.x, v.y)   # 1 2

try:
    v.z = 3   # not declared in __slots__ -> contrast with Dog's dynamic rex.age = 3 above
except AttributeError as e:
    print("AttributeError:", e)   # 'Vector' object has no attribute 'z'

try:
    print(v.__dict__)
except AttributeError as e:
    print("AttributeError:", e)   # 'Vector' object has no attribute '__dict__' -> there isn't one at all
1 2
AttributeError: 'Vector' object has no attribute 'z' and no __dict__ for setting new attributes
AttributeError: 'Vector' object has no attribute '__dict__'

In practice — high-volume object creation. A data pipeline that constructs one object per row while processing millions of records, or a game engine creating an object per particle/entity, are cases where each instance's __dict__ overhead genuinely adds up — __slots__ is a standard, real technique for cutting per-instance memory in exactly this kind of hot path.

An instance attribute doesn't have to be created in __init__ — assigning to self.x in any method creates it, the first time that method actually runs. Until then, it simply doesn't exist yet on that instance:

In [9]15 lines
class Dog:
    def __init__(self, name):
        self.name = name

    def start_training(self):
        self.trained = True   # only exists on this instance AFTER this method runs

d = Dog("Rex")
try:
    print(d.trained)
except AttributeError as e:
    print("AttributeError:", e)   # 'Dog' object has no attribute 'trained'

d.start_training()
print(d.trained)   # True -> now it exists
AttributeError: 'Dog' object has no attribute 'trained'
True

hasattr() / getattr() / setattr() are the idiomatic, functional alternative to writing out a try/except AttributeError like the one just above — useful whenever the attribute name is dynamic, or a one-line check/default reads more clearly than a whole try block:

In [10]8 lines
d = Dog("Rex")

print(hasattr(d, "trained"))            # False -> hasn't been set yet, no exception needed to find out
print(getattr(d, "trained", False))   # False -> same idea as the try/except above, as one expression

setattr(d, "trained", True)              # equivalent to d.trained = True
print(hasattr(d, "trained"))            # True
print(getattr(d, "trained"))             # True -> no default needed now that it exists
False
False
True
True

Removing an instance attribute — del obj.name (2.5) removes it from that instance's __dict__ only. If a class attribute of the same name exists, the lookup diagram above means the object falls back to seeing that instead, instead of disappearing entirely:

In [11]17 lines
class Dog:
    species = "Canis familiaris"   # class attribute

    def __init__(self, name):
        self.name = name
        self.species = "Dingo"       # instance attribute, shadows the class one

d = Dog("Rex")
print(d.species)   # Dingo -> instance attribute wins

del d.species         # removes only the INSTANCE attribute
print(d.species)   # Canis familiaris -> falls back to the class attribute

try:
    del d.species   # nothing left at the instance level to delete anymore
except AttributeError as e:
    print("AttributeError:", e)   # 'Dog' object has no attribute 'species'
Dingo
Canis familiaris
AttributeError: 'Dog' object has no attribute 'species'

That was deletion from outside the class, via an object reference. The same thing can happen from inside a method too, via self — a method can deliberately forget one of its own instance's attributes:

In [12]15 lines
class Dog:
    def __init__(self, name):
        self.name = name

    def forget_name(self):
        del self.name   # removing an instance attribute from WITHIN the class

d = Dog("Rex")
print(d.name)   # Rex

d.forget_name()
try:
    print(d.name)
except AttributeError as e:
    print("AttributeError:", e)   # 'Dog' object has no attribute 'name'
Rex
AttributeError: 'Dog' object has no attribute 'name'

Where a class attribute can be declared, read, and modified — it can be touched from every kind of method (10.3), each with its own natural spelling:

Context How to reach the class attribute
directly in the class body just the bare name
inside __init__ ClassName.attr (self is for instance attributes, not this)
inside an instance method self.attr to read (falls back per the lookup diagram), ClassName.attr to modify
inside a @classmethod cls.attr
inside a @staticmethod ClassName.attr (no self/cls available at all)
from outside the class ClassName.attr (correct) — instance.attr = x looks the same but silently creates a shadowing instance attribute instead (10.2's mutable-attribute gotcha)
In [13]27 lines
class Counter:
    total = 0   # declared directly in the class body

    def __init__(self):
        Counter.total += 1   # modified inside __init__, via the class name

    def show(self):
        print(self.total, Counter.total)   # read via self OR the class name - both work

    @classmethod
    def reset(cls):
        cls.total = 0   # modified inside a classmethod, via cls

    @staticmethod
    def describe():
        return f"seen {Counter.total} so far"   # only the class name is available here

c1 = Counter()
c2 = Counter()
c1.show()                     # 2 2
print(Counter.describe())   # seen 2 so far

Counter.reset()
print(Counter.total)   # 0 -> reset via cls affected the real, shared attribute

c1.total = 99   # looks like the same kind of assignment, but does NOT modify the shared value
print(c1.total, c2.total, Counter.total)   # 99 0 0 -> only c1 now has its own shadowing instance attribute
2 2
seen 2 so far
0
99 0 0

Reading (not writing) a class attribute through an object reference is fine — the shadowing problem above only happens on assignment. A plain read falls through to the class attribute exactly as the lookup diagram (10.2) describes, as long as that instance has no attribute of its own by that name yet:

In [14]4 lines
c3 = Counter()   # no instance attribute named "total" was ever set on this one

print(c3.total)                      # 1 -> no shadow, so the read falls through to the (just-reset) class attribute
print(c3.total is Counter.total)   # True -> genuinely the same shared object, not a copy
1
True

10.3 Methods: Instance, Class and Static

Kind Decorator Implicit first argument Typical use
Instance method none self — the calling instance operates on one object's own state
Class method @classmethod cls — the class itself alternative constructors; operates on class-level state
Static method @staticmethod none a plain function that's just logically grouped with the class
In [15]30 lines
class Dog:
    population = 0

    def __init__(self, name):
        self.name = name
        Dog.population += 1

    def bark(self):                        # instance method - needs a specific dog
        return f"{self.name} says Woof!"

    @classmethod
    def from_greeting(cls, greeting):   # classmethod - an alternative constructor
        name = greeting.split()[-1]
        return cls(name)

    @classmethod
    def count(cls):
        return cls.population

    @staticmethod
    def is_valid_name(name):           # staticmethod - no self/cls, just grouped here
        return isinstance(name, str) and len(name) > 0

rex = Dog("Rex")
fido = Dog.from_greeting("Hi there, Fido")

print(rex.bark())                                     # Rex says Woof!
print(fido.name)                                        # Fido
print(Dog.count())                                       # 2
print(Dog.is_valid_name("Rex"), Dog.is_valid_name(""))   # True False
Rex says Woof!
Fido
2
True False

In practice — Django's Model.objects.create(...). Alternate-constructor classmethods like from_greeting above are exactly the pattern behind Django's ORM convenience methods and the standard library's own datetime.fromisoformat(...) — a named, purpose-built way to build an instance from a specific kind of input, alongside (not instead of) the plain __init__.

What self actually is — rex.bark() is really shorthand for Dog.bark(rex): accessing a method through an instance automatically supplies that instance as the first argument, producing a "bound method." Accessed through the class directly, it's just an ordinary function, and self has to be passed explicitly:

Note: a "bound method" just means the instance is already baked into the callable — obj.method (no parens, not called yet) is itself a ready-to-call object that remembers obj, so calling it needs no self argument. Class.method, in contrast, is still a plain, unbound function — it needs an instance passed in explicitly, as the demo below shows.

In [16]5 lines
print(rex.bark())        # Rex says Woof!
print(Dog.bark(rex))   # Rex says Woof! -> identical, self passed explicitly this time

print(type(rex.bark))   # <class 'method'> -> bound to rex specifically
print(type(Dog.bark))   # <class 'function'> -> just a plain function on the class
Rex says Woof!
Rex says Woof!
<class 'method'>
<class 'function'>

10.4 Constructors and Initialization

Creating an instance is actually two steps: __new__ creates the (empty) object, then __init__ initializes it — sets up its starting attributes. __init__ is what gets overridden almost all the time; __new__ is rarely touched (it matters more for immutable types or metaclasses, both out of scope here) but seeing the order makes the split clear:

In [17]13 lines
class Dog:
    def __new__(cls, *args, **kwargs):
        print("__new__ creates the instance")
        return super().__new__(cls)

    def __init__(self, name):
        print("__init__ initializes it")
        self.name = name

d = Dog("Rex")
# __new__ creates the instance
# __init__ initializes it
print(d.name)   # Rex
__new__ creates the instance
__init__ initializes it
Rex

Note: super() (covered fully in chapter 12) reaches up to the next class in the inheritance chain — here, plain object itself — so super().__new__(cls) is what actually allocates the instance's memory; everything above just adds behavior around that.

__init__ is an ordinary function otherwise — it can take default argument values (6.2), just like any other function, making some constructor arguments optional:

In [18]10 lines
class Dog:
    def __init__(self, name, breed="unknown"):
        self.name = name
        self.breed = breed

d1 = Dog("Rex", "Labrador")
d2 = Dog("Fido")   # breed omitted -> falls back to the default

print(d1.name, d1.breed)   # Rex Labrador
print(d2.name, d2.breed)   # Fido unknown
Rex Labrador
Fido unknown

Common mistake — Python has no constructor overloading. A class body is just executed top to bottom (9.1's "module is just code that runs" idea, applied to a class): defining __init__ a second time doesn't add an overload, it simply replaces the first definition, which is now gone entirely. classmethod-based factories (from_greeting in 10.3) are the idiomatic way to offer more than one way to build an object:

In [19]15 lines
class Dog:
    def __init__(self, name):        # this definition...
        self.name = name

    def __init__(self, name, breed):   # ...is completely replaced by this one
        self.name = name
        self.breed = breed

d = Dog("Rex", "Labrador")   # only the two-argument version exists at all now
print(d.name, d.breed)         # Rex Labrador

try:
    Dog("Fido")   # the one-argument version is simply gone
except TypeError as e:
    print("TypeError:", e)   # Dog.__init__() missing 1 required positional argument: 'breed'
Rex Labrador
TypeError: Dog.__init__() missing 1 required positional argument: 'breed'

10.5 Encapsulation and Access Control

Python has no true "private" attribute that the language physically blocks access to — access control here is convention plus one small mechanical trick:

Naming Convention Actually enforced?
name public — part of the intended interface no restriction at all
_name "internal use" — a hint to other developers, not to Python no restriction; still fully accessible
__name "private" name-mangled to _ClassName__name, mainly to avoid accidental clashes in subclasses
In [20]17 lines
class Account:
    def __init__(self, balance):
        self.balance = balance        # public
        self._pin = "1234"              # _protected - convention only
        self.__secret = "vault-key"   # __private - name-mangled

a = Account(100)
print(a.balance)   # 100
print(a._pin)         # 1234 -> still fully accessible, Python doesn't stop this

try:
    print(a.__secret)
except AttributeError as e:
    print("AttributeError:", e)   # 'Account' object has no attribute '__secret'

print(a._Account__secret)   # vault-key -> the mangled name still works
print(vars(a))                     # {'balance': 100, '_pin': '1234', '_Account__secret': 'vault-key'}
100
1234
AttributeError: 'Account' object has no attribute '__secret'
vault-key
{'balance': 100, '_pin': '1234', '_Account__secret': 'vault-key'}

10.6 Properties and Managed Attributes

A plain attribute accepts anything, silently — there's no way to add a rule later without changing every caller from obj.attr = x to something like obj.set_attr(x):

In [21]7 lines
class Circle:
    def __init__(self, radius):
        self.radius = radius   # plain attribute, no validation at all

c = Circle(5)
c.radius = -10   # silently accepted, even though a negative radius makes no sense
print(c.radius)   # -10
-10

@property fixes this without changing the calling syntax at all — obj.attr and obj.attr = x still look identical to the caller, but now run actual code: a getter, a setter that can validate, and (optionally) a read-only computed value with no setter:

In [22]33 lines
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius   # goes through the setter below, even here in __init__

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("temperature below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):        # read-only - computed on the fly, no setter defined
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.celsius, t.fahrenheit)   # 25 77.0

t.celsius = 30                     # looks like a plain assignment, actually calls the setter
print(t.celsius, t.fahrenheit)   # 30 86.0

try:
    t.celsius = -300
except ValueError as e:
    print("ValueError:", e)   # temperature below absolute zero

try:
    t.fahrenheit = 100
except AttributeError as e:
    print("AttributeError:", e)   # property 'fahrenheit' of 'Temperature' object has no setter
25 77.0
30 86.0
ValueError: temperature below absolute zero
AttributeError: property 'fahrenheit' of 'Temperature' object has no setter

In practice — validating a value the moment it's set. An e-commerce Product.price property that rejects a negative value, or an IoT ingestion pipeline's sensor-reading class that rejects a physically implausible measurement, uses exactly this pattern — catching bad data at the single point it enters the object, rather than trusting every caller to check it first.

Going deeper. fahrenheit above is cheap, so recomputing it on every access is fine. For a computed property that's genuinely expensive (a database query, a heavy calculation) and whose backing data doesn't change, functools.cached_property is the natural next step: it behaves like @property, but stores the result in the instance's __dict__ the first time it's accessed and just returns that cached value from then on — no @x.setter boilerplate needed to make that caching happen:

In [23]16 lines
from functools import cached_property

class Report:
    def __init__(self, rows):
        self.rows = rows
        self.compute_count = 0

    @cached_property
    def total(self):
        self.compute_count += 1   # would tick up on every access, with a plain @property
        return sum(self.rows)

r = Report([1, 2, 3, 4])
print(r.total)              # 10
print(r.total)              # 10 again
print(r.compute_count)   # 1 -> only computed once, cached in r.__dict__["total"] after that
10
10
1

A property can also define a deleter, run when del obj.attr (2.5) is used on it — useful for "clearing" a value instead of leaving it undefined:

In [24]16 lines
class Session:
    def __init__(self, token):
        self._token = token

    @property
    def token(self):
        return self._token

    @token.deleter
    def token(self):
        print("clearing the token")
        self._token = None

s = Session("abc123")
del s.token   # clearing the token
print(s.token)   # None
clearing the token
None

10.7 Class Design Basics

A class earns its place when data and the behavior that acts on it belong together — without that, passing the same values between plain functions works just as well and adds less machinery. That "belong together" quality is cohesion (the demo below even calls its class-based version the "cohesive" one); its usual counterpart is coupling — how much a class, or its callers, has to know about some other class's internals to get anything done. The goal is high cohesion, low coupling: each class's own responsibilities packed tightly together, with as little reaching into other classes' internals as possible. Compare passing a dict around versus bundling the data and its own method on one object:

In [25]21 lines
# "loose" version: a function has to be handed the same pieces of data every time
def make_point(x, y):
    return {"x": x, "y": y}

def distance(p1, p2):
    return ((p1["x"] - p2["x"]) ** 2 + (p1["y"] - p2["y"]) ** 2) ** 0.5

p1, p2 = make_point(0, 0), make_point(3, 4)
print(distance(p1, p2))   # 5.0

# cohesive version: the data and the behavior that acts on it live on one object
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def distance_to(self, other):
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

a, b = Point(0, 0), Point(3, 4)
print(a.distance_to(b))   # 5.0
5.0
5.0

A few rules of thumb that keep classes easy to reason about:

  • Single responsibility — a class should represent one concept (Point, not PointAndFileLoggerAndValidator). If describing what it does needs "and," it's probably two classes.
  • Don't reach for a class just to hold data with no behavior — a plain dict, tuple, or (10.9) a dataclass is simpler when nothing actually needs to act on that data as a unit.
  • Favor a few well-named methods over exposing raw internals — how Point stores its coordinates can change later without breaking callers, as long as distance_to() keeps working the same way.
  • How objects relate to each other (one object containing another, one object being a kind of another) is covered in depth in chapters 11 and 12.

10.9 Additional Object-Oriented Programming Concepts

Overflow bucket for this chapter — small or unclassified items that clearly belong to this domain but not yet to a specific section above.

dataclasses — a plain class holding just a few fields (10.7's "data with no behavior" case) still needs __init__, a readable __repr__, and value-based __eq__ written by hand every time. @dataclass (stdlib) generates all three from type-annotated field declarations alone:

In [26]20 lines
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

class PlainPoint:   # the hand-written equivalent, minus the boilerplate methods
    def __init__(self, x, y):
        self.x = x
        self.y = y

p1, p2 = Point(3, 4), Point(3, 4)
pp1, pp2 = PlainPoint(3, 4), PlainPoint(3, 4)

print(p1)               # Point(x=3, y=4) -> auto-generated, readable __repr__
print(p1 == p2)         # True -> auto-generated __eq__, compares fields

print(pp1)               # <__main__.PlainPoint object at 0x...> -> default repr, not useful
print(pp1 == pp2)        # False -> default __eq__ falls back to identity (2.4)
Point(x=3, y=4)
True
<__main__.PlainPoint object at 0x7f2fc8da4980>
False

Remember the Team.members = [] bug from 10.2 — a mutable class attribute shared by every instance? @dataclass refuses to let that happen by accident: giving a field a bare mutable default (a list, dict, or set literal) is a ValueError at class definition time, not a subtle runtime surprise. The fix is field(default_factory=...) — a zero-argument callable that's invoked fresh for each new instance:

In [27]19 lines
from dataclasses import field

try:
    @dataclass
    class Team:
        name: str
        members: list = []   # same bug as 10.2's Team.members, just dataclass-flavored
except ValueError as e:
    print("ValueError:", e)
    # ValueError: mutable default <class 'list'> for field members is not allowed: use default_factory

@dataclass
class TeamFixed:
    name: str
    members: list = field(default_factory=list)   # called fresh for every instance

t1, t2 = TeamFixed("Alpha"), TeamFixed("Beta")
t1.members.append("Ada")
print(t1.members, t2.members)   # ['Ada'] [] -> independent lists, no shared state
ValueError: mutable default <class 'list'> for field members is not allowed: use default_factory
['Ada'] []

In practice — the standard shape of a DTO (data transfer object). An API's request body, a config object loaded at startup, or one row of processed data moving through an ETL pipeline is very often defined as exactly this: a small @dataclass carrying typed fields between layers of a system, with no behavior beyond holding the data itself.

Every class inherits from object, even one declared with no base class at all — object is the root of the class tree (12 covers inheritance and that tree in depth) and is where the default __repr__/__eq__/__new__ seen throughout this chapter actually come from:

In [28]6 lines
class Foo:
    pass

print(Foo.__bases__)                    # (<class 'object'>,)
print(issubclass(Foo, object))          # True
print(isinstance(Foo(), object))   # True
(<class 'object'>,)
True
True