3 คะแนน โดย GN⁺ 2024-07-01 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • C เป็นภาษาที่เรียบง่ายและมีพลังในการแสดงออกสูง แต่เพียงแค่คำสั่ง switchและกฎของเลเบล ก็สามารถสร้างโค้ดที่ทำให้นักพัฒนามากประสบการณ์ถึงกับงงได้
  • การทำโค้ดอ่านยากแบบ IOCCC มักซ่อนโค้ดด้วยแมโครและการจัดฟอร์แมต แต่ด้วยไวยากรณ์ของ C เอง ก็สามารถสร้างโค้ดที่อ่านได้แต่ดูแปลกตาได้เช่นกัน
  • switch (...) ไม่จำเป็นต้องมีวงเล็บปีกกา และเพราะมันกระโดดไปยัง case label ที่ตรงกัน จึงอาจทำงานต่างจากลำดับการเริ่มต้นภายในบล็อกทั่วไป
  • case label ไม่จำเป็นต้องอยู่เฉพาะระดับบนสุดของบล็อก 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
  • case label ไม่จำเป็นต้องอยู่เฉพาะระดับบนสุดของบล็อก 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
  • ตัวดำเนินการ && ซึ่งเป็นส่วนขยายของ GNU ใช้สำหรับนำที่อยู่ของเลเบลมาใช้ และทำ goto ไปยังที่อยู่นั้นได้
    • จึงสามารถใช้วิธีอย่าง goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i]; เพื่อสร้าง switch เองได้
    • ดูตัวอย่างได้ที่ Godbolt
  • ด้วยส่วนขยาย GNU แบบเดียวกันนี้ ยังสามารถทำลูปแบบอิงเลเบลภายในประกาศตัวแปรได้แม้ไม่มี for (...)
    • ตัวอย่างสร้างลูปที่พิมพ์ค่าตั้งแต่ i = 0 ถึง i = 5 ด้วยเลเบลและ goto *&&_
    • ดูตัวอย่างได้ที่ Godbolt
    • สนิพเพ็ตสุดท้ายนี้ใช้ได้เฉพาะกับ GCC และอาจไม่ปลอดภัยจากพฤติกรรมที่ไม่ถูกกำหนด
  • ในภาษา C ต่อให้ไม่ได้ตั้งใจทำโค้ดให้อ่านยาก ก็ยังสร้างรูปแบบที่แปลกประหลาดและชวนสับสนอย่างมากได้

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

 
GN⁺ 2024-07-01
ความคิดเห็นบน Hacker News
  • ตัวอย่างข้างต้นบอกว่าโปรแกรมพิมพ์ค่าของ a ออกมา แต่ไม่ได้ถูกเริ่มต้นเป็น 123 ทว่าใน C จริง ๆ แล้วมันอาจเป็นแบบนั้นก็ได้
    การใช้ตัวแปรที่ยังไม่ได้เริ่มต้นไม่ได้หมายถึงการอ่าน “ค่าที่บังเอิญค้างอยู่ในหน่วยความจำนั้น” แต่เป็น พฤติกรรมที่ไม่ได้กำหนดไว้ ดังนั้นคอมไพเลอร์จะทำอะไรก็ได้
    เช่น อาจเริ่มต้นหน่วยความจำนั้นเป็น 123 เสมอ หรือมองว่าทั้งชิ้นส่วนนั้นเป็นพฤติกรรมที่ไม่ได้กำหนดไว้แล้วลบคำสั่งทั้งหมดทิ้งจนไม่พิมพ์อะไรเลยก็ได้ ยิ่งไปกว่านั้น ยังอาจปรับให้ return ที่ตามมา หรือแม้แต่คำสั่งก่อนหน้า หายไปได้ด้วย ทำให้ดูเหมือนพฤติกรรมที่ไม่ได้กำหนดไว้ส่งผล “ย้อนเวลา” ได้

    • ใน C พฤติกรรมที่ไม่ได้กำหนดไว้ ไม่ได้ย้อนเวลาได้จริง ๆ
      มันอาจส่งผลต่อคำสั่งก่อนหน้าได้ แต่การจัดเรียงโค้ดใหม่หรือการแปลงที่ซับซ้อนก็เป็นสิ่งที่เกิดขึ้นได้แม้ไม่มีพฤติกรรมที่ไม่ได้กำหนดไว้
    • ใน C จะเป็นพฤติกรรมที่ไม่ได้กำหนดไว้เฉพาะเมื่อเข้าถึง อ็อบเจ็กต์ที่มีอายุการเก็บแบบอัตโนมัติ ที่ยังไม่ได้เริ่มต้นเท่านั้น
      อ็อบเจ็กต์แบบ 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 ก็น่าจะทำงานคล้ายกัน แต่แบบนั้นจุดประสงค์ของมาโครก็หายไปเล็กน้อย
    • วิธีนี้ดูไม่ชัดเจนมากว่าทำงานอย่างไร ดังนั้นแทนที่จะ ใช้ switch แบบพิสดารสไตล์ Duff's device น่าจะเขียน control flow เดียวกันด้วย goto ดีกว่า
    • วิธีนี้จะทำงานได้ก็ต่อเมื่อเนื้อหาของ label case เป็นบรรทัดเดียวหรือถูกครอบด้วยวงเล็บปีกกาเท่านั้น
      เมื่อก่อนเคยใช้โครงสร้างนี้ในความหมายว่า “ข้ามเฉพาะบรรทัดแรกของ label case ถัดไป แล้วให้ส่วนที่เหลือ fallthrough ต่อไปเหมือนเดิม”
      ถ้ามอง label case เป็นเพียง label ธรรมดา ไม่ใช่ตัวคั่นระหว่าง statement ทุกอย่างก็สมเหตุสมผล
  • สามารถใช้เทคนิคนี้เพื่อทำ coroutine ใน C ได้: https://stackoverflow.com/questions/24202890/switch-based-co...

    • uIP หรือ TCP/IP stack สำหรับไมโครคอนโทรลเลอร์ขนาดเล็ก ก็เป็นตัวอย่างที่น่าสนใจที่ใช้ coroutine ประเภทนี้จริง ๆ: https://github.com/adamdunkels/uip/blob/master/uip/lc-switch...
  • ไม่รู้ทำไมถึงไม่เคยรู้ว่า case 1 ... 10: เป็น C ที่ถูกต้อง
    ใช้ C มาตั้งหลายปี เลยสงสัยว่ามันมาจากมาตรฐานไหน

    • ถ้าไม่ใช่สิ่งที่เพิ่งถูกทำให้เป็นมาตรฐานเมื่อไม่นานนี้ มันก็ไม่ใช่ C ที่ถูกต้อง แต่เป็น ส่วนขยายของ GNU
    • ดูเหมือนเป็น ส่วนขยาย GNU C: https://gcc.gnu.org/onlinedocs/gcc/Case-Ranges.html
      หาไม่เจอว่าประวัติของส่วนขยายนี้เป็นมาอย่างไร และเท่าที่รู้ไม่มีใน C มาตรฐาน ส่วน clang ไม่แน่ใจ
  • เมื่อก่อนเคยเขียนโค้ด C แปลก ๆ ที่นับถอยหลังจาก 10 ถึง 1 เล่น ๆ
    ทั้งเวอร์ชัน C, Python และ shell สามารถทำเป็น one-liner ได้โดยใช้ && กับการเรียกตัวเองแบบ recursive

    • ขอทักเล็กน้อยว่า sys.stdout.write(f"{n}\n") สามารถเปลี่ยนเป็น print(n) ได้
      โค้ดตอนนี้ถ้าไม่นับ f-string ดูเหมือนสไตล์ Python 2 ตอนที่ print ยังเป็น statement อยู่ ใน Python 3 print เป็นฟังก์ชันธรรมดาและคืนค่า None ซึ่งประเมินเป็น false ดังนั้น and ตัวแรกก็ต้องเปลี่ยนเป็น or ด้วย
  • อีกเรื่องที่น่าประหลาดใจคือ 4[arr] เหมือนกับ arr[4]

    • เพราะ array ถูกแปลงเป็น pointer โดยพื้นฐานแล้วจึงเป็น *(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 ไม่ใช่หรือ

    • Duff's device อาศัยจุดที่ไวยากรณ์ K&R C อนุญาตให้ผสมบล็อก switch กับลูปได้, การเลือกให้ case fallthrough ถ้าไม่ 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 ก็ให้เครื่องมือจัดการให้ถูกต้องแทนมนุษย์