3 पॉइंट द्वारा GN⁺ 2024-07-23 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • Type-driven design में input को सिर्फ जांचकर छोड़ देने वाले validation की तुलना में, जांच के परिणाम को अधिक सटीक type के रूप में बचा लेने वाली parsing बाद के code की safety बढ़ाती है
  • head:: [a] -> a जैसी function, जो कुछ inputs पर fail होती है, return type को कमजोर करके भी संभाली जा सकती है, लेकिन caller को गैर-जरूरी failure branch लगातार उठानी पड़ती है
  • NonEmpty a खाली list न होने की स्थिति को type में सुरक्षित रखता है, जिससे duplicate checks और “कभी नहीं होने वाली” error handling कम होती है
  • processing code में जगह-जगह ad-hoc validation मिलाने पर यह shotgun parsing बन जाता है, और input error कुछ state changes के बाद ही पता चल सकती है
  • practical काम में function signature में पहले वांछित data representation रखें, और Map, abstract type, smart constructor आदि से invariants को type boundary पर लाएं

Type-driven design की शुरुआत

  • “Parse, don’t validate” type-driven design को तीन शब्दों में संक्षेपित करने वाला वाक्य है
  • static type system code लिखने से पहले ही यह सवाल सामने ला देता है कि “क्या इस function को लिखा जा सकता है”
  • Haskell example में foo :: Integer -> Void में Void की कोई value नहीं होती, इसलिए असली value बनाई नहीं जा सकती
  • head :: [a] -> a भी सभी inputs के लिए defined नहीं है, क्योंकि empty list [] आ सकती है
    • GHC warning देता है कि pattern matching [] को handle नहीं करती
    • यह partial function है, जो सभी संभव inputs के लिए defined नहीं है

partial function को total function में बदलने के दो तरीके

  • return type को कमजोर करना

    • head :: [a] -> Maybe a में बदलने पर empty list के लिए Nothing लौटाया जा सकता है, इसलिए यह total function बन जाती है
    • implementation आसान हो जाती है, लेकिन caller को हमेशा Nothing की संभावना handle करनी पड़ती है
    • एक example आता है जिसमें CONFIG_DIRS environment variable पढ़कर list खाली नहीं है यह पहले ही check करने के बाद भी, main में head result की Nothing branch दोबारा handle करनी पड़ती है
    • duplicate checks code को messy बनाते हैं, और जटिल मामलों में performance cost के रूप में भी जुड़ सकते हैं
    • अगर शुरुआती check हटा भी दिया जाए, तो पीछे की “कभी नहीं होने वाली” error type में दिखाई नहीं देती
    • अंततः type system में छेद बन जाता है, और bug खोजने के लिए tests या manual review पर निर्भर रहना पड़ता है
  • argument type को मजबूत करना

    • return type को कमजोर किए बिना argument type को मजबूत करने पर head को empty list पर call किए जाने की संभावना हटाई जा सकती है
    • Data.List.NonEmpty का NonEmpty a non-empty list को represent करता है
    • definition है data NonEmpty a = a :| [a]
    • पहला element a और बाकी list [a] को अलग करके, tail खाली होने पर भी पहला element हमेशा मौजूद रहता है
    • head :: NonEmpty a -> a एक ही pattern से implement होती है और total function बन जाती है
    • getConfigurationDirectories :: IO (NonEmpty FilePath) की तरह return type बदलने पर, non-empty होने का तथ्य type में सुरक्षित रहता है
    • nonEmpty :: [a] -> Maybe (NonEmpty a) सामान्य list को NonEmpty में बदलता है
    • Nothing handling input boundary पर सिर्फ एक बार की जाती है
    • main में initializeCache (head configDirs) की तरह duplicate branch के बिना इस्तेमाल किया जा सकता है
    • आगे getConfigurationDirectories अगर non-empty होने की guarantee न देने के लिए बदलता है, तो return type भी बदलना होगा, और main type check में fail होगा

validation और parsing का अंतर

  • validateNonEmpty :: [a] -> IO () और parseNonEmpty :: [a] -> IO (NonEmpty a) दोनों empty list check करते हैं और fail होने पर error देते हैं
  • फर्क return type में है
    • validateNonEmpty बिना information वाला () return करके check result को फेंक देता है
    • parseNonEmpty NonEmpty a return करके check से मिली knowledge को type system में छोड़ता है
  • parser को ऐसी function के रूप में देखा जा सकता है जो कम structured input consume करके अधिक structured output बनाती है
  • इस definition में parseNonEmpty list को non-empty list में parse करने वाला एक simple parser है
  • parsing program और external world की boundary पर पहले checks खत्म कर देती है, जिससे आगे वही check दोहराने की जरूरत नहीं रहती

Haskell ecosystem की parsing boundaries

  • Haskell applications external world से मिलने वाली जगहों पर कई तरह के parsers इस्तेमाल करती हैं
    • aeson: JSON data को domain types में parse करने वाला Parser type देता है
    • optparse-applicative: command-line arguments के parser combinators देता है
    • persistent, postgresql-simple: external data stores की values parse करने के mechanisms देते हैं
    • servant: path components, query parameters, HTTP headers आदि से Haskell data types parse करता है
  • external world product types और sum types में नहीं, byte streams में बात करता है, इसलिए parsing से बचा नहीं जा सकता
  • data इस्तेमाल करने से पहले सामने की boundary पर parse करने से कई तरह के bugs से बचा जा सकता है, और कुछ bugs security vulnerabilities तक बन सकते हैं
  • सब कुछ शुरुआत में parse करने के लिए actual use से काफी पहले values parse करनी पड़ सकती हैं
  • static type system में parsing logic और processing logic अलग-अलग हो जाएं, तो program compile नहीं होता

validation-केंद्रित approach का जोखिम

  • ad-hoc validation language-theoretic security क्षेत्र में कही जाने वाली shotgun parsing तक ले जा सकती है
  • 2016 के paper The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them में shotgun parsing वह anti-pattern है जिसमें parsing और input validation code, processing code में मिलकर बिखर जाते हैं
  • अगर input को शुरुआत में पूरा parse नहीं किया गया, तो program valid input के कुछ हिस्सों को process करने के बाद दूसरे हिस्से की error देर से खोज सकता है
    • ऐसे में पहले से किए गए state changes को वापस करना पड़ता है
    • RDBMS transaction की तरह rollback संभव होने के मामले भी होते हैं, लेकिन आम तौर पर यह हमेशा संभव नहीं होता
  • validation-based approach यह confirm करना मुश्किल या असंभव बना देती है कि सभी validation सच में शुरुआत में खत्म हुए हैं या नहीं
  • parsing program को parsing phase और execution phase में बांटती है, जिससे गलत input के कारण होने वाली failure पहले phase तक सीमित रहती है

practical use में लागू करने का तरीका

  • function जिस data representation को चाहती है, उसे पहले type signature में लिखें, और current given representation से उसका अंतर भरने के तरीके से design करें
  • अगर कोई function [(k, v)] list लेती है जिसमें duplicate keys allow नहीं होनी चाहिए, तो अलग checkNoDuplicateKeys :: ... => [(k, v)] -> m () check आसानी से छूट सकता है
  • बेहतर तरीका है Map को function argument के रूप में लेना, जो structurally duplicate keys allow नहीं करता
    • call site type check में fail हो सकता है
    • call chain के साथ list को Map में बदलने का काम ऊपर की ओर धकेला जाता है
    • जब उस जगह पहुंचें जहां value बनती है या जहां duplicates सच में allow होने चाहिए, तो [(k, v)] -> m (Map k v) के रूप का check लगाएं
  • इस समय check का result आगे execution में जरूरी होता है, इसलिए check छोड़ा नहीं जा सकता
  • दो principles दोहराए जाते हैं
    • ऐसी data structures इस्तेमाल करें जो impossible states को represent ही न कर सकें
    • proof का बोझ जितना हो सके ऊपर उठाएं, लेकिन जरूरी point से ज्यादा दूर न धकेलें

अतिरिक्त design guidelines और सीमाएं

  • data types को code को guide करने दें, और केवल अभी लिखी जा रही function की वजह से record में simple Bool डालने के temptation से बचें
  • m () return करने वाली functions को शक की नजर से देखने की जरूरत है
    • जब केवल imperative effects करते हैं और meaningful result नहीं होता, तो वे जरूरी हो सकते हैं
    • अगर मुख्य उद्देश्य error raise करना है, तो बेहतर तरीका होने की संभावना काफी है
  • data को कई चरणों में parse करने से डरने की जरूरत नहीं है
    • shotgun parsing से बचने का मतलब है कि पूरी तरह parse करने से पहले input data पर action न लें
    • कुछ input से यह तय करना संभव है कि दूसरे input को कैसे parse करना है
  • denormalized data representation से खासकर mutable होने पर बचना चाहिए
    • वही data कई जगह replicate करने पर आपस में inconsistent state आसानी से represent हो जाती है
    • अगर denormalization जरूरी है, तो उसे abstraction boundary के पीछे छिपाएं, और केवल छोटे trusted module को synchronization की जिम्मेदारी दें
  • जब Haskell tools से कोई invariant सच में express करना मुश्किल हो, तो abstract newtype और smart constructor से validator को parser जैसा बनाया जा सकता है
  • हर error "impossible" हटाने के लिए singletons लाकर पूरी application refactor करने की जरूरत नहीं है, लेकिन ऐसे मामलों में invariant को comment में छोड़ने जैसे तरीकों से सावधानी से संभालना चाहिए

और पढ़ने योग्य सामग्री और practical सावधानियां

  • Haskell type system का अच्छा उपयोग करने के लिए PhD या latest GHC language extensions अनिवार्य नहीं हैं
  • शुरुआत “total functions लिखो” जैसे सरल principle के करीब है, लेकिन real code में लागू करने की प्रक्रिया आसान नहीं हो सकती
  • Haskell community छोटी होने से design patterns और techniques कभी-कभी documents से ज्यादा oral knowledge के रूप में रह जाती हैं
  • related material के रूप में Matt Parson का Type Safety Back and Forth है
  • अधिक advanced topic के रूप में Matt Noonan का 2018 paper Ghosts of Departed Proofs अधिक जटिल invariants को type system में डालने की technique पर चर्चा करता है
  • real programs में कुछ specific invariants को type system में डालना मुश्किल हो सकता है, और ये principles कठोर requirements की बजाय follow करने योग्य ideals के ज्यादा करीब हैं

1 टिप्पणियां

 
GN⁺ 2024-07-23
Hacker News टिप्पणियाँ
  • यह बहुत अच्छी सलाह है और शानदार लेख है। इस साइट पर यह कभी-कभी फिर से ऊपर आता है, उसके पीछे वजह है।
    जो लोग static typed functional language का इस्तेमाल नहीं करते, उनके लिए भी यह विचार paradigm से आगे जाता है। 80~90 के दशक के object-oriented साहित्य में, जैसे Design by Contract, में भी बहुत मिलती-जुलती अवधारणाएँ दिखती हैं, और इससे भी पुराने पेपर, चर्चाएँ और specifications मिल सकते हैं।
    TypeScript भी अक्सर runtime पर types को क्रमशः narrow करने के अंदाज़ में लिखा जाता है। लगता है Design by Contract ने dynamic language Clojure के spec पर भी असर डाला होगा।
    बुनियादी तौर पर यह assumptions और guarantees का सवाल है। अगर किसी assumption की जाँच करके guarantee बनाई जा सकती है, तो प्रोग्राम के दूसरे हिस्सों को वही assumption फिर से जाँचने की ज़रूरत नहीं रहती।
    कोड पढ़ते समय जब कहीं पहले से guaranteed properties को दूसरी जगह फिर से जाँचा जाता देखता हूँ, तो वही सबसे ज़्यादा उलझाने वाला लगता है। इससे reasoning और improvement दोनों कठिन हो जाते हैं।

    • वह “पहले से guaranteed property” किसी समय गायब हो सकती है। ज़्यादा सही कहें तो उस guarantee को लागू और execute करने वाली प्रक्रिया किसी भी कारण से अपना काम करना बंद कर सकती है।
      सांख्यिकीय रूप से ऐसा कभी न कभी होता ही है, और तब वे दूसरे process, script या code, जो “मूल” validation procedure पर निर्भर थे, बहुत मुश्किल में पड़ जाते हैं।
    • जिन भाषाओं में type system मज़बूत होता है, वहाँ प्रोग्राम के बड़ा और जटिल होने के साथ यह आखिरकार आज़ादी देने वाला एक व्यावहारिक फ़ायदा बन जाता है।
      लेकिन इसे सच में इस्तेमाल करना पड़ता है। उदाहरण के लिए UncheckedEmail, ValidEmail, VerifiedEmail classes रखी जाएँ, और एक stage से अगले stage में जाने के लिए email verification process से गुज़रना अनिवार्य हो।
      तब यह अनुमान लगाने की ज़रूरत नहीं रहती कि email address unverified है, format के हिसाब से valid है, या verify हो चुका है, और is_email_verified जैसे boolean की भी ज़रूरत नहीं रहती जिसे update या check करना भूल सकते हैं। गलत जगह गलत value डालेंगे तो type checker शोर मचा देगा, और इंसान ज़्यादा ज़रूरी काम पर ध्यान दे सकता है।
    • पुराने लेख की टिप्पणियाँ सरसरी तौर पर देखने पर लगता है कि इस लेख की सबसे बड़ी समस्याओं में से एक इसका शीर्षक है। शीर्षक anchor की तरह काम करता है, इसलिए बहुत से लोग उस बात का खंडन करने लगते हैं जो मुख्य पाठ में है ही नहीं, बल्कि सिर्फ़ शीर्षक बिना संदर्भ के संकेत देता है।
      इसलिए कुछ लोग इसे ऐसे लेते हैं मानो लेखक कह रहा हो कि validation बिल्कुल मत करो, सिर्फ़ parsing करो। लेकिन असल लेख इस बारे में है कि डेटा को कहाँ validate करना है और उसके नतीजे के साथ क्या करना है। यह सभी validation हटाने की वकालत नहीं करता।
  • यह 2019 का लेख है, लेकिन आज भी काफ़ी अच्छी सलाह देता है। यह pattern आधुनिक C# में भी बहुत अच्छी तरह फिट बैठता है, और explicit variable declaration छोड़ने से थोड़ी जगह भी बचती है।
    if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
    या
    if(!Whatever.TryParse(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");
    expert tip: दूसरा वाला kernel mode driver में मत करना।

    • expert tip: दोनों में से कोई भी मत करो। खासकर पहला तो बिल्कुल मत करो।
      जब कोई value, जिसे आप सही मान बैठे थे, दरअसल गलत निकले, तब उसकी जगह इस्तेमाल होने वाला implicit default कभी भी explicit handling से बेहतर नहीं होता।
      जो करना चाहिए, वह यह है कि शुरुआत में ही हाथ खड़े कर दो और उसे parsing failure मानो, फिर un-loadable file को handle करने की process और protocol को बहुत स्पष्ट रूप से परिभाषित करो। तब आप खुद से वे कठिन सवाल पूछने लगेंगे जिन्हें ऊपर के दोनों विकल्प छूते भी नहीं।
      हाल की CrowdStrike kernel mode driver घटना में किसी def/config file की parsing failure से जुड़ी असली समस्या यह थी कि developers, product owners और business analysts ने यह सवाल ही नहीं पूछा: “अगर कोई invalid file load करने की कोशिश हो तो क्या होगा?”
    • इसे सिर्फ़ “काफ़ी अच्छा” कहने की वजह क्या है? और लेख किस साल प्रकाशित हुआ, इससे उसका क्या लेना-देना है? क्या मतलब यह है कि अगर यह 2019 से पहले प्रकाशित हुआ होता तो इसकी सलाह ज़्यादा authoritative होती?
    • मैं चाहता हूँ कि पहला तरीका इस्तेमाल न हो। बुरे case को handle करना चाहिए। “reasonable default” पर लौटने वाली handling बहुत ही दुर्लभ होनी चाहिए।
      explicit handling > implicit handling
    • if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
      इस तरह का तरीका मुझे सच में पसंद नहीं है। मेरा मानना है कि invalid input error को parsing function के बाहर handle किया जाना चाहिए। F# में यह आसान है।
      type Whatever =
      static member create input =
      match input with
      | ValidWhatever x -> Some x
      | _ -> None
      match Whatever.create input with
      | Some x -> // parsed data को handle करें
      | None -> // सही तरह parse न होने की स्थिति को handle करें
      या Option.map/Option.bind के साथ chain किए गए कामों के लिए pipeline को और सुविधाजनक बनाया जा सकता है।
      इस तरह instance केवल input को parse करने वाले create method के ज़रिए ही बनाया जा सकता है।
      हालाँकि व्यवहार में option की बजाय result इस्तेमाल करना ज़्यादा उचित लग सकता है, लेकिन वह अलग मुद्दा है।
    • if(!Whatever.TryParse(input, out var output)) output = some-sane-default; जैसा code मैं लगभग कभी, शायद बिल्कुल भी, देखना नहीं चाहूँगा।
      अगर input दिया ही नहीं गया, यानी parameter optional है, तब reasonable default का इस्तेमाल समझ में आता है।
      लेकिन अगर गलत input दिया गया है, तो कृपया ऐसा दिखावा न करें कि सब ठीक है।
      अगर कोई फूलों की दुकान में आकर coffee माँगे, तो सही जवाब उसे गुलाब पकड़ा देना नहीं है। अगर वह उसे पीने की कोशिश करे, तो उसका मुँह बुरी तरह कट जाएगा।
      उस input set के लिए method, module या program का कोई परिभाषित output नहीं है। चुपचाप गलत या अस्पष्ट काम करके प्रोग्राम को जल्दी ही reasoning के बाहर ले जाने के बजाय, इस तथ्य को साफ़-साफ़ सामने लाना चाहिए। कुछ महीनों बाद इसे weird behavior bug के रूप में पकड़ने देने से बेहतर है कि आप तुरंत स्पष्ट failure करें और ऐसा stack trace छोड़ें जो सीधे समस्या की जगह तक ले जाए।
  • strong type system का उपयोग करके error states को represent ही असंभव बना देने की सलाह है। यह पूरे software में bugs कम करने के लिए बहुत अच्छा है
    समस्या पर और गहराई से सोचने और ऐसी design करने में ज़्यादा समय लगता है, लेकिन कई मामलों में वह समय पूरी तरह वाजिब होता है

    • अगर language algebraic data types को support करती है, तो मैं बेझिझक कहूँगा कि इस तरीके में ज़्यादा समय नहीं लगता। चीज़ें बस स्वाभाविक रूप से ऐसे ही बनती हैं
      बेशक C++, Java, C#, Python, Go, JavaScript जैसी languages में, जहाँ data modeling के लिए काफ़ी conscious procedure की ज़रूरत होती है, ज़्यादा समय लगता है
  • “अब मेरे पास एक छोटा और मज़बूत slogan है जो बताता है कि type-driven design मेरे लिए क्या मायने रखता है, और इससे भी बेहतर बात यह है कि इसमें सिर्फ़ तीन शब्द हैं: Parse, don’t validate.”
    मेरा slogan इसके बजाय हमेशा सिर्फ़ एक ही constructor में validate करो के ज़्यादा क़रीब है। constructor function भी चलेगा
    ऐसा करने पर invalid objects शुरू से मौजूद ही नहीं हो सकते, और हमेशा एक single source of truth मिलता है। अगर object को modify करना हो, तो उसी constructor को फिर से call करके नया state बनाओ

    • यह बिल्कुल वही बात नहीं है
      मुख्य बात यह है कि केवल validate करने से वह जानकारी बाद में गायब हो जाती है
      उदाहरण के लिए, सिर्फ़ यह validate कर लेना कि कोई int positive है, बहुत सीमित फ़ायदा देता है। जब तक उस value को positive integer के रूप में parse नहीं किया जाता, type level पर वह जानकारी बाद में बची नहीं रहती। non-empty arrays और lists के साथ भी यही बात है, इसलिए बाद का consumer शायद फिर से जाँचे कि list सचमुच empty नहीं है
      इस तरह की जानकारी हमेशा object या constructor में encode नहीं की जा सकती
  • संबंधित सामग्री: Richard Feldman का Making Impossible States Impossible
    https://www.youtube.com/watch?v=IcgmSRJHu_8

  • पहले भी अच्छी discussions हुई हैं
    https://news.ycombinator.com/item?id=35053118
    https://news.ycombinator.com/item?id=21476261

  • जब भी यह विषय आता है, मुझे https://cr.yp.to/qmail/guarantee.html का section 5 याद आता है। वहाँ “parse मत करो” और “computing world के command interfaces दो तरह के होते हैं: अच्छे interfaces और user interfaces” जैसी पंक्तियाँ हैं
    अगर मैं छोटे या बड़े scale की नहीं बल्कि medium-scale programming सिखाने वाली class ले रहा होता, तो मैं छात्रों को इन सुझावों की तुलना और विरोध पर essay लिखने का assignment देता। हर एक से कुछ सीखने लायक है, और हो सकता है कि वे पहली नज़र में जितने विरोधाभासी लगते हैं, उतने हों नहीं

  • 2000s के मध्य के XML boom के दौरान देखा हुआ एक comment याद आता है। उसमें कहा गया था कि बहुत-सी organizations ने configuration languages समेत domain-specific languages को XML में implement इसलिए किया, क्योंकि XML parser दे देता था और ज़्यादातर organizations अपना parser खुद नहीं लिखना चाहती थीं
    लोग parser इस्तेमाल या लिखना क्यों नहीं चाहते थे, यह मुझे नहीं पता। parser लिखना इतना मुश्किल नहीं है, और काफ़ी मज़ेदार भी है

  • मेरे career में पढ़े गए लेखों में यह मेरे सबसे पसंदीदा लेखों में से एक है। मैंने अक्सर देखा है कि लोग सिर्फ़ title पढ़कर मान लेते हैं कि parsing और validation somehow एक-दूसरे के mutually exclusive हैं, लेकिन असल में ऐसा नहीं है। parsing में अक्सर validation शामिल होती है
    यह बात लेख के “Use abstract datatypes to make validators ‘look like’ parsers” हिस्से में आती है
    यह primitive obsession से बचने वाली बात के ही आसपास का विषय है