Python Generators: Prestaties Verbeteren en Code Vereenvoudigen

Stel je voor dat je werkt aan een datascience-project en je taak is om een dataset te verwerken die zo groot is dat het laden ervan in het geheugen je machine laat crashen. Of je hebt te maken met een oneindige reeks, zoals een live datastroom, waar je onmogelijk alles tegelijk kunt opslaan. Dit zijn de uitdagingen die ervoor zorgen dat datascientists naar het koffiezetapparaat grijpen – en soms naar de resetknop.

In dit artikel zullen we leren over Python generators en hoe je ze kunt gebruiken om je code te vereenvoudigen. Deze benadering vergt wat oefening, dus als je nieuw bent in Python en een beetje verdwaalt in dit artikel, probeer dan onze Inleiding tot Python cursus om een sterke basis te leggen. 

Wat Zijn Python Generators?

Python-generatoren zijn in de kern een speciaal soort functie of zelfs een compacte expressie die lui een reeks waarden produceert. Denk aan generatoren als een transportband in een fabriek: in plaats van alle producten op één plek te stapelen en ruimtegebrek te krijgen, verwerk je elk item naarmate het langs komt. Dit maakt generatoren geheugenefficiënt en een natuurlijke uitbreiding van het iterator-protocol van Python, dat de basis vormt voor veel van de ingebouwde tools van Python zoals for-loops en comprehensies.

De magie achter generatoren zit in het yield-keyword. In tegenstelling tot return, dat een enkele waarde produceert en de functie verlaat, produceert yield een waarde, pauzeert de uitvoering van de functie en slaat de status op. Wanneer de generator opnieuw wordt aangeroepen, pakt het op waar het gebleven was.

Stel je bijvoorbeeld voor dat je een enorme logboekbestand regel voor regel aan het lezen bent. Een generator kan elke regel verwerken zoals deze gelezen wordt zonder het hele bestand in het geheugen te laden. Deze “luie evaluatie” onderscheidt generatoren van traditionele functies en maakt ze een handig hulpmiddel voor prestatiegevoelige taken.

Een eenvoudig voorbeeld van een Python-generator

Laten we een beetje oefenen om het idee onder de knie te krijgen. Hier is een generatorfunctie die de eerste n gehele getallen produceert.

def generate_integers(n): for i in range(n): yield i # Pauzeert hier en retourneert i # Gebruik van de generator for num in generate_integers(5): print(num)
0 1 2 3 4

Ik heb een visuele weergave gemaakt om je te laten zien wat er onder de motorkap gebeurt:

Python Generator Syntax en Patronen

Generators kunnen op meerdere manieren geïmplementeerd worden. Dat gezegd hebbende, zijn er twee primaire manieren: generatorfuncties en generatorexpressies.

Generatorfuncties

Een generatorfunctie is gedefinieerd als een reguliere functie maar gebruikt het sleutelwoord yield in plaats van return. Wanneer aangeroepen, retourneert het een generatorobject dat geïtereerd kan worden.

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # Gebruik van de generator counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

Uit het bovenstaande voorbeeld kunnen we zien dat wanneer de functie count_up_to wordt aangeroepen, het een generator object retourneert. Elke keer dat de for-lus een waarde opvraagt, wordt de functie uitgevoerd totdat deze yield bereikt, waarbij de huidige waarde van count wordt geproduceerd en de status ervan tussen iteraties wordt behouden zodat het precies kan hervatten waar het was gebleven.

Generator expressies  

Generator expressies zijn een compacte manier om generators te maken. Ze lijken op lijstcomprehensies, maar met haakjes in plaats van vierkante haken.

# Lijstcomprehensie (directe evaluatie) squares_list = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] # Generator expressie (uitgestelde evaluatie) squares_gen = (x**2 for x in range(5)) # Gebruik van de generator for square in squares_gen: print(square)
0 1 4 9 16

Dus, wat is het verschil tussen een list comprehension en een generator expressie? De list comprehension maakt de hele lijst in het geheugen aan, terwijl de generator expressie waarden één voor één produceert, wat geheugen bespaart. Als je niet bekend bent met list comprehensions, kun je erover lezen in onze Python List Comprehension Tutorial.

Python generator vs. iterator

Traditionele iterators in Python vereisten klassen met expliciete __iter__() en __next__() methoden, wat veel standaardcode en handmatig beheer van de status met zich meebracht, terwijl generator functies het proces vereenvoudigen door automatisch de status te behouden en de noodzaak voor deze methoden te elimineren—zoals gedemonstreerd door een eenvoudige functie die het kwadraat oplevert van elk getal tot n.

Waarom We Python Generators Gebruiken

Bij het uitleggen wat Python-generators zijn, heb ik ook enkele ideeën over waarom ze worden gebruikt overgebracht. In dit gedeelte wil ik wat dieper ingaan. Want generators zijn niet alleen een leuke functie van Python, maar ze lossen daadwerkelijk echte problemen op.

Geheugenefficiëntie  

In tegenstelling tot lijsten of arrays, die al hun elementen tegelijkertijd in het geheugen opslaan, produceren generators waarden on-the-fly, dus ze houden slechts één item tegelijkertijd in het geheugen.  

Bijvoorbeeld, bekijk het verschil tussen Python 2’s range() en xrange():  

  •  range() creëerde een lijst in het geheugen, wat problematisch kon zijn voor grote reeksen.

  • De functie xrange() gedroeg zich als een generator, die waarden lui produceerde.

Omdat het gedrag van xrange() handiger was, gedraagt range() zich nu in Python 3 ook als een generator, waardoor het geheugenoverhead van het tegelijkertijd opslaan van alle waarden wordt vermeden.

Om het idee te laten zien, laten we het geheugengebruik vergelijken bij het genereren van een reeks van 10 miljoen getallen:

import sys # Met behulp van een lijst numbers_list = [x for x in range(10_000_000)] print(f"Memory used by list: {sys.getsizeof(numbers_list) / 1_000_000:.2f} MB") # Met behulp van een generator numbers_gen = (x for x in range(10_000_000)) print(f"Memory used by generator: {sys.getsizeof(numbers_gen)} bytes")
Memory used by list: 89.48 MB Memory used by the generator: 112 bytes

Zoals je kunt zien, gebruikt de generator bijna geen geheugen in vergelijking met de lijst, en dit verschil is significant. 

Prestatieverbeteringen 

Dankzij luie evaluatie worden waarden alleen berekend wanneer dat nodig is. Dit betekent dat je direct kunt beginnen met het verwerken van gegevens zonder te hoeven wachten tot de hele reeks is gegenereerd. 

Stel je bijvoorbeeld voor dat je de kwadraten van de eerste 1 miljoen getallen bij elkaar optelt:

# Met behulp van een lijst (directe evaluatie) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # Met behulp van een generator (luie evaluatie) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

Hoewel beide benaderingen hetzelfde resultaat opleveren, vermijdt de generatorversie het maken van een enorme lijst, waardoor we sneller het resultaat krijgen. 

Eenvoud en leesbaarheid 

Generatoren vereenvoudigen de implementatie van iterators door boilerplate code te elimineren. Vergelijk een op klassen gebaseerde iterator met een generatorfunctie:

Hier is de op klassen gebaseerde iterator:

class SquaresIterator: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration result = self.current ** 2 self.current += 1 return result # Gebruik squares = SquaresIterator(5) for square in squares: print(square)

Hier is de generatorfunctie:

def squares_generator(n): for i in range(n): yield i ** 2 # Gebruik squares = squares_generator(5) for square in squares: print(square)

De generatieversie is korter, gemakkelijker te lezen en vereist geen boilerplate-code. Het is een perfect voorbeeld van de filosofie van Python: eenvoud is beter.

Omgaan met oneindige sequenties

Tenslotte wil ik zeggen dat generatoren bijzonder geschikt zijn voor het representeren van oneindige sequenties, iets wat simpelweg onmogelijk is met lijsten. Neem bijvoorbeeld de Fibonacci-reeks:

def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b # Gebruik fib = fibonacci() for _ in range(10): print(next(fib))
0 1 1 2 3 5 8 13 21 34

Deze generator kan Fibonacci-getallen produceren zonder ooit geheugenproblemen te veroorzaken. Andere voorbeelden zijn het verwerken van live datastromen of werken met tijdreeksgegevens.

Gevorderde concepten van Python-generator

laten we nu eens naar wat moeilijkere ideeën kijken. In deze sectie zullen we verkennen hoe je generators kunt componeren en unieke generatormethoden kunt gebruiken zoals .send(), .throw() en .close().

Generators aan elkaar koppelen

Generators kunnen gecombineerd worden. Je kunt data modulair transformeren, filteren en verwerken door generators aan elkaar te koppelen.

Stel dat je een oneindige reeks getallen hebt en elk getal wilt kwadrateren en oneven resultaten wilt filteren:

def infinite_sequence(): num = 0 while True: yield num num += 1 def square_numbers(sequence): for num in sequence: yield num ** 2 def filter_evens(sequence): for num in sequence: if num % 2 == 0: yield num # Composeer de generators numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # Druk de eerste 10 even kwadraten af for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

Het proces omvat de functie infinite_sequence die onafgebroken getallen genereert, terwijl de functie square_numbers het kwadraat van elk getal oplevert, en vervolgens filter_evens oneven getallen filtert om alleen even kwadraten te produceren.Onze loopbaan in Associate Python Developer gaat over dit soort zaken, zodat je kunt zien hoe je complexe pipelines kunt bouwen en debuggen met behulp van generators, evenals iterators en lijstcomprehensies.

Speciale generatormethoden

Generators worden geleverd met geavanceerde methoden die tweerichtingscommunicatie en gecontroleerde beëindiging mogelijk maken.

send()

De methode .send() stelt je in staat waarden terug te sturen naar een generator, waardoor het een coroutine wordt. Dit is handig voor het maken van interactieve of stateful generators.

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # Gebruik van de generator acc = accumulator() next(acc) # Start de generator print(acc.send(10)) # Uitvoer: 10 print(acc.send(5)) # Uitvoer: 15 print(acc.send(20)) # Uitvoer: 35

Hier is hoe het werkt: 

  • De generator begint met next(acc) om het te initialiseren.  

  • Elke oproep naar .send(value) geeft een waarde door aan de generator, die wordt toegewezen aan value in de yield verklaring.  

  • De generator werkt zijn status bij (total) en geeft het nieuwe resultaat terug.

throw()

De methode .throw() stelt je in staat om een uitzondering te genereren binnen de generator, wat handig kan zijn voor foutafhandeling of het signaleren van specifieke omstandigheden.

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # Gebruik van de generator gen = resilient_generator() print(next(gen)) # Uitvoer: 0 print(next(gen)) # Uitvoer: 1 print(gen.throw(ValueError)) # Uitvoer: "Fout opgetreden!"

Hier is hoe dit werkt: 

  • De generator draait meestal totdat er .throw() wordt aangeroepen.  

  •  De uitzondering wordt gegenereerd binnen de generator, die dit kan afhandelen met een try-except blok.

close()

De methode .close() stopt een generator door een GeneratorExit uitzondering te veroorzaken. Dit is handig voor het opruimen van resources of het stoppen van oneindige generators.

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # Het gebruik van de generator counter = infinite_counter() print(next(counter)) # Uitvoer: 0 print(next(counter)) # Uitvoer: 1 counter.close() # Uitvoer: "Generator gesloten!"

En zo werkt het:

  • De generator loopt totdat .close() wordt aangeroepen.

  • De GeneratorExit uitzondering wordt opgeworpen, waardoor de generator kan worden opgeschoond of een bericht kan loggen voordat deze wordt beëindigd.

Real-World Toepassingen in Data Science  

Ik hoop dat je gaat waarderen dat generators handig zijn. In dit gedeelte ga ik proberen de gebruiksscenario’s te benadrukken zodat je je kunt voorstellen hoe ze daadwerkelijk voor je werken in je dagelijks leven. 

Verwerken van grote datasets  

Een van de meest voorkomende uitdagingen in data science is het werken met datasets die te groot zijn om in het geheugen te passen. Generators bieden een manier om dergelijke gegevens regel voor regel te verwerken.  

Stel je voor dat je een CSV-bestand van 10 GB hebt met verkoopgegevens en records moet filteren voor een specifieke regio. Zo kun je een generator-pijplijn gebruiken om dit te bereiken:  

import csv def read_large_csv(file_path): """ Generator to read a large CSV file line by line.""" with open(file_path, mode="r") as file: reader = csv.DictReader(file) for row in reader: yield row def filter_by_region(data, region): """ Generator to filter rows by a specific region.""" for row in data: if row["Region"] == region: yield row # Generator-pijplijn file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # Verwerk de gefilterde gegevens for record in filtered_data: print(record)

Dit is wat er gebeurt: 

  1. lees_groot_csv leest het bestand regel voor regel in, waarbij elke rij wordt opgeleverd als een woordenboek.  

  2. filter_op_regio filtert rijen op basis van de opgegeven regio.  

  3. De pijplijn verwerkt gegevens incrementeel, waarbij overbelasting van het geheugen wordt vermeden.

Deze aanpak is gunstig voor extractie, transformatie en laden workflows, waar gegevens moeten worden schoongemaakt en getransformeerd voordat ze geanalyseerd kunnen worden. U zult dit soort dingen zien in onze cursus ETL en ELT in Python.

Streaming en pijplijnen

Gegevens komen soms binnen als een continue stroom. Denk aan sensordata, live feeds of sociale media.

Stel dat je werkt met IoT-apparaten die elke seconde temperatuurmetingen genereren. Je wilt de gemiddelde temperatuur berekenen over een glijdend venster van 10 metingen:

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # Simuleer sensorgegevens def sliding_window_average(stream, window_size): """ Calculate the average over a sliding window of readings.""" window = [] for value in stream: window.append(value) if len(window) > window_size: window.pop(0) if len(window) == window_size: yield sum(window) / window_size # Generatorpijplijn sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # Druk het gemiddelde elke seconde af for avg in averages: print(f"Average temperature: {avg:.2f}")

Hier is de uitleg:

  1. sensor_data_stream simuleert een oneindige stroom van sensormetingen.

  2. sliding_window_average onderhoudt een glijdend venster van de laatste 10 metingen en geeft hun gemiddelde terug.  

  3. De pipeline verwerkt gegevens in realtime, waardoor het ideaal is voor monitoring en analyse.

Extra gebruiksscenario’s

Generators worden ook gebruikt in situaties waar de gegevensgrootte onvoorspelbaar is of wanneer deze maar blijft binnenkomen/oneindig is.  

Web scraping

Wanneer u websites scrapet, weet u vaak niet hoeveel pagina’s of items u moet verwerken. Generators stellen u in staat om met deze onvoorspelbaarheid elegant om te gaan:

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # Simuleer het ophalen en parseren van een pagina print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # Hypothetische functie om de volgende pagina te krijgen # Gebruik scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

Simulatietaken

In simulaties, zoals Monte Carlo-methoden of game-ontwikkeling, kunnen generators oneindige of dynamische sequenties vertegenwoordigen:

def monte_carlo_simulation(): """ Generator to simulate random events for Monte Carlo analysis.""" import random while True: yield random.random() # Gebruik simulation = monte_carlo_simulation() for _ in range(10): print(next(simulation))

Geheugen- en snelheidsbenchmarks

Omdat ze werken, excelleren generators in scenario’s waar geheugenefficiëntie cruciaal is, maar (het zal u misschien verbazen) zijn ze niet altijd de snelste optie. Laten we generators vergelijken met lijsten om hun afwegingen te begrijpen.

Voorheen hebben we laten zien hoe generators beter waren dan lijsten wat betreft geheugen. Dit was het gedeelte waar we het geheugengebruik vergeleken bij het genereren van een reeks van 10 miljoen getallen. Laten we nu iets anders doen, een snelheidsvergelijking:

import time # Lijstcomprehensie start_time = time.time() sum([x**2 for x in range(1_000_000)]) print(f"List comprehension time: {time.time() - start_time:.4f} seconds") # Generatorexpressie start_time = time.time() sum(x**2 for x in range(1_000_000)) print(f"Generator expression time: {time.time() - start_time:.4f} seconds")
List comprehension time: 0.1234 seconds Generator expression time: 0.1456 seconds

Hoewel een generator geheugen bespaart, is het in dit geval eigenlijk langzamer dan de lijst. Dit komt omdat er voor deze kleinere dataset overhead is van het pauzeren en hervatten van de uitvoering.

Het prestatieverschil is verwaarloosbaar voor kleine datasets, maar voor grote datasets wegen de geheugenbesparingen van generators vaak op tegen de lichte snelheidsstraf.

Problemen Die Zich Voordoen

Tenslotte, laten we eens kijken naar enkele veelvoorkomende fouten of problemen:

Generators zijn uitputbaar

Zodra een generator is uitgeput, kan deze niet opnieuw worden gebruikt. Je zult het opnieuw moeten aanmaken als je opnieuw wilt itereren.

gen = (x for x in range(5)) print(list(gen)) # Uitvoer: [0, 1, 2, 3, 4] print(list(gen)) # Uitvoer: [] (de generator is uitgeput)

Lui evalueren kan lastig zijn 

Aangezien generators waarden produceren op verzoek, kunnen fouten of neveneffecten pas optreden wanneer de generator wordt geïtereerd.

Je kunt generators te veel gebruiken 

Voor kleine datasets of eenvoudige taken kan de overhead van het gebruik van een generator mogelijk niet opwegen tegen de geheugenbesparingen. Overweeg dit voorbeeld waarbij ik gegevens materialiseer voor meerdere iteraties.

# Generator expressie gen = (x**2 for x in range(10)) # Materialiseer in een lijst squares = list(gen) # Hergebruik de lijst print(sum(squares)) # Uitvoer: 285 print(max(squares)) # Uitvoer: 81

Het kiezen wanneer Generators te Gebruiken

Om samen te vatten, zal ik wat algemene regels geven over wanneer generators te gebruiken. Gebruik voor:

  • Grote datasets: Gebruik generators bij het werken met datasets die te groot zijn om in het geheugen te passen.
  • Oneindige reeksen: Gebruik generators voor het vertegenwoordigen van oneindige reeksen, zoals live datastreams of simulaties.
  • Pipelines: Gebruik generators om modulaire gegevensverwerkingspipelines te bouwen die gegevens incrementeel transformeren en filteren.

Wanneer gegevens te materialiseren (omzetten naar een lijst)

  • Kleine datasets: Gebruik geen generators als geheugen geen probleem is en je snel toegang nodig hebt tot alle elementen; gebruik in plaats daarvan een lijst.
  • Meerdere iteraties: Gebruik geen generators als je meerdere keren over dezelfde gegevens moet itereren; materialiseer het in plaats daarvan in een lijst om het opnieuw maken van de generator te vermijden.

Conclusie en Belangrijkste Leerpunten

Doorheen dit artikel hebben we onderzocht hoe generators je kunnen helpen bij het aanpakken van uitdagingen in data science in de echte wereld, van het verwerken van grote datasets tot het bouwen van real-time data pipelines. Blijf oefenen. De beste manier om generators onder de knie te krijgen is door ze te gebruiken in je eigen werk. Als startpunt kun je proberen een list comprehension te vervangen door een generator expressie of een lus te herstructureren tot een generator functie. 

Zodra je de basis onder de knie hebt, kun je nieuwe en meer geavanceerde onderwerpen verkennen die voortbouwen op het generator concept:  

  • Coroutines: Gebruik .send() en .throw() om generators te creëren die gegevens kunnen ontvangen en verwerken, waardoor tweerichtingscommunicatie mogelijk is.  

  • Asynchroon programmeren: Combineer generators met Python’s asyncio bibliotheek om efficiënte, niet-blokkerende applicaties te bouwen.

  • Concurrentie: Leer hoe generators coöperatieve multitasking en lichte concurrentie kunnen implementeren.

Blijf leren en word een expert. Volg vandaag nog onze carrièreroute voor Python Developer of onze vaardigheidsroute voor Python Programmeren. Klik op de onderstaande link om te beginnen.

Source:
https://www.datacamp.com/tutorial/python-generators