Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Python avançat

Introducció

Aquest document recull el Python que va més enllà de la sintaxi: com està fet el llenguatge per dins, com es comporta la memòria, com s’expressen contractes amb el sistema de tipus, i com s’organitza un programa quan deixa de ser un sol fitxer. És la continuació de Python bàsic, que cobreix les construccions del dia a dia —tipus, control de flux, funcions, col·leccions, classes, errors, anotacions— i que convé tenir llegit abans d’entrar aquí.

Està organitzat en cinc parts, més un apèndix de consulta:

  1. Fonaments del model d’objectes — identitat, tipus i valor; noms lligats a objectes. És curt i val la pena llegir-lo primer, perquè tota la resta hi remet.
  2. Mutabilitat, àlies i estat — la conseqüència pràctica d’aquells fonaments: qui pot canviar què, quan una còpia no és una còpia, i com es comporten NumPy, pandas i PyTorch, que hi prenen partit per rendiment.
  3. Tipus — les anotacions com a contracte que es comprova sense executar: Protocol en lloc d’herència, genèrics, i com es desxifren les signatures de NumPy, PyTorch o scikit-learn.
  4. Organització del codi — funcions o classes, què és realment un mòdul, com s’eviten els cicles d’importació i en quines capes es reparteix un projecte.
  5. Python modern — el que han afegit les versions recents i val la pena adoptar.

Al final, la referència del catàleg: els set protocols integrats, els mètodes especials i la jerarquia de tipus. És material de consulta, i per això va al darrere.

Què hi trobaràs i què no. Aquí s’explica com funciona cada cosa. El criteri —quan convé fer-la servir en un projecte concret i quan no— depèn del context, i als cursos que s’hi recolzen aquest paper el fa el seu propi itinerari de llenguatge.

Tres nivells de lectura. No tot el que hi ha aquí s’ha de saber produir. Les seccions marcades amb 🔬 són profunditat opcional: expliquen mecanismes que val la pena reconèixer quan es veuen en una biblioteca, però que gairebé mai s’escriuen en codi d’aplicació. Es poden saltar en una primera lectura sense perdre el fil.

Versió. Tots els exemples són Python 3.12 o superior i fan servir la notació de tipus moderna: list[str], int | None, type Alias = ..., i els tipus abstractes importats de collections.abc. Les formes antigues del mòdul typing, que Python bàsic descriu com a llegat, no apareixen enlloc d’aquest document.

Què és el Model d’Objectes de Python?

Python organitza tots els seus elements en un sistema unificat on tot és un objecte. Aquest model inclou diverses capes i conceptes interconnectats:

  1. Capa d’Objectes Abstractes: Defineix operacions universals per a tots els objectes
  2. Protocols Integrats: Contractes que els objectes poden implementar (numèric, seqüència, etc.)
  3. Protocol Interfaces: Sistema de tipus per formalitzar contractes
  4. Jerarquia de Tipus: Com s’organitzen i hereten comportaments els tipus
  5. Sistema de Metaclasses: Com es creen i configuren les classes

Conceptes Fonamentals del Model d’Objectes

1. Identitat, Tipus i Valor

Cada objecte Python té tres característiques fonamentals:

obj = [1, 2, 3]

# Identitat: ubicació única a la memòria
print(f"ID: {id(obj)}")           # 140234567890

# Tipus: què mena d'objecte és
print(f"Tipus: {type(obj)}")      # <class 'list'>

# Valor: el contingut de l'objecte  
print(f"Valor: {obj}")            # [1, 2, 3]

2. Mutabilitat i Immutabilitat

Els objectes es classifiquen segons si es poden modificar després de la creació:

# Objectes immutables
nombre = 42          # int
text = "hola"        # str  
tupla = (1, 2, 3)    # tuple

# Objectes mutables
llista = [1, 2, 3]   # list
diccionari = {"a": 1} # dict
conjunt = {1, 2, 3}  # set

# Demostració
llista[0] = 99       # ✓ Permès
# text[0] = "H"      # ✗ Error! str és immutable

3. Binding i Referències

Python utilitza noms que es lliguen a objectes, no variables que contenen valors:

a = [1, 2, 3]
b = a           # b i a referencien el mateix objecte
c = [1, 2, 3]   # c referencien un objecte diferent amb el mateix valor

print(a is b)   # True - mateix objecte
print(a is c)   # False - objectes diferents
print(a == c)   # True - mateix valor

Principis Fonamentals

  1. Tot és un objecte: A Python, tot (números, cadenes, funcions, classes, mòduls) és un objecte
  2. Interfície unificada: Tots els objectes responen a un conjunt comú d’operacions
  3. Polimorfisme: Diferents tipus d’objectes poden implementar les mateixes operacions de maneres específiques

Mutabilitat, àlies i estat

El model d’objectes que acabem de descriure té una conseqüència pràctica que és, de llarg, l’origen més freqüent d’errors desconcertants: dos noms poden apuntar al mateix objecte, i si aquest objecte es pot modificar, tocar-lo per un nom es veu per l’altre.

Què fa exactament =

L’assignació té dos passos, i confondre’ls és l’origen de mig malentès. Primer s’avalua l’expressió de la dreta —i aquí sí que pot néixer un objecte: un literal, una crida, un operador—; després = lliga el nom a l’objecte que n’ha sortit. El pas que crea és el primer, no el segon. Per això = es comporta igual amb mutables i immutables:

x = 5
y = x          # cap objecte nou: NOMÉS un nom més
print(x is y)  # True: àlies, també amb un immutable

Amb immutables l’àlies hi és igualment; el que passa és que no es pot notar, perquè ningú no pot modificar el 5 compartit.

Només la forma «nom sol» és inofensiva. Si a l’esquerra hi ha un índex o un camp, l’assignació muta l’objecte:

nom = valor      # lliga un NOM: no toca cap objecte
xs[0] = 99       # muta xs: tots els seus àlies ho veuen
obj.camp = 99    # muta obj

Còpia superficial i còpia profunda

Per obtenir una col·lecció independent cal demanar-ho explícitament, i el que se n’obté copia el contenidor, no el contingut:

import copy

graella = [[0, 0], [0, 0]]
g2 = graella.copy()          # còpia SUPERFICIAL (també: list(graella), graella[:])
g2[0][0] = 9
print(graella)               # [[9, 0], [0, 0]]  ← la fila interior era COMPARTIDA

g3 = copy.deepcopy(graella)  # còpia profunda: ara sí, tot independent
g3[0][0] = 0
print(graella)               # [[9, 0], [0, 0]]  ← intacta

Amb estructures niades —una llista de diccionaris, per exemple— la còpia superficial enganya: sembla que treballes amb dades pròpies i estàs modificant les originals.

+= no vol dir el mateix sobre tot

t = (1, 2)
u = t
t += (3,)      # tupla NOVA; u no canvia
print(u)       # (1, 2)

xs = [1, 2]
ys = xs
xs += [3]      # equival a xs.extend([3]): modifica L'OBJECTE
print(ys)      # [1, 2, 3]: l'àlies ho veu

Sobre un immutable, += relliga el nom; sobre un mutable, modifica l’objecte in place.

Cada operació ve en dues formes

Sobre un tipus mutable, gairebé cada feina existeix dues vegades: una que muta i retorna None, i una que construeix un objecte nou. Triar-ne una és decidir si els àlies ho veuran.

Muta l’objecte (retorna None)En crea un de nou
xs.sort()sorted(xs)
xs.reverse()reversed(xs), xs[::-1]
xs.append(v)xs + [v]
d.update(altre){**d, **altre}
s.add(v)s | {v}

Retornar None és la manera que té Python de dir «he mutat, no he creat»: per això xs = xs.sort() acaba amb xs valent None, i l’error està dissenyat perquè es noti. Un tipus immutable no pot tenir columna esquerra, i per això la pregunta «muta o crea?» només cal fer-se-la davant d’un mutable —i llavors cal fer-se-la sempre.

El valor per defecte mutable

El valor per defecte d’un paràmetre s’avalua un sol cop, en definir la funció. Totes les crides que no passen l’argument comparteixen aquell mateix objecte:

def acumula_malament(x, diposit=[]):    # MAI: la llista es crea UNA vegada
    diposit.append(x)
    return diposit

print(acumula_malament(1))   # [1]
print(acumula_malament(2))   # [1, 2]  ← arrossega l'estat de la crida anterior

def acumula_be(x, diposit=None):        # el patró correcte
    if diposit is None:
        diposit = []                    # llista NOVA a cada crida
    diposit.append(x)
    return diposit

Hashabilitat: per què existeixen les tuples

La immutabilitat no és una limitació, és una garantia que es pot exigir. Les claus d’un dict i els elements d’un set han de ser hashables: se’ls ha de poder calcular un codi numèric estable que no canviï mentre hi són a dins. Una llista no pot; una tupla sí:

memoria = {}
memoria[("cerca", "webhooks")] = "…resultat…"   # clau composta: correcte
memoria[["cerca", "webhooks"]] = "…"            # TypeError: unhashable type: 'list'

La clau pot ser-ho perquè és immutable. El mateix val per a un @dataclass(frozen=True), que és hashable precisament perquè ha renunciat a canviar.

Mutabilitat a les biblioteques de dades

En càlcul numèric les dades són grans, i això capgira els costos: copiar un vector de cinc elements és gratis; copiar una matriu de dos milions de files a cada operació és inviable. Les biblioteques prenen partit per rendiment, cadascuna a la seva manera.

NumPy afegeix un tercer comportament que les llistes no tenen: la vista. Llescar una llista copia; llescar un array no.

import numpy as np

xs = [1, 2, 3, 4, 5]
part = xs[1:4]      # llista: CÒPIA; tocar part no toca xs

a = np.array([1, 2, 3, 4, 5])
b = a[1:4]          # array: VISTA; comparteix memòria amb a
b[0] = 99
print(a)            # [ 1 99  3  4  5]  ← modificat a través de la vista
c = a[1:4].copy()   # còpia real quan cal de debò

És el mateix protocol de buffer de més avall, vist des de dalt. El preu és que, davant d’un array, la pregunta «això és una vista o una còpia?» sempre és pertinent.

pandas va majoritàriament a l’altre extrem: les operacions retornen un objecte nou, i si el resultat no s’assigna, no ha passat res.

df.dropna()             # no fa "res": el resultat es llença
df = df.dropna()        # això sí

df["preu_eur"] = df["preu"] * 0.92          # afegir columna SÍ que muta df
df.loc[df["any"] < 1990, "km"] = None       # mutació selectiva: SEMPRE amb .loc

L’error clàssic és l’indexat encadenat —df[df["any"] < 1990]["km"] = None— que pot escriure sobre una còpia temporal que es llença tot seguit. La forma amb .loc diu on i què en una sola operació.

scikit-learn i PyTorch muten objectes, i pel mateix motiu: l’estat és gran. model.fit(X, y) no retorna un model nou, escriu els coeficients dins del model; optimizer.step() actualitza els pesos in place. PyTorch ho marca fins i tot al nom: els mètodes acabats en guió baix (tensor.add_()) muten, i sense (tensor.add()) en retornen un de nou.

Tipus: contractes que es comproven sense executar

Python bàsic presenta les anotacions com a etiquetes que documenten una funció. Aquesta part va un pas més enllà: una anotació és un contracte que una eina externa —mypy, pyright, l’editor— pot verificar sense executar el programa. Els errors que això atrapa són els que altrament apareixerien a mitja execució, amb les dades ja carregades.

Res d’això canvia el que passa en executar: Python no comprova les anotacions en temps d’execució, i una funció anotada -> int pot retornar una cadena sense queixar-se. La xarxa de seguretat és el verificador, no l’intèrpret.

La resta d’aquesta part és, sobretot, com llegir els tipus que ja hi són. Una de les dificultats més grans quan s’aprèn Python, especialment en el camp del Machine Learning, és entendre les definicions que apareixen quan examines llibreries com NumPy, PyTorch o scikit-learn. Poden semblar intimidants, però tenen una lògica clara.

Exemples Reals de Tipus Complexes

NumPy Arrays

# El que veus quan passes el cursor sobre una funció NumPy:
# numpy.ndarray[Any, numpy.dtype[numpy.floating[Any]]]

# Desglossament:
# - numpy.ndarray: El tipus base (array N-dimensional)
# - [Any, ...]: Paràmetres de tipus genèrics
# - numpy.dtype[...]: El tipus de les dades que conté
# - numpy.floating[Any]: Números de punt flotant de qualsevol precisió

import numpy as np
arr: np.ndarray[Any, np.dtype[np.float64]] = np.array([1.0, 2.0, 3.0])

PyTorch Tensors

# Tipus complex de PyTorch:
# torch.Tensor | torch.nn.Parameter | torch.nn.Module

# Significa: "pot ser qualsevol d'aquests tres tipus"
import torch

def processa_tensor(x: torch.Tensor | torch.nn.Parameter) -> torch.Tensor:
    # Funciona amb tensors normals O paràmetres de xarxes neurals
    return x * 2

Pandas DataFrames

# Tipus que veuràs sovint:
# pandas.DataFrame[Any] | pandas.Series[Any] | None

import pandas as pd

def analitza_dades(df: pd.DataFrame | None) -> pd.Series | None:
    if df is not None:
        return df.mean()
    return None

pd.DataFrame | None es llegeix «un DataFrame o res». És l’absència de dada feta explícita al tipus, i obliga qui crida a decidir què fa quan no n’hi ha.

Anatomia d’una Definició de Tipus Complexa

Vegem com descomposar aquests tipus pas a pas:

from collections.abc import Callable
from typing import Any

import numpy as np
import pandas as pd

# Exemple complex típic d'una funció de ML:
def preprocessa_dades(
    dades: np.ndarray[Any, np.dtype[np.floating[Any]]] | list[list[float]] | pd.DataFrame,
    transformador: Callable[[Any], Any] | None = None,
    parametres: dict[str, int | float | str] | None = None,
) -> tuple[np.ndarray[Any, np.dtype[np.floating[Any]]], dict[str, Any]]:
    """
    Desglossament del tipus:

    dades: pot ser
      - NumPy array de floats
      - Llista de llistes de floats
      - Pandas DataFrame

    transformador: opcionalment una funció que transforma qualsevol cosa

    parametres: opcionalment un diccionari amb valors de diferents tipus

    Retorna: una tupla amb (array NumPy, diccionari de metadades)
    """
    ...

Fixa’t en dues coses. La primera: Callable ve de collections.abc, no de typing — des de Python 3.9 els tipus abstractes viuen allà i typing només conserva els àlies antics. La segona: quan una signatura arriba a aquesta llargada, el problema ja no és la notació sinó la funció, que accepta massa formes d’entrada. Els àlies de tipus de més avall hi posen remei.

Patrons Comuns en ML/Data Science

1. Arrays Multidimensionals

import numpy as np

# Tipus que accepta diferents formes de dades numèriques.
# `type` (Python 3.12+) declara un àlies de debò, no una variable qualsevol.
type ArrayLike = (
    np.ndarray            # Arrays NumPy
    | list[float]         # Llistes de Python
    | list[list[float]]   # Matrius com llistes niades
    | tuple               # Tuples també poden funcionar
)

def entrena_model(X: ArrayLike, y: ArrayLike) -> None:
    # Converteix tot a NumPy internament
    X_arr = np.asarray(X)
    y_arr = np.asarray(y)

2. Tipus Opcionals Encadenats

from typing import Any

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator

# Molt comú en pipelines de dades:
def processa_pipeline(
    dades: pd.DataFrame | None = None,
    model: BaseEstimator | None = None,
    config: dict[str, Any] | None = None,
) -> np.ndarray | None:
    """Molts paràmetres opcionals = flexibilitat"""
    if dades is None or model is None:
        return None
    # ... processament

Molts paràmetres opcionals també volen dir moltes combinacions que cal validar a dins. Sovint és senyal que hi ha dues funcions barrejades en una.

3. Generics amb Restriccions

from typing import Any, Protocol

# Definir tipus que han de tenir certes propietats:
class HasFit(Protocol):
    def fit(self, X: Any, y: Any) -> None: ...
    def predict(self, X: Any) -> Any: ...

# Sintaxi de genèrics de Python 3.12+: el paràmetre de tipus es declara
# entre claudàtors, just després del nom. No cal cap TypeVar a part.
def avalua_model[M: HasFit](model: M, X: Any, y: Any) -> M:
    """El tipus de retorn és el mateix que el d'entrada"""
    model.fit(X, y)
    return model

[M: HasFit] es llegeix «per a qualsevol tipus M que compleixi HasFit». Com que entra i surt el mateix M, el verificador sap que si li passes un RandomForest en recuperes un RandomForest, no un HasFit genèric.

Estratègies per Entendre Tipus Complexes

1. Llegeix d’Dins cap Enfora

# Tipus complex:
dict[str, list[int | float | None]]

# Descomposició:
# 1. int | float      → pot ser int o float
# 2. ... | None       → o pot no haver-hi valor
# 3. list[...]        → llista d'elements del tipus anterior
# 4. dict[str, ...]   → diccionari amb claus string i valors del tipus anterior

# En paraules: "Diccionari amb claus string i valors que són
#              llistes d'elements que poden ser int, float o None"

Amb la notació | la lectura de dins cap enfora és més directa que amb la sintaxi antiga: les alternatives se separen amb una barra, com en una gramàtica, en comptes d’embolicar-se en crides niades.

2. Utilitza Type Aliases

# En lloc de repetir tipus complexes, crea àlies amb `type` (Python 3.12+):
type Numeric = int | float
type OptionalNumeric = Numeric | None
type NumericList = list[OptionalNumeric]
type DataDict = dict[str, NumericList]

# Ara és molt més llegible:
def processa_dades(dades: DataDict) -> DataDict:
    ...

La paraula clau type no és cosmètica. Abans un àlies era una assignació normal (Numeric = int | float) i res no distingia un àlies de tipus d’una variable que resulta que conté un tipus. Amb type, el verificador ho sap del cert, i l’àlies s’avalua mandrosament: pot referir-se a coses definides més avall al fitxer sense haver de posar-les entre cometes.

3. Usa isinstance() per Debug

from typing import Any
import numpy as np
import pandas as pd

# Quan no estiguis segur del tipus que tens:
def debug_tipus(obj: Any) -> None:
    print(f"Tipus: {type(obj)}")
    print(f"És llista: {isinstance(obj, list)}")
    print(f"És NumPy array: {isinstance(obj, np.ndarray)}")
    print(f"És DataFrame: {isinstance(obj, pd.DataFrame)}")
    
    if hasattr(obj, 'shape'):
        print(f"Forma: {obj.shape}")
    if hasattr(obj, 'dtype'):
        print(f"Dtype: {obj.dtype}")

Exemples Pràctics del Món Real

Scikit-learn

# Tipus típic que veuràs:
# sklearn.base.BaseEstimator

# Què significa:
from sklearn.base import BaseEstimator

class ElMeuModel(BaseEstimator):
    """Qualsevol model de sklearn hereta d'aquesta classe base"""
    
    def fit(self, X, y):
        return self
    
    def predict(self, X):
        return np.zeros(len(X))

# El type checker sap que ElMeuModel és compatible amb BaseEstimator

PyTorch Lightning

# Tipus complex típic:
# torch.optim.Optimizer | Callable[..., torch.optim.Optimizer]

from collections.abc import Callable
from typing import Any

import torch
import torch.optim as optim

# Significa: pot ser un optimitzador YA creat, o una funció que en crea un

# Opció 1: Optimitzador directe
optimizer1: torch.optim.Optimizer = optim.Adam([])

# Opció 2: Funció que crea un optimitzador
def create_optimizer(params: Any) -> torch.optim.Optimizer:
    return optim.SGD(params, lr=0.01)

optimizer2: Callable[..., torch.optim.Optimizer] = create_optimizer

Consells Pràctics

  1. No et preocupis per entendre-ho tot: Comença pel que necessites
  2. Usa IDEs moderns: PyCharm, VSCode, etc. ajuden amb autocompletar
  3. Llegeix documentació oficial: Sovint explica els tipus més clarament
  4. Practica amb exemples simples: Abans d’atacar codi complex
  5. Usa reveal_type() en mypy per veure què infereix el type checker
# Debug de tipus amb mypy:
import numpy as np

arr = np.array([1, 2, 3])
# mypy: reveal_type(arr)  # Mostra el tipus inferit

Duck typing, protocols i descriptors

Duck Typing vs Protocol Typing

from typing import Any, Protocol

# Duck Typing tradicional: "Si camina com un ànec..."
def proces_iterable(obj: Any) -> None:
    try:
        for item in obj:
            print(item)
    except TypeError:
        print("No és iterable")

# Protocol Typing modern: especificació explícita
class Drawable(Protocol):
    def draw(self) -> None: ...

def render(obj: Drawable) -> None:
    obj.draw()  # Type checker verifica que obj tingui draw()

# Protocol genèric, amb la sintaxi de 3.12+: el paràmetre va al nom de la classe
class Container[T](Protocol):
    def add(self, item: T) -> None: ...
    def get(self) -> T: ...

def process_container(container: Container[str]) -> str:
    container.add("test")
    return container.get()

La diferència pràctica entre els dos estils és quan te n’assabentes. El duck typing falla en executar, amb les dades reals al davant; el protocol falla mentre escrius, i sense executar res. Cap dels dos obliga la classe a heretar de res: n’hi ha prou que tingui la forma.

On viuen els atributs: __dict__

Un objecte de Python no guarda els atributs en unes caselles fixes decidides en compilar: els guarda en un diccionari corrent, accessible com a obj.__dict__. Assignar un atribut és, literalment, posar una clau en aquest diccionari.

class Punt:
    dim = 2                     # atribut de classe: viu al __dict__ de la classe

    def __init__(self, x: float, y: float) -> None:
        self.x = x              # equival a self.__dict__["x"] = x
        self.y = y

p = Punt(1.0, 2.0)
print(p.__dict__)               # {'x': 1.0, 'y': 2.0}
print(vars(p))                  # el mateix: vars() és la manera educada de mirar-lo

p.color = "roig"                # ningú no ho impedeix: la clau s'hi afegeix i ja està
print(vars(p))                  # {'x': 1.0, 'y': 2.0, 'color': 'roig'}

print("dim" in vars(p))         # False: dim no és a la instància...
print(p.dim)                    # ...i tot i així p.dim val 2

Escriure p.x no és llegir una posició de memòria, és una cerca: primer al __dict__ de la instància i, si no hi és, al de la classe i al dels ancestres. Per això p.dim funciona sense ser a la instància, i per això els mètodes no es dupliquen a cada objecte —viuen una sola vegada al __dict__ de la classe—. Les classes i els mòduls també en tenen un, de __dict__: és el mateix mecanisme a tot arreu.

Aquest disseny explica tres coses que ja hauràs vist. Que puguis afegir atributs a una instància en qualsevol moment, encara que la classe no els declari. Que p.cordenada = 3 —amb la falta d’ortografia inclosa— no falli: només crea una clau nova. I que cada instància carregui amb un diccionari propi, que ocupa memòria i s’ha de consultar a cada accés. Més endavant, slots és exactament l’opció de renunciar a aquest diccionari: perds poder afegir atributs sobre la marxa i, a canvi, guanyes memòria, velocitat i un error immediat quan t’equivoques de nom.

🔬 Descriptor Protocol

Profunditat opcional. Un descriptor és un objecte que s’interposa en la cerca que acabem de veure: quan el nom es troba a la classe i el que hi ha té __get__ o __set__, Python el crida en comptes de retornar-lo tal qual. És la maquinària sota @property, sota els mètodes lligats i sota els camps dels ORM. Saber que existeixen explica per què @property funciona; escriure’n un de propi és rar, i normalment @property o un validador de Pydantic resolen el mateix amb menys peces.

from collections.abc import Callable
from typing import Any

class ValidatedAttribute:
    def __init__(self, validator: Callable[[Any], bool]) -> None:
        self.validator = validator
        self.name: str = ""

    def __set_name__(self, owner: type[Any], name: str) -> None:
        self.name = f"_{name}"

    def __get__(self, obj: Any | None, owner: type[Any]) -> Any:
        if obj is None:
            return self
        return getattr(obj, self.name, None)

    def __set__(self, obj: Any, value: Any) -> None:
        if not self.validator(value):
            raise ValueError(f"Valor invàlid: {value}")
        setattr(obj, self.name, value)

class Person:
    # Sense anotació: a nivell de classe això és un ValidatedAttribute,
    # no un int. Anotar-ho `age: int` enganyaria el verificador.
    age = ValidatedAttribute(lambda x: isinstance(x, int) and x >= 0)

    def __init__(self, age: int) -> None:
        self.age = age

# Ús
p = Person(25)
# p.age = -5  # Llança ValueError

Organització del codi

Fins aquí, com es comporta un objecte. Aquesta part tracta la pregunta que ve després: on viu cada tros de codi, quan un programa deixa de ser un sol fitxer.

Funcions o classes? L’estat com a criteri

La pregunta no és d’estil: és si hi ha estat, i de qui és. Hi ha tres graons, i convé no pujar-ne cap sense necessitat.

Graó 1 — una funció. Si tot el que necessita entra pels arguments i tot el que produeix surt pel return, és una funció. No cal res més.

def rmse(y_true: list[float], y_pred: list[float]) -> float:
    n = len(y_true)
    return (sum((t - p) ** 2 for t, p in zip(y_true, y_pred)) / n) ** 0.5

Graó 2 — un @dataclass. Quan el que tens són dades que viatgen juntes: un registre amb nom, no un comportament. La classe existeix per donar nom als camps, no per fer res.

from dataclasses import dataclass, field

@dataclass
class ResultatAvaluacio:
    exactitud: float
    tokens: int
    errors: list[str] = field(default_factory=list)

    @property
    def ha_fallat(self) -> bool:
        """Valor DERIVAT: es calcula, no es desa. Així no pot quedar desincronitzat."""
        return bool(self.errors)

field(default_factory=list) és la trampa del valor per defecte mutable, ara en versió dataclass: sense això, totes les instàncies compartirien la mateixa llista.

Graó 3 — una classe amb estat. Només quan hi ha alguna cosa cara de construir que es reutilitza a moltes crides: una connexió, un client HTTP amb el seu pool, un model carregat en memòria. El constructor captura aquest estat una vegada; els mètodes el fan servir.

class ClientAPI:
    def __init__(self, url_base: str, timeout: float = 30.0) -> None:
        # L'estat es captura UN cop, aquí. A partir d'ara l'objecte és
        # autònom: canviar la configuració global no li canvia el comportament.
        self.url_base = url_base
        self.timeout = timeout
        self._sessio = crea_sessio(url_base)

    def consulta(self, ruta: str) -> dict:
        return self._sessio.get(ruta, timeout=self.timeout).json()

El criteri per no pujar de graó: si una classe només té __init__ i un mètode, i el __init__ no desa res car, era una funció.

Un mòdul és un singleton

En importar un mòdul, Python l’executa una sola vegada i en desa el resultat a sys.modules. Totes les importacions posteriors, des de qualsevol fitxer, reben el mateix objecte mòdul:

# config.py
print("carregant config…")
PARAMETRES = {"mode": "dev"}

# a.py
from config import PARAMETRES
PARAMETRES["mode"] = "prod"

# b.py
from config import PARAMETRES
print(PARAMETRES)      # {'mode': 'prod'}  ← el canvi d'a.py es veu aquí

«Carregant config…» s’imprimeix un cop, per molts fitxers que l’importin. És l’àlies de la part anterior a escala de fitxer: l’estat a nivell de mòdul és estat compartit per tot el programa. Va bé per a constants; va malament per a res que canviï, perquè cap dels dos fitxers no veu qui més hi escriu.

Cicles d’importació

Si a.py importa b.py i b.py importa a.py, un dels dos rebrà un mòdul a mig construir i fallarà amb un ImportError que sembla dir una altra cosa. No es resol amb trucs d’ordre: es resol trencant el cicle.

   abans (cicle)              després (el comú avall)
   a.py ←──→ b.py             a.py ──→ comu.py ←── b.py

La causa habitual és que dos mòduls comparteixen una definició —un tipus, una constant— que no és de cap dels dos. Es mou a un tercer mòdul del qual tots dos depenen, i el cicle desapareix. La dependència ha d’anar en una sola direcció.

Capes

De la mateixa regla surt l’organització d’un projecte: cada mòdul depèn només de mòduls més «endins», mai al revés.

CapaQuè hi viuDe qui depèn
Presentacióqui parla amb el món: HTTP, CLI, quadernsde serveis
Serveisla feina de debò, sense detalls d’infraestructurad’esquemes
Infraestructuraqui parla amb l’exterior: disc, xarxa, base de dadesd’esquemes
Esquemesels contractes de dadesde ningú

Els esquemes no depenen de res, i per això es poden importar des de qualsevol lloc sense crear mai un cicle. Si un mòdul d’esquemes necessita importar un servei, el disseny s’ha girat del revés.

Python modern: el que ha canviat i val la pena adoptar

Les versions recents han afegit peces que resolen problemes que abans es resolien a mà. Totes són d’ús quotidià, no de biblioteca.

Self: el tipus «aquesta mateixa classe»

Abans calia una cadena amb el nom de la classe, que s’havia de mantenir sincronitzada si la classe canviava de nom, i que era incorrecta a les subclasses:

from typing import Self

class Consulta:
    def __init__(self) -> None:
        self.filtres: list[str] = []

    def on(self, condicio: str) -> Self:
        """Retorna la MATEIXA classe, també si algú hereta d'aquesta."""
        self.filtres.append(condicio)
        return self

class ConsultaSQL(Consulta):
    ...

c = ConsultaSQL().on("edat > 18")   # el verificador sap que c és ConsultaSQL

StrEnum: enumeracions que ja són cadenes

Per als camps que viatgen com a text (JSON, CSV, paràmetres d’URL) i alhora han de tenir un conjunt tancat de valors:

from enum import StrEnum

class Severitat(StrEnum):
    BAIXA = "baixa"
    MITJANA = "mitjana"
    ALTA = "alta"

print(Severitat.ALTA == "alta")        # True: ÉS una cadena
print(f"nivell: {Severitat.ALTA}")     # 'nivell: alta'
print(list(Severitat))                 # els valors vàlids, enumerables

@dataclass amb slots i frozen

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Punt:
    x: float
    y: float

frozen=True fa l’objecte immutable: intentar p.x = 3 llança FrozenInstanceError, i com que ja no pot canviar, l’objecte esdevé hashable i pot ser clau d’un dict o element d’un set. slots=True elimina el __dict__ per instància: menys memòria, accés als camps més ràpid, i un error immediat si escrius malament el nom d’un camp en comptes de crear-ne un de nou en silenci.

TypedDict: posar tipus a un diccionari que ja existeix

Quan les dades arriben com a diccionari i no vols convertir-les en objectes:

from typing import TypedDict

class Registre(TypedDict):
    estacio: str
    temp: float
    humitat: float | None

def mitjana(files: list[Registre]) -> float:
    return sum(f["temp"] for f in files) / len(files)

Registre no és una classe nova: en executar continua sent un dict corrent, sense cap cost. El verificador, en canvi, ja sap quines claus hi ha i de quin tipus és cadascuna, i et marca f["tmp"] com a error.

La diferència amb Pydantic és quan es comprova: TypedDict no valida res en executar —és una promesa sobre el codi—, mentre que un model de Pydantic mira els valors reals en construir l’objecte i llança si no quadren. Per a dades pròpies que ja són de fiar, TypedDict; per a dades que arriben de fora (una API, un fitxer, un LLM), validació de debò.

Grups d’excepcions i except*

Quan una operació en llança diverses alhora —típicament tasques concurrents— es poden propagar juntes i tractar-les per tipus:

try:
    raise ExceptionGroup("errors del lot", [ValueError("fila 3"), KeyError("fila 9")])
except* ValueError as eg:
    print("valors invàlids:", eg.exceptions)
except* KeyError as eg:
    print("claus que falten:", eg.exceptions)

Els dos blocs s’executen: except* no tria una branca, sinó que reparteix les excepcions del grup. A nivell de reconeixement n’hi ha prou; apareix sobretot en codi asíncron.

functools.cache: memòria de crides, feta

from functools import cache

@cache
def fibonacci(n: int) -> int:
    return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2)

Guarda el resultat de cada combinació d’arguments i no torna a executar la funció per als mateixos. Requisit: els arguments han de ser hashables —per això una llista no serveix com a argument i una tupla sí—, i la funció ha de ser pura, perquè el segon cop no s’executa.

Referència: el catàleg del model d’objectes

Les seccions que segueixen són material de consulta, no de lectura seguida. Recullen els protocols que Python té integrats, les operacions que valen per a qualsevol objecte i com s’organitza la jerarquia de tipus. Convé saber que hi són i tornar-hi quan calgui implementar-ne un.

Protocols Integrats: Categories d’Objectes

La Capa d’Objectes Abstractes defineix diversos protocols que agrupen objectes segons el seu comportament:

1. Objectes Numèrics (Numeric Objects)

Els objectes numèrics implementen operacions matemàtiques bàsiques.

Implementacions Concretes

  • int: Nombres enters
  • float: Nombres decimals
  • complex: Nombres complexos
  • decimal.Decimal: Decimals amb precisió arbitrària
  • fractions.Fraction: Fraccions
# Exemples d'objectes numèrics
enter = 42
decimal = 3.14159
complexe = 3 + 4j

# Operacions comunes
suma = enter + decimal
producte = decimal * 2
potencia = enter ** 2

Operacions Abstractes

  • Suma (+), resta (-), multiplicació (*), divisió (/)
  • Comparacions (<, >, ==, !=)
  • Conversions de tipus (int(), float(), complex())

2. Objectes Seqüència (Sequence Objects)

Les seqüències són col·leccions ordenades d’elements amb accés indexat.

Implementacions Concretes

  • str: Cadenes de caràcters
  • list: Llistes mutables
  • tuple: Tuples immutables
  • range: Seqüències de números
  • bytes: Seqüències de bytes
  • bytearray: Arrays de bytes mutables
# Exemples de seqüències
cadena = "Hola món"
llista = [1, 2, 3, 4, 5]
tupla = (1, 2, 3)
rang = range(10)

# Operacions comunes
longitud = len(llista)        # 5
element = llista[2]           # 3
subcadena = cadena[0:4]       # "Hola"
pertany = 3 in llista         # True

Operacions Abstractes

  • Indexació (obj[i])
  • Slicing (obj[i:j])
  • Longitud (len())
  • Pertinença (in, not in)
  • Iteració (for item in obj)
  • Concatenació (+)

3. Objectes Mapping (Mapping Objects)

Els mappings associen claus amb valors.

Implementacions Concretes

  • dict: Diccionaris
  • collections.defaultdict: Diccionaris amb valors per defecte
  • collections.OrderedDict: Diccionaris ordenats (Python < 3.7)
  • collections.Counter: Comptadors
# Exemples de mappings
diccionari = {"nom": "Anna", "edat": 25}
from collections import Counter
comptador = Counter([1, 2, 2, 3, 3, 3])

# Operacions comunes
valor = diccionari["nom"]         # "Anna"
diccionari["ciutat"] = "Barcelona"
claus = list(diccionari.keys())
existeix = "edat" in diccionari   # True

Operacions Abstractes

  • Accés per clau (obj[key])
  • Assignació (obj[key] = value)
  • Eliminació (del obj[key])
  • Pertinença de claus (key in obj)
  • Iteració sobre claus (for key in obj)

4. Objectes Set (Set Objects)

Els sets són col·leccions no ordenades d’elements únics.

Implementacions Concretes

  • set: Sets mutables
  • frozenset: Sets immutables
# Exemples de sets
conjunt = {1, 2, 3, 4, 5}
conjunt_immutable = frozenset([1, 2, 3])

# Operacions comunes
conjunt.add(6)
unio = conjunt | {7, 8, 9}
interseccio = conjunt & {3, 4, 5, 6}
diferencia = conjunt - {1, 2}

Operacions Abstractes

  • Pertinença (in, not in)
  • Unió (|, union())
  • Intersecció (&, intersection())
  • Diferència (-, difference())
  • Subset/superset (<=, >=)

5. Objectes Callable (Callable Objects)

Els objectes callable són aquells que es poden “cridar” com a funcions.

Implementacions Concretes

  • function: Funcions definides amb def
  • method: Mètodes de classes
  • builtin_function_or_method: Funcions integrades
  • type: Classes (constructors)
  • lambda: Funcions anònimes
# Exemples d'objectes callable
def funcio(x):
    return x * 2

classe = str  # Constructor de cadena
lambda_func = lambda x: x + 1

# Ús dels callables
resultat1 = funcio(5)        # 10
resultat2 = classe(42)       # "42"
resultat3 = lambda_func(10)  # 11

# Verificar si un objecte és callable
print(callable(funcio))      # True
print(callable(42))          # False

6. Objectes Iterables (Iterable Objects)

Els iterables són objectes que es poden recórrer element per element.

Característiques

  • Implementen el protocol d’iteració
  • Es poden usar en bucles for
  • Es poden convertir a llistes, tuples, etc.
# Exemples d'iterables
llista = [1, 2, 3]
cadena = "abc"
diccionari = {"a": 1, "b": 2}

# Iteració
for element in llista:
    print(element)

# Crear iteradors
iterador = iter(llista)
primer_element = next(iterador)  # 1

7. 🔬 Protocol de Buffer (Buffer Protocol)

Profunditat opcional. Aquest protocol explica per què NumPy pot llescar matrius enormes sense copiar ni un byte, i val la pena haver-lo vist un cop per aquest motiu. Programar-hi directament és cosa de qui escriu biblioteques, no de qui les fa servir.

El Protocol de Buffer proporciona accés directe a la memòria d’un objecte sense necessitat de còpies intermèdies. És especialment útil per a operacions d’E/S i processament de dades binàries eficient.

Implementacions Concretes

  • bytes: Seqüències immutables de bytes
  • bytearray: Arrays mutables de bytes
  • memoryview: Vistes de memòria d’altres objectes buffer
  • array.array: Arrays homogenis eficients
  • numpy.ndarray: Arrays NumPy (si està instal·lat)
import array

# Exemples d'objectes buffer
# Un literal de bytes només admet ASCII: per a text amb accents, cal codificar-lo.
dades_bytes = "Hola món".encode("utf-8")
buffer_mutable = bytearray(b"Python")
array_nums = array.array('i', [1, 2, 3, 4, 5])

# Memoryview per accés eficient
mv = memoryview(buffer_mutable)
print(mv[0])          # 80 (codi ASCII de 'P')
mv[0] = ord('J')      # Modifica directament la memòria
print(buffer_mutable) # bytearray(b'Jython')

# Slicing sense còpies
subvista = mv[1:4]    # Vista dels bytes 1-3
print(bytes(subvista)) # b'yth'

Operacions del Protocol Buffer

  • Accés directe a memòria: Sense còpies de dades
  • Slicing eficient: Crear vistes sense duplicar dades
  • Interoperabilitat: Compartir dades entre diferents mòduls
  • Operacions d’E/S ràpides: Lectura/escriptura directa
# Exemple pràctic: processament eficient de dades
def processa_dades_eficient(buffer_obj):
    """Processa dades sense crear còpies innecessàries"""
    mv = memoryview(buffer_obj)
    
    # Processar en chunks sense còpies
    chunk_size = 1024
    for i in range(0, len(mv), chunk_size):
        chunk = mv[i:i+chunk_size]
        # Processar chunk directament
        print(f"Processant chunk de {len(chunk)} bytes")

# Funciona amb qualsevol objecte buffer
dades = bytearray(5000)  # 5KB de dades
processa_dades_eficient(dades)

Avantatges del Model d’Objectes de Python

  1. Consistència: Interfície uniforme per a tots els tipus d’objectes
  2. Polimorfisme: El mateix codi funciona amb diferents tipus d’objectes
  3. Extensibilitat: Es poden crear nous tipus que s’integren perfectament
  4. Simplicitat: Menys conceptes per aprendre i recordar
  5. Flexibilitat: Suport per programació funcional, OOP i procedural
  6. Interoperabilitat: Protocols permeten integració amb libreries externes

Operacions Universals

Totes aquestes operacions es poden aplicar a qualsevol objecte Python:

1. Identitat i Tipus

objecte = [1, 2, 3]

# Identitat
print(id(objecte))           # Identificador únic
print(type(objecte))         # <class 'list'>
print(isinstance(objecte, list))  # True

2. Representació

objecte = [1, 2, 3]

# Representacions
print(str(objecte))          # "[1, 2, 3]"
print(repr(objecte))         # "[1, 2, 3]"
print(bool(objecte))         # True (llista no buida)

3. Comparació

a = [1, 2, 3]
b = [1, 2, 3]
c = a

print(a == b)    # True (igualtat de valor)
print(a is c)    # True (identitat d'objecte)
print(a is b)    # False (objectes diferents)

Protocol d’Objectes i Mètodes Especials

Python utilitza mètodes especials (dunder methods) per implementar les operacions de la capa abstracta:

Relació amb Protocol Interfaces

És important distingir entre la Capa d’Objectes Abstractes (que hem descrit) i les Protocol Interfaces del sistema de tipus:

  • Capa d’Objectes Abstractes: Protocols integrats a nivell C que defineixen operacions fonamentals
  • Protocol Interfaces: Part del sistema de tipus de Python (des de Python 3.8) per definir contractes
from typing import Any, Protocol

# La Capa d'Objectes Abstractes defineix com funcionen aquestes operacions:
#   len(obj)   crida  obj.__len__()
#   obj[key]   crida  obj.__getitem__(key)
#   str(obj)   crida  obj.__str__()

# Les Protocol Interfaces formalitzen aquests contractes:
class Sized(Protocol):
    def __len__(self) -> int: ...

class Indexable(Protocol):
    def __getitem__(self, key: Any) -> Any: ...

Python no té tipus intersecció: no es pot escriure Sized & Indexable per demanar les dues coses alhora. Quan una funció necessita dos protocols, se’n declara un que els combini —els protocols hereten entre ells com qualsevol classe:

class SizedIndexable(Sized, Indexable, Protocol):
    """Els dos contractes en un. Cal repetir `Protocol` a les bases."""

def processa_col_leccio(obj: SizedIndexable) -> None:
    print(f"Té {len(obj)} elements")
    if len(obj) > 0:
        print(f"Primer element: {obj[0]}")

Mètodes Fonamentals

class ExempleObjecte:
    def __init__(self, valor):
        self.valor = valor
    
    def __str__(self):
        return f"ExempleObjecte({self.valor})"
    
    def __repr__(self):
        return f"ExempleObjecte(valor={self.valor!r})"
    
    def __bool__(self):
        return self.valor != 0
    
    def __eq__(self, altre):
        return self.valor == altre.valor

Mètodes per a Seqüències

class SeqüènciaPersonalitzada:
    def __init__(self, elements):
        self.elements = elements
    
    def __len__(self):
        return len(self.elements)
    
    def __getitem__(self, index):
        return self.elements[index]
    
    def __contains__(self, element):
        return element in self.elements

Jerarquia de Tipus i Herència

Python organitza els tipus en una jerarquia que permet herència de comportament:

Jerarquia Bàsica

# Tots els tipus hereten d'`object`, que ja existeix: no cal (ni convé) redefinir-lo.
print(object.__doc__)   # 'The base class of the class hierarchy...'

# Exemple de jerarquia
print(int.__mro__)  # Method Resolution Order
# (<class 'int'>, <class 'object'>)

print(bool.__mro__)
# (<class 'bool'>, <class 'int'>, <class 'object'>)

Abstract Base Classes (ABC)

Python proporciona classes base abstractes per formalitzar protocols:

import numbers
from collections.abc import Mapping, Sequence

# Verificar si un objecte implementa un protocol
llista = [1, 2, 3]
print(isinstance(llista, Sequence))    # True
print(isinstance(llista, Mapping))     # False

diccionari = {"a": 1}
print(isinstance(diccionari, Mapping)) # True

nombre = 42
print(isinstance(nombre, numbers.Number)) # True

Crear Tipus Personalitzats amb Protocols

from collections.abc import Iterator, Sequence
from typing import Any

class RangPersonalitzat:
    """Implementa el protocol de Sequence"""
    def __init__(self, start: int, end: int) -> None:
        self.start = start
        self.end = end
    
    def __len__(self) -> int:
        return max(0, self.end - self.start)
    
    def __getitem__(self, index: int) -> int:
        if not 0 <= index < len(self):
            raise IndexError("Index fora de rang")
        return self.start + index
    
    def __contains__(self, value: Any) -> bool:
        return isinstance(value, int) and self.start <= value < self.end
    
    def __iter__(self) -> Iterator[int]:
        for i in range(self.start, self.end):
            yield i

# Ús automàtic com a Sequence
rang = RangPersonalitzat(5, 10)
print(isinstance(rang, Sequence))  # True
print(len(rang))                   # 5
print(list(rang))                  # [5, 6, 7, 8, 9]
print(7 in rang)                   # True

🔬 Metaclasses i Creació de Tipus

Profunditat opcional. Les metaclasses són el mecanisme amb què estan fetes eines com els ORM o Pydantic, i és útil saber que existeixen quan te’n trobes una. Escriure’n una pròpia és molt rar: gairebé sempre hi ha una solució més simple —un decorador de classe, __init_subclass__, o directament una funció. Es pot saltar sense perdre res del que ve després.

Les metaclasses controlen com es creen les classes:

from typing import Any

# Totes les classes són instàncies de 'type'
class MevaClasse:
    pass

print(type(MevaClasse))  # <class 'type'>
print(type(type))        # <class 'type'>

# Crear classes dinàmicament
DynamicClass = type('DynamicClass', (), {'atribut': 42})
obj = DynamicClass()
print(obj.atribut)  # 42

# Metaclasse personalitzada
class MetaDebug(type):
    def __new__(
        mcs,
        name: str,
        bases: tuple[type, ...],
        namespace: dict[str, Any],
    ) -> "MetaDebug":
        print(f"Creant classe: {name}")
        return super().__new__(mcs, name, bases, namespace)

class DebugClass(metaclass=MetaDebug):
    pass  # Imprimeix: "Creant classe: DebugClass"

L’alternativa que cobreix la majoria de casos reals, sense metaclasses:

class Base:
    registre: dict[str, type] = {}

    def __init_subclass__(cls, **kwargs) -> None:
        """Es crida sol cada cop que algú hereta de Base."""
        super().__init_subclass__(**kwargs)
        Base.registre[cls.__name__] = cls

class Full(Base): ...

print(Base.registre)   # {'Full': <class 'Full'>}

Exemples Pràctics

Funció Polimòrfica

def processa_col_leccio(col_leccio):
    """Funció que funciona amb qualsevol tipus de col·lecció"""
    print(f"Tipus: {type(col_leccio).__name__}")
    print(f"Longitud: {len(col_leccio)}")
    print(f"Primer element: {col_leccio[0] if col_leccio else 'Cap'}")
    
    for i, element in enumerate(col_leccio):
        if i >= 3:  # Només mostrar els primers 3
            break
        print(f"  {i}: {element}")

# Funciona amb diferents tipus
processa_col_leccio([1, 2, 3, 4])
processa_col_leccio("Hola")
processa_col_leccio((10, 20, 30))

Classe que Implementa Múltiples Protocols

class Comptador:
    def __init__(self, inicial=0):
        self.valor = inicial
    
    # Protocol numèric
    def __add__(self, altre):
        return Comptador(self.valor + altre)
    
    def __int__(self):
        return self.valor
    
    # Protocol de comparació
    def __eq__(self, altre):
        return self.valor == altre
    
    def __lt__(self, altre):
        return self.valor < altre
    
    # Protocol de representació
    def __str__(self):
        return str(self.valor)
    
    def __repr__(self):
        return f"Comptador({self.valor})"
    
    # Protocol callable
    def __call__(self):
        self.valor += 1
        return self

# Ús de la classe
comptador = Comptador(5)
print(comptador + 3)    # Comptador(8)
print(comptador < 10)   # True
comptador()             # Increment
print(comptador)        # 6

Millors Pràctiques

  1. Prefereix Protocol a l’herència per definir interfaces: el contracte és la forma de l’objecte, no el seu arbre genealògic
  2. Implementa protocols complets (no només alguns mètodes)
  3. Considera la mutabilitat en el disseny de classes, i fes servir el tipus que la garanteix quan un valor no ha de canviar (frozen=True, tupla, frozenset)
  4. Anota amb la notació moderna: list[str], int | None, type Alias = ..., i els tipus abstractes des de collections.abc
  5. Respecta les convencions dels mètodes especials
  6. Documenta els contractes que els teus objectes implementen
  7. Reserva metaclasses i descriptors per quan no hi hagi alternativa: __init_subclass__, un decorador de classe o @property cobreixen gairebé tots els casos

Si d’aquest document n’has de retenir una sola idea, que sigui la que travessa les cinc parts: en Python un nom no és una capsa, és una etiqueta enganxada a un objecte. D’aquí surten els àlies, la diferència entre is i ==, per què .copy() no sempre copia, per què una tupla pot ser clau d’un diccionari i una llista no, per què un mòdul importat dues vegades és el mateix mòdul, i per què Protocol pot descriure un contracte sense que ningú hereti de res.

Last change: , commit: d473117