वैलिडेशन नहीं, पार्सिंग तकनीक (2019)
(lexi-lambda.github.io)- 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 नहीं है
- GHC warning देता है कि pattern matching
partial function को total function में बदलने के दो तरीके
-
return type को कमजोर करना
head :: [a] -> Maybe aमें बदलने पर empty list के लिएNothingलौटाया जा सकता है, इसलिए यह total function बन जाती है- implementation आसान हो जाती है, लेकिन caller को हमेशा
Nothingकी संभावना handle करनी पड़ती है - एक example आता है जिसमें
CONFIG_DIRSenvironment variable पढ़कर list खाली नहीं है यह पहले ही check करने के बाद भी,mainमेंheadresult कीNothingbranch दोबारा 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 anon-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में बदलता हैNothinghandling input boundary पर सिर्फ एक बार की जाती हैmainमेंinitializeCache (head configDirs)की तरह duplicate branch के बिना इस्तेमाल किया जा सकता है- आगे
getConfigurationDirectoriesअगर non-empty होने की guarantee न देने के लिए बदलता है, तो return type भी बदलना होगा, औरmaintype check में fail होगा
- return type को कमजोर किए बिना argument type को मजबूत करने पर
validation और parsing का अंतर
validateNonEmpty :: [a] -> IO ()औरparseNonEmpty :: [a] -> IO (NonEmpty a)दोनों empty list check करते हैं और fail होने पर error देते हैं- फर्क return type में है
validateNonEmptyबिना information वाला()return करके check result को फेंक देता हैparseNonEmptyNonEmpty areturn करके check से मिली knowledge को type system में छोड़ता है
- parser को ऐसी function के रूप में देखा जा सकता है जो कम structured input consume करके अधिक structured output बनाती है
- इस definition में
parseNonEmptylist को 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 करने वाला
Parsertype देता है - 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 करता है
- aeson: JSON data को domain 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 टिप्पणियां
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 दोनों कठिन हो जाते हैं।
सांख्यिकीय रूप से ऐसा कभी न कभी होता ही है, और तब वे दूसरे process, script या code, जो “मूल” validation procedure पर निर्भर थे, बहुत मुश्किल में पड़ जाते हैं।
लेकिन इसे सच में इस्तेमाल करना पड़ता है। उदाहरण के लिए
UncheckedEmail,ValidEmail,VerifiedEmailclasses रखी जाएँ, और एक stage से अगले stage में जाने के लिए email verification process से गुज़रना अनिवार्य हो।तब यह अनुमान लगाने की ज़रूरत नहीं रहती कि email address unverified है, format के हिसाब से valid है, या verify हो चुका है, और
is_email_verifiedजैसे boolean की भी ज़रूरत नहीं रहती जिसे update या check करना भूल सकते हैं। गलत जगह गलत value डालेंगे तो type checker शोर मचा देगा, और इंसान ज़्यादा ज़रूरी काम पर ध्यान दे सकता है।इसलिए कुछ लोग इसे ऐसे लेते हैं मानो लेखक कह रहा हो कि 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 में मत करना।
जब कोई 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 करने की कोशिश हो तो क्या होगा?”
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| _ -> Nonematch Whatever.create input with| Some x -> // parsed data को handle करें| None -> // सही तरह parse न होने की स्थिति को handle करेंया
Option.map/Option.bindके साथ chain किए गए कामों के लिए pipeline को और सुविधाजनक बनाया जा सकता है।इस तरह instance केवल input को parse करने वाले
createmethod के ज़रिए ही बनाया जा सकता है।हालाँकि व्यवहार में
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 करने में ज़्यादा समय लगता है, लेकिन कई मामलों में वह समय पूरी तरह वाजिब होता है
बेशक 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 कर लेना कि कोई
intpositive है, बहुत सीमित फ़ायदा देता है। जब तक उस 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 से बचने वाली बात के ही आसपास का विषय है