Garantire codice pulito: uno sguardo a Python, parametrizzato

Pubblicato: 2022-03-11

In questo post parlerò di quella che considero la tecnica o il modello più importante nella produzione di codice Pythonico pulito, ovvero la parametrizzazione. Questo post è per te se:

  • Sei relativamente nuovo all'intera faccenda dei modelli di progettazione e forse un po' disorientato da lunghe liste di nomi di modelli e diagrammi di classe. La buona notizia è che c'è davvero solo un modello di progettazione che devi assolutamente conoscere per Python. Ancora meglio, probabilmente lo conosci già, ma forse non tutti i modi in cui può essere applicato.
  • Sei arrivato in Python da un altro linguaggio OOP come Java o C# e vuoi sapere come tradurre la tua conoscenza dei modelli di progettazione da quel linguaggio in Python. In Python e in altri linguaggi tipizzati dinamicamente, molti modelli comuni nei linguaggi OOP tipizzati staticamente sono "invisibili o più semplici", come ha affermato l'autore Peter Norvig.

In questo articolo, esploreremo l'applicazione della "parametrizzazione" e come può essere correlata ai modelli di progettazione tradizionali noti come iniezione di dipendenza , strategia , metodo modello , fabbrica astratta , metodo di fabbrica e decoratore . In Python, molti di questi risultano essere semplici o non sono necessari dal fatto che i parametri in Python possono essere oggetti o classi richiamabili.

La parametrizzazione è il processo di prendere valori o oggetti definiti all'interno di una funzione o di un metodo e renderli parametri di quella funzione o metodo, al fine di generalizzare il codice. Questo processo è anche noto come refactoring del "parametro di estrazione". In un certo senso, questo articolo riguarda i modelli di progettazione e il refactoring.

Il caso più semplice di Python parametrizzato

Per la maggior parte dei nostri esempi, useremo il modulo tartaruga della libreria standard di istruzioni per fare alcuni grafici.

Ecco del codice che disegnerà un quadrato 100x100 usando turtle :

 from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)

Supponiamo ora di voler disegnare un quadrato di dimensioni diverse. Un programmatore molto giovane a questo punto sarebbe tentato di copiare e incollare questo blocco e modificarlo. Ovviamente, un metodo molto migliore sarebbe quello di estrarre prima il codice del disegno del quadrato in una funzione, quindi rendere la dimensione del quadrato un parametro per questa funzione:

 def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)

Quindi ora possiamo disegnare quadrati di qualsiasi dimensione usando draw_square . Questo è tutto ciò che serve per la tecnica essenziale della parametrizzazione, e abbiamo appena visto il primo utilizzo principale: eliminare la programmazione copia-incolla.

Un problema immediato con il codice sopra è che draw_square dipende da una variabile globale. Questo ha molte conseguenze negative e ci sono due semplici modi per risolverlo. Il primo sarebbe che draw_square crei l'istanza Turtle stessa (di cui parlerò più avanti). Questo potrebbe non essere desiderabile se vogliamo usare una singola Turtle per tutti i nostri disegni. Quindi, per ora, useremo semplicemente di nuovo la parametrizzazione per rendere turtle un parametro per draw_square :

 from turtle import Turtle def draw_square(turtle, size): for i in range(0, 4): turtle.forward(size) turtle.left(90) turtle = Turtle() draw_square(turtle, 100)

Questo ha un nome di fantasia: iniezione di dipendenza. Significa solo che se una funzione ha bisogno di un qualche tipo di oggetto per fare il suo lavoro, come draw_square bisogno di un Turtle , il chiamante è responsabile del passaggio di quell'oggetto come parametro. No, davvero, se sei mai stato curioso dell'iniezione di dipendenza da Python, è proprio questo.

Finora, abbiamo affrontato due usi molto basilari. L'osservazione chiave per il resto di questo articolo è che, in Python, c'è una vasta gamma di cose che possono diventare parametri, più che in altri linguaggi, e questo lo rende una tecnica molto potente.

Tutto ciò che è un oggetto

In Python, puoi usare questa tecnica per parametrizzare qualsiasi cosa che sia un oggetto, e in Python, la maggior parte delle cose che incontri sono, in effetti, oggetti. Ciò comprende:

  • Istanze di tipi predefiniti, come la stringa "I'm a string" e l'intero 42 o un dizionario
  • Istanze di altri tipi e classi, ad esempio un oggetto datetime.datetime
  • Funzioni e metodi
  • Tipi incorporati e classi personalizzate

Gli ultimi due sono quelli che sorprendono di più, soprattutto se provieni da altre lingue, e hanno bisogno di qualche discussione in più.

Funziona come parametri

L'istruzione della funzione in Python fa due cose:

  1. Crea un oggetto funzione.
  2. Crea un nome nell'ambito locale che punta a quell'oggetto.

Possiamo giocare con questi oggetti in un REPL:

 > >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'

E proprio come tutti gli oggetti, possiamo assegnare funzioni ad altre variabili:

 > >> bar = foo > >> bar() 'Hello from foo'

Nota che la bar è un altro nome per lo stesso oggetto, quindi ha la stessa proprietà interna __name__ di prima:

 > >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>

Ma il punto cruciale è che poiché le funzioni sono solo oggetti, ovunque si veda una funzione in uso, potrebbe essere un parametro.

Quindi, supponiamo di estendere la nostra funzione di disegno quadrato sopra, e ora a volte quando disegniamo quadrati vogliamo fare una pausa a ogni angolo, una chiamata a time.sleep() .

Ma supponiamo che a volte non vogliamo fermarci. Il modo più semplice per ottenere ciò sarebbe aggiungere un parametro di pause , magari con un valore predefinito pari a zero in modo che per impostazione predefinita non ci fermiamo.

Tuttavia, scopriamo in seguito che a volte in realtà vogliamo fare qualcosa di completamente diverso agli angoli. Forse vogliamo disegnare un'altra forma ad ogni angolo, cambiare il colore della penna, ecc. Potremmo essere tentati di aggiungere molti più parametri, uno per ogni cosa che dobbiamo fare. Tuttavia, una soluzione molto migliore sarebbe quella di consentire il passaggio di qualsiasi funzione come azione da eseguire. Per impostazione predefinita, creeremo una funzione che non fa nulla. Faremo anche in modo che questa funzione accetti i parametri della turtle e della size locali, nel caso siano richiesti:

 def do_nothing(turtle, size): pass def draw_square(turtle, size, at_corner=do_nothing): for i in range(0, 4): turtle.forward(size) at_corner(turtle, size) turtle.left(90) def pause(turtle, size): time.sleep(5) turtle = Turtle() draw_square(turtle, 100, at_corner=pause)

Oppure, potremmo fare qualcosa di un po' più interessante come disegnare ricorsivamente quadrati più piccoli ad ogni angolo:

 def smaller_square(turtle, size): if size < 10: return draw_square(turtle, size / 2, at_corner=smaller_square) draw_square(turtle, 128, at_corner=smaller_square) 

Illustrazione di quadrati più piccoli disegnati in modo ricorsivo come dimostrato nel codice parametrizzato Python sopra

Ci sono, ovviamente, variazioni di questo. In molti esempi, verrebbe utilizzato il valore di ritorno della funzione. Qui abbiamo uno stile di programmazione più imperativo e la funzione è chiamata solo per i suoi effetti collaterali.

In altre lingue...

Avere funzioni di prima classe in Python lo rende molto semplice. Nelle lingue che ne sono prive o in alcune lingue tipizzate staticamente che richiedono firme di tipo per i parametri, questo può essere più difficile. Come lo faremmo se non avessimo funzioni di prima classe?

Una soluzione sarebbe trasformare draw_square in una classe, SquareDrawer :

 class SquareDrawer: def __init__(self, size): self.size = size def draw(self, t): for i in range(0, 4): t.forward(self.size) self.at_corner(t, size) t.left(90) def at_corner(self, t, size): pass

Ora possiamo sottoclassare SquareDrawer e aggiungere un metodo at_corner che fa ciò di cui abbiamo bisogno. Questo modello python è noto come modello del metodo modello: una classe base definisce la forma dell'intera operazione o algoritmo e le parti varianti dell'operazione vengono inserite in metodi che devono essere implementati dalle sottoclassi.

Anche se questo a volte può essere utile in Python, estrarre il codice variante in una funzione che viene semplicemente passata come parametro sarà spesso molto più semplice.

Un secondo modo in cui potremmo affrontare questo problema nelle lingue senza funzioni di prima classe è racchiudere le nostre funzioni come metodi all'interno delle classi, in questo modo:

 class DoNothing: def run(self, turtle, size): pass def draw_square(turtle, size, at_corner=DoNothing()): for i in range(0, 4): turtle.forward(size) at_corner.run(turtle, size) t.left(90) class Pauser: def run(self, turtle, size): time.sleep(5) draw_square(turtle, 100, at_corner=Pauser())

Questo è noto come modello di strategia. Ancora una volta, questo è certamente un modello valido da usare in Python, specialmente se la classe strategy contiene effettivamente un insieme di funzioni correlate, piuttosto che solo una. Tuttavia, spesso tutto ciò di cui abbiamo veramente bisogno è una funzione e possiamo smettere di scrivere classi.

Altri Callable

Negli esempi precedenti, ho parlato del passaggio di funzioni in altre funzioni come parametri. Tuttavia, tutto ciò che ho scritto era, in effetti, vero per qualsiasi oggetto richiamabile. Le funzioni sono l'esempio più semplice, ma possiamo anche considerare i metodi.

Supponiamo di avere una lista foo :

 foo = [1, 2, 3]

foo ora ha un sacco di metodi collegati, come .append() e .count() . Questi "metodi vincolati" possono essere passati e utilizzati come funzioni:

 > >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]

Oltre a questi metodi di istanza, esistono altri tipi di oggetti richiamabili: staticmethods e classmethods , istanze di classi che implementano __call__ e classi/tipi stessi.

Classi come parametri

In Python, le classi sono "di prima classe" - sono oggetti di runtime proprio come dicts, strings, ecc. Questo potrebbe sembrare ancora più strano delle funzioni che sono oggetti, ma per fortuna, in realtà è più facile dimostrare questo fatto che per le funzioni.

L'istruzione di classe con cui hai familiarità è un bel modo di creare classi, ma non è l'unico modo: possiamo anche usare la versione a tre argomenti di tipo. Le due affermazioni seguenti fanno esattamente la stessa cosa:

 class Foo: pass Foo = type('Foo', (), {})

Nella seconda versione, nota le due cose che abbiamo appena fatto (che sono fatte in modo più conveniente usando l'istruzione class):

  1. Sul lato destro del segno di uguale, abbiamo creato una nuova classe, con un nome interno di Foo . Questo è il nome che riceverai indietro se fai Foo.__name__ .
  2. Con l'assegnazione, abbiamo quindi creato un nome nell'ambito corrente, Foo, che si riferisce a quell'oggetto di classe che abbiamo appena creato.

Abbiamo fatto le stesse osservazioni per ciò che fa l'istruzione di funzione.

L'intuizione chiave qui è che le classi sono oggetti a cui possono essere assegnati nomi (cioè possono essere inseriti in una variabile). Ovunque vedi una classe in uso, in realtà stai solo vedendo una variabile in uso. E se è una variabile, può essere un parametro.

Possiamo suddividerlo in una serie di usi:

Classi come fabbriche

Una classe è un oggetto richiamabile che crea un'istanza di se stesso:

 > >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>

E come oggetto, può essere assegnato ad altre variabili:

 > >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>

Tornando al nostro esempio di tartaruga sopra, un problema con l'utilizzo delle tartarughe per il disegno è che la posizione e l'orientamento del disegno dipendono dalla posizione e dall'orientamento correnti della tartaruga, e può anche lasciarla in uno stato diverso che potrebbe non essere utile per il chiamante. Per risolvere questo problema, la nostra funzione draw_square potrebbe creare la propria tartaruga, spostarla nella posizione desiderata e quindi disegnare un quadrato:

 def draw_square(x, y, size): turtle = Turtle() turtle.penup() # Don't draw while moving to the start position turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

Tuttavia, ora abbiamo un problema di personalizzazione. Supponiamo che il chiamante volesse impostare alcuni attributi della tartaruga o utilizzare un diverso tipo di tartaruga che ha la stessa interfaccia ma ha un comportamento speciale?

Potremmo risolverlo con l'iniezione di dipendenza, come abbiamo fatto prima: il chiamante sarebbe stato responsabile della configurazione dell'oggetto Turtle . Ma cosa succede se la nostra funzione a volte ha bisogno di creare molte tartarughe per diversi scopi di disegno, o se forse vuole dare il via a quattro fili, ognuno con la propria tartaruga per disegnare un lato del quadrato? La risposta è semplicemente rendere la classe Turtle un parametro per la funzione. Possiamo usare un argomento parola chiave con un valore predefinito, per semplificare le cose per i chiamanti a cui non interessa:

 def draw_square(x, y, size, make_turtle=Turtle): turtle = make_turtle() turtle.penup() turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

Per usarlo, potremmo scrivere una funzione make_turtle che crei una tartaruga e la modifichi. Supponiamo di voler nascondere la tartaruga quando disegniamo i quadrati:

 def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)

Oppure potremmo sottoclassare Turtle per incorporare quel comportamento e passare la sottoclasse come parametro:

 class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)

In altre lingue...

Diversi altri linguaggi OOP, come Java e C#, mancano di classi di prima classe. Per creare un'istanza di una classe, devi utilizzare la new parola chiave seguita da un nome di classe effettivo.

Questa limitazione è la ragione di modelli come la fabbrica astratta (che richiede la creazione di un insieme di classi il cui unico compito è quello di istanziare altre classi) e il modello del metodo Factory. Come puoi vedere, in Python, è solo questione di estrarre la classe come parametro perché una classe è la sua stessa fabbrica.

Classi come classi base

Supponiamo di trovarci a creare sottoclassi per aggiungere la stessa caratteristica a classi diverse. Ad esempio, vogliamo una sottoclasse Turtle che scriverà in un registro quando viene creata:

 import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")

Ma poi, ci troviamo a fare esattamente la stessa cosa con un'altra classe:

 class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")

Le uniche cose che variano tra questi due sono:

  1. La classe base
  2. Il nome della sottoclasse, ma non ci interessa davvero e potremmo generarlo automaticamente dall'attributo __name__ della classe base.
  3. Il nome utilizzato all'interno della chiamata di debug , ma ancora una volta, potremmo generarlo dal nome della classe base.

Di fronte a due bit di codice molto simili con una sola variante, cosa possiamo fare? Proprio come nel nostro primo esempio, creiamo una funzione ed estraiamo la parte variante come parametro:

 def make_logging_class(cls): class LoggingThing(cls): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("{0} got created".format(cls.__name__)) LoggingThing.__name__ = "Logging{0}".format(cls.__name__) return LoggingThing LoggingTurtle = make_logging_class(Turtle) LoggingHippo = make_logging_class(Hippo)

Qui, abbiamo una dimostrazione di classi di prima classe:

  • Abbiamo passato una classe in una funzione, assegnando al parametro un nome convenzionale cls per evitare lo scontro con la parola chiave class (vedrai anche class_ e klass usati per questo scopo).
  • All'interno della funzione, abbiamo creato una classe: si noti che ogni chiamata a questa funzione crea una nuova classe.
  • Abbiamo restituito quella classe come valore di ritorno della funzione.

Abbiamo anche impostato LoggingThing.__name__ che è del tutto facoltativo ma può aiutare con il debug.

Un'altra applicazione di questa tecnica è quando abbiamo un intero gruppo di funzionalità che a volte vogliamo aggiungere a una classe e potremmo voler aggiungere varie combinazioni di queste funzionalità. Creare manualmente tutte le diverse combinazioni di cui abbiamo bisogno potrebbe diventare molto ingombrante.

Nei linguaggi in cui le classi vengono create in fase di compilazione anziché in fase di esecuzione, ciò non è possibile. Invece, devi usare il modello decoratore. Quel modello può essere utile a volte in Python, ma per lo più puoi semplicemente usare la tecnica sopra.

Normalmente, in realtà evito di creare molte sottoclassi per la personalizzazione. Di solito, ci sono metodi più semplici e più Pythonici che non coinvolgono affatto le classi. Ma questa tecnica è disponibile se ne hai bisogno. Vedi anche il trattamento completo di Brandon Rhodes sul motivo decoratore in Python.

Classi come eccezioni

Un altro punto in cui si vedono le classi in uso è nella clausola exclude di un'istruzione try/ except /finally. Nessuna sorpresa per indovinare che possiamo parametrizzare anche quelle classi.

Ad esempio, il codice seguente implementa una strategia molto generica di tentare un'azione che potrebbe non riuscire e riprovare con backoff esponenziale fino al raggiungimento del numero massimo di tentativi:

 import time def retry_with_backoff(action, exceptions_to_catch, max_attempts=10, attempts_so_far=0): try: return action() except exceptions_to_catch: attempts_so_far += 1 if attempts_so_far >= max_attempts: raise else: time.sleep(attempts_so_far ** 2) return retry_with_backoff(action, exceptions_to_catch, attempts_so_far=attempts_so_far, max_attempts=max_attempts)

Abbiamo estratto sia l'azione da intraprendere che le eccezioni da catturare come parametri. Il parametro exceptions_to_catch può essere una singola classe, come IOError o httplib.client.HTTPConnectionError , o una tupla di tali classi. (Vogliamo evitare le clausole "bare salvo" o anche except Exception perché è noto che nascondono altri errori di programmazione).

Avvertenze e Conclusione

La parametrizzazione è una tecnica potente per riutilizzare il codice e ridurre la duplicazione del codice. Non è privo di alcuni inconvenienti. Nella ricerca del riutilizzo del codice, spesso emergono diversi problemi:

  • Codice eccessivamente generico o astratto che diventa molto difficile da capire.
  • Codice con una proliferazione di parametri che oscura il quadro generale o introduce bug perché, in realtà, solo determinate combinazioni di parametri vengono adeguatamente testate.
  • Accoppiamento inutile di diverse parti della base di codice perché il loro "codice comune" è stato scomposto in un unico posto. A volte il codice in due posizioni è simile solo accidentalmente e le due posizioni dovrebbero essere indipendenti l'una dall'altra perché potrebbe essere necessario modificarle in modo indipendente.

A volte un po' di codice "duplicato" è molto meglio di questi problemi, quindi usa questa tecnica con cura.

In questo post, abbiamo trattato i modelli di progettazione noti come iniezione di dipendenza , strategia , metodo modello , fabbrica astratta , metodo fabbrica e decoratore . In Python, molti di questi si rivelano davvero una semplice applicazione di parametrizzazione o sono decisamente resi superflui dal fatto che i parametri in Python possono essere oggetti o classi richiamabili. Si spera che questo aiuti ad alleggerire il carico concettuale di "cose ​​​​che dovresti sapere come un vero sviluppatore Python" e ti consente di scrivere codice Pythonico conciso!

Ulteriori letture:

  • Modelli di design Python: per codice elegante e alla moda
  • Modelli Python: per modelli di progettazione Python
  • Registrazione Python: un tutorial approfondito