Come scrivere codice come un Senior Data Engineer

Dec 16 2022
Scrivere codice ad alte prestazioni senza creare più problemi per te stesso in futuro. Congratulazioni! Se stai leggendo questo, probabilmente vorrai migliorare nella scrittura di codice per l'ingegneria dei dati.

Scrivere codice ad alte prestazioni senza creare più problemi per te stesso in futuro.

Congratulazioni!

Se stai leggendo questo, probabilmente vorrai migliorare nella scrittura di codice per l'ingegneria dei dati. In questa guida, ti mostrerò come mi avvicino alla scrittura del codice come mezzo per risolvere i problemi.

Un po 'di me

Sono un ingegnere dati senior presso Headspace Health e mi occupo di ingegneria dei dati da oltre 5 anni. Maggiori informazioni sul mio sito personale .

I. CONCETTI DI BASE

In questo articolo, mi riferirò al codice come dichiarativo o non dichiarativo (imperativo) :

  • Il codice imperativo (non dichiarativo) indica al compilatore cosa fa un programma, passo dopo passo. Il compilatore non può saltare i passaggi, poiché ogni passaggio dipende completamente dal passaggio precedente.
  • Il codice dichiarativo dice al tuo compilatore quale dovrebbe essere lo stato desiderato di un programma, astraendo i passaggi su come raggiungerlo. Il compilatore può saltare passaggi o combinarli, poiché tutto ciò può determinare in anticipo tutti gli stati.

I compilatori moderni hanno tutti i tipi di trucchi per rendere il codice più veloce ed efficiente su hardware moderno. Più un compilatore è in grado di prevedere lo stato di un programma, più "trucchi" può impiegare, con il risultato di un minor numero di istruzioni e di significativi vantaggi in termini di prestazioni.

Di seguito è riportato un diagramma dell'architettura dell'interfaccia di un compilatore con un'unità di elaborazione (componente hardware). Fatti aiutare dal compilatore! Dagli istruzioni più semplici e più prevedibili.

Architettura hardware per un'unità di elaborazione. (credito: Science Direct)

Per riassumere: il codice dichiarativo sfrutta l'abilità dei compilatori moderni e si traduce in prestazioni più elevate.

Ok, iniziamo a scrivere un po' di codice!

II. DICHIARAZIONE PROBLEMA

Hai un elenco di things, forse l'elenco è vuoto, forse contiene milioni di elementi e ti serve il primo non-nullvalore:

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

  • La funzione dovrebbe restituire risultati accurati e non discriminare 0'so stringhe vuote "".
  • Le prestazioni della soluzione non dovrebbero essere lente. Probabilmente è ragionevole mirare a min = O(1), max = O(k), dove kè la dimensione dell'elenco
  • >> 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])
    ""
    

Soluzione 1: Comprensione dell'elenco [Soluzione semplice]

Sono sicuro che ogni ingegnere di dati ha risolto questo problema dozzine di volte. Fagioli facili! Scorri l'elenco e vedi quali elementi non sono nulli, quindi restituisci il primo valore:

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

  • Devi operare su ogni elemento della lista. Questo può essere lento se la tua lista è ENORME
  • La comprensione dell'elenco essenzialmente copia l'elenco, quindi potrebbe richiedere un'intensa memoria. A meno che non operiamo in place ( my_vals = [x for x in my_vals]) che potrebbe introdurre problemi con la sovrascrittura dell'elenco originale. Quindi dovremmo evitare di farlo.
  • L'accesso al primo elemento nell'elenco non list[0]è dichiarativo >> significa che il tuo programma non ha alcuna garanzia su quale sarà l'attributo, finché non lo ottiene. Questo va bene per Python, la maggior parte delle volte. Ma man mano che scrivi più "codice utilizzato a livello organizzativo", tendi a vedere esempi in cui l'accesso agli elementi in un elenco va storto. Ad esempio: customer_email = response["data"][0]["custom_attributes"][-1]["email"]<< EEEEEK!
  • Ci sono 2 istruzioni return — questo è parzialmente non dichiarativo e aumenta la complessità del codice (potenzialmente limitando l'estensibilità).

Quindi possiamo cambiare la funzione per scorrere l'elenco senza elaborare tutti i valori:

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

Ma ci sono ancora dei difetti:

  • Il codice è pazzo e non trarrebbe vantaggio dalla vettorizzazione
  • Il codice non è dichiarativo: il nostro compilatore è triste.
  • Simile alla soluzione 1 sopra, ci sono 2 istruzioni return. Voglio che ce ne sia solo 1.

Soluzione 3: filtrare utilizzando un generatore [soluzione difficile]

Carica dinamicamente i valori utilizzando la filterfunzione incorporata di Python che crea un generatore che ci consente di accedere e valutare dinamicamente ogni componente:

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)

  • L' filteroperatore è un generatore/iteratore, nel senso che valuta solo gli elementi di cui ha bisogno. Dal momento che stiamo usando la nextfunzione, sarà sostanzialmente un carico pigro.
  • La partialfunzione ci consente di applicare dinamicamente la valutazione python più veloce al valore >> is not None>> altrimenti, se usiamo qualcosa come [x for x in my_list if x]allora 0'sverrà escluso.
  • La nextfunzione ottiene l'elemento successivo dall'iteratore. La memoria non esplode, poiché otteniamo solo 1 valore alla volta. Il default è impostato in modo esplicito, altrimenti questo solleverà una StopIterationvolta che l'iteratore è esaurito.
  • La natura dichiarativa consente la vettorializzazione (e miglioramenti della compilazione).
  • Consente anche la compilazione just in time , se vogliamo estendere per un'ulteriore ottimizzazione.

Felice di aver suscitato il tuo interesse! Spiegherò in dettaglio un'altra volta. Nel frattempo, puoi leggere qualcosa a riguardo qui: Vettorizzazione: uno strumento chiave per migliorare le prestazioni delle moderne CPU

IV. ESTENDERE LA NOSTRA SOLUZIONE

Ottenere il primo valore non vuoto da un dizionario.

Ottenere il primo elemento di un elenco è piuttosto semplice. Ma che ne dici di ottenere il primo valore non vuoto da un dizionario, basato su un set di chiavi?

Ad esempio, prendi il seguente documento:

{
  "key": {
    "field_1": "one",
    "field_2": "two" 
  }
}

Poiché la nostra terza soluzione get_first_non_null_generator()accetta qualsiasi iteratore, possiamo creare un mapperfile che lega il nostro documento alle chiavi di ricerca e utilizzare nella nostra funzione in questo modo:

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"

Ecco un esempio leggermente più lungo (che ricorda più da vicino il caso d'uso che ho avuto per scrivere questo codice):

# 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

Ecco un caso d'uso davvero sofisticato di accesso agli attributi di classe utilizzando funzioni parziali e mappatori:

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

Considera tutti questi fattori quando scrivi il tuo codice in anticipo e documenta le tue ipotesi. (Va bene prendere scorciatoie! Finché dici a te stesso futuro nella documentazione.)

V. PENSARE ALLE SOLUZIONI

Gran parte dell'essere uno sviluppatore senior è il modo in cui pensi ai problemi. La maggior parte dei problemi che i team di dati (e i team di software) devono affrontare sono una combinazione di preoccupazioni tecniche e organizzative.

Per dimostrare, nel nostro caso, stiamo scrivendo codice per trovare il primo non-nullvalore in un elenco. Ma nel tempo, la nostra soluzione verrà utilizzata da altri team che utilizzeranno la nostra soluzione in modi diversi. Ad esempio, qualcuno potrebbe provare a trovare il primo non-nullvalore in un dizionario, dato un elenco di chiavi. Questo non è necessariamente un male. È inevitabile che gli sviluppatori utilizzino il tuo codice in modi che non avevi previsto quando lo hai scritto per la prima volta.

Senza intervento, è garantito che la complessità di una base di codice aumenti nel tempo. Se la complessità diventa eccessiva, creerai una grossa palla di fango . Sapendo questo, come puoi proteggere lo stato futuro della tua base di codice?

Se la nostra organizzazione fosse piccola: possiamo lasciare un commento nel codice dicendo:#This code only works with flat lists. Contact YBressler if you have problems

In altre parole, utilizzare la disunione delle preoccupazioni tecniche e organizzative e risolverle separatamente. (Tecnico = scrivere codice. Organizzativo = lasciare un commento.)

Onestamente, questa è un'ottima soluzione se la tua squadra è piccola. Ma una volta che un'organizzazione raggiunge una certa dimensione o le persone lasciano l'organizzazione, questa soluzione diventa problematica.

Una soluzione migliore prende in considerazione le preoccupazioni del ciclo di vita dello sviluppo del software. Questo di solito significa garantire che il tuo codice sia semplice, facile da testare e performante. Questo tipo di codice consentirà ai futuri sviluppatori di eseguire facilmente il refactoring, consentendo loro di riutilizzare ed estendere la soluzione originale alle esigenze successive senza aumentare la complessità della base di codice.

In altre parole, il nostro codice dovrebbe risolvere sia i problemi tecnici che quelli organizzativi. Tecnico = il codice funziona. Organizzativo = il codice è facile da capire e può essere refactored con facilità.

Nei nostri esempi di codice, sono certamente interessato alle prestazioni di una soluzione. Sono altrettanto preoccupato di come i futuri sviluppatori interagiranno con questa soluzione.

Se una soluzione di codice non è facile da capire (alto grado di complessità), le persone avranno paura di apportarvi modifiche. Lo useranno in un modo ancora più complesso o creeranno più codice, il che aumenta anche la complessità di una base di codice.

VI. CONCLUSIONE:

In conclusione, i data engineer senior [cercano di] scrivere codice che sia facile da capire, ad alte prestazioni, ma soprattutto, risolva i problemi futuri riducendo la complessità di una base di codice.

Come punto dimostrativo sopra, la soluzione veloce get_first_non_null_generator() è intelligente, facile da leggere e performante. Ancora più importante, mira a ridurre la complessità in una base di codice.

Riferimenti:

  1. Farley, D. (2022). In Modern Software Engineering: fare ciò che funziona per creare software migliore più velocemente (p. 128), Addison-Wesley.