Herz­li­chen Glück­wunsch! 🎉
Wenn Sie dies lesen, möch­ten Sie wahr­schein­lich bes­ser darin wer­den, Python-Code für die Daten­tech­nik zu schrei­ben. In die­sem Leit­fa­den zeige ich Ihnen, wie ich das Schrei­ben von Code als Mit­tel zur Pro­blem­lö­sung betrachte.

I. HINTERGRUNDKONZEPTE
In die­sem Arti­kel werde ich den Code als dekla­ra­tiv bzw. nicht dekla­ra­tiv (impe­ra­tiv) bezeichnen:

  • Impe­ra­ti­ver Code (nicht dekla­ra­tiv) teilt dem Com­pi­ler mit, was ein Pro­gramm Schritt für Schritt tut. Der Com­pi­ler kann keine Schritte über­sprin­gen, da jeder Schritt voll­stän­dig vom vor­her­ge­hen­den Schritt abhängt.
  • Dekla­ra­ti­ver Code teilt dem Com­pi­ler mit, wie der gewünschte Zustand eines Pro­gramms aus­se­hen soll, und abs­tra­hiert die Schritte, um die­sen Zustand zu errei­chen. Der Com­pi­ler kann Schritte über­sprin­gen oder sie kom­bi­nie­ren, da er alle Zustände im Vor­aus bestim­men kann.

Leis­tungs­starke Soft­ware (sehr schnel­ler Code) wird dadurch erreicht, dass ein Maxi­mum an Arbeit in der kleins­ten Anzahl von Schrit­ten erle­digt wird, wie David Far­ley ele­gant formulierte.¹

Moderne Com­pi­ler ver­fü­gen über alle mög­li­chen Tricks, um Code auf moder­ner Hard­ware schnel­ler und effi­zi­en­ter lau­fen zu las­sen. Je bes­ser ein Com­pi­ler den Zustand eines Pro­gramms vor­her­sa­gen kann, desto mehr „Tricks“ kann er anwen­den, was zu weni­ger Anwei­sun­gen und erheb­li­chen Leis­tungs­vor­tei­len führt.

Unten sehen Sie ein Archi­tek­tur­dia­gramm der Schnitt­stelle zwi­schen einem Com­pi­ler und einer Ver­ar­bei­tungs­ein­heit (einem Stück Hard­ware). Las­sen Sie sich vom Com­pi­ler hel­fen! Geben Sie ihm ein­fa­chere, bes­ser vor­her­seh­bare Anweisungen.

Um es zusam­men­zu­fas­sen: Dekla­ra­ti­ver Code macht sich die Fähig­kei­ten moder­ner Com­pi­ler zunutze und führt zu einer höhe­ren Leistung.

Okay, fan­gen wir an, etwas Code zu schreiben!

II. PROBLEMSTELLUNG
Sie haben eine Liste von Din­gen, viel­leicht ist die Liste leer, viel­leicht hat sie Mil­lio­nen von Ein­trä­gen, und Sie brau­chen den ers­ten Wert, der nicht Null ist:

# 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()]

Gewünsch­tes Ergeb­nis:
Die Funk­tion sollte genaue Ergeb­nisse lie­fern – und keine 0’sor-Leerzeichenfolgen „“ dis­kri­mi­nie­ren.
Die Leis­tung der Lösung sollte nicht lang­sam sein. Es ist wahr­schein­lich sinn­voll, min = O(1), max = O(k) anzu­stre­ben, wobei k die Größe der Liste ist

>> 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])
""

III. [CODE]-LÖSUNGEN:
Hier sind ver­schie­dene Lösungs­an­sätze, begin­nend mit dem einfachsten.

Lösung 1: List Com­pre­hen­sion [Ein­fa­che Lösung]
Ich bin sicher, dass jeder Daten­tech­ni­ker die­ses Pro­blem schon dut­zende Male gelöst hat. Ein­fa­che Boh­nen! Ite­rie­ren Sie über die Liste und prü­fen Sie, wel­che Ele­mente nicht null sind, und geben Sie dann den ers­ten 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

Dies ist jedoch ein­deu­tig nicht leistungsfähig:

  • Sie müs­sen auf jedes Ele­ment der Liste zugrei­fen. Dies kann lang­sam sein, wenn Ihre Liste MASSIV ist 🐘.
  • Das Lis­ten­ver­ständ­nis kopiert im Wesent­li­chen die Liste und kann daher sehr spei­cher­in­ten­siv sein. Es sei denn, wir ope­rie­ren an Ort und Stelle (my_vals = [x for x in my_vals]), was zu Pro­ble­men beim Über­schrei­ben der ursprüng­li­chen Liste füh­ren kann. Wir soll­ten dies also vermeiden.
  • Der Zugriff auf das erste Ele­ment in der Liste list[0] ist nicht-dekla­ra­tiv » das heißt, Ihr Pro­gramm hat keine Garan­tie, was das Attri­but sein wird, bis es es bekommt. Das ist für Python die meiste Zeit in Ord­nung. Aber wenn man dazu über­geht, mehr „orga­ni­sa­to­risch genutz­ten Code“ zu schrei­ben, sieht man oft Bei­spiele, wo der Zugriff auf Ele­mente in einer Liste schief geht. Zum Bei­spiel: customer_email = response[„data“][0][„custom_attributes“][-1][„email“]
  • Es gibt 2 Return-Anwei­sun­gen – das ist teil­weise nicht dekla­ra­tiv und erhöht die Kom­ple­xi­tät des Codes (und schränkt mög­li­cher­weise die Erwei­ter­bar­keit ein).
  • Das Lis­ten­ver­ständ­nis kopiert im Wesent­li­chen die Liste und kann daher sehr spei­cher­in­ten­siv sein. Es sei denn, wir ope­rie­ren an Ort und Stelle (my_vals = [x for x in my_vals]), was zu Pro­ble­men beim Über­schrei­ben der ursprüng­li­chen Liste füh­ren kann. Wir soll­ten dies also vermeiden.
  • Der Zugriff auf das erste Ele­ment in der Liste list[0] ist nicht-dekla­ra­tiv » das heißt, Ihr Pro­gramm hat keine Garan­tie, was das Attri­but sein wird, bis es es bekommt. Das ist für Python die meiste Zeit in Ord­nung. Aber wenn man dazu über­geht, mehr „orga­ni­sa­to­risch genutz­ten Code“ zu schrei­ben, sieht man oft Bei­spiele, wo der Zugriff auf Ele­mente in einer Liste schief geht. Zum Bei­spiel: customer_email = response[„data“][0][„custom_attributes“][-1][„email“]
  • Es gibt 2 Return-Anwei­sun­gen – das ist teil­weise nicht dekla­ra­tiv und erhöht die Kom­ple­xi­tät des Codes (und schränkt mög­li­cher­weise die Erwei­ter­bar­keit ein).

Lösung 2: Schlei­fen­aus­wer­tung [Ein­fa­che Lösung]
Wir kön­nen also die Funk­tion so ändern, dass sie durch die Liste ite­riert, 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

Das ist nicht schlecht und würde die meis­ten Code-Reviews bestehen. ⭐️

Aber es gibt noch Unzulänglichkeiten:

  • Der Code ist unüber­sicht­lich und würde nicht von Vek­to­ri­sie­rung profitieren 🚫 🚀
  • Der Code ist nicht dekla­ra­tiv – unser Com­pi­ler ist traurig. 😢
  • Ähn­lich wie bei Lösung 1 oben gibt es 2 Return-Anwei­sun­gen. Ich möchte, dass es nur 1 gibt.

Was aber, wenn Sie Ihren Code auf die nächste Stufe brin­gen wollen? 💡

Lösung 3: Fil­tern mit einem Gene­ra­tor [Schwie­rige Lösung]
Dyna­mi­sches Laden von Wer­ten mit Hilfe der in Python ein­ge­bau­ten Fil­ter­funk­tion, die einen Gene­ra­tor erzeugt, mit dem wir dyna­misch auf jede Kom­po­nente zugrei­fen und sie aus­wer­ten können:

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)

Was sind die Vor­teile die­ser Vorgehensweise?

  • Der Fil­ter­ope­ra­tor ist ein Generator/Iterator, d. h., er wer­tet nur die Ele­mente aus, die er benö­tigt. Da wir die nächste Funk­tion ver­wen­den, wird es im Grunde Lazy Load.
  • Die par­ti­elle Funk­tion ermög­licht es uns, dyna­misch die schnellste Python-Aus­wer­tung auf den Wert anzu­wen­den » ist nicht None » sonst, wenn wir etwas wie [x for x in my_list if x] ver­wen­den, wer­den 0’s ausgeschlossen.
  • Die nächste Funk­tion holt das nächste Ele­ment aus dem Itera­tor. Der Spei­cher explo­diert nicht, da wir immer nur 1 Wert auf ein­mal erhal­ten. Die Stan­dard­ein­stel­lung ist expli­zit fest­ge­legt, andern­falls wird ein Sto­pI­te­ra­tion aus­ge­löst, sobald der Itera­tor erschöpft ist.
  • Die dekla­ra­tive Natur ermög­licht die Vek­to­ri­sie­rung 🚀 (und Kompilierungsverbesserungen).
  • Ermög­licht auch eine Just-in-Time-Kom­pi­lie­rung, wenn wir für wei­tere Opti­mie­run­gen erwei­tern wollen.

Was ist Vek­to­ri­sie­rung?
Schön, dass ich Ihr Inter­esse geweckt habe! Ich werde es ein ande­res Mal genauer erklä­ren. In der Zwi­schen­zeit kön­nen Sie hier ein wenig dar­über lesen: Vek­to­ri­sie­rung: Ein Schlüs­sel­werk­zeug zur Leis­tungs­stei­ge­rung auf moder­nen CPUs

IV. ERWEITERUNG UNSERER LÖSUNG
Den ers­ten nicht lee­ren Wert aus einem Wör­ter­buch ermitteln.

Das erste Ele­ment einer Liste zu ermit­teln ist recht ein­fach. Aber wie wäre es, den ers­ten nicht lee­ren Wert aus einem Wör­ter­buch zu ermit­teln, das auf einer Reihe von Schlüs­seln basiert?

Neh­men wir zum Bei­spiel das fol­gende Dokument:

{
  "Schlüssel": {
    "feld_1": "eins",
    "feld_2": "two" 
  }
}

Ange­nom­men, Sie wol­len den Wert für field_1 abru­fen, es sei denn, es exis­tiert nicht, dann holen Sie den Wert von field_2 (es sei denn, es exis­tiert eben­falls nicht), ansons­ten geben Sie einen Stan­dard­wert zurück.

Da unsere dritte Lösung get_first_non_null_generator() einen belie­bi­gen Itera­tor annimmt, kön­nen wir einen Map­per erstel­len, der unser Doku­ment an Nach­schla­ge­schlüs­sel bin­det, und in unse­rer Funk­tion 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"

Tie­fer gehend:
Hier ist ein etwas län­ge­res Bei­spiel (das dem Anwen­dungs­fall, den ich beim Schrei­ben die­ses Codes hatte, näher kommt):

# 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

Noch tie­fer gehend:
Hier ist ein wirk­lich aus­ge­klü­gel­ter Anwen­dungs­fall für den Zugriff auf Klas­sen­at­tri­bute mit par­ti­el­len Funk­tio­nen 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

Sehr oft soll der Code aktu­elle und zukünf­tige Pro­bleme lösen. Mein Ziel beim Schrei­ben die­ser fort­ge­schrit­te­nen (und etwas selt­sa­men) Fälle ist es, Sie dazu zu brin­gen, über die mög­li­chen Ver­wen­dungs­zwe­cke für den von Ihnen geschrie­be­nen Code nach­zu­den­ken. Ist die Vek­to­ri­sie­rung ein Pro­blem für die Zukunft? Ist es die mensch­li­che Les­bar­keit? Wer­den Sie eine Schnitt­stelle zu einem selt­sa­men Ding haben, das stark erwei­tert wer­den muss? Oder müs­sen Sie ein­fach nur ein Ele­ment aus einer Liste zurückgeben?

Berück­sich­ti­gen Sie mög­lichst viele die­ser Fak­to­ren, wenn Sie Ihren Code im Vor­aus schrei­ben, und doku­men­tie­ren Sie Ihre Annah­men. (Es ist in Ord­nung, Abkür­zun­gen zu neh­men! Solange Sie es Ihrem zukünf­ti­gen Ich in der Doku­men­ta­tion mitteilen.)

V. NACHDENKEN ÜBER LÖSUNGEN
Ein gro­ßer Teil der Arbeit eines lei­ten­den Ent­wick­lers besteht darin, wie man über Pro­bleme denkt. Die meis­ten Pro­bleme, mit denen Daten­teams (und Soft­ware­teams) kon­fron­tiert wer­den, sind eine Kom­bi­na­tion aus tech­ni­schen und orga­ni­sa­to­ri­schen Belangen.

Zur Ver­an­schau­li­chung: In unse­rem Fall schrei­ben wir einen Code, um den ers­ten Nicht-Null-Wert in einer Liste zu fin­den. Im Laufe der Zeit wird unsere Lösung jedoch auch von ande­ren Teams ver­wen­det wer­den, die unsere Lösung auf unter­schied­li­che Weise nut­zen wer­den. Zum Bei­spiel könnte jemand ver­su­chen, den ers­ten Nicht-Null-Wert in einem Wör­ter­buch zu fin­den, das eine Liste von Schlüs­seln ent­hält. Das ist nicht unbe­dingt eine schlechte Sache. Es ist unver­meid­lich, dass Ent­wick­ler Ihren Code auf eine Art und Weise ver­wen­den, die Sie beim Schrei­ben nicht vor­aus­ge­se­hen haben.

Wenn man nicht ein­greift, wird die Kom­ple­xi­tät einer Code­ba­sis mit der Zeit zwangs­läu­fig zuneh­men. Wenn die Kom­ple­xi­tät zu groß wird, ent­steht ein gro­ßer Schlamm­ball. Wie kön­nen Sie also den zukünf­ti­gen Zustand Ihrer Code­ba­sis schützen?

Wenn unsere Orga­ni­sa­tion klein wäre: Wir kön­nen einen Kom­men­tar im Code hin­ter­las­sen, der besagt: #Die­ser Code funk­tio­niert nur mit fla­chen Lis­ten. Kon­tak­tie­ren Sie YBress­ler, wenn Sie Pro­bleme haben

Mit ande­ren Wor­ten: Tren­nen Sie die tech­ni­schen und orga­ni­sa­to­ri­schen Belange und lösen Sie jedes Pro­blem sepa­rat. (Tech­nisch = Code schrei­ben. Orga­ni­sa­to­risch = einen Kom­men­tar hinterlassen.)

Ehr­lich gesagt, ist dies eine groß­ar­tige Lösung, wenn Ihr Team klein ist. Aber sobald eine Orga­ni­sa­tion eine gewisse Größe erreicht oder Leute die Orga­ni­sa­tion ver­las­sen, wird diese Lösung problematisch.

Eine bes­sere Lösung berück­sich­tigt die Belange des Lebens­zy­klus der Soft­ware­ent­wick­lung. Dies bedeu­tet in der Regel, dass Ihr Code ein­fach, leicht zu tes­ten und leis­tungs­fä­hig sein muss. Diese Art von Code ermög­licht zukünf­ti­gen Ent­wick­lern ein ein­fa­ches Refac­to­ring, so dass sie Ihre ursprüng­li­che Lösung für spä­tere Anfor­de­run­gen wie­der­ver­wen­den und erwei­tern kön­nen, ohne die Kom­ple­xi­tät der Code­ba­sis zu erhöhen.

Mit ande­ren Wor­ten: Unser Code sollte sowohl die tech­ni­schen als auch die orga­ni­sa­to­ri­schen Pro­bleme lösen. Tech­nisch = der Code funk­tio­niert. Orga­ni­sa­to­risch = der Code ist leicht zu ver­ste­hen und lässt sich leicht umgestalten.

Bei unse­ren Code­bei­spie­len geht es mir natür­lich um die Leis­tung einer Lösung. Genauso wich­tig ist mir aber auch, wie zukünf­tige Ent­wick­ler mit die­ser Lösung umge­hen werden.

Wenn eine Code­lö­sung nicht leicht zu ver­ste­hen ist (hoher Grad an Kom­ple­xi­tät), wer­den sich die Leute scheuen, Ände­run­gen daran vor­zu­neh­men. Sie wer­den sie ent­we­der auf eine noch kom­ple­xere Art und Weise ver­wen­den oder mehr Code erstel­len, was die Kom­ple­xi­tät der Code­ba­sis eben­falls erhöht.

VI. SCHLUSSFOLGERUNG:
Zusam­men­fas­send lässt sich sagen, dass erfah­rene Daten­in­ge­nieure [ver­su­chen], Code zu schrei­ben, der leicht zu ver­ste­hen und leis­tungs­stark ist, aber vor allem zukünf­tige Pro­bleme löst, indem er die Kom­ple­xi­tät einer Code­ba­sis reduziert.

Zur Ver­an­schau­li­chung: Die schnelle Lösung get_first_non_null_generator() ist cle­ver, leicht zu lesen und per­for­mant. Vor allem aber zielt sie dar­auf ab, die Kom­ple­xi­tät einer Code­ba­sis zu reduzieren.

Quelle: medium.com

Erfah­ren Sie hier mehr über Lösun­gen im Bereich Data Engi­nee­ring oder besu­chen Sie eines unse­rer kos­ten­lo­sen Web­i­nare.