1 पॉइंट द्वारा GN⁺ 4 시간 전 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • C के NUL-terminated strings length जानकारी सुरक्षित नहीं रखते, जिससे बार-बार खोज और boundary errors पैदा होते हैं; आधुनिक संदर्भ में pointer और size को साथ रखने वाले length-based strings default design के रूप में अधिक उपयुक्त हैं
  • explicit length न होने पर strlen calls और byte traversal बार-बार दोहराने पड़ते हैं, और snprintf, sizeof, strlen में terminating byte शामिल है या नहीं इसके नियम अलग होते हैं, जिससे code लिखना और review करना कठिन हो जाता है
  • length-based strings empty string को size == 0 के रूप में एकसमान ढंग से संभालते हैं, और NUL byte वाले arbitrary binary data पर भी search, split और slice operations वैसे ही लागू कर सकते हैं
  • substring जो original memory के एक हिस्से को point करता है, लौटाया जा सकता है, जिससे trimming, search, tokenization और CSV, Markdown, JSON, C parsing में बीच के allocation और copy से बचा जा सकता है
  • sentinels अब भी invariants बनाए रखने और कुछ token खोज पर उपयोगी हैं, और मौजूदा C·OS APIs के साथ conversion भी ज़रूरी है, लेकिन ज़्यादातर code में length-based strings को API boundary immutability के साथ जोड़ना अधिक सरल और लचीला है

NUL termination की बजाय pointer और length संग्रहीत करें

  • C strings character stream को point करने वाले pointer और अंत को दर्शाने वाले अंतिम NUL byte पर आधारित होते हैं
    • 1970 के दशक की memory और performance constraints में यह उचित रहा हो सकता है, लेकिन आज इस तरीके को बनाए रखने की लगभग कोई वजह नहीं है
  • आधुनिक भाषाओं और प्रमुख frameworks द्वारा अपनाया गया विकल्प एक ऐसा struct है जो data pointer और size को साथ रखता है
struct String
{
    u8* data;
    u64 size;
};
  • string literals को sizeof(s) - 1 से length निकालकर String में बदला जा सकता है, और comparison, output जैसी operations सीधे stored size का उपयोग कर सकती हैं
  • arrays भी pointer में बदलते ही length जैसी जानकारी खो देते हैं, और strings जैसी safety व usability समस्याओं का सामना करते हैं

length जानकारी फेंकने की लागत

  • length store न करने पर उसे इस्तेमाल करने वाला code हर बार strlen दोहराता है या bytes को एक-एक करके traverse करता है, जिससे अनावश्यक काम और complexity बढ़ती है
  • runtime पर length जांचना और enforce करना भी धीमा और असुविधाजनक हो जाता है, और debuggers व analysis tools के लिए char*, char[N], char*[N] को लगातार एक समान ढंग से संभालना कठिन होता है
  • एक मत यह है कि C की memory समस्याओं और overflow bugs का बड़ा हिस्सा इसी design से आता है, हालांकि इसका प्रतिवाद भी है कि sentinel अकेले पर्याप्त हैं
  • length न जानने वाले strings, arrays की random access प्रकृति की तुलना में linked list जैसी sequential access pattern के अधिक करीब हो जाते हैं
  • NUL termination के समर्थक मानते हैं कि sequential processing, multi-pass की तुलना में single-pass algorithms को बढ़ावा देती है और इसलिए अधिक efficient होती है
    • beginners सहित कई programmers मान लेते हैं कि string length सस्ते में मिल जाती है और उसी आधार पर code लिखते हैं
    • सामान्य string routines भी तब अधिक स्वाभाविक बनती हैं जब length उपलब्ध हो, और बार-बार strlen calls के कारण वास्तविक code में एक ही string पर कई बार traversal होता है
    • यह मान लेना कि कम passes हमेशा तेज़ होंगे, modern CPU behavior की गलत समझ पर आधारित है

snprintf, sizeof, strlen में “length” का अलग-अलग मतलब

  • snprintf के दो integers एक ही “length” जैसे लगते हैं, लेकिन NUL inclusion rules दोनों में उलटे होते हैं
int snprintf(char *str, size_t size, const char *format, ...);
  • input size में output buffer के NUL termination की जगह शामिल होनी चाहिए, लेकिन return value उस string की length बताती है जो NUL byte को छोड़कर बनाई जाती
  • string functions या APIs का उपयोग करते समय हर बार अलग से यह देखना पड़ता है
    • क्या function खुद NUL byte लिखता है
    • क्या allocation size में NUL byte के लिए अतिरिक्त जगह जोड़नी है
    • क्या size argument और return value में NUL byte शामिल है
  • sizeof("some string") में NUL byte शामिल होता है, लेकिन strlen("some string") में नहीं
  • literal-आधारित code को runtime pointer-आधारित code में बदलते समय अगर वही - 1 रखा जाए तो length एक byte कम होने वाला bug पैदा हो जाता है
#define TEST_STRING "some string"
size_t size = sizeof(TEST_STRING) - 1;

const char *str = "some string";
size_t size = strlen(str) - 1;

बीच के NUL bytes को बार-बार overwrite करना

  • कई snprintf return values को offsets में जोड़कर strings concatenate करने पर, हर call द्वारा लिखा गया NUL termination byte अगली call overwrite कर देती है
int offset = 0;
offset += snprintf(ptr + offset, size - offset, "%d", my_int);
offset += snprintf(ptr + offset, size - offset, "%s", my_str);
offset += snprintf(ptr + offset, size - offset, "%f", my_flt);
  • बीच के termination bytes लिखना अनावश्यक है, हालांकि performance पर इसका असर बहुत बड़ा न भी हो
  • बड़ी समस्या यह है कि यह व्यवहार intuitive नहीं है, इसलिए यह गलतफहमी पैदा होती है कि snprintf NUL termination नहीं करता
  • परिणामस्वरूप अंत में ptr[offset] = 0; जोड़ने वाला code दिखाई देता है, लेकिन यह भी अनावश्यक है और उसी गलतफहमी को और मजबूत करता है

empty string और null string की दोहरी स्थिति

  • NUL-terminated model में empty string और null pointer को अलग-अलग invalid-like states की तरह लिया जाता है, इसलिए string handling code को दोनों की जांच करनी पड़ती है
  • C# की strings और arrays भी reference types हैं, इसलिए null state में Length पढ़ने या iterate करने पर exception आता है और अलग handling चाहिए
  • आम उपाय यह है कि null की जगह हमेशा empty string या empty array pass और return किए जाएँ
    • String.IsNullOrEmpty, “Don’t Return Null”, “Don’t Pass Null” जैसे नियम भी इसी भेद को संभालने के तरीके हैं
  • pointer और length वाले struct में string empty है या नहीं, यह सिर्फ size == 0 से तय किया जा सकता है
    • pointer null भी हो सकता है और valid address भी
    • string के अंत तक पहुँचने या किसी बड़ी string के अंदर empty substring को point करने पर length 0 होते हुए भी valid pointer हो सकता है
    • अगर dereference करने से पहले length जांची जाए, तो पहले से freed memory को point करने वाला pointer भी length 0 होने पर access नहीं किया जाएगा

binary data के लिए भी वही representation

  • length-based strings sentinel पर निर्भर नहीं करते, इसलिए NUL byte वाला arbitrary binary data भी सुरक्षित रूप से store किया जा सकता है
  • जैसे ASCII string functions UTF-8 पर भी काम कर सकती हैं, वैसे ही scan, split, trim, slice जैसी length-based operations byte arrays पर भी लागू की जा सकती हैं
  • NUL-terminated binary format में यह अलग से तय करना पड़ता है कि termination byte store किया जाए या नहीं
    • store करने पर read/write code सामान्य string processing के अधिक करीब आता है
    • store न करने पर space और processing overhead कम होता है, और binary format अपनाने का यह एक प्रमुख कारण है
  • ऐसा format जो length और content दोनों store करे और साथ में NUL byte भी जोड़ दे, वही ambiguity वापस ले आता है कि stored length में terminating byte शामिल है या नहीं

allocation और copy के बिना substrings

  • NUL termination लागू करने पर trimming, slicing, splitting, tokenization और search results के अंत में भी terminating byte रखना पड़ता है, इसलिए नई strings और intermediate buffers के लिए allocation और copy करनी पड़ती है
  • length-based strings में सिर्फ pointer और length लौटाकर original का एक हिस्सा दर्शाया जा सकता है
String StrPrefix(String str, u64 size);
String StrPostfix(String str, u64 size);
String StrChop(String str, u64 size);
String StrSkip(String str, u64 size);
String Substr(String a, u64 min, u64 max);

String StrFindNeedle(String str, String needle);
String StrTrim(String str);
  • CSV, Markdown, JSON, C के lexers और parsers हर token को copy किए बिना input buffer की slices को parse tree में name और value के रूप में रख सकते हैं
  • parse tree का उपयोग करने वाला बाद का code भी वही slices लेकर बिना अतिरिक्त memory management के काम कर सकता है

जहाँ sentinels अब भी उपयोगी हैं

  • sentinels data structure invariants बनाए रखने और कुछ स्थितियों में performance benefits देने में उपयोगी हो सकते हैं
  • आधुनिक hardware और compilers में performance लाभ अधिकतर विशेष मामलों तक सीमित हो गए हैं, लेकिन program द्वारा संभाले जाने वाले invariants की संख्या घटाने का फायदा अब भी बना हुआ है
  • hand-written token checks में NUL sentinel की वजह से loop वर्तमान byte को सुरक्षित रूप से पढ़ सकता है
    • s[i] == 'f' && s[i+1] == 'o' && s[i+2] == 'r' जैसी lookahead checks byte order और short-circuit evaluation का उपयोग करके जल्दी fail हो सकती हैं
    • length-based strings में यह अलग से पहचानना पड़ता है कि token whitespace पर खत्म हुआ या input ही खत्म हो गया, और lookahead के लिए अधिक व्यवस्थित boundary checks चाहिए
  • अधिकांश मौजूदा C libraries और OS APIs NUL-terminated strings मांगते हैं, इसलिए conversion cost आती है
    • Windows में UTF-8 को पहले ही UTF-16 में convert करना पड़ता है, इसलिए NUL byte जोड़ने की लागत तुलनात्मक रूप से कम है
    • OS और vendors द्वारा लगाए गए constraints को वास्तविकता से अधिक आंका जा सकता है, और length-based strings के लाभों को कम आंका जा सकता है

string APIs में immutability लागू करने का तरीका

  • एक परिपक्व string layer की पूरी API design और abstraction यहाँ scope से बाहर है, लेकिन मुख्य structural principle के रूप में immutability की सिफारिश की गई है
  • string एक बार बन जाने के बाद उसकी content बदली न जाए और उसे constant की तरह treat किया जाए; strings लेने और strings लौटाने वाले functions को भी यह गुण बनाए रखना चाहिए
  • immutability को function signatures और API boundaries पर सुनिश्चित करना पर्याप्त है
    • function के अंदर ज़रूरत के अनुसार in-place modification या अन्य procedural processing का उपयोग किया जा सकता है
    • ऊपर की information flow में functional programming वाली immutability के फायदे मिलते हैं, और नीचे के implementation में procedural programming की flexibility बनी रहती है
  • यह नियम language या runtime द्वारा enforce न किया जाए, तब भी coding और API convention के रूप में चलाया जा सकता है
  • string types और operations को स्पष्ट रूप से अलग रखने पर इन नियमों को निभाने का बोझ बहुत बड़ा नहीं होता, और codebase खुद तय कर सकता है कि कहाँ लागू करना है और कहाँ ढील देनी है
  • language भी एक API है, इसलिए सिर्फ language committee द्वारा तय constraints पर निर्भर रहने के बजाय ज़रूरत के हिसाब से नियम अधिक granular स्तर पर बनाए जा सकते हैं

length-based strings के implementation उदाहरण

दूसरी string representations की सीमाएँ

  • flexible array members

    • Redis के SDS की तरह struct के आगे current length और maximum capacity रखकर, आखिरी field को flexible array member घोषित किया जाता है और उसमें character data रखा जाता है
    • इससे strlen की O(n) scan और manual concatenation व boundary management जैसी C string समस्याओं से बचा जा सकता है
    • लेकिन string literals के साथ सीधे compatibility कठिन होती है, और यह length-based strings के एक मुख्य लाभ यानी efficient substrings को support नहीं करता
  • stretch buffers

    • stretch buffers dynamic arrays जैसे होते हैं, और length व capacity header को explicit struct की बजाय pointer के पहले वाले अलग memory area में रखते हैं
    • flexible array member approach की कमियों के अलावा, यह लगभग हर char* पर लगभग अदृश्य metadata नियम थोपता है, और अर्थपूर्ण string operations में इस header को API model का हिस्सा बनाना पड़ता है
    • pointer representation type safety दे सकती है और .str या ->str access से बचा सकती है, लेकिन केवल इस लाभ से hidden rules को justify करना कठिन है
    • अलग capacity field dynamic arrays के लिए स्वाभाविक है, लेकिन strings में यह अलग concepts को एक ही type में मिला देता है और type distinctions को धुंधला करता है
    • flexible array members में भी यही समस्या हो सकती है, हालांकि वहाँ initial fixed size को capacity की तरह इस्तेमाल करके dynamic growth से बचा जा सकता है
  • Pascal style

    • Pascal-style short strings आमतौर पर 256 bytes की fixed-size character array का उपयोग करते हैं, जिसमें length के लिए 1 byte reserve किया जाता है या NUL termination का उपयोग होता है
    • अतीत की वास्तविक memory और hardware constraints में यह उपयोगी था, लेकिन आज अच्छी arena memory allocation strategies उपलब्ध हैं, इसलिए विशेष परिस्थितियों को छोड़कर यह उपयुक्त नहीं है

length-based strings को default क्यों बनाना चाहिए

  • length-based strings के फायदे कुछ communities और अनुभवी programmers के बीच जाने-पहचाने हैं, लेकिन बहुत से developers ने अब तक इस तरह की implementation पर विचार ही नहीं किया है
  • संबंधित सामग्री कई जगह बिखरी हुई है, इसलिए length loss, terminating-byte ambiguity, binary handling, substrings, sentinels के फायदे-नुकसान और alternative implementations की एक साथ तुलना करना कठिन रहा है
  • अगर default string type को pointer और length वाली slice representation बनाया जाए, तो boundary information preservation, state simplification, binary compatibility और allocation-free substrings जैसी समस्याओं का समाधान एक ही design में किया जा सकता है

1 टिप्पणियां

 
GN⁺ 4 시간 전
Lobste.rs की राय
  • padding byte से बचना है तो struct के member order को इस तरह बदलना बेहतर लगता है

    struct String  
    {  
        u8* data;  
        u64 size;  
    };  
    

    इसकी जगह:

    struct String  
    {  
        u64 size;  
        u8* data;  
    };  
    
    • 64-bit pointer environment में दोनों field का size समान होता है, इसलिए किसी भी क्रम में padding नहीं पड़ती, और 32-bit environment में pointer से व्यक्त की जा सकने वाली range से बड़ा u64 अपने आप में ही waste है
      CHERI जैसे 128-bit pointer का उपयोग करने पर पहली layout में data और size के बीच, और दूसरी layout में struct के अंत में alignment padding बनती है, इसलिए अंततः थोड़ी जगह बर्बाद होती है

    • सही definition वह है जिसमें flexible array member का उपयोग हो

      typedef struct {  
        u64 len;  
        u8 buf[];  
      } String;  
      
  • C standard में u8 और u64 type define नहीं हैं; यह Rust-style notation है
    32-bit environment में length को u64 से दिखाना भी उपयुक्त नहीं है, और C में array size को व्यक्त करने के लिए प्रचलित type size_t है, इसलिए इस तरह लिखना अधिक स्वाभाविक है

    struct String  
    {  
        char* data;  
        size_t size;  
    };  
    

    लगता है कि यह लिखने वाला व्यक्ति या इसे बनाने वाला LLM, C को ठीक से नहीं जानता

    • बहुत-से C project u8 और u64 को typedef से define करके इस्तेमाल करते हैं, और यह बहुत आम practice है
      standard के uintN_t नाम कई लोगों को झंझट वाले लगते हैं, कई बड़े project उस standard से भी पुराने हैं, और Linux kernel भी ऐसे alias का उपयोग करता है
      उल्टा, यह आलोचना ही दिखाती है कि आलोचक C project से परिचित नहीं है
  • अगर आपने कुछ महीनों से ज़्यादा C या C++ लिखा है, तो pointer और length को साथ pass करने का तरीका पहले से ही जानी-पहचानी बात नहीं है क्या
    लगभग सभी modern C codebase अंत में data pointer और length को साथ pass करते हैं या फिर अपनी string library बना लेते हैं

    • length store करना या न करना C string की समस्याओं में से सिर्फ एक है; lifetime management में भी बहुत सिरदर्द है
      modern C++ का std::string_view यह अर्थ साफ़ करता है कि “आप इस string को देख सकते हैं, लेकिन इसे बदलना या store करना नहीं चाहिए”, जिससे bugs काफ़ी कम हो जाते हैं
      C string लेने वाले API में ऐसी पाबंदियाँ सिर्फ documentation में लिखी हुई सहमति भर होती हैं
  • असली गलती length store करना या null-termination नहीं, बल्कि String नाम की अवधारणा खुद है, जो किसी भी context में अर्थ रखने के लिए बहुत ज़्यादा व्यापक है
    string सिर्फ character array नहीं है; इसका उपयोग तरह-तरह के कामों में होता है, और यहाँ “character” शब्द भी vacuum में spherical cow मान लेने वाले physics joke की तरह बहुत ज़्यादा सरलीकरण है
    “length” का मतलब grapheme cluster की संख्या है, code point की संख्या है, या कुल byte count है—यह भी स्पष्ट नहीं होता
    यह भी अलग करना चाहिए कि encoding क्या है, क्या यह user-facing है और localization चाहिए, क्या यह sanitized है, और क्या यह map key की तरह बदलने की ज़रूरत न रखने वाला immutable value है
    इस नज़रिए से देखें तो सामान्य implementation वाला string type अब चाहने लायक नहीं लगता

    • लोग शिकायत करते हैं कि Rust में string type बहुत ज़्यादा हैं, लेकिन उनमें से कुछ का उद्देश्य ऐसी जानकारी को type system में रखना है
      मेरी जानकारी में कोई library grapheme cluster की संख्या को बस “length” नहीं कहती
      default encoding को UTF-8 मानकर, दूसरी encoding या byte array के लिए अलग type रखने की परंपरा से कुछ समस्याएँ हल की जा सकती हैं, और बाकी ज़्यादातर चीज़ें type system से व्यक्त की जा सकती हैं
  • मेरी जानकारी के अनुसार C में अधिकांश modern language की तरह language-level string concept नहीं है
    string manipulation भाषा खुद नहीं, बल्कि standard library देती है
    K&R 2nd edition समझाती है कि C अपेक्षाकृत low-level भाषा है, जो character, number और address जैसी चीज़ों के क़रीब है जिन्हें कंप्यूटर सीधे संभालता है, और यह string, set, list, array जैसी composite object पूरी की पूरी सीधे manipulate करने वाले operation नहीं देती
    यह कमी बड़ी खामी लग सकती है, लेकिन भाषा को छोटा रखने की वजह से इसे संक्षेप में बताया जा सकता है, जल्दी सीखा जा सकता है, और programmer भाषा को पूरी तरह समझकर रोज़मर्रा में उपयोग कर सकता है
    C को इस तरह क्यों design किया गया और आज जो modern feature स्वाभाविक लगते हैं वे इसमें क्यों नहीं हैं, यह समझना हो तो K&R 2nd edition की preface और introduction ज़रूर पढ़ें

    • हालांकि, string literal के type और memory layout library के रूप को तय करते हैं—इस सीमित अर्थ में string भी C language का हिस्सा है
      यह Rust जैसी low-level language में language और library के बीच के भेद से बहुत अलग नहीं है
  • serialization method के रूप में https://cr.yp.to/proto/netstrings.txt जैसी proposals हैं, और https://web.archive.org/web/20230305073119/… की तरह complex structure तक संभालने वाले तरीके भी हैं
    लेकिन शायद सुरक्षित parsing के लिए इनका रूप बहुत ज़्यादा सरल है, इसलिए XML, JSON, YAML, Protocol Buffers आदि के उत्साही समर्थक लगातार व्यस्त रह सकते हैं