4 คะแนน โดย GN⁺ 2023-10-10 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • ในปี 2023 วิธีเขียนโค้ด C เปลี่ยนไปมาก จนมีการจัดระเบียบสไตล์ใหม่โดยยึด ชื่อชนิดข้อมูลแบบสั้น, การไม่ใช้สตริงแบบ null-terminated, การคืนค่าเป็น struct, และการคอมไพล์แบบหน่วยแปลเดียวเป็นหลัก
  • การใช้ชื่อย่อสั้นอย่าง u8, i32, size, s8 และการตัด const·struct ออก เป็นทางเลือกเพื่อลด สิ่งรบกวนทางสายตา และภาระในการรับรู้จากการประกาศที่ซ้ำๆ
  • สตริงจะไม่ใช้รูปแบบ null-terminated แต่จัดการด้วย s8 fat pointer ที่มี data และ len และในสภาพแวดล้อม Win32·UTF-16 ก็ใช้ c16 และ s16 ควบคู่กัน
  • การออกแบบฟังก์ชันนิยม คืนค่าเป็น struct มากกว่าใช้พารามิเตอร์ out และใช้แพตเทิร์นที่คืนค่าเริ่มต้นแบบกำหนดเป็น 0 ไว้ก่อน แล้วค่อยตั้ง ok เมื่อสำเร็จเท่านั้น
  • แม้แต่กับแมโคร, assert, การประกาศ Win32, และ inline assembly ก็ยังให้ความสำคัญกับกฎภายในที่อ่านง่าย แต่เมื่อไปมีส่วนร่วมกับโปรเจกต์อื่นก็จะทำตามสไตล์ของโปรเจกต์นั้น

สื่อเจตนาให้ชัดด้วยชื่อชนิดข้อมูลแบบสั้น

  • ใช้ชื่อย่อสั้นกับชนิดข้อมูลพื้นฐานอย่างจำนวนเต็ม·ตัวอักษร·ขนาดพอยน์เตอร์
    • ตัวอย่าง: u8, c16, b32, i32, u32, u64, f32, f64, uptr, byte, size, usize
  • ชื่อเหล่านี้ปรากฏบ่อยมากทั้งโปรแกรม จึงได้ประโยชน์โดยตรงด้าน ความกระชับ ทั้งเวลาอ่านและรีวิว
  • ไม่ใช้ suffix _t อีกต่อไป เพราะตอนนี้รู้สึกว่าเป็น องค์ประกอบที่ทำให้ภาพรวมรกสายตา
  • สำหรับ prefix ของ signed type จะชอบ i มากกว่า s
    • เพราะเก็บ s ไว้ใช้กับชื่อชนิดข้อมูลสตริง
  • สำหรับชนิดข้อมูลขนาด จะใช้ size แทน isize
    • เพราะมองว่า signed size เป็นค่าปริยายที่สำคัญกว่า
    • ส่วน usize ใช้ค่อนข้างเฉพาะตอนต้องติดต่อกับอินเทอร์เฟซภายนอก
  • b32 ช่วยสื่อเจตนาได้ชัดว่าเป็น “boolean 32 บิต”
    • ใช้ขนาดคำตามธรรมชาติแทน _Bool
    • เพราะมองว่าในทางปฏิบัติมักอยู่ในรีจิสเตอร์หรืออยู่ใน padding ของ struct อยู่แล้ว
    • ถ้าหน่วยความจำสำคัญจริง ก็จะบีบ boolean รวมไว้ในตัวแปร flags
  • c16 คือชนิดข้อมูลสำหรับอักขระ UTF-16 ที่ต้องใช้บน Win32
    • หากอิงกับ char16_t ก็ช่วยให้ดีบักเกอร์อย่าง GDB แสดงผลเป็นข้อมูลตัวอักษรได้
    • แม้ชื่อชนิดข้อมูลทางการของ Win32 จะเป็น wchar_t แต่จะชอบแบบที่ระบุว่าเป็น UTF-16 ให้ชัดกว่า
  • u8 ใช้กับ octet และข้อมูล UTF-8 เป็นหลัก ส่วน byte แยกไว้ใช้กับหน่วยความจำดิบและ aliasing type แบบพิเศษ
  • มองว่าความกังวลเรื่องระบบที่ไม่รองรับ fixed-width type แทบไม่มีประโยชน์เชิงปฏิบัติ
    • และชื่อชนิดข้อมูลยาวๆ อย่าง int_fast32_t ก็เป็นความสิ้นเปลืองที่ไม่จำเป็น
  • เวลาแสดงเพียงบางส่วนของโค้ดแยกเดี่ยว จะไม่ใช้ชื่อย่อเหล่านี้ลอยๆ
    • เพราะผู้อ่านต้องเห็น typedef ประกอบด้วยถึงจะเข้าใจบริบท

กฎของแมโครและ assert

  • แมโครแบบฟังก์ชันจะใช้ตัวพิมพ์เล็ก
    • ตัวอย่าง: countof(a), lengthof(s), new(a, t, n)
  • สำหรับค่าคงที่ยังชอบ ALL_CAPS เหมือนเดิม แต่สำหรับแมโครแบบฟังก์ชันมองว่า ตัวพิมพ์เล็ก อ่านง่ายกว่า
  • แมโครแบบฟังก์ชันมีปัญหาเรื่อง namespace น้อยกว่าแมโครทั่วไป
    • สามารถมีทั้งแมโคร new() และตัวแปรหรือฟิลด์ชื่อ new ได้พร้อมกัน
    • เพราะถ้าไม่ใช่รูปแบบการเรียกฟังก์ชันก็จะไม่ถูกขยายเป็นแมโคร
  • สำหรับ GCC และ Clang จะใช้แมโคร assert ในรูปแบบ while (!(c)) __builtin_unreachable()
  • วิธี assert แบบนี้ไม่ต้องแยกการตั้งค่าบิลด์ออกจากกัน
    • ไม่จำเป็นต้องมีนิยามแยกสำหรับบิลด์ดีบักกับรีลีส
    • การทำงานจะถูกควบคุมโดยการมีอยู่ของ Undefined Behavior Sanitizer หรือ UBSan
    • libubsan จะให้ผลวินิจฉัยที่มีชื่อไฟล์และเลขบรรทัด
    • ในบิลด์รีลีสก็กลายเป็น hint สำหรับการ optimize ที่ใช้ได้จริง
  • ถ้าต้องการเปิด assertion ในบิลด์รีลีส ให้ใช้ -fsanitize-trap เพื่อให้ UBSan อยู่ในโหมด trap และเปิดอย่างน้อย -fsanitize=unreachable
  • ตามทฤษฎีก็อาจใช้ -funreachable-traps ได้เช่นกัน แต่ ณ เวลาที่เขียน เวอร์ชัน GCC ช่วงหลังๆ หลายรุ่นยังมีปัญหาอยู่

สิ่งที่ลดทอนออกจากการประกาศ

  • ไม่ใช้ const กับพารามิเตอร์
    • เพราะมองว่าไม่มีบทบาทเชิงปฏิบัติในการ optimize
    • และนึกไม่ออกว่าเคยช่วยจับหรืออาจจับความผิดพลาดอะไรได้จริง
    • มองว่าถ้าตั้งชื่อพารามิเตอร์ดีพอ ก็ทำหน้าที่เป็นเอกสารใน prototype ได้อยู่แล้ว
  • การตัด const ออกเป็นการเปลี่ยนแปลงที่ช่วยเพิ่มผลิตภาพ เพราะลด ภาระทางความคิด และสิ่งรบกวนทางสายตา
  • มีข้อยกเว้นเล็กน้อยคือยังชอบใช้ const เป็น hint เพื่อวางตารางสถิตไว้ในหน่วยความจำแบบอ่านอย่างเดียวใกล้กับโค้ด
    • ถ้าจำเป็นก็จะตัด const ออกด้วยการ cast
  • สำหรับ null pointer จะใช้ลิเทอรัล 0
    • เป็นสไตล์ที่ใช้มาต่อเนื่องราว 7 ปี
    • แม้จะมีความเป็นไปได้เชิงทฤษฎีว่าจะมีข้อบกพร่อง แต่ในโค้ดหลักแสนบรรทัดก็ยังไม่เคยเจอกรณีจริง
  • restrict จะใช้เมื่อจำเป็นเท่านั้น
    • โดยจัดโค้ดให้ไม่ใช้พารามิเตอร์ out ในลูป หรือหลีกเลี่ยงพารามิเตอร์ out ไปเลย
  • ไม่ใช้ inline
    • เพราะคอมไพล์ทุกอย่างเป็นหน่วยแปลเดียว
  • ทุก struct จะทำ typedef
    • เพราะเมื่อตัดคีย์เวิร์ด struct ออก โค้ดจะอ่านง่ายขึ้น
    • สำหรับ recursive struct จะใส่ forward declaration ไว้ด้านบนทันที และใช้ชื่อสั้นๆ สำหรับฟิลด์
  • ทุกฟังก์ชันยกเว้น entry point จะประกาศเป็น static
    • เพราะตั้งอยู่บนสมมติฐานของการคอมไพล์แบบหน่วยแปลเดียว
  • ด้วยชื่อชนิดข้อมูลแบบสั้น, การตัด const, และการตัด struct ออก จึงสามารถวางชนิดคืนค่าและชื่อฟังก์ชันไว้บรรทัดเดียวกันได้สะดวก
  • เคยมีช่วงหนึ่งที่เขียนชื่อชนิดข้อมูลด้วยตัวพิมพ์ใหญ่ แต่สุดท้ายก็เลิกไป

ใช้ s8 แทนสตริงแบบ null-terminated

  • หนึ่งในการเปลี่ยนแปลงที่เพิ่มผลิตภาพที่สุดคือการปฏิเสธสตริงแบบ null-terminated อย่างสิ้นเชิง และหันมาใช้ ชนิดข้อมูลสตริง s8 ที่มี data และ len
  • โครงสร้างของ s8:
    • u8 *data
    • size len
  • แมโคร s8(s) ใช้ห่อ string literal ของ C ให้เป็นสตริง s8
  • s8 ถูกส่งและคืนค่าแบบค่าเหมือน fat pointer
  • s8 ยังเหมาะจะใช้เป็น prefix ของฟังก์ชันด้วย
    • เพราะชื่อชุด str ถูกสงวนไว้
    • ตัวอย่าง: s8span, s8equals, s8compare, s8hash, s8trim, s8clone
  • เวลาจะเทียบกับลิเทอรัลจะใช้รูปแบบอย่าง s8equals(tagname, s8("body"))
  • เคยลองใช้ flexible array member เพื่อผูกขนาดกับอาร์เรย์ไว้ในการจัดสรรครั้งเดียว แต่เห็นว่าข้อเสียด้านความยืดหยุ่นมีมากกว่าข้อดี
  • บางครั้งเคยคิดว่าโปรแกรมง่ายๆ คงไม่ต้องมีชนิดข้อมูลสตริง แต่โดยมากแล้วมองว่าเป็นการตัดสินใจที่ผิด
  • ยังใช้ s16 เป็นชนิดข้อมูลรองรับ UTF-16 ด้วย
    • มี c16 *data และ size len
    • แต่ยังไม่มั่นใจเต็มที่กับวิธีเติม u หน้า literal ในแมโคร

การคืนค่าเป็น struct และรูปแบบการกำหนดค่าเริ่มต้น

  • ชอบ คืนค่าเป็น struct มากกว่าใช้พารามิเตอร์ out
    • โดยพฤตินัยก็เท่ากับคืนค่าหลายค่า แต่ไม่มี destructuring
  • ตัวอย่าง i32parse(s8) จะคืนทั้ง value ซึ่งเป็นผลการ parse และ ok ซึ่งเป็นสถานะ
  • มองว่าค่าใช้จ่ายจากการคัดลอกเพิ่มไม่ใช่ปัญหาใหญ่ในการทำงานจริง
    • เพราะ calling convention อาจแปลงให้เป็นพารามิเตอร์ out แบบ restrict ที่ซ่อนอยู่
    • หรือถ้าถูก inline แล้ว overhead ของค่าที่คืนมาก็แทบไม่มีความหมาย
  • วิธีนี้ช่วยลดแรงจูงใจที่จะส่งสัญญาณข้อผิดพลาดแบบ in-band เช่นการคืนค่า null แบบพิเศษ
  • ชอบแพตเทิร์นที่สร้างค่าคืนแบบกำหนดเป็น 0 ไว้ที่ต้นฟังก์ชัน แล้วใช้กับทุก return
    • ถ้าเกิดข้อผิดพลาดก็คืนสภาพที่เป็น 0 ทันที
    • ในเส้นทางสำเร็จจะตั้ง ok เป็น true ก่อนคืนค่าเท่านั้น
  • นอกจากข้อมูลสถิตและแมโคร s8·s16 แล้ว ก็ลดการใช้ initializer ด้วย
    • รวมถึงหลีกเลี่ยง designated initializer และหันไป initialize ด้วยคำสั่งกำหนดค่าแทน
  • การ initialize ด้วยคำสั่งกำหนดค่าอ่านง่าย และมี sequence point ระหว่างแต่ละคำสั่ง จึงให้ ลำดับที่ชัดเจน
  • ในการกำหนดค่าเริ่มต้นที่ลำดับการเรียกมีผลต่อผลลัพธ์ เช่นฟังก์ชันสร้างเลขสุ่ม ก็ไม่ต้องมาคิดถึงกรณีความเป็นไปได้ของค่า

การประกาศ Win32 และ inline assembly

  • ชอบ __attribute มากกว่า __attribute__
    • เพราะมองว่า suffix __ ด้านหลังเยอะเกินและไม่จำเป็น
  • ในการเขียน system programming บน Win32 จะไม่ include windows.h แต่เขียน prototype ที่ต้องใช้ขึ้นมาเอง
    • เพราะโดยปกติจำนวนประกาศและนิยามที่ต้องใช้ไม่ได้มาก
    • ช่วยลดเวลา build และทำให้ namespace ไม่รกเกินไป
    • อีกทั้งยังเข้ากับ custom type อย่าง u32, b32, uptr ได้สะอาดกว่า DWORD, BOOL, ULONG_PTR
  • ตัวอย่างการประกาศ Win32 จะใช้แมโคร W32(r) __declspec(dllimport) r __stdcall
    • และประกาศฟังก์ชันอย่าง ExitProcess, GetStdHandle, VirtualAlloc, WriteConsoleA, WriteConsoleW ขึ้นมาเอง
  • ใน inline assembly จะปฏิบัติกับวงเล็บชั้นนอกเหมือนวงเล็บปีกกา
    • เว้นวรรคก่อนวงเล็บเปิดเหมือน if
    • แต่ละบรรทัดของ constraint จะขึ้นต้นด้วย colon
  • ตัวอย่างสำหรับดูสไตล์ที่กล่าวมานี้ในโปรแกรมขนาดเล็กมีอยู่ใน wordhist.c
  • และตัวอย่างที่ใหญ่ขึ้นเล็กน้อยคืออิมพลีเมนเทชันภาษาสำหรับเขียนโปรแกรมขนาดเล็กใน asmint.c

1 ความคิดเห็น

 
GN⁺ 2023-10-10
ความคิดเห็นบน Hacker News
  • ดูเหมือนจะมองว่า #define sizeof(x) (size)sizeof(x) ไม่จำเป็นต้องมีวงเล็บครอบด้านนอก แต่มีข้อยกเว้นเล็กมากอยู่
    การแคสต์มีลำดับความสำคัญสูงกว่าการคูณ ดังนั้น sizeof(x) * 3 จึงทำงานได้อย่างปลอดภัยเป็น (size)sizeof(x) * 3
    แต่ใน (size)sizeof(x)[y] นั้น การทำดัชนีอาร์เรย์ จะถูกใช้ก่อนการแคสต์ ทำให้กลายเป็น (size)(sizeof(x)[y]) ไม่ใช่ ((size)sizeof(x))[y]
    ในโค้ดจริงคงไม่มีใครทำดัชนีกับ sizeof(x) แต่ C อนุญาตให้ integer[pointer] มีความหมายเดียวกับ pointer[integer] ดังนั้นแมโครนี้อาจคอมไพล์ผ่านแต่ทำงานผิดเพราะวงเล็บไม่พอ
    ที่เป็นประเด็นพื้นฐานกว่านั้นคือ ผมก็ไม่ค่อยเห็นด้วยกับข้ออ้างว่า signed size ดีกว่า ผู้เขียนบอกว่า unsigned size เป็นแหล่งที่มาของข้อบกพร่อง แต่โค้ดที่ยกมาก็มีบั๊กที่ทำให้หน่วยความจำเสียหายได้ถ้า count เป็นค่าลบ
    สำหรับจำนวนเต็มไม่มีเครื่องหมาย จะไม่สามารถแทนจำนวนติดลบได้ และแม้เกิดโอเวอร์โฟลว์ก็จะกลายเป็นค่าบวกขนาดใหญ่มากจนถูกดักได้ด้วยการตรวจเดิม โดยส่วนตัวผมชอบใช้จำนวนเต็มไม่มีเครื่องหมาย แต่ให้หยุดทำงานเมื่อเกิดโอเวอร์โฟลว์ด้วย แรปเปอร์ตรวจช่วงค่า เท่าที่ทำได้

    • กลับกัน ผมค่อนข้างชอบเซแมนติกของ _Bool
      เพราะนิพจน์ที่ทำงานได้ดีใน if (flags & FLAG_ALLOCATED) สามารถแยกออกมาเป็น ตัวแปรบูลีน เช่น _Bool need_free = flags & FLAG_ALLOCATED; ได้
      flags & FLAG_ALLOCATED เมื่อถูกตั้งค่าอาจไม่ใช่ 1 แต่เป็นค่าใด ๆ ที่ไม่ใช่ 0 ได้ ส่วน _Bool จะทำให้เป็นมาตรฐานเป็น 1 ถ้ารับด้วย int แล้ว if (need_free) จะผ่าน แต่ if (need_free == true) อาจล้มเหลวได้
      ก็มีข้อเสียเช่นกัน ระหว่างรีแฟกเตอร์ หากพลาดไปว่าการแปลงโดยนัยเป็น _Bool กำลังทำงานที่มีประโยชน์อยู่ ก็อาจกลายเป็นโค้ดผิด ๆ อย่าง if ((flags & FLAG_ALLOCATED) == true) ได้
      นอกจากนี้ เมื่ออ่านสตรักต์จากดิสก์หรือเติมไบต์ใด ๆ ลงไป ถ้าฟิลด์ _Bool ไม่ใช่ 0 หรือ 1 ก็มีความเสี่ยงต่อ พฤติกรรมที่ไม่ได้นิยาม
    • จริง ๆ แล้ว (size)(sizeof(x)[y]) ก็น่าประหลาดใจสำหรับหลายคนเช่นกัน แต่มันเท่ากับ (size)(sizeof ((x)[y]))
      sizeof ไม่ใช่ฟังก์ชัน แต่เป็น ตัวดำเนินการเอกภาค และการทำดัชนีกับการเรียกฟังก์ชันมีลำดับความสำคัญสูงกว่า sizeof ดังนั้นผมจึงชอบเว้นวรรคหลัง sizeof และใส่วงเล็บให้โอเปอแรนด์เฉพาะเมื่อจำเป็น
      https://en.cppreference.com/w/c/language/operator_precedence
      ถ้าจะเขียนแมโครให้ถูกต้องควรเป็น #define sizeof(x) ((size)(sizeof (x)))
    • จับประเด็นได้ดี บทเรียนคือถ้านิยามแมโครไม่ได้ขยายออกมาเป็นโทเคนเดียวเท่านั้น ก็ควรครอบด้วยวงเล็บเสมอ กฎลำดับความสำคัญ ของ C นั้นซับซ้อนจริง ๆ
  • การนิยามชนิดของตัวเองดูเหมือนจะเลยเถิดไปก้าวหนึ่ง
    แม้แต่คนที่คุ้นกับชนิดข้อมูลของ C อยู่แล้ว หากต้องเข้าใจโปรแกรมหนึ่งก็ยังต้องเรียนระบบประหลาดแยกต่างหาก การระบุขนาดให้ชัดเจนเป็นเรื่องสมเหตุสมผล เช่นใช้ uint32_t แทน uint แบบนั้นเข้าใจได้
    ชนิดเหล่านี้ควรถูกนิยามไว้ในเฮดเดอร์ที่เหมาะสม และผมอาจผิดก็ได้เพราะไม่ได้ใช้ C มานาน

    • ในทางปฏิบัติ int ของ C คือ 32 บิต
      อาจไม่ใช่บนเป้าหมาย 16 บิต แต่คุณจะพอร์ตโปรแกรมขนาด 5MB ไปเป็น 16 บิตจริง ๆ หรือ? ความกังวลแบบนั้นส่วนใหญ่ไม่ค่อยคุ้มค่า
      ปัญหาคือ long บางเครื่องเป็น 32 บิต บางเครื่องเป็น 64 บิต จึงทำให้สับสน โชคดีที่ long long เป็น 64 บิตเสมอ ดังนั้นก็แค่เลิกใช้ long
      char 8 บิต, short 16 บิต, int 32 บิต, long long 64 บิต ก็จบแล้ว ใน C เราเสียเวลาไม่รู้จบกับเรื่องขนาดของ int มาตลอด
    • ผู้เขียนก็จำกัดขอบเขตไว้แล้วว่าเป็นสไตล์การเขียนโค้ดส่วนตัว พูดตามตรง ชนิดมาตรฐานนั้นยืดยาวเกินไป และผมก็อดคิดไม่ได้ว่าถ้า รายการแบบกระชับ ที่คนนี้เสนอถูกนำมาใช้ตั้งแต่ก่อนก็คงดี
    • พูดแบบกึ่งล้อเล่น งานเขียนโปรแกรมส่วนใหญ่คือการรับมือกับ ระบบชนิดข้อมูล ของคนอื่น
      สำหรับคนที่ใช้ C บ่อย ๆ ตัวย่อที่ออกมานี้คุ้นเคยดี และในฐานะระบบชนิดแบบกำหนดเองก็นับว่าค่อนข้างงดงาม นึกถึง Rust เลย
    • ชนิดแบบนั้นมีอยู่ใน stdint.h แล้ว
      ผมแปลกใจเสมอที่เห็นหลายโปรเจกต์ลำบากสร้างไฟล์นี้ขึ้นมาใหม่
      การแปลชนิดมาตรฐานไปใช้ชื่อของตัวเองอีกทีเป็นเรื่องน่ารำคาญสำหรับผู้อ่าน เคยถามในโปรเจกต์ C++ หนึ่งว่าทำไมถึงใช้ typedef เต็มไปหมดกับคอลเลกชัน การอ้างอิง และอ็อบเจกต์ประกอบ คำตอบคือทำให้เข้าใจง่ายขึ้น
      ภายหลังผมเห็น ชีตสรุป typedef แปะอยู่ข้างจอมอนิเตอร์ของคนนั้น
    • การนิยามชนิดจำนวนเต็มเองมีความหมายบน แพลตฟอร์มที่มีข้อจำกัดด้านทรัพยากร
      มักเห็นชนิดอย่าง dim_t ซึ่งอาจเป็น 32 บิตหรือ 64 บิตตามการใช้งาน แม้บนแพลตฟอร์ม 64 บิต โครงสร้างที่บีบอัดพอยน์เตอร์ก็มักใช้จำนวนเต็ม 32 บิตบ่อย ๆ
      ตัวอย่างเช่น หากจัดสรรฮีปเองและเก็บเฉพาะออฟเซ็ต 32 บิต การใช้งานหน่วยความจำในเวิร์กโหลดที่ต่ำกว่า 4GB จะลดลงครึ่งหนึ่ง และยังทำให้ locality ของแคชดีขึ้น ส่งผลให้ประสิทธิภาพดีขึ้น
  • การทิ้งธรรมเนียมที่เป็นที่ยอมรับของ C ไปเพราะรสนิยมส่วนตัวดูจะเกินไปหน่อย
    การใช้ u8, i32 แทน uint8_t หรือ int32_t อาจลดจำนวนตัวอักษรได้ไม่กี่ตัว แต่ก็อาจทำให้คนอื่นสับสนเมื่ออ่านโค้ด
    การใช้ชนิดสตริงแบบกำหนดเองแทนสตริงที่จบด้วย null ก็ดูเหมือนจะเพิ่มความยากในการทำงานร่วมกัน เมื่อคิดว่า C ถูกสร้างขึ้นโดยมีสตริงแบบนั้นเป็นศูนย์กลาง
    การเขียน prototype ของ Win32 API เองโดยไม่ include windows.h แม้จะลดเวลา compile ได้ แต่ก็เหมือนทิ้งทางด่วนที่ดูแลไว้อย่างดีแล้วไปเดินทางป่า หลายอย่างดูใกล้เคียงกับรสนิยมส่วนตัวมากกว่าโค้ด C ที่ทุกคนจัดการได้ง่าย

    • u8 หรือ i32 ไม่ได้มีไว้เพื่อประหยัดจำนวนการพิมพ์ แต่เพื่อลด ภาระทางความรู้สึก ตอนอ่าน
      ข้ออ้างเรื่อง “จำนวนคีย์ที่กด” ซึ่งมักโผล่มาในข้อถกเถียงระหว่างความยืดยาวกับความกระชับนั้นมีข้อบกพร่องมาก ความเชื่อที่ว่าความกระชับดีแค่ช่วยให้พิมพ์เร็ว ส่วนความยืดยาวดีกับการอ่านเสมอนั้นผิด
      ความยืดยาวมีข้อดีต่อการอ่านทำความเข้าใจ แต่ความกระชับก็มีข้อดีเช่นกัน และไม่มีฝ่ายใดชนะอย่างชัดเจน เป็นเพียง trade-off ที่ต่างกันเท่านั้น
    • ชื่ออย่าง u16 ถูกใช้กันมาก และมีโอกาสน้อยที่จะทำให้โปรแกรมเมอร์สับสน
      จุดที่พังจริง ๆ คือเมื่อโปรแกรมสองตัวต่างก็กำหนด u16 ของตัวเองและเปิดเผยไว้ในไฟล์ header แล้วโปรแกรมที่สาม include header ทั้งสองพร้อมกัน
      ชนิดของไลบรารีที่ใส่ namespace ก็จะกลายเป็นหน้าตาแบบ libname_u32 และพอถึงจุดนั้นก็อยากใช้ uint32_t ไปเลยแทน prefix libname_
    • โอกาสที่โปรแกรมเมอร์ C ที่มีฝีมือจะเห็น u8 หรือ i32 แล้วสับสน อย่างมากก็เป็นไปได้แค่ในทางทฤษฎี และดูเหมือนเป็นการโต้แย้งแบบ straw man อยู่บ้าง
      เขาอาจรำคาญได้ แต่คงไม่ถึงกับสับสน อย่างที่ Rich Hickey พูดไว้ ทุกอย่างอ่านยากทั้งนั้นก่อนที่เราจะเรียนรู้วิธีอ่านมัน
  • มีการบอกว่าการใช้บูลีน 32 บิตอาจดูเหมือนเปลืองหน่วยความจำสำหรับมือใหม่ ถ้าอย่างนั้นผมก็คงเป็นมือใหม่เหมือนกัน
    เคยได้ยินบางกรณีที่มันไม่ได้แย่กว่า bool 8 บิต แต่ยังไม่เห็นกรณีที่ดีกว่าจริง ๆ ถ้ามีบูลีนที่อยู่ติดกันใน struct หรือถ้าตัวแปรบูลีนในฟังก์ชันถูกดันออกจาก register ไปอยู่บน stack ก็ยังเปลืองหน่วยความจำอยู่ดี
    ถึงจะแค่ไม่กี่ไบต์ ก็ไม่เข้าใจว่าทำไมต้องตั้งใจทำให้แย่ลง สิ่งที่ได้จากการใช้ขนาดใหญ่กว่าคืออะไร?

    • ขึ้นอยู่กับสถาปัตยกรรมและ CPU ล้วน ๆ แต่จากประสบการณ์ในอดีต กรณีที่ชัดเจนคือ งานประมวลผลเชิงตัวเลข
      รูปแบบคือมีค่าบอกเงื่อนไขอยู่หน้า struct ต่อรอบ แล้วตามด้วยค่าตัวอย่าง 512, 1024, 2048 ค่า มี junior คนหนึ่งพยายามประหยัดพื้นที่โดย pack struct และทำให้ค่าเงื่อนไขเป็น 8 บิต 1 ไบต์
      โค้ดที่ “ปรับปรุง” แล้วนั้นทำให้ throughput บนชิป Intel ลดลงประมาณ 10 เท่า และทำให้เกิด BUS ERROR บนสถาปัตยกรรม SPARC RISC
      การ pack header ของ struct ทำให้ array ข้อมูลไม่ถูก align; Intel ต้องไปดึง word 32 บิตสองตัวมาอย่างเงียบ ๆ แล้วประกอบกัน ส่วน SPARC ก็โกรธอย่างถูกต้องกับข้อมูลที่ไม่ align
      ถ้าเป็นการคำนวณใน pipeline ที่ throughput สำคัญ ไม่ใช่การจัดเก็บไฟล์ระยะยาว บางครั้งการจัดข้อมูลให้ตรงกับ การจัดแนวของสถาปัตยกรรม ดีกว่าการ pack เพื่อ “ประหยัดพื้นที่”
    • โดยมาก optimization ที่ง่ายคือการ pad field ของ struct ให้ตรงกับขอบเขต 32 บิต
      compiler แทบทั้งหมดทำสิ่งนี้ให้อยู่แล้ว จึงลองค้นหา “struct alignment/padding” ได้ ถ้า compiler จะเว้นพื้นที่ว่างไว้แต่แรก การใช้หน่วยความจำนั้นเองย่อมดีกว่า ไม่เช่นนั้นอาจเสีย performance ได้
      พูดให้แม่นขึ้นคือ แต่ละ field ควรอยู่ใน address ที่หารลงตัวด้วยขนาดของตัวเองหรือขนาด word line และ struct ทั้งตัวก็ควรถูก pad ให้เป็นจำนวนเท่าของขนาด field ที่ใหญ่ที่สุด ในทางปฏิบัติมักหมายถึง การ align แบบ 32 บิต
      อ้างอิง: http://www.catb.org/esr/structure-packing/
    • ถ้าใช้ชนิด bool จริง ๆ sanitizer จะเตือนเมื่อค่าไม่ใช่ 0 ซึ่งเป็น false หรือ 1 ซึ่งเป็น true
    • สถาปัตยกรรมคอมพิวเตอร์ส่วนใหญ่ถูก optimize สำหรับ การเข้าถึงที่ align อย่างน้อย 32 บิต สิ่งที่ได้มักเป็น performance แต่ไม่ใช่เสมอไป
    • อยากรู้ว่ามีตัวอย่างฟังก์ชันแบบไหนที่ตัวแปรบูลีนถูกดันไปอยู่บน stack แล้ว 3 ไบต์นั้นกลายเป็นเรื่องสำคัญ
  • ไม่เห็นด้วยกับข้ออ้างเรื่องการคืนค่า struct และ output parameter
    มันทำให้การประกอบฟังก์ชันที่สามารถคืน error ได้ยากขึ้นมาก และทำให้ชนิดต่าง ๆ เพิ่มขึ้นทั่วโค้ด ในความเป็นจริงฟังก์ชันแทบทั้งหมดอาจล้มเหลวได้ ดังนั้น สไตล์การคืน error ที่คาดเดาได้ จึงสำคัญกว่า โดยเฉพาะถ้ารวมการจัดการกรณีหน่วยความจำไม่พอด้วย

    • แทบไม่มีใครจัดการกรณีหน่วยความจำไม่พอ
      มันยากมากและแทบไม่มีประโยชน์ เมื่อถึงจุดนั้นจะมีปัญหาอื่นที่ต่างไปจากการเลือกสไตล์การเขียนโปรแกรมมาก
    • ถ้า unpack struct อย่างมีความหมายได้ ก็คงได้ semantics แบบ errno ปกติและ output parameter แต่ใน C ไม่มี
      ถึงอย่างนั้นการประกอบค่าทางเลือกใน C ก็เจ็บปวดอยู่เสมอ ถ้าไม่ใช้ exception ในแต่ละภาษาดูเหมือน exception กับ monad จะเป็นสองทางเลือกหลัก แต่ทั้งคู่ไม่เข้ากับ C และไม่เข้ากับปรัชญาของโปรแกรมเมอร์ C ส่วนใหญ่
      สำหรับการเรียกแบบหนึ่งต่อหนึ่งที่เรียบง่ายอาจลองใช้ macro ได้ แต่ก็มีขีดจำกัด ต่อให้ C++ แย่แค่ไหน C++ optional ก็ยังใช้งานสนุกกว่า if(foo(x,y, out1, out2) != WHATEVER_LIBRARY_OK) { ... }
    • การคืน option หรือ sum type น่าจะถูกต้อง แต่ใน C เขียนได้ยุ่งยากจริง ๆ
      รูปแบบที่ต้องใส่ if (thing(...)) goto fail ทุกครั้งที่เรียกฟังก์ชันก็ดูไม่ค่อยยอดเยี่ยมเท่าไร แต่ฝั่ง Go ดูเหมือนจะชอบ
      หรือไม่ก็มี thread_local mylibrary_errno ซึ่งภายในไลบรารีอาจเป็นวิธีที่เหมาะจริง ๆ และค่อยแปลงเป็นค่าคืนแบบ enum ที่ขอบเขต
  • จากประโยค “signed sizes are the way” ผมอยากบอกว่าอ่านถึงตรงนี้ก็พอแล้ว
    signed size เป็นการรั่วไหลของ abstraction ที่น่าตกใจมาก และเป็นแนวทางที่เรียกหาภัยพิบัติ
    คำกล่าวที่ว่า const ไม่มีบทบาทเชิงปฏิบัติและไม่เคยช่วยจับข้อผิดพลาดก็ยากจะยอมรับได้ ผู้คนมักสับสนระหว่างบัฟเฟอร์อินพุตกับบัฟเฟอร์เอาต์พุต และ const ทำให้เรื่องนี้ปรากฏชัดทันที
    การบอกให้ทำทุกฟังก์ชันเป็น static ยกเว้น entry point ก็อาจทำให้ตอนดีบักหา variable หรือ function ไม่เจอจนสาปแช่งคนเขียนได้
    การชอบคืนค่าเป็น struct นั้นเปิดช่องให้เผลอคืน stack pointer จนเกิดช่องโหว่ความปลอดภัยใหญ่ได้ง่าย ถ้าส่ง output buffer เข้าไป ownership semantics จะชัดเจนขึ้น
    คำแนะนำนี้อาจพอใช้ได้กับคนที่เขียนโค้ดระบบ 64-bit เป็นหลัก แต่ในโลก embedded 32-bit อาจกลายเป็นปัญหาได้อย่างรวดเร็ว

    • Bjarne Stroustrup เคยเขียนบันทึกรายละเอียดเพื่อสนับสนุน signed size
      https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p14...
    • คนที่ไม่ชอบ const จะไม่มีวันพอใจอยู่แล้ว ดังนั้นใช้ const ในที่ที่จำเป็นและ propagate เท่าที่จำเป็น แล้วก็เมินคำบ่นไป
      ถ้าพวกเขาถอดออก ก็ใส่กลับเข้าไปใหม่ ยังไงฝั่งนั้นก็เป็นฝ่ายเริ่มก่อน ผมทำแบบนี้มา 25 ปีแล้ว และก็ยังอยู่ตรงนี้
      static อาจขึ้นอยู่กับเครื่องมือ ราว 15 ปีก่อนผมหันไปใช้ static เป็นค่าเริ่มต้นและใช้ size_t เต็มรูปแบบ แต่จนถึงตอนนี้ยังไม่เจอปัญหา
  • น่าสนใจที่ประสบการณ์ของผมพาไปอีกทางหนึ่ง
    https://dlang.org/blog/2023/10/02/crafting-self-evident-code...
    บทความเขียนโดยมี D เป็นศูนย์กลาง แต่หลักการใช้กับ C ได้ด้วย

    • อ่านแล้วสนุกดี
      ส่วนที่ย้าย conditional expression เข้าไปใน doX() และ doZ() น่าสนใจ ไม่แน่ใจว่าแบบนั้นถูกเสมอไหม เพราะขึ้นอยู่กับว่า abstraction วางอยู่ตรงไหนและ mental model ของโค้ดเป็นอย่างไร
      ตัวอย่างเช่น deleteRecords(); ไม่ได้ดีกว่า if let x = deadRecords() deleteRecords(x); แบบหลังอาจดูรกกว่า แต่มีคุณค่าตรงที่แสดงให้เห็นตั้งแต่ด้านหน้าว่า ไม่ใช่การลบ แต่เป็นการตัดแต่ง
      ถ้าเปลี่ยนชื่อฟังก์ชันอย่างฉลาดเป็น pruneDeadProjects() ก็โอเค แต่การแค่ย้ายเงื่อนไขเข้าไปในฟังก์ชันอาจทำให้บริบทอันตรายและกลายเป็น abstraction ที่รั่วได้
  • ผมเห็นด้วยกับการใช้ typedef กับ struct ทุกตัว เพราะช่วยให้กระชับขึ้น
    ผมคิดว่าใช้ typedef ได้อย่างเผื่อ ๆ แต่ควร typedef เฉพาะตัว type เอง ไม่ใช่ pointer ถ้าต้องใช้ pointer ก็เขียน (type *) ได้เสมอ
    โดยเฉพาะ function pointer ควร typedef ตัวฟังก์ชันเอง ไม่ใช่ function pointer แล้วเราจะใช้ typedef นั้นกับการประกาศฟังก์ชันได้ด้วย เพื่อให้ตรวจสอบชนิดของพารามิเตอร์ได้ และเมื่อเปลี่ยน signature ของฟังก์ชันก็ไม่ต้องแก้ทุก declaration
    โค้ดเบส C ส่วนใหญ่ทำเรื่องนี้ผิด โดย typedef function pointer แล้วก็ยังต้องเขียน declaration ของฟังก์ชันให้เข้ากับนิยาม pointer นั้นเองด้วยมือ
    ผมยังไม่ถูกโน้มน้าวกับวิธีใช้ struct เป็น return type ผมชอบให้คืน numeric error code เป็น return value แล้วรับค่าที่เหลือผ่าน output parameter มากกว่า

    • สำหรับ opaque struct ผมชอบใช้ typedef เพื่อเลียนแบบ class ที่ทุก field เป็น private และใช้ struct กับ data structure แบบเรียบง่าย
      class ควรเข้าถึงได้ผ่านฟังก์ชันเท่านั้น ส่วน struct ควรเข้าถึงได้โดยตรง
      โดยรวมแล้วนี่ใกล้เคียงกับ convention ของมาตรฐาน C/POSIX เช่นความต่างระหว่าง pthread_t กับ struct stat
    • เห็นด้วยว่าไม่ควร typedef ตัว pointer เอง
      ของอย่าง SDL_net ทำแบบนั้นพอดี เลยไม่ชอบ จริง ๆ แล้วเป็น pointer แต่ typedef ไว้เหมือนเป็น value type
      เข้าใจเจตนา แต่เป็นวิธีที่ค่อนข้างชวนคาใจ
  • หลายส่วนในบทความนี้ฟังขึ้น
    ผมเริ่มเขียน bare-metal OS สำหรับ Arm64 อยู่ ยังอยู่ช่วงต้นมากแต่ก็ทำอะไรคล้ายกัน ใช้สตริงแบบ Pascal และเปลี่ยนชื่อ type ด้วย เพียงแต่เป็นสไตล์ int8 ไม่ใช่ i8
    ผมตัดสินใจอย่างรวดเร็วว่าจะไม่พอร์ตซอฟต์แวร์จริง ๆ จึงไม่จำเป็นต้องตามฟังก์ชันหรือ convention ของ C standard library ทำให้ทดลองได้อย่างอิสระมากขึ้น
    C เป็นภาษาที่เก่าจนสัมภาระจากยุคที่ทุกไบต์มีค่าเหลือมาถึงชื่อฟังก์ชันด้วย การหลุดออกจากตรงนั้นรู้สึกดี และเนื้อหาในบทความนี้กับการเปลี่ยนชื่อเล็ก ๆ หลายจุดก็ดูเหมือนการจัดระเบียบที่ค่อนข้างสะอาด

    • การบอกว่าไม่ต้องตามฟังก์ชันหรือ convention ของ C standard library หมายความว่าแค่ทำเป็นงานอดิเรก และจะไม่ใช่อะไรที่ใหญ่และมืออาชีพแบบ gnu ใช่ไหม?
    • ไม่เคยคิดมาก่อนเลยว่าจะประหยัดไบต์แม้กระทั่งใน symbol
  • typedef float f32;, typedef double f64; ดูเหมือนเป็น ฐานรองที่อันตราย ซึ่งตั้งสมมติฐานว่า float เป็น 32 บิต และ double เป็น 64 บิต
    OpenCV นิยาม float16_t, CUDA ก็มีการใช้งานเลขทศนิยมความแม่นยำครึ่งหนึ่ง และไมโครคอนโทรลเลอร์แต่ละตัวก็อาจใช้งานแตกต่างกันได้
    C++23 นำชนิดเลขทศนิยมแบบความกว้างคงที่เข้ามา แต่ผมไม่รู้วิธีบังคับสิ่งนี้ใน C การมีแมโครไว้ตรวจสอบตอนคอมไพล์ว่าไม่มีการสูญเสียข้อมูลน่าจะดีกว่า
    โดยรวมแล้ว อย่างที่คนอื่น ๆ พูดกัน แม้จะไม่กระชับนัก แต่เพื่อให้อ่านง่าย การปล่อยบางส่วนไว้เป็นค่าเริ่มต้นอาจดีกว่า
    [0] https://docs.opencv.org/4.x/df/dc9/classcv_1_1float16__t.htm...
    [1] https://docs.nvidia.com/cuda/cuda-math-api/group__CUDA__MATH...
    [2] https://en.cppreference.com/w/cpp/types/floating-point