Wie man Code wie ein Senior Data Engineer schreibt
Hochleistungscode schreiben, ohne sich in Zukunft weitere Probleme zu bereiten.
Herzliche Glückwünsche!
Wenn Sie dies lesen, möchten Sie wahrscheinlich besser darin werden, Code für Data Engineering zu schreiben. In diesem Leitfaden zeige ich Ihnen, wie ich das Schreiben von Code als Mittel zur Problemlösung angehe.
Ein bisschen über mich
Ich bin Senior Data Engineer bei Headspace Health und praktiziere Data Engineering seit mehr als 5 Jahren. Mehr auf meiner persönlichen Website .
I. HINTERGRUNDKONZEPTE
In diesem Artikel beziehe ich mich auf Code als deklarativ vs. nicht deklarativ (Imperativ) :
- Imperativer Code (nicht deklarativ) teilt Ihrem Compiler Schritt für Schritt mit, was ein Programm tut. Der Compiler kann keine Schritte überspringen, da jeder Schritt vollständig vom vorherigen Schritt abhängt.
- Deklarativer Code sagt Ihrem Compiler, was der gewünschte Zustand eines Programms sein sollte, und abstrahiert die Schritte, wie man ihn erreicht. Der Compiler kann Schritte überspringen oder kombinieren, da er alle Zustände im Voraus bestimmen kann.
Moderne Compiler haben alle möglichen Tricks , um Code auf moderner Hardware schneller und effizienter laufen zu lassen. Je besser ein Compiler den Zustand eines Programms vorhersagen kann, desto mehr „Tricks“ kann er anwenden, was zu weniger Anweisungen und erheblichen Leistungsvorteilen führt.
Unten ist ein Architekturdiagramm der Schnittstelle eines Compilers mit einer Verarbeitungseinheit (Hardware). Lassen Sie sich vom Compiler helfen! Geben Sie ihm einfachere, vorhersehbarere Anweisungen.
Um es zusammenzufassen: Deklarativer Code nutzt die Fähigkeiten moderner Compiler und führt zu einer höheren Leistung.
Okay, lass uns anfangen, Code zu schreiben!
II. PROBLEMSTELLUNG
Sie haben eine Liste von things, vielleicht ist die Liste leer, vielleicht enthält sie Millionen von Elementen, und Sie brauchen den ersten non-nullWert:
# A small list
things_small = [0, 1]
# An impossibly big list
things_big = list(range(1_000_000))
# A list with nulls and other stuff
things_with_nulls = [None, "", object()]
- Die Funktion sollte genaue Ergebnisse zurückgeben – und keine Diskriminierung
0'soder leere Zeichenfolgen"". - Die Leistung der Lösung sollte nicht langsam sein. Es ist wahrscheinlich vernünftig,
min = O(1), max = O(k), wokdie Größe der Liste ist , anzustreben
>> get_first_non_null([1, 2, 3])
1
>> get_first_non_null([None, 2, 3])
2
>> get_first_non_null([None, 0, 3])
0
>> get_first_non_null([None, None, None])
None
>> get_first_non_null([])
None
>> get_first_non_null([None, "", 1])
""
Lösung 1: Listenverständnis [einfache Lösung]
Ich bin mir sicher, dass jeder Datentechniker dieses Problem dutzende Male gelöst hat. Einfache Bohnen! Iterieren Sie über die Liste und sehen Sie, welche Elemente nicht null sind, und geben Sie dann den ersten Wert zurück:
def get_first_non_null_list_comp(my_vals: list, default=None):
"""
Get first non-null value using
list comprehension.
"""
filtered_vals = [x for x in my_vals if x is not None]
if len(filtered_vals)>0:
return filtered_vals[0]
else:
return default
- Sie müssen jedes Element auf der Liste bearbeiten. Dies kann langsam sein, wenn Ihre Liste MASSIV ist
- Das Listenverständnis kopiert im Wesentlichen die Liste, daher kann es gedächtnisintensiv sein. Es sei denn, wir arbeiten an Ort und Stelle (
my_vals = [x for x in my_vals]), was zu Problemen beim Überschreiben der ursprünglichen Liste führen kann. Das sollten wir also vermeiden. - Der Zugriff auf das erste Element in der Liste
list[0]ist nicht deklarativ >> das heißt, Ihr Programm hat keine Garantie, was das Attribut sein wird, bis es es bekommt. Dies ist für Python die meiste Zeit in Ordnung. Aber wenn Sie dazu übergehen, mehr „von der Organisation genutzten Code“ zu schreiben, sehen Sie tendenziell Beispiele, bei denen der Zugriff auf Elemente in einer Liste schief geht. Zum Beispiel:customer_email = response["data"][0]["custom_attributes"][-1]["email"]<< EEEEEK! - Es gibt 2 return-Anweisungen – dies ist teilweise nicht deklarativ und erhöht die Codekomplexität (möglicherweise Einschränkung der Erweiterbarkeit).
So können wir die Funktion so ändern, dass sie die Liste durchläuft, ohne alle Werte zu verarbeiten:
def get_first_non_null_loop(my_vals: list, default=None):
"""
Get first non-null value using
a loop.
"""
for x in my_vals:
if x is not None:
return x
# Otherwise, return the default value
return default
Aber es gibt noch Mängel:
- Der Code ist durchgeknallt und würde nicht von einer Vektorisierung profitieren
- Code ist nicht deklarativ – unser Compiler ist traurig.
- Ähnlich wie bei Lösung 1 oben gibt es 2 return-Anweisungen. Ich möchte, dass es nur 1 gibt.
Lösung 3: Filtern mit einem Generator [schwierige Lösung]
Dynamisches Laden von Werten mit der integrierten Funktion von Python, filterdie einen Generator erstellt, der es uns ermöglicht, dynamisch auf jede Komponente zuzugreifen und sie auszuwerten:
from operator import is_not
from functools import partial
def get_first_non_null_generator(my_vals: list, default=None):
"""
Get first non-null value using
a generator (via filter).
"""
# Create a generator of values
filtered_vals = filter(partial(is_not, None), my_vals)
# Iterate and get the first not none value
return next(filtered_vals, default)
- Der
filterOperator ist ein Generator/Iterator, d. h. er wertet nur Elemente aus, die er benötigt. Da wir dienextFunktion verwenden, wird sie im Grunde träge geladen. - Die
partialFunktion ermöglicht es uns, die schnellste Python-Evaluierung dynamisch auf den Wert >>is not None>> anzuwenden, andernfalls, wenn wir etwas wie verwenden, wird[x for x in my_list if x]then0'sausgeschlossen. - Die
nextFunktion ruft das nächste Element vom Iterator ab. Der Speicher explodiert nicht, da wir jeweils nur 1 Wert erhalten. Dasdefaultwird explizit festgelegt, andernfalls wird dies ein auslösen,StopIterationsobald der Iterator erschöpft ist. - Die deklarative Natur ermöglicht die Vektorisierung (und Kompilierungsverbesserungen).
- Ermöglicht auch eine Just-in-Time-Kompilierung , wenn wir zur weiteren Optimierung erweitern möchten.
Schön, dass ich Ihr Interesse geweckt habe! Ich erkläre es ein anderes Mal ausführlich. In der Zwischenzeit können Sie hier ein wenig darüber lesen: Vectorization: A Key Tool To Improve Performance On Modern CPUs
IV. ERWEITERUNG UNSERER LÖSUNG
Abrufen des ersten nicht leeren Werts aus einem Wörterbuch.
Das erste Element einer Liste zu erhalten ist ziemlich einfach. Aber wie wäre es mit dem Abrufen des ersten nicht leeren Werts aus einem Wörterbuch, basierend auf einer Reihe von Schlüsseln?
Nehmen Sie zum Beispiel das folgende Dokument:
{
"key": {
"field_1": "one",
"field_2": "two"
}
}
Da unsere dritte Lösung get_first_non_null_generator()einen beliebigen Iterator verwendet, können wir eine erstellen mapper, die unser Dokument an Nachschlageschlüssel bindet, und in unserer Funktion wie folgt verwenden:
my_doc = {
"field_1": "one",
"field_2": "two"
}
# Get the first non-empty value from a dictionary:
res = get_first_non_null_generator(
map(my_doc.get, ("field_1", "field_2"))
)
# We should get the first non-empty value
assert res == "one"
Hier ist ein etwas längeres Beispiel (das eher dem Anwendungsfall ähnelt, den ich zum Schreiben dieses Codes hatte):
# A dict of fields with default and example values
my_dict = {
"name": {
"example": "Willy Wonka"
},
"country": {
"default": "USA",
"example": "Wonka-land"
},
"n_wonka_bars": {
"default": 0,
"example": 11
},
"has_golden_ticket": {
"default": False
},
"is_an_oompa_loompa": {
"description": "Is this person an Oompa Loompa?"
}
}
# Now I want to get an example record, from default/example vals:
expected_result = {
"name": "Willy Wonka",
"country": "Wonka-land",
"n_wonka_bars": 11,
"has_golden_ticket": False,
"is_an_oompa_loompa": None
}
# Iterate through fields, though if we wanted to
# get crazy, we can compress to a single line (not shown)
example_record = {}
for key, value in my_dict.items():
# We want "examples" before "default", if any
example_record[key] = get_first_non_null_generator(
map(value.get, ("example", "default"))
)
# We should get the above expected result
assert example_record == expected_result
Hier ist ein wirklich raffinierter Anwendungsfall für den Zugriff auf Klassenattribute mit partiellen Funktionen und Mappern:
from typing import Any, Optional
from operator import attrgetter
class FieldAttributes:
"""
Field attributes.
We will want to access these dynamically
"""
example: Any
default: Any
description: Optional[str]
def __init__(self, example=None, default=None, description=None):
self.example = example
self.default = default
self.description = description
class Field(FieldAttributes):
"""Class representing a field"""
name: str
attrs: FieldAttributes
def __init__(self, name, **kwargs):
self.name = name
self.attrs = FieldAttributes(**kwargs)
class UserData:
"""Class representing our user data"""
name = Field("user_name", example="Willy Wonka")
country = Field("country", default="USA", example="Wonka-land")
n_wonka_bars = Field("n_wonka_bars", default=0, example=11)
has_golden_ticket = Field("has_golden_ticket", default=False)
is_an_oompa_loompa = Field("is_an_oompa_loompa",
description="Is this person an Oompa Loompa?"
)
# Access all the fields here
fields = (
name,
country,
n_wonka_bars,
has_golden_ticket,
is_an_oompa_loompa
)
# ------------------------------------------------
# We could compress it all down to something even tighter:
example_record = {
k.name: get_first_non_null_generator(
map(k.attrs.__getattribute__,
("example", "default")
)
)
for k in UserData.fields
}
assert example_record == expected_result
"""
If we were concerned with high-performance (at the expense
of readibility), we could compress everything further
into a single context – which could translate
neatly within a vectorized library. But this is way overkill
"""
example_record = dict(
zip(
map(attrgetter('name'), UserData.fields),
map(
get_first_non_null_generator,
map(
attrgetter("attrs.example", "attrs.default"),
UserData.fields
)
)
)
)
assert example_record == expected_result
Berücksichtigen Sie möglichst viele dieser Faktoren, wenn Sie Ihren Code im Voraus schreiben, und dokumentieren Sie Ihre Annahmen. (Es ist in Ordnung, Abkürzungen zu nehmen! Solange du dein zukünftiges Ich in der Dokumentation erzählst.)
V. ÜBER LÖSUNGEN DENKEN
Ein großer Teil davon, ein leitender Entwickler zu sein, besteht darin, wie Sie über Probleme denken. Die meisten Probleme, mit denen Datenteams (und Softwareteams) konfrontiert sind, sind eine Kombination aus technischen und organisatorischen Bedenken.
Zur Veranschaulichung schreiben wir in unserem Fall Code, um den ersten non-nullWert in einer Liste zu finden. Aber im Laufe der Zeit wird unsere Lösung von anderen Teams verwendet, die unsere Lösung auf unterschiedliche Weise verwenden werden. Beispielsweise kann jemand versuchen, den ersten non-nullWert in einem Wörterbuch zu finden, wenn ihm eine Liste von Schlüsseln gegeben ist. Das ist nicht unbedingt eine schlechte Sache. Es ist unvermeidlich, dass Entwickler Ihren Code auf eine Weise verwenden, die Sie beim ersten Schreiben nicht erwartet haben.
Ohne Eingriff wird die Komplexität einer Codebasis mit der Zeit zunehmen. Wenn die Komplexität zu stark wird, entsteht ein großer Schlammball . Wie können Sie mit diesem Wissen den zukünftigen Zustand Ihrer Codebasis schützen?
Wenn unsere Organisation klein wäre: Wir können einen Kommentar im Code hinterlassen, der besagt:#This code only works with flat lists. Contact YBressler if you have problems
Mit anderen Worten, nutzen Sie die technischen und organisatorischen Belange auseinander und lösen Sie diese jeweils separat. (Technisch = Code schreiben. Organisatorisch = Kommentar hinterlassen.)
Ehrlich gesagt ist dies eine großartige Lösung, wenn Ihr Team klein ist. Aber sobald eine Organisation eine bestimmte Größe erreicht oder Leute die Organisation verlassen, wird diese Lösung problematisch.
Eine bessere Lösung berücksichtigt die Bedenken des Softwareentwicklungslebenszyklus. Dies bedeutet normalerweise, sicherzustellen, dass Ihr Code unkompliziert, leicht zu testen und leistungsfähig ist. Diese Art von Code ermöglicht zukünftigen Entwicklern ein einfaches Refactoring, sodass sie Ihre ursprüngliche Lösung für spätere Anforderungen neu verwenden und erweitern können, ohne die Komplexität der Codebasis zu erhöhen.
Mit anderen Worten, unser Code sollte sowohl die technischen als auch die organisatorischen Probleme lösen. Technisch = der Code funktioniert. Organisatorisch = Der Code ist leicht verständlich und kann problemlos umgestaltet werden.
Bei unseren Codebeispielen geht es mir sicherlich um die Performance einer Lösung. Ich bin ebenso besorgt darüber, wie zukünftige Entwickler mit dieser Lösung interagieren werden.
Wenn eine Code-Lösung nicht einfach zu verstehen ist (hohe Komplexität), werden die Menschen Angst haben, Änderungen daran vorzunehmen. Sie werden es entweder auf noch komplexere Weise verwenden oder mehr Code erstellen, was auch die Komplexität einer Codebasis erhöht.
VI. FAZIT:
Zusammenfassend lässt sich sagen, dass leitende Dateningenieure [versuchen], Code zu schreiben, der leicht verständlich und leistungsstark ist, aber vor allem zukünftige Probleme löst, indem er die Komplexität einer Codebasis reduziert.
Als Demonstrationspunkt oben ist die schnelle Lösung get_first_non_null_generator() clever, leicht zu lesen und leistungsfähig. Am wichtigsten ist, dass es darauf abzielt, die Komplexität in einer Codebasis zu reduzieren.
Verweise:
- Farley, D. (2022). In Modern Software Engineering: Tun, was funktioniert, um schneller bessere Software zu entwickeln (S. 128), Addison-Wesley.

![Was ist überhaupt eine verknüpfte Liste? [Teil 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)



































