สิ่งที่ภาษา C ควรทำอย่างชัดเจน
(digitalmars.com)- แม้ 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 ว่าค่าของ enumeratorCไม่ใช่ค่าคงที่ชนิดจำนวนเต็ม - 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 ซ้ำอีกครั้งในภายหลัง
- เขียน declaration อย่าง
- วิธีนี้เป็นงานซ้ำที่ไม่จำเป็น และทำให้โปรแกรมเมอร์จัดวาง แบบย้อนลำดับ โดยวาง leaf function ไว้ด้านบน และวางฟังก์ชัน interface ระดับ global ไว้ด้านล่าง
- บทความวิจารณ์ว่าการจัดวางเช่นนี้ไม่สมเหตุสมผล เหมือนกับการอ่านบทความหนังสือพิมพ์จากล่างขึ้นบน
การ import declaration โดยไม่ใช้ไฟล์ header
- ใน C หากต้องการใช้โมดูลภายนอก โดยทั่วไปต้องสร้าง declaration ในไฟล์
.hแล้ว include จากไฟล์.c - โครงสร้างตัวอย่างเป็นดังนี้
floo.cincludedex.hและเรียกdex()dex.hประกาศchar dex(char *s, int i);dex.cincludedex.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 ก็รองรับวิธีนี้เช่นกัน
เอกสารที่เกี่ยวข้อง
- ImportC: เอกสาร ImportC
- D Language: เอกสารภาษา D
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
หนึ่งในสิ่งที่คิดถึงที่สุดเวลาใช้ภาษาที่ไม่ใช่ C คือ ไฟล์เฮดเดอร์
สิ่งที่ชอบเป็นพิเศษในโค้ด C คือการแบ่งสาธารณะ/ส่วนตัว และอินเทอร์เฟซ/การใช้งานจริงออกจากกันอย่างชัดเจนมาก แค่กวาดตาดูไฟล์
.hของไลบรารีก็รู้วิธีใช้ได้ ซึ่งเป็นเรื่องดี และโดยทั่วไปใน.hจะมีเอกสารการใช้งานอยู่ โดยไม่ซ้ำกับ.cจะใส่เอกสารไว้ใน.cก็ได้ แต่จากมุมของผู้ใช้ การอ่านอินเทอร์เฟซจะสบายตาน้อยลงมากตัวอย่างเช่นใน Rust ถ้าต้องการดูอินเทอร์เฟซและวิธีใช้ของไลบรารี ก็ใช้
cargo doc --openได้เลย API สาธารณะทั้งหมดจะถูกสร้างอัตโนมัติในรูปแบบที่ค้นหาได้ และไม่จำเป็นต้องเขียนโค้ดซ้ำหรือดูแลด้วยมือระหว่างเฮดเดอร์กับซอร์สมันทำงานได้ดีเฉพาะเมื่อทำตามธรรมเนียมเท่านั้น และเมื่อเทียบกับภาษาอย่าง 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
และก็สงสัยด้วยว่าคอมไพเลอร์ C สมัยใหม่ในทางปฏิบัติยังเป็น single pass อยู่จริงหรือไม่
ตัวอย่าง การประเมินนิพจน์ค่าคงที่ ในบทความค่อนข้างเรียบง่าย แต่ในกรณีที่ซับซ้อนกว่านี้ ความเร็วของคอมไพเลอร์และการใช้หน่วยความจำอาจแย่ลงมาก และถ้าจะใช้ประโยชน์จากมันก็น่าจะต้องมีเครื่องเสมือน
ดังนั้นจึงเข้าใจได้ว่าอาจถูกมองว่า “ซับซ้อนเกินไป” สำหรับการใส่ไว้ในมาตรฐาน น่าจะดีกว่าถ้า C++ หรือ C เดินไปทาง การนำเข้า declaration แทนส่วนผสมแปลก ๆ ที่กำหนดไว้ใน C++20 เช่น นำเข้าโมดูลภายใต้สัญลักษณ์บางตัวอย่าง
#import "string.c" as strแล้วให้เข้าถึงสัญลักษณ์ที่ไม่ใช่ static ทั้งหมดของไฟล์ได้แบบstr.trim(" Hello World ");ส่วนตัวไม่ชอบรูปแบบที่ไม่ได้ระบุพาธไฟล์อย่างชัดเจน เช่น__import dex;เพราะกรณีนี้จะไม่รู้ว่านำเข้าdex.dหรือdex.cถ้าใช้เยอะก็ย่อมกินเวลา compile และหน่วยความจำ สำหรับเรื่องเครื่องเสมือนนั้น constant folding เองก็เป็นเครื่องเสมือนอยู่แล้ว และนี่ก็เหมือนขยายให้รองรับการเรียกฟังก์ชันเพิ่มเข้าไป semantics ของ C นั้นเรียบง่าย จึงไม่ได้เลวร้ายขนาดนั้น วิธี import ที่เสนอมาก็แทบจะเหมือนกับสิ่งที่ D import ทำ: https://dlang.org/spec/module.html#import-declaration. ปัญหาว่าเป็น
dex.dหรือdex.cเกิดขึ้นจริง และคำตอบก็คือการตั้งค่า import path คล้ายกับ include path ของคอมไพเลอร์ CC ก็มี 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 สร้างบางส่วนของเทสต์ได้ แต่ชอบให้คงความเรียบง่ายไว้มากกว่าตัวอย่าง 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ถูกปิดใช้งานใช้สิ่งที่คล้ายกันอยู่: 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
เพียงแต่ว่า identifier ในตัวฟังก์ชันจะยังไม่ถูก resolve จนกว่าฟังก์ชันนั้นเองจะถูกเรียกใช้ และ ณ ตอนนั้นทุกอย่างใน module scope ก็ถูกกำหนดไว้แล้ว ลองใส่ชื่ออะไรก็ได้ในตัวฟังก์ชันแล้วโหลดโมดูลดูก็จะตรวจสอบได้เอง
ไม่ได้มีกฎเคร่งครัดอะไร แต่รู้สึกว่าอ่านแบบนั้นสะอาดและสมเหตุสมผลกว่า โดยเฉพาะถ้า implementation ใหญ่ ผมไม่อยากต้องเลื่อนผ่านทั้งหมดก่อนถึงจะเห็นว่าโดยพื้นฐานแล้วมันทำอะไร อยากรู้ว่าคนอื่นทำกันอย่างไร
“สิ่งที่ชัดเจนว่า C ควรทำให้ได้” สำหรับผมมีทั้งการรองรับ slice type ที่เก็บ pointer กับความยาว, API ที่ใช้ global state ควรมีเวอร์ชันที่ reentrant และถ้าเป็นไปได้ก็ thread-safe, การทำให้สิ่งอย่าง
deferของ Go หรือ Zig หรือ attributecleanupของ GCC กลายเป็นมาตรฐาน และการรองรับ Unicode กับ UTF-8 แบบพกพาได้แนวคิดว่าในทุกตำแหน่งของไวยากรณ์ 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