- สตริงแบบจบด้วย NUL ของ C ไม่เก็บข้อมูลความยาวไว้ จึงก่อให้เกิดการค้นหาซ้ำและข้อผิดพลาดเรื่องขอบเขต ขณะที่ในยุคปัจจุบัน สตริงแบบอิงความยาวที่เก็บพอยน์เตอร์และขนาดไว้ด้วยกันเหมาะจะเป็นดีไซน์พื้นฐานมากกว่า
- เมื่อไม่มีความยาวแบบชัดเจน ก็ต้องเรียก
strlen และไล่วนทีละไบต์ซ้ำ ๆ อีกทั้ง snprintf, sizeof, strlen ต่างก็มี กฎเรื่องการนับไบต์ปิดท้าย ไม่เหมือนกัน ทำให้ทั้งการเขียนและรีวิวโค้ดยากขึ้น
- สตริงแบบอิงความยาวจัดการสตริงว่างอย่างสม่ำเสมอด้วย
size == 0 และยังใช้การค้นหา แบ่ง และสไลซ์กับ ข้อมูลไบนารีตามอำเภอใจ ที่มีไบต์ NUL อยู่ภายในได้โดยตรง
- สามารถคืนค่า สตริงย่อย ที่ชี้ไปยังบางส่วนของหน่วยความจำต้นฉบับได้ จึงหลีกเลี่ยงการจัดสรรและคัดลอกระหว่างทางในการตัด ค้นหา token และการพาร์ส CSV, Markdown, JSON และ C
- sentinel ยังมีประโยชน์ต่อการรักษา invariant และการค้นหา token บางแบบ รวมถึงยังต้องมีการแปลงเพื่อทำงานกับ C และ OS API เดิมอยู่บ้าง แต่สำหรับโค้ดส่วนใหญ่ สตริงแบบอิงความยาวที่จับคู่กับ ความไม่เปลี่ยนรูปที่ขอบเขต API จะเรียบง่ายและยืดหยุ่นกว่า
เก็บพอยน์เตอร์และความยาวแทนการจบด้วย NUL
- สตริง C ใช้พอยน์เตอร์ที่ชี้ไปยังสตรีมของตัวอักษร และใช้ไบต์
NUL ตัวสุดท้ายเป็นตัวบอกขอบเขต
- ภายใต้ข้อจำกัดด้านหน่วยความจำและประสิทธิภาพของยุค 1970 วิธีนี้อาจสมเหตุสมผล แต่ในปัจจุบันแทบไม่มีเหตุผลให้คงแนวทางนี้ไว้
- ทางเลือกที่ภาษาใหม่และเฟรมเวิร์กหลักจำนวนมากใช้คือสตรักต์ที่เก็บ พอยน์เตอร์ข้อมูลและขนาด ไว้ด้วยกัน
struct String
{
u8* data;
u64 size;
};
- สตริงลิเทอรัลสามารถคำนวณความยาวด้วย
sizeof(s) - 1 แล้วสร้างเป็น String ได้ และการดำเนินการอย่างการเปรียบเทียบหรือแสดงผลก็ใช้ขนาดที่เก็บไว้โดยตรง
- อาร์เรย์เองก็เจอปัญหาด้านความปลอดภัยและการใช้งานคล้ายสตริงเช่นกัน เมื่อมันถูกแปลงเป็นพอยน์เตอร์แล้วสูญเสียข้อมูลอย่างความยาวไป
ต้นทุนที่เกิดขึ้นเมื่อทิ้งข้อมูลความยาว
- หากไม่เก็บความยาวไว้ โค้ดที่ใช้งานก็ต้องเรียก
strlen ซ้ำ ๆ หรือวนดูทีละไบต์เอง ทำให้เกิด งานที่ไม่จำเป็น และเพิ่มความซับซ้อน
- การตรวจสอบและบังคับเรื่องความยาวตอนรันไทม์ก็ช้ากว่าและยุ่งยากกว่า อีกทั้งดีบักเกอร์และเครื่องมือวิเคราะห์ยังจัดการ
char*, char[N], char*[N] ให้สอดคล้องกันได้ยาก
- มีมุมมองว่าปัญหาหน่วยความจำและบั๊กโอเวอร์โฟลว์จำนวนมากของ C มีรากมาจากดีไซน์นี้ แต่ก็มีข้อโต้แย้งว่า sentinel เพียงอย่างเดียวก็เพียงพอแล้ว
- สตริงที่ไม่รู้ความยาวจะมีลักษณะใกล้กับ รูปแบบการเข้าถึงแบบลำดับ คล้าย linked list มากกว่าการเข้าถึงแบบสุ่มของอาร์เรย์
- ฝ่ายที่สนับสนุนการจบด้วย NUL มองว่าการประมวลผลแบบลำดับช่วยผลักดันให้ออกแบบอัลกอริทึมแบบเดินครั้งเดียว ซึ่งมีประสิทธิภาพกว่าหลายพาส
- โปรแกรมเมอร์จำนวนมากรวมถึงมือใหม่ มักเขียนโค้ดโดยสมมติว่าสามารถได้ความยาวสตริงมาในต้นทุนต่ำ
- รูทีนสตริงทั่วไปก็เขียนได้เป็นธรรมชาติกว่าเมื่อมีความยาวให้ และในโค้ดจริงมักต้องวนผ่านสตริงเดียวกันหลายรอบเพราะเรียก
strlen ซ้ำ
- สมมติฐานที่ว่าจำนวนพาสน้อยกว่าย่อมเร็วกว่าเสมอ เป็นผลจากความเข้าใจคลาดเคลื่อนเกี่ยวกับวิธีการทำงานของ CPU สมัยใหม่
snprintf, sizeof, strlen มีความหมายของความยาวไม่เหมือนกัน
- จำนวนเต็มสองตัวของ
snprintf ดูเหมือนจะเป็น “ความยาว” แบบเดียวกัน แต่จริง ๆ แล้วมี กฎการนับ NUL ตรงข้ามกัน
int snprintf(char *str, size_t size, const char *format, ...);
- ค่า
size ที่ป้อนเข้าไปต้องนับรวมพื้นที่สำหรับ NUL ปิดท้ายของบัฟเฟอร์เอาต์พุต แต่ค่าที่คืนกลับมาจะหมายถึงความยาวของสตริงที่ควรถูกสร้างขึ้นโดยไม่รวมไบต์ NUL
- ทุกครั้งที่ใช้ฟังก์ชันหรือ API ที่เกี่ยวกับสตริง ต้องแยกตรวจสอบเงื่อนไขต่อไปนี้
- ฟังก์ชันนั้นเขียนไบต์ NUL ลงไปเองหรือไม่
- ต้องเผื่อพื้นที่ไบต์ NUL เพิ่มในการจัดสรรหรือไม่
- อาร์กิวเมนต์ขนาดและค่าที่คืนกลับมานับรวมไบต์ NUL หรือไม่
sizeof("some string") นับรวมไบต์ NUL แต่ strlen("some string") ไม่นับ
- หากแปลงโค้ดที่อิงลิเทอรัลไปเป็นโค้ดที่อิงพอยน์เตอร์ตอนรันไทม์ แล้วคง
- 1 เดิมไว้ จะเกิด บั๊กที่ทำให้ความยาวสั้นไปหนึ่ง
#define TEST_STRING "some string"
size_t size = sizeof(TEST_STRING) - 1;
const char *str = "some string";
size_t size = strlen(str) - 1;
การเขียนทับไบต์ NUL ตรงกลางซ้ำ ๆ
- หากนำค่าที่
snprintf คืนมาหลายครั้งมาบวกเป็นออฟเซ็ตเพื่อเชื่อมสตริงเข้าด้วยกัน ไบต์ NUL ปิดท้าย ที่แต่ละครั้งเขียนไว้จะถูกการเรียกครั้งถัดไปเขียนทับ
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);
- การเขียนไบต์ปิดท้ายตรงกลางเหล่านี้เป็นงานที่ไม่จำเป็น แต่ผลกระทบด้านประสิทธิภาพอาจไม่ได้มากนัก
- ปัญหาที่ใหญ่กว่าคือพฤติกรรมนี้ไม่เป็นธรรมชาติและทำให้คนเข้าใจผิดว่า
snprintf ไม่ได้ใส่ NUL ปิดท้าย
- ผลลัพธ์คือเกิดโค้ดที่เติม
ptr[offset] = 0; ไว้ท้ายสุด ทั้งที่ไม่จำเป็น และยิ่งตอกย้ำความเข้าใจผิดเดิม
สถานะคู่ของสตริงว่างและสตริง null
- ในโมเดลแบบจบด้วย NUL สตริงว่างและ null pointer ถูกมองเป็น สถานะที่ไม่ถูกต้อง คนละแบบ ทำให้โค้ดจัดการสตริงต้องตรวจทั้งสองกรณี
- สตริงและอาร์เรย์ของ C# ก็เป็น reference type เช่นกัน ดังนั้นหากอยู่ในสถานะ null แล้วไปอ่าน
Length หรือวนลูปก็จะเกิด exception จึงต้องมีการจัดการแยก
- วิธีรับมือที่พบบ่อยคือส่งและคืนค่าเป็นสตริงว่างหรืออาร์เรย์ว่างเสมอแทน null
- กฎอย่าง
String.IsNullOrEmpty, “Don’t Return Null”, “Don’t Pass Null” ก็เป็นแนวทางสำหรับจัดการความแยกนี้
- ในสตรักต์ที่มีพอยน์เตอร์และความยาว สามารถตัดสินว่าสตริงว่างหรือไม่ด้วย
size == 0 เพียงอย่างเดียว
- พอยน์เตอร์อาจเป็น null หรือเป็นที่อยู่ที่ใช้ได้ก็ได้
- เมื่อเลื่อนไปถึงท้ายสตริงแล้ว หรือเมื่อชี้ไปยังสตริงย่อยว่างภายในสตริงใหญ่ ก็อาจมีพอยน์เตอร์ที่ใช้ได้แม้ความยาวจะเป็น 0
- หาก dereference หลังจากตรวจความยาวแล้วเท่านั้น แม้แต่พอยน์เตอร์ที่ชี้ไปยังหน่วยความจำที่ถูก free ไปแล้ว ก็จะไม่ถูกเข้าถึงในสถานะความยาว 0
ใช้รูปแบบแทนข้อมูลเดียวกันกับไบนารี
- สตริงแบบอิงความยาวไม่พึ่งพา sentinel จึงเก็บ ข้อมูลไบนารีตามอำเภอใจที่มีไบต์ NUL อยู่ภายใน ได้อย่างปลอดภัย
- เช่นเดียวกับที่ฟังก์ชันสตริง ASCII ยังใช้กับ UTF-8 ได้ การสแกน แบ่ง ตัดขอบ และสไลซ์แบบอิงความยาวก็ใช้กับอาร์เรย์ไบต์ได้เช่นกัน
- ฟอร์แมตไบนารีแบบจบด้วย NUL ต้องตัดสินใจแยกต่างหากว่าจะเก็บไบต์ปิดท้ายไว้หรือไม่
- ถ้าเก็บไว้ โค้ดอ่านเขียนจะใกล้เคียงกับการประมวลผลสตริงทั่วไปมากขึ้น
- ถ้าไม่เก็บ ก็ลดทั้งพื้นที่และโอเวอร์เฮดในการประมวลผลได้ ซึ่งเป็นหนึ่งในเหตุผลหลักของการใช้ฟอร์แมตไบนารี
- ฟอร์แมตที่เก็บทั้งความยาว เนื้อหา และยังต่อท้ายด้วยไบต์ NUL จะนำความกำกวมเดิมกลับมาอีก เพราะต้องตรวจสอบซ้ำว่าความยาวที่เก็บไว้นับรวมไบต์ปิดท้ายหรือไม่
สตริงย่อยโดยไม่ต้องจัดสรรและคัดลอก
- หากบังคับให้จบด้วย NUL การ trim, slice, split, tokenize และผลการค้นหาจะต้องวางไบต์ปิดท้ายไว้ท้ายผลลัพธ์ด้วย จึงต้อง จัดสรรและคัดลอก ไปยังสตริงใหม่หรือบัฟเฟอร์กลาง
- สตริงแบบอิงความยาวสามารถคืนกลับมาเป็นเพียงพอยน์เตอร์และความยาวที่ชี้ไปยังบางส่วนของต้นฉบับได้
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);
- lexer และ parser สำหรับ CSV, Markdown, JSON และ C สามารถเก็บ slice ของบัฟเฟอร์อินพุตเป็นชื่อและค่าของ parse tree ได้ โดยไม่ต้องคัดลอกแต่ละ token
- โค้ดภายหลังที่ใช้งาน parse tree ก็รับ slice ชุดเดิมไปใช้งานต่อได้โดยไม่ต้องมีการจัดการหน่วยความจำเพิ่ม
กรณีที่ sentinel ยังมีประโยชน์
- sentinel ยังมีประโยชน์ในการรักษาinvariant ของโครงสร้างข้อมูล และอาจให้ข้อได้เปรียบด้านประสิทธิภาพในบางสถานการณ์
- บนฮาร์ดแวร์และคอมไพเลอร์สมัยใหม่ ข้อได้เปรียบด้านประสิทธิภาพมักลดความสำคัญลง ยกเว้นบางกรณีเฉพาะ แต่ผลในด้าน ลดจำนวน invariant ที่โปรแกรมต้องดูแลยังคงอยู่
- ในการตรวจ token ที่เขียนเอง NUL sentinel ช่วยให้ลูปอ่านไบต์ปัจจุบันได้อย่างปลอดภัย
- การมองล่วงหน้าอย่าง
s[i] == 'f' && s[i+1] == 'o' && s[i+2] == 'r' สามารถล้มเหลวได้ตั้งแต่เนิ่น ๆ โดยอาศัยลำดับไบต์และ short-circuit evaluation
- สตริงแบบอิงความยาวต้องแยกกรณีที่ token จบเพราะช่องว่าง ออกจากกรณีที่อินพุตจบจริง และยังต้องมีการตรวจขอบเขตอย่างเป็นระบบมากขึ้นสำหรับการมองล่วงหน้า
- ไลบรารี C และ OS API เดิมส่วนใหญ่ยังต้องการสตริงแบบจบด้วย NUL จึงมีต้นทุนในการแปลง
- บน Windows มักต้องแปลง UTF-8 เป็น UTF-16 อยู่แล้ว ดังนั้นต้นทุนของการเพิ่มไบต์ NUL จึงเล็กน้อยเมื่อเทียบกัน
- ข้อจำกัดที่ OS และผู้ขายกำหนดไว้อาจถูกประเมินว่าสำคัญเกินจริง ขณะที่ข้อดีของสตริงแบบอิงความยาวกลับถูกประเมินต่ำไป
วิธีนำความไม่เปลี่ยนรูปมาใช้กับ API สตริง
- แม้การออกแบบ API ทั้งชุดและ abstraction ทั้งหมดของชั้นสตริงที่สมบูรณ์จะอยู่นอกขอบเขต แต่หลักโครงสร้างสำคัญที่แนะนำคือ ความไม่เปลี่ยนรูป
- เมื่อสร้างสตริงแล้ว ควรปฏิบัติต่อมันเหมือนค่าคงที่โดยไม่แก้ไขเนื้อหา และฟังก์ชันที่รับสตริงแล้วคืนสตริงก็ควรรักษาคุณสมบัตินี้ไว้
- เพียงรับประกันความไม่เปลี่ยนรูปที่ function signature และขอบเขต API ก็เพียงพอแล้ว
- ภายในฟังก์ชันยังสามารถแก้ไขแบบ in-place หรือใช้กระบวนการเชิงลำดับอื่น ๆ ได้ตามจำเป็น
- ในระดับการไหลของข้อมูลด้านบน จะได้ข้อดีของความไม่เปลี่ยนรูปแบบ functional programming ขณะที่รายละเอียดการใช้งานด้านล่างยังคงความยืดหยุ่นแบบ procedural programming
- กฎนี้สามารถใช้เป็น ธรรมเนียมการเขียนโค้ดและ API ได้ แม้ภาษาและรันไทม์จะไม่ได้บังคับ
- หากแยกชนิดและการดำเนินการของสตริงให้ชัดเจน ภาระในการรักษากฎนี้ก็ไม่สูง และโค้ดเบสสามารถกำหนดขอบเขตการใช้รวมถึงจุดที่ผ่อนคลายกฎได้เอง
- ภาษาเองก็เป็น API รูปแบบหนึ่ง ดังนั้นแทนที่จะพึ่งข้อจำกัดที่คณะกรรมการภาษากำหนดไว้เพียงอย่างเดียว ก็สามารถออกแบบกฎในระดับความละเอียดที่ต้องการได้
ตัวอย่างการใช้งานสตริงแบบอิงความยาว
ข้อจำกัดของรูปแบบสตริงแบบอื่น
-
flexible array member
- เช่นเดียวกับ SDS ของ Redis วิธีนี้เก็บ ความยาวปัจจุบันและความจุสูงสุด ไว้ที่ส่วนหัวของสตรักต์ แล้วประกาศฟิลด์สุดท้ายเป็น flexible array member เพื่อวางข้อมูลตัวอักษรต่อท้าย
- จึงเลี่ยงปัญหาของสตริง C อย่างการสแกน
strlen แบบ O(n) และการเชื่อมต่อหรือจัดการขอบเขตด้วยมือได้
- แต่เข้ากับสตริงลิเทอรัลโดยตรงได้ยาก และไม่รองรับสตริงย่อยอย่างมีประสิทธิภาพ ซึ่งเป็นข้อดีหลักของสตริงแบบอิงความยาว
-
stretch buffer
- stretch buffer คล้ายอาร์เรย์ไดนามิก โดยเก็บส่วนหัวความยาวและความจุไว้ในพื้นที่แยกก่อนหน้าพอยน์เตอร์ แทนการใช้สตรักต์แบบชัดเจน
- นอกจากข้อเสียแบบ flexible array member แล้ว ยังเพิ่มกฎเมทาดาทาที่แทบมองไม่เห็นให้กับ
char* ทุกตัว และต้องใช้ส่วนหัวนั้นเป็นส่วนหนึ่งของโมเดล API ในทุกการดำเนินการสตริงที่มีความหมาย
- การแทนด้วยพอยน์เตอร์อาจให้ type safety และเลี่ยงการเข้าถึงแบบ
.str หรือ ->str ได้ แต่ข้อดีเพียงเท่านี้ยังยากจะใช้เป็นเหตุผลรองรับกฎที่ซ่อนอยู่
- ฟิลด์ capacity แยกต่างหากเป็นสิ่งธรรมชาติสำหรับอาร์เรย์ไดนามิก แต่ในสตริงกลับเป็นการผสมแนวคิดต่างชนิดไว้ใน type เดียว ทำให้เส้นแบ่งระหว่างประเภทพร่ามัว
- ปัญหาแบบเดียวกันอาจเกิดกับ flexible array member ได้เช่นกัน แต่มีความต่างตรงที่สามารถใช้ขนาดคงที่เริ่มต้นเป็นความจุและหลีกเลี่ยงการขยายแบบไดนามิกได้
-
Pascal style
- สตริงสั้นแบบ Pascal มักใช้อาร์เรย์ตัวอักษร ขนาดคงที่ 256 ไบต์ โดยกันไว้ 1 ไบต์สำหรับเก็บความยาว หรือใช้การจบด้วย NUL
- ภายใต้ข้อจำกัดจริงด้านหน่วยความจำและฮาร์ดแวร์ในอดีต วิธีนี้เคยใช้ได้ผล แต่ในยุคปัจจุบันเรามีกลยุทธ์จัดสรรหน่วยความจำแบบ arenaที่ดีกว่า จึงไม่ค่อยเหมาะนอกจากในสถานการณ์เฉพาะ
ทำไมสตริงแบบอิงความยาวควรเป็นค่าเริ่มต้น
- ข้อดีของสตริงแบบอิงความยาวเป็นที่รู้จักในบางชุมชนและในหมู่โปรแกรมเมอร์ที่มีประสบการณ์ แต่ผู้พัฒนาจำนวนมากยังไม่เคยพิจารณาการทำงานลักษณะนี้
- เนื้อหาที่เกี่ยวข้องกระจัดกระจายอยู่หลายที่ จึงยากที่จะเปรียบเทียบเรื่องการสูญเสียความยาว ความกำกวมของไบต์ปิดท้าย การจัดการไบนารี สตริงย่อย ข้อดีข้อเสียของ sentinel และ implementation ทางเลือกต่าง ๆ ไว้ด้วยกัน
- หากทำให้ชนิดสตริงหลักเป็น slice ที่ประกอบด้วยพอยน์เตอร์และความยาว ก็จะสามารถแก้ทั้ง การคงข้อมูลขอบเขต, การทำให้สถานะเรียบง่าย, ความเข้ากันได้กับไบนารี และสตริงย่อยที่ไม่ต้องจัดสรร ได้ภายใต้ดีไซน์เดียว
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
ผมคิดว่าถ้าต้องการหลีกเลี่ยง padding byte ควรเปลี่ยนลำดับสมาชิกของ struct เป็นแบบนี้
แทนที่จะเป็น:
ในสภาพแวดล้อมที่ใช้ pointer 64 บิต ฟิลด์ทั้งสองมีขนาดเท่ากัน จึงไม่มี padding ไม่ว่าจะเรียงลำดับใด ส่วนในสภาพแวดล้อม 32 บิต ตัว
u64เองที่ใช้แทนความยาวซึ่งใหญ่กว่าช่วงที่ pointer จะแสดงได้ก็ถือว่าเปลืองอยู่แล้วถ้าใช้ pointer 128 บิตแบบ CHERI การจัดวางแบบแรกจะมี padding สำหรับ alignment ระหว่าง
dataกับsizeส่วนแบบที่สองจะมีที่ท้าย struct ดังนั้นสุดท้ายก็เปลืองพื้นที่อยู่เล็กน้อยนิยามที่ถูกต้องคือรูปแบบที่ใช้ สมาชิกอาร์เรย์ความยาวแปรผัน
มาตรฐาน C ไม่ได้นิยาม ชนิด
u8และu64ไว้ และนี่เป็นรูปแบบการเขียนแบบ Rustการใช้
u64แทนความยาวในสภาพแวดล้อม 32 บิตก็ไม่ค่อยเหมาะ และใน C ชนิดที่นิยมใช้เพื่อแสดงขนาดอาร์เรย์คือsize_tดังนั้นเขียนแบบนี้จะเป็นธรรมชาติกว่าคนเขียนบทความหรือ LLM ที่สร้างมันขึ้นมาดูเหมือนจะไม่รู้ C ดีพอ
u8และu64ด้วยtypedefแล้วนำมาใช้ ซึ่งเป็นแนวปฏิบัติที่พบได้ทั่วไปมากหลายคนก็รู้สึกว่าชื่อ
uintN_tในมาตรฐานนั้นยุ่งยาก และในบรรดาโปรเจกต์ขนาดใหญ่ บางโปรเจกต์ก็เก่ากว่ามาตรฐานนั้นเสียอีก อีกทั้ง เคอร์เนล Linux ก็ใช้ alias แบบนี้เช่นกันกลับกัน คำวิจารณ์นี้ยิ่งเผยให้เห็นว่าไม่คุ้นเคยกับโปรเจกต์ C
ถ้าเขียน C หรือ C++ มามากกว่าสองสามเดือน วิธี ส่ง pointer พร้อมความยาวไปด้วย ก็น่าจะเป็นเรื่องที่รู้กันดีอยู่แล้วไม่ใช่หรือ
โค้ดเบส C สมัยใหม่แทบทั้งหมดท้ายที่สุดก็จะส่ง pointer ของข้อมูลพร้อมความยาวไปด้วย หรือไม่ก็มีไลบรารี string ของตัวเอง
std::string_viewใน C++ สมัยใหม่ระบุความหมายชัดเจนว่า “ดู string นี้ได้ แต่ห้ามแก้ไขหรือเก็บไว้” จึงช่วยลดบั๊กได้มากใน API ที่รับ C string ข้อจำกัดแบบนี้เป็นเพียงข้อตกลงในเอกสารเท่านั้น
ผมมองว่าความผิดพลาดจริง ๆ ไม่ใช่การเก็บความยาวหรือการจบด้วย null แต่เป็น แนวคิดที่ชื่อว่า
Stringเอง ซึ่งกว้างเกินไปจนมีความหมายได้ในบริบทสารพัดแบบstring ถูกใช้เพื่อวัตถุประสงค์สารพัดเกินกว่าจะเป็นแค่อาร์เรย์ของอักขระ และแม้แต่คำว่า “อักขระ” ในที่นี้ก็ยังเป็นการทำให้ง่ายเกินไป เหมือนมุกฟิสิกส์เรื่องวัวทรงกลมในสุญญากาศ
แค่คำว่า “ความยาว” ก็ยังไม่ชัดเจนว่าเป็นจำนวน grapheme cluster จำนวน code point หรือจำนวนไบต์ทั้งหมด
ต้องแยกด้วยว่า encoding คืออะไร ถูกแสดงให้ผู้ใช้เห็นและต้องทำ localization หรือไม่ ผ่านการ sanitize แล้วหรือยัง และเป็น ค่าที่แบ่งแยกไม่ได้ ซึ่งไม่มีเหตุผลให้แก้ไขเหมือน map key หรือไม่
เมื่อมองจากมุมนี้แล้ว ประเภท string ใน implementation ทั่วไปก็ไม่ใช่สิ่งที่ต้องการอีกต่อไป
เท่าที่รู้ ไม่มีไลบรารีไหนเรียกจำนวน grapheme cluster แบบง่าย ๆ ว่า “ความยาว”
การกำหนด encoding พื้นฐานเป็น UTF-8 และใช้ชนิดแยกสำหรับ encoding อื่นหรืออาร์เรย์ไบต์ช่วยแก้ปัญหาบางส่วนได้ และส่วนใหญ่ก็แสดงออกผ่าน type system ได้
เท่าที่ผมรู้ C ไม่มี แนวคิด string ในระดับภาษา แบบที่ภาษาสมัยใหม่ส่วนใหญ่มี
การจัดการ string มาจาก standard library ไม่ใช่ตัวภาษาเอง
K&R ฉบับที่ 2 อธิบายว่า C เป็นภาษาระดับค่อนข้างต่ำที่ใกล้กับสิ่งที่คอมพิวเตอร์จัดการโดยตรง เช่น อักขระ ตัวเลข และ address และไม่ได้มี operation สำหรับจัดการ object เชิงประกอบทั้งก้อนอย่าง string, set, list หรือ array โดยตรง
การไม่มีฟีเจอร์เหล่านี้อาจดูเหมือนข้อบกพร่องใหญ่ แต่การรักษาภาษาให้เล็กทำให้สามารถอธิบายได้สั้น เรียนรู้ได้เร็ว และมีข้อดีคือโปรแกรมเมอร์สามารถเข้าใจทั้งภาษาและใช้งานได้ในชีวิตประจำวัน
ถ้าต้องการเข้าใจว่าทำไม C จึงออกแบบมาแบบนี้ และทำไมฟีเจอร์สมัยใหม่ที่เรามองว่าเป็นเรื่องปกติจึงไม่มีอยู่ ขอแนะนำอย่างยิ่งให้อ่าน คำนำและบทนำของ K&R ฉบับที่ 2
เรื่องนี้ก็ไม่ได้ต่างจากการแบ่งเส้นระหว่างภาษาและไลบรารีในภาษาระดับต่ำอย่าง Rust มากนัก
มีข้อเสนอเกี่ยวกับรูปแบบ serialization อย่าง https://cr.yp.to/proto/netstrings.txt และยังมีวิธีที่จัดการได้ถึง struct ที่ซับซ้อน อย่าง https://web.archive.org/web/20230305073119/…
แต่ดูเหมือนว่ารูปแบบจะเรียบง่ายเกินไปสำหรับการ parse อย่างปลอดภัย จึงทำให้เหล่าสาวกตัวยงของ XML, JSON, YAML, Protocol Buffers และอื่น ๆ ยังคงมีงานให้ยุ่งกันต่อไป