Modelli di design Python: per codice elegante e alla moda
Pubblicato: 2022-03-11Diciamolo ancora: Python è un linguaggio di programmazione di alto livello con tipizzazione dinamica e binding dinamico. Lo descriverei come un linguaggio dinamico potente e di alto livello. Molti sviluppatori sono innamorati di Python per la sua chiara sintassi, moduli e pacchetti ben strutturati e per la sua enorme flessibilità e gamma di funzionalità moderne.
In Python, nulla ti obbliga a scrivere classi e istanziare oggetti da esse. Se non hai bisogno di strutture complesse nel tuo progetto, puoi semplicemente scrivere funzioni. Ancora meglio, puoi scrivere uno script semplice per eseguire alcune attività semplici e veloci senza strutturare affatto il codice.
Allo stesso tempo Python è un linguaggio orientato agli oggetti al 100%. Com'è quello? Bene, in poche parole, tutto in Python è un oggetto. Le funzioni sono oggetti, oggetti di prima classe (qualunque cosa significhi). Questo fatto sul fatto che le funzioni siano oggetti è importante, quindi ricordalo.
Quindi, puoi scrivere semplici script in Python o semplicemente aprire il terminale Python ed eseguire istruzioni proprio lì (è così utile!). Ma allo stesso tempo, puoi creare framework complessi, applicazioni, librerie e così via. Puoi fare così tanto in Python. Ci sono ovviamente una serie di limitazioni, ma non è questo l'argomento di questo articolo.
Tuttavia, poiché Python è così potente e flessibile, abbiamo bisogno di alcune regole (o schemi) quando si programma in esso. Quindi, vediamo quali sono i pattern e come si relazionano con Python. Procederemo anche con l'implementazione di alcuni modelli di progettazione Python essenziali.
Perché Python è buono per i modelli?
Qualsiasi linguaggio di programmazione è buono per i modelli. In effetti, i modelli dovrebbero essere considerati nel contesto di un dato linguaggio di programmazione. Entrambi i modelli, la sintassi del linguaggio e la natura impongono limitazioni alla nostra programmazione. Le limitazioni che derivano dalla sintassi del linguaggio e dalla natura del linguaggio (dinamico, funzionale, orientato agli oggetti e simili) possono differire, così come le ragioni alla base della loro esistenza. Le limitazioni derivanti dai modelli ci sono per una ragione, hanno uno scopo. Questo è l'obiettivo di base dei modelli; per dirci come fare una cosa e come non farla. Parleremo di modelli, e in particolare di modelli di progettazione Python, più avanti.
La filosofia di Python si basa sull'idea di best practice ben congegnate. Python è un linguaggio dinamico (l'ho già detto?) e in quanto tale implementa già, o ne semplifica l'implementazione, una serie di modelli di progettazione popolari con poche righe di codice. Alcuni modelli di progettazione sono integrati in Python, quindi li usiamo anche senza saperlo. Non sono necessari altri modelli a causa della natura della lingua.
Ad esempio, Factory è un design pattern strutturale Python volto a creare nuovi oggetti, nascondendo la logica di istanziazione all'utente. Ma la creazione di oggetti in Python è dinamica in base alla progettazione, quindi non sono necessarie aggiunte come Factory. Naturalmente, se lo desideri, sei libero di implementarlo. Potrebbero esserci casi in cui sarebbe davvero utile, ma sono un'eccezione, non la norma.
Cosa c'è di così buono nella filosofia di Python? Iniziamo con questo (esploralo nel terminale Python):
> >> import this The Zen of Python, by Tim Peters Beautiful is better than ugly. Explicit is better than implicit. Simple is better than complex. Complex is better than complicated. Flat is better than nested. Sparse is better than dense. Readability counts. Special cases aren't special enough to break the rules. Although practicality beats purity. Errors should never pass silently. Unless explicitly silenced. In the face of ambiguity, refuse the temptation to guess. There should be one-- and preferably only one --obvious way to do it. Although that way may not be obvious at first unless you're Dutch. Now is better than never. Although never is often better than *right* now. If the implementation is hard to explain, it's a bad idea. If the implementation is easy to explain, it may be a good idea. Namespaces are one honking great idea -- let's do more of those!Questi potrebbero non essere schemi nel senso tradizionale, ma queste sono regole che definiscono l'approccio "Pythonic" alla programmazione nel modo più elegante e utile.
Abbiamo anche linee guida del codice PEP-8 che aiutano a strutturare il nostro codice. Per me è un must, con alcune eccezioni, ovviamente. A proposito, queste eccezioni sono incoraggiate dallo stesso PEP-8:
Ma soprattutto: sapere quando essere incoerente – a volte la guida di stile semplicemente non si applica. In caso di dubbio, usa il tuo miglior giudizio. Guarda altri esempi e decidi cosa sembra migliore. E non esitare a chiedere!
Combina PEP-8 con The Zen of Python (anche un PEP - PEP-20) e avrai una base perfetta per creare codice leggibile e manutenibile. Aggiungi Design Patterns e sei pronto per creare ogni tipo di sistema software con coerenza ed evolvibilità.
Modelli di progettazione Python
Che cos'è un modello di progettazione?
Tutto inizia con la Gang of Four (GOF). Fai una rapida ricerca online se non hai familiarità con il GOF.
I modelli di progettazione sono un modo comune per risolvere problemi ben noti. Due principi fondamentali sono alla base dei modelli di progettazione definiti dal GOF:
- Programma su un'interfaccia non un'implementazione.
- Preferisci la composizione dell'oggetto rispetto all'ereditarietà.
Diamo un'occhiata più da vicino a questi due principi dal punto di vista dei programmatori Python.
Programma su un'interfaccia non un'implementazione
Pensa alla digitazione delle anatre. In Python non ci piace definire interfacce e classi di programma in base a queste interfacce, vero? Ma ascoltami! Questo non significa che non pensiamo alle interfacce, infatti con Duck Typing lo facciamo sempre.
Diciamo alcune parole sul famigerato approccio Duck Typing per vedere come si adatta a questo paradigma: programma su un'interfaccia.
Non ci preoccupiamo della natura dell'oggetto, non dobbiamo preoccuparci di quale sia l'oggetto; vogliamo solo sapere se è in grado di fare ciò di cui abbiamo bisogno (ci interessa solo l'interfaccia dell'oggetto).
L'oggetto può ciarlare? Quindi, lascia che ciarlatano!
try: bird.quack() except AttributeError: self.lol()Abbiamo definito un'interfaccia per la nostra papera? No! Abbiamo programmato sull'interfaccia invece che sull'implementazione? Sì! E lo trovo così bello.
Come sottolinea Alex Martelli nella sua famosa presentazione sui Design Patterns in Python, "Insegnare alle papere a digitare richiede un po' di tempo, ma in seguito ti fa risparmiare un sacco di lavoro!"
Preferisci la composizione dell'oggetto rispetto all'ereditarietà
Ora, questo è quello che chiamo un principio Pythonico ! Ho creato meno classi/sottoclassi rispetto al wrapping di una classe (o più spesso, più classi) in un'altra classe.
Invece di fare questo:
class User(DbObject): passPossiamo fare qualcosa del genere:
class User: _persist_methods = ['get', 'save', 'delete'] def __init__(self, persister): self._persister = persister def __getattr__(self, attribute): if attribute in self._persist_methods: return getattr(self._persister, attribute)I vantaggi sono evidenti. Possiamo limitare i metodi della classe avvolta da esporre. Possiamo iniettare l'istanza persister in runtime! Ad esempio, oggi è un database relazionale, ma domani potrebbe essere qualunque cosa, con l'interfaccia di cui abbiamo bisogno (di nuovo quei fastidiosi paperi).
La composizione è elegante e naturale per Python.
Modelli comportamentali
I modelli comportamentali implicano la comunicazione tra oggetti, il modo in cui gli oggetti interagiscono e svolgono un determinato compito. Secondo i principi del GOF, ci sono un totale di 11 modelli comportamentali in Python: Catena di responsabilità, Comando, Interprete, Iteratore, Mediatore, Memento, Osservatore, Stato, Strategia, Modello, Visitatore.
Trovo questi modelli molto utili, ma questo non significa che gli altri gruppi di modelli non lo siano.
Iteratore
Gli iteratori sono integrati in Python. Questa è una delle caratteristiche più potenti della lingua. Anni fa, ho letto da qualche parte che gli iteratori rendono Python fantastico e penso che sia ancora così. Impara abbastanza sugli iteratori e sui generatori Python e saprai tutto ciò di cui hai bisogno su questo particolare modello Python.
Catena di responsabilità
Questo modello ci offre un modo per trattare una richiesta utilizzando metodi diversi, ognuno rivolto a una parte specifica della richiesta. Sai, uno dei migliori principi per un buon codice è il principio della responsabilità unica .
Ogni pezzo di codice deve fare una, e solo una, cosa.
Questo principio è profondamente integrato in questo modello di progettazione.
Ad esempio, se vogliamo filtrare alcuni contenuti possiamo implementare diversi filtri, ognuno dei quali esegue un tipo di filtraggio preciso e chiaramente definito. Questi filtri possono essere utilizzati per filtrare parole offensive, annunci, contenuti video non adatti e così via.
class ContentFilter(object): def __init__(self, filters=None): self._filters = list() if filters is not None: self._filters += filters def filter(self, content): for filter in self._filters: content = filter(content) return content filter = ContentFilter([ offensive_filter, ads_filter, porno_video_filter]) filtered_content = filter.filter(content)Comando
Questo è uno dei primi modelli di progettazione Python che ho implementato come programmatore. Questo mi ricorda: i modelli non si inventano, si scoprono . Esistono, dobbiamo solo trovarli e metterli a frutto. L'ho scoperto per un progetto straordinario che abbiamo implementato molti anni fa: un editor XML WYSIWYM per scopi speciali. Dopo aver utilizzato questo modello in modo intensivo nel codice, ho letto di più su di esso su alcuni siti.
Il modello di comando è utile in situazioni in cui, per qualche motivo, è necessario iniziare preparando ciò che verrà eseguito e quindi eseguirlo quando necessario. Il vantaggio è che l'incapsulamento delle azioni in questo modo consente agli sviluppatori Python di aggiungere funzionalità aggiuntive relative alle azioni eseguite, come annullare/ripristinare o mantenere una cronologia delle azioni e simili.
Vediamo come appare un esempio semplice e usato di frequente:
class RenameFileCommand(object): def __init__(self, from_name, to_name): self._from = from_name self._to = to_name def execute(self): os.rename(self._from, self._to) def undo(self): os.rename(self._to, self._from) class History(object): def __init__(self): self._commands = list() def execute(self, command): self._commands.append(command) command.execute() def undo(self): self._commands.pop().undo() history = History() history.execute(RenameFileCommand('docs/cv.doc', 'docs/cv-en.doc')) history.execute(RenameFileCommand('docs/cv1.doc', 'docs/cv-bg.doc')) history.undo() history.undo()Modelli Creativi
Iniziamo sottolineando che i modelli di creazione non sono comunemente usati in Python. Come mai? A causa della natura dinamica della lingua.
Qualcuno più saggio di me una volta ha detto che Factory è integrato in Python. Significa che il linguaggio stesso ci fornisce tutta la flessibilità di cui abbiamo bisogno per creare oggetti in modo sufficientemente elegante; raramente abbiamo bisogno di implementare qualcosa in cima, come Singleton o Factory.
In un tutorial di Python Design Patterns, ho trovato una descrizione dei modelli di progettazione della creazione che affermava che questi modelli "forniscono un modo per creare oggetti nascondendo la logica di creazione, piuttosto che creare un'istanza di oggetti direttamente utilizzando un nuovo operatore".

Questo praticamente riassume il problema: non abbiamo un nuovo operatore in Python!
Tuttavia, vediamo come possiamo implementarne alcuni, se riteniamo di poter ottenere un vantaggio utilizzando tali schemi.
Singleton
Il modello Singleton viene utilizzato quando si desidera garantire che esista una sola istanza di una determinata classe durante il runtime. Abbiamo davvero bisogno di questo modello in Python? In base alla mia esperienza, è più semplice creare un'istanza intenzionalmente e quindi utilizzarla invece di implementare il modello Singleton.
Ma se vuoi implementarlo, ecco alcune buone notizie: in Python, possiamo alterare il processo di creazione di un'istanza (insieme praticamente a qualsiasi altra cosa). Ricordi il __new__() che ho menzionato prima? Eccoci qui:
class Logger(object): def __new__(cls, *args, **kwargs): if not hasattr(cls, '_logger'): cls._logger = super(Logger, cls ).__new__(cls, *args, **kwargs) return cls._loggerIn questo esempio, Logger è un Singleton.
Queste sono le alternative all'utilizzo di un Singleton in Python:
- Usa un modulo.
- Crea un'istanza da qualche parte al livello più alto della tua applicazione, magari nel file di configurazione.
- Passa l'istanza a ogni oggetto che ne ha bisogno. Questa è un'iniezione di dipendenza ed è un meccanismo potente e facilmente padroneggiabile.
Iniezione di dipendenza
Non intendo entrare in una discussione sul fatto che l'inserimento delle dipendenze sia un modello di progettazione, ma dirò che è un ottimo meccanismo per implementare accoppiamenti sciolti e aiuta a rendere la nostra applicazione manutenibile ed estensibile. Combinalo con Duck Typing e la Forza sarà con te. Sempre.
L'ho elencato nella sezione del modello di creazione di questo post perché affronta la domanda su quando (o meglio: dove) viene creato l'oggetto. È creato all'esterno. Meglio dire che gli oggetti non vengono affatto creati dove li usiamo, quindi la dipendenza non viene creata dove viene consumata. Il codice del consumatore riceve l'oggetto creato esternamente e lo utilizza. Per ulteriori riferimenti, leggi la risposta più votata a questa domanda di Stackoverflow.
È una bella spiegazione dell'iniezione di dipendenza e ci dà una buona idea del potenziale di questa particolare tecnica. Fondamentalmente la risposta spiega il problema con il seguente esempio: non prendere da solo cose da bere dal frigorifero, indica invece un bisogno. Dì ai tuoi genitori che hai bisogno di qualcosa da bere a pranzo.
Python ci offre tutto ciò di cui abbiamo bisogno per implementarlo facilmente. Pensa alla sua possibile implementazione in altri linguaggi come Java e C# e ti renderai presto conto della bellezza di Python.
Pensiamo a un semplice esempio di iniezione di dipendenza:
class Command: def __init__(self, authenticate=None, authorize=None): self.authenticate = authenticate or self._not_authenticated self.authorize = authorize or self._not_autorized def execute(self, user, action): self.authenticate(user) self.authorize(user, action) return action() if in_sudo_mode: command = Command(always_authenticated, always_authorized) else: command = Command(config.authenticate, config.authorize) command.execute(current_user, delete_user_action)Iniettiamo i metodi di autenticazione e autorizzazione nella classe Command. Tutto ciò di cui la classe Command ha bisogno è eseguirli correttamente senza preoccuparsi dei dettagli di implementazione. In questo modo, possiamo utilizzare la classe Command con qualsiasi meccanismo di autenticazione e autorizzazione che decidiamo di utilizzare in runtime.
Abbiamo mostrato come iniettare dipendenze tramite il costruttore, ma possiamo facilmente iniettarle impostando direttamente le proprietà dell'oggetto, sbloccando ancora più potenziale:
command = Command() if in_sudo_mode: command.authenticate = always_authenticated command.authorize = always_authorized else: command.authenticate = config.authenticate command.authorize = config.authorize command.execute(current_user, delete_user_action)C'è molto altro da imparare sull'iniezione di dipendenza; le persone curiose cercherebbero IoC, per esempio.
Ma prima di farlo, leggi un'altra risposta di Stackoverflow, la più votata a questa domanda.
Ancora una volta, abbiamo appena dimostrato come implementare questo meraviglioso modello di progettazione in Python sia solo questione di utilizzare le funzionalità integrate del linguaggio.
Non dimentichiamo cosa significa tutto questo: la tecnica di iniezione delle dipendenze consente unit test molto flessibili e facili. Immagina un'architettura in cui puoi modificare al volo i dati archiviati. Deridere un database diventa un compito banale, vero? Per ulteriori informazioni, puoi consultare l'introduzione di Toptal alla beffa in Python.
Potresti anche voler ricercare modelli di progettazione Prototype , Builder e Factory .
Schemi strutturali
Facciata
Questo potrebbe benissimo essere il modello di progettazione Python più famoso.
Immagina di avere un sistema con un numero considerevole di oggetti. Ogni oggetto offre un ricco set di metodi API. Puoi fare molte cose con questo sistema, ma che ne dici di semplificare l'interfaccia? Perché non aggiungere un oggetto interfaccia che espone un sottoinsieme ben congegnato di tutti i metodi API? Una facciata!
Esempio di modello di progettazione della facciata in Python:
class Car(object): def __init__(self): self._tyres = [Tyre('front_left'), Tyre('front_right'), Tyre('rear_left'), Tyre('rear_right'), ] self._tank = Tank(70) def tyres_pressure(self): return [tyre.pressure for tyre in self._tyres] def fuel_level(self): return self._tank.level Nessuna sorpresa, nessun trucco, la classe Car è una Facciata , e questo è tutto.
Adattatore
Se le facciate vengono utilizzate per la semplificazione dell'interfaccia, gli adattatori riguardano l'alterazione dell'interfaccia. Come usare una mucca quando il sistema si aspetta un'anatra.
Supponiamo che tu abbia un metodo di lavoro per registrare le informazioni in una determinata destinazione. Il tuo metodo si aspetta che la destinazione abbia un metodo write() (come ha ogni oggetto file, per esempio).
def log(message, destination): destination.write('[{}] - {}'.format(datetime.now(), message)) Direi che è un metodo ben scritto con iniezione di dipendenza, che consente una grande estensibilità. Supponiamo di voler accedere a un socket UDP invece di un file, sai come aprire questo socket UDP ma l'unico problema è che l'oggetto socket non ha il metodo write() . Hai bisogno di un adattatore !
import socket class SocketWriter(object): def __init__(self, ip, port): self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._ip = ip self._port = port def write(self, message): self._socket.send(message, (self._ip, self._port)) def log(message, destination): destination.write('[{}] - {}'.format(datetime.now(), message)) upd_logger = SocketWriter('1.2.3.4', '9999') log('Something happened', udp_destination)Ma perché trovo l'adattatore così importante? Ebbene, quando è efficacemente combinato con l'iniezione di dipendenza, ci offre un'enorme flessibilità. Perché alterare il nostro codice ben collaudato per supportare nuove interfacce quando possiamo semplicemente implementare un adattatore che tradurrà la nuova interfaccia in quella ben nota?
Dovresti anche controllare e padroneggiare i modelli di progettazione di bridge e proxy , a causa della loro somiglianza con l' adattatore . Pensa a quanto sono facili da implementare in Python e pensa a diversi modi in cui potresti usarli nel tuo progetto.
Decoratore
Oh quanto siamo fortunati! I decoratori sono davvero carini e li abbiamo già integrati nel linguaggio. Quello che mi piace di più in Python è che usarlo ci insegna a usare le migliori pratiche. Non è che non dobbiamo essere consapevoli delle migliori pratiche (e dei modelli di progettazione, in particolare), ma con Python mi sento come se stessi seguendo le migliori pratiche, a prescindere. Personalmente, trovo che le migliori pratiche di Python siano intuitive e di seconda natura, e questo è qualcosa apprezzato sia dagli sviluppatori principianti che da quelli d'élite.
Il modello decoratore riguarda l'introduzione di funzionalità aggiuntive e, in particolare, il farlo senza utilizzare l'ereditarietà.
Quindi, diamo un'occhiata a come decoriamo un metodo senza utilizzare la funzionalità Python integrata. Ecco un semplice esempio.
def execute(user, action): self.authenticate(user) self.authorize(user, action) return action() Ciò che non è così buono qui è che la funzione di execute fa molto di più che eseguire qualcosa. Non stiamo seguendo alla lettera il principio della responsabilità unica.
Sarebbe bene scrivere semplicemente quanto segue:
def execute(action): return action()Possiamo implementare qualsiasi funzionalità di autorizzazione e autenticazione in un altro luogo, in un decoratore , in questo modo:
def execute(action, *args, **kwargs): return action() def autheticated_only(method): def decorated(*args, **kwargs): if check_authenticated(kwargs['user']): return method(*args, **kwargs) else: raise UnauthenticatedError return decorated def authorized_only(method): def decorated(*args, **kwargs): if check_authorized(kwargs['user'], kwargs['action']): return method(*args, **kwargs) else: raise UnauthorizeddError return decorated execute = authenticated_only(execute) execute = authorized_only(execute) Ora il metodo execute() è:
- Semplice da leggere
- Fa solo una cosa (almeno guardando il codice)
- È decorato con autenticazione
- È decorato con autorizzazione
Scriviamo lo stesso usando la sintassi del decoratore integrato di Python:
def autheticated_only(method): def decorated(*args, **kwargs): if check_authenticated(kwargs['user']): return method(*args, **kwargs ) else: raise UnauthenticatedError return decorated def authorized_only(method): def decorated(*args, **kwargs): if check_authorized(kwargs['user'], kwargs['action']): return method(*args, **kwargs) else: raise UnauthorizedError return decorated @authorized_only @authenticated_only def execute(action, *args, **kwargs): return action() È importante notare che non sei limitato alle funzioni di decoratore. Un decoratore può coinvolgere intere classi. L'unico requisito è che devono essere callable . Ma non abbiamo problemi con questo; dobbiamo solo definire il __call__(self) .
Potresti anche voler dare un'occhiata più da vicino al modulo functools di Python. C'è molto da scoprire lì!
Conclusione
Ho mostrato quanto sia naturale e facile usare i modelli di progettazione di Python, ma ho anche mostrato come anche la programmazione in Python dovrebbe essere facile.
"Semplice è meglio del complesso", ricordi? Forse hai notato che nessuno dei modelli di progettazione è completamente e formalmente descritto. Non sono state mostrate implementazioni complesse su vasta scala. Devi "sentire" e implementarli nel modo che meglio si adatta al tuo stile e alle tue esigenze. Python è un ottimo linguaggio e ti dà tutta la potenza necessaria per produrre codice flessibile e riutilizzabile.
Tuttavia, ti dà di più. Ti dà la "libertà" di scrivere codice davvero pessimo . Non farlo! Non ripeterti (DRY) e non scrivere mai righe di codice più lunghe di 80 caratteri. E non dimenticare di utilizzare i modelli di progettazione ove applicabile; è uno dei modi migliori per imparare dagli altri e guadagnare gratuitamente dalla loro ricchezza di esperienza.
