Go’da Aşırı nil Pointer Kontrolleri
(konradreiche.com)- Go’daki nil kontrolleri panikleri önleyebilir; ancak tekrarlandıkları yer yanlışsa kod, “neyin nil olabileceğini” kendi kendine açıklayamaz
- Redis istemcisi gibi zorunlu bağımlılıkları iç metotlarda kontrol etmek, oluşturma hatasını normal çalışma yolunun parçasıymış gibi ele almak anlamına gelir
- Yalnızca constructor’da nil’i elemek yeterli değildir;
NewRedisClient(addr)gibi başlatma noktalarında hata hemen ele alınmalıdır - İstek nesnesi gibi dışarıdan gelen değerler HTTP handler’ı, RPC dispatch’i, kuyruk consumer’ı gibi sınır katmanlarında doğrulanmalı; iç mantık bu garantilere güvenmelidir
- İmkânsız olması gereken durumları sessizce kabul etmek hatayı sessiz, gecikmeli ve belirsiz hale getirir; daha sonra metrikler, dashboard’lar ve alarmlarla kaybolan sinyali yeniden oluşturma maliyeti doğar
nil kontrolü her zaman savunmacı programlama değildir
- Production’da panikleri önlemek için
deferred recoverdan önce girdileri, aralıkları ve pointer’ları kontrol eden savunmacı programlama gerekir - Doğru yerde yapılan nil kontrolü güvenli kod üretir; yanlış yerde yapılan kontrol ise hangi değerin nil olabileceğinin izlenemediğine dair bir sinyale dönüşür
- Bu kalıp üretilen kodda daha sık görülür, ancak ne yeni bir olgudur ne de yalnızca yapay zekayla sınırlıdır
- nil kontrolü ucuz ve güvenli görünebilir; fakat bir sonraki okuyucuya “bu değer nil olabilir” mesajını bırakır ve çoğu zaman yanlış anlam taşır
Bağımlılık nil kontrollerinin sorunu
RateLimiterın alan olarak*redis.ClienttaşıyıpAllowiçinder.redis != nilkontrolü yapması dışarıdan güvenli görünebilir- Redis istemcisi nil ise sorun
Allowçalıştığı anda değil, zaten oluşturma anında ortaya çıkmıştır - İç metotta nil kontrolü yapmak, oluşturma hatası durumunda çalışmaya devam etmeyi kabul edilebilir bir durum gibi ele alır
- Bu tür kontroller, kodun nesnenin kaynağını, başlatma sorumluluğunu ve nil’in imkânsız olması gereken değişmez koşulunu kaybettiğinin sinyalidir
Constructor’da nil kontrolü tek başına yeterli değildir
NewRateLimiter(client *redis.Client)içindeclient == nilise hata döndürmek daha iyidir, ancak eksiksiz bir çözüm değildir- nil pointer’ın fonksiyona kadar aktarılmış olması bile hatalı bir durumun sisteme çoktan girdiği anlamına gelir
- Asıl hata, Redis istemcisinin oluşturulduğu başlatma noktasında ele alınmalıdır
redisClient, err := NewRedisClient(addr)sırasında hata oluşursa hemen dönülmelidir- Sonrasında
NewRateLimiter(redisClient)yalnızca geçerli bir istemci almalıdır
- Böylece
RateLimiterconstructor’ının hata döndürmesine de gerek kalmaz - Depolamanın geçici olarak kullanılamaz olmasına izin vermek gerekiyorsa nil’i yaymayın; bunun yerine her zaman non-nil olan bir dış tiple sarmalayarak yeniden deneme veya performans düşüşü davranışını içeride kapsülleyin
- Bu, veritabanlarındaki
NOT NULLveya foreign key kısıtlarına benzer- Hatalı satırlar baştan var olamazsa her sorgunun veriyi yeniden kontrol etmesine gerek kalmaz
- Runtime değerleri için de değişmez koşul bir kez kurulduğunda kodun geri kalanı tekrarlı kontrollerden kaçınabilir
Sessiz hataların maliyeti
- Küçük bir değişiklik yüzünden programı durdurmak istemeyip yalnızca nil kontrolü yapmak veya log bırakmak istikrarlı hissettirebilir
- Gerçek seçenekler “crash mi, çalışmaya devam mı”dan çok yüksek sesle başarısız olmak mı, sessizce başarısız olmak mı ayrımına yakındır
- Açıkça döndürülen hataların üç özelliği vardır
- Açıklık: Hatanın oluştuğu anlaşılır
- Anındalık: Hata, nedenine yakın yerde fark edilir
- Atfedilebilirlik: Çağıran taraf hatayı ilgili işlemle ilişkilendirebilir
- Yutulan hatalar bunun tersine çalışır
- Hata sessizce kaybolur
- Daha fazla kod çalıştıktan sonra daha sonra belirti olarak ortaya çıkar
- Belirti göründüğünde nedeni belirlemek zorlaşır
- Program hatalı durumla hayatta kaldıkça, neden ile belirti arasındaki mesafe de büyür
- Doğru düzeltme hatayı yerel olarak gizlemek değil; hatanın nereye yayıldığını ve nerede isteği reddetmeye, işi başarısız kılmaya, yeniden denemeye, alarm üretmeye ya da çıkış yapmaya dönüştüğünü anlamaktır
- Hata döndürmek sistemi gerekenden fazla durduruyorsa sorun o fonksiyonda değil, hata işleme sınırındadır
Kaybolan sinyali yeniden üretmenin ikincil maliyeti
- Hatalar sessizleştiğinde gerçekte ne olduğunu bilemezsiniz; bug’lar gizlenebilir
- Bu durumda davranışın yokluğunu algılamak için metrikler, dashboard’lar ve alarmlar gibi gözlemlenebilirlik altyapısı kurmanız gerekir
- İmkânsız veya ele alınmamış bir duruma her izin verişinizde, attığınız sinyali daha sonra gözlemlenebilirlikle geri kazanmanın mühendislik maliyetini ödersiniz
Dış katmanların ve iç katmanların rolleri
- Çalışmanın başladığı ve dış verinin içeri girdiği yer dış katmandır; bu çağrının ulaştığı daha derindeki kod ise iç katmandır
- Çalışmanın başında hiçbir şey garanti değildir, ancak henüz yapılmış bir iş de yoktur
- Başlatma sırasında programın bağımlı olduğu unsurlar ayarlanmalı ve her unsurun mutlaka gerekli mi yoksa geçici olarak yok olabilir mi olduğuna karar verilmelidir
- Tasarım, her zaman kullanılabilir bağımlılıklar yönünde olmalı; çalışma ortasında ortadan kaybolabilecek bağımlılıklar en aza indirilmelidir
İstek kapsamındaki veriler sınırda doğrulanmalıdır
- İstek nesnesi, istek alanları ve istekten türetilen değerler sabit bağımlılıklardan farklıdır
- İstekler her çağrıda HTTP handler’ı, RPC, kuyruk, test helper’ı veya başka paketler gibi dış kaynaklardan gelir
RateLimiter.Allow(ctx, req)içindereq == nilkontrolü yapmak da bağımlılık nil kontrolüyle aynı hatadır- İstek
Allowiçinde ilk kez ortaya çıkmış değildir; daha öndeki taşıma sınırından girip kodun içinde ilerleyen bir değerdir Allowgibi iç fonksiyonlarda yeniden doğrulamak, dış katmanın garanti etmesi gereken şeyi derindeki bir fonksiyonun tekrar doğrulaması anlamına gelir ve belirsizlik yayılır
Sınır doğrulamasından sonra iç mantık değişmez koşullara güvenir
- nil kontrolü, güvenilmeyen byte’ların
*Requestgibi iç tipe dönüştüğü sınır noktasında olmalıdır - HTTP handler örneğinde
DecodeRequest(r)başarısız olursahttp.StatusBadRequestile yanıt verilir ve dönülür - Doğrulama bittikten sonra
reqgeçerli bir değerdir; ardındanh.limiter.Allow(r.Context(), req)bu değere güvenebilir - Dışarıdan alınan veri kontrol edilemediği için sınırda nil’i ve gerekli kısıtları kontrol etmek mantıklıdır
- Sınırı geçen veri iç tiplere ve iş mantığına eşlenir; sonrasında sistemin değişmez koşulu haline gelir
- Nihai
Allow, nil kontrolü olmadan gerçek mantığa odaklanıruserID := GetUserID(req)userID == ""isefalse, nildöndürür- Aksi halde
r.checkLimit(ctx, userID)çağrılır
- Boş
userIDkontrolü HTTP katmanına da taşınabilir; ancak örnekte bu politikanın hız sınırlayıcıya ait olması tercih edilmiştir
Tekrarlanan nil kontrolleri yeni dallar ve yeni davranışlar yaratır
- Bu yapıdaki sistemlerin akıl yürütmesi ve değiştirilmesi kolaydır
- Buna karşılık değişmez koşulları olmayan sistemler, her yere kontroller ekledikten sonra her kontrol için ne yapılacağına karar vermek zorunda kalır
- Her nil kontrolü yeni bir daldır; her dal da var olmaması gereken bir durum için yeni bir davranış tanımlatır
- nil kontrolleri, belgelenmiş sınırları zorunlu kılarken veya bilinçli olarak seçilebilir durumları modellerken yararlıdır
- Programın imkânsız saydığı durumları sessizce ele alan nil kontrollerinden şüphe edilmelidir
- nil kontrolleri her yerde görünüyorsa iki olasılıktan biri söz konusudur
- Güvenilmeyen sınır girdisini koruyan normal kod
- Kod tabanının değişmez koşullar kuramadığı bir tasarım sorunu
- Hiçbir parametreye güvenilemeyen bir sistemde hemen kontrol eklemek gerekebilir; ancak asıl iş, bu kontrolün yerine geçtiği değişmez koşulu kurmak ve bunu güvenilir bir garantiye dönüştürmektir
1 yorum
Lobste.rs yorumları
Diğer Go programcılarından tekrar rica ediyorum: lütfen hataları sarmalayın
Çağrı yığını açılırken hataya dair bağlam birikmeli
errnin ise ne olduğunu söylediği bir yapı daha iyiPratikte “sarmalama” çoğu zaman hata dizgesini
greplemek, o dizgenin benzersiz olmasını ummak ve dizgeyi benzersiz kılmak için zoraki yaratıcılığa başvurmak anlamına geliyorEskiden bir ağ ürününde bir mühendis yüzlerce hata mesajını düzeltmek için bir ay harcamıştı; çünkü günlüklerde “What the f-ck?” yazması son kullanıcıya yardımcı olmuyordu
Bu mesajları yararlı hâle getirmek ve yukarıdaki nedenlerle hata yığını da eklemek gerekiyordu
Go’nun burada iki sorun yarattığını düşünüyorum
“Ne geçirileceğini kontrol edemediğiniz için, o sınırda
nilolup olmadığını kontrol etmek makuldür” kısmıDış girdiler için bu doğru, ancak tüm işaretçiler
nilolabiliyorsa kod tabanı içinde güvenli sınırları izlemek için çıkarım yapmak gerekirGo’nun sorunu, bu çıkarımı derleyiciye değil tüm programcıların zihnine yaptırmaya zorlaması
Rust’ta
Option<T>var, C#’ta ise null olabilir tipler var2026’da hâlâ bu tür sorunlar yaşamamız gerektiğini düşünmüyorum
Dillerde sözdizimi genelde daha az ilginç bir unsur olsa da, sevdiğiniz betik dilinde
foo.bar.bazyazmak Rust’takifoo.unwrap().bar.unwrap().bazyazmaktan çok daha kolayRust’ı seven biri olarak da böyle düşünüyorum; ayrıca Go ve Rust sık sık aynı sepete konulsa da Go’nun, C programcılarının yeniden yaptığı bir betik diline çok daha yakın olduğunu düşünüyorum
Yine de bir dil null kullanıyorsa varsayılanın null olamaz olması daha iyi. Özellikle
?veya.?gibi kısa bir sözdizimi varsa, büyük projelerde sözdizimsel yükü taşımaya değerGo’nun null olamaz nesneleri iyi modelleyen bir dil olmadığını anlıyorum
Bu açıdan C’ye benziyor;
Option<T>,T*ile temsil edilebilir amaT*mutlakaOption<T>anlamına gelmezGenel olarak yazıya katılıyorum. Gömülü yazılım geliştiren bir şirkette çalışırken de C++ kodunda her yere null kontrolü koymak yerine assert kullanalım diye ikna etmeye çalışmıştım
assert hata ayıklamayı kolaylaştırır, kapsama açısından dal olarak sayılmaz ve okuyana beklenen koşulu açıkça iletir. Sürüm derlemelerinde çıkarıldığı için daha verimlidir de
Ancak Go’da nil dereference zaten iyi hata ayıklama bilgisi verdiğinden, assert’in faydasının C++’taki kadar büyük olmadığını anlıyorum
Yazıdaki örnekte
checkLimitin derinliklerinde patlar ve orada nil’in kaynağını geriye doğru izlemek gerekir. Sisteme veya mimariye bağlı olarak bu epey karmaşık olabilirBu yüzden
NewRateLimiterın hemen içinde assert etmek kesinlikle faydalı. Örnek kodda şunu yapmak gibi olur Ancak Go ekibi assertion’a güçlü biçimde karşı ve panic de ideal değil; yakalanmazsa tüm runtime’ı çökertirassert “bu durum geçerli değil” demektir ve assert makrosu sürüm derlemesinde o null kontrolünü no-op hâline getirebilir
assert makrosunun tanımlanma biçimine bağlı olarak tanımsız davranışla ilgili optimizasyonlar gerçekleşebilir; sonrasında kontroller kaldırılıp kafa karıştırıcı çökmelere yol açabilir
Örneğin
assert(p); if (!p) { ... }ifadesinde sonraki kontrolün kaldırıldığı türden assert tanımları gördümDüşünmeden “null kontrolü yapma, assert kullan” demek durum değişmezleri için uygun olabilir ama hata kontrolü için uygun değil
Sonuç bölümünde iyi bir tavsiye var
nilkontrolleri her yerde görünmeye başladıysa bu iki şeyden biridir: güvenilmeyen sınır girdilerine karşı savunma yapan normal kod ya da kod tabanının değişmezler oluşturamadığını gösteren bir tasarım sorunuHiçbir parametreye güvenilemeyen bir sistemde çözüm daha fazla kontrol eklemek değildir. Kısa vadede bunu yapmak gerekebilir, ama asıl iş, o kontrollerin yerine geçtiği değişmezleri oluşturmak ve korkudan doğan gürültüyü sistemin dayanabileceği garantilere yavaş yavaş dönüştürmektir
Bence bu, nil kontrollerinin de ötesine geçiyor. Sistemin “yaprak” kısımlarına kontrol ya da savunma kodu eklemek, çoğu zaman değişmezlerin eksik olduğu veya düzgün zorlanmadığı bir belirtisini tedavi etme biçimi olarak ortaya çıkıyor
“Bir kontrol daha ekle” yaklaşımını varsayılan yapmak kolaydır ama ölçeklenme sınırı vardır. Bir noktada kontrol mantığı, işlev mantığından daha fazla hale gelir ve toplam karmaşıklık kontrolden çıkar
Bir iki hatayı önlemek için eklenen kontroller genelde zararlı değildir; ama kontrol sayısının ve karmaşıklığının fazla arttığını hissettiğinizde, sürekli yaprakları düzeltmek yerine bir adım geri çekilip kök nedeni aramak, uzun vadede hem sistem hem de bakımını yapanların hayatı için daha iyi oldu
Ancak geliştiricileri savunmacı programlamayı bırakmaları için eğitmek daha zor bir problemdir
Bu tür değişmezler, burada null olamazlık gibi şeyler, Go’dan daha ifade gücü yüksek tip sistemlerinde çok daha iyi modellenebilir
Bu konuda en sevdiğim yazı, Alexis King’in 2019 tarihli Parse, don't validate yazısı
İlke her yerde uygulanabilir, ama Haskell’in tip sisteminde gerçekten kolay görünüyor. TypeScript’te Alexis’in tavsiyesini yıllarca uygulamaya çalıştım ama kolay olmadı
Özetle sorun kontrollerin fazla olması değil, nil’i bir değer olarak sarmalamak
Bu sorun tekrar tekrar gündeme geldi; bence bu, hata işlemenin birinci sınıf özellik olmadığı dillerin sonucu
Hatırladığım kadarıyla başka başlıklarda da geçtiği gibi, fiilen standart linter’lar bu yapıyı zorunlu kılıyor
Bu nil kontrollerinin mantıksal olarak kötü olup olmadığını bilmiyorum. Birçok dil hata işlemeyi yerleşik olarak sunuyor; fark daha çok yaymanın tutarlılığı ve basitliği düzeyinde
Hata döndüren bir arayüzle karşılaşıldığında seçenekler kabaca dört tane: işleyip toparlamak, yok saymak, hatayı yaymak, hatayı atıp kendi hatasını yaymak; sonuncusu mevcut hatayı sarmalayabilir de
Hata işlemenin birinci sınıf özellik olduğu diller genelde 2. ve 3. seçenekleri kolaylaştırır; modern dillerde bu daha da böyledir. Bu yüzden 4. seçenek de dile bağlı olarak epey temiz hale gelebilir
Temelde bir fonksiyon hata üretebiliyorsa, uygulamadan bağımsız olarak her dil
{error,result} = functioncall()ardındanif (error) { ... }yapıyormuş gibidirGo’da hata işleme birinci sınıf olmadığı için birçok fonksiyon önceden
(result, err)demeti döndürür; linter daerr != nilkontrolünü fiilen zorunlu kılınca kodun bu kalıpla dolduğu izlenimi oluşurDoğru hata işlemeyi dilin doğrudan ele almamasını bir dil tasarımı kusuru olarak görüyorum; ama bir kez o konumdaysanız bu model muhtemelen en iyiye yakın görünüyor
Go kodunun deyimsel olarak, işlevsel açıdan yok sayılabilir hatalarla “önemsenmesi gereken” hataları ayırmak için isteğe bağlı dönüş tipleri kullanıp kullanmadığından emin değilim. Böyle durumlarda bile her zaman hata tipi döndürmek deyimsel ise linter her zaman bu kalıbı dayatacak gibi
Go’dan nefret etmiyorum; yalnızca bir tasarım tercihine katılmıyorum. Neredeyse her dilin tasarım tercihleri hakkında şikâyet edilebilir
Bence Go’nun en büyük hatası, açık
err != nilkontrolünün fiilen her yerde işlevsel olarak gerekli olması ve bu yüzden linter’ların da bunu istemesiGo ilk çıktığında da yüzlerce kişi bu bütün yapının ne kadar gülünç olduğuna dikkat çekmişti
Ama dil çok popüler oldu ve Rob Pike’ın daha iyi bildiği havası içinde eleştiriler geçiştirildi
İnsanların bunu ancak şimdi mantıklı gerekçelerle düzgün biçimde tartıştığını görmek güzel
Sanki bu onlarca yıl öncesinden beri kötü bir fikir olarak bilinmiyordu da, Google yapıyorsa iyidir… değil mi?
Çünkü buna “gülünç saçmalık” demek, daha fazlasını görmek istediğini söylediğin mantıksal düşüncenin kendisini bastırmayı kolaylaştırır
Hangi Oxide podcast’iydi unuttum ama Bryan Cantrill bir keresinde “bundan daha iyi nefret edebilmek için bunu incelemek istiyorum” gibi bir şey söylemişti
Bu anlamda 2010’larda insanların Go’ya neden bu kadar heyecanlandığını anlamak istiyorum. Bunun bir kısmı kesinlikle abartıydı; o dönemde iş yerimde geliştiricilerin neden iyi olduğunu açıklayamadan heyecanlandıklarını bizzat gördüm
Ama sadece saf abartıdan ibaret olmasa gerek. O dönemde Go kullanmak için en güçlü steel-man argument neydi, merak ediyorum