EXAMTOPICS / TECHNOLOGY INSIGHTS

Python Institute PCAP-31-03: Python Classes, Inheritance, and Polymorphism

Object-oriented Python becomes much easier when classes are treated as namespaces and behavior definitions rather than as magical blueprints. A class stores attributes and methods. Calling the class normally creates an instance. Attribute lookup on that instance can find per-instance state, class-level state, and inherited behavior according to Python’s method-resolution rules. Inheritance and polymorphism then become consequences of that lookup model.

As of September 28, 2026, the Python Institute still lists PCAP-31-03 as the active associate-level exam while PCAP-31-04 remains in development. Across Python Institute certifications, the object model discussed here is stable Python behavior and remains central to intermediate programming regardless of the routine exam-version update.

A class defines a namespace and instances carry their own state

A class body executes to create a class object with its own namespace. Methods are function objects stored in that namespace. When an instance accesses a normal method through the class, Python binds the instance as the first argument, conventionally named self. That is why an instance method can read or modify attributes on the specific object that called it.

Instance attributes are usually created by assignment such as self.name = name in __init__. Each instance can then hold a different value. Class attributes live on the class and are shared through attribute lookup unless an instance shadows the same name with its own attribute.

The distinction matters when the class attribute is mutable. If a list is defined on the class and instances append to it without first creating an instance attribute, all those instances can observe the same shared list. Beginners often intended per-object state and accidentally created class-wide state.

__init__ initializes an already created instance

__init__ is commonly described as a constructor, but a more precise model is that object creation and object initialization are separate steps. For everyday classes, Python creates the instance and then calls __init__ to initialize it. That distinction becomes useful later when learning __new__, immutable types, or metaclasses.

The initializer should establish a valid starting state. If every instance needs a name, identifier, or configuration value, store it there rather than relying on callers to add the attribute later. Predictable initialization reduces defensive checks in other methods.

Default arguments deserve care, especially mutable defaults. A list used as a default parameter value is created when the function definition is evaluated, not freshly for every call. Using None as a sentinel and creating a new list inside the initializer is often the safer pattern for per-instance collections.

Inheritance reuses and specializes behavior through attribute lookup

A subclass names one or more base classes. If an attribute is not found on the subclass or instance, Python continues searching base classes according to the method resolution order. This lets a subclass reuse methods while overriding selected behavior.

super() supports cooperative delegation along that method resolution order. In a simple single-inheritance hierarchy, it often looks like “call the parent method,” but in multiple inheritance it is better understood as “continue lookup after the current class in the MRO.” Hard-coding a particular base-class method can break cooperative multiple-inheritance designs.

Inheritance is appropriate when the subclass truly satisfies the behavioral expectations of the base class. Reusing a few lines of code is not enough reason by itself. Composition—storing another object as an attribute—can produce clearer designs when the relationship is “has a” rather than “is a.”

Polymorphism means callers can rely on a common behavior

Python polymorphism does not require every object to share one declared base class. If multiple objects provide the method or protocol a caller needs, the caller can often use them without checking their concrete types. This is part of Python’s duck-typing culture: behavior matters more than nominal type in many designs.

Suppose several classes implement a render() method. A function can call obj.render() on any of them. Each class can produce a different result while the caller uses the same interface. That is polymorphism at work: one operation, multiple implementations selected by the object’s type and attribute lookup.

Type checks can still be appropriate at boundaries, but long chains of if isinstance(...) inside core logic often indicate that behavior could live on the objects themselves. Moving the variation into methods can reduce branching and make new subclasses easier to add.

Class and instance attributes interact through shadowing

When Python evaluates obj.x, it first considers instance-specific state and then class-level and inherited definitions according to its attribute model. If an instance assignment creates obj.x, that value can shadow the class attribute ClassName.x for that instance without changing the class value or other instances.

This can be useful for default configuration: a class attribute supplies a shared default, and selected instances override it. But it can also create confusion if code expects assigning through an instance to update a class-wide counter. In that case, the code should deliberately update the class attribute rather than accidentally creating an instance attribute.

Inspecting obj.__dict__ and ClassName.__dict__ in a learning environment can make the difference visible. The exact mechanics involve descriptors and special lookup rules, but the basic namespace distinction explains most PCAP-level questions.

Method types encode where the implicit first argument comes from

A normal instance method receives the instance as its first argument when accessed through an instance. A class method, marked with @classmethod, receives the class instead and is often used for alternate constructors or behavior that needs class-level state. A static method, marked with @staticmethod, receives no automatic instance or class argument.

These are design choices, not three interchangeable syntaxes. If behavior needs per-object state, use an instance method. If it naturally operates on the class and should support subclasses, a class method can be appropriate. If the function conceptually belongs near the class but needs neither object nor class state, a static method may be reasonable.

Beginner Python programming covers the language at a broader level. At the associate level, practice predicting what object is bound to each method and where an attribute assignment will be stored.

Multiple inheritance makes the MRO operationally important

Python computes a method resolution order for a class. The MRO defines the sequence used when looking through bases for methods and other attributes. In diamond-shaped inheritance, Python’s C3 linearization preserves a consistent ordering so cooperative super() calls can move through each class without simply calling one hard-coded parent twice.

You do not need to build complicated multiple-inheritance hierarchies to understand the exam concept. Learn how to inspect a class’s MRO and follow it when predicting which method is selected. If two base classes implement the same method, base-class order and the computed MRO matter.

Mixins are a common controlled use of multiple inheritance: small classes contribute focused behavior rather than representing a full domain entity. Even then, clear contracts and cooperative initialization are important because every class participates in the same lookup chain.

Debug object-oriented code by asking where the attribute came from

When an attribute has an unexpected value, determine whether it lives on the instance, the class, or a base class. When an overridden method is not running, inspect the actual object type and MRO. When all instances share unexpected state, look for a mutable class attribute or mutable default argument. These questions target the object model directly.

For PCAP preparation, build small hierarchies and predict output before execution. Create a base class and subclass, override one method, call super(), add a class attribute, shadow it on one instance, and compare the results. Then try two base classes with the same method and inspect the MRO. The goal is not to memorize terminology; it is to be able to predict how Python resolves behavior from the objects and namespaces involved.

Properties and descriptors show that attribute access can execute behavior.

Attribute access is not always a simple dictionary lookup. Python’s descriptor protocol allows class attributes to define behavior for getting, setting, or deleting attributes. The built-in property type uses this mechanism so code can expose attribute-style access while executing methods behind the scenes.

This matters when refactoring. A public value can begin as a plain instance attribute and later become a property that validates assignments or computes the value dynamically, often without changing caller syntax. The design should still avoid surprising side effects; reading what looks like a simple attribute should not normally trigger expensive or destructive work.

At the PCAP level, the key lesson is that classes participate actively in attribute resolution. Instance dictionaries, class dictionaries, inheritance, descriptors, and the MRO together determine what obj.name means. You do not need to implement a custom descriptor to benefit from that model.

Prefer small behavioral contracts over inheritance for its own sake.

Inheritance can reduce duplication, but a hierarchy becomes fragile when subclasses inherit methods that do not make sense for them. A good base class defines a coherent behavioral contract. Subclasses can specialize that contract without violating assumptions callers make about the base type.

When classes merely share a helper operation, composition or a standalone function can be simpler. This is particularly true when the relationship could change at runtime or when an object needs several independent collaborators. Object-oriented design is not measured by how deep the inheritance tree is; it is measured by whether state and behavior are organized so the program remains predictable.