# 32. Method Calls

# 32. Method Calls

Method calls are function calls that usually begin with attribute access. They are central to Python’s object model because most object behavior is exposed through methods.

A method call such as:

```python id="l3owku"
obj.method(arg)
```

looks like one operation, but CPython executes several conceptual steps:

```text id="j39rdj"
load obj
look up attribute method
bind obj if needed
load arg
call the resolved callable
return result or raise exception
```

At the language level, this is attribute lookup followed by a call. At the CPython level, this path is optimized heavily because method calls are common.

## 32.1 Method Calls Are Attribute Access Plus Call

The source expression:

```python id="xgyvgk"
obj.method(10)
```

can be understood as:

```python id="gg912h"
tmp = obj.method
tmp(10)
```

This is the correct semantic model. The attribute lookup happens first. The result of that lookup is then called.

That result may be:

```text id="mfp3rd"
a bound method
a function
a built-in method
a callable object
a descriptor result
a property result
any object returned by __getattribute__
```

The call succeeds only if the resolved value is callable.

Example:

```python id="hsu4g1"
class C:
    value = 10

obj = C()
obj.value()
```

This fails because `obj.value` resolves to `10`, and an integer is not callable.

## 32.2 Plain Function Stored on a Class

A function defined inside a class becomes a descriptor.

```python id="ys9rpg"
class User:
    def greet(self, name):
        return "hello " + name
```

The class dictionary contains a function object:

```python id="vcrq8f"
print(User.__dict__["greet"])
```

When accessed through an instance:

```python id="moa6m1"
u = User()
u.greet
```

the function’s descriptor behavior binds the instance as `self`.

Conceptually:

```text id="ho345m"
User.__dict__["greet"].__get__(u, User)
    -> bound method
```

The bound method stores:

```text id="3tilp4"
function object
self object
```

Calling:

```python id="tnkuwp"
u.greet("Ada")
```

is conceptually equivalent to:

```python id="zdsl7s"
User.greet(u, "Ada")
```

The automatic `self` argument is the main distinction between function calls and ordinary instance method calls.

## 32.3 Bound Methods

A bound method packages a function with an object.

```python id="xz4yfy"
class C:
    def f(self, x):
        return x + 1

obj = C()
m = obj.f

print(m(10))
```

Here, `m` is a bound method. It remembers `obj`.

Conceptually:

```text id="vqipye"
bound method
    __func__ -> C.f
    __self__ -> obj
```

You can inspect this:

```python id="e0hgzo"
print(m.__func__)
print(m.__self__)
```

When `m(10)` is called, CPython calls the underlying function with `obj` inserted as the first argument:

```python id="9rt8ja"
m.__func__(m.__self__, 10)
```

## 32.4 Unbound Function Access Through the Class

Accessing a method through the class gives a function-like object that does not bind an instance in the same way.

```python id="kyy416"
class C:
    def f(self, x):
        return x + 1

obj = C()

print(C.f(obj, 10))
```

Here, `obj` is passed explicitly.

The class access:

```python id="0trmyu"
C.f
```

does not bind a specific instance as `self`.

Conceptually:

```text id="xgfyue"
C.__dict__["f"].__get__(None, C)
    -> function object or descriptor result suitable for class access
```

So these are equivalent for normal instance methods:

```python id="1yhu3p"
obj.f(10)
C.f(obj, 10)
```

The first form performs instance binding. The second form passes the instance explicitly.

## 32.5 Descriptor Protocol

Method binding is part of the descriptor protocol.

A descriptor is an object with one or more of these methods:

```text id="02rkfq"
__get__
__set__
__delete__
```

Functions are non-data descriptors because they define `__get__`.

When CPython evaluates:

```python id="8f0fmh"
obj.method
```

and finds a function object in the class, it calls the function’s descriptor logic.

Conceptually:

```python id="q89qnh"
method = function.__get__(obj, type(obj))
```

This produces the bound method.

The descriptor protocol also powers:

```text id="7keg5l"
methods
staticmethod
classmethod
property
slots
many built-in attributes
ORM fields
cached attributes
validation descriptors
```

Method calls cannot be understood fully without descriptors.

## 32.6 Data vs Non-Data Descriptors

Descriptors come in two broad categories.

| Descriptor kind | Defines | Priority |
|---|---|---|
| Data descriptor | `__get__` and `__set__` or `__delete__` | Higher than instance dictionary |
| Non-data descriptor | Usually only `__get__` | Lower than instance dictionary |

Normal functions stored on classes are non-data descriptors.

This means an instance attribute can shadow a method:

```python id="8vtwg1"
class C:
    def f(self):
        return "method"

obj = C()
obj.f = lambda: "instance function"

print(obj.f())
```

The instance dictionary contains `f`, so it wins over the non-data descriptor in the class.

A property is a data descriptor, so it wins over the instance dictionary:

```python id="tfs80s"
class C:
    @property
    def x(self):
        return 10
```

The lookup rules affect what object is eventually called.

## 32.7 Attribute Lookup Order for Methods

For a typical instance attribute access:

```python id="ebj6os"
obj.name
```

CPython follows a lookup process roughly like this:

```text id="r7zrsx"
1. Determine type(obj)
2. Look for name in type and base classes
3. If a data descriptor is found, call its __get__
4. Otherwise, look in obj.__dict__ if present
5. If found in instance dictionary, return it
6. Otherwise, if a non-data descriptor was found, call its __get__
7. Otherwise, return the class attribute
8. Otherwise, call __getattr__ if defined
9. Otherwise, raise AttributeError
```

A method call depends on this lookup. The method is not selected by name alone. It is selected by the full attribute lookup protocol.

## 32.8 Method Call Bytecode

For:

```python id="fwr5qr"
def call(obj, x):
    return obj.method(x)
```

conceptual bytecode looks like:

```text id="yqk6u7"
LOAD_FAST obj
LOAD_METHOD method
LOAD_FAST x
CALL 1
RETURN_VALUE
```

The exact instructions vary by Python version, but modern CPython distinguishes method loading from generic attribute loading in common cases.

The goal is to optimize this pattern:

```python id="2kv28s"
obj.method(arg)
```

without changing the semantic result.

## 32.9 Why `LOAD_METHOD` Exists

A naive method call would do this:

```text id="dg1ri1"
LOAD_FAST obj
LOAD_ATTR method
LOAD_FAST arg
CALL 1
```

If `method` is an ordinary function on the class, `LOAD_ATTR` creates a bound method object.

Then `CALL` immediately calls it.

That temporary bound method allocation is often unnecessary.

An optimized path can instead load:

```text id="eeqqg9"
underlying function
self object
```

and call the function directly with `self` inserted.

Conceptually:

```text id="q3brdt"
obj.method(arg)

optimized:
    function = C.__dict__["method"]
    self = obj
    call function(self, arg)
```

This avoids creating a bound method object in the common immediate-call case.

## 32.10 Bound Method Allocation Avoidance

Consider a loop:

```python id="v4zxb6"
for item in items:
    obj.process(item)
```

A naive implementation could allocate one bound method per iteration:

```text id="th33f3"
obj.process -> new bound method
call bound method
discard bound method
```

That would create unnecessary allocation and reference count traffic.

CPython’s method-call optimization avoids this in common cases.

The semantic model remains:

```python id="vxkr9d"
tmp = obj.process
tmp(item)
```

But the implementation may skip materializing `tmp` as a heap object when the method is called immediately.

## 32.11 When Bound Methods Are Still Created

A bound method object is still created when method access is itself the result.

Example:

```python id="vnqqc0"
m = obj.method
```

Here, Python code asks for the attribute value. CPython must produce the bound method object because the program may store it, inspect it, pass it around, or call it later.

```python id="ipgvev"
callbacks.append(obj.method)
```

In this case, the bound method object is the correct Python-visible result.

Optimization applies mainly to immediate call patterns where the bound method does not need to escape.

## 32.12 Built-in Methods

Built-in types expose many methods implemented in C.

```python id="dkd1sx"
xs = []
xs.append(1)
```

The list method `append` is implemented in C.

A built-in method call involves:

```text id="8n6a77"
method lookup
binding to list object
argument setup
C method call
mutation of list object
return None
```

The bound object is still conceptually present, but the implementation can be highly optimized.

For list append, the operation mutates the underlying list structure directly in C.

## 32.13 Method Descriptors

Built-in methods are often represented by descriptor objects rather than ordinary Python function objects.

Example:

```python id="sfrzai"
print(list.__dict__["append"])
```

This is not a normal Python function. It is a built-in method descriptor.

When accessed through an instance:

```python id="20nfof"
[].append
```

it produces a built-in method object bound to that list.

When called immediately:

```python id="n6aps6"
[].append(1)
```

CPython can follow a specialized native path.

## 32.14 `staticmethod`

`staticmethod` disables instance binding.

```python id="i095gy"
class Math:
    @staticmethod
    def add(a, b):
        return a + b

Math.add(2, 3)
Math().add(2, 3)
```

In both cases, no `self` is inserted.

The descriptor returns the underlying function without binding an instance.

Conceptually:

```text id="1s9dk7"
staticmethod.__get__(obj, cls)
    -> original function
```

So this works:

```python id="opmkar"
Math().add(2, 3)
```

because the function receives exactly two arguments, not three.

## 32.15 `classmethod`

`classmethod` binds the class rather than the instance.

```python id="dlj84i"
class C:
    @classmethod
    def make(cls, value):
        return cls(value)
```

Calling:

```python id="g1ck47"
C.make(10)
```

passes `C` as the first argument.

Calling through an instance:

```python id="frurij"
obj = C()
obj.make(10)
```

also passes the class, usually `C`, not `obj`.

Conceptually:

```text id="2cfzmr"
classmethod.__get__(obj, cls)
    -> bound method with cls as first argument
```

This is why class methods are useful for alternate constructors and polymorphic construction.

## 32.16 `property` and Method-Like Access

A property turns method logic into attribute access.

```python id="b2eu4v"
class C:
    @property
    def value(self):
        return 42

obj = C()
print(obj.value)
```

This does not call `obj.value()`.

The call happens during attribute access:

```text id="m2zdkj"
obj.value
    property.__get__(obj, C)
        calls getter function
        returns result
```

If the property returns a callable, then a later call can happen:

```python id="ylxm9w"
obj.factory()
```

If `factory` is a property, this means:

```text id="95d6z5"
call property getter
call returned object
```

So one source-level call can involve hidden descriptor calls before the explicit call.

## 32.17 `__getattribute__`

Every normal attribute lookup goes through `__getattribute__`.

```python id="e8vyww"
class C:
    def __getattribute__(self, name):
        print("lookup", name)
        return super().__getattribute__(name)

    def f(self):
        return 1

obj = C()
obj.f()
```

The expression `obj.f()` first calls `obj.__getattribute__("f")`.

This means method calls can be intercepted.

A custom `__getattribute__` can:

```text id="xczvn9"
return a normal bound method
return a different callable
return a non-callable
raise AttributeError
log access
implement proxies
implement lazy loading
```

The call machinery does not know the original source intent. It calls whatever attribute lookup returns.

## 32.18 `__getattr__`

`__getattr__` is called only after normal lookup fails.

```python id="zewpol"
class Dynamic:
    def __getattr__(self, name):
        if name == "run":
            return lambda: "dynamic"
        raise AttributeError(name)

obj = Dynamic()
print(obj.run())
```

Here, `run` does not exist in the instance or class. `__getattr__` returns a callable. The call then invokes that returned callable.

This is common in:

```text id="xud82d"
proxies
RPC clients
ORM models
mock objects
lazy APIs
dynamic wrappers
```

It also means method calls can be resolved dynamically at runtime.

## 32.19 Method Calls on Modules

Modules can also support dynamic attribute access with module-level `__getattr__`.

```python id="m9w5tu"
# module.py
def __getattr__(name):
    if name == "run":
        return lambda: 42
    raise AttributeError(name)
```

Then:

```python id="pkzy1x"
import module
module.run()
```

can resolve dynamically.

The lookup path differs from instance method binding, but the source pattern is still attribute access followed by call.

## 32.20 Method Resolution Order

For class instances, method lookup searches the class and its base classes using the method resolution order.

```python id="pvvfui"
class A:
    def f(self):
        return "A"

class B(A):
    pass

obj = B()
print(obj.f())
```

The lookup searches `B`, then `A`.

For multiple inheritance:

```python id="f6sw15"
class A:
    def f(self):
        return "A"

class B:
    def f(self):
        return "B"

class C(A, B):
    pass
```

The selected method depends on `C.__mro__`.

```python id="ig5h2o"
print(C.__mro__)
```

The MRO is central to method calls because it determines where class attribute lookup finds the descriptor.

## 32.21 `super()` Method Calls

`super()` changes where method lookup begins in the MRO.

```python id="ujmrwo"
class Base:
    def f(self):
        return 1

class Child(Base):
    def f(self):
        return super().f() + 1
```

The call:

```python id="w4m10h"
super().f()
```

does not mean “call the parent class by name.” It means search the MRO after the current class, with binding to the current instance.

Conceptually:

```text id="nlfcwi"
current class = Child
instance = self
MRO = [Child, Base, object]
search after Child
find Base.f
bind to self
call
```

The result is a bound method using the same instance.

## 32.22 Methods and Inheritance

A method call may use a method inherited from a base class.

```python id="xtljqg"
class Base:
    def save(self):
        return "saved"

class User(Base):
    pass

u = User()
u.save()
```

The lookup finds `save` in `Base`, then binds it to `u`.

Conceptually:

```text id="e2s1rr"
find Base.__dict__["save"]
bind with self = u
call Base.save(u)
```

The function’s defining class and the instance’s actual class can differ. This is normal.

## 32.23 Overriding Methods

Subclass methods override base methods.

```python id="3eakv8"
class Base:
    def f(self):
        return "base"

class Child(Base):
    def f(self):
        return "child"

print(Child().f())
```

Lookup finds `Child.f` before `Base.f`.

This is runtime lookup. The call is not statically bound by the variable type.

```python id="43x5xb"
def call_f(obj):
    return obj.f()
```

The method chosen depends on `type(obj)` at runtime.

## 32.24 Polymorphic Method Calls

Python method calls are dynamically dispatched.

```python id="uwb85l"
def speak(animal):
    return animal.speak()
```

Different objects can provide different implementations:

```python id="fcv0ik"
class Dog:
    def speak(self):
        return "woof"

class Cat:
    def speak(self):
        return "meow"
```

The same bytecode can call different methods depending on the runtime object.

Conceptually:

```text id="cje20f"
LOAD_FAST animal
LOAD_METHOD speak
CALL 0
```

The actual target is discovered during execution.

This dynamic dispatch is flexible but creates optimization challenges.

## 32.25 Monomorphic and Polymorphic Call Sites

A method call site is monomorphic if it usually sees one object type.

```python id="j7bxa8"
for user in users:
    user.validate()
```

If every `user` has the same type, the call site is monomorphic.

A call site is polymorphic if it sees several types:

```python id="kw1tz2"
for shape in shapes:
    shape.area()
```

where `shape` may be `Circle`, `Square`, or `Triangle`.

Inline caches work best when call sites are stable. A monomorphic call site can cache type and method lookup information more effectively.

## 32.26 Inline Caches for Method Calls

CPython can cache method lookup information near the bytecode instruction.

A method cache may record facts like:

```text id="cdvqx9"
expected receiver type
type version tag
resolved descriptor
method object or function pointer
offset or lookup result
call shape
```

On the next execution:

```text id="9o02ps"
if receiver type still matches
and type version is unchanged
    use cached method path
else
    fall back to generic lookup
```

This speeds up repeated calls without changing semantics.

If a class is modified, version tags or cache guards invalidate the fast path.

## 32.27 Class Mutation and Cache Invalidation

Python allows classes to change at runtime.

```python id="jtw9f0"
class C:
    def f(self):
        return 1

obj = C()
print(obj.f())

def new_f(self):
    return 2

C.f = new_f
print(obj.f())
```

The second call must use the new method.

Therefore, any cache that assumed the old method must be invalidated or guarded.

The safety rule:

```text id="89t5m7"
fast method path is valid only while class and lookup assumptions remain true
```

Dynamic class mutation is one reason CPython optimization uses guards.

## 32.28 Instance Dictionary Mutation

Instance attributes can also affect method lookup for non-data descriptors.

```python id="4tnwq5"
class C:
    def f(self):
        return "class method"

obj = C()
obj.f = lambda: "instance value"

print(obj.f())
```

The instance dictionary entry shadows the class function because normal functions are non-data descriptors.

A method cache must account for instance dictionary state when relevant.

This is another reason method lookup is more complex than a direct class-table jump.

## 32.29 Slots and Method Calls

Classes with `__slots__` may not have a normal instance dictionary.

```python id="vhbgrn"
class C:
    __slots__ = ("x",)

    def f(self):
        return self.x
```

Slots affect attribute storage, but method lookup still searches the class and its bases.

The absence of `__dict__` can simplify some attribute cases, but descriptors, inheritance, and dynamic class mutation still matter.

## 32.30 Special Methods

Special methods such as `__len__`, `__add__`, and `__iter__` are often looked up through type slots rather than ordinary instance attribute lookup.

Example:

```python id="k6wyb3"
len(obj)
```

does not simply execute:

```python id="9dm1lp"
obj.__len__()
```

in all respects. CPython typically uses the type’s slot for length.

This distinction matters:

```python id="0t598r"
class C:
    def __len__(self):
        return 10

obj = C()
obj.__len__ = lambda: 20

print(len(obj))
print(obj.__len__())
```

The explicit method call may use the instance attribute. The `len()` operation uses special method lookup through the type.

Special methods are optimized and integrated into object protocol slots.

## 32.31 Operator Calls vs Method Calls

Operators often map to special methods.

```python id="3pof5j"
a + b
```

may call:

```text id="pzts11"
a.__add__(b)
b.__radd__(a)
```

But CPython does not perform ordinary `obj.__add__` attribute lookup for every addition. It uses numeric slots on type objects.

So:

```python id="rizu1b"
obj.method()
```

and:

```python id="g6n28n"
obj + other
```

are both dynamic dispatch, but they use different internal paths.

Method calls use attribute lookup and call machinery. Operators use protocol slots, with fallback behavior.

## 32.32 Method Calls and Reference Counts

A method call must keep alive:

```text id="nb20se"
receiver object
resolved callable
arguments
temporary bound method, if created
return value
exception state, if raised
```

For an optimized method call that avoids a bound method object, CPython still needs to ensure the receiver remains alive while the underlying function runs.

Conceptually:

```text id="7pxphh"
load receiver
resolve method
prepare self and args
call
release temporaries
push result
```

Incorrect reference handling here can cause severe bugs because method calls often reenter Python and arbitrary code can run.

## 32.33 Method Calls Can Reenter Python

Method lookup itself can execute Python code.

Examples:

```text id="av2mft"
custom __getattribute__
descriptor __get__
property getter
__getattr__
metaclass attribute lookup
```

Then the resolved method call can execute more Python code.

A single source expression:

```python id="1eq7mu"
obj.method(arg)
```

can involve:

```text id="44juqs"
call __getattribute__
call descriptor __get__
call method body
```

Each call can raise, mutate state, or change future lookup behavior.

## 32.34 Method Calls and Exceptions

A method call can fail at multiple points:

```text id="8b856f"
receiver expression raises
attribute lookup raises AttributeError or another exception
descriptor binding raises
argument expression raises
resolved object is not callable
argument binding fails
method body raises
return cleanup raises indirectly
```

Example:

```python id="q0kkqy"
obj.missing()
```

fails during attribute lookup.

Example:

```python id="68k1hw"
obj.method(bad())
```

may fail while evaluating the argument before the method is called.

Example:

```python id="ex1xt4"
obj.method()
```

may fail inside the method body.

The bytecode error path must clean temporary stack values in all cases.

## 32.35 Method Calls and `None`

A common error:

```python id="ov87fl"
xs = []
result = xs.append(1)
result.append(2)
```

`list.append` returns `None`.

The second line fails because `result` is `None`, not the list.

At the method-call level:

```text id="dx8gic"
xs.append(1)
    mutates xs
    returns None
```

The return value is still pushed by the call instruction, then stored in `result`.

Method calls do not imply fluent chaining unless the method explicitly returns `self` or another object.

## 32.36 Chained Method Calls

Chained calls execute left to right.

```python id="jxorjs"
obj.a().b().c()
```

Conceptually:

```text id="dylm9m"
tmp1 = obj.a()
tmp2 = tmp1.b()
tmp3 = tmp2.c()
```

Each call returns the receiver for the next attribute lookup.

If `a()` returns `None`, then `.b()` fails.

Bytecode uses the stack to hold each intermediate result long enough to access the next method.

## 32.37 Fluent APIs

Some APIs deliberately return `self`:

```python id="36isx5"
class Builder:
    def set_name(self, name):
        self.name = name
        return self

    def set_age(self, age):
        self.age = age
        return self

builder = Builder().set_name("Ada").set_age(37)
```

Each method mutates the object and returns it.

The method-call machinery is ordinary. Fluent style is a library convention, not a special interpreter feature.

## 32.38 Methods as First-Class Objects

Methods can be stored and passed.

```python id="63bqx2"
class C:
    def f(self, x):
        return x + 1

obj = C()
callback = obj.f

print(callback(10))
```

The bound method keeps `obj` alive.

Conceptually:

```text id="jrk395"
callback
    function = C.f
    self = obj
```

This can affect memory lifetime:

```python id="ka89yy"
callbacks.append(obj.method)
```

The callback list now keeps the object alive through the bound method.

## 32.39 Method Calls and Garbage Collection

Bound methods can participate in reference graphs.

Example:

```python id="7qj87e"
class C:
    def f(self):
        return 1

obj = C()
obj.callback = obj.f
```

Now:

```text id="7udx0g"
obj
    -> callback bound method
        -> self obj
```

This creates a cycle.

CPython’s cyclic garbage collector can collect such cycles if they become unreachable and if finalization rules allow cleanup.

This is a practical example of how method objects connect to memory management.

## 32.40 Inspecting Methods

You can inspect method objects:

```python id="dcn13l"
class C:
    def f(self, x):
        return x + 1

obj = C()
m = obj.f

print(type(m))
print(m.__func__)
print(m.__self__)
```

You can inspect class dictionary contents:

```python id="brp4q2"
print(C.__dict__["f"])
```

You can inspect bytecode:

```python id="xq1plc"
import dis

def call(obj, x):
    return obj.f(x)

dis.dis(call)
```

This lets you see whether the compiler emits method-specific instructions for your Python version.

## 32.41 A Minimal Method Binding Model

A toy descriptor model:

```python id="b5ukc4"
class Function:
    def __init__(self, code):
        self.code = code

    def __get__(self, obj, cls):
        if obj is None:
            return self
        return BoundMethod(self, obj)

    def __call__(self, *args):
        return self.code(*args)

class BoundMethod:
    def __init__(self, func, self_obj):
        self.__func__ = func
        self.__self__ = self_obj

    def __call__(self, *args):
        return self.__func__(self.__self__, *args)
```

Use it:

```python id="f8zqma"
def body(self, x):
    return self.value + x

class C:
    value = 10

C.f = Function(body)

obj = C()
print(obj.f(5))
```

This is not CPython’s implementation, but it captures the binding idea:

```text id="lybvyc"
function stored on class
access through instance
descriptor creates bound method
call inserts self
```

## 32.42 Common Misunderstandings

| Misunderstanding | Correct model |
|---|---|
| `obj.method(x)` directly calls a function stored on `obj` | It performs attribute lookup, binding, then call |
| `self` is a keyword inserted by syntax | `self` is just the first argument by convention |
| Methods always live in instances | Normal methods live on classes and bind to instances |
| Bound methods are always allocated | CPython can avoid allocation for immediate calls |
| `staticmethod` receives `self` | It receives no automatic first argument |
| `classmethod` receives the instance | It receives the class |
| Special methods are always looked up like normal methods | Many are resolved through type slots |
| Method lookup is static | It depends on runtime type, MRO, descriptors, and instance state |

## 32.43 Reading Strategy

To study method calls, start with this program:

```python id="fybmbx"
class C:
    def f(self, x):
        return x + 1

def call(obj):
    return obj.f(10)
```

Inspect:

```python id="r4y4v3"
import dis

dis.dis(call)

obj = C()
m = obj.f

print(m.__func__)
print(m.__self__)
print(C.__dict__["f"])
```

Then vary the class:

```python id="10svlx"
@staticmethod
def s(x): ...

@classmethod
def c(cls, x): ...

@property
def p(self): ...
```

Also test:

```python id="p178dp"
obj.f = lambda x: x * 2
```

and inspect how lookup changes.

This reveals the interaction between descriptors, instance dictionaries, bytecode, and call machinery.

## 32.44 Chapter Summary

A method call is attribute lookup followed by a call. For ordinary instance methods, function descriptors bind the receiver object as the first argument, producing a bound method conceptually equivalent to calling the class function with the instance inserted.

The core model is:

```text id="xs3s0t"
obj.method(arg)
    ↓
lookup "method" on obj
    ↓
apply descriptor binding if needed
    ↓
obtain callable
    ↓
call callable with arguments
    ↓
return result or raise exception
```

CPython optimizes this path heavily. It can avoid temporary bound method allocation, cache method lookups, specialize stable call sites, and call built-in methods through fast C paths.

The semantics remain dynamic. Runtime type, MRO, descriptors, instance dictionaries, custom attribute hooks, class mutation, and special method rules all affect what method is actually called.
