Web development और system-to-system संचार में, वर्तमान में सबसे व्यापक रूप से उपयोग की जाने वाली डेटा विवरण भाषा (data description language) निस्संदेह JSON (JavaScript Object Notation) है। दुनिया में RapidJSON और simdjson जैसे बहुत अच्छे और तेज़ JSON पार्सर पहले से मौजूद हैं। व्यावहारिक रूप से, आपको अपने स्वयं के पार्सर को प्रोडक्शन कोड में उपयोग करने के अवसर कम ही मिल सकते हैं, लेकिन “अपना खुद का JSON पार्सर बनाना” सिंटैक्स विश्लेषण (parsing), मेमोरी प्रबंधन, स्ट्रिंग प्रोसेसिंग और परफॉरमेंस ट्यूनिंग सीखने के लिए एक बहुत ही बेहतरीन विषय है।
इस लेख में, हम C++17/C++20 के आधुनिक फीचर्स (जैसे std::string_view, std::variant, std::from_chars) का उपयोग करके खरोंच (scratch) से एक तेज़ और मेमोरी-कुशल JSON पार्सर बनाने की प्रक्रिया को विस्तार से समझाएंगे।
1. JSON विशिष्टता (RFC 8259) का पुनरावलोकन
JSON की विशिष्टता (specification) RFC 8259 में सख्ती से परिभाषित है। पार्सर लिखने के लिए, आपको पहले विशिष्टता को सही ढंग से समझना होगा।
JSON के डेटा प्रकार केवल निम्नलिखित 6 प्रकारों तक सीमित हैं।
- Object (ऑब्जेक्ट): स्ट्रिंग की (key) और वैल्यू (value) के जोड़ों का एक अव्यवस्थित (unordered) संग्रह। इसे
{}के अंदर रखा जाता है, और प्रत्येक जोड़े को,से अलग किया जाता है। - Array (ऐरे): मानों (values) की एक क्रमित (ordered) सूची। इसे
[]के अंदर रखा जाता है, और मानों को,से अलग किया जाता है। - String (स्ट्रिंग): डबल कोट्स
""में संलग्न यूनिकोड वर्णों का एक अनुक्रम (sequence)। इसमें बैकस्लैश\के माध्यम से एस्केप वर्ण शामिल हो सकते हैं। - Number (संख्या): एक पूर्णांक (integer) या फ्लोटिंग-पॉइंट संख्या। इन्फिनिटी (
Infinity) और नॉट-ए-नंबर (NaN) की अनुमति नहीं है। - Boolean (बूलियन):
trueयाfalse। - Null:
null।
विशिष्टता के अनुसार, खाली स्थान वर्ण (Space, Horizontal Tab, Line Feed, Carriage Return) टोकन के बीच कहीं भी डाले जा सकते हैं, और सिंटैक्स का विश्लेषण करते समय इन्हें अनदेखा किया जाना चाहिए।
2. पार्सर का आर्किटेक्चर
पार्सिंग (Parsing) प्रक्रिया को आम तौर पर दो चरणों में विभाजित किया जाता है: लेक्सिकल एनालिसिस (Lexical Analysis) और सिंटैक्स एनालिसिस (Syntactic Analysis)।
graph TD
A["इनपुट JSON स्ट्रिंग"] --> B["लेक्सर (टोकेनाइज़र)"]
B --> C["टोकन स्ट्रीम"]
C --> D["पार्सर (रिकर्सिव डिसेंट)"]
D --> E["AST / DOM ट्री (JsonValue)"]
style A fill:#f9f,stroke:#333,stroke-width:2px
style E fill:#bbf,stroke:#333,stroke-width:2px
- लेक्सर (Lexer / Tokenizer): यह इनपुट रॉ स्ट्रिंग (कैरेक्टर ऐरे) को शुरुआत से पढ़ता है और इसे “सार्थक न्यूनतम इकाइयों (टोकन)” में विभाजित करता है।
- पार्सर (Parser): यह लेक्सर से प्राप्त टोकन के अनुक्रम (sequence) को पढ़ता है, और व्याकरण के नियमों के अनुसार एक ट्री संरचना (DOM ट्री: Document Object Model) बनाता है।
इस कार्यान्वयन (implementation) में, मेमोरी दक्षता में सुधार करने के लिए, लेक्सर को इस तरह से डिज़ाइन किया गया है कि यह स्ट्रिंग की प्रतिलिपि (copy) नहीं बनाता है, बल्कि मूल इनपुट स्ट्रिंग के लिए एक पॉइंटर और लंबाई (std::string_view) रखता है।
3. AST (DOM) मॉडल का डिज़ाइन और आधुनिक C++
C++ में विभिन्न JSON डेटा प्रकारों का प्रतिनिधित्व करने के लिए, हम C++17 में पेश किए गए std::variant का उपयोग करेंगे। std::variant एक प्रकार-सुरक्षित (type-safe) यूनियन (Union) है, जो डायनामिक टाइपिंग वाले JSON डेटा का प्रतिनिधित्व करने के लिए आदर्श है।
| |
इस तरह से डिज़ाइन करने पर, हम JsonArray और JsonObject जैसी रिकर्सिव डेटा संरचनाओं को आसानी से और सुरक्षित रूप से व्यक्त कर सकते हैं (हालांकि कुछ C++ मानक लाइब्रेरी के कार्यान्वयन में std::variant के भीतर अपूर्ण प्रकारों (incomplete types) के उपयोग पर प्रतिबंध है, जिसके लिए स्मार्ट पॉइंटर्स का उपयोग करके हीप आवंटन (heap allocation) की आवश्यकता हो सकती है, लेकिन नवीनतम कंपाइलरों में ऊपर दिया गया कोड आमतौर पर काम करता है)।
4. लेक्सर (Lexical Analyzer) का कार्यान्वयन
लेक्सर की भूमिका स्ट्रिंग को पढ़ना और उसे टोकन में काटना है। सबसे पहले, हम टोकन के प्रकारों को परिभाषित करेंगे।
| |
लेक्सर के आंतरिक स्टेट ट्रांज़िशन (state transition) को Mermaid का उपयोग करके इस प्रकार देखा जा सकता है:
stateDiagram-v2
[*] --> Start : "व्हाइटस्पेस छोड़ें"
Start --> ParseString : "डबल कोट ('\"')"
Start --> ParseNumber : "अंक या माइनस ('-')"
Start --> ParseKeyword : "वर्ण ('t', 'f', 'n')"
Start --> ParseSymbol : "विराम चिह्न ('{', '[', आदि)"
ParseString --> Start : "कोट समाप्त ('\"')"
ParseNumber --> Start : "गैर-अंक"
ParseKeyword --> Start : "कीवर्ड मैच"
ParseSymbol --> Start : "सिंगल कैरेक्टर"
लेक्सर का मुख्य कार्यान्वयन इस प्रकार है। रिक्त स्थान को छोड़ते हुए, यह वर्तमान वर्ण (character) के आधार पर शाखाएँ (switch या if स्टेटमेंट के माध्यम से) बनाता है।
| |
यहाँ मुख्य बात यह है कि हम स्ट्रिंग (String) और संख्या (Number) के मानों को std::string_view के रूप में निकालते हैं। परिणामस्वरूप, लेक्सर चरण के दौरान कोई डायनामिक मेमोरी एलोकेशन (हीप एलोकेशन) या कॉपी नहीं होती है। यह एक महत्वपूर्ण डिज़ाइन है जो सीधे प्रदर्शन (performance) को प्रभावित करता है।
5. सिंटैक्स एनालाइज़र (पार्सर) का कार्यान्वयन: रिकर्सिव डिसेंट पार्सिंग
लेक्सर के पूरा होने के बाद, अगला कदम पार्सर है। चूंकि JSON का व्याकरण LL(1) व्याकरण है, यह रिकर्सिव डिसेंट पार्सिंग (Recursive Descent Parsing) के लिए अत्यधिक उपयुक्त है, जहां आप “केवल वर्तमान टोकन को देखकर” यह तय कर सकते हैं कि आगे किस फ़ंक्शन को कॉल करना है।
| |
रिकर्सिव डिसेंट पार्सर का कोड संरचना और JSON व्याकरण (BNF) के बीच वन-टू-वन (1-to-1) संबंध होता है, जिससे कोड सहज (intuitive) और पढ़ने में आसान हो जाता है। उदाहरण के लिए, parseObject में, सिंटैक्स को की (String) -> कोलन (Colon) -> वैल्यू (Value) के क्रम में पार्स किया जाता है।
6. प्रदर्शन अनुकूलन (Performance Optimization) तकनीकें
केवल एक साधारण पार्सर लागू करना एक व्यावहारिक (practical) लाइब्रेरी को मात देने के लिए पर्याप्त नहीं है। यहां कुछ C++ विशिष्ट अनुकूलन (optimization) तकनीकें दी गई हैं।
6.1. जीरो-कॉपी आर्किटेक्चर और std::string_view
पार्सर के प्रदर्शन में अधिकांश रुकावटें “स्ट्रिंग कॉपी करने” और “हीप मेमोरी के डायनामिक एलोकेशन” के कारण होती हैं।
जब आप std::string का बहुत अधिक उपयोग करते हैं, तो हर बार सबस्ट्रिंग बनाते समय मेमोरी एलोकेशन होता है। इसे रोकने के लिए, लेक्सर में पूरी तरह से std::string_view का उपयोग किया गया है।
std::string_view का निर्माण समय स्ट्रिंग की लंबाई $L$ पर निर्भर नहीं करता है और $O(1)$ में पूरा होता है।
6.2. संख्या पार्सिंग अनुकूलन (std::from_chars)
मानक std::stod या sscanf वर्तमान लोकेल (Locale) सेटिंग्स पर निर्भर करते हैं, जिसका अर्थ है कि वे आंतरिक रूप से म्युचुअल एक्सक्लूजन (mutual exclusion) के लिए लॉक प्राप्त कर सकते हैं और लोकलाइज़ेशन ओवरहेड (localization overhead) का कारण बन सकते हैं।
C++17 में पेश किया गया std::from_chars लोकेल-स्वतंत्र (locale-independent) है और इसमें मेमोरी कॉपी शामिल नहीं है, जिससे यह संख्या पार्सिंग में अत्यधिक प्रदर्शन प्रदान करता है। यदि अंकों की संख्या $M$ है, तो इसकी समय जटिलता (time complexity) $O(M)$ होती है।
6.3. मेमोरी एलोकेशन और std::pmr (Polymorphic Memory Resources)
AST का निर्माण करते समय, std::vector या std::map नोड्स के निर्माण के कारण बड़ी संख्या में छोटे एलोकेशन (फ़्रेगमेंटेशन) होते हैं।
इसे रोकने के लिए, कस्टम एलोकेटर के रूप में C++17 के std::pmr::monotonic_buffer_resource का उपयोग करना प्रभावी है। चूंकि यह पहले से एक बड़ा मेमोरी ब्लॉक आवंटित करता है और केवल वहां से पॉइंटर को आगे बढ़ाकर मेमोरी काटता है, इसलिए एलोकेशन की लागत लगभग शून्य हो जाती है।
6.4. SIMD का उपयोग (Advanced)
simdjson जैसे अत्याधुनिक (state-of-the-art) पार्सर AVX2 और NEON जैसे SIMD निर्देशों (instructions) का उपयोग करते हैं, ताकि एक साथ 32-बाइट या 64-बाइट स्ट्रिंग्स को स्कैन किया जा सके। इससे व्हाइटस्पेस छोड़ने और कोटेशन खोजने में काफी तेज़ी आती है। इस लेख में कार्यान्वयन वर्ण-दर-वर्ण (character-by-character) स्कैनिंग पर आधारित है, लेकिन यदि आप सर्वोच्च प्रदर्शन (peak performance) प्राप्त करना चाहते हैं, तो ब्रांचलेस प्रोग्रामिंग और SIMD आवश्यक हैं।
7. जटिलता (Complexity) और एल्गोरिथ्म का मूल्यांकन
हम इस पार्सर की एल्गोरिथम जटिलता का मूल्यांकन करेंगे। मान लें कि इनपुट JSON स्ट्रिंग की कुल लंबाई $N$ बाइट्स है।
समय जटिलता (Time Complexity): लेक्सर प्रत्येक वर्ण को केवल एक निश्चित समय (आमतौर पर 1 बार) संदर्भित करता है, और पार्सर प्रत्येक टोकन के लिए एक निश्चित समय तक काम करता है। कोई बैकट्रैकिंग (दोबारा पढ़ना) नहीं होती है। इसलिए, समग्र समय जटिलता रैखिक समय (linear time) है।
$$ T(N) = O(N) $$स्थान जटिलता (Space Complexity):
AST (DOM ट्री) बनाने के लिए आवंटित मेमोरी JSON स्ट्रिंग में तत्वों की संख्या के समानुपाती (proportional) होती है। सबसे खराब स्थिति (उदाहरण के लिए: एक विशाल नेस्टेड ऐरे [[[[...]]]]) पर विचार करने पर भी, आवश्यक मेमोरी की मात्रा इनपुट आकार $N$ से अधिक नहीं होगी और यह एक स्थिर गुणक (constant multiple) के भीतर रहेगी।
हालाँकि, रिकर्सिव डिसेंट पार्सिंग में, JSON की नेस्टिंग की गहराई (Depth) के अनुपात में कॉल स्टैक की खपत होती है। गहराई $D$ के लिए, $O(D)$ स्टैक मेमोरी की आवश्यकता होती है। यदि कोई दुर्भावनापूर्ण (malicious) रूप से अनंत नेस्टेड JSON देता है, तो इससे स्टैक ओवरफ्लो (Stack Overflow) होने का खतरा होता है। इसलिए, एक व्यावहारिक पार्सर में, रिकर्सन की गहराई पर एक ऊपरी सीमा (जैसे 256 या 512) सेट करना, या रिकर्सन को लूप में बदलने का प्रयास करना आवश्यक है।
8. निष्कर्ष
इस लेख में, हमने बताया कि C++ का उपयोग करके शुरू से (from scratch) अपना खुद का JSON पार्सर कैसे बनाया जाए।
- लेक्सर स्ट्रिंग को टोकन में विभाजित करता है, और अनावश्यक कॉपी को रोकने के लिए
std::string_viewका उपयोग किया जाता है। - पार्सर में, टोकन को रिकर्सिव डिसेंट पार्सिंग का उपयोग करके AST (
std::variant) में बदल दिया जाता है। - प्रदर्शन (Performance) को ध्यान में रखते हुए,
std::from_charsऔर मेमोरी प्रबंधन रणनीतियों को लागू किया गया है।
किसी भाषा की विशिष्टता को पढ़ने और उसे कोड में बदलने का अनुभव आपको एक प्रोग्रामर के रूप में कौशल के अगले स्तर तक ले जाता है। इस लेख को एक शुरुआती बिंदु के रूप में उपयोग करते हुए, अपने स्वयं के पार्सर या सीरियलाइज़र (JSON स्ट्रिंग्स बनाना) का विस्तार करने का प्रयास करें, और आगे के अनुकूलन (जैसे कस्टम एलोकेटर्स या SIMD की शुरूआत) की चुनौती लें।
