2 คะแนน โดย GN⁺ 2025-01-13 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • แม้ C มาตรฐานจะเปลี่ยนแปลงมาอย่างต่อเนื่องจนถึง C23 แต่ Walter Bright มองว่าความไม่สะดวกเก่า ๆ อย่าง การประเมินนิพจน์ค่าคงที่, forward reference และการพึ่งพา header ยังรบกวน flow การพัฒนาอยู่
  • คอมไพเลอร์ C ImportC ที่ฝังอยู่ในคอมไพเลอร์ D ใช้ข้อได้เปรียบของการเป็น implementation ใหม่ เพื่อให้สามารถรันฟังก์ชันที่เข้าเงื่อนไขได้ในช่วง compile time ณ ตำแหน่ง constant-expression
  • เมื่อรองรับ CTFE ก็สามารถรัน unit test อย่าง _Static_assert(sum(3, 4) == 7,...) ได้ทุกครั้งที่คอมไพล์ โดยไม่ต้องมีไฟล์ executable แยกต่างหาก
  • ข้อจำกัดเรื่องลำดับการประกาศของ C มาตรฐานทำให้ต้องประกาศล่วงหน้าซ้ำ ๆ และจัดวางโค้ดแบบย้อนลำดับ แต่ ImportC เลือกวิธีที่ผูกกับ ลำดับการประกาศระดับ global น้อยกว่า
  • การนำ declaration จากไฟล์ .c มาใช้โดยตรง เช่น __import dex; ช่วยลดจำนวนไฟล์ .h แยกต่างหาก และลดภาระ debugging จากความไม่ตรงกันระหว่าง header กับ implementation

ความไม่สะดวกที่ยังเหลืออยู่ใน C มาตรฐาน

  • C มาตรฐานได้รับการปรับปรุงเป็นประจำจนถึง C23 แต่ Walter Bright มองว่ายังมีส่วนที่ยังไม่ได้แก้ไขเหลืออยู่
  • ชุมชน Dlang ได้ฝังคอมไพเลอร์ C ไว้ภายในคอมไพเลอร์ของภาษาโปรแกรม D และเรียกคอมไพเลอร์นี้ว่า ImportC
  • ImportC ถูกสร้างขึ้นใหม่ตั้งแต่ต้น จึงมีโอกาสใช้เทคโนโลยีคอมไพเลอร์สมัยใหม่เพื่อจัดการจุดที่ C ยังขาดอยู่
  • บทความนี้กล่าวถึง 4 รายการ
    • การประเมิน constant-expression
    • unit test ในช่วง compile time
    • forward reference ของการประกาศ
    • การ import declaration

การรันฟังก์ชันในช่วง compile time และการประเมินนิพจน์ค่าคงที่

  • C สามารถคำนวณนิพจน์ง่าย ๆ ในช่วง compile time ด้วย constant folding ได้ แต่ใน C มาตรฐานไม่สามารถรันฟังก์ชันในช่วง compile time ได้
  • หากคอมไพล์โค้ดตัวอย่าง enum E { A = 3, B = 4, C = sum(5, 6) }; ด้วย gcc จะเกิด error ว่าค่าของ enumerator C ไม่ใช่ค่าคงที่ชนิดจำนวนเต็ม
  • ImportC สามารถคอมไพล์โค้ดเดียวกันได้
  • แนวทางคือคอมไพเลอร์ควรสามารถรันฟังก์ชันในช่วง compile time ได้ในทุกตำแหน่งที่ constant-expression ปรากฏในไวยากรณ์ของ C
  • อย่างไรก็ตาม ฟังก์ชันดังกล่าวต้องไม่ทำงานอย่าง I/O, การเข้าถึงตัวแปร global ที่เปลี่ยนแปลงได้ หรือ system call

unit test ที่รันทุกครั้งเมื่อคอมไพล์

  • เมื่อสามารถประเมินฟังก์ชันในช่วง compile time หรือ CTFE ได้ วิธีทำ unit test ก็อาจเปลี่ยนไป
  • เหตุผลที่ unit test ในโค้ด C ไม่แพร่หลาย คือความยุ่งยากที่ต้องสร้าง build target และไฟล์ executable แยกต่างหาก แล้วต้องรันมัน
  • หากคอมไพล์โค้ดตัวอย่าง _Static_assert(sum(3, 4) == 7, "test #1"); ด้วย gcc จะเกิด error ว่านิพจน์ภายใน static assertion ไม่ใช่ค่าคงที่
  • ImportC สามารถคอมไพล์โค้ดนี้ได้
  • วิธีนี้ทำให้ unit test ของฟังก์ชันที่รันได้ในช่วง compile time ง่ายขึ้น
    • ไม่ต้องมี build แยกต่างหาก
    • ไม่ต้องมีงานเพิ่มเติม
    • test จะรันทุกครั้งที่คอมไพล์โค้ด
  • test suite ของ ImportC ก็ใช้วิธีนี้อย่างกว้างขวาง

การประกาศซ้ำที่เกิดจาก forward reference

  • ใน C มาตรฐาน คอมไพเลอร์รู้เฉพาะ declaration ที่ปรากฏมาก่อนตามลำดับ lexical เท่านั้น จึงไม่อนุญาต forward reference
  • ในโค้ดตัวอย่าง หาก floo() เรียก dex() ที่ถูกนิยามไว้ทีหลังก่อน gcc จะรายงาน error ว่าการประกาศแบบ implicit ของ dex ขัดแย้งกับชนิดของนิยามจริง
  • หากสลับลำดับของ floo กับ dex โค้ดเดียวกันจะคอมไพล์ได้ตามปกติ
  • ImportC ยอมรับ declaration ระดับ global ได้ไม่ว่าจะปรากฏในลำดับใด
  • หากทำ forward reference ไม่ได้ ก็ต้องเพิ่ม declaration แยกต่างหากสำหรับทุก definition ที่จะถูกอ้างถึงก่อน
    • เขียน declaration อย่าง char dex(char *s, int i); ไว้ก่อน
    • จากนั้นต้องเขียน function definition ซ้ำอีกครั้งในภายหลัง
  • วิธีนี้เป็นงานซ้ำที่ไม่จำเป็น และทำให้โปรแกรมเมอร์จัดวาง แบบย้อนลำดับ โดยวาง leaf function ไว้ด้านบน และวางฟังก์ชัน interface ระดับ global ไว้ด้านล่าง
  • บทความวิจารณ์ว่าการจัดวางเช่นนี้ไม่สมเหตุสมผล เหมือนกับการอ่านบทความหนังสือพิมพ์จากล่างขึ้นบน

การ import declaration โดยไม่ใช้ไฟล์ header

  • ใน C หากต้องการใช้โมดูลภายนอก โดยทั่วไปต้องสร้าง declaration ในไฟล์ .h แล้ว include จากไฟล์ .c
  • โครงสร้างตัวอย่างเป็นดังนี้
    • floo.c include dex.h และเรียก dex()
    • dex.h ประกาศ char dex(char *s, int i);
    • dex.c include dex.h และนิยาม dex()
  • การสร้างไฟล์ .h สำหรับทุกโมดูลภายนอกทำให้งานซ้ำเพิ่มขึ้น
  • หากไฟล์ .h ไม่ตรงกับไฟล์ .c อย่างถูกต้อง อาจต้องใช้เวลามากในการหาว่าอะไรผิดพลาด
  • วิธีที่เสนอคือ import dex.c โดยตรง
    • ใช้ __import dex; ใน floo.c
    • ใน dex.c ใส่เฉพาะ definition เช่น char dexx(char *s, int i) { return s[i]; }
  • ด้วยวิธีนี้จึงไม่จำเป็นต้องเขียนไฟล์ .h เลย
  • ImportC ก็รองรับวิธีนี้เช่นกัน

เอกสารที่เกี่ยวข้อง

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

 
GN⁺ 2025-01-13
ความคิดเห็นบน Hacker News
  • หนึ่งในสิ่งที่คิดถึงที่สุดเวลาใช้ภาษาที่ไม่ใช่ C คือ ไฟล์เฮดเดอร์
    สิ่งที่ชอบเป็นพิเศษในโค้ด C คือการแบ่งสาธารณะ/ส่วนตัว และอินเทอร์เฟซ/การใช้งานจริงออกจากกันอย่างชัดเจนมาก แค่กวาดตาดูไฟล์ .h ของไลบรารีก็รู้วิธีใช้ได้ ซึ่งเป็นเรื่องดี และโดยทั่วไปใน .h จะมีเอกสารการใช้งานอยู่ โดยไม่ซ้ำกับ .c จะใส่เอกสารไว้ใน .c ก็ได้ แต่จากมุมของผู้ใช้ การอ่านอินเทอร์เฟซจะสบายตาน้อยลงมาก

    • ข้ออ้างนี้รู้สึกแปลกเสมอ เพราะภาษาอื่น ๆ แก้ปัญหานี้ด้วย เครื่องมือ และผมมองว่านั่นดีกว่า
      ตัวอย่างเช่นใน Rust ถ้าต้องการดูอินเทอร์เฟซและวิธีใช้ของไลบรารี ก็ใช้ cargo doc --open ได้เลย API สาธารณะทั้งหมดจะถูกสร้างอัตโนมัติในรูปแบบที่ค้นหาได้ และไม่จำเป็นต้องเขียนโค้ดซ้ำหรือดูแลด้วยมือระหว่างเฮดเดอร์กับซอร์ส
    • ผมมองว่าไฟล์เฮดเดอร์เป็นเหมือนแฮ็กแบบอ่อน ๆ เพื่อรับมือกับ แพลตฟอร์มที่มีข้อจำกัดด้านทรัพยากร ในยุค 70
      มันทำงานได้ดีเฉพาะเมื่อทำตามธรรมเนียมเท่านั้น และเมื่อเทียบกับภาษาอย่าง Ada ที่ออกแบบสเปกของอินเทอร์เฟซและการใช้งานจริงไว้อย่างดีจนไม่ต้องพาร์สซ้ำแล้ว ก็ดูด้อยไปเลย ผมชอบใช้ C แต่ส่วนนี้ควรถูกออกแบบให้ดีกว่านี้
    • เห็นด้วยและก็ไม่เห็นด้วย
      ไฟล์เฮดเดอร์ของ C เป็นวิธีเอาข้อความมาแทรกตรง ๆ จึงอยู่ในระดับ “ดูเหมือนพอใช้ได้แบบคร่าว ๆ” ในขณะที่ Ada มีแนวคิดเรื่องแพ็กเกจกับเนื้อหาแพ็กเกจ โดยแพ็กเกจเทียบได้กับไฟล์เฮดเดอร์ และเนื้อหาแพ็กเกจเทียบได้กับการใช้งานจริง เมื่อก่อนตอนใช้ Ada แม้การใช้งานจริงในเนื้อหาแพ็กเกจยังไม่พร้อม ทุกคนก็ยังคอมไพล์โดยอิงกับแพ็กเกจได้ ทำให้ปรับอินเทอร์เฟซให้ตรงกันได้ก่อนมีการใช้งานจริง อีกทางหนึ่งคือ import ของ Python แมปเข้ากับระบบไฟล์ได้อย่างเป็นธรรมชาติ และไม่ต้องจัดการกับเซแมนติกส์ของ include ใน C จึงชอบมันในฐานะบทบาทแบบ “ไฟล์เฮดเดอร์”
    • ดูเหมือนว่าตรงนี้มีความแตกต่างด้านวิธีคิดแฝงอยู่
      เมื่อมีไฟล์เฮดเดอร์ ก็ทำให้เราคิดว่าอินเทอร์เฟซเป็น คนละสิ่ง กับการใช้งานจริง ดังนั้นคนที่คุ้นกับแนวทางนี้จึงรู้สึกไม่สบายใจกับแนวคิดที่ว่าอินเทอร์เฟซถูกสร้างขึ้นโดยเครื่องมือ อินเทอร์เฟซไม่ใช่ผลพลอยได้ของการใช้งานจริง แต่เป็นสิ่งที่ออกแบบแยกต่างหากอย่างตั้งใจ และสำหรับบางคนสำคัญยิ่งกว่าการใช้งานจริงด้วยซ้ำ ในทางกลับกัน คนที่คุ้นกับเอกสารที่สร้างอัตโนมัติจะรู้สึกไม่สบายใจกับการที่อินเทอร์เฟซไม่ได้ถูกสร้างจากซอร์สของการใช้งานจริงซึ่งเป็นแหล่งความจริงเดียว เมื่อใช้ภาษาที่มีไฟล์อินเทอร์เฟซแยกกับภาษาที่ไม่มีไปนาน ๆ แต่ละคนก็จะยึดติดกับค่ายของตน และลืมความรู้สึกเวลาคิดแบบฝั่งตรงข้ามไป
    • ภาษาโมดูลแบบคอมไพล์ส่วนใหญ่ทำได้อยู่แล้ว
      ไม่ว่าจะเป็นการแยกไว้ต่างหากแบบ Modula-2, Modula-3, Ada, Standard ML, Caml Light, OCaml, F#, D หรือสร้างด้วยเครื่องมือแบบข้อความหรือกราฟิกอย่าง Object Pascal, D, Haskell, Java, C#, F#, Swift, Go, Rust ทั้งหมดมี type ที่แข็งแรงกว่า คอมไพล์เร็วกว่า และมี namespace ที่ถูกต้อง เครื่องมือของ Rust และ Swift ยังมีจุดที่ต้องปรับอยู่บ้าง แต่เครื่องมือของ C นั้นดูดิบเถื่อนมาโดยตลอดเมื่อเทียบกับสิ่งที่เกิดขึ้นนอก Bell Labs ถ้า AT&T สามารถนำไปใช้เชิงพาณิชย์ได้ ประวัติศาสตร์คงเปลี่ยนไป แต่ผลลัพธ์คือเราได้มะนาวฟรีแทนส้มสุก ๆ อย่างไรก็ตาม ก็มีการทำสิ่งที่คล้าย TypeScript สำหรับ C ขึ้นมา และทุกวันนี้ยังรองรับชนิดคอลเลกชันที่ตรวจขอบเขตกับโมดูลที่ถูกต้องด้วย
  • ในสาขานี้ผู้เขียนมีความรู้และประสบการณ์มากกว่ามาก จึงอยากรู้ว่าจะจัดการปัญหาต่อไปนี้อย่างไร
    การประเมินนิพจน์ค่าคงที่ จะง่ายขึ้นถ้าทำเฉพาะภายใน translation unit แต่สิ่งที่ทำได้โดยไม่ต้องเขียนโค้ดซ้ำจะถูกจำกัดลงมาก unit test ตอนคอมไพล์ไทม์ ทำได้ระดับหนึ่งถ้าแสดง test เป็นมาโครได้ และจะง่ายขึ้นเมื่อรวมกับข้อก่อนหน้า การอ้างอิงล่วงหน้าของ declaration อาจถูกต่อต้านมาก เพราะคอมไพเลอร์จะเปลี่ยนจาก 1 pass เป็น 2 pass และมีผลต่อประสิทธิภาพ สำหรับคนที่คอมไพล์ codebase ขนาดใหญ่มากพร้อมทำ parallelization ระดับ translation unit ด้วย อาจยอมรับได้ยาก การนำเข้า declaration เป็นการเปลี่ยนแปลงที่ทำลาย compatibility ผมเคยลองทำอะไรคล้ายเทมเพลตใน C โดยกำหนดตัวแปร แล้ว import ไฟล์ .c จากนั้นเปลี่ยนตัวแปร แล้ว import .c เดิมอีกครั้ง ผมยังเคยใส่ definition หลายตัวหน้า SQLite C Amalgamation และเพิ่มฟังก์ชันเพื่อเปิดเผยฟังก์ชันภายในด้วย กรณีใช้งานแบบนี้ดูเหมือนจะพังหมด จึงอยากรู้ว่ามีทางแก้สำหรับปัญหาเหล่านี้ไหม

    • ประเด็นที่ว่าคอมไพเลอร์ต้องมองเห็นซอร์สโค้ดของฟังก์ชันที่จะถูกประเมินนั้นถูกต้อง
      ใน C ทำได้ด้วย #include และใน D จัดการโดย import โมดูลที่มีโค้ดที่ต้องการ วิธีแสดง test เป็นมาโครไม่เหมาะเมื่อคุณต้องการทดสอบฟังก์ชัน ตัวอย่างยกมาแบบง่าย ๆ เพื่อช่วยให้เข้าใจ ส่วนการใช้งานจริงอาจซับซ้อนกว่านั้นมาก ด้านประสิทธิภาพ D คอมไพล์ได้เร็วกว่า compiler ของ C เหตุผลหลักคือ preprocessor ของ C เป็นโครงสร้างเหมือนฟอสซิลที่ต้องใช้หลาย pass ส่วน D ใช้ import แทน #include จึงไม่ต้องคอมไพล์ไฟล์ .h ซ้ำ ๆ กลยุทธ์ของ D คือแยกการพาร์สกับการวิเคราะห์เชิงความหมายออกจากกัน และแม้อาจช้าลงเล็กน้อย แต่ก็ไม่มีต้นทุนในการคอมไพล์ declaration ซ้ำแล้วพับรวมให้เป็นอันเดียว การรันฟังก์ชันตอนคอมไพล์ไทม์ถ้าใช้มาก ๆ อาจกลายเป็นคอขวดได้ แต่ถ้าใช้เบา ๆ ประสิทธิภาพก็ใช้ได้ หากคุณกำลังแฮ็กเทมเพลตใน C อยู่ นั่นก็ข้ามขอบเขตของภาษาไปแล้ว และคุณต้องการภาษาที่ทรงพลังกว่า D มี metaprogramming ระดับชั้นยอด และภาษาเทมเพลตอื่น ๆ ก็มักเดินตามแนวทางของ D
    • โดยส่วนตัวผมไม่ชอบ การอ้างอิงล่วงหน้า เพราะทำให้การอ่านโค้ดยากขึ้น
      เราจะไม่สามารถเชื่อได้อีกต่อไปว่ากราฟ dependency ถูกวางเรียงตามลำดับ topological sort
    • ภาษาอื่น ๆ ดูเหมือนจะไปได้สวยโดยไม่มีไฟล์เฮดเดอร์หรือ forward declaration เลย จึงเข้าใจยากว่าทำไมถึงมีแรงต่อต้าน
      และก็สงสัยด้วยว่าคอมไพเลอร์ C สมัยใหม่ในทางปฏิบัติยังเป็น single pass อยู่จริงหรือไม่
  • ตัวอย่าง การประเมินนิพจน์ค่าคงที่ ในบทความค่อนข้างเรียบง่าย แต่ในกรณีที่ซับซ้อนกว่านี้ ความเร็วของคอมไพเลอร์และการใช้หน่วยความจำอาจแย่ลงมาก และถ้าจะใช้ประโยชน์จากมันก็น่าจะต้องมีเครื่องเสมือน
    ดังนั้นจึงเข้าใจได้ว่าอาจถูกมองว่า “ซับซ้อนเกินไป” สำหรับการใส่ไว้ในมาตรฐาน น่าจะดีกว่าถ้า C++ หรือ C เดินไปทาง การนำเข้า declaration แทนส่วนผสมแปลก ๆ ที่กำหนดไว้ใน C++20 เช่น นำเข้าโมดูลภายใต้สัญลักษณ์บางตัวอย่าง #import "string.c" as str แล้วให้เข้าถึงสัญลักษณ์ที่ไม่ใช่ static ทั้งหมดของไฟล์ได้แบบ str.trim(" Hello World "); ส่วนตัวไม่ชอบรูปแบบที่ไม่ได้ระบุพาธไฟล์อย่างชัดเจน เช่น __import dex; เพราะกรณีนี้จะไม่รู้ว่านำเข้า dex.d หรือ dex.c

    • ภาษาอื่น ๆ ที่นิยมก็ทำได้เหมือนกัน และใน D นั้น การรันฟังก์ชันตอนคอมไพล์ เป็นฟีเจอร์ที่ได้รับความนิยมและมีประโยชน์มาก
      ถ้าใช้เยอะก็ย่อมกินเวลา compile และหน่วยความจำ สำหรับเรื่องเครื่องเสมือนนั้น constant folding เองก็เป็นเครื่องเสมือนอยู่แล้ว และนี่ก็เหมือนขยายให้รองรับการเรียกฟังก์ชันเพิ่มเข้าไป semantics ของ C นั้นเรียบง่าย จึงไม่ได้เลวร้ายขนาดนั้น วิธี import ที่เสนอมาก็แทบจะเหมือนกับสิ่งที่ D import ทำ: https://dlang.org/spec/module.html#import-declaration. ปัญหาว่าเป็น dex.d หรือ dex.c เกิดขึ้นจริง และคำตอบก็คือการตั้งค่า import path คล้ายกับ include path ของคอมไพเลอร์ C
    • คอมไพเลอร์ C ที่ใช้งานจริงส่วนใหญ่น่าจะทำ การประเมินตอนคอมไพล์ อยู่แล้วในระดับหนึ่งเพื่อการ optimize
      C ก็มี constant expression อยู่แล้ว อุปสรรคที่ใหญ่กว่าคือคอมไพเลอร์ต้องเข้าถึงซอร์สโค้ดของฟังก์ชันได้ ดังนั้นอาจถูกจำกัดไว้ที่ฟังก์ชันใน translation unit เดียวกัน และปัญหาเรื่องคนก็อาจใหญ่กว่ามาก เพราะคณะกรรมการที่มีตัวแทนจากหลายคอมไพเลอร์ต้องตกลงกันให้ได้เกี่ยวกับ semantics ของการประเมินค่าคงที่แบบนั้น
    • จริง ๆ แล้วสิ่งนี้ดูเกือบจะตรงกับฟีเจอร์ constexpr ของ C++ แบบ 1:1
      คอมไพเลอร์ C แทบทั้งหมดก็เป็นคอมไพเลอร์ C++ อยู่แล้ว จึงสงสัยว่าการรองรับฟังก์ชัน constexpr และการประเมินใน C จะเลวร้ายได้ขนาดนั้นจริงหรือ
  • เขียน unit test ให้โค้ด C เสมอ
    ถ้าใช้ build system ที่ดีและยอมรับ boilerplate เล็กน้อย ก็ไม่ได้ยาก เทสต์ของไลบรารี npy จะเรียก npy_load("tests/npy/uint8.npy") ใน test_load_uint8() จากนั้น assert มิติ ขนาด และชนิด แล้ว npy_free และรันใน main แบบ PRINT_RUN(test_load_uint8); อาจใช้ preprocessor สร้างบางส่วนของเทสต์ได้ แต่ชอบให้คงความเรียบง่ายไว้มากกว่า

    • ฟังก์ชันนั้นดูเหมือนจะทำ I/O จึงใช้เป็น เทสต์ตอนคอมไพล์ ไม่ได้
      ตัวอย่าง unit test ของคอมไพเลอร์ ImportC จะเป็นประมาณ _Static_assert(sizeof(struct S22079){1,2,3} == sizeof(int)*3, "ok"); และ _Static_assert(sizeof(struct S22079){1,2,3}.a == sizeof(int), "ok"); ตรวจสอบ semantics ตอนคอมไพล์ จึงไม่ต้องลิงก์และรัน ยิ่งมีเทสต์มาก วิธีนี้ก็ยิ่งเร็วขึ้นมาก และยิ่งชุดทดสอบรันเร็ว ผลิตภาพก็ยิ่งสูงขึ้น
    • ผมก็ทำแทบจะเหมือนกัน
      เคยขุดลึกกับ unit test framework หรู ๆ อยู่ช่วงหนึ่ง แต่พอรู้ว่าไม่ได้ประโยชน์มากนัก ก็ลงเอยกับรูปแบบที่แทบเหมือนกัน จุดที่คนอื่นอาจมองไม่ดีก็คือ ยอม #include ไฟล์ .c ที่เป็นเป้าหมายการทดสอบเพื่อเรียกฟังก์ชัน static โดย include ไฟล์ .c เพียงไฟล์เดียวเท่านั้น อีกอย่างคือทำ preprocessing เล็กน้อยก่อน #include เพื่อไม่ให้ NDEBUG ถูกกำหนดไว้ ป้องกันกรณีที่ใครบางคน build แบบ “release mode” แล้ว assert ถูกปิดใช้งาน
    • คิดว่าการทำให้รันเทสต์ได้ง่ายที่สุดช่วยได้มากจริง ๆ ในการไปสู่ codebase ที่มีเทสต์
      ใช้สิ่งที่คล้ายกันอยู่: https://github.com/ensisoft/detonator/blob/master/base/test_.... ยืมมาจาก boost.test.minimal ค่อนข้างมาก เดิมทีเป็น header เดียว แต่เมื่อเวลาผ่านไปก็ต้องเพิ่ม translation unit เดียวเข้ามา สรุปได้ว่า ถ้ารักษา codebase ให้อยู่ในสภาพที่เทสต์ผ่านเสมอ ความซับซ้อนอย่างการรายงานข้อผิดพลาดหรือการยอมให้มีข้อบกพร่องของเครื่องมือทดสอบก็จำเป็นน้อยลงมาก
  • unit test ตอนคอมไพล์ เป็นแนวคิดที่ไม่ดีพอ ๆ กับการทำให้ “import/ตัวแปร/ผลลัพธ์ที่ไม่ได้ใช้” เป็น error แทนที่จะเป็น warning
    มันเป็น “ฟีเจอร์พี่เลี้ยง” ที่แย่งการควบคุมไปจากนักพัฒนา และสุดท้ายทำให้ต้องทำตามขั้นตอนแบบราชการเพื่อให้งานเสร็จ เทสต์ที่ทำให้ build ล้มเหลวแบบนี้เหมาะกับ build ที่ “ดูเหมือนเสร็จแล้ว” แต่ไม่เหมาะกับ build ที่ “ยังทำงานค้างอยู่” ซึ่งคิดเป็น 99% ของ build ทั้งหมด คล้ายกับการบอกว่า “ห้ามใช้โต๊ะเลื่อยจนกว่าจะเก็บสว่านก่อน”

    • ตรงนี้เห็นด้วยยาก
      ถ้าพยายามจะสื่อความคิดบางอย่างแล้วเทสต์ตอนคอมไพล์บอกว่าผิด จริง ๆ แล้วความคิดนั้นอาจยังไม่สมบูรณ์ หรืออาจยังคิดผลลัพธ์ทั้งหมดของการแสดงออกนั้นไม่พอ คล้ายกับการตรวจชนิดของ Haskell โปรแกรมที่ไม่ผ่านการตรวจชนิดจะคอมไพล์ไม่ได้ และบังคับให้โปรแกรมเมอร์แสดงออกเฉพาะความคิดที่สมบูรณ์เสมอ ในเชิงทฤษฎีอาจนำไปสู่โปรแกรมที่ผ่านการไตร่ตรองมากกว่า อย่างไรก็ตาม การเขียนจะยากขึ้น เพราะมันบังคับให้โปรแกรมเมอร์ต้องค้นพบแม้แต่มุมที่ “รู้ว่าไม่ valid แต่ไม่สนใจ”
    • เทสต์ตอนคอมไพล์แบบนี้อาจใกล้เคียงกับ static_assert มากกว่า และมีคุณค่ามากในการจับ การใช้งานที่ไม่เข้ากัน ของฟังก์ชันในไลบรารี
      คิดว่าเป็นไอเดียที่ค่อนข้างดี
  • แนวคิดที่ว่า “ฟังก์ชัน leaf มาก่อน และฟังก์ชันอินเทอร์เฟซระดับ global อยู่ท้ายสุด” สำหรับผมนั้นกลับด้านกัน
    ด้วยหลายเหตุผล ผมชอบเขียนโค้ดตาม ลำดับการเรียงเชิงทอพอโลยี มากกว่า มันคล้ายกับวิธีเขียนโค้ดภายในฟังก์ชัน ทำให้ชัดเจนว่าควรวางฟังก์ชันไว้ตรงไหนในโมดูล และที่สำคัญที่สุดคือทำให้การพึ่งพาแบบวนรอบระหว่างชิ้นส่วนโค้ดภายในโมดูลเห็นได้ชัดมาก ผมไม่ค่อยชอบการพึ่งพาแบบวนรอบ เพราะมันทำให้ codebase พัวพันกันมากขึ้น และทำให้เข้าใจโมดูลเป็นหน่วยอิสระได้ยาก ใน Python มันอาจสร้างปัญหาที่ไม่ปรากฏจนกว่าจะรันจริง[0] และเพราะ circular import พบได้บ่อยมาก ดูเหมือนตัวตรวจชนิดในปัจจุบันจะปิดการวินิจฉัยเรื่องนี้ไว้เป็นค่าเริ่มต้น[1] ภาษาอย่าง C, OCaml, SML ที่ไม่รองรับ forward reference ทำให้สามารถใช้ “หลักการประหลาดใจน้อยที่สุด” กับการพึ่งพาแบบวนรอบได้ OCaml ถึงกับห้ามการพึ่งพาแบบ recursive ระหว่างฟังก์ชันด้วย หากไม่ประกาศในรูป let rec fn1 = .. and fn2 = .. ซึ่งตอนเขียนอาจน่ารำคาญเล็กน้อย แต่ตอนอ่านกลับเป็นข้อมูลสำคัญ
    [0]: https://gist.github.com/Mark24Code/2073470277437f2241033c200...
    [1]: https://microsoft.github.io/pyright/#/configuration?id=type-... (ดู reportImportCycles)

  • มีคำอธิบายว่าเพราะคอมไพเลอร์รู้จักเฉพาะสิ่งที่มาก่อนตามลำดับ lexical จึงเกิดลำดับแบบ “จากล่างขึ้นบน” ที่ฟังก์ชัน leaf มาก่อนและฟังก์ชันอินเทอร์เฟซระดับ global อยู่ท้ายสุด แต่แม้แต่ในภาษาอย่าง Python ที่อนุญาต forward reference ลำดับแบบนี้ก็พบได้บ่อย[0]
    เลยสงสัยว่านี่เป็นเศษตกค้างจากภาษาที่ไม่อนุญาต forward reference หรือว่าเป็นวิธีที่สมเหตุสมผลกว่าจริง ๆ สำหรับโค้ดบางประเภท
    [0] https://stackoverflow.com/a/73131538

    • Python ไม่มี forward reference
      เพียงแต่ว่า identifier ในตัวฟังก์ชันจะยังไม่ถูก resolve จนกว่าฟังก์ชันนั้นเองจะถูกเรียกใช้ และ ณ ตอนนั้นทุกอย่างใน module scope ก็ถูกกำหนดไว้แล้ว ลองใส่ชื่ออะไรก็ได้ในตัวฟังก์ชันแล้วโหลดโมดูลดูก็จะตรวจสอบได้เอง
    • ในโค้ดของผม public interface มักจะอยู่ด้านบนเสมอ ส่วนรายละเอียดการ implement มักจะไปอยู่ท้ายไฟล์
      ไม่ได้มีกฎเคร่งครัดอะไร แต่รู้สึกว่าอ่านแบบนั้นสะอาดและสมเหตุสมผลกว่า โดยเฉพาะถ้า implementation ใหญ่ ผมไม่อยากต้องเลื่อนผ่านทั้งหมดก่อนถึงจะเห็นว่าโดยพื้นฐานแล้วมันทำอะไร อยากรู้ว่าคนอื่นทำกันอย่างไร
  • “สิ่งที่ชัดเจนว่า C ควรทำให้ได้” สำหรับผมมีทั้งการรองรับ slice type ที่เก็บ pointer กับความยาว, API ที่ใช้ global state ควรมีเวอร์ชันที่ reentrant และถ้าเป็นไปได้ก็ thread-safe, การทำให้สิ่งอย่าง defer ของ Go หรือ Zig หรือ attribute cleanup ของ GCC กลายเป็นมาตรฐาน และการรองรับ Unicode กับ UTF-8 แบบพกพาได้

    • ส่วนใหญ่ในนี้ไม่ใช่สิ่งที่ตัวภาษาควรทำเอง แต่เป็นฟีเจอร์ที่อยากได้ใน standard library มากกว่าไม่ใช่หรือ?
  • แนวคิดว่าในทุกตำแหน่งของไวยากรณ์ C ที่มี constant expression คอมไพเลอร์ควรสามารถรันฟังก์ชันตอน compile time ได้ ตราบใดที่ฟังก์ชันนั้นไม่ทำ I/O, ไม่เข้าถึง global variable ที่เปลี่ยนค่าได้, ไม่เรียก system call ฯลฯ นั้นพังได้ง่าย
    แค่เลือก pure function ที่รันนานมากก็พอ เช่น ถ้า int busybeaver(int n) {...} เป็น pure function ที่คืนอายุการทำงานสูงสุดของเครื่อง busy beaver แบบ n สถานะ โค้ดอย่าง int x = busybeaver(99); ก็จะกลายเป็นปัญหา

  • C23 มี constexpr แล้ว แต่ยังใช้กับฟังก์ชันไม่ได้
    อย่างไรก็ตามมีข้อเสนออยู่: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2976.pdf