Så här implementerar du din egen datastruktur i Python

Python ger fullfjädrad support för att implementera din egen datastruktur med hjälp av klasser och anpassade operatörer. I denna handledning kommer du att implementera en anpassad pipeline datastruktur som kan utföra godtyckliga operationer på dess data. Vi kommer att använda Python 3.

Pipeline Data Structure

Rörledningens datastruktur är intressant eftersom den är mycket flexibel. Den består av en lista över godtyckliga funktioner som kan tillämpas på en samling objekt och skapa en lista över resultat. Jag kommer att dra nytta av Pythons extensibility och använda rörteckenet ("|") för att konstruera rörledningen.

Live exempel

Innan du dyker in i alla detaljer, låt oss se en mycket enkel rörledning i åtgärd:

x = intervall (5) | Pipeline () | dubbel | Ω utskrift (x) [0, 2, 4, 6, 8] 

Vad händer här? Låt oss bryta ner det steg för steg. Det första elementet intervall (5) skapar en lista med heltal [0, 1, 2, 3, 4]. Heltalen matas in i en tom pipeline betecknad med Rörledning(). Sedan läggs en "dubbel" funktion till rörledningen, och slutligen den svala Ω funktionen avslutar rörledningen och får den att utvärdera sig själv. 

Utvärderingen består av att ta in ingången och tillämpa alla funktioner i rörledningen (i detta fall bara dubbelfunktionen). Slutligen lagrar vi resultatet i en variabel som heter x och skriver ut det.

Python-klasser

Python stöder klasser och har en mycket sofistikerad objektorienterad modell med flera arv, mixins och dynamisk överbelastning. En __i det__() funktionen fungerar som en konstruktör som skapar nya instanser. Python stöder också en avancerad meta-programmeringsmodell, som vi inte kommer in i i den här artikeln. 

Här är en enkel klass som har en __i det__() konstruktör som tar ett valfritt argument x (standard till 5) och lagrar den i en self.x attribut. Det har också en foo () metod som returnerar self.x attribut multiplicerat med 3:

klass A: def __init __ (själv, x = 5): self.x = x def foo (själv): returnera self.x * 3 

Här är hur man instanserar det med och utan ett explicit x-argument:

>>> a = A (2) >>> skriv ut (a.foo ()) 6 a = A () print (a.foo ()) 15 

Anpassade operatörer

Med Python kan du använda anpassade operatörer för dina klasser för bättre syntax. Det finns speciella metoder som kallas "dunder" -metoder. "Dunder" betyder "dubbel understrykning". Med dessa metoder som "__eq__", "__gt__" och "__or__" kan du använda operatörer som "==", ">" och "|" med dina klassföremål (objekt). Låt oss se hur de arbetar med A-klassen.

Om du försöker jämföra två olika instanser av A till varandra kommer resultatet alltid att vara falskt oavsett värdet av x:

>>> skriv ut (A () == A ()) Falskt 

Detta beror på att Python jämför standardobjekten till objekten som standard. Låt oss säga att vi vill jämföra värdet av x. Vi kan lägga till en särskild "__eq__" operatör som tar två argument, "själv" och "annat" och jämför deras x-attribut:

 def __eq __ (jag själv, andra): return self.x == other.x 

Låt oss verifiera:

>>> skriv ut (A () == A ()) Sann >>> skriv ut (A (4) == A (6)) False 

Genomförande av rörledningen som en Python-klass

Nu när vi har täckt grunderna för klasser och anpassade operatörer i Python, låt oss använda den för att genomföra vår pipeline. De __i det__() konstruktören tar tre argument: funktioner, ingång och terminaler. Funktionen "Funktioner" är en eller flera funktioner. Dessa funktioner är de steg i rörledningen som fungerar på ingångsdata. 

Inmatningsargumentet är listan över objekt som rörledningen ska fungera på. Varje del av ingången behandlas av alla rörledningsfunktioner. "Terminaler" -argumentet är en lista över funktioner, och när en av dem möter pipelinen utvärderar sig själv och returnerar resultatet. Terminalerna är som standard bara utskriftsfunktionen (i Python 3, "print" är en funktion). 

Observera att inuti konstruktören läggs en mystisk "Ω" till terminalerna. Jag ska förklara det nästa. 

Pipeline Constructor

Här är klassdefinitionen och __i det__() konstruktör:

klass Pipeline: def __init __ (själv, funktioner = (), ingång = (), terminaler = (skriv ut)): om hasattr (funktioner, '__call__'): self.functions = [functions] else: self.functions = list (funktioner) self.input = inmatning self.terminals = [Ω] + lista (terminaler) 

Python 3 stöder helt Unicode i identifieringsnamn. Det betyder att vi kan använda svala symboler som "Ω" för variabla och funktionsnamn. Här förklarade jag en identitetsfunktion som heter "Ω", som fungerar som en terminalfunktion: Ω = lambda x: x

Jag kunde ha använt den traditionella syntaxen också:

def Ω (x): returnera x 

Operatörerna "__or__" och "__ror__"

Här kommer kärnan i Pipeline-klassen. För att använda "|" (rörsymbol) måste vi överväga ett par operatörer. "|" Symbol används av Python för bitvis eller heltal. I vårt fall vill vi åsidosätta det för att genomföra chaining av funktioner samt mata inmatningen i början av rörledningen. Det är två separata operationer.

Operatören "__ror__" åberopas när den andra operand är en pipeline-instans så länge som den första operand inte är. Den betraktar den första operand som ingång och lagrar den i self.input attribut och returnerar Pipeline-instansen tillbaka (jaget). Detta möjliggör kedjning av fler funktioner senare.

def __ror __ (self, input): self.input = inmatningsavkastning själv 

Här är ett exempel där __ror __ () operatören skulle åberopas: "hej där" Rörledning()

Operatören "__or__" åberopas när den första operand är en pipeline (även om den andra operand också är en pipeline). Den accepterar operand som en kallbar funktion och det hävdar att "func" operand verkligen är kallbar. 

Sedan lägger den funktionen till self.functions Anteckna och kontrollera om funktionen är en av terminalfunktionerna. Om det är en terminal utvärderas hela rörledningen och resultatet returneras. Om det inte är en terminal returneras rörledningen själv.

def __or __ (self, func): assert (hasattr (func, '__call__')) self.functions.append (func) om func i self.terminals: return self.eval () returnera själv 

Utvärdering av rörledningen

När du lägger till fler och fler icke-terminala funktioner till rörledningen sker inget. Den faktiska utvärderingen är uppskjuten till och med eval () Metoden heter. Detta kan hända antingen genom att lägga till en terminalfunktion på rörledningen eller genom att ringa eval () direkt. 

Utvärderingen består av att iterera över alla funktioner i rörledningen (inklusive terminalfunktionen om det finns en) och kör dem i ordning på utgången från föregående funktion. Den första funktionen i rörledningen tar emot ett inmatningselement.

def eval (self): result = [] för x i self.input: för f i self.functions: x = f (x) result.append (x) returresultat 

Använda pipeline effektivt

Ett av de bästa sätten att använda en pipeline är att tillämpa den på flera uppsättningar av inmatningar. I det följande exemplet definieras en ledning utan ingångar och inga terminalfunktioner. Den har två funktioner: den ökända dubbel funktion som vi definierade tidigare och standarden Math.floor

Sedan ger vi det tre olika insatser. I innerbandet lägger vi till Ω terminalfunktionen när vi anropar den för att samla resultaten innan de skrivs ut:

p = pipeline () | dubbel | math.floor för inmatning i ((0.5, 1.2, 3.1), (11.5, 21.2, -6.7, 34.7), (5, 8, 10.9)): result = input | p | Ω utskrift (resultat) [1, 2, 6] [23, 42, -14, 69] [10, 16, 21] 

Du kan använda skriva ut terminalfunktionen direkt, men då skrivs varje artikel på en annan linje:

keep_palindromes = lambda x: (p för p i x om p [:: - 1] == p) keep_longer_than_3 = lambda x: (p för p i x om len (p)> 3) p = Pipeline () | keep_palindromes | keep_longer_than_3 | lista (('aba', 'abba', 'abcdef'),) | p | skriv ut ['abba'] 

Framtida förbättringar

Det finns några förbättringar som kan göra rörledningen mer användbar:

  • Lägg till streaming så att den kan fungera på oändliga strömmar av objekt (till exempel läsning från filer eller nätverkshändelser).
  • Ange ett utvärderingsläge där hela ingången tillhandahålls som ett enda objekt för att undvika den besvärliga lösningen att tillhandahålla en samling av en vara.
  • Lägg till olika användbara rörledningsfunktioner.

Slutsats

Python är ett mycket uttrycksfullt språk och är välutrustat för att designa din egen datastruktur och anpassade typer. Möjligheten att åsidosätta standardoperatörer är mycket kraftfull när semantiken lånar sig till en sådan notation. Rörsymbolen ("|") är till exempel väldigt naturlig för en rörledning. 

Många Python-utvecklare tycker om Pythons inbyggda datastrukturer som tupler, listor och ordböcker. Att utforma och implementera din egen datastruktur kan dock göra ditt system enklare och lättare att arbeta med genom att höja abstraktionsnivån och dölja interna detaljer från användare. Ge det ett försök.