A De les dades als objectes vam definir una classe Tasca i li vam assignar atributs des de fora, i vam comprovar que allò no arreglava res: continuàvem podent crear una tasca sense completada i amb la prioritat escrita com a "Alta". L'única cosa que va canviar va ser el nom de l'error, de KeyError a AttributeError. Aquesta lliçó aporta la peça que faltava, i és el cor de tot el mòdul. Aquesta peça és el constructor, __init__: un bloc que Python executa automàticament en crear cada instància, i que per tant és l'únic punt del programa pel qual passen, obligatòriament, totes les tasques que arribaran a existir. Allà s'exigeixen les dades imprescindibles i se'n validen els valors. Al seu costat arriben els mètodes, que permeten recollir les funcions soltes que des del mòdul 4 van fent voltes per tascafacil.py i desar-les on pertoquen: dins de la mateixa tasca. Al final de la lliçó, TascaFàcil serà la v0.15 i treballarà amb objectes.

Contingut

  1. __init__: el constructor
  2. self, explicat de veritat
  3. Validar dins del constructor
  4. Mètodes que llegeixen l'estat
  5. Mètodes que canvien l'estat
  6. __str__ enfront de __repr__
  7. Comparar objectes amb __eq__ (i una nota sobre __len__)
  8. Docstrings de classe i de mètode
  9. @property: valors calculats sense duplicar estat
  10. @staticmethod i @classmethod
  11. @dataclass: la drecera per a classes que només guarden dades
  12. TascaFàcil v0.15: la classe Tasca
  13. Errors comuns i consells
  14. Exercicis
  15. Conclusió

  1. __init__: el constructor

El constructor és un mètode especial anomenat __init__ (dos guions baixos a cada costat) que Python executa just després de crear una instància. La seva feina és deixar l'objecte a punt per fer-se servir: donar valor a tots els seus atributs.

class Tasca:
    """Una tasca de l'estudi Estudi Alba."""
    def __init__(self, titol, responsable, prioritat="mitjana", dies=1):
        self.titol = titol
        self.responsable = responsable
        self.prioritat = prioritat
        self.dies = dies
        self.completada = False       # tota tasca neix pendent

cartell = Tasca("Cartell fira del llibre", "Luis", "alta", 3)
print(cartell.titol, cartell.prioritat, cartell.completada)   # ... alta False
Tasca("Pressupost Vidal")
# TypeError: __init__() missing 1 required positional argument: 'responsable'

Hi ha diverses coses noves alhora, i convé llegir-les a poc a poc:

  • __init__ no es crida mai a mà. Escrius Tasca("Cartell...", "Luis", "alta", 3) i Python crea l'objecte i li passa aquests arguments a __init__ per tu. És el mateix mecanisme pel qual list("abc") o int("42") construeixen objectes.
  • Els paràmetres sense valor per defecte són les dades obligatòries. titol i responsable no en tenen, així que és impossible crear una tasca sense ells: l'error salta al lloc i al moment correctes, com mostra l'última línia de l'exemple. Els que sí que en tenen funcionen igual que a les funcions (04-02): prioritat="mitjana" i dies=1 permeten escriure Tasca("Revisar esbossos", "Nuria"), i han d'anar després dels obligatoris.
  • completada no és un paràmetre: es fixa a dins. Cap tasca no neix completada, així que no té sentit preguntar-ho en crear-la. Hi ha atributs que es reben i atributs que es fixen o es calculen, i tots surten del constructor amb valor. Abans, la garantia que una tasca tingués els seus cinc camps depenia que cada programador se'n recordés; ara la garantia és del llenguatge: si l'objecte existeix, els seus atributs existeixen, i l'AttributeError a mig llistat ha deixat de ser possible.

  1. self, explicat de veritat

self és la paraula que més confon al principi i en realitat no té cap misteri: self és l'objecte sobre el qual s'està treballant. Quan escrius Tasca("Cartell fira del llibre", "Luis"), Python fa dues coses: fabrica un objecte buit i crida __init__ passant-li aquest objecte com a primer argument. Dins del mètode, aquest primer paràmetre s'anomena self per conveni, i self.titol = titol vol dir «desa en aquest objecte el valor rebut».

El que escrius El que Python executa realment
cartell = Tasca("Cartell", "Luis") crea l'objecte i crida Tasca.__init__(objecte, "Cartell", "Luis")
cartell.completar() crida Tasca.completar(cartell)
cartell.reassignar("Nuria") crida Tasca.reassignar(cartell, "Nuria")

D'aquí en surten les dues regles que cal memoritzar: self és sempre el primer paràmetre de tot mètode d'instància, encara que no rebi res més; i self no es passa en cridar, el posa Python a partir de l'objecte que hi ha a l'esquerra del punt. Per això cartell.completar() s'escriu sense arguments encara que la seva definició sigui def completar(self). Dos errors clàssics, amb el seu missatge exacte perquè els reconeguis:

class Tasca:
    def __init__(titol, responsable):         # MALAMENT: falta self
        ...
Tasca("Cartell", "Luis")
# TypeError: __init__() takes 2 positional arguments but 3 were given

El missatge sembla absurd —«n'has donat 3 i n'accepta 2»— fins que recordes que Python afegeix l'objecte com a primer argument. Sense self, els comptes no quadren mai. El segon error no dona cap missatge, i per això és pitjor: escriure dins del constructor titol = titol en lloc de self.titol = titol. Això assigna a una variable local que mor en acabar el mètode (és l'àmbit del mòdul 4 en acció); l'objecte es queda sense titol i la falla apareixerà més tard i en un altre lloc. La regla és simple: tot el que hagi de sobreviure a la crida es desa a self.. Finalment, self no és una paraula reservada —el podries anomenar jo i funcionaria—, però no ho facis: tothom escriu self.

  1. Validar dins del constructor

Que la tasca tingui els cinc atributs no n'hi ha prou: els seus valors també han de ser correctes. I com que el constructor és el pas obligatori de tota tasca, és el lloc natural per comprovar-ho.

PRIORITATS = ("alta", "mitjana", "baixa")
EQUIP = ("marta", "luis", "nuria")

class Tasca:
    def __init__(self, titol, responsable, prioritat="mitjana", dies=1):   # v0.15
        self.titol = titol.strip()                        # fora espais sobrants
        net = responsable.strip().lower()
        self.responsable = net.capitalize() if net in EQUIP else "Marta"
        prioritat = prioritat.strip().lower()             # 'Alta' -> 'alta'
        self.prioritat = prioritat if prioritat in PRIORITATS else "mitjana"
        self.dies = dies if 1 <= dies <= 365 else 1
        self.completada = False

t = Tasca("  Logotip Forn Sole  ", "NURIA", "Alta", 5)
print(f"[{t.titol}] {t.responsable} {t.prioritat} {t.dies}d")
# [Logotip Forn Sole] Nuria alta 5d

Fixa't en el que acaba de passar: "Alta", el valor que al mòdul 6 anava a fer petar l'ORDRE_PRIORITAT, ja no pot entrar al programa. La classe el normalitza a l'únic punt pel qual passen totes les tasques, i el mateix fa amb els espais del títol i amb "NURIA". Hi ha tres estratègies possibles davant d'un valor invàlid:

Estratègia Què fa Quan convé
Normalitzar Arregla el que és arreglable: "Alta""alta", treu espais Diferències de format, no de contingut
Valor segur Substitueix el que és irrecuperable per un valor per defecte Dades dubtoses que no han d'aturar el programa
Avisar i rebutjar Impedeix crear l'objecte i comunica la falla El que és correcte en un programa seriós

De moment fem servir les dues primeres a consciència. La tercera —llançar un error amb raise i capturar-lo amb try/except— és el contingut de Depuració i gestió d'errors; quan ho estudiïs tornaràs a aquesta classe i substituiràs els valors segurs per errors explícits, sense canviar-ne l'estructura. Mentrestant cal anar amb compte amb un perill: un valor segur silenciós amaga el problema. Si algú escriu Tasca("Cartell", "Pep") i la tasca acaba assignada a Marta sense dir res, l'error existeix però ningú no el veu; com a mínim cal avisar per pantalla amb un print(f"Avis: '{responsable}' no consta a EQUIP; la tasca passa a Marta.") en aquesta branca, i així ho farà la versió definitiva de la secció 12.

  1. Mètodes que llegeixen l'estat

Un mètode és una funció definida dins de la classe, amb self com a primer paràmetre, que per tant accedeix directament als atributs de l'objecte. N'hi ha de dues famílies, i distingir-les ajuda a dissenyar bé: els que consulten l'estat sense tocar-lo i els que el modifiquen. Comencem pels primers, que recullen lògica fins ara dispersa en funcions soltes (la classe té a més un atribut self.fets = 0, els dies ja invertits):

    def esta_endarrerida(self):
        """Indica si s han invertit mes dies dels estimats."""
        return not self.completada and self.fets > self.dies

    def urgencia(self):
        """Retorna la urgencia real: 'cap', 'critica' o la prioritat."""
        if self.completada:
            return "cap"
        return "critica" if self.esta_endarrerida() else self.prioritat

cartell = Tasca("Cartell fira del llibre", "Luis", "alta", 3)
cartell.fets = 5                                        # 5 dies invertits de 3 previstos
print(cartell.esta_endarrerida(), cartell.urgencia())   # True critica

Tres observacions que valen per a tots els mètodes que escriguis. No reben la tasca com a paràmetre: abans escrivíem esta_endarrerida(tasca) i ara la tasca és self i arriba sola, amb la qual cosa cartell.urgencia() diu qui i què sense ambigüitat. Un mètode pot cridar-ne un altre del mateix objecte, sempre a través de self, com fa urgencia amb self.esta_endarrerida(); sense el self., Python buscaria una funció global amb aquest nom i no la trobaria. I aquests mètodes no imprimeixen: retornen un valor, que és la separació entre entrada/sortida i lògica de 04-04 aplicada dins de les classes; qui crida decideix si l'imprimeix, el suma o el fa servir per filtrar.

  1. Mètodes que canvien l'estat

L'altra família modifica els atributs de l'objecte. Aquí és on marcar_completada i canviar_prioritat, que des del mòdul 4 rebien un diccionari i el modificaven al lloc, troben per fi el seu espai:

    def completar(self):
        """Marca la tasca com a acabada. Retorna False si ja ho estava."""
        if self.completada:
            return False
        self.completada = True
        self.fets = max(self.fets, self.dies)
        return True

    def reassignar(self, persona):
        """Canvia el responsable si la persona pertany a l'equip."""
        net = persona.strip().lower()
        if net not in EQUIP:
            return False
        self.responsable = net.capitalize()
        return True

    def avancar(self, dies_treballats):
        """Suma dies de feina ja invertits en la tasca."""
        self.fets += max(0, dies_treballats)

cartell = Tasca("Cartell fira del llibre", "Luis", "alta", 3)
print(cartell.reassignar("nuria"), cartell.reassignar("Pep"))  # True False
print(cartell.completar(), cartell.completar())   # True False: la segona no canvia res

Dues decisions de disseny que val la pena copiar. Retornen True/False per indicar si el canvi s'ha fet, de manera que qui crida avisa l'usuari sense que el mètode imprimeixi res: if not cartell.reassignar(nom): print("Aquesta persona no es a l'equip."). I el mètode protegeix l'invariant: reassignar no admet un responsable fora d'EQUIP, igual que feia el constructor. Aquesta és la regla general de l'encapsulació: si una dada té regles, tots els camins que la modifiquen les han de respectar, i per això es canvia mitjançant mètodes i no escrivint cartell.responsable = "Pep" des de fora.

  1. __str__ enfront de __repr__

Ja vas veure que print(cartell) mostra alguna cosa il·legible: <__main__.Tasca object at 0x7f8b1c0d5e50>. S'arregla amb dos mètodes especials:

    def __str__(self):                           # text per a l usuari final
        marca = "[X]" if self.completada else "[ ]"
        return f"{marca} {self.titol:<28}{self.responsable:<8}{self.prioritat:<6}{self.dies}d"

    def __repr__(self):                          # text per al programador
        return f"Tasca({self.titol!r}, {self.responsable!r}, {self.prioritat!r}, {self.dies})"

cartell = Tasca("Cartell fira del llibre", "Luis", "alta", 3)
print(cartell)          # [ ] Cartell fira del llibre     Luis    alta  3d
print(repr(cartell))    # Tasca('Cartell fira del llibre', 'Luis', 'alta', 3)
print([cartell])        # [Tasca('Cartell fira del llibre', 'Luis', 'alta', 3)]
__str__ __repr__
Per a qui L'usuari del programa El programador
El fan servir print(obj), str(obj), f-strings La consola, repr(obj), els objectes dins de llistes
Objectiu Que es llegeixi bé Que sigui precís i inequívoc
Si només en defineixes un print funciona, però les llistes continuen lletges print també el fa servir com a recanvi

L'última fila és el consell pràctic: si només n'has d'escriure un, escriu __repr__, perquè Python el fa servir de substitut quan falta __str__ i a més és el que es veu en depurar. La sorpresa més habitual és la de print([cartell]): en imprimir una llista, Python no fa servir el __str__ dels seus elements sinó el seu __repr__, així que una llista de tasques sense __repr__ continua mostrant adreces de memòria. El !r de la f-string, per cert, és la drecera per aplicar repr() a un valor: per això els textos surten entre cometes.

  1. Comparar objectes amb __eq__ (i una nota sobre __len__)

Per defecte, dos objectes diferents mai no són iguals, encara que continguin les mateixes dades. És coherent amb el que vam veure a 05-01 sobre is i ==, però gairebé mai no és el que volem. Definint __eq__ decidim nosaltres què significa que dues tasques siguin la mateixa:

    def __eq__(self, altra):
        """Dues tasques son la mateixa si coincideixen titol i responsable."""
        if not isinstance(altra, Tasca):
            return NotImplemented          # comparar amb una altra cosa no ens toca
        return (self.titol.lower() == altra.titol.lower()
                and self.responsable == altra.responsable)

a = Tasca("Cartell fira del llibre", "Luis", "alta", 3)
b = Tasca("cartell fira del llibre", "Luis", "baixa", 9)
print(a == b, a == "Cartell")   # True False (sense __eq__, la primera seria False)
print(a in [b])                 # True: l'operador 'in' fa servir ==

L'interessant és l'última línia: en definir __eq__, operadors i funcions que ja coneixes comencen a funcionar amb els teus objectes. in, .count(), .index() i .remove() sobre llistes fan servir == internament, així que ara localitzen tasques per contingut. Retornar NotImplemented quan l'altre objecte no és una Tasca és la manera educada de dir «jo no sé comparar això»; Python llavors respon False. A la mateixa família hi ha __len__, que defineix què retorna len(obj). En una tasca solta no té sentit —la longitud de què?—, però a la classe Agenda de Col·leccions d'objectes serà evident: len(agenda) ha de donar el nombre de tasques. A aquests mètodes amb guions baixos se'ls anomena mètodes especials o dunder (de double underscore), i la seva gràcia és que connecten les teves classes amb la sintaxi del llenguatge.

  1. Docstrings de classe i de mètode

Igual que les funcions, les classes i els seus mètodes porten docstring: un text entre triples cometes a la primera línia del cos. Ja l'has vist a tots els exemples d'aquesta lliçó —"""Marca la tasca com a acabada. Retorna False si ja ho estava."""—, amb una diferència: el docstring de la classe sol ocupar diverses línies, perquè a més de dir què representa l'objecte descriu què garanteix, com a """Una tasca de l'estudi. Neix pendent, validada i amb tots els seus camps.""".

Es consulten amb help(Tasca) o Tasca.completar.__doc__, i l'editor els mostra en escriure. La regla mínima: la classe explica què representa i què garanteix; cada mètode, què fa i què retorna. La guia completa és a Documentació i comentaris.

  1. @property: valors calculats sense duplicar estat

Imagina que vols saber quants dies de feina li queden a una tasca. La temptació és desar-ho com un atribut més, self.dies_restants = dies, i aquí comença el problema: cada vegada que algú cridi avancar() o completar() caldrà recordar-se'n de recalcular-lo, i el dia que se n'oblidi l'objecte mostrarà una dada falsa. Això és estat duplicat, una de les fonts d'error més silencioses que hi ha. La solució és no desar-lo, sinó calcular-lo quan es demani. I perquè es llegeixi com un atribut en comptes de com un mètode, es fa servir el decorador @property:

    @property
    def dies_restants(self):
        """Dies de feina que falten; zero si la tasca ja esta completada."""
        return 0 if self.completada else max(0, self.dies - self.fets)

cartell = Tasca("Cartell fira del llibre", "Luis", "alta", 3)
print(cartell.dies_restants)     # 3   <- sense parentesis: sembla un atribut
cartell.avancar(2)
print(cartell.dies_restants)     # 1   <- sempre coherent: es recalcula
cartell.completar()
print(cartell.dies_restants)     # 0   <- i continua sent coherent

La diferència amb un atribut desat és la coherència: tots dos es llegeixen igual, amb obj.x i sense parèntesis, però l'atribut cal actualitzar-lo a mà a cada mètode que l'afecti, mentre que la @property es calcula en el moment i per tant no menteix mai. A canvi paga un petit càlcul per lectura, que tret dels bucles enormes és irrellevant. Els decoradors ja et sonen de @lru_cache (06-03): són funcions que n'embolcallen una altra per modificar-ne el comportament. La regla pràctica: si un valor es pot deduir d'altres atributs, fes-lo @property; si és una dada independent, desa'l. I compte, una @property de només lectura no admet assignació —cartell.dies_restants = 5 dona AttributeError—, cosa que és una protecció, no un inconvenient.

  1. @staticmethod i @classmethod

No tots els mètodes necessiten un objecte concret. Un @staticmethod és una funció normal que viu dins de la classe per afinitat temàtica: no rep self ni toca cap objecte. Serveix per a utilitats del domini, com comprovar una dada abans de crear res:

    @staticmethod
    def prioritat_valida(text):
        """Indica si un text es una prioritat acceptable."""
        return text.strip().lower() in PRIORITATS

print(Tasca.prioritat_valida("ALTA"))    # True: des de la classe, sense instancia

Un @classmethod rep com a primer paràmetre la classe (cls) en comptes de la instància, i el seu ús més valuós és el constructor alternatiu: una altra manera de fabricar objectes a partir de dades amb un format diferent. Just el que necessitem per al JSON de 05-05, on cada tasca està desada com a diccionari:

    @classmethod
    def des_de_diccionari(cls, dades):
        """Crea una Tasca a partir d un diccionari llegit del JSON."""
        tasca = cls(dades["titol"], dades["responsable"],
                    dades.get("prioritat", "mitjana"), dades.get("dies", 1))
        tasca.fets = dades.get("fets", 0)
        tasca.completada = dades.get("completada", False)
        return tasca

d = {"titol": "Pressupost Vidal", "responsable": "Marta", "prioritat": "Alta"}
print(Tasca.des_de_diccionari(d))   # [ ] Pressupost Vidal            Marta   alta  1d

Observa el detall que ho fa robust: dades.get("prioritat", "mitjana") tolera que la clau falti i el constructor normalitza l'"Alta" que venia del fitxer. És a dir, els diccionaris bruts del mòdul 6 entren, i en surten tasques netes. L'operació inversa —convertir l'objecte en diccionari per poder desar-lo— és to_dict(), i arriba a 07-03 al costat de la classe Agenda, perquè json.dump no sap escriure objectes.

  1. @dataclass: la drecera per a classes que només guarden dades

Quan una classe es limita a agrupar dades, escriure __init__, __repr__ i __eq__ a mà és pur tràmit. El mòdul dataclasses de la biblioteca estàndard els genera per tu. El mateix Client, abans i després:

class Client:                                    # ABANS: tot a ma
    def __init__(self, nom, contacte, actiu=True):
        self.nom, self.contacte, self.actiu = nom, contacte, actiu
    def __repr__(self): ...                      # la f-string amb els tres camps
    def __eq__(self, altre): ...                 # isinstance + comparacio camp a camp

from dataclasses import dataclass                # DESPRES: el mateix, generat

@dataclass
class Client:
    """Un client de l'estudi."""
    nom: str
    contacte: str
    actiu: bool = True

sole = Client("Forn Sole", "[email protected]")
print(sole, sole == Client("Forn Sole", "[email protected]"))
# Client(nom='Forn Sole', contacte='[email protected]', actiu=True) True

El decorador llegeix les línies nom: stranotacions de tipus, que Python no comprova però que serveixen de documentació i ajuden l'editor— i genera __init__, __repr__ i __eq__. Quan fer servir cada cosa: si la classe només agrupa dades, @dataclass; si hi ha validació, normalització, invariants o comportament propi, classe normal amb __init__ escrit a mà. La nostra Tasca valida, normalitza i decideix, així que es queda com a classe normal; Client, que només transporta dades, és un @dataclass de manual.

  1. TascaFàcil v0.15: la classe Tasca

Ho apliquem tot. Les funcions soltes que treballaven sobre diccionaris es converteixen en mètodes, i l'agenda passa a ser una llista d'objectes Tasca.

# tascafacil.py - Estudi Alba / Versio 0.15: la tasca es un objecte
CAMPS = ("titol", "responsable", "prioritat", "dies", "fets", "completada")
# --- Resta de constants i funcions d E/S: sense canvis respecte de la v0.14 ---
class Tasca:
    """Una tasca de l'estudi. Neix pendent, validada i amb tots els seus camps."""
    # __init__ com a la seccio 3, mes l avis de responsable invalid i
    #     self.fets = 0; dies_restants (@property), esta_endarrerida, urgencia,
    #     completar, reassignar, canviar_prioritat, avancar, des_de_diccionari,
    #     __str__, __repr__ i __eq__ tal com s han escrit en aquesta llico

    def clau_ordre(self):
        """Criteri del llistat: prioritat, despres dies, despres titol."""
        return (ORDRE_PRIORITAT[self.prioritat], self.dies, self.titol)

def registrar_tasca(agenda):
    """Demana una tasca nova i l afegeix a l agenda."""
    agenda.append(Tasca(demanar_text("Titol         : "),
                        demanar_opcio("Responsable   : ", EQUIP),
                        demanar_opcio("Prioritat     : ", PRIORITATS),
                        demanar_enter("Dies (1-365)  : ", 1, 365)))

def mostrar_llistat(agenda):
    """Mostra l agenda ordenada per prioritat i dies."""
    for numero, tasca in enumerate(sorted(agenda, key=Tasca.clau_ordre), start=1):
        print(f"{numero:>2}. {tasca}")          # aqui actua __str__

Dos detalls nous. Al print del bucle, {tasca} invoca __str__ automàticament: la funció ja no sap res de l'estructura interna d'una tasca. I key=Tasca.clau_ordre funciona perquè un mètode al qual s'accedeix des de la classe és una funció normal que rep l'objecte com a primer argument, així que sorted li passarà cada tasca com a self; també valdria key=lambda t: t.clau_ordre(). Abans i després, línia a línia:

A la v0.14 (diccionaris) A la v0.15 (objectes)
tasca["titol"] tasca.titol
mostrar_fitxa(tasca) print(tasca), gràcies a __str__
clau_ordre(tasca) / marcar_completada(tasca) tasca.clau_ordre() / tasca.completar()
Validació repartida per registrar_tasca Centralitzada a __init__
Un KeyError possible en qualsevol llistat Impossible: si l'objecte existeix, té els seus camps

Queda un cap solt evident: desar_tasques ja no funciona. json.dump sap escriure diccionaris, però no objectes Tasca, i protesta amb TypeError: Object of type Tasca is not JSON serializable. La solució —un mètode to_dict() que retorni el diccionari equivalent, amb des_de_diccionari per al camí de tornada— arriba a la propera lliçó.

Errors Comuns i Consells

  • Oblidar self a la definició del mètode, amb el missatge takes 1 positional argument but 2 were given. Recorda que Python afegeix l'objecte: si el mètode rep n arguments en cridar-lo, la seva definició necessita n + 1 paràmetres. I oblidar el self. en assignar dins del constructor no dona cap error: crea una variable local que es perd, així que si un atribut «desapareix», revisa això primer.
  • Posar un mutable com a valor per defecte d'un paràmetre, def __init__(self, notes=[]). És el mateix error del mòdul 4 i aquí és pitjor, perquè aquesta llista es comparteix entre totes les instàncies. Fes servir notes=None i a dins self.notes = notes if notes else []. Tampoc no cal mai cridar __init__ a mà (cartell.__init__(...)): escriu Tasca(...).
  • Definir només __str__ i esperar que les llistes es vegin bé. print([tasca]) fa servir __repr__: defineix-los tots dos, o com a mínim __repr__. I no modifiquis atributs des de fora saltant-te els mètodes (tasca.prioritat = "Alta"), perquè trenca justament la garantia que dona la classe: si una dada té regles, passa pel mètode.
  • Consell: posa els mètodes en ordre. Primer __init__, després les @property, tot seguit els mètodes que consulten, els que modifiquen i al final els especials. Una classe ordenada es llegeix en diagonal.

Exercicis

Exercici 1: La classe Client

Escriu una classe Client el constructor de la qual rebi nom, contacte i dies_credit=30; que normalitzi el nom (sense espais sobrants i amb la primera lletra de cada paraula en majúscula), obligui que dies_credit estigui entre 0 i 90 (si no, 30) i fixi factures = []. Afegeix-hi un mètode facturar(quantitat) que afegeixi la quantitat a la llista si és positiva i retorni True/False, i una @property total_facturat. Prova-la amb el Forn Solé.

Exercici 2: Un mètode a partir d'una funció solta

Aquesta funció treballava amb diccionaris a la v0.14. Converteix-la en un mètode resum() de Tasca sense canviar-ne el comportament i explica per escrit què millora: return f"{tasca['titol']} ({tasca['responsable']}): {estat}, {tasca['dies']} dies", on estat val "completada" o "pendent" segons tasca["completada"].

Exercici 3: @property enfront d'atribut desat

Afegeix a Tasca una @property anomenada percentatge que retorni l'avanç en tant per cent (dies fets sobre dies estimats, amb un màxim de 100, i 100 si està completada). Després respon per escrit: què passaria si en lloc d'una @property es desés self.percentatge al constructor? Escriu la seqüència de crides que demostraria la falla.

Solucions

Solució 1.

class Client:
    """Un client de l'estudi, amb el seu credit i les seves factures."""
    def __init__(self, nom, contacte, dies_credit=30):
        self.nom = nom.strip().title()
        self.contacte = contacte.strip().lower()
        self.dies_credit = dies_credit if 0 <= dies_credit <= 90 else 30
        self.factures = []             # mutable: sempre creat al constructor

    @property
    def total_facturat(self):
        """Suma de totes les factures emeses al client."""
        return sum(self.factures)

    def facturar(self, quantitat):
        """Afegeix una factura si la quantitat es positiva."""
        if quantitat <= 0:
            return False
        self.factures.append(quantitat)
        return True

sole = Client(" forn sole ", "[email protected]", 120)
sole.facturar(450.0)
print(sole.nom, sole.dies_credit, sole.facturar(-10), sole.total_facturat)
# Forn Sole 30 False 450.0   <- nom normalitzat, credit segur, rebuig

La clau és a self.factures = [] dins del constructor: si estigués al cos de la classe seria un atribut de classe compartit i les factures d'un client apareixerien als altres (07-01, secció 7). I total_facturat és una @property perquè és una suma deduïble de factures: desar-la obligaria a actualitzar-la a cada facturar.

Solució 2.

    def resum(self):
        """Retorna una linia descriptiva de la tasca."""
        estat = "completada" if self.completada else "pendent"
        return f"{self.titol} ({self.responsable}): {estat}, {self.dies} dies"

Tres millores concretes. Viu al costat de les dades: si demà la tasca guanya un camp, el resum és a tres línies de distància i no en un altre punt del fitxer. No pot rebre res que no sigui una tasca: resum_tasca({"titol": "x"}) petava amb KeyError, mentre que tasca.resum() només existeix si tasca és una Tasca. I es llegeix millor al punt d'ús: print(cartell.resum()) diu de qui és el resum sense necessitat de mirar la signatura de cap funció.

Solució 3.

    @property
    def percentatge(self):                       # avanc, entre 0 i 100
        if self.completada:
            return 100
        return min(100, round(self.fets / self.dies * 100))

# Si en comptes de la @property es deses self.percentatge = 0 al constructor:
t = Tasca("Cartell fira del llibre", "Luis", "alta", 4)
t.avancar(2)                 # ha fet la meitat
print(t.percentatge)         # 0  <- MENTIDA: ningu no va actualitzar l atribut

avancar modifica fets, però l'atribut percentatge conserva el valor que li va posar el constructor. És estat duplicat: el mateix fet —quant s'ha avançat— desat en dos llocs que poden discrepar. Amb la @property, el percentatge no existeix fins que es demana i per tant no pot estar desactualitzat. El mateix parany apareixeria amb completar(), que també canvia fets sense tocar percentatge.

Conclusió

El constructor __init__ és el pas obligatori de tots els objectes d'una classe, i per això és on es garanteix que tota tasca neix completa: els paràmetres sense valor per defecte són les dades imprescindibles, els que sí que en tenen cobreixen allò opcional, i els atributs que no es reben —com completada— es fixen a dins. Allà mateix es valida: normalitzar "Alta" a "alta" i comprovar el responsable contra EQUIP elimina d'arrel els KeyError del mòdul 6, i quan estudiïs raise i try/except a 08-02 substituiràs els valors segurs per errors explícits. self no és màgia: és el mateix objecte, que Python passa com a primer argument a partir del que hi ha a l'esquerra del punt, i tot el que hagi de sobreviure a la crida es desa a self.. Els mètodes recullen la lògica que estava solta, distingint els que consulten l'estat (esta_endarrerida, urgencia, clau_ordre) dels que el modifiquen (completar, reassignar, canviar_prioritat), que retornen True/False en comptes d'imprimir. Els mètodes especials connecten la classe amb el llenguatge: __str__ per a l'usuari, __repr__ per al programador —i és el que fan servir les llistes—, __eq__ perquè == i in comparin contingut. @property converteix en atribut de lectura qualsevol valor deduïble dels altres i elimina l'estat duplicat; @staticmethod allotja utilitats sense objecte i @classmethod dona constructors alternatius com Tasca.des_de_diccionari(d), la porta d'entrada de les dades del JSON. I @dataclass estalvia la feina manual a les classes que només agrupen dades.

TascaFàcil és ja la v0.15: l'agenda és una llista d'objectes Tasca, el llistat s'imprimeix amb {tasca} i set funcions soltes s'han mudat dins de la classe. Però fixa't en el que ha quedat despenjat. L'agenda continua sent una llista pelada: qualsevol part del programa pot fer-li append del que vulgui —inclòs un diccionari solt o un número—, les operacions que la manegen (cercar, filtrar, ordenar, resumir) continuen repartides pel fitxer, i desar_tasques està literalment trencat perquè json.dump no sap escriure objectes. A Col·leccions d'objectes farem el mateix salt d'avui, però un nivell més amunt: apareix la classe Agenda, que conté les tasques i ofereix afegir, cercar, filtrar i resum; aprendràs a recórrer, ordenar i indexar objectes, veuràs per què una col·lecció ha de protegir la seva llista interna i recuperaràs la persistència amb to_dict/from_dict per continuar desant en el mateix JSON de 05-05.

© Copyright 2026. Tots els drets reservats