Pemeriksaan pointer nil yang berlebihan di Go
(konradreiche.com)- 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
RateLimitermemiliki*redis.Clientsebagai field dan memeriksar.redis != nildi dalamAllowsekilas tampak aman - Jika klien Redis bernilai nil, masalahnya sudah terjadi pada saat pembuatan, bukan saat
Allowdijalankan - 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 == nildiNewRateLimiter(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)
- Jika terjadi error pada
- Dengan begitu, konstruktor
RateLimiterjuga 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 NULLatau 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 == nildi dalamRateLimiter.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
Allowmemvalidasi 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 denganhttp.StatusBadRequestlalu return - Setelah validasi selesai,
reqadalah nilai yang valid, dan setelah ituh.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
Allowfinal berfokus pada logika nyata tanpa pemeriksaan niluserID := GetUserID(req)- Jika
userID == "", returnfalse, nil - Jika tidak, panggil
r.checkLimit(ctx, userID)
- Pemeriksaan
userIDkosong 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
Opini di Lobste.rs
Sekali lagi meminta kepada para programmer Go lain: tolong wrap error
Konteks tentang error seharusnya terakumulasi saat call stack di-unwind
errpaling dalam memberi tahu apa yang terjadi; struktur seperti itu lebih baikDalam praktiknya, “wrapping” mudah berubah menjadi aktivitas
grepstring error, berharap string itu unik, lalu memaksa diri kreatif agar string tersebut menjadi unikDulu, 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
Saya melihat Go menciptakan dua masalah di sini
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 codebaseMasalah Go adalah ia memaksa penalaran ini dilakukan di kepala setiap programmer, bukan oleh compiler
Rust punya
Option<T>dan C# punya nullable typeMenurut saya pada 2026 kita tidak perlu lagi masih mengalami masalah seperti ini
Dalam bahasa pemrograman, sintaks biasanya bukan elemen yang paling menarik, tetapi menulis
foo.bar.bazdi bahasa scripting favorit jauh lebih mudah daripadafoo.unwrap().bar.unwrap().bazdi RustIni 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 besarSaya memahami Go bukan bahasa yang memodelkan objek non-nullable dengan baik
Dalam hal ini mirip dengan C, dan
Option<T>bisa direpresentasikan sebagaiT*, tetapiT*tidak selalu berartiOption<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++
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 rumitJadi melakukan assert langsung di dalam
NewRateLimiterjelas menguntungkan. Dalam kode contoh, artinya mengganti menjadi Namun tim Go sangat menentang assertion, dan panic juga tidak ideal karena jika tidak ditangkap akan membuat seluruh runtime crashAssert 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
nilmuncul 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 invarianDalam 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
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()laluif (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 pengecekanerr != nil, kodenya terasa dipenuhi pola tersebutSaya 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 != nilsecara praktis wajib secara fungsional hampir di semua tempat, sehingga linter pun menuntutnyaKetika 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?
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