โปรเจกต์สุดสัปดาห์: ความท้าทายสุดเพี้ยนที่สนุกกับภาษา C
(lcamtuf.substack.com)- C เป็นภาษาที่เรียบง่ายและมีพลังในการแสดงออกสูง แต่เพียงแค่คำสั่ง switchและกฎของเลเบล ก็สามารถสร้างโค้ดที่ทำให้นักพัฒนามากประสบการณ์ถึงกับงงได้
- การทำโค้ดอ่านยากแบบ IOCCC มักซ่อนโค้ดด้วยแมโครและการจัดฟอร์แมต แต่ด้วยไวยากรณ์ของ C เอง ก็สามารถสร้างโค้ดที่อ่านได้แต่ดูแปลกตาได้เช่นกัน
switch (...)ไม่จำเป็นต้องมีวงเล็บปีกกา และเพราะมันกระโดดไปยัง case label ที่ตรงกัน จึงอาจทำงานต่างจากลำดับการเริ่มต้นภายในบล็อกทั่วไปcaselabel ไม่จำเป็นต้องอยู่เฉพาะระดับบนสุดของบล็อกswitchจึงคอมไพล์switch แบบไร้วงเล็บปีกกาที่ผสมif (0) case...กับเชนelse ifได้- ส่วนขยาย
&&label address ของ GNU C ทำให้ทำ switch เองหรือแม้แต่ลูปแบบอิงเลเบลได้ แต่บางตัวอย่างใช้ได้เฉพาะ GCCและอาจไม่ปลอดภัยจากพฤติกรรมที่ไม่ถูกกำหนด
โค้ดประหลาดที่เกิดจากไวยากรณ์ของ C
- C มีข้อบกพร่องมากมาย แต่ด้วยไวยากรณ์ที่เรียบง่ายและความสามารถในการแสดงออก จึงถูกใช้เป็นภาษาสำหรับเขียนซอฟต์แวร์ขนาดใหญ่อย่างระบบปฏิบัติการ
- ไวยากรณ์ที่กระชับของมันยังส่งอิทธิพลต่อโครงสร้างโค้ดของภาษากระแสหลักรุ่นหลังจำนวนมาก ตั้งแต่ Java ไปจนถึง Go
- ตัวอย่างเด่นของการทำโค้ดให้อ่านยากคือ IOCCC
- ผลงานที่ชนะใน IOCCC มักประกอบด้วยแมโครพรีโปรเซสเซอร์ การจัดฟอร์แมตแบบผิดธรรมชาติ ชื่อตัวแปรที่ไม่ช่วยอะไร และนิพจน์เลขคณิตที่เข้าใจยาก
- โค้ดแบบนี้น่าทึ่งก็จริง แต่เพราะต้องถอดกลับเป็นโค้ดปกติเพื่อทำความเข้าใจ จึงอาจสนุกน้อยลงในแง่การเรียนรู้
มุมลึกของ C ที่เผยออกมาผ่าน switch, case, goto
switch (...)สามารถเขียนโดยไม่ต้องมีวงเล็บปีกกาได้ เหมือนif (...)หรือfor (...)switch (i) case 1: puts("i = 1");สามารถคอมไพล์ได้- ถ้าไม่มีวงเล็บปีกกา จะมีเพียงหนึ่งคำสั่งเท่านั้นที่เป็นส่วนของ
switchดังนั้นcase 2:ที่ตามมาจะไม่อยู่ในswitchอีกต่อไปและทำให้เกิดข้อผิดพลาด
- โดยแก่นแท้แล้ว
switchเป็นโครงสร้างที่ใกล้เคียงกับ goto ที่ย้ายไปยัง case label ที่ตรงกัน- ต่อให้ในบล็อก
switchจะมีint a = 123;และputs(...)อยู่ หากกระโดดไปที่default:โค้ดเริ่มต้นก่อนหน้านั้นก็จะไม่ถูกรัน - ในกรณีนี้
aจะไม่ถูกกำหนดค่าเริ่มต้นเป็น 123 และในทางเทคนิคถือเป็นพฤติกรรมที่ไม่ถูกกำหนด - ดูตัวอย่างได้ที่ Godbolt
- ต่อให้ในบล็อก
caselabel ไม่จำเป็นต้องอยู่เฉพาะระดับบนสุดของบล็อกswitchที่เชื่อมอยู่- รูปแบบอย่าง
if (0) case 0: puts("i = 0");ก็ทำงานได้ - เพราะ
switchกระโดดตรงไปยังcaseนั้น จึงข้ามif (0)ที่อยู่ข้างหน้าไป - หลังจากรัน
puts(...)แล้ว คำสั่งพิมพ์อื่นที่ตามมาจะยังถูกเงื่อนไขif (0)ขวางไว้ จึงหลีกเลี่ยง fallthrough ได้แม้ไม่มีbreak - ดูตัวอย่างได้ที่ Godbolt
- รูปแบบอย่าง
- เชน
if ... elseทำงานในทางไวยากรณ์ราวกับเป็นคำสั่งระดับบนสุดเพียงคำสั่งเดียว จึงนำมารวมกับswitchเพื่อสร้างswitch พิสดารแบบไร้วงเล็บปีกกาได้- ตัวอย่างมีทั้ง range case แบบ
case 1 ... 10และdefault - ดูตัวอย่างได้ที่ Godbolt
- ตัวอย่างมีทั้ง range case แบบ
- ตัวดำเนินการ
&&ซึ่งเป็นส่วนขยายของ GNU ใช้สำหรับนำที่อยู่ของเลเบลมาใช้ และทำgotoไปยังที่อยู่นั้นได้- จึงสามารถใช้วิธีอย่าง
goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i];เพื่อสร้าง switch เองได้ - ดูตัวอย่างได้ที่ Godbolt
- จึงสามารถใช้วิธีอย่าง
- ด้วยส่วนขยาย GNU แบบเดียวกันนี้ ยังสามารถทำลูปแบบอิงเลเบลภายในประกาศตัวแปรได้แม้ไม่มี
for (...)- ตัวอย่างสร้างลูปที่พิมพ์ค่าตั้งแต่
i = 0ถึงi = 5ด้วยเลเบลและgoto *&&_ - ดูตัวอย่างได้ที่ Godbolt
- สนิพเพ็ตสุดท้ายนี้ใช้ได้เฉพาะกับ GCC และอาจไม่ปลอดภัยจากพฤติกรรมที่ไม่ถูกกำหนด
- ตัวอย่างสร้างลูปที่พิมพ์ค่าตั้งแต่
- ในภาษา C ต่อให้ไม่ได้ตั้งใจทำโค้ดให้อ่านยาก ก็ยังสร้างรูปแบบที่แปลกประหลาดและชวนสับสนอย่างมากได้
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
ตัวอย่างข้างต้นบอกว่าโปรแกรมพิมพ์ค่าของ
aออกมา แต่ไม่ได้ถูกเริ่มต้นเป็น 123 ทว่าใน C จริง ๆ แล้วมันอาจเป็นแบบนั้นก็ได้การใช้ตัวแปรที่ยังไม่ได้เริ่มต้นไม่ได้หมายถึงการอ่าน “ค่าที่บังเอิญค้างอยู่ในหน่วยความจำนั้น” แต่เป็น พฤติกรรมที่ไม่ได้กำหนดไว้ ดังนั้นคอมไพเลอร์จะทำอะไรก็ได้
เช่น อาจเริ่มต้นหน่วยความจำนั้นเป็น 123 เสมอ หรือมองว่าทั้งชิ้นส่วนนั้นเป็นพฤติกรรมที่ไม่ได้กำหนดไว้แล้วลบคำสั่งทั้งหมดทิ้งจนไม่พิมพ์อะไรเลยก็ได้ ยิ่งไปกว่านั้น ยังอาจปรับให้
returnที่ตามมา หรือแม้แต่คำสั่งก่อนหน้า หายไปได้ด้วย ทำให้ดูเหมือนพฤติกรรมที่ไม่ได้กำหนดไว้ส่งผล “ย้อนเวลา” ได้มันอาจส่งผลต่อคำสั่งก่อนหน้าได้ แต่การจัดเรียงโค้ดใหม่หรือการแปลงที่ซับซ้อนก็เป็นสิ่งที่เกิดขึ้นได้แม้ไม่มีพฤติกรรมที่ไม่ได้กำหนดไว้
อ็อบเจ็กต์แบบ static จะถูกเริ่มต้นเสมอ จึงไม่เกิดสถานการณ์นี้
ที่เหลือคืออ็อบเจ็กต์แบบไดนามิก เช่น สมาชิกของ struct ที่จัดสรรด้วย
mallocแต่ยังไม่ได้เริ่มต้น ซึ่งการอ่านหน่วยความจำไดนามิกที่ยังไม่ได้เริ่มต้นใน C ไม่ใช่พฤติกรรมที่ไม่ได้กำหนดไว้ และจะได้ค่าที่บิตที่ยังไม่ได้เริ่มต้นนั้นสื่อถึง ถ้าชนิดข้อมูลนั้นไม่มี trap representation ก็ไม่มีทางล้มเหลวไวยากรณ์แบบนี้ก็ทำได้
switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }สามารถทำให้เหมือน
switchที่ไม่ต้องเขียนbreakท้ายแต่ละส่วนได้ และยังทำมาโครอย่าง#define brkcase if (0) caseได้ด้วย คอมไพเลอร์คงไม่ชอบ control flow แบบนี้นัก แต่โดยทั่วไปน่าจะลบส่วนเกินออกได้ดี#define brkcase break;caseก็น่าจะทำงานคล้ายกัน แต่แบบนั้นจุดประสงค์ของมาโครก็หายไปเล็กน้อยgotoดีกว่าcaseเป็นบรรทัดเดียวหรือถูกครอบด้วยวงเล็บปีกกาเท่านั้นเมื่อก่อนเคยใช้โครงสร้างนี้ในความหมายว่า “ข้ามเฉพาะบรรทัดแรกของ label
caseถัดไป แล้วให้ส่วนที่เหลือ fallthrough ต่อไปเหมือนเดิม”ถ้ามอง label
caseเป็นเพียง label ธรรมดา ไม่ใช่ตัวคั่นระหว่าง statement ทุกอย่างก็สมเหตุสมผลสามารถใช้เทคนิคนี้เพื่อทำ coroutine ใน C ได้: https://stackoverflow.com/questions/24202890/switch-based-co...
ไม่รู้ทำไมถึงไม่เคยรู้ว่า
case 1 ... 10:เป็น C ที่ถูกต้องใช้ C มาตั้งหลายปี เลยสงสัยว่ามันมาจากมาตรฐานไหน
หาไม่เจอว่าประวัติของส่วนขยายนี้เป็นมาอย่างไร และเท่าที่รู้ไม่มีใน C มาตรฐาน ส่วน
clangไม่แน่ใจเมื่อก่อนเคยเขียนโค้ด C แปลก ๆ ที่นับถอยหลังจาก 10 ถึง 1 เล่น ๆ
ทั้งเวอร์ชัน C, Python และ shell สามารถทำเป็น one-liner ได้โดยใช้
&&กับการเรียกตัวเองแบบ recursivesys.stdout.write(f"{n}\n")สามารถเปลี่ยนเป็นprint(n)ได้โค้ดตอนนี้ถ้าไม่นับ
f-stringดูเหมือนสไตล์ Python 2 ตอนที่printยังเป็น statement อยู่ ใน Python 3printเป็นฟังก์ชันธรรมดาและคืนค่าNoneซึ่งประเมินเป็น false ดังนั้นandตัวแรกก็ต้องเปลี่ยนเป็นorด้วยอีกเรื่องที่น่าประหลาดใจคือ
4[arr]เหมือนกับarr[4]*(array_label+offset)และในกรณีนี้กลายเป็น*(offset+array_label)กล่าวคือ
*(arr+4)กับ*(4+arr)เหมือนกันarr[i][j]กับj[i[arr]]ก็เหมือนกันทุกประการแค่รู้ว่า
a[x][y]เหมือนกับ(a[x])[y]และa[x]เหมือนกับx[a]ก็พอarr[i][j]→(arr[i])[j]→(i[arr])[j]→j[i[arr]]ชิ้นส่วนโค้ด obfuscation ท้ายบทความยังแสดง ส่วนขยาย GCC อีกอย่างหนึ่งด้วย: https://stackoverflow.com/questions/34559705/ternary-conditi...
เคยเห็นเทคนิคเล่น ๆ แบบนี้ของผู้เขียนบล็อกคนนี้บน Twitter ก่อนแล้ว
สามารถสร้าง ลูป ด้วย statement
switchได้ด้วย: https://twitter.com/lcamtuf/status/1807129116980007037คิดว่าการเล่นกับ
switchแบบนี้น่าจะเป็นส่วนสำคัญของ Duff's device ไม่ใช่หรือswitchกับลูปได้, การเลือกให้casefallthrough ถ้าไม่breakอย่างชัดเจน, และจุดที่ลูปใน C สามารถกระโดดกลับเข้าไปด้านในswitchได้Duff พยายามปรับแต่ง memory-mapped I/O (MMIO) ให้เร็วขึ้น และใน C ยุคปัจจุบันก็คงไม่ทำแบบนี้แล้ว ตอนนี้ MMIO ไม่ได้เร็วใกล้เคียงความเร็วคำสั่งของ CPU และถ้าข้อมูลมากขึ้นแม้แต่นิดเดียวก็ใช้ DMA ได้
ถ้าเป็นภาษาสมัยใหม่ก็คงไม่จัดการ MMIO เป็นแค่การอ้างผ่าน pointer แบบง่าย ๆ แต่ใน C ต้องคอยเพิ่มทางเลี่ยงใน type system เพื่อคงสิ่งนี้ไว้
ส่วนตัวมองว่าสิ่งที่สืบทอด “Device” ของ Tom Duff คือกลไก iterate loops ของ WUFFS ซึ่งเป็นวิธีระบุการคลี่บางส่วนของ N ขั้นตอนในลูป โดยให้สัญญาว่าผลลัพธ์เหมือนกับการรันเนื้อหาลูปหลัก N ครั้ง แต่อาจเร็วกว่า การทำ vectorization จะเข้าใจเจตนาได้ง่ายขึ้น และ edge case น่ารำคาญอย่าง
M % N != 0ก็ให้เครื่องมือจัดการให้ถูกต้องแทนมนุษย์