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 è 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.
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.
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
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
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.
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
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
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
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']
Ci sono alcuni miglioramenti che possono rendere la pipeline più utile:
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.