Python avançat
- Introducció
- Què és el Model d’Objectes de Python?
- Mutabilitat, àlies i estat
- Tipus: contractes que es comproven sense executar
- Duck typing, protocols i descriptors
- Organització del codi
- Python modern: el que ha canviat i val la pena adoptar
- Referència: el catàleg del model d’objectes
- Protocols Integrats: Categories d’Objectes
- Operacions Universals
- Protocol d’Objectes i Mètodes Especials
- Jerarquia de Tipus i Herència
- 🔬 Metaclasses i Creació de Tipus
- Exemples Pràctics
- Millors Pràctiques
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:
- 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.
- 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.
- Tipus — les anotacions com a contracte que es comprova sense executar:
Protocolen lloc d’herència, genèrics, i com es desxifren les signatures de NumPy, PyTorch o scikit-learn. - 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.
- 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 decollections.abc. Les formes antigues del mòdultyping, 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:
- Capa d’Objectes Abstractes: Defineix operacions universals per a tots els objectes
- Protocols Integrats: Contractes que els objectes poden implementar (numèric, seqüència, etc.)
- Protocol Interfaces: Sistema de tipus per formalitzar contractes
- Jerarquia de Tipus: Com s’organitzen i hereten comportaments els tipus
- 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
- Tot és un objecte: A Python, tot (números, cadenes, funcions, classes, mòduls) és un objecte
- Interfície unificada: Tots els objectes responen a un conjunt comú d’operacions
- 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
- No et preocupis per entendre-ho tot: Comença pel que necessites
- Usa IDEs moderns: PyCharm, VSCode, etc. ajuden amb autocompletar
- Llegeix documentació oficial: Sovint explica els tipus més clarament
- Practica amb exemples simples: Abans d’atacar codi complex
- 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è@propertyfunciona; escriure’n un de propi és rar, i normalment@propertyo 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.
| Capa | Què hi viu | De qui depèn |
|---|---|---|
| Presentació | qui parla amb el món: HTTP, CLI, quaderns | de serveis |
| Serveis | la feina de debò, sense detalls d’infraestructura | d’esquemes |
| Infraestructura | qui parla amb l’exterior: disc, xarxa, base de dades | d’esquemes |
| Esquemes | els contractes de dades | de 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
- Consistència: Interfície uniforme per a tots els tipus d’objectes
- Polimorfisme: El mateix codi funciona amb diferents tipus d’objectes
- Extensibilitat: Es poden crear nous tipus que s’integren perfectament
- Simplicitat: Menys conceptes per aprendre i recordar
- Flexibilitat: Suport per programació funcional, OOP i procedural
- 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
- Prefereix
Protocola l’herència per definir interfaces: el contracte és la forma de l’objecte, no el seu arbre genealògic - Implementa protocols complets (no només alguns mètodes)
- 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) - Anota amb la notació moderna:
list[str],int | None,type Alias = ..., i els tipus abstractes des decollections.abc - Respecta les convencions dels mètodes especials
- Documenta els contractes que els teus objectes implementen
- Reserva metaclasses i descriptors per quan no hi hagi alternativa:
__init_subclass__, un decorador de classe o@propertycobreixen 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.