สไตล์การเขียนโค้ด C ส่วนตัว ณ ช่วงปลายปี 2023
(nullprogram.com)- ในปี 2023 วิธีเขียนโค้ด C เปลี่ยนไปมาก จนมีการจัดระเบียบสไตล์ใหม่โดยยึด ชื่อชนิดข้อมูลแบบสั้น, การไม่ใช้สตริงแบบ null-terminated, การคืนค่าเป็น struct, และการคอมไพล์แบบหน่วยแปลเดียวเป็นหลัก
- การใช้ชื่อย่อสั้นอย่าง
u8,i32,size,s8และการตัดconst·structออก เป็นทางเลือกเพื่อลด สิ่งรบกวนทางสายตา และภาระในการรับรู้จากการประกาศที่ซ้ำๆ - สตริงจะไม่ใช้รูปแบบ null-terminated แต่จัดการด้วย
s8fat 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 *datasize len
- แมโคร
s8(s)ใช้ห่อ string literal ของ C ให้เป็นสตริงs8 s8ถูกส่งและคืนค่าแบบค่าเหมือน fat pointers8ยังเหมาะจะใช้เป็น 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 ของค่าที่คืนมาก็แทบไม่มีความหมาย
- เพราะ calling convention อาจแปลงให้เป็นพารามิเตอร์ out แบบ
- วิธีนี้ช่วยลดแรงจูงใจที่จะส่งสัญญาณข้อผิดพลาดแบบ in-band เช่นการคืนค่า null แบบพิเศษ
- ชอบแพตเทิร์นที่สร้างค่าคืนแบบกำหนดเป็น 0 ไว้ที่ต้นฟังก์ชัน แล้วใช้กับทุก
return- ถ้าเกิดข้อผิดพลาดก็คืนสภาพที่เป็น 0 ทันที
- ในเส้นทางสำเร็จจะตั้ง
okเป็น true ก่อนคืนค่าเท่านั้น
- นอกจากข้อมูลสถิตและแมโคร
s8·s16แล้ว ก็ลดการใช้ initializer ด้วย- รวมถึงหลีกเลี่ยง designated initializer และหันไป initialize ด้วยคำสั่งกำหนดค่าแทน
- การ initialize ด้วยคำสั่งกำหนดค่าอ่านง่าย และมี sequence point ระหว่างแต่ละคำสั่ง จึงให้ ลำดับที่ชัดเจน
- ในการกำหนดค่าเริ่มต้นที่ลำดับการเรียกมีผลต่อผลลัพธ์ เช่นฟังก์ชันสร้างเลขสุ่ม ก็ไม่ต้องมาคิดถึงกรณีความเป็นไปได้ของค่า
การประกาศ Win32 และ inline assembly
- ชอบ
__attributeมากกว่า__attribute__- เพราะมองว่า suffix
__ด้านหลังเยอะเกินและไม่จำเป็น
- เพราะมองว่า 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 ความคิดเห็น
ความคิดเห็นบน 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 อยู่แล้ว หากต้องเข้าใจโปรแกรมหนึ่งก็ยังต้องเรียนระบบประหลาดแยกต่างหาก การระบุขนาดให้ชัดเจนเป็นเรื่องสมเหตุสมผล เช่นใช้
uint32_tแทนuintแบบนั้นเข้าใจได้ชนิดเหล่านี้ควรถูกนิยามไว้ในเฮดเดอร์ที่เหมาะสม และผมอาจผิดก็ได้เพราะไม่ได้ใช้ C มานาน
intของ C คือ 32 บิตอาจไม่ใช่บนเป้าหมาย 16 บิต แต่คุณจะพอร์ตโปรแกรมขนาด 5MB ไปเป็น 16 บิตจริง ๆ หรือ? ความกังวลแบบนั้นส่วนใหญ่ไม่ค่อยคุ้มค่า
ปัญหาคือ
longบางเครื่องเป็น 32 บิต บางเครื่องเป็น 64 บิต จึงทำให้สับสน โชคดีที่long longเป็น 64 บิตเสมอ ดังนั้นก็แค่เลิกใช้longchar8 บิต,short16 บิต,int32 บิต,long long64 บิต ก็จบแล้ว ใน 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ไปเลยแทน prefixlibname_u8หรือi32แล้วสับสน อย่างมากก็เป็นไปได้แค่ในทางทฤษฎี และดูเหมือนเป็นการโต้แย้งแบบ straw man อยู่บ้างเขาอาจรำคาญได้ แต่คงไม่ถึงกับสับสน อย่างที่ Rich Hickey พูดไว้ ทุกอย่างอ่านยากทั้งนั้นก่อนที่เราจะเรียนรู้วิธีอ่านมัน
มีการบอกว่าการใช้บูลีน 32 บิตอาจดูเหมือนเปลืองหน่วยความจำสำหรับมือใหม่ ถ้าอย่างนั้นผมก็คงเป็นมือใหม่เหมือนกัน
เคยได้ยินบางกรณีที่มันไม่ได้แย่กว่า bool 8 บิต แต่ยังไม่เห็นกรณีที่ดีกว่าจริง ๆ ถ้ามีบูลีนที่อยู่ติดกันใน struct หรือถ้าตัวแปรบูลีนในฟังก์ชันถูกดันออกจาก register ไปอยู่บน stack ก็ยังเปลืองหน่วยความจำอยู่ดี
ถึงจะแค่ไม่กี่ไบต์ ก็ไม่เข้าใจว่าทำไมต้องตั้งใจทำให้แย่ลง สิ่งที่ได้จากการใช้ขนาดใหญ่กว่าคืออะไร?
รูปแบบคือมีค่าบอกเงื่อนไขอยู่หน้า 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 เพื่อ “ประหยัดพื้นที่”
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ไม่เห็นด้วยกับข้ออ้างเรื่องการคืนค่า struct และ output parameter
มันทำให้การประกอบฟังก์ชันที่สามารถคืน error ได้ยากขึ้นมาก และทำให้ชนิดต่าง ๆ เพิ่มขึ้นทั่วโค้ด ในความเป็นจริงฟังก์ชันแทบทั้งหมดอาจล้มเหลวได้ ดังนั้น สไตล์การคืน error ที่คาดเดาได้ จึงสำคัญกว่า โดยเฉพาะถ้ารวมการจัดการกรณีหน่วยความจำไม่พอด้วย
มันยากมากและแทบไม่มีประโยชน์ เมื่อถึงจุดนั้นจะมีปัญหาอื่นที่ต่างไปจากการเลือกสไตล์การเขียนโปรแกรมมาก
errnoปกติและ output parameter แต่ใน C ไม่มีถึงอย่างนั้นการประกอบค่าทางเลือกใน C ก็เจ็บปวดอยู่เสมอ ถ้าไม่ใช้ exception ในแต่ละภาษาดูเหมือน exception กับ monad จะเป็นสองทางเลือกหลัก แต่ทั้งคู่ไม่เข้ากับ C และไม่เข้ากับปรัชญาของโปรแกรมเมอร์ C ส่วนใหญ่
สำหรับการเรียกแบบหนึ่งต่อหนึ่งที่เรียบง่ายอาจลองใช้ macro ได้ แต่ก็มีขีดจำกัด ต่อให้ C++ แย่แค่ไหน C++ optional ก็ยังใช้งานสนุกกว่า
if(foo(x,y, out1, out2) != WHATEVER_LIBRARY_OK) { ... }รูปแบบที่ต้องใส่
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 อาจกลายเป็นปัญหาได้อย่างรวดเร็ว
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 มากกว่า
typedefเพื่อเลียนแบบ class ที่ทุก field เป็น private และใช้structกับ data structure แบบเรียบง่ายclass ควรเข้าถึงได้ผ่านฟังก์ชันเท่านั้น ส่วน struct ควรเข้าถึงได้โดยตรง
โดยรวมแล้วนี่ใกล้เคียงกับ convention ของมาตรฐาน C/POSIX เช่นความต่างระหว่าง
pthread_tกับstruct stattypedefตัว pointer เองของอย่าง SDL_net ทำแบบนั้นพอดี เลยไม่ชอบ จริง ๆ แล้วเป็น pointer แต่
typedefไว้เหมือนเป็น value typeเข้าใจเจตนา แต่เป็นวิธีที่ค่อนข้างชวนคาใจ
หลายส่วนในบทความนี้ฟังขึ้น
ผมเริ่มเขียน bare-metal OS สำหรับ Arm64 อยู่ ยังอยู่ช่วงต้นมากแต่ก็ทำอะไรคล้ายกัน ใช้สตริงแบบ Pascal และเปลี่ยนชื่อ type ด้วย เพียงแต่เป็นสไตล์
int8ไม่ใช่i8ผมตัดสินใจอย่างรวดเร็วว่าจะไม่พอร์ตซอฟต์แวร์จริง ๆ จึงไม่จำเป็นต้องตามฟังก์ชันหรือ convention ของ C standard library ทำให้ทดลองได้อย่างอิสระมากขึ้น
C เป็นภาษาที่เก่าจนสัมภาระจากยุคที่ทุกไบต์มีค่าเหลือมาถึงชื่อฟังก์ชันด้วย การหลุดออกจากตรงนั้นรู้สึกดี และเนื้อหาในบทความนี้กับการเปลี่ยนชื่อเล็ก ๆ หลายจุดก็ดูเหมือนการจัดระเบียบที่ค่อนข้างสะอาด
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
_Floatอยู่typedef _Float32 f32;typedef _Float64 f64;https://gcc.gnu.org/onlinedocs/gcc/Floating-Types.html