AI systems collect state quickly: model settings, dataset metadata, client configuration, request context, evaluation results, cache keys, and pipeline steps. Classes give related state a clear home and put the behavior that works on that state next to it.
Python's object model is lighter than many class-based languages. You do not need a class for everything. But when a concept has state, rules, and behavior, a class can make the design easier to test and change.
This chapter focuses on practical object design for AI applications: when to use a plain class, when a dataclass is enough, and how Python expresses interfaces.
Python classes do not require much setup. There is no separate constructor keyword, no enforced private or public, and type annotations are optional unless you want them for tooling, documentation, or validation.
Here is a small class:
Three details are worth knowing early.
self ParameterEvery instance method receives the current object as its first argument. By convention, that argument is named self.
You write self in the method signature, and you read or write instance attributes through it: self.name, self.max_tokens, and so on. If you forget self, Python will usually raise an argument error when you call the method.
Python does not enforce private, protected, or public.
By convention, a leading underscore means "internal use" (self._cache). A double leading underscore triggers name mangling (self.__secret becomes something like self._Model__secret), which helps avoid accidental name collisions in subclasses. It does not protect secrets and it is not a security boundary.
Python often cares more about what an object can do than what class it inherits from. If an object has __len__, you can call len() on it. If it has __getitem__, you can index into it.
This is why many Python APIs accept any object that supports the right operations, even if it does not inherit from a specific framework base class.
Dunder methods are methods with names like __repr__, __len__, and __getitem__. "Dunder" means "double underscore."
These methods let your objects work with built-in Python operations such as print(), len(), ==, indexing, and for loops. AI code uses this often for dataset-like objects, configuration objects, and lightweight containers.
__init__, __repr__, and __str__You have already seen __init__. It initializes an object after Python creates it.
Now consider what happens when you print an object without defining any display methods:
That output is not useful. It tells you the type and memory location, but not the object's contents. This is where __repr__ and __str__ help.
__repr__ is for developers. It should make debugging easier and, when practical, look like valid Python code that could recreate the object.__str__ is for readable display. print() calls __str__ first and falls back to __repr__ if __str__ is not defined.The !r in the f-string calls repr() on the value, so strings include quotes. That is a common pattern in __repr__.
__eq__ and __hash__By default, two instances of a custom class compare by identity. In other words, Python asks whether they are the same object, not whether their fields have the same values.
If you want value-based equality, define __eq__:
__hash__ matter?If you want to use objects as dictionary keys or store them in sets, Python needs __hash__.
The rule is important: if two objects are equal, they must have the same hash. If you define __eq__ without __hash__, Python makes the class unhashable to avoid inconsistent dictionary and set behavior.
This comes up when you cache responses by configuration or deduplicate a list of settings.
Be careful with mutable hashable objects. If an object is used as a dictionary key, do not later change the fields that affect equality or hashing. Frozen dataclasses, which we will cover soon, are often a better fit for cache keys.
__len__ and __getitem__: Making Objects Behave Like CollectionsPython's protocol style shows up clearly in dataset code. A map-style dataset needs two basic operations: "how many items are there?" and "give me item at index i."
In PyTorch, subclassing torch.utils.data.Dataset is conventional, but the practical behavior still comes from __len__ and __getitem__.
When you define sequence-style __getitem__, Python can iterate by asking for index 0, then 1, and so on until IndexError. This is duck typing at work.
The practical lesson: many libraries care less about your class hierarchy than about the operations your object supports.
Python supports multiple inheritance, but most AI application code is easier to maintain with single inheritance, composition, or protocols. You still need the basic idea of method resolution because framework classes often use mixins.
When you call model.predict(), Python looks for the method on the instance, then on SentimentModel, then on BaseModel, and then farther up the chain if needed. With multiple inheritance, the order is called the method resolution order, or MRO. You can inspect it with ClassName.__mro__ or ClassName.mro().
You will not think about MRO every day, but it explains super(). super().__init__() calls the next class in the MRO, not simply "the parent class" in a hard-coded way. This matters most when multiple inheritance is involved.
@property for Computed AttributesThe @property decorator lets a method behave like an attribute. It is useful when a value is derived from other fields and should stay up to date:
The advantage over storing steps_per_epoch in __init__ is that it stays correct if batch_size or total_samples changes later. If the value is expensive to compute, use functools.cached_property or compute it once during validation.
@classmethod and @staticmethodA @classmethod receives the class itself as the first argument, conventionally named cls. The most common use is an alternative constructor:
A @staticmethod is a regular function that lives in the class namespace. It does not receive self or cls. Use it when the function is closely related to the class but does not need instance or class state.
Writing __init__, __repr__, and __eq__ by hand gets repetitive when a class mostly stores data. For configuration objects, API responses, evaluation records, and metadata, Python's dataclasses module can generate that boilerplate for you.
The @dataclass decorator reads the class annotations and generates __init__, __repr__, and __eq__. Default values behave like normal Python defaults.
field() for Complex DefaultsMutable defaults are a common Python mistake:
The dataclass decorator raises a ValueError for common mutable defaults like lists and dictionaries. The fix is field(default_factory=...), which creates a fresh value for each instance:
The default_factory parameter takes a callable such as list, dict, or a small function. It creates a fresh default for each instance. The repr=False parameter leaves a field out of the string representation, which is useful for internal caches or large data structures.
Configuration objects are often safer when they cannot be changed after creation. A setting that changes halfway through a run can be painful to debug. The frozen=True option makes dataclass fields read-only:
For eq=True and frozen=True, dataclasses generate __hash__ when it is safe to do so. The fields still need to be hashable at runtime. A frozen dataclass containing a list cannot be used as a dictionary key until you replace the list with a tuple or another hashable structure.
__post_init__Sometimes you need to validate inputs or compute derived fields after the generated __init__ runs. That is what __post_init__ is for:
The field(init=False) setting tells the dataclass not to include that field in the generated __init__. The field is set in __post_init__ instead.
For data containers, dataclasses are often a good default. Use plain classes when behavior and lifecycle matter more than stored fields.
A practical rule: if your class is mostly named fields with light validation or derived values, start with a dataclass. If it validates untrusted input, serializes across process boundaries, or defines an external API contract, consider Pydantic instead.
Sometimes framework or application code needs to say: "any subclass must implement these methods." Python's runtime tool for that is an abstract base class, or ABC.
If you try to instantiate BaseLLM directly, or create a subclass without implementing both abstract methods, Python raises a TypeError:
generate_batch has a default implementation in the base class. Subclasses inherit it, but they can override it with a more efficient version, such as one real batched API call instead of several individual calls.
This pattern appears throughout AI framework code: a base class defines the contract, provides shared behavior where it can, and leaves provider-specific work to subclasses.
ABCs require explicit inheritance. Your class must say something like class MyLLM(BaseLLM) to satisfy the runtime contract.
Python also supports structural typing through Protocol. With a protocol, a class can satisfy an interface by having the right methods. It does not need to inherit from the protocol.
The @runtime_checkable decorator lets you use isinstance() with the protocol. That runtime check is shallow: it checks that required attributes exist, not that full type signatures match. Without @runtime_checkable, protocols are mainly for static type checkers such as mypy and pyright.
When should you use each?
Use ABCs when you control the class hierarchy and want shared behavior. Use protocols when you want to describe what an object must be able to do, without forcing inheritance.
10 quizzes