Come implementare la propria struttura dati in Python

Python fornisce un supporto completo per l'implementazione della propria struttura dati utilizzando classi e operatori personalizzati. In questo tutorial implementerai una struttura dati pipeline personalizzata in grado di eseguire operazioni arbitrarie sui suoi dati. Useremo Python 3.

La struttura dei dati della pipeline

La struttura dei dati della pipeline è interessante perché è molto flessibile. Consiste in un elenco di funzioni arbitrarie che possono essere applicate a una raccolta di oggetti e produrre un elenco di risultati. Approfitterò dell'estensibilità di Python e userò il carattere pipe ("|") per costruire la pipeline.

Esempio dal vivo

Prima di immergerci in tutti i dettagli, vediamo una pipeline molto semplice in azione:

x = intervallo (5) | Pipeline () | doppio | Ω stampa (x) [0, 2, 4, 6, 8] 

Cosa sta succedendo qui? Scopriamolo passo dopo passo. Il primo elemento gamma (5) crea una lista di numeri interi [0, 1, 2, 3, 4]. I numeri interi vengono inseriti in una pipeline vuota designata da Pipeline (). Quindi una funzione "doppia" viene aggiunta alla pipeline, e infine il cool Ω la funzione termina la pipeline e la fa valutare. 

La valutazione consiste nel prendere l'input e applicare tutte le funzioni nella pipeline (in questo caso solo la doppia funzione). Infine, memorizziamo il risultato in una variabile chiamata x e stampala.

Classi Python

Python supporta le classi e ha un modello orientato agli oggetti molto sofisticato che include ereditarietà, mixaggi e sovraccarico dinamico. Un __dentro__() la funzione funge da costruttore che crea nuove istanze. Python supporta anche un modello avanzato di meta-programmazione, che non entreremo in questo articolo. 

Ecco una classe semplice che ha un __dentro__() costruttore che accetta un argomento facoltativo X (il valore predefinito è 5) e lo memorizza in a self.x attributo. Ha anche un foo () metodo che restituisce il self.x attributo moltiplicato per 3:

classe A: def __init __ (self, x = 5): self.x = x def foo (self): return self.x * 3 

Ecco come istanziarlo con e senza un argomento x esplicito:

>>> a = A (2) >>> stampa (a.foo ()) 6 a = A () stampa (a.foo ()) 15 

Operatori personalizzati

Con Python, puoi usare gli operatori personalizzati per le tue classi per una sintassi più gradevole. Esistono metodi speciali noti come metodi "dunder". "Dunder" significa "doppio trattino". Questi metodi come "__eq__", "__gt__" e "__or__" ti permettono di usare operatori come "==", ">" e "|" con le istanze di classe (oggetti). Vediamo come funzionano con la classe A.

Se provi a confrontare due diverse istanze di A l'una con l'altra, il risultato sarà sempre False indipendentemente dal valore di x:

>>> stampa (A () == A ()) Falso 

Questo perché Python confronta gli indirizzi di memoria degli oggetti per impostazione predefinita. Diciamo che vogliamo confrontare il valore di x. Possiamo aggiungere uno speciale operatore "__eq__" che accetta due argomenti, "self" e "other" e confronta il loro attributo x:

 def __eq __ (self, other): return self.x == other.x 

Verifichiamo:

>>> stampa (A () == A ()) Vero >>> stampa (A (4) == A (6)) False 

Implementazione della pipeline come classe Python

Ora che abbiamo coperto le basi delle classi e degli operatori personalizzati in Python, usiamolo per implementare la nostra pipeline. Il __dentro__() costruttore prende tre argomenti: funzioni, input e terminali. L'argomento "funzioni" è una o più funzioni. Queste funzioni sono gli stadi della pipeline che operano sui dati di input. 

L'argomento "input" è l'elenco degli oggetti su cui agirà la pipeline. Ogni elemento dell'input sarà elaborato da tutte le funzioni della pipeline. L'argomento "terminali" è un elenco di funzioni e quando una di queste viene rilevata, la pipeline si valuta e restituisce il risultato. I terminali sono di default solo la funzione di stampa (in Python 3, "print" è una funzione). 

Si noti che all'interno del costruttore, un misterioso "Ω" viene aggiunto ai terminali. Te lo spiego dopo. 

The Pipeline Constructor

Ecco la definizione della classe e il __dentro__() costruttore:

classe Pipeline: def __init __ (self, functions = (), input = (), terminals = (print,)): if hasattr (funzioni, '__call__'): self.functions = [functions] else: self.functions = list (funzioni) self.input = input self.terminals = [Ω] + list (terminali) 

Python 3 supporta completamente Unicode nei nomi degli identificatori. Questo significa che possiamo usare simboli interessanti come "Ω" per nomi di variabili e funzioni. Qui, ho dichiarato una funzione di identità chiamata "Ω", che funge da funzione terminale: Ω = lambda x: x

Avrei potuto usare anche la sintassi tradizionale:

def Ω (x): restituisce x 

Gli operatori "__or__" e "__ror__"

Ecco che arriva il nucleo della classe Pipeline. Per usare il "|" (simbolo pipe), abbiamo bisogno di scavalcare un paio di operatori. Il "|" il simbolo è usato da Python per bit per bit o per interi. Nel nostro caso, vogliamo sovrascriverlo per implementare il concatenamento di funzioni e alimentare l'input all'inizio della pipeline. Quelle sono due operazioni separate.

L'operatore "__ror__" viene richiamato quando il secondo operando è un'istanza Pipeline fintanto che il primo operando non lo è. Considera il primo operando come input e lo memorizza nel self.input attributo e restituisce l'istanza della pipeline (il sé). Ciò consente il concatenamento di più funzioni in seguito.

def __ror __ (self, input): self.input = input return self 

Ecco un esempio in cui il __ror __ () l'operatore sarebbe invocato: 'ciao lì' | Pipeline ()

L'operatore "__or__" viene richiamato quando il primo operando è una pipeline (anche se il secondo operando è anche una pipeline). Accetta che l'operando sia una funzione chiamabile e afferma che l'operando "func" è effettivamente chiamabile. 

Quindi, aggiunge la funzione a self.functions attributo e controlla se la funzione è una delle funzioni del terminale. Se si tratta di un terminale, viene valutata l'intera pipeline e viene restituito il risultato. Se non è un terminale, la pipeline stessa viene restituita.

def __or __ (self, func): assert (hasattr (func, '__call__')) self.functions.append (func) if func in self.terminals: return self.eval () return self 

Valutazione della pipeline

Man mano che si aggiungono sempre più funzioni non terminali alla pipeline, non accade nulla. La valutazione effettiva viene differita fino al eval () il metodo è chiamato. Questo può accadere aggiungendo una funzione terminale alla pipeline o chiamando eval () direttamente. 

La valutazione consiste nell'iterare su tutte le funzioni della pipeline (compresa la funzione terminale se ce n'è una) e eseguirle in ordine sull'output della funzione precedente. La prima funzione nella pipeline riceve un elemento di input.

def eval (self): result = [] per x in self.input: for f in self.functions: x = f (x) result.append (x) risultato di ritorno 

Utilizzo efficace della pipeline

Uno dei modi migliori per utilizzare una pipeline consiste nell'applicarlo a più insiemi di input. Nell'esempio seguente, viene definita una pipeline senza ingressi e senza funzioni terminali. Ha due funzioni: la famigerata Doppio funzione che abbiamo definito in precedenza e lo standard Math.floor

Quindi, forniamo tre diversi input. Nel ciclo interno, aggiungiamo il Ω funzione terminale quando la invochiamo per raccogliere i risultati prima di stamparli:

p = Pipeline () | doppio | Math.floor per input in ((0.5, 1.2, 3.1), (11.5, 21.2, -6.7, 34.7), (5, 8, 10.9)): result = input | p | Ω print (result) [1, 2, 6] [23, 42, -14, 69] [10, 16, 21] 

Potresti usare il stampare funzione terminale direttamente, ma poi ogni elemento verrà stampato su una linea diversa:

keep_palindromes = lambda x: (p per p in x se p [:: - 1] == p) keep_longer_than_3 = lambda x: (p per p in x se len (p)> 3) p = Pipeline () | keep_palindromes | keep_longer_than_3 | lista (('aba', 'abba', 'abcdef'),) | p | stampa ['abba'] 

Miglioramenti futuri

Ci sono alcuni miglioramenti che possono rendere la pipeline più utile:

  • Aggiungi streaming in modo che possa funzionare su infiniti flussi di oggetti (ad esempio, la lettura da file o eventi di rete).
  • Fornire una modalità di valutazione in cui l'intero input viene fornito come un singolo oggetto per evitare l'ingombrante soluzione alternativa di fornire una raccolta di un elemento.
  • Aggiungi varie funzioni utili della pipeline.

Conclusione

Python è un linguaggio molto espressivo ed è ben equipaggiato per progettare la tua struttura dati e i tuoi tipi personalizzati. La capacità di sovrascrivere gli operatori standard è molto potente quando la semantica si presta a tale notazione. Ad esempio, il simbolo pipe ("|") è molto naturale per una pipeline. 

Molti sviluppatori Python godono delle strutture di dati incorporate di Python come tuple, elenchi e dizionari. Tuttavia, progettare e implementare la propria struttura dati può rendere il sistema più semplice e più facile da lavorare, aumentando il livello di astrazione e nascondendo i dettagli interni agli utenti. Provaci.