1 poin oleh GN⁺ 4 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • Pemeriksaan nil di Go dapat mencegah panic, tetapi jika pemeriksaan berulang dilakukan di tempat yang salah, kode tidak bisa menjelaskan sendiri “apa yang mungkin nil”
  • Jika dependensi wajib seperti klien Redis diperiksa di dalam method internal, kegagalan pembuatan akan diperlakukan seperti jalur eksekusi normal
  • Menyaring nil di konstruktor saja tidak cukup; kegagalan harus segera ditangani di titik inisialisasi seperti NewRedisClient(addr)
  • Nilai yang masuk dari luar, seperti objek request, harus divalidasi di lapisan batas seperti HTTP handler, RPC dispatch, atau queue consumer, sementara logika internal harus memercayai jaminan itu
  • Jika keadaan yang seharusnya mustahil dibiarkan diam-diam, kegagalan menjadi senyap, tertunda, dan ambigu, lalu muncul biaya untuk memulihkan sinyal yang hilang lewat metrik, dashboard, dan alert

Pemeriksaan nil tidak selalu berarti defensive programming

  • Untuk mencegah panic di produksi, diperlukan defensive programming yang memeriksa input, rentang, dan pointer sebelum deferred recover
  • Pemeriksaan nil di tempat yang tepat membuat kode aman, tetapi pemeriksaan di tempat yang salah menjadi sinyal bahwa kode tidak bisa melacak nilai mana yang mungkin nil
  • Pola seperti ini lebih sering terlihat pada kode yang dihasilkan, tetapi ini bukan fenomena baru dan tidak terbatas pada AI
  • Pemeriksaan nil terlihat murah dan aman, tetapi meninggalkan pesan kepada pembaca berikutnya bahwa “nilai ini bisa nil”, dan sering menyampaikan makna yang keliru

Masalah pemeriksaan nil pada dependensi

  • Kode di mana RateLimiter memiliki *redis.Client sebagai field dan memeriksa r.redis != nil di dalam Allow sekilas tampak aman
  • Jika klien Redis bernilai nil, masalahnya sudah terjadi pada saat pembuatan, bukan saat Allow dijalankan
  • Memeriksa nil di method internal membuat keadaan gagal dibuat tetap diperlakukan seolah-olah itu keadaan yang dapat diterima untuk terus berjalan
  • Pemeriksaan seperti ini adalah sinyal bahwa kode telah kehilangan asal objek, tanggung jawab inisialisasi, dan invarian bahwa nil seharusnya mustahil

Pemeriksaan nil di konstruktor saja tidak cukup

  • Mengembalikan error ketika client == nil di NewRateLimiter(client *redis.Client) memang lebih baik, tetapi bukan solusi lengkap
  • Fakta bahwa pointer nil sudah diteruskan sampai ke fungsi berarti keadaan yang salah sudah masuk ke sistem
  • Error sebenarnya harus ditangani di titik inisialisasi yang membuat klien Redis
    • Jika terjadi error pada redisClient, err := NewRedisClient(addr), harus segera return
    • Setelah itu, hanya klien yang valid yang boleh diteruskan ke NewRateLimiter(redisClient)
  • Dengan begitu, konstruktor RateLimiter juga tidak perlu mengembalikan error
  • Jika repository harus mengizinkan keadaan sementara tidak tersedia, jangan teruskan nil; bungkus dengan tipe eksternal yang selalu non-nil, lalu enkapsulasi retry atau penurunan performa di dalamnya
  • Ini mirip dengan constraint NOT NULL atau foreign key pada database
    • Jika baris yang salah sejak awal tidak bisa ada, semua query tidak perlu memeriksa ulang datanya
    • Nilai runtime juga dapat menghindari pemeriksaan berulang di sisa kode setelah invarian ditegakkan sekali

Biaya kegagalan yang senyap

  • Pendekatan hanya melakukan pemeriksaan nil atau meninggalkan log karena tidak ingin perubahan kecil menghentikan program bisa terasa stabil
  • Pilihan sebenarnya lebih dekat ke gagal dengan suara keras vs gagal secara senyap daripada “crash vs terus berjalan”
  • Error yang dikembalikan secara eksplisit memiliki tiga sifat
    • Jelas: dapat diketahui bahwa kegagalan terjadi
    • Segera: kegagalan diketahui dekat dengan penyebabnya
    • Dapat diatribusikan: caller dapat mengaitkan kegagalan dengan pekerjaan tersebut
  • Error yang ditelan bekerja sebaliknya
    • Kegagalan hilang secara senyap
    • Lebih banyak kode berjalan, lalu baru muncul sebagai gejala di kemudian hari
    • Saat gejala terlihat, penyebabnya sulit diidentifikasi
  • Semakin banyak pemanggilan yang membuat program bertahan dalam keadaan yang salah, semakin lebar pula jarak antara penyebab dan gejala
  • Perbaikan yang benar bukan menyembunyikan kegagalan secara lokal, melainkan memahami ke mana error dipropagasi dan di mana error berubah menjadi penolakan request, kegagalan pekerjaan, retry, alert, atau penghentian
  • Jika pengembalian error menghentikan lebih banyak bagian sistem daripada yang diperlukan, masalahnya bukan pada fungsi tersebut, melainkan pada batas penanganan error

Biaya sekunder untuk membuat ulang sinyal yang hilang

  • Jika kegagalan menjadi senyap, bug bisa bersembunyi karena kita tidak tahu apa yang sebenarnya terjadi
  • Akibatnya, perlu dibuat infrastruktur observabilitas seperti metrik, dashboard, dan alert untuk mendeteksi ketiadaan perilaku
  • Setiap kali keadaan yang mustahil atau tidak tertangani dibiarkan, kita membayar biaya engineering untuk memulihkan sinyal yang dibuang lewat observabilitas di kemudian hari

Peran lapisan eksternal dan internal

  • Tempat eksekusi dimulai dan data eksternal masuk adalah lapisan eksternal, sedangkan kode yang lebih dalam yang dicapai oleh pemanggilan itu adalah lapisan internal
  • Di awal eksekusi, belum ada yang dijamin, tetapi juga belum ada pekerjaan yang dilakukan
  • Dalam proses inisialisasi, program harus menyiapkan elemen yang menjadi dependensinya dan memutuskan apakah tiap elemen wajib ada atau bisa hilang sementara
  • Desain harus selalu condong ke dependensi yang tersedia, dan meminimalkan dependensi yang bisa hilang di tengah jalan

Data dalam cakupan request harus divalidasi di batas

  • Objek request, field request, dan nilai turunan dari request berbeda dari dependensi tetap
  • Request masuk pada setiap pemanggilan dari luar, seperti HTTP handler, RPC, queue, test helper, atau package lain
  • Memeriksa req == nil di dalam RateLimiter.Allow(ctx, req) juga merupakan kesalahan yang sama seperti pemeriksaan nil pada dependensi
  • Request tidak pertama kali masuk di Allow, melainkan masuk dari batas transport yang lebih depan lalu berpindah di dalam kode
  • Jika fungsi internal seperti Allow memvalidasi ulang, fungsi yang dalam akan memeriksa kembali sesuatu yang seharusnya dijamin oleh lapisan eksternal, dan ketidakpastian menyebar

Setelah validasi batas, logika internal memercayai invarian

  • Pemeriksaan nil harus berada di titik batas tempat byte yang tidak tepercaya berubah menjadi tipe internal seperti *Request
  • Dalam contoh HTTP handler, jika DecodeRequest(r) gagal, respons diberikan dengan http.StatusBadRequest lalu return
  • Setelah validasi selesai, req adalah nilai yang valid, dan setelah itu h.limiter.Allow(r.Context(), req) dapat memercayai nilai tersebut
  • Karena data yang diterima dari luar tidak dapat dikendalikan, masuk akal untuk memeriksa nil dan constraint yang diperlukan di batas
  • Data yang melewati batas dipetakan ke tipe internal dan logika bisnis, lalu setelah itu menjadi invarian sistem
  • Allow final berfokus pada logika nyata tanpa pemeriksaan nil
    • userID := GetUserID(req)
    • Jika userID == "", return false, nil
    • Jika tidak, panggil r.checkLimit(ctx, userID)
  • Pemeriksaan userID kosong juga bisa dipindahkan ke lapisan HTTP, tetapi dalam contoh ini rate limiter dibiarkan memiliki kebijakan tersebut

Pemeriksaan nil berulang menciptakan cabang dan perilaku baru

  • Sistem dengan struktur seperti ini mudah dinalar dan mudah diubah
  • Sebaliknya, sistem tanpa invarian menambahkan pemeriksaan di mana-mana lalu harus menentukan apa yang harus dilakukan pada setiap pemeriksaan
  • Setiap pemeriksaan nil adalah cabang baru, dan setiap cabang membuat perilaku baru untuk keadaan yang seharusnya tidak ada
  • Pemeriksaan nil berguna ketika memaksakan batas yang terdokumentasi atau memodelkan keadaan opsional yang disengaja
  • Pemeriksaan nil yang diam-diam menangani keadaan yang dianggap mustahil oleh program patut dicurigai
  • Jika pemeriksaan nil muncul di banyak tempat, kemungkinannya salah satu dari dua hal
    • Kode normal yang melindungi input batas yang tidak tepercaya
    • Masalah desain karena codebase gagal menegakkan invarian
  • Dalam sistem di mana parameter apa pun tidak bisa dipercaya, pemeriksaan mungkin perlu segera ditambahkan, tetapi pekerjaan sebenarnya adalah menegakkan invarian yang sedang digantikan oleh pemeriksaan tersebut dan mengubahnya menjadi jaminan yang dapat dipercaya

1 komentar

 
GN⁺ 4 jam lalu
Opini di Lobste.rs
  • Sekali lagi meminta kepada para programmer Go lain: tolong wrap error

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    Konteks tentang error seharusnya terakumulasi saat call stack di-unwind

    • Contoh yang lebih idiomatis terlihat seperti ini
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      Setelah itu, tiap layer cukup menambahkan di mana error terjadi, sementara err paling dalam memberi tahu apa yang terjadi; struktur seperti itu lebih baik
    • Sayangnya tidak ada stack trace untuk error yang seragam dan secara de facto standar
      Dalam praktiknya, “wrapping” mudah berubah menjadi aktivitas grep string error, berharap string itu unik, lalu memaksa diri kreatif agar string tersebut menjadi unik
    • Ada juga yang mengeluh stack error terlalu panjang, tetapi kebanyakan orang menganggap pesan semacam ini bisa ditindaklanjuti dan berguna
      Dulu, di sebuah produk networking, seorang engineer menghabiskan sebulan memperbaiki ratusan pesan error, karena log yang berisi “What the f-ck?” tidak membantu pengguna akhir
      Pesan-pesan itu harus diubah agar berguna, dan karena alasan seperti di atas, stack error juga perlu ditambahkan
    • Cara yang sekarang, seingat saya, cenderung memakai errors.Join
  • Saya melihat Go menciptakan dua masalah di sini

    1. Jika Go punya nullability yang eksplisit, masalah ini sendiri hampir akan hilang
    2. Tampaknya tidak ada cara untuk mencegah zero initialization pada tipe yang bisa diberi nama, sehingga kesalahan bisa menyelinap kapan saja
    • Menurut saya kalimat dalam artikel ini menunjukkan akar masalahnya dengan baik
      Yaitu bagian “karena kita tidak bisa mengendalikan apa yang akan diterima, masuk akal untuk memeriksa apakah nil di batas tersebut”
      Itu benar untuk input eksternal, tetapi jika semua pointer bisa bernilai nil, maka perlu penalaran untuk melacak batas yang aman di dalam codebase
      Masalah Go adalah ia memaksa penalaran ini dilakukan di kepala setiap programmer, bukan oleh compiler
  • Rust punya Option<T> dan C# punya nullable type
    Menurut saya pada 2026 kita tidak perlu lagi masih mengalami masalah seperti ini

    • Jika dilihat dari sisi berlawanan, kemampuan untuk mengekspresikan “tidak ada” atau “hilang” secara ringkas sangat berguna, terutama saat menangani struktur data arbitrer seperti JSON
      Dalam bahasa pemrograman, sintaks biasanya bukan elemen yang paling menarik, tetapi menulis foo.bar.baz di bahasa scripting favorit jauh lebih mudah daripada foo.unwrap().bar.unwrap().baz di Rust
      Ini tetap begitu meski saya menyukai Rust, dan meskipun Go dan Rust sering diperlakukan dalam satu kelompok yang sama, menurut saya Go jauh lebih dekat dengan bahasa scripting yang dibuat ulang oleh programmer C
      Meski begitu, jika sebuah bahasa memakai null, default-nya sebaiknya non-nullable. Terutama bila ada sintaks pendek seperti ? atau .?, beban sintaksis itu layak ditanggung dalam proyek besar
    • Kalau tidak memakai pointer, tidak ada null, hore… 😭
  • Saya memahami Go bukan bahasa yang memodelkan objek non-nullable dengan baik
    Dalam hal ini mirip dengan C, dan Option<T> bisa direpresentasikan sebagai T*, tetapi T* tidak selalu berarti Option<T>
    Secara umum saya setuju dengan artikelnya. Saat bekerja di perusahaan firmware embedded pun, saya pernah meyakinkan orang agar tidak menebar null check di kode C++, melainkan memakai assert
    Assert mudah di-debug, tidak dihitung sebagai cabang dari perspektif coverage, dan menyampaikan kondisi yang diharapkan dengan jelas kepada pembaca. Karena dikeluarkan dari release build, assert juga lebih efisien
    Namun di Go, dereference nil sudah memberikan informasi debugging yang bagus, jadi saya memahami manfaat assert tidak sebesar di C++

    • Dereference nil di Go memang secara deterministik memicu panic, lebih baik daripada dereference null pointer di C, tetapi tetap tidak begitu hebat karena error baru muncul ketika pointer sungguhan di-dereference
      Dalam contoh artikel, itu akan meledak jauh di dalam checkLimit, dan dari sana kita harus menelusuri balik asal nil tersebut. Bergantung pada sistem atau arsitektur, ini bisa cukup rumit
      Jadi melakukan assert langsung di dalam NewRateLimiter jelas menguntungkan. Dalam kode contoh, artinya mengganti
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      menjadi
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      Namun tim Go sangat menentang assertion, dan panic juga tidak ideal karena jika tidak ditangkap akan membuat seluruh runtime crash
    • Null check dan assert menurut saya sepenuhnya berbeda
      Assert berarti “state ini tidak valid”, dan macro assert bisa membuat null check itu menjadi no-op dalam release build
      Bergantung pada cara macro assert didefinisikan, optimisasi terkait undefined behavior bisa terjadi sehingga check berikutnya dihapus dan berujung pada crash yang membingungkan
      Misalnya saya pernah melihat definisi assert yang membuat check setelahnya dihapus dalam assert(p); if (!p) { ... }
      Saran mentah-mentah “jangan null check, pakai assert” mungkin cocok untuk state invariant, tetapi tidak cocok untuk pemeriksaan error
  • Ada nasihat bagus di bagian kesimpulan
    Jika pengecekan nil muncul di mana-mana, kemungkinannya salah satu dari dua hal. Entah itu kode normal untuk mempertahankan diri dari input di batas sistem yang tidak bisa dipercaya, atau masalah desain karena codebase gagal menetapkan invarian
    Dalam sistem yang tidak bisa memercayai parameter apa pun, solusinya bukan menambahkan lebih banyak pengecekan. Untuk sementara mungkin harus begitu, tetapi pekerjaan sebenarnya adalah menetapkan invarian yang selama ini digantikan oleh pengecekan tersebut, lalu secara bertahap mengubah noise yang lahir dari rasa takut menjadi jaminan yang bisa diandalkan sistem
    Menurut saya ini melampaui pengecekan nil. Menambahkan pengecekan atau kode defensif di bagian “daun” sistem sering kali merupakan cara untuk menangani gejala kurangnya invarian, atau invarian yang tidak ditegakkan dengan benar
    “Tambah satu pengecekan lagi” mudah dijadikan default, tetapi ada batas skalanya. Pada titik tertentu, logika pengecekan menjadi lebih banyak daripada logika fitur, dan kompleksitas keseluruhan membengkak tak terkendali
    Pengecekan tambahan untuk mencegah satu atau dua bug biasanya tidak merugikan, tetapi ketika jumlah dan kompleksitas pengecekan terasa sudah terlalu banyak, dalam jangka panjang lebih baik bagi sistem dan kehidupan para maintainer untuk mundur sejenak dan mencari akar penyebab, daripada terus memperbaiki bagian daun saja

    • Meng-assert invarian itu sangat bagus jika dilakukan sejak awal dan terus dipertahankan
      Namun masalah yang lebih sulit adalah melatih developer agar berhenti melakukan pemrograman defensif
  • Invarian seperti ini, dalam konteks ini hal seperti ketidakbolehan null, jauh lebih baik dimodelkan dalam sistem tipe yang lebih ekspresif daripada Go
    Tulisan favorit saya tentang topik ini adalah tulisan Alexis King tahun 2019, Parse, don't validate
    Prinsipnya bisa diterapkan di mana saja, tetapi di sistem tipe Haskell terlihat sangat mudah. Saya mencoba mengikuti saran Alexis selama beberapa tahun di TypeScript, tetapi itu tidak mudah

  • Singkatnya, masalahnya bukan terlalu banyak pengecekan, melainkan membungkus nil sebagai nilai

  • Masalah ini sudah muncul berulang kali, dan menurut saya ini adalah akibat dari bahasa yang tidak menjadikan penanganan error sebagai fitur first-class
    Seingat saya, seperti yang juga muncul di thread lain, linter standar secara praktis akhirnya memaksa struktur seperti ini
    Saya tidak tahu apakah pengecekan nil ini secara logis buruk. Banyak bahasa memiliki penanganan error bawaan, dan perbedaannya lebih pada konsistensi serta kesederhanaan propagasinya
    Opsi untuk menghadapi interface yang menghasilkan error kira-kira ada empat: menangani dan memulihkan, mengabaikan, mempropagasi error, atau membuang error lalu mempropagasi error sendiri; yang terakhir juga bisa membungkus error yang ada
    Bahasa dengan penanganan error sebagai fitur first-class biasanya membuat nomor 2 dan 3 menjadi mudah, terutama bahasa modern. Karena itu, nomor 4 juga bisa cukup rapi tergantung bahasanya
    Nomor 1 tidak banyak terbantu oleh dukungan first-class, selain membuat kebutuhan akan penanganan seperti itu menjadi lebih eksplisit
    Pada dasarnya, jika sebuah fungsi bisa menghasilkan error, semua bahasa, terlepas dari implementasinya, pada akhirnya seolah melakukan {error,result} = functioncall() lalu if (error) { ... }
    Karena Go tidak memiliki penanganan error sebagai fitur first-class, banyak fungsi secara proaktif mengembalikan tuple (result, err), dan ketika linter secara praktis memaksa pengecekan err != nil, kodenya terasa dipenuhi pola tersebut
    Saya menganggap tidak ditanganinya error handling yang benar secara langsung oleh bahasa sebagai cacat desain bahasa, tetapi jika sudah berada di posisi itu, model ini mungkin mendekati yang terbaik
    Saya tidak begitu tahu apakah kode Go secara idiomatis memakai tipe return opsional untuk membedakan error yang secara fungsional bisa diabaikan dan error yang “harus diperhatikan”. Jika dalam kasus seperti itu pun yang idiomatis tetap selalu mengembalikan tipe error, linter tampaknya akan selalu memaksa pola ini
    Saya tidak membenci Go; saya hanya tidak setuju dengan satu pilihan desainnya. Hampir semua pilihan desain bahasa bisa dikeluhkan
    Menurut saya kesalahan terbesar Go adalah bahwa pengecekan eksplisit err != nil secara praktis wajib secara fungsional hampir di semua tempat, sehingga linter pun menuntutnya

  • Ketika Go pertama kali muncul, ratusan orang sudah menunjukkan betapa konyolnya keseluruhan struktur ini
    Namun bahasa itu menjadi sangat populer, dan kritik disingkirkan dalam suasana bahwa Rob Pike pasti lebih tahu
    Senang melihat sekarang orang-orang akhirnya mendiskusikannya secara normal dengan dasar yang logis
    Bukannya ini tidak dikenal sebagai ide buruk sejak puluhan tahun lalu, tetapi kalau Google yang melakukannya, pasti bagus… kan?

    • Saya bukan penggemar Go, tetapi framing seperti ini mengganggu
      Karena menyebutnya “omong kosong konyol” bisa dengan mudah menekan pemikiran logis yang katanya ingin lebih banyak dilihat
      Saya lupa di episode podcast Oxide yang mana, tetapi Bryan Cantrill pernah mengatakan sesuatu seperti “saya ingin mempelajari ini agar bisa membencinya dengan lebih baik”
      Dalam arti itu, saya ingin memahami mengapa orang-orang begitu antusias terhadap Go pada 2010-an. Sebagian jelas hype, dan saya sendiri melihat para developer di tempat kerja saat itu antusias meski tidak bisa menjelaskan mengapa itu bagus
      Namun rasanya tidak mungkin itu murni hype saja. Saya penasaran apa steel-man argument terkuat pada masa itu untuk menggunakan Go