1 / 1
Indice della lezione

Capitolo 5 - Classi e Programmazione Orientata agli Oggetti

La Programmazione Orientata agli Oggetti (OOP, Object-Oriented Programming) è uno dei principali paradigmi utilizzati per progettare software complessi.

In Python le classi permettono di creare nuovi tipi di oggetti che combinano:

  • dati, rappresentati dagli attributi;
  • comportamenti, rappresentati dai metodi;
  • regole, che stabiliscono come lo stato dell'oggetto può essere modificato.

L'obiettivo non è semplicemente imparare la sintassi di class, ma imparare a modellare un problema attraverso oggetti che collaborano tra loro.

Questo capitolo parte dalle classi più semplici e arriva a:

  • attributi e metodi;
  • costruttori;
  • proprietà;
  • incapsulamento;
  • metodi di classe e statici;
  • metodi speciali;
  • confronto e operatori;
  • contenitori personalizzati;
  • ereditarietà;
  • overriding e super();
  • ereditarietà multipla e MRO;
  • classi astratte;
  • polimorfismo e duck typing;
  • composizione;
  • dataclass;
  • principi di buona progettazione OOP.

Prerequisiti: funzioni, strutture dati, *args, **kwargs, eccezioni di base e type hints.


5.1 Che cos'è una classe?

Una classe è una definizione che descrive come saranno fatti gli oggetti di un determinato tipo.

Un oggetto creato a partire da una classe è un'istanza.

class Persona:
    pass

Possiamo creare due istanze:

p1 = Persona()
p2 = Persona()

p1 e p2 appartengono alla stessa classe, ma sono due oggetti distinti.

Possiamo verificare il tipo:

print(type(p1))

e verificare se un oggetto è istanza di una classe:

print(isinstance(p1, Persona))

Classe e istanza

È utile distinguere immediatamente:

  • classe → descrizione del tipo;
  • istanza → oggetto concreto creato da quella classe.

Un'analogia semplice:

La classe è il progetto di una casa; l'istanza è una casa costruita a partire da quel progetto.

Esercizio 5.1

Definisci una classe Libro vuota e crea due istanze libro1 e libro2.

Verifica con isinstance() che entrambe siano istanze di Libro.


5.2 Definire una classe

La sintassi fondamentale è:

class NomeClasse:
    # corpo della classe
    pass

Per convenzione i nomi delle classi utilizzano il PascalCase:

class Studente:
    pass

class ContoBancario:
    pass

class ProdottoDigitale:
    pass

All'interno di una classe possiamo definire attributi e metodi.

class Studente:

    def saluta(self):
        print("Ciao, sono uno studente")

Creiamo un'istanza:

s = Studente()
s.saluta()

Output:

Ciao, sono uno studente

5.3 Il parametro self

Nei metodi di istanza il primo parametro è normalmente chiamato self.

class Persona:

    def saluta(self):
        print("Ciao!")

Quando scriviamo:

persona.saluta()

Python passa automaticamente l'istanza come primo argomento.

Concettualmente possiamo immaginare:

Persona.saluta(persona)

self rappresenta quindi l'istanza corrente.

È importante capire che self non è una parola chiave riservata: è una convenzione obbligatoria nella pratica Python. Il nome potrebbe tecnicamente essere diverso, ma usare self è lo standard.

self permette di accedere allo stato dell'oggetto

class Persona:

    def __init__(self, nome):
        self.nome = nome

    def saluta(self):
        print(f"Ciao, sono {self.nome}")

Ogni istanza possiede il proprio nome.

p1 = Persona("Anna")
p2 = Persona("Marco")

p1.saluta()
p2.saluta()

Output:

Ciao, sono Anna
Ciao, sono Marco

Esercizio 5.2

Crea una classe Lampada con un metodo accendi() che stampi:

La lampada è accesa

Crea un'istanza e richiama il metodo.


5.4 Il costruttore __init__

Quando si crea un'istanza è spesso necessario inizializzarla con alcuni dati.

Python mette a disposizione il metodo speciale __init__().

class Persona:

    def __init__(self, nome, eta):
        self.nome = nome
        self.eta = eta

Ora:

p = Persona("Mario", 30)

Durante la creazione dell'istanza Python esegue __init__() per inizializzare l'oggetto.

Possiamo poi usare gli attributi:

print(p.nome)
print(p.eta)

Parametri e attributi

Questa istruzione:

self.nome = nome

contiene due elementi diversi:

  • nome a destra → parametro del metodo;
  • self.nome a sinistra → attributo dell'istanza.

Un esempio più completo:

class Studente:

    def __init__(self, nome, eta):
        self.nome = nome
        self.eta = eta

    def presenta(self):
        print(f"Sono {self.nome} e ho {self.eta} anni")

Uso:

s = Studente("Anna", 18)
s.presenta()

Valori predefiniti

Come nelle normali funzioni, possiamo utilizzare valori di default:

class Studente:

    def __init__(self, nome, eta=18):
        self.nome = nome
        self.eta = eta

Quindi entrambe sono valide:

s1 = Studente("Anna")
s2 = Studente("Marco", 21)

Attenzione: __init__ non è il vero metodo di costruzione

In termini tecnici, __init__() inizializza un'istanza già creata; non è il metodo che alloca l'oggetto. Per la maggior parte dei programmi didattici e applicativi è comunque corretto chiamarlo comunemente "costruttore".

Esercizio 5.3

Crea una classe Prodotto con:

  • nome;
  • prezzo;
  • metodo descrizione().

Esempio:

p = Prodotto("Mouse", 25.90)
p.descrizione()

dovrebbe stampare una descrizione leggibile del prodotto.


5.5 Attributi di istanza

Gli attributi preceduti da self. appartengono normalmente alla singola istanza.

class Studente:

    def __init__(self, nome, voto):
        self.nome = nome
        self.voto = voto

Possiamo creare:

s1 = Studente("Anna", 8)
s2 = Studente("Marco", 6)

Gli oggetti hanno valori indipendenti:

print(s1.voto)  # 8
print(s2.voto)  # 6

Modificare s1.voto non modifica s2.voto.

s1.voto = 10

print(s1.voto)  # 10
print(s2.voto)  # 6

5.6 Attributi di classe

Un attributo definito direttamente nel corpo della classe è un attributo di classe.

class Studente:

    scuola = "Istituto Tecnico"

    def __init__(self, nome):
        self.nome = nome

Possiamo accedervi attraverso la classe:

print(Studente.scuola)

oppure attraverso un'istanza:

s = Studente("Anna")
print(s.scuola)

Se non esiste un attributo scuola nell'istanza, Python cerca quello definito nella classe.

Modificare l'attributo di classe

Studente.scuola = "Liceo"

Le istanze che non hanno un proprio scuola vedranno il nuovo valore.

Attenzione all'assegnazione sull'istanza

Questa istruzione:

s.scuola = "Università"

non modifica necessariamente l'attributo di classe. Crea invece un attributo di istanza che nasconde quello di classe per quell'oggetto.

s1 = Studente("Anna")
s2 = Studente("Marco")

s1.scuola = "Università"

print(s1.scuola)  # Università
print(s2.scuola)  # Liceo
print(Studente.scuola)  # Liceo

Quando usare gli attributi di classe?

Sono adatti quando un'informazione è effettivamente comune alla classe:

class Dipendente:
    azienda = "ACME"

Non sono invece adatti per dati che devono essere indipendenti per ogni oggetto.

Esercizio 5.4

Crea una classe Auto con:

  • attributo di classe numero_ruote = 4;
  • attributi di istanza marca e modello.

Crea due automobili e verifica la differenza tra i due tipi di attributo.


5.7 Metodi di istanza

I metodi di istanza lavorano normalmente sullo stato di una specifica istanza e ricevono self.

class Conto:

    def __init__(self, intestatario, saldo=0):
        self.intestatario = intestatario
        self.saldo = saldo

    def deposita(self, importo):
        self.saldo += importo

    def visualizza_saldo(self):
        print(f"Saldo: {self.saldo:.2f} €")

Uso:

conto = Conto("Anna", 100)
conto.deposita(50)
conto.visualizza_saldo()

I metodi permettono quindi di associare comportamento e dati.


5.8 Metodi di classe: @classmethod

Un metodo di classe riceve la classe attraverso cls.

class Studente:

    scuola = "ITIS"

    @classmethod
    def cambia_scuola(cls, nuova_scuola):
        cls.scuola = nuova_scuola

Possiamo chiamarlo senza creare un'istanza:

Studente.cambia_scuola("Liceo")

Factory method

Un uso particolarmente interessante di @classmethod è creare costruttori alternativi.

class Studente:

    def __init__(self, nome, eta):
        self.nome = nome
        self.eta = eta

    @classmethod
    def da_stringa(cls, testo):
        nome, eta = testo.split("-")
        return cls(nome, int(eta))

Uso:

s = Studente.da_stringa("Marco-18")

print(s.nome)
print(s.eta)

cls(...) è preferibile a Studente(...) perché permette alla factory di funzionare correttamente anche con eventuali sottoclassi.

Esercizio 5.5

Crea una classe Data con giorno, mese e anno.

Aggiungi un metodo di classe:

da_stringa("25/12/2026")

che restituisca una nuova istanza.


5.9 Metodi statici: @staticmethod

Un metodo statico non riceve automaticamente né self né cls.

class Matematica:

    @staticmethod
    def somma(a, b):
        return a + b

Uso:

print(Matematica.somma(3, 5))

La funzione non dipende dallo stato di una specifica istanza né dalla classe.

È quindi una normale funzione collocata nella classe perché è concettualmente collegata a quella classe.

Confronto

Tipo Primo parametro automatico Usa lo stato dell'istanza?


metodo di istanza self sì metodo di classe cls no, usa la classe metodo statico nessuno no


5.10 Proprietà con @property

Una property permette di esporre un valore attraverso una sintassi simile a quella di un attributo, mantenendo però la possibilità di eseguire logica.

Esempio:

class Studente:

    def __init__(self, nome, eta):
        self.nome = nome
        self.eta = eta

Se vogliamo impedire età negative:

class Studente:

    def __init__(self, nome, eta):
        self.nome = nome
        self.eta = eta

    @property
    def eta(self):
        return self._eta

    @eta.setter
    def eta(self, valore):
        if valore < 0:
            raise ValueError("L'età non può essere negativa")
        self._eta = valore

Uso:

s = Studente("Marco", 18)

print(s.eta)

s.eta = 20

La sintassi rimane semplice:

s.eta
s.eta = 20

ma dietro le quinte viene eseguita la logica della property.

Perché usare _eta?

Nel setter non possiamo fare:

self.eta = valore

perché richiameremmo nuovamente il setter, creando una ricorsione infinita.

Utilizziamo quindi un attributo interno:

self._eta = valore

Property di sola lettura

Se definiamo solo il getter:

class Persona:

    def __init__(self, nome):
        self._nome = nome

    @property
    def nome(self):
        return self._nome

possiamo leggere:

print(p.nome)

ma non assegnare:

p.nome = "Marco"

Property calcolata

Una property non deve necessariamente rappresentare un dato memorizzato.

class Rettangolo:

    def __init__(self, base, altezza):
        self.base = base
        self.altezza = altezza

    @property
    def area(self):
        return self.base * self.altezza

Uso:

r = Rettangolo(5, 3)
print(r.area)

area viene calcolata quando viene richiesta.

Esercizio 5.6

Crea una classe Prodotto con una property prezzo che impedisca valori minori di zero.

Aggiungi una property prezzo_scontato che calcoli il prezzo applicando una percentuale di sconto memorizzata nell'oggetto.


5.11 Incapsulamento e membri "privati"

Python non implementa il concetto di private nello stesso modo di Java o C++.

Esistono però due convenzioni importanti.

Un underscore: _nome

class Conto:

    def __init__(self, saldo):
        self._saldo = saldo

L'underscore iniziale comunica:

Questo membro è destinato all'uso interno della classe o del modulo.

Non è un vero blocco all'accesso.

Due underscore: __nome

class Conto:

    def __init__(self, saldo):
        self.__saldo = saldo

Python applica il name mangling.

Il nome viene internamente trasformato in qualcosa di simile a:

_Conto__saldo

Quindi:

conto.__saldo

produce un AttributeError, mentre tecnicamente l'attributo può ancora essere raggiunto tramite il nome mangled.

Questo meccanismo serve soprattutto a evitare collisioni accidentali con nomi definiti da sottoclassi, non a creare una barriera di sicurezza.

Regola pratica

Usa:

  • nome → membro pubblico;
  • _nome → dettaglio interno/convenzione;
  • __nome → name mangling, quando serve evitare collisioni o accessi accidentali.

Per controllare realmente lettura e modifica, spesso è preferibile usare @property.

Esercizio 5.7

Crea una classe ContoBancario con _saldo.

Non permettere di impostare direttamente un saldo negativo: usa un metodo preleva() che controlli la disponibilità.


5.12 Metodi speciali: i dunder methods

I metodi speciali, spesso chiamati dunder methods ("double underscore"), permettono di definire come un oggetto interagisce con il linguaggio Python.

Alcuni esempi:

  • __init__ → inizializzazione;
  • __str__ → rappresentazione leggibile;
  • __repr__ → rappresentazione tecnica;
  • __len__ → comportamento di len();
  • __eq__ → ==;
  • __lt__ → <;
  • __add__ → +;
  • __getitem__ → obj[indice];
  • __iter__ → iterazione.

Questi metodi non vengono normalmente chiamati direttamente. È il linguaggio a utilizzarli quando eseguiamo determinate operazioni.


5.13 __str__ e __repr__

Senza __str__:

class Persona:

    def __init__(self, nome):
        self.nome = nome

potremmo ottenere una rappresentazione poco utile:

<__main__.Persona object at 0x...>

Possiamo definire:

class Persona:

    def __init__(self, nome):
        self.nome = nome

    def __str__(self):
        return f"Persona: {self.nome}"

Ora:

p = Persona("Marco")
print(p)

produce:

Persona: Marco

__repr__

__repr__ dovrebbe fornire una rappresentazione utile soprattutto per debug e sviluppo.

class Persona:

    def __init__(self, nome):
        self.nome = nome

    def __repr__(self):
        return f"Persona(nome={self.nome!r})"

Uso:

p = Persona("Marco")
print(repr(p))

Differenza

In modo semplificato:

  • __str__ → rappresentazione orientata all'utente;
  • __repr__ → rappresentazione orientata allo sviluppatore.

Una buona __repr__ può essere molto utile durante il debug.

Esercizio 5.8

Aggiungi __str__ e __repr__ alla classe Prodotto.

Fai in modo che:

print(prodotto)

produca una descrizione leggibile.


5.14 Confrontare oggetti: == e is

Questi due operatori hanno significati diversi.

is

Controlla l'identità:

a is b

significa, in sostanza:

a e b sono la stessa istanza?

==

Controlla l'uguaglianza:

a == b

Il significato concreto dipende dal tipo dell'oggetto e può essere personalizzato tramite __eq__.

Esempio:

class Studente:

    def __init__(self, nome):
        self.nome = nome

s1 = Studente("Marco")
s2 = Studente("Marco")

print(s1 is s2)  # False

Le istanze sono diverse.

Senza ridefinire __eq__, due istanze di una classe normale non vengono considerate uguali solo perché hanno gli stessi attributi.

Implementare __eq__

class Studente:

    def __init__(self, nome):
        self.nome = nome

    def __eq__(self, other):
        if not isinstance(other, Studente):
            return NotImplemented

        return self.nome == other.nome

Ora:

s1 = Studente("Marco")
s2 = Studente("Marco")

print(s1 == s2)  # True

Restituiamo NotImplemented quando il confronto con l'altro tipo non è supportato: permette a Python di gestire correttamente il confronto.

Regola fondamentale: is è per l'identità; == è per l'uguaglianza.

Esercizio 5.9

Implementa __eq__ nella classe Prodotto, considerando uguali due prodotti con lo stesso codice prodotto.


5.15 Operatori di confronto

Possiamo definire anche confronti ordinati.

I principali metodi sono:

__lt__   <
__le__   <=
__eq__   ==
__ne__   !=
__gt__   >
__ge__   >=

Esempio:

class Studente:

    def __init__(self, nome, voto):
        self.nome = nome
        self.voto = voto

    def __lt__(self, other):
        if not isinstance(other, Studente):
            return NotImplemented

        return self.voto < other.voto

Ora:

a = Studente("Anna", 7)
b = Studente("Marco", 9)

print(a < b)  # True

Per confronti più articolati può essere utile il modulo functools, oppure definire esplicitamente gli operatori necessari.


5.16 Operatori aritmetici e operator overloading

Possiamo decidere cosa significa + per una nostra classe.

class Money:

    def __init__(self, euro):
        self.euro = euro

    def __add__(self, other):
        if not isinstance(other, Money):
            return NotImplemented

        return Money(self.euro + other.euro)

    def __str__(self):
        return f"{self.euro:.2f} €"

Uso:

m1 = Money(10)
m2 = Money(25)

m3 = m1 + m2

print(m3)

Output:

35.00 €

L'espressione:

m1 + m2

richiede a Python di utilizzare il comportamento definito da __add__.

Altri metodi

__sub__       -
__mul__       *
__truediv__   /
__floordiv__  //
__mod__       %
__pow__       **

Possiamo anche supportare l'operazione inversa.

class Money:

    def __init__(self, euro):
        self.euro = euro

    def __mul__(self, n):
        return Money(self.euro * n)

    def __rmul__(self, n):
        return self.__mul__(n)

Così funzionano entrambe:

m = Money(10)

print(m * 3)
print(3 * m)

Attenzione

L'operator overloading deve mantenere un significato intuitivo.

Creare:

prodotto + studente

solo perché Python lo permette non è una buona progettazione se l'operazione non ha un significato naturale.


5.17 Creare contenitori personalizzati

Una classe può comportarsi come una collezione.

Supponiamo di voler rappresentare una classe scolastica:

class Classe:

    def __init__(self):
        self.studenti = []

    def aggiungi(self, studente):
        self.studenti.append(studente)

Possiamo aggiungere il supporto a len():

class Classe:

    def __init__(self):
        self.studenti = []

    def aggiungi(self, studente):
        self.studenti.append(studente)

    def __len__(self):
        return len(self.studenti)

Ora:

c = Classe()

c.aggiungi("Anna")
c.aggiungi("Marco")

print(len(c))

__getitem__

Possiamo supportare:

c[0]

definendo:

def __getitem__(self, indice):
    return self.studenti[indice]

__iter__

Per rendere naturale:

for studente in c:
    ...

possiamo scrivere:

def __iter__(self):
    return iter(self.studenti)

__contains__

Per supportare:

"Anna" in c

possiamo definire:

def __contains__(self, studente):
    return studente in self.studenti

Una versione completa:

class Classe:

    def __init__(self):
        self.studenti = []

    def aggiungi(self, studente):
        self.studenti.append(studente)

    def __len__(self):
        return len(self.studenti)

    def __getitem__(self, indice):
        return self.studenti[indice]

    def __iter__(self):
        return iter(self.studenti)

    def __contains__(self, studente):
        return studente in self.studenti

Uso:

c = Classe()

c.aggiungi("Anna")
c.aggiungi("Marco")

print(len(c))
print(c[0])
print("Marco" in c)

for studente in c:
    print(studente)

Esercizio 5.10

Crea una classe Playlist che contenga una lista di brani e supporti:

  • len(playlist);
  • playlist[0];
  • for brano in playlist;
  • "titolo" in playlist.

5.18 Ereditarietà

L'ereditarietà permette di creare una nuova classe a partire da una classe esistente.

class Persona:

    def __init__(self, nome):
        self.nome = nome

    def saluta(self):
        return f"Ciao, sono {self.nome}"


class Studente(Persona):
    pass

Studente eredita da Persona.

s = Studente("Marco")

print(s.nome)
print(s.saluta())

Terminologia

  • Persona → superclasse / classe base;
  • Studente → sottoclasse / classe derivata.

Relazione IS-A

L'ereditarietà ha senso quando esiste una relazione logica del tipo:

Studente è una Persona.

Ha meno senso quando la relazione è semplicemente:

Auto possiede un Motore.

In questo secondo caso è generalmente più naturale la composizione.

Esercizio 5.11

Crea:

Veicolo
 ├── Auto
 └── Moto

con un metodo muovi() nella classe base e un comportamento specifico nelle sottoclassi.


5.19 super()

Se una sottoclasse definisce un proprio __init__, il costruttore della classe base non viene eseguito automaticamente.

Esempio:

class Persona:

    def __init__(self, nome):
        self.nome = nome


class Studente(Persona):

    def __init__(self, nome, matricola):
        self.matricola = matricola

In questo caso nome non viene inizializzato dalla classe base.

Possiamo usare super():

class Studente(Persona):

    def __init__(self, nome, matricola):
        super().__init__(nome)
        self.matricola = matricola

Ora:

s = Studente("Marco", 12345)

print(s.nome)
print(s.matricola)

Perché usare super()?

Per:

  • riutilizzare il codice della classe base;
  • evitare duplicazioni;
  • mantenere la logica comune nella classe appropriata;
  • collaborare correttamente con l'ereditarietà multipla.

5.20 Method overriding

Una sottoclasse può ridefinire un metodo ereditato.

class Persona:

    def saluta(self):
        return "Ciao, sono una persona"


class Studente(Persona):

    def saluta(self):
        return "Ciao, sono uno studente"

Ora:

p = Persona()
s = Studente()

print(p.saluta())
print(s.saluta())

Il metodo utilizzato dipende dal tipo reale dell'oggetto.

Estendere invece di sostituire

Possiamo usare super():

class Studente(Persona):

    def saluta(self):
        messaggio = super().saluta()
        return messaggio + " e studio informatica"

Questo permette alla sottoclasse di aggiungere comportamento senza duplicare quello della classe base.

Esercizio 5.12

Crea:

Dipendente
 ├── Docente
 └── Tecnico

Ogni classe deve implementare descrizione() in modo diverso.

Usa super() nei costruttori per inizializzare gli attributi comuni.


5.21 Un esempio realistico di ereditarietà

Un esempio coerente può essere:

Persona
   │
   └── Dipendente
         ├── Docente
         └── Tecnico

Implementazione:

class Persona:

    def __init__(self, nome):
        self.nome = nome

    def descrizione(self):
        return f"Persona: {self.nome}"


class Dipendente(Persona):

    def __init__(self, nome, stipendio):
        super().__init__(nome)
        self.stipendio = stipendio

    def descrizione(self):
        return f"Dipendente: {self.nome}, stipendio={self.stipendio}"


class Docente(Dipendente):

    def __init__(self, nome, stipendio, materia):
        super().__init__(nome, stipendio)
        self.materia = materia

    def descrizione(self):
        return (
            f"Docente: {self.nome}, "
            f"materia={self.materia}, "
            f"stipendio={self.stipendio}"
        )


class Tecnico(Dipendente):

    def __init__(self, nome, stipendio, reparto):
        super().__init__(nome, stipendio)
        self.reparto = reparto

    def descrizione(self):
        return (
            f"Tecnico: {self.nome}, "
            f"reparto={self.reparto}, "
            f"stipendio={self.stipendio}"
        )

Possiamo lavorare con oggetti di tipo diverso:

persone = [
    Persona("Anna"),
    Docente("Marco", 1800, "Informatica"),
    Tecnico("Luca", 1600, "Laboratorio")
]

for persona in persone:
    print(persona.descrizione())

Questo è già un esempio di polimorfismo.


5.22 La classe object

In Python tutte le classi derivano, direttamente o indirettamente, da object.

Quindi:

class Persona:
    pass

è concettualmente equivalente a:

class Persona(object):
    pass

Possiamo verificare:

print(Persona.__bases__)

e ottenere:

(<class 'object'>,)

Questo spiega anche perché ogni oggetto Python dispone di numerosi comportamenti ereditati.


5.23 Ereditarietà multipla

Python permette a una classe di ereditare da più classi:

class Volante:

    def vola(self):
        return "Sto volando"


class Nuotatore:

    def nuota(self):
        return "Sto nuotando"


class Anatra(Volante, Nuotatore):
    pass

Ora:

a = Anatra()

print(a.vola())
print(a.nuota())

Conflitto tra metodi

Se entrambe le classi definiscono lo stesso metodo:

class A:

    def info(self):
        return "A"


class B:

    def info(self):
        return "B"


class C(A, B):
    pass

allora:

print(C().info())

restituisce:

A

perché A viene prima di B nell'ordine di risoluzione.


5.24 MRO --- Method Resolution Order

Python utilizza un ordine preciso per cercare attributi e metodi: la MRO.

Possiamo visualizzarlo:

print(C.mro())

oppure:

print(C.__mro__)

Per il caso precedente otteniamo concettualmente:

C → A → B → object

L'MRO diventa particolarmente importante con gerarchie complesse e con l'ereditarietà multipla.

super() e MRO

In una gerarchia con ereditarietà multipla, super() non significa semplicemente "chiama il padre".

Significa, più precisamente:

prosegui nella MRO a partire dalla posizione corrente.

Questo è fondamentale per costruire gerarchie cooperative.


5.25 Il Diamond Problem

Un caso classico è:

       A
      / \
     B   C
      \ /
       D

D eredita sia da B sia da C, che a loro volta derivano da A.

Python utilizza una MRO basata sull'algoritmo C3 linearization per stabilire un ordine coerente di ricerca.

Nelle gerarchie cooperative è importante usare super() correttamente, evitando di chiamare direttamente una specifica classe base quando non è necessario.

Esempio cooperativo

class Base:

    def saluta(self):
        print("Base")


class A(Base):

    def saluta(self):
        print("A")
        super().saluta()


class B(Base):

    def saluta(self):
        print("B")
        super().saluta()


class C(A, B):
    pass

Uso:

C().saluta()

produce:

A
B
Base

perché super() segue la MRO.

Regola pratica

L'ereditarietà multipla è potente, ma va usata con criterio. In molti casi, soprattutto in applicazioni aziendali, la composizione è più semplice da comprendere e mantenere.


5.26 Classi astratte

Una Abstract Base Class (ABC) definisce un contratto per le classi derivate.

Utilizziamo il modulo abc:

from abc import ABC, abstractmethod

Esempio:

class Animale(ABC):

    @abstractmethod
    def verso(self):
        pass

Non possiamo istanziare direttamente Animale:

animale = Animale()

Python genera un TypeError.

Classe concreta

class Cane(Animale):

    def verso(self):
        return "Bau!"

Ora:

cane = Cane()
print(cane.verso())

Classe incompleta

class Pesce(Animale):
    pass

Anche Pesce rimane astratta e non può essere istanziata finché non implementa verso().

Metodi concreti e astratti

Una ABC può contenere entrambi:

from abc import ABC, abstractmethod

class Figura(ABC):

    @abstractmethod
    def area(self):
        pass

    def descrizione(self):
        return "Sono una figura geometrica"

Le sottoclassi devono implementare area(), ma possono ereditare descrizione().

Esercizio 5.13

Crea una ABC Pagamento con un metodo astratto:

paga(importo)

Crea:

  • PagamentoCarta;
  • PagamentoPayPal.

Ogni sottoclasse deve implementare il proprio comportamento.


5.27 Polimorfismo

Il polimorfismo significa, in termini pratici:

stessa interfaccia, comportamenti diversi.

Esempio:

class Cane:

    def verso(self):
        return "Bau!"


class Gatto:

    def verso(self):
        return "Miao!"

Possiamo scrivere:

animali = [Cane(), Gatto()]

for animale in animali:
    print(animale.verso())

Il codice chiamante non deve sapere se l'oggetto è un cane o un gatto.

Polimorfismo con ereditarietà

class Figura(ABC):

    @abstractmethod
    def area(self):
        pass


class Rettangolo(Figura):

    def __init__(self, base, altezza):
        self.base = base
        self.altezza = altezza

    def area(self):
        return self.base * self.altezza


class Cerchio(Figura):

    def __init__(self, raggio):
        self.raggio = raggio

    def area(self):
        return 3.141592653589793 * self.raggio ** 2

Ora:

figure = [
    Rettangolo(10, 5),
    Cerchio(3)
]

for figura in figure:
    print(figura.area())

La funzione che utilizza area() non deve distinguere le classi concrete.

Questo riduce la necessità di costruire grandi catene di if e elif.


5.28 Duck typing

Python porta il polimorfismo ancora più avanti con il principio del duck typing.

La metafora è:

"Se cammina come un'anatra e starnazza come un'anatra, allora è un'anatra."

In pratica non importa necessariamente quale sia la classe dell'oggetto. Importa che supporti l'operazione richiesta.

class Cane:

    def verso(self):
        return "Bau!"


class Gatto:

    def verso(self):
        return "Miao!"


class Persona:

    def verso(self):
        return "Ciao!"

Possiamo scrivere:

def fai_parlare(oggetto):
    print(oggetto.verso())

e usare:

fai_parlare(Cane())
fai_parlare(Gatto())
fai_parlare(Persona())

La funzione non controlla la classe.

Duck typing senza ereditarietà

Cane, Gatto e Persona non devono avere una superclasse comune.

È sufficiente che offrano il comportamento necessario.

Errore a runtime

Se passiamo:

class Pesce:
    pass

e poi:

fai_parlare(Pesce())

otteniamo un AttributeError, perché l'oggetto non possiede verso.

Questa è una caratteristica importante del duck typing: molti vincoli vengono verificati quando il codice viene eseguito.


5.29 Ereditarietà o composizione?

Uno degli aspetti più importanti della progettazione OOP non è sapere come usare l'ereditarietà, ma sapere quando non usarla.

Ereditarietà

class Auto(Veicolo):
    pass

Esprime:

Auto IS-A Veicolo.

Composizione

class Motore:

    def avvia(self):
        print("Motore avviato")


class Auto:

    def __init__(self):
        self.motore = Motore()

Esprime:

Auto HAS-A Motore.

Perché la composizione è spesso preferibile?

La composizione:

  • riduce gerarchie profonde;
  • rende più facile sostituire componenti;
  • riduce l'accoppiamento;
  • facilita il testing;
  • permette di combinare comportamenti senza creare molte sottoclassi.

Una regola utile è:

Preferire la composizione all'ereditarietà quando non esiste una relazione IS-A realmente significativa.

Non significa che l'ereditarietà sia "sbagliata": significa che deve essere una scelta progettuale consapevole.

Esempio

Invece di:

AutoConMotoreBenzina
AutoConMotoreDiesel
AutoConMotoreElettrico

possiamo avere:

Auto → Motore
             ├── MotoreBenzina
             ├── MotoreDiesel
             └── MotoreElettrico

e scegliere il motore al momento della costruzione.


5.30 Le dataclass

Molte classi servono principalmente a rappresentare dati.

Esempio tradizionale:

class Punto:

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Punto(x={self.x}, y={self.y})"

    def __eq__(self, other):
        if not isinstance(other, Punto):
            return NotImplemented
        return self.x == other.x and self.y == other.y

Python offre dataclass per ridurre questo boilerplate.

from dataclasses import dataclass

@dataclass
class Punto:
    x: int
    y: int

Ora:

p = Punto(3, 4)

print(p)

produce una rappresentazione utile, e:

print(Punto(3, 4) == Punto(3, 4))

restituisce True.

Perché usare dataclass?

È particolarmente utile per:

  • DTO;
  • record;
  • modelli dati;
  • configurazioni;
  • oggetti con molti campi.

5.31 Valori predefiniti nelle dataclass

Possiamo definire:

from dataclasses import dataclass

@dataclass
class Studente:
    nome: str
    eta: int = 18

Uso:

s = Studente("Anna")

Attenzione ai mutabili

Questo è sbagliato:

@dataclass
class Classe:
    studenti: list = []

Per un campo mutabile bisogna utilizzare default_factory:

from dataclasses import dataclass, field

@dataclass
class Classe:
    studenti: list = field(default_factory=list)

Così ogni istanza riceve una lista distinta.

Lo stesso principio vale per:

field(default_factory=dict)
field(default_factory=set)

Esercizio 5.14

Crea una dataclass Prodotto con:

  • nome: str;
  • prezzo: float;
  • categoria: str = "generale".

Crea almeno tre prodotti e stampali.


5.32 field() nelle dataclass

field() permette di controllare il comportamento dei campi.

init=False

Un campo può essere escluso dal costruttore generato:

from dataclasses import dataclass, field

@dataclass
class Persona:
    nome: str
    cognome: str
    codice: str = field(init=False)

    def __post_init__(self):
        self.codice = self.nome[0] + self.cognome[0]

Uso:

p = Persona("Mario", "Rossi")

print(p.codice)

repr=False

Possiamo escludere un campo dalla rappresentazione:

@dataclass
class Utente:
    username: str
    password: str = field(repr=False)

In questo modo la password non compare nel repr generato.

Questo non è un meccanismo di sicurezza: impedisce solo che il campo venga incluso nella rappresentazione automatica.

compare=False

Possiamo escludere un campo dai confronti:

@dataclass
class Prodotto:
    nome: str
    prezzo: float
    id_interno: int = field(compare=False)

Esercizio 5.15

Crea una dataclass Utente con:

  • username;
  • email;
  • password esclusa dal repr.

Verifica la rappresentazione prodotta da Python.


5.33 __post_init__()

Una dataclass può definire __post_init__() per eseguire logica subito dopo l'__init__ generato automaticamente.

@dataclass
class Studente:
    nome: str
    eta: int

    def __post_init__(self):
        if self.eta < 0:
            raise ValueError("Età non valida")

Il controllo viene eseguito automaticamente:

s = Studente("Anna", 18)

mentre:

s = Studente("Anna", -2)

genera un ValueError.


5.34 Property e metodi nelle dataclass

Una dataclass non è una classe "limitata": può avere metodi e property normalmente.

from dataclasses import dataclass

@dataclass
class Rettangolo:
    base: float
    altezza: float

    @property
    def area(self):
        return self.base * self.altezza

Uso:

r = Rettangolo(5, 2)
print(r.area)

Possiamo anche definire metodi:

@dataclass
class Conto:
    intestatario: str
    saldo: float = 0.0

    def deposita(self, importo: float):
        self.saldo += importo

    def preleva(self, importo: float):
        if importo > self.saldo:
            raise ValueError("Saldo insufficiente")

        self.saldo -= importo

Quindi dataclass significa semplicemente che Python genera automaticamente molto codice ripetitivo: non significa rinunciare alla normale programmazione a oggetti.


5.35 Ordinamento nelle dataclass

Con:

@dataclass(order=True)
class Studente:
    matricola: int
    nome: str

Python genera anche i metodi necessari per gli ordinamenti.

a = Studente(2, "Luca")
b = Studente(1, "Anna")

print(b < a)

L'ordinamento tiene conto dei campi nell'ordine in cui sono dichiarati.

Per progettazioni più specifiche può essere preferibile usare sorted(..., key=...), così da rendere esplicito il criterio.


5.36 frozen=True

Possiamo creare una dataclass immutabile:

@dataclass(frozen=True)
class Coordinate:
    x: float
    y: float

Dopo la creazione:

c = Coordinate(10, 20)

non possiamo normalmente fare:

c.x = 50

perché l'istanza è frozen.

Le dataclass frozen sono utili quando vogliamo rappresentare valori che non devono essere modificati dopo la creazione.


5.37 Ereditarietà con le dataclass

Le dataclass possono partecipare a gerarchie di ereditarietà.

from dataclasses import dataclass

@dataclass
class Persona:
    nome: str


@dataclass
class Studente(Persona):
    matricola: int

Uso:

s = Studente("Marco", 12345)

print(s)

Python genera automaticamente il costruttore considerando i campi ereditati e quelli della sottoclasse, nel rispetto delle regole delle dataclass.


5.38 Funzioni come parte del modello

Una buona classe non dovrebbe essere solo un contenitore di variabili.

Confrontiamo:

conto.saldo = conto.saldo + 100

con:

conto.deposita(100)

La seconda soluzione esprime meglio l'intenzione.

Il metodo:

def deposita(self, importo):
    ...

può inoltre controllare le regole dell'oggetto.

Questo porta a un principio importante:

L'oggetto dovrebbe proteggere e gestire il proprio stato.

Non significa nascondere tutto, ma concentrare nella classe le regole che riguardano i suoi dati.


5.39 Progettare classi con responsabilità chiare

Una classe dovrebbe avere una responsabilità comprensibile.

Una classe come:

class SistemaGestioneNegozio:
    ...

che contemporaneamente:

  • legge file;
  • parla con il database;
  • invia email;
  • calcola prezzi;
  • stampa report;
  • gestisce utenti;

rischia di diventare difficile da testare e mantenere.

Meglio separare le responsabilità:

Prodotto
Ordine
Cliente
Catalogo
ServizioOrdini
Repository
Report

Le classi possono poi collaborare.

Questo concetto prepara il terreno alla progettazione di applicazioni più grandi e ai pattern architetturali che verranno affrontati più avanti.


5.40 Esempio completo: un piccolo negozio

Costruiamo un modello semplice.

from dataclasses import dataclass


@dataclass
class Prodotto:
    codice: str
    nome: str
    prezzo: float


class Carrello:

    def __init__(self):
        self._prodotti = []

    def aggiungi(self, prodotto):
        self._prodotti.append(prodotto)

    @property
    def totale(self):
        return sum(prodotto.prezzo for prodotto in self._prodotti)

    def __len__(self):
        return len(self._prodotti)

    def __iter__(self):
        return iter(self._prodotti)

    def __str__(self):
        return f"Carrello con {len(self)} prodotti: {self.totale:.2f} €"

Uso:

mouse = Prodotto("M01", "Mouse", 25.90)
tastiera = Prodotto("T01", "Tastiera", 49.90)

carrello = Carrello()

carrello.aggiungi(mouse)
carrello.aggiungi(tastiera)

print(carrello)
print(carrello.totale)

for prodotto in carrello:
    print(prodotto.nome)

Qui abbiamo combinato:

  • classe;
  • dataclass;
  • composizione;
  • property;
  • __len__;
  • __iter__;
  • __str__;
  • generator expression.

5.41 Esempio completo: polimorfismo nei pagamenti

Possiamo definire una classe astratta:

from abc import ABC, abstractmethod


class MetodoPagamento(ABC):

    @abstractmethod
    def paga(self, importo):
        pass

Implementazioni:

class Carta(MetodoPagamento):

    def paga(self, importo):
        return f"Pagamento con carta: {importo:.2f} €"


class PayPal(MetodoPagamento):

    def paga(self, importo):
        return f"Pagamento con PayPal: {importo:.2f} €"

Una funzione generica:

def completa_pagamento(metodo, importo):
    print(metodo.paga(importo))

Uso:

completa_pagamento(Carta(), 100)
completa_pagamento(PayPal(), 100)

La funzione non deve conoscere la classe concreta.

Questo è un esempio di:

  • astrazione;
  • polimorfismo;
  • programmazione orientata alle interfacce;
  • separazione delle responsabilità.

5.42 Esempio completo: composizione

Supponiamo di voler rappresentare un computer.

Un computer ha un processore, una memoria e un disco.

class Processore:

    def avvia(self):
        print("Processore avviato")


class Memoria:

    def carica(self):
        print("Memoria inizializzata")


class Computer:

    def __init__(self):
        self.processore = Processore()
        self.memoria = Memoria()

    def avvia(self):
        self.processore.avvia()
        self.memoria.carica()
        print("Computer avviato")

Uso:

computer = Computer()
computer.avvia()

Non abbiamo creato:

class ComputerConProcessore:
    ...

La relazione è di composizione.


5.43 Errori comuni nelle classi

Errore 1 --- dimenticare self

Sbagliato:

class Persona:

    def saluta():
        print("Ciao")

Corretto:

class Persona:

    def saluta(self):
        print("Ciao")

Errore 2 --- confondere parametro e attributo

def __init__(self, nome):
    nome = nome

Questa istruzione non salva il nome nell'oggetto.

Corretto:

def __init__(self, nome):
    self.nome = nome

Errore 3 --- dimenticare super()

Se una sottoclasse ridefinisce __init__ ma deve inizializzare anche la parte ereditata:

super().__init__(...)

Errore 4 --- usare una lista mutabile come attributo di classe

Da evitare:

class Classe:
    studenti = []

se la lista dovrebbe essere indipendente per ogni istanza.

Meglio:

class Classe:

    def __init__(self):
        self.studenti = []

oppure, con una dataclass:

@dataclass
class Classe:
    studenti: list = field(default_factory=list)

Errore 5 --- usare l'ereditarietà solo per riutilizzare codice

La domanda non dovrebbe essere:

"Posso evitare di riscrivere questo codice?"

ma:

"Esiste davvero una relazione concettuale tra queste classi?"

Se la risposta è no, valuta la composizione.

Errore 6 --- abusare delle property

Una property è utile quando c'è logica da applicare o quando vogliamo un'interfaccia di accesso controllata.

Se non c'è alcun comportamento, un semplice attributo può essere sufficiente.


5.44 Mini-verifica

Domande

  1. Che differenza c'è tra classe e istanza?
  2. A cosa serve self?
  3. Qual è il ruolo di __init__?
  4. Che differenza c'è tra attributo di classe e attributo di istanza?
  5. Che cosa rappresenta cls?
  6. Quando si usa @staticmethod?
  7. A cosa serve @property?
  8. Qual è la differenza tra is e ==?
  9. Che cosa sono i dunder methods?
  10. A cosa serve super()?
  11. Che cosa significa overriding?
  12. Che cosa significa polimorfismo?
  13. Che cos'è il duck typing?
  14. Che cos'è l'MRO?
  15. Quando è utile una ABC?
  16. Qual è la differenza tra ereditarietà e composizione?
  17. A cosa serve @dataclass?
  18. Perché si usa default_factory con liste e dizionari nelle dataclass?

Risposte sintetiche

  1. La classe definisce un tipo; l'istanza è un oggetto concreto di quel tipo.
  2. Rappresenta l'istanza corrente nei metodi di istanza.
  3. Inizializza lo stato dell'istanza.
  4. Il primo è associato alla classe; il secondo alla singola istanza.
  5. Rappresenta la classe corrente nei class methods.
  6. Per funzioni collocate nella classe che non richiedono né istanza né classe.
  7. Per controllare l'accesso a un valore mantenendo una sintassi da attributo.
  8. is confronta l'identità; == l'uguaglianza.
  9. Metodi speciali usati implicitamente da Python.
  10. Per proseguire nella gerarchia/MRO e riutilizzare comportamenti ereditati.
  11. Ridefinire un metodo ereditato.
  12. Usare una stessa interfaccia con implementazioni diverse.
  13. Basarsi sul comportamento richiesto invece che sul tipo concreto.
  14. Method Resolution Order, l'ordine con cui Python cerca attributi e metodi.
  15. Per definire un contratto comune per classi concrete.
  16. L'ereditarietà rappresenta soprattutto IS-A; la composizione HAS-A.
  17. Per generare automaticamente molto codice tipico delle classi dati.
  18. Per creare un nuovo oggetto mutabile per ogni istanza.

5.45 Esercizi di riepilogo

Esercizio 1 --- Studente

Crea una classe Studente con:

  • nome;
  • cognome;
  • matricola;
  • voto medio.

Aggiungi:

  • __str__;
  • metodo promosso() che restituisca True se la media è almeno 6;
  • property voto_medio con controllo tra 0 e 10.

Esercizio 2 --- Conto bancario

Crea:

class ContoBancario:
    ...

con:

  • intestatario;
  • saldo;
  • deposita(importo);
  • preleva(importo);
  • property saldo;
  • __str__.

Il prelievo non deve permettere di superare il saldo disponibile.


Esercizio 3 --- Gerarchia dei dipendenti

Crea:

Persona
   └── Dipendente
         ├── Docente
         └── Tecnico

Ogni classe deve:

  • avere il proprio costruttore;
  • utilizzare super();
  • implementare descrizione().

Poi crea una lista con oggetti di tipi diversi e stampa le descrizioni.


Esercizio 4 --- Figure geometriche

Definisci una ABC:

class Figura(ABC):
    @abstractmethod
    def area(self):
        pass

Implementa:

  • Rettangolo;
  • Quadrato;
  • Cerchio.

Inserisci le figure in una lista e stampa le aree utilizzando polimorfismo.


Esercizio 5 --- Playlist

Crea una classe Playlist che:

  • contenga una lista di brani;
  • permetta di aggiungere brani;
  • supporti len();
  • supporti l'indicizzazione;
  • sia iterabile;
  • supporti in;
  • abbia una rappresentazione leggibile con __str__.

5.46 Sfida finale --- Sistema di gestione di una biblioteca

Realizza un piccolo sistema OOP per gestire una biblioteca.

Requisiti

1. Libro

Il libro deve avere:

  • ISBN;
  • titolo;
  • autore;
  • anno di pubblicazione;
  • disponibilità.

Implementa:

  • __str__;
  • __repr__;
  • __eq__, considerando uguali due libri con lo stesso ISBN.

2. Biblioteca

La biblioteca deve contenere una collezione di libri.

Deve permettere:

biblioteca.aggiungi_libro(libro)
biblioteca.rimuovi_libro(isbn)
biblioteca.cerca_titolo("Python")
biblioteca.presta(isbn)
biblioteca.restituisci(isbn)

Inoltre deve supportare:

len(biblioteca)

e:

for libro in biblioteca:
    ...

3. Validazione

Non deve essere possibile:

  • inserire due libri con lo stesso ISBN;
  • prestare un libro già prestato;
  • restituire un libro già disponibile;
  • rimuovere un libro inesistente.

Gli errori devono essere segnalati tramite eccezioni appropriate.

Nel prossimo capitolo sulle eccezioni approfondiremo proprio come progettare e gestire questi errori in modo sistematico.


4. Ricerca

cerca_titolo() deve restituire tutti i libri il cui titolo contiene la stringa cercata, senza distinguere maiuscole e minuscole.

Esempio:

risultati = biblioteca.cerca_titolo("python")

5. Polimorfismo

Aggiungi una classe astratta:

class ElementoBiblioteca(ABC):
    @abstractmethod
    def descrizione(self):
        pass

Fai derivare Libro da ElementoBiblioteca.

Successivamente aggiungi una seconda tipologia, per esempio:

Rivista

anch'essa derivata da ElementoBiblioteca.

La biblioteca deve poter gestire entrambi i tipi.


5.47 Soluzione guidata della sfida

Una possibile soluzione è la seguente.

Classe astratta

from abc import ABC, abstractmethod


class ElementoBiblioteca(ABC):

    @abstractmethod
    def descrizione(self):
        pass

Libro

class Libro(ElementoBiblioteca):

    def __init__(self, isbn, titolo, autore, anno):
        self.isbn = isbn
        self.titolo = titolo
        self.autore = autore
        self.anno = anno
        self.disponibile = True

    def descrizione(self):
        stato = "disponibile" if self.disponibile else "in prestito"

        return (
            f"{self.titolo} - {self.autore} "
            f"({self.anno}) [{stato}]"
        )

    def __str__(self):
        return self.descrizione()

    def __repr__(self):
        return (
            f"Libro(isbn={self.isbn!r}, "
            f"titolo={self.titolo!r}, "
            f"autore={self.autore!r}, "
            f"anno={self.anno})"
        )

    def __eq__(self, other):
        if not isinstance(other, Libro):
            return NotImplemented

        return self.isbn == other.isbn

Rivista

class Rivista(ElementoBiblioteca):

    def __init__(self, codice, titolo, numero):
        self.codice = codice
        self.titolo = titolo
        self.numero = numero

    def descrizione(self):
        return f"Rivista: {self.titolo}, numero {self.numero}"

    def __str__(self):
        return self.descrizione()

Biblioteca

class Biblioteca:

    def __init__(self):
        self._elementi = []

    def aggiungi(self, elemento):
        self._elementi.append(elemento)

    def __len__(self):
        return len(self._elementi)

    def __iter__(self):
        return iter(self._elementi)

    def cerca_titolo(self, testo):
        testo = testo.lower()

        return [
            elemento
            for elemento in self._elementi
            if testo in elemento.titolo.lower()
        ]

Possiamo quindi fare:

biblioteca = Biblioteca()

biblioteca.aggiungi(
    Libro(
        "978-0000000001",
        "Python per tutti",
        "Mario Rossi",
        2026
    )
)

biblioteca.aggiungi(
    Rivista(
        "R001",
        "Python Magazine",
        12
    )
)

for elemento in biblioteca:
    print(elemento.descrizione())

Qui il ciclo:

for elemento in biblioteca:
    print(elemento.descrizione())

non ha bisogno di sapere se elemento è un Libro o una Rivista.

Abbiamo quindi ottenuto polimorfismo.


5.48 Come migliorare la soluzione

La prima soluzione funziona, ma può essere ulteriormente migliorata.

Possiamo:

  1. separare la gestione degli identificativi;
  2. usare eccezioni personalizzate;
  3. introdurre una property per la disponibilità;
  4. usare un dizionario per indicizzare gli elementi;
  5. separare il modello dalla logica della biblioteca;
  6. aggiungere type hints;
  7. utilizzare dataclass dove appropriato;
  8. introdurre test automatici.

Ad esempio, invece di:

self._elementi = []

potremmo utilizzare:

self._elementi = {}

indicizzando gli elementi tramite un identificativo.

Questo rende alcune ricerche più efficienti.

Nel capitolo successivo sulle eccezioni vedremo anche come trasformare controlli come:

if libro non disponibile:
    ...

in una gestione degli errori più pulita e strutturata.


5.49 Una classe non deve sapere tutto

Quando un programma cresce, è importante evitare classi gigantesche.

Per esempio, una biblioteca reale potrebbe avere:

Libro
Utente
Prestito
Catalogo
Biblioteca
RepositoryLibri
ServizioPrestiti

Ogni componente ha una responsabilità.

La classe Libro non dovrebbe sapere:

  • come viene salvata nel database;
  • come viene inviata un'email;
  • come viene stampato un report;
  • come funziona l'interfaccia grafica.

Dovrebbe conoscere soprattutto ciò che riguarda il concetto di libro.

Questo principio diventerà fondamentale quando inizieremo a costruire applicazioni più grandi.


5.50 OOP e progettazione del software

La vera competenza nella programmazione a oggetti non consiste nel creare molte classi.

Consiste nel decidere:

  • quali oggetti esistono;
  • quali responsabilità hanno;
  • quali dati possiedono;
  • quali operazioni espongono;
  • come collaborano;
  • quali dipendenze devono avere;
  • dove applicare ereditarietà;
  • dove preferire composizione;
  • quali comportamenti rendere polimorfici.

Un buon modello OOP tende a produrre codice:

  • leggibile;
  • estendibile;
  • testabile;
  • riutilizzabile;
  • meno accoppiato.

5.51 Riepilogo del capitolo

In questo capitolo abbiamo introdotto la programmazione orientata agli oggetti in Python.

Concetti fondamentali

Una classe definisce un tipo:

class Persona:
    ...

Un'istanza è un oggetto concreto:

p = Persona()

Gli attributi rappresentano lo stato:

self.nome

I metodi rappresentano comportamenti:

def saluta(self):
    ...

__init__() inizializza l'istanza.

self rappresenta l'istanza corrente.


Incapsulamento

Python utilizza convenzioni e strumenti come:

_nome
__nome
@property

per controllare l'accesso ai dati.


Metodi

Abbiamo visto:

def metodo(self):
    ...
@classmethod
def metodo(cls):
    ...
@staticmethod
def metodo():
    ...

Metodi speciali

Abbiamo imparato a personalizzare il comportamento degli oggetti tramite:

__str__
__repr__
__eq__
__lt__
__add__
__len__
__getitem__
__iter__

Ereditarietà

Una sottoclasse può estendere una superclasse:

class Studente(Persona):
    ...

super() permette di riutilizzare la logica ereditata.


Polimorfismo

Oggetti diversi possono offrire la stessa interfaccia:

for figura in figure:
    figura.area()

Duck typing

In Python spesso conta il comportamento dell'oggetto più che la sua classe:

oggetto.verso()

Classi astratte

Le ABC permettono di definire contratti:

from abc import ABC, abstractmethod

Composizione

Un oggetto può contenere altri oggetti:

self.motore = Motore()

e spesso questa soluzione è preferibile a gerarchie di ereditarietà troppo profonde.


Dataclass

Quando una classe rappresenta soprattutto dati:

@dataclass
class Punto:
    x: int
    y: int

può ridurre notevolmente il codice ripetitivo.


5.52 Mappa concettuale finale

                    PROGRAMMAZIONE OOP
                           │
          ┌────────────────┼────────────────┐
          │                │                │
       CLASSE           ISTANZA          METODI
          │                │                │
     ┌────┴────┐       stato proprio    ┌───┼────┐
     │         │                       │   │    │
 attributi   metodi                 self cls static
     │
 ┌───┴───────────────┐
 │                   │
istanza             classe
 │                   │
self.nome          Classe.nome
 │
 ├── property
 ├── incapsulamento
 └── dunder methods
         │
         ▼
   __str__, __eq__,
   __len__, __add__...
         │
         ▼
    EREDITARIETÀ
         │
     ┌───┴────┐
     │        │
   override  super()
     │
     ▼
 POLIMORFISMO
     │
 ┌───┴─────────┐
 │             │
ABC       Duck Typing
 │
 ▼
COMPOSIZIONE
 │
 ▼
DATACLASS

5.53 Verso il capitolo successivo

Le classi ci permettono di modellare oggetti e regole del dominio. Ma gli oggetti possono anche trovarsi in situazioni non valide.

Per esempio:

conto.preleva(1000)

quando il saldo è solo 100.

Oppure:

biblioteca.presta("ISBN inesistente")

oppure:

int("abc")

In tutti questi casi il programma deve gestire un errore.

Python mette a disposizione un sistema completo per farlo:

try:
    ...
except:
    ...
else:
    ...
finally:
    ...

Nel prossimo capitolo affronteremo quindi le eccezioni, imparando a:

  • distinguere errori di sintassi ed eccezioni;
  • usare try e except;
  • gestire tipi diversi di eccezione;
  • utilizzare else e finally;
  • generare errori con raise;
  • creare eccezioni personalizzate;
  • progettare funzioni e classi robuste;
  • collegare la gestione degli errori al modello OOP sviluppato in questo capitolo.

Obiettivo: non limitarsi a "evitare che il programma vada in errore", ma imparare a progettare programmi che sappiano gestire correttamente le situazioni eccezionali.

← → per navigare F fullscreen