1 पॉइंट द्वारा GN⁺ 5 시간 전 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • Go में nil checks panic को रोक सकती हैं, लेकिन अगर वे बार-बार गलत जगहों पर हों, तो code खुद यह नहीं बता पाता कि “क्या nil हो सकता है”
  • Redis client जैसी अनिवार्य dependency को internal methods में check करने से, creation failure को normal execution path जैसा treat किया जाने लगता है
  • केवल constructor में nil को filter करना काफी नहीं है; NewRedisClient(addr) जैसे initialization point पर failure को तुरंत handle करना चाहिए
  • request object जैसे बाहर से आने वाले values को HTTP handler, RPC dispatch, queue consumer जैसी boundary layer में validate करना चाहिए, और internal logic को उस guarantee पर भरोसा करना चाहिए
  • जो state असंभव होनी चाहिए, उसे चुपचाप allow करने से failure silent, delayed और ambiguous हो जाता है, और बाद में metrics, dashboards, alerts से गायब signal को restore करने की cost आती है

nil check हमेशा defensive programming नहीं होती

  • production में panic रोकने के लिए deferred recover से पहले input, range और pointer check करने वाली defensive programming की जरूरत होती है
  • सही जगह की nil check सुरक्षित code बनाती है, लेकिन गलत जगह की check इस बात का signal बन जाती है कि कौन सा value nil हो सकता है, यह track नहीं हो पा रहा
  • generated code में यह pattern अधिक बार दिखता है, लेकिन यह कोई नया phenomenon नहीं है और न ही केवल AI तक सीमित है
  • nil check सस्ती और सुरक्षित लगती है, लेकिन अगले reader को “यह value nil हो सकता है” का message छोड़ती है, और अक्सर गलत meaning convey करती है

dependency nil check की समस्या

  • RateLimiter में field के रूप में *redis.Client हो और Allow के अंदर r.redis != nil check किया जाए, तो code ऊपर से सुरक्षित लगता है
  • अगर Redis client nil है, तो समस्या Allow execution के समय नहीं, बल्कि creation time पर ही हो चुकी है
  • internal method में nil check करने से creation failure state में भी चलते रहना acceptable state जैसा treat होता है
  • ऐसी check इस बात का signal है कि code ने object का origin, initialization responsibility और nil असंभव होना चाहिए—ऐसी invariant condition—खो दी है

केवल constructor nil check पर्याप्त नहीं है

  • NewRateLimiter(client *redis.Client) में client == nil होने पर error return करना बेहतर है, लेकिन यह पूरा solution नहीं है
  • nil pointer का function तक पहुंच जाना ही इस बात का संकेत है कि गलत state पहले ही system में आ चुकी है
  • असली error को Redis client बनाने वाले initialization point पर handle करना चाहिए
    • redisClient, err := NewRedisClient(addr) में error आए, तो तुरंत return करना चाहिए
    • इसके बाद NewRateLimiter(redisClient) को केवल valid client ही pass किया जाना चाहिए
  • ऐसा करने पर RateLimiter constructor को error return करने की जरूरत भी खत्म हो जाती है
  • अगर storage के temporarily unavailable रहने की state को allow करना जरूरी हो, तो nil propagate न करें; उसे हमेशा non-nil रहने वाले external type में wrap करें, और retry या degraded performance handling को उसके अंदर encapsulate करें
  • यह database के NOT NULL या foreign key constraint जैसा है
    • अगर गलत row शुरू से exist ही नहीं कर सकती, तो हर query को data फिर से check करने की जरूरत नहीं रहती
    • runtime values के लिए भी, एक बार invariant condition बना दी जाए तो बाकी code repeated checks से बच सकता है

silent failure की cost

  • छोटी change की वजह से program रोकना न पड़े, इसलिए nil check करना या सिर्फ log छोड़ना stable लग सकता है
  • वास्तविक choice “crash बनाम चलते रहना” से ज्यादा loud failure बनाम silent failure जैसी है
  • explicitly returned error में तीन गुण होते हैं
    • clarity: पता चलता है कि failure हुआ है
    • immediacy: cause के पास ही failure पता चलता है
    • attribution: caller failure को संबंधित operation से जोड़ सकता है
  • निगला गया error उल्टा काम करता है
    • failure चुपचाप गायब हो जाता है
    • ज्यादा code run होने के बाद बाद में symptom के रूप में दिखता है
    • जब symptom दिखता है, तब cause identify करना मुश्किल हो जाता है
  • program जितनी ज्यादा calls में गलत state के साथ survive करता है, cause और symptom के बीच का gap उतना ही बढ़ता है
  • सही fix failure को local रूप से छिपाना नहीं है, बल्कि यह समझना है कि error कहां propagate होता है और कहां request rejection, operation failure, retry, alert या shutdown में बदलता है
  • अगर error return system को जरूरत से ज्यादा रोक देता है, तो समस्या उस function में नहीं बल्कि error handling boundary में है

गायब signal को फिर से बनाने की secondary cost

  • failure silent हो जाए, तो असल में क्या हुआ यह पता नहीं चलता और bugs छिप सकते हैं
  • फिर behavior की अनुपस्थिति detect करने के लिए metrics, dashboards, alerts जैसी observability infrastructure बनानी पड़ती है
  • हर बार जब आप किसी impossible या unhandled state को allow करते हैं, तो फेंके गए signal को बाद में observability से restore करने की engineering cost चुकानी पड़ती है

external layer और internal layer की भूमिका

  • जहां execution शुरू होता है और external data अंदर आता है, वह external layer है; उस call के पहुंचने वाले deeper code को internal layer कहा जा सकता है
  • execution की शुरुआत में कुछ भी guaranteed नहीं होता, लेकिन अभी कोई काम भी नहीं हुआ होता
  • initialization process में program जिन elements पर depend करता है उन्हें set up करना, और हर element जरूरी है या temporarily गायब हो सकता है, यह decide करना चाहिए
  • design को हमेशा available dependencies की तरफ झुकना चाहिए, और बीच में गायब हो सकने वाली dependencies को कम से कम रखना चाहिए

request scope data को boundary पर validate करना चाहिए

  • request object, request fields और request से derived values fixed dependencies से अलग हैं
  • request हर call में बाहर से आती है—HTTP handler, RPC, queue, test helper, दूसरे package आदि से
  • RateLimiter.Allow(ctx, req) के अंदर req == nil check करना भी dependency nil check जैसी ही गलती है
  • request पहली बार Allow में नहीं आई; वह उससे पहले की transport boundary से अंदर आई और code के भीतर आगे बढ़ी है
  • Allow जैसे internal function में फिर से validate करने से, जो guarantee external layer को देनी चाहिए थी, उसे deep function फिर से validate करने लगता है, और uncertainty फैलती है

boundary validation के बाद internal logic invariant conditions पर भरोसा करता है

  • nil check उस boundary point पर होनी चाहिए जहां untrusted bytes *Request जैसे internal type में बदलते हैं
  • HTTP handler example में DecodeRequest(r) fail हो, तो http.StatusBadRequest से response देकर return किया जाता है
  • validation के बाद का req valid value है, और इसके बाद h.limiter.Allow(r.Context(), req) उस value पर भरोसा कर सकता है
  • बाहर से मिले data को control नहीं किया जा सकता, इसलिए boundary पर nil और जरूरी constraints check करना उचित है
  • boundary पार कर चुका data internal types और business logic में map हो जाता है, और उसके बाद system की invariant condition बन जाता है
  • अंतिम Allow nil check के बिना actual logic पर focus करता है
    • userID := GetUserID(req)
    • अगर userID == "" हो, तो false, nil return
    • वरना r.checkLimit(ctx, userID) call
  • empty userID check को HTTP layer में भी ले जाया जा सकता है, लेकिन example में rate limiter को वह policy own करने दिया गया है

repeated nil checks नई branches और नया behavior बनाती हैं

  • इस structure वाला system reason करना आसान होता है और बदलना भी आसान होता है
  • इसके उलट, invariant conditions के बिना system में जगह-जगह checks जोड़ने पड़ते हैं, और हर check पर क्या करना है यह decide करना पड़ता है
  • हर nil check एक नई branch है, और हर branch ऐसी state के लिए नया behavior define करवाती है जो exist नहीं करनी चाहिए
  • nil check documented boundaries enforce करने या intentional optional state model करने में उपयोगी होती है
  • जो state program impossible मानता है, उसे चुपचाप handle करने वाली nil check पर संदेह करना चाहिए
  • अगर nil checks जगह-जगह दिखें, तो दो में से एक case है
    • untrusted boundary input को protect करने वाला normal code
    • codebase invariant conditions establish नहीं कर पाया—ऐसी design problem
  • जिस system में कोई parameter भरोसेमंद नहीं है, उसमें तुरंत checks जोड़ने पड़ सकते हैं, लेकिन असली काम उन invariant conditions को establish करना है जिनकी जगह ये checks ले रही हैं, और उन्हें भरोसेमंद guarantees में बदलना है

1 टिप्पणियां

 
GN⁺ 5 시간 전
Lobste.rs की राय
  • दूसरे Go programmers से फिर अनुरोध है कि errors को wrap करें

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

    call stack unwind होते समय error का context जमा होना चाहिए

    • ज़्यादा idiomatic उदाहरण ऐसा दिखता है
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      इसके बाद हर layer बस यह जोड़ती जाए कि error कहाँ हुआ, और सबसे अंदर वाला err बताए कि क्या हुआ
    • अफसोस की बात है कि errors के लिए कोई unified, de facto standard stack trace नहीं है
      असल में “wrapping” अक्सर error string को grep करने, उम्मीद करने कि वह string unique हो, और उसे unique बनाने के लिए जबरन creativity दिखाने जैसा बन जाता है
    • कुछ लोग शिकायत करते हैं कि error stack बहुत लंबा हो जाता है, लेकिन ज़्यादातर लोग ऐसे messages को actionable और उपयोगी मानते हैं
      पहले एक networking product में एक engineer ने सैकड़ों error messages सुधारने में एक महीना लगाया था, क्योंकि logs में “What the f-ck?” दिखना end user के लिए मददगार नहीं था
      उन messages को उपयोगी बनाना था, और ऊपर बताए कारणों से error stack भी जोड़ना था
    • आजकल का तरीका, याद पड़ता है, errors.Join इस्तेमाल करने की तरफ है
  • मुझे लगता है Go यहाँ दो समस्याएँ पैदा करता है

    1. अगर Go में explicit nullability होती, तो यह समस्या अपने-आप लगभग खत्म हो जाती
    2. ऐसा कोई तरीका नहीं दिखता जिससे नाम दिए जा सकने वाले types की zero initialization रोकी जा सके, इसलिए गलती कभी भी चुपचाप घुस सकती है
    • लेख का यह वाक्य मूल समस्या को अच्छी तरह दिखाता है
      “आप यह control नहीं कर सकते कि आपको क्या pass किया जाएगा, इसलिए उस boundary पर nil की जाँच करना reasonable है” वाला हिस्सा
      बाहरी input के लिए यह सही है, लेकिन अगर हर pointer nil हो सकता है, तो codebase के अंदर safe boundaries track करने के लिए inference चाहिए
      Go की समस्या यह है कि यह inference compiler से करवाने के बजाय हर programmer के दिमाग में करने को मजबूर करता है
  • Rust में Option<T> है और C# में nullable types हैं
    मुझे लगता है 2026 में हमें ऐसी समस्या अब भी झेलने की जरूरत नहीं होनी चाहिए

    • दूसरी तरफ से देखें तो, “नहीं है” या “missing” को concise तरीके से व्यक्त करने की क्षमता, खासकर JSON जैसी arbitrary data structures से निपटते समय, बहुत उपयोगी है
      language में syntax आम तौर पर कम दिलचस्प हिस्सा होता है, लेकिन अपनी पसंदीदा scripting language में foo.bar.baz लिखना Rust के foo.unwrap().bar.unwrap().baz से कहीं आसान है
      Rust पसंद करने के बावजूद ऐसा लगता है; और भले Go और Rust को अक्सर एक ही group में रखा जाता है, Go मुझे C programmer द्वारा फिर से बनाई गई scripting language के कहीं ज़्यादा करीब लगता है
      फिर भी अगर language null का इस्तेमाल करती है, तो default non-nullable होना बेहतर है। खासकर अगर ? या .? जैसा छोटा syntax हो, तो बड़े projects में syntax का बोझ उठाना ठीक है
    • pointer इस्तेमाल न करें तो null भी नहीं होगा, वाह… 😭
  • मेरी समझ है कि Go non-nullable objects को अच्छी तरह model करने वाली language नहीं है
    इस मामले में यह C जैसा है, और Option<T> को T* से represent किया जा सकता है, लेकिन T* का मतलब जरूरी नहीं कि Option<T> हो
    कुल मिलाकर लेख से सहमत हूँ। embedded firmware company में काम करते समय भी मैंने लोगों को समझाया था कि C++ code में हर जगह null checks लिखने के बजाय assert इस्तेमाल करें
    assert debug करना आसान बनाता है, coverage के नजरिए से branch के रूप में count नहीं होता, और पढ़ने वाले को expected conditions साफ-साफ बताता है। release build में यह हट जाता है, इसलिए अधिक efficient भी होता है
    हालांकि Go में nil dereference पहले से ही अच्छी debugging information देता है, इसलिए assert का फायदा C++ जितना बड़ा नहीं है, मेरी समझ से

    • Go का nil dereference C के null pointer dereference से बेहतर है क्योंकि यह deterministic तरीके से panic करता है, लेकिन इतना भी शानदार नहीं है क्योंकि error तभी आता है जब actual pointer dereference होता है
      लेख के उदाहरण में यह checkLimit के काफी अंदर जाकर फटेगा, और वहाँ से nil का source reverse-track करना पड़ेगा। system या architecture के हिसाब से यह काफी complex हो सकता है
      इसलिए NewRateLimiter के अंदर ही assert करना निश्चित रूप से फायदेमंद है। example code में इसका मतलब
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      को
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      में बदलना है
      हालांकि Go team assertion का कड़ा विरोध करती है, और panic भी ideal नहीं है क्योंकि अगर पकड़ा न जाए तो पूरा runtime crash कर देता है
    • null check और assert मुझे पूरी तरह अलग चीजें लगती हैं
      assert का मतलब है “यह state valid नहीं है”, और assert macro release build में उस null check को no-op बना सकता है
      assert macro कैसे define किया गया है, उसके आधार पर undefined behavior से जुड़े optimizations हो सकते हैं, जिनसे बाद के checks हट जाते हैं और confusing crash हो सकता है
      उदाहरण के लिए मैंने ऐसी assert definition देखी है जिसमें assert(p); if (!p) { ... } में बाद वाला check हट जाता है
      अंधाधुंध “null check मत करो, assert इस्तेमाल करो” कहना state invariants के लिए सही हो सकता है, लेकिन error checking के लिए नहीं
  • निष्कर्ष वाले हिस्से में अच्छी सलाह है
    अगर nil checks जगह-जगह दिख रहे हैं, तो मामला दो में से एक है। या तो यह अविश्वसनीय boundary input से बचाव करने वाला सामान्य code है, या फिर codebase में invariant तय न कर पाने की design problem है
    जिस system में किसी भी parameter पर भरोसा नहीं किया जा सकता, वहाँ समाधान और checks जोड़ना नहीं है। फिलहाल ऐसा करना पड़ सकता है, लेकिन असली काम उन invariants को स्थापित करना है जिनकी जगह ये checks ले रहे हैं, और डर से पैदा हुए noise को धीरे-धीरे ऐसी guarantees में बदलना है जिन पर system निर्भर कर सके
    मुझे लगता है यह nil checks से आगे की बात है। system के “leaf” हिस्सों में checks या defensive code जोड़ना अक्सर invariants की कमी या उन्हें ठीक से enforce न किए जाने के symptoms को handle करने का तरीका बन जाता है
    “एक और check जोड़ दो” को default बनाना आसान है, लेकिन उसकी scaling limit है। एक समय पर check logic, feature logic से ज्यादा हो जाता है और कुल complexity बेकाबू हो जाती है
    एक-दो bugs रोकने के लिए extra checks आम तौर पर नुकसानदेह नहीं होते, लेकिन जब लगे कि checks की संख्या और complexity बहुत बढ़ रही है, तो leaf को ही ठीक करते रहने के बजाय एक कदम पीछे हटकर root cause ढूंढना लंबे समय में system और maintainers की जिंदगी के लिए बेहतर रहा है

    • invariants को assert करना तब बहुत अच्छा है जब आप शुरू से ही ऐसा करते हैं और उसे लगातार maintain रखते हैं
      हालांकि developers को defensive programming छोड़ने के लिए train करना ज्यादा कठिन समस्या है
  • ऐसे invariants, यहाँ non-nullability जैसी चीजें, Go की तुलना में ज्यादा expressive type systems में कहीं बेहतर model की जा सकती हैं
    इस विषय पर मेरा पसंदीदा लेख Alexis King का 2019 का लेख Parse, don't validate है
    principle हर जगह लागू हो सकता है, लेकिन Haskell के type system में यह सचमुच आसान लगता है। मैंने कई सालों तक TypeScript में Alexis की सलाह मानने की कोशिश की, लेकिन यह आसान नहीं था

  • संक्षेप में, समस्या checks के बहुत ज्यादा होने की नहीं, बल्कि nil को value में wrap करने की है

  • यह समस्या बार-बार सामने आई है, और मेरे हिसाब से यह ऐसी language जिसमें error handling first-class feature नहीं है का नतीजा है
    याद पड़ता है, दूसरे threads में भी आया था कि practically standard linters ही ऐसी structure को enforce करने लगते हैं
    मुझे नहीं पता ये nil checks logically खराब हैं या नहीं। कई languages में error handling built-in होती है, और फर्क propagation की consistency और simplicity का ही होता है
    error देने वाले interface से निपटने के विकल्प मोटे तौर पर चार हैं: handle करके recover करना, ignore करना, error propagate करना, या error को छोड़कर अपना error propagate करना; आखिरी वाला existing error को wrap भी कर सकता है
    जिन languages में error handling first-class है, वे आम तौर पर 2 और 3 को आसान बना देती हैं, और modern languages में यह और ज्यादा दिखता है। इसलिए 4 भी language के हिसाब से काफी साफ-सुथरा हो सकता है
    1 में first-class support भी बहुत मदद नहीं कर सकता, सिवाय इसके कि वह इस बात को ज्यादा explicit बना दे कि ऐसी handling की जरूरत है
    मूल रूप से अगर कोई function error दे सकता है, तो implementation चाहे जैसी हो, हर language में यह {error,result} = functioncall() के बाद if (error) { ... } करने जैसा ही है
    Go में error handling first-class नहीं है, इसलिए कई functions proactively (result, err) tuple return करते हैं, और linter err != nil check को practically enforce करता है, जिससे code उस pattern से भरा हुआ लगता है
    सही error handling को language द्वारा सीधे handle न करना मुझे language design flaw लगता है, लेकिन एक बार आप उस जगह पर हैं, तो यह model शायद best के करीब दिखता है
    मुझे ठीक से नहीं पता कि Go code idiomatically optional return type का इस्तेमाल करके functionally ignore किए जा सकने वाले errors और “ध्यान देने लायक” errors में फर्क करता है या नहीं। अगर ऐसे मामलों में भी error type return करना ही idiom है, तो linter हमेशा यह pattern enforce करेगा
    ऐसा नहीं है कि मुझे Go पसंद नहीं है; मैं बस इसके एक design choice से सहमत नहीं हूँ। लगभग हर language के design choices पर शिकायत की जा सकती है
    मेरे हिसाब से Go की सबसे बड़ी गलती यह है कि practically हर जगह explicit err != nil check functionally जरूरी है, और इसलिए linters भी उसे मांगने लगते हैं

  • जब Go पहली बार आया था, तब भी सैकड़ों लोगों ने बताया था कि यह पूरा structure कितना हास्यास्पद है
    लेकिन language बहुत popular हो गई, और “Rob Pike बेहतर जानते हैं” वाले माहौल में criticism को खारिज कर दिया गया
    अब लोगों को logical basis पर सामान्य तरीके से चर्चा करते देखना अच्छा लग रहा है
    ऐसा भी नहीं था कि यह दशकों से खराब idea के रूप में जाना नहीं जाता था, लेकिन Google कर रहा है तो अच्छा ही होगा… है ना?

    • मैं Go fan नहीं हूँ, लेकिन यह framing खटकती है
      क्योंकि इसे “हास्यास्पद बकवास” कहने से वही logical thinking दबना आसान हो जाता है जिसे आप और देखना चाहते थे
      कौन-सा Oxide podcast था याद नहीं, लेकिन Bryan Cantrill ने कभी कुछ ऐसा कहा था कि “मैं इसे इसलिए study करना चाहता हूँ ताकि इसे बेहतर तरीके से dislike कर सकूँ”
      उसी अर्थ में, मैं समझना चाहता हूँ कि 2010s में लोग Go को लेकर इतने excited क्यों थे। कुछ हिस्सा निश्चित रूप से hype था, और मैंने उस समय अपने workplace में developers को देखा था जो यह explain नहीं कर पाते थे कि यह अच्छा क्यों है, फिर भी इसे लेकर excited थे
      लेकिन यह केवल pure hype नहीं रहा होगा। मैं जानना चाहता हूँ कि उस दौर में Go अपनाने के पक्ष में सबसे मजबूत steel-man argument क्या था