Python Objects Under the Hood: Classes, Instances, Attributes, Methods, Inheritance, and MRO

Published on
Authors

Python Objects Under the Hood: Classes, Instances, Attributes, Methods, Inheritance, and MRO

Python Under the Hood — Series 3

In Series 1, we explored how Python executes code, manages objects in memory, organizes names into namespaces, and resolves names using LEGB.

In Series 2, we built on that model to understand function calls, argument binding, mutability, closures, and decorators.

Now we can use the same mental model to understand Python's object-oriented system.

At the surface, this looks simple:

class User:
    def greet(self):
        return "Hello"


bob = User()

print(bob.greet())

But underneath, several important mechanisms are working together:

Execute class statement
        ↓
Create a class namespace
        ↓
Create a class object
        ↓
Bind the name User to that class object
        ↓
Call User()
        ↓
Create an instance object
        ↓
Initialize the instance
        ↓
Resolve bob.greet
        ↓
Search instance → class → inheritance hierarchy
        ↓
Descriptor protocol binds the method
        ↓
bob becomes self
        ↓
Call the underlying function

A useful mental model for this article is:

Classes are objects, instances are objects, attributes are resolved through layered lookup, functions stored on classes become bound methods through the descriptor protocol, and inheritance follows the Method Resolution Order (MRO).

Python class, instance, and attribute lookup

Figure 1 — A class statement creates a class object, instances hold their own state, and attribute lookup moves through the instance and class hierarchy.


1. Classes Are Objects Too

Consider:

class User:
    pass

When Python executes this class statement, it does not merely create a template.

It creates a class object.

Conceptually:

User ───► <class object>

You can inspect it:

print(User)
print(type(User))

A class is itself an object that can:

be assigned to another name
be passed to a function
be stored in a collection
be returned from another function
be inspected
be called

For example:

class User:
    pass


Person = User

bob = Person()

Conceptually:

User ────┐
         ▼
      class object
         ▲
Person ──┘

Both names refer to the same class object.


2. What Happens When Python Executes a class Statement?

Consider:

class User:
    species = "human"

    def greet(self):
        return "Hello"

A useful high-level model is:

1. Python encounters the class statement
2. A namespace is prepared for the class body
3. The class body executes
4. Names such as species and greet are created in that namespace
5. Python builds a class object from that namespace
6. The name User is bound to the resulting class object

During class-body execution, you can imagine a temporary namespace containing:

{
    "species": "human",
    "greet": <function object>
}

Then Python creates the class object:

User
  │
  ▼
<class User>
  │
  ├── species → "human"
  └── greet   → <function object>

This connects directly to Series 1:

A class body is executable Python code, and the names created while that code runs become part of the class namespace.


3. A Class Namespace

You can inspect a class namespace using:

print(User.__dict__)

You may see names such as:

__module__
species
greet
__dict__
__weakref__
__doc__

The important conceptual part is:

User namespace

species ───► "human"
greet   ───► function object

That function object will become especially important later when we explain bound methods.


4. Creating an Instance

Consider:

bob = User()

User is a class object, and class objects are callable.

Calling:

User()

creates an instance.

Conceptually:

User
 │
 │ call
 ▼
new User instance
 │
 ▼
bob

Now:

bob ───► <User instance>

The class object and the instance object are different objects:

User ───► class object

bob  ───► instance object

You can verify:

print(type(bob))
print(isinstance(bob, User))

5. __new__() and __init__()

Instance creation is often described as if __init__() creates the object.

That is not quite accurate.

At a high level:

User(...)
   ↓
__new__()
   ↓
creates / returns the instance
   ↓
__init__()
   ↓
initializes that instance

A simple example:

class User:
    def __new__(cls, *args, **kwargs):
        instance = super().__new__(cls)
        return instance

    def __init__(self, name):
        self.name = name

Then:

bob = User("Bob")

Conceptually:

1. Call User
2. User.__new__(User, "Bob")
3. New instance is created
4. User.__init__(instance, "Bob")
5. self.name = "Bob"
6. Instance is returned to caller
7. bob is bound to the instance

For most classes, you override __init__() and let the inherited __new__() handle object creation.


6. What Is self?

Consider:

class User:
    def greet(self):
        return "Hello"

A common beginner misconception is that self is a special Python keyword.

It is not.

self is a conventional parameter name.

This would technically work:

class User:
    def greet(current_object):
        return "Hello"

But Python convention strongly prefers:

self

because it communicates that the first parameter refers to the current instance.


7. self Is Bound During a Method Call

Suppose:

bob = User()
bob.greet()

The method was defined as:

def greet(self):

Yet you do not manually write:

bob.greet(bob)

So where does self come from?

Conceptually, this:

bob.greet()

behaves approximately like:

User.greet(bob)

That means:

bob ───► self

inside the function call.

This is one of the central mechanisms of Python's object model.


8. Instance Attributes

Consider:

class User:

    def __init__(self, name):
        self.name = name

Now:

bob = User("Bob")
alice = User("Alice")

Each instance can have its own attributes.

Conceptually:

bob
 │
 ▼
User instance
{
    "name": "Bob"
}


alice
 │
 ▼
User instance
{
    "name": "Alice"
}

So:

print(bob.name)
print(alice.name)

produces:

Bob
Alice

9. The Instance __dict__

For ordinary user-defined classes, instance attributes are commonly stored in a per-instance namespace accessible through:

bob.__dict__

For example:

print(bob.__dict__)

may show:

{
    "name": "Bob"
}

If we add:

bob.age = 30

the instance namespace can become:

{
    "name": "Bob",
    "age": 30
}

Conceptually:

bob instance namespace

name ───► "Bob"
age  ───► 30

10. Class Attributes vs. Instance Attributes

Consider:

class User:
    species = "human"

    def __init__(self, name):
        self.name = name

Here:

species

belongs to the class namespace.

But:

name

is typically stored on each instance.

Conceptually:

User class
│
└── species → "human"


bob instance
│
└── name → "Bob"


alice instance
│
└── name → "Alice"

Both instances can access:

bob.species
alice.species

even though species is not stored directly in either instance.

That works because Python performs attribute lookup through multiple layers.


11. Attribute Lookup

Suppose:

bob.species

Python needs to resolve the attribute:

species

A simplified mental model is:

bob
 ↓
instance
 ↓
look for species on instance
 ↓ not found
look on User class
 ↓ found
return "human"

So:

bob.species

can find an attribute defined on the class.

This lookup model is more sophisticated in real Python because descriptors and inheritance participate too, but this is the right starting point.


12. Instance Attributes Can Shadow Class Attributes

Consider:

class User:
    role = "member"


bob = User()
alice = User()

Initially:

print(bob.role)
print(alice.role)

both find the class attribute:

member

Now:

bob.role = "admin"

This creates an instance attribute.

Conceptually:

User class
└── role → "member"

bob instance
└── role → "admin"

alice instance
└── no role attribute

Now:

print(bob.role)
print(alice.role)

produces:

admin
member

The instance-level binding shadows the class-level binding for bob.


13. A Dangerous Shared Mutable Class Attribute

Consider:

class User:
    permissions = []

Then:

bob = User()
alice = User()

bob.permissions.append("admin")

Now:

print(alice.permissions)

may also show:

["admin"]

Why?

Because both instances resolve the same class-level list object.

Conceptually:

User.permissions
      │
      ▼
   shared list
      ▲
      │
bob.permissions

alice.permissions

If each instance needs independent mutable state, initialize it per instance:

class User:

    def __init__(self):
        self.permissions = []

Now each instance receives its own list.

This is closely related to the mutable-default lesson from Series 2:

Shared mutable objects remain shared until you deliberately create separate objects.


14. Methods Start as Functions in the Class Namespace

Consider:

class User:

    def greet(self):
        return "Hello"

Inside the class namespace:

greet ───► function object

You can see this directly:

print(User.__dict__["greet"])

Conceptually:

User class namespace

greet ───► <function object>

But:

bob.greet

behaves differently.

It produces a bound method.


15. What Is a Bound Method?

Consider:

bob = User()

Now inspect:

print(User.greet)
print(bob.greet)

Conceptually:

User.greet
    ↓
function object


bob.greet
    ↓
bound method
    ├── function → User.greet
    └── instance → bob

The bound method remembers which instance should be supplied as the first argument.

This explains why:

bob.greet()

automatically behaves approximately like:

User.greet(bob)

16. The Descriptor Protocol Is Behind Method Binding

Why does accessing:

bob.greet

turn a function into a bound method?

Because Python functions stored on classes implement the descriptor protocol.

At a simplified level:

bob.greet
    ↓
find greet on User
    ↓
greet is a function object
    ↓
function implements descriptor behavior
    ↓
bind bob to the function
    ↓
return bound method

Conceptually:

function.__get__(bob, User)
        ↓
bound method

This is one of the most important "under the hood" facts about Python methods:

Methods are functions whose descriptor behavior automatically binds an instance when accessed through that instance.


17. Calling a Bound Method

Suppose:

class User:

    def greet(self, message):
        return f"{message}, {self.name}"

and:

bob = User()
bob.name = "Bob"

Calling:

bob.greet("Hello")

can be understood conceptually as:

1. Resolve bob
2. Look up greet
3. Find function on User class
4. Descriptor binds bob
5. Create bound method
6. Call method with "Hello"
7. Parameters become:

   self    → bob
   message → "Hello"

This connects Series 2 directly to Series 3:

method lookup
      ↓
bound method
      ↓
ordinary function call
      ↓
argument binding
      ↓
execution frame

18. classmethod

Python can bind methods to the class instead of the instance.

Example:

class User:
    count = 0

    @classmethod
    def describe_class(cls):
        return cls.__name__

Call:

User.describe_class()

The first parameter:

cls

receives the class object.

Conceptually:

User.describe_class()
        ↓
cls → User

This makes class methods useful for:

alternative constructors
class-level configuration
subclass-aware behavior

19. staticmethod

A static method does not receive an automatic instance or class binding.

Example:

class Math:

    @staticmethod
    def add(a, b):
        return a + b

Call:

Math.add(2, 3)

Conceptually:

a → 2
b → 3

No automatic:

self

or:

cls

is inserted.

A static method is essentially a function placed inside a class namespace for organizational purposes.


20. Instance Method vs. Class Method vs. Static Method

A useful comparison:

| Method type | Automatic first argument | Typical use | | --------------- | ------------------------ | --------------------------------------- | | Instance method | self → instance | work with instance state | | Class method | cls → class | work with class/subclass state | | Static method | none | utility logically associated with class |

Conceptually:

bob.method()
    ↓
self → bob


User.class_method()
    ↓
cls → User


User.static_method()
    ↓
no automatic binding

21. getattr(), setattr(), and hasattr()

Python exposes attribute operations dynamically.

Example:

class User:
    pass


bob = User()

Set dynamically:

setattr(bob, "name", "Bob")

Equivalent in spirit to:

bob.name = "Bob"

Read:

print(getattr(bob, "name"))

Check:

print(hasattr(bob, "name"))

Delete:

delattr(bob, "name")

These functions show that attribute names can be manipulated dynamically as strings.


22. __getattribute__() and __getattr__()

Attribute access such as:

bob.name

ultimately goes through:

__getattribute__()

for ordinary objects.

A simplified idea:

bob.name
   ↓
bob.__getattribute__("name")

__getattribute__() is called for attribute access generally.

__getattr__() is different.

It acts as a fallback when normal lookup fails.

Example:

class User:

    def __getattr__(self, name):
        return f"{name} does not exist"

Then:

bob = User()

print(bob.unknown)

can return:

unknown does not exist

A useful distinction:

__getattribute__
    ↓
participates in every normal attribute lookup

__getattr__
    ↓
fallback when normal lookup cannot find the attribute

Overriding __getattribute__() requires care because almost every attribute access passes through it.


23. Inheritance

Consider:

class Animal:

    def speak(self):
        return "sound"


class Dog(Animal):
    pass

Now:

dog = Dog()

print(dog.speak())

Even though Dog does not define:

speak

Python can find it through inheritance.

Conceptually:

dog instance
   ↓
Dog
   ↓
Animal
   ↓
find speak

This is where attribute lookup expands beyond one class.


24. Overriding Methods

A subclass can define its own implementation:

class Animal:

    def speak(self):
        return "sound"


class Dog(Animal):

    def speak(self):
        return "woof"

Now:

dog = Dog()

print(dog.speak())

outputs:

woof

Lookup finds the method on Dog before reaching Animal.

Conceptually:

dog
 ↓
Dog.speak
 ↓ found
stop lookup

25. super()

Sometimes a subclass wants to extend parent behavior instead of completely replacing it.

Example:

class Animal:

    def __init__(self, name):
        self.name = name


class Dog(Animal):

    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed

When:

dog = Dog("Rex", "Labrador")

runs:

Dog.__init__
    ↓
super().__init__(name)
    ↓
Animal.__init__
    ↓
self.name = name
    ↓
back to Dog.__init__
    ↓
self.breed = breed

But super() does not simply mean:

"call my parent."

A more accurate model is:

Continue attribute lookup from the next class in the current Method Resolution Order.

That distinction becomes essential with multiple inheritance.


26. The Method Resolution Order — MRO

Consider:

class A:
    pass


class B(A):
    pass


class C(B):
    pass

Python needs a deterministic order for searching classes.

You can inspect it:

print(C.mro())

Conceptually:

C
↓
B
↓
A
↓
object

So attribute lookup follows:

C → B → A → object

This ordering is the Method Resolution Order.


27. Every Ordinary Class Ultimately Inherits from object

In Python 3:

class User:
    pass

implicitly inherits from:

object

So the MRO includes:

User
↓
object

You can inspect:

print(User.mro())

This explains where many built-in object behaviors ultimately come from.


28. Multiple Inheritance

Python allows:

class A:
    pass


class B:
    pass


class C(A, B):
    pass

Now Python needs a consistent order for attribute lookup.

The MRO may look like:

C
↓
A
↓
B
↓
object

You can inspect:

print(C.mro())

Python uses the C3 linearization algorithm to compute a consistent MRO.

You do not need to memorize the algorithm immediately.

The practical rule is:

Always think of inheritance lookup in terms of the MRO, not simply "parent then grandparent."


29. The Diamond Inheritance Problem

Consider:

      A
     / \
    B   C
     \ /
      D

In code:

class A:
    pass


class B(A):
    pass


class C(A):
    pass


class D(B, C):
    pass

Without a deterministic lookup algorithm, Python could face ambiguity.

The MRO gives one consistent order.

For example:

print(D.mro())

conceptually follows:

D
↓
B
↓
C
↓
A
↓
object

The exact ordering follows C3 linearization rules.


30. Why super() Works with Multiple Inheritance

Consider:

class A:

    def run(self):
        print("A")


class B(A):

    def run(self):
        print("B")
        super().run()


class C(A):

    def run(self):
        print("C")
        super().run()


class D(B, C):

    def run(self):
        print("D")
        super().run()

Calling:

D().run()

can follow the MRO:

D
↓
B
↓
C
↓
A
↓
object

So super() means:

continue lookup from the next class in the MRO

not:

hard-code a specific parent class

This makes cooperative multiple inheritance possible.


31. Attribute Lookup and MRO Together

Suppose:

obj.some_attribute

A simplified lookup model is:

1. Check relevant descriptor behavior
2. Check instance namespace
3. Check class
4. Follow base classes according to MRO
5. If still missing, possibly use __getattr__
6. Otherwise raise AttributeError

This simplified model is enough for most application-level reasoning.

The exact descriptor precedence matters, however, so we will look at descriptors next.

Python inheritance, MRO, and descriptors

Figure 2 — Attribute lookup follows the class hierarchy according to the MRO, while descriptors can participate in how attributes and methods are resolved.


32. What Is a Descriptor?

A descriptor is an object that defines one or more methods such as:

__get__()
__set__()
__delete__()

Descriptors allow objects stored on a class to customize attribute access.

A very small example:

class Descriptor:

    def __get__(self, instance, owner):
        return "value from descriptor"

Use:

class Example:
    value = Descriptor()

Then:

obj = Example()

print(obj.value)

calls descriptor behavior rather than simply returning the raw Descriptor object.

Conceptually:

obj.value
   ↓
find value on Example
   ↓
value is a descriptor
   ↓
call descriptor.__get__
   ↓
return computed result

33. Functions Are Descriptors

This is the mechanism behind method binding.

When a function is stored on a class:

class User:

    def greet(self):
        return "Hello"

the function object has descriptor behavior.

So:

bob.greet

can conceptually trigger:

User.greet.__get__(bob, User)

and return:

bound method

This is why understanding descriptors makes Python methods much less mysterious.


34. property Is Built on the Descriptor Protocol

Consider:

class User:

    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

Now:

bob = User("Bob", "Smith")

print(bob.full_name)

Notice:

bob.full_name

not:

bob.full_name()

The property object is a descriptor.

Conceptually:

bob.full_name
    ↓
find full_name on User
    ↓
property descriptor
    ↓
property.__get__(...)
    ↓
call getter
    ↓
return computed value

35. Property Setters

Properties can also define controlled assignment:

class User:

    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("age cannot be negative")

        self._age = value

Now:

bob = User(30)

bob.age = 31

does not simply create or replace an ordinary instance attribute named age.

The descriptor intercepts assignment.

Conceptually:

bob.age = 31
    ↓
find age descriptor
    ↓
descriptor.__set__
    ↓
run validation
    ↓
self._age = 31

Descriptors are therefore one of the foundations of managed attributes in Python.


36. Data Descriptors vs. Non-Data Descriptors

Descriptors can be divided broadly into two categories.

A data descriptor defines:

__set__
or
__delete__

in addition to descriptor access behavior.

A non-data descriptor typically defines only:

__get__

This distinction affects attribute lookup precedence.

For example:

property

is normally a data descriptor.

Ordinary function objects acting as methods are non-data descriptors.

A useful simplified precedence model is:

data descriptor
      ↓
instance dictionary
      ↓
non-data descriptor / class attribute
      ↓
base classes through MRO
      ↓
__getattr__ fallback

This explains why properties can control attribute access even when instance dictionaries exist.


37. Why Methods Can Be Shadowed on an Instance

Because ordinary methods are based on non-data descriptors, an instance attribute can shadow them.

Example:

class User:

    def greet(self):
        return "Hello"


bob = User()

bob.greet = "not a method anymore"

Now:

print(bob.greet)

returns:

not a method anymore

The instance attribute can take precedence over the class's non-data descriptor.

This is another practical example of descriptor precedence.


38. __slots__

Ordinary instances frequently use:

__dict__

for dynamic attribute storage.

Python also supports:

__slots__

Example:

class Point:
    __slots__ = ("x", "y")

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

This can change how instance attributes are stored and can reduce per-instance memory overhead in some situations.

It may also prevent arbitrary new attributes unless a __dict__ slot is included.

For example:

p = Point(1, 2)

p.z = 3

may raise:

AttributeError

The important lesson is:

__dict__ is common, but it is not the only mechanism Python can use to manage instance attributes.


39. Class Objects Have a Type Too

We established that:

User

is an object.

So what is its type?

Usually:

print(type(User))

returns:

<class 'type'>

Conceptually:

bob
 ↓ type
User


User
 ↓ type
type

This gives us:

bob is an instance of User

User is an instance of type

That leads toward metaclasses, which are an advanced topic.

For the core mental model, it is enough to know:

Classes are themselves objects, and type is the default metaclass that creates most ordinary Python classes.


40. type() Has Two Important Roles

Most developers first see:

type(obj)

which returns the object's class.

But type can also create class objects dynamically.

For example:

User = type(
    "User",
    (),
    {
        "species": "human"
    },
)

This is conceptually similar to:

class User:
    species = "human"

That makes the class-creation model clearer:

class statement
     ↓
prepare namespace
     ↓
execute class body
     ↓
metaclass builds class object

41. Everything Connects Back to Namespaces

Consider:

class User:

    role = "member"

    def __init__(self, name):
        self.name = name

We now have several namespaces:

Module namespace
    ↓
User → class object


Class namespace
    ↓
role
__init__


bob instance namespace
    ↓
name

Accessing:

bob.role

moves through these object/attribute lookup rules.

Calling:

bob.__init__

also involves method binding through descriptor behavior.

The same core ideas from Series 1 remain present:

objects
names
namespaces
lookup
binding

42. One Worked Example Tying Everything Together

Consider:

class Animal:
    kingdom = "animal"

    def __init__(self, name):
        self.name = name

    def describe(self):
        return f"{self.name} is an animal"


class Dog(Animal):

    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed

    @property
    def description(self):
        return f"{self.name} is a {self.breed}"

    def describe(self):
        parent_text = super().describe()
        return f"{parent_text} and a {self.breed}"

Now:

bob = Dog("Bob", "Labrador")

Let's trace it.


Step 1 — Class Objects Already Exist

Python has created:

Animal ───► class object
Dog    ───► class object

Dog's MRO is conceptually:

Dog
↓
Animal
↓
object

Step 2 — Calling Dog(...)

Python calls the class object:

Dog("Bob", "Labrador")

An instance is created.

Then:

Dog.__init__

is called with:

self  → new Dog instance
name  → "Bob"
breed → "Labrador"

Step 3 — super().__init__

Inside:

Dog.__init__

we call:

super().__init__(name)

super() continues lookup from the next class in the MRO.

So Python finds:

Animal.__init__

which sets:

self.name = "Bob"

Then execution returns to:

Dog.__init__

which sets:

self.breed = "Labrador"

The instance namespace now contains conceptually:

{
    "name": "Bob",
    "breed": "Labrador"
}

Step 4 — Accessing a Class Attribute

Suppose:

print(bob.kingdom)

kingdom is not in the instance namespace.

Lookup continues:

bob instance
   ↓ not found
Dog
   ↓ not found
Animal
   ↓ found

Result:

animal

Step 5 — Calling an Overridden Method

Now:

bob.describe()

Lookup finds:

Dog.describe

before:

Animal.describe

Because the function is a descriptor, Python creates a bound method:

function → Dog.describe
self     → bob

Inside that method:

super().describe()

continues along the MRO and finds:

Animal.describe

Step 6 — Accessing a Property

Now:

bob.description

finds:

description

on the Dog class.

That object is a property descriptor.

Its descriptor behavior calls the getter and returns:

Bob is a Labrador

No explicit parentheses are required.


Step 7 — The Whole Model

This one example combines:

class objects
instance objects
instance namespace
class namespace
inheritance
MRO
super()
bound methods
descriptors
properties

All of them fit into one underlying model:

Python objects expose attributes through lookup rules, and classes control much of that lookup behavior.


43. The Object-Oriented Mental Model

When you see:

bob.greet()

train yourself to think:

1. Resolve bob
        ↓
   instance object

2. Look up attribute "greet"
        ↓

3. Search using attribute rules
        ↓
   instance / descriptors / class / MRO

4. Find function on class
        ↓

5. Function descriptor binds bob
        ↓

6. Produce bound method
        ↓

7. Call method
        ↓

8. self → bob
        ↓

9. Create execution frame
        ↓

10. Execute function body

When you see:

bob.name

think:

attribute lookup
      ↓
instance state
      ↓
class and MRO if needed

When you see:

super().method()

think:

continue lookup from the next class in the MRO

44. Five Rules Worth Memorizing

Rule 1 — A class is an object

class User:
    pass

creates a class object and binds it to the name:

User

Rule 2 — Instances and classes have different roles

class
    ↓
defines shared behavior and class-level state

instance
    ↓
represents individual runtime state

Rule 3 — Methods are functions plus binding behavior

A function stored on a class can become a bound method when accessed through an instance.

Think:

bob.greet
      ↓
function + bob
      ↓
bound method

Rule 4 — Inheritance follows the MRO

Do not think only:

child → parent

Think:

object's class
     ↓
MRO
     ↓
ordered lookup path

Rule 5 — Descriptors customize attribute access

Descriptors power important Python features such as:

bound methods
property
classmethod
staticmethod
many framework field systems

They are one of the central mechanisms behind Python's object model.


45. Exercise 1 — Class vs. Instance Attribute

Predict:

class User:
    role = "member"


bob = User()
alice = User()

bob.role = "admin"

print(bob.role)
print(alice.role)
print(User.role)
Answer
admin
member
member

bob.role = "admin" creates an instance-level attribute.

So:

bob instance
└── role → "admin"

User class
└── role → "member"

alice has no instance-level role, so lookup reaches the class attribute.


46. Exercise 2 — Shared Mutable Class State

Predict:

class User:
    tags = []


bob = User()
alice = User()

bob.tags.append("python")

print(alice.tags)
Answer
["python"]

Neither instance has its own tags attribute.

Both resolve:

User.tags

which refers to the same list object.

If each user needs independent tags:

class User:

    def __init__(self):
        self.tags = []

47. Exercise 3 — Bound Method

Given:

class User:

    def greet(self, message):
        return message


bob = User()

Which expression is conceptually closest to:

bob.greet("Hello")
Answer

Approximately:

User.greet(bob, "Hello")

Attribute access:

bob.greet

creates a bound method that already carries the bob instance as the value that will become self.


48. Exercise 4 — MRO

Predict the lookup order:

class A:
    pass


class B(A):
    pass


class C(A):
    pass


class D(B, C):
    pass
Answer

Conceptually:

D
↓
B
↓
C
↓
A
↓
object

You can verify with:

print(D.mro())

Python computes the order using C3 linearization.


49. Exercise 5 — Property

Consider:

class Circle:

    def __init__(self, radius):
        self.radius = radius

    @property
    def diameter(self):
        return self.radius * 2


circle = Circle(5)

print(circle.diameter)

What happens?

Answer

Output:

10

diameter is a property descriptor.

Accessing:

circle.diameter

causes the property getter to execute.

No method-call syntax is required.


50. Final Mental Picture

The most important lesson from this article is that Python's object-oriented system is not separate from the execution model we learned earlier.

It is built from the same ideas.

Series 1
Objects
Names
Namespaces
Lookup
Execution frames
        ↓

Series 2
Functions
Argument binding
Closures
Decorators
        ↓

Series 3
Classes
Instances
Attributes
Methods
Descriptors
Inheritance
MRO

The connections are direct:

A class
    ↓
is an object

A class body
    ↓
executes code in a namespace

An instance
    ↓
is another object

An attribute
    ↓
is resolved through lookup rules

A method
    ↓
starts as a function object

Method binding
    ↓
uses the descriptor protocol

Inheritance
    ↓
extends attribute lookup

super()
    ↓
continues lookup through the MRO

property
    ↓
is a descriptor

So the complete mental model becomes:

Python's object system is built from objects, namespaces, attribute lookup, function binding, descriptors, and an ordered inheritance path called the MRO.


Where to Go Next

At this point, the first three articles provide a strong understanding of Python's core runtime architecture:

Series 1
Execution, Memory, Namespaces, LEGB
        ↓

Series 2
Functions, Arguments, Mutability,
Closures, Decorators
        ↓

Series 3
Classes, Instances, Attributes,
Methods, Inheritance, MRO

A natural fourth and final article in the core series is:

Python Runtime Under the Hood: Iterators, Generators, Context Managers, and Async

That article can explain how Python manages execution over time:

  • the iterator protocol,
  • iter() and next(),
  • how for loops work internally,
  • generators and yield,
  • suspended execution frames,
  • yield from,
  • context managers,
  • __enter__() and __exit__(),
  • how with works,
  • exceptions and cleanup,
  • coroutines,
  • async def,
  • await,
  • event loops at a conceptual level,
  • async iterators,
  • and async context managers.

That would complete the core progression:

Series 1 → How Python executes and resolves names
Series 2 → How Python calls functions and shares state
Series 3 → How Python objects and classes resolve behavior
Series 4 → How Python pauses, resumes, iterates, and coordinates execution