Cara menulis kode seperti Senior Data Engineer

Dec 16 2022
Menulis kode berkinerja tinggi tanpa menimbulkan lebih banyak masalah bagi diri Anda sendiri di masa mendatang. Selamat! Jika Anda membaca ini, Anda mungkin ingin menjadi lebih baik dalam menulis kode untuk rekayasa data.

Menulis kode berkinerja tinggi tanpa menimbulkan lebih banyak masalah bagi diri Anda sendiri di masa mendatang.

Selamat!

Jika Anda membaca ini, Anda mungkin ingin menjadi lebih baik dalam menulis kode untuk rekayasa data. Dalam panduan ini, saya akan menunjukkan kepada Anda bagaimana pendekatan saya menulis kode sebagai sarana pemecahan masalah.

Sedikit tentang saya

Saya seorang Insinyur Data Senior di Headspace Health dan telah mempraktikkan rekayasa data selama 5+ tahun. Lebih lanjut di situs web pribadi saya .

I. KONSEP LATAR BELAKANG

Sepanjang artikel ini, saya akan mengacu pada kode sebagai deklaratif vs. non-deklaratif (imperatif) :

  • Kode imperatif (non-deklaratif) memberi tahu kompiler Anda apa yang dilakukan program, langkah demi langkah. Kompiler tidak dapat melewati langkah-langkah, karena setiap langkah sepenuhnya bergantung pada langkah sebelumnya.
  • Kode deklaratif memberi tahu kompiler Anda apa yang seharusnya menjadi keadaan program yang diinginkan, mengabstraksi langkah-langkah cara mencapainya. Kompiler dapat melompati langkah-langkah atau menggabungkannya, karena semua itu dapat menentukan sebelumnya semua status.

Kompiler modern memiliki segala macam trik untuk membuat kode berjalan lebih cepat dan lebih efisien pada perangkat keras modern. Semakin kompiler dapat memprediksi keadaan program, semakin banyak "trik" yang dapat digunakannya, menghasilkan lebih sedikit instruksi dan manfaat kinerja yang signifikan.

Di bawah ini adalah diagram arsitektur antarmuka kompiler dengan unit pemrosesan (perangkat keras). Biarkan kompiler membantu Anda! Berikan instruksi yang lebih sederhana dan lebih mudah diprediksi.

Arsitektur perangkat keras untuk unit pemrosesan. (kredit: Science Direct)

Singkatnya: Kode deklaratif memanfaatkan kemampuan kompiler modern dan menghasilkan kinerja yang lebih tinggi.

Oke, mari kita menulis beberapa kode!

II. PERNYATAAN MASALAH

Anda memiliki daftar things, mungkin daftarnya kosong, mungkin ada jutaan item, dan Anda memerlukan nilai pertama non-null:

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

  • Fungsi harus mengembalikan hasil yang akurat — dan tidak mendiskriminasi 0'satau mengosongkan string "".
  • Performa solusi tidak boleh lambat. Mungkin masuk akal untuk membidik min = O(1), max = O(k), di mana kukuran daftarnya
  • >> 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])
    ""
    

Solusi 1: Pemahaman Daftar [Solusi Sederhana]

Saya yakin setiap insinyur data telah memecahkan masalah ini puluhan kali. Kacang mudah! Ulangi daftar dan lihat item mana yang bukan nol, lalu kembalikan nilai pertama:

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

  • Anda perlu mengoperasikan setiap elemen dalam daftar. Ini bisa lambat jika daftar Anda MASSIVE
  • Pemahaman daftar pada dasarnya menyalin daftar tersebut, jadi ini mungkin membutuhkan banyak memori. Kecuali jika kami beroperasi di tempat ( my_vals = [x for x in my_vals]) yang dapat menimbulkan masalah dengan menimpa daftar asli. Jadi kita harus menghindari melakukan itu.
  • Mengakses elemen pertama dalam daftar list[0]adalah non-deklaratif >> artinya, program Anda tidak memiliki jaminan akan seperti apa atributnya, sampai ia mendapatkannya. Ini OK untuk python, sebagian besar waktu. Tetapi ketika Anda mulai menulis lebih banyak "kode yang digunakan organisasi", Anda cenderung melihat contoh di mana mengakses elemen dalam daftar menjadi serba salah. Misalnya: customer_email = response["data"][0]["custom_attributes"][-1]["email"]<<EEEEEK!
  • Ada 2 pernyataan pengembalian — ini sebagian non-deklaratif dan meningkatkan kompleksitas kode (berpotensi membatasi perpanjangan).

Jadi kita dapat mengubah fungsi untuk beralih melalui daftar tanpa memproses semua nilai:

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

Tapi masih ada kekurangannya:

  • Kodenya gila dan tidak akan mendapat manfaat dari vektorisasi
  • Kode tidak bersifat deklaratif — kompiler kami sedih.
  • Mirip dengan solusi 1 di atas, ada 2 pernyataan pengembalian. Saya ingin hanya ada 1.

Solusi 3: Filter menggunakan Generator [Hard Solution]

Memuat nilai secara dinamis menggunakan filterfungsi bawaan python yang membuat generator yang memungkinkan kita mengakses dan mengevaluasi setiap komponen secara dinamis:

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)

  • Operator adalah generator/iterator filter, artinya hanya mengevaluasi item yang diperlukan. Karena kita menggunakan nextfungsi tersebut, pada dasarnya akan memuat dengan malas.
  • Fungsi ini memungkinkan kita untuk partialsecara dinamis menerapkan evaluasi python tercepat ke nilai >> is not None>> jika tidak, jika kita menggunakan sesuatu seperti [x for x in my_list if x]itu maka 0'sakan dikecualikan.
  • Fungsi mendapatkan item berikutnya dari iterator next. Memori tidak meledak, karena kita hanya mendapatkan 1 nilai dalam satu waktu. Ini default diatur secara eksplisit, jika tidak, ini akan memunculkan a StopIterationsetelah iterator habis.
  • Sifat deklaratif memungkinkan untuk vektorisasi (dan peningkatan kompilasi).
  • Juga memungkinkan untuk kompilasi tepat waktu juga, jika kami ingin memperluas untuk pengoptimalan lebih lanjut.

Senang saya mendapatkan minat Anda! Saya akan menjelaskan secara rinci lain kali. Sementara itu, Anda dapat membaca sedikit tentangnya di sini: Vektorisasi: Alat Utama Untuk Meningkatkan Performa Pada CPU Modern

IV. MEMPERLUAS SOLUSI KAMI

Mendapatkan nilai tidak kosong pertama dari kamus.

Mendapatkan item pertama dari daftar agak mudah. Tapi bagaimana dengan mendapatkan nilai tidak kosong pertama dari kamus, berdasarkan sekumpulan kunci?

Misalnya, ambil dokumen berikut:

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

Karena solusi ke-3 kami get_first_non_null_generator()menggunakan iterator apa pun, kami dapat membuat mapperyang mengikat dokumen kami ke kunci pencarian, dan menggunakan fungsi kami seperti ini:

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"

Berikut adalah contoh yang sedikit lebih panjang (yang lebih mirip dengan kasus penggunaan yang saya miliki untuk menulis kode ini):

# 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

Inilah kasus penggunaan yang sangat canggih untuk mengakses atribut kelas menggunakan fungsi parsial dan pembuat peta:

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

Pertimbangkan sebanyak mungkin faktor ini saat menulis kode Anda di muka, dan dokumentasikan asumsi Anda. (Tidak apa-apa untuk mengambil jalan pintas! Selama Anda memberi tahu diri Anda di masa depan dalam dokumentasi.)

V. BERPIKIR TENTANG SOLUSI

Sebagian besar menjadi pengembang senior adalah cara Anda memikirkan masalah. Sebagian besar masalah yang dihadapi tim data (dan tim perangkat lunak) adalah kombinasi dari masalah teknis dan organisasi.

Untuk menunjukkan, dalam kasus kami, kami menulis kode untuk menemukan nilai pertama non-nulldalam daftar. Namun seiring berjalannya waktu, solusi kami akan digunakan oleh tim lain yang akan menggunakan solusi kami dengan cara yang berbeda. Misalnya, seseorang mungkin mencoba menemukan nilai pertama non-nulldalam kamus, dengan memberikan daftar kunci. Ini belum tentu merupakan hal yang buruk. Tidak dapat dipungkiri bahwa pengembang akan menggunakan kode Anda dengan cara yang tidak Anda antisipasi saat pertama kali menulisnya.

Tanpa intervensi, kompleksitas basis kode dijamin akan meningkat seiring waktu. Jika kerumitannya terlalu parah, Anda akan membuat bola lumpur yang besar . Mengetahui hal ini, bagaimana Anda bisa melindungi status masa depan basis kode Anda?

Jika organisasi kami kecil: kami dapat memberikan komentar dalam kode yang berbunyi:#This code only works with flat lists. Contact YBressler if you have problems

Dengan kata lain, gunakan pemisahan masalah teknis dan organisasi dan selesaikan masing-masing secara terpisah. (Teknis = tulis kode. Organisasi = tinggalkan komentar.)

Sejujurnya, ini adalah solusi yang bagus jika tim Anda kecil. Namun begitu sebuah organisasi mencapai ukuran tertentu, atau orang-orang meninggalkan organisasi tersebut, solusi ini menjadi bermasalah.

Solusi yang lebih baik mempertimbangkan masalah siklus hidup pengembangan perangkat lunak. Ini biasanya berarti memastikan bahwa kode Anda lugas, mudah diuji, dan berperforma tinggi. Jenis kode ini akan memungkinkan pengembang masa depan untuk melakukan refactor dengan mudah, memungkinkan mereka untuk menggunakan kembali dan memperluas solusi asli Anda untuk kebutuhan selanjutnya tanpa meningkatkan kompleksitas basis kode.

Dengan kata lain, kode kita harus menyelesaikan masalah teknis dan organisasi. Teknis = kode berfungsi. Organizational = kode mudah dipahami dan dapat di-refactor dengan mudah.

Dalam contoh kode kami, saya tentu saja memperhatikan kinerja suatu solusi. Saya sama prihatinnya dengan bagaimana pengembang masa depan akan berinteraksi dengan solusi ini.

Jika solusi kode tidak mudah dipahami (tingkat kerumitan tinggi), orang akan takut untuk mengubahnya. Mereka akan menggunakannya dengan cara yang lebih kompleks, atau membuat lebih banyak kode, yang juga menambah kompleksitas basis kode.

VI. KESIMPULAN:

Kesimpulannya, insinyur data senior [mencoba] menulis kode yang mudah dipahami, berkinerja tinggi, tetapi yang terpenting, memecahkan masalah di masa depan dengan mengurangi kompleksitas basis kode.

Sebagai demonstrasi di atas, solusi cepatnya get_first_non_null_generator() cerdas, mudah dibaca, dan berkinerja. Yang terpenting, ini bertujuan untuk mengurangi kompleksitas dalam basis kode.

Referensi:

  1. Farley, D. (2022). Dalam Rekayasa Perangkat Lunak Modern: Melakukan apa yang berhasil untuk membangun perangkat lunak yang lebih baik dengan lebih cepat (p. 128), Addison-Wesley.