Overload in Python

Python @overload vs Traditional Compiled Overload (Java, C#, C++)

– Overloading in statically-typed compiled languages (Java, C#, C++):
In compiled languages, method overloading is a runtime and compile-time reality.
You can define multiple functions with the exact same name as long as their parameter types or counts differ.
The compiler encodes each method with a distinct signature (name mangling / virtual tables), and the runtime executes the exact matching implementation.

– Why Python is fundamentally different:
In Python, namespaces are runtime dictionaries (__dict__).
A function definition def foo(): ... simply assigns a function object to the key "foo".
If you define foo() twice, the second definition unconditionally overwrites the first in memory.

– The role of PEP 484 and @overload:
Introduced in Python 3.5+, the @typing.overload decorator does not modify Python’s runtime execution model.
Instead, it provides a formal type contract for static type checkers (Pyright, Mypy) and IDEs to predict return types and parameter combinations based on call-site arguments.

How @overload Works: Runtime vs Static Analysis

– The Two Execution Worlds:
Understanding @overload requires separating what Python executes from what type checkers analyze.

– 1. Runtime Behavior (CPython):
At runtime, @overload is a no-op decorator.
All overloaded signatures are evaluated as dummy function definitions (containing only ...) and are immediately overwritten.
Only the final, non-decorated implementation is kept in memory and executed.

– 2. Static Analysis Behavior (Pyright / Mypy):
Static checkers evaluate overloaded signatures top-to-bottom using a « first-match wins » strategy.
The non-decorated implementation signature is hidden from callers; callers only see the overloaded declarations.

– Implementation example:

from typing import overload, Any
 
# 1. First declaration for the type checker
@overload
def process(data: int) -> str:
    ...
 
# 2. Second declaration for the type checker
@overload
def process(data: str) -> list[str]:
    ...
 
# 3. Concrete runtime implementation (hidden from IDE auto-completion)
def process(data: Any) -> Any:
    if isinstance(data, int):
        return f"Number: {data}"
    return data.split(",")

Case Study: Fixing the dump() Return Type with Literal and @overload

– The Problem with Marshmallow’s dump():
Marshmallow’s Schema.dump() is defined as:
def dump(self, obj: typing.Any, *, many: bool | None = None).
In the implementation itself, it returns a dict if many=False and a list[dict] if many=True.
However, the return type inferred by static type checkers is overly broad: list[Any] | Any.
Consequently, static checkers like Pyright raise assignment errors unless manual cast() calls are scattered across the codebase.
To fix this, we need to define dedicated @overload signatures for each expected parameter and return type combination.

– Capturing the boolean value with typing.Literal:
Annotating many: bool = False on the first overload fails: because True is an instance of bool, Pyright matches the first overload immediately and mistakenly infers a dict even when many=True is passed.
To solve this, we must discriminate with typing.Literal:
By binding each overload to Literal[False] and Literal[True], static checkers strictly separate single-object serialization from collection serialization, while a third overload acts as a fallback for dynamic boolean variables.

– Custom Schema Implementation:

from typing import Any, Generic, Literal, TypeVar, overload
from marshmallow import Schema
 
T = TypeVar("T")
 
class CustomSchema(Generic[T], Schema):
 
    # Case 1: many is omitted or explicitly False -> strictly returns dict
    @overload
    def dump(self, obj: Any, *, many: Literal[False] = False) -> dict[str, Any]:
        ...
 
    # Case 2: many is explicitly True -> strictly returns list[dict]
    @overload
    def dump(self, obj: Any, *, many: Literal[True]) -> list[dict[str, Any]]:
        ...
 
    # Case 3: many is a dynamic boolean variable -> returns union fallback
    @overload
    def dump(
        self, obj: Any, *, many: bool | None = None
    ) -> dict[str, Any] | list[dict[str, Any]]:
        ...
 
    # Real implementation executed at runtime
    def dump(self, obj: Any, *, many: bool | None = None) -> Any:
        return super().dump(obj, many=many)

– Usage and Developer Experience:
Callers no longer need explicit casts or type suppression comments:

schema = CustomSchema()
 
# Pyright infers dict[str, Any] automatically:
user_dict: dict = schema.dump(user)
 
# Pyright infers list[dict[str, Any]] automatically:
users_list: list = schema.dump([user1, user2], many=True)

Other Real-World Use Cases: open() and Mutually Exclusive Arguments

– 1. Python’s built-in open() function:
The standard library uses @overload to vary the returned stream object and forbid invalid argument combinations depending on the opening mode.
In text mode ("r"), it returns a TextIOWrapper yielding strings. In binary mode ("rb"), it returns a BufferedReader yielding bytes and disallows the encoding parameter.

from typing import overload, Literal
import io
 
# Text mode: returns TextIO, encoding is valid
@overload
def open_file(path: str, mode: Literal["r", "w"] = "r", encoding: str | None = None) -> io.StringIO:
    ...
 
# Binary mode: returns BytesIO, encoding parameter is not accepted
@overload
def open_file(path: str, mode: Literal["rb", "wb"]) -> io.BytesIO:
    ...
 
def open_file(path: str, mode: str = "r", encoding: str | None = None) -> Any:
    # Runtime dispatch logic
    if "b" in mode:
        return io.BytesIO()
    return io.StringIO()

– 2. Dependent API responses (Raw vs Parsed):
Another frequent pattern is returning either raw bytes or a parsed model depending on a flag:

from typing import overload, Literal, Any
from pydantic import BaseModel
 
class UserPayload(BaseModel):
    id: int
    name: str
 
@overload
def fetch_user(user_id: int, *, raw: Literal[True]) -> bytes:
    ...
 
@overload
def fetch_user(user_id: int, *, raw: Literal[False] = False) -> UserPayload:
    ...
 
def fetch_user(user_id: int, *, raw: bool = False) -> bytes | UserPayload:
    content = b'{"id": 1, "name": "Alice"}'
    if raw:
        return content
    return UserPayload.model_validate_json(content)

Runtime Polymorphism: functools.singledispatch vs @overload

– When you need true runtime method dispatch:
If your application requires actual polymorphic behavior at runtime (calling different code based on the argument type without writing giant if/elif isinstance(...) blocks), Python standard library provides functools.singledispatch (and singledispatchmethod for classes).

– Difference summary:
@typing.overload: Static only. Zero runtime overhead. Dictates types to IDEs and linters.
@functools.singledispatch: Runtime only. Inspects argument types dynamically and routes execution to the registered handler function.

– Example of runtime dispatch:

from functools import singledispatch
 
@singledispatch
def format_data(val: Any) -> str:
    return f"Raw: {val}"
 
@format_data.register(int)
def _(val: int) -> str:
    return f"Integer: {val:05d}"
 
@format_data.register(list)
def _(val: list) -> str:
    return f"List containing {len(val)} items"
 
# Runtime execution routing:
print(format_data(42))        # Output: Integer: 00042
print(format_data(["a", "b"])) # Output: List containing 2 items
Ce contenu a été publié dans Non classé. Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *