การประเมินว่า Zig comptime ยอดเยี่ยม
(scottredig.com)- เมตาโปรแกรมมิงแบบ compile time (comptime) ของ Zig อ่านได้เหมือนโค้ด runtime แต่สามารถสร้างโค้ดที่รันได้อย่างมีประสิทธิภาพเทียบเท่าฟังก์ชัน C ที่เขียนด้วยมือสำหรับโค้ดซ้ำ ๆ เช่น การรวมค่าฟิลด์ของ struct
- แทนที่จะมีไวยากรณ์ generic แยกต่างหาก จุดใช้งานหลักของ Zig comptime คือการจัดการ generic programming ด้วย
comptime T: typeและanytype - runtime, comptime และ build system ใช้ภาษา Zig เดียวกันร่วมกัน ดังนั้นแม้แต่ฟังก์ชันอย่าง Fizz Buzz ก็สามารถรันระหว่างคอมไพล์เพื่อสร้างเป็นอาร์เรย์ที่คำนวณไว้ล่วงหน้าได้
- คอมไพเลอร์จะประเมินส่วนที่ทำได้ก่อน และเหลือไว้เฉพาะส่วนที่ต้องใช้ค่า runtime เป็น bytecode เอาต์พุต ที่จะกลายเป็น machine code ภายหลัง
- Zig comptime สามารถแก้ปัญหาคล้ายกับการสร้างโค้ดแบบข้อความได้ แต่ไม่แทนที่คีย์เวิร์ดแบบตามใจเหมือน macro ของ C จึงลดภาระในการอ่านโค้ดและดีบัก
มุมมองในการอ่าน Zig comptime
- comptime ของ Zig เป็นฟีเจอร์เมตาโปรแกรมมิง แต่เวลาอ่านพฤติกรรมสุดท้าย ไม่จำเป็นต้องกังวลกับการแยก compile time กับ runtime มากนัก
- ข้อที่สามของ
zig zenคือ “Favor reading code over writing code” เป็นเกณฑ์สำคัญ- เมตาโปรแกรมมิงที่อิง macro หรือการสร้างโค้ดอาจสร้างสองชั้น คือโค้ดต้นฉบับและโค้ดที่ถูกขยายออก
- ชั้นทางอ้อมแบบนี้ทำให้การอ่านโค้ด การดีบัก และการเปลี่ยนพฤติกรรมยากขึ้น
- มีการเปรียบเทียบโค้ด runtime สำหรับหาผลรวมของอาร์เรย์
[3]i64 = .{1,2,3}กับโค้ด comptime ที่รวมค่าฟิลด์a,b,cของ structMyStructวางคู่กัน- ใช้
inline for (comptime std.meta.fieldNames(MyStruct))เพื่อวนผ่านชื่อฟิลด์ - ใช้
@field(my_struct, field_name)เพื่อเข้าถึงฟิลด์ด้วยชื่อฟิลด์ที่รู้ตั้งแต่ compile time
- ใช้
- ตัวอย่างการรวมค่าฟิลด์ของ struct ดูเหมือน runtime reflection แต่ในไฟล์ executable จะเหลือโค้ดที่มีประสิทธิภาพเหมือนเขียนฟังก์ชันรวมค่าสำหรับ struct type นั้นด้วยมือ
- กระบวนการแปลงในตัวอย่างไม่ได้จำลองการทำงานจริงของ Zig แบบตรงตัว แต่ใกล้เคียงกับ คำอธิบายเชิงแนวคิด
- ทีม core ของ Zig กำลังทำงานกับ debugger ที่สามารถรันโค้ดที่ผสม comptime และ runtime ทีละขั้นได้เหมือนในตัวอย่าง
จัดการ generic ด้วย comptime
- Zig ไม่มี ฟีเจอร์ generic แยกต่างหาก
- หากต้องการทำ type ให้เป็น generic ให้เขียนฟังก์ชันที่รับ type และคืนค่าเป็น type
- ตัวอย่าง:
pub fn GenericMyStruct(comptime T: type) type - ฟิลด์
a,b,cของstructที่คืนค่ากลับมา ล้วนใช้ typeT
- ตัวอย่าง:
- ฟังก์ชัน generic ก็เขียนด้วยวิธีเดียวกันได้
- ตัวอย่าง:
fn quadratic(comptime T: type, a: T, b: T, c: T, x: T) T - สามารถระบุ type argument ได้ เช่น
quadratic(f32, ...)และquadratic(i64, ...)
- ตัวอย่าง:
- ถ้า type ของอาร์กิวเมนต์ไม่สำคัญต่อส่วนอื่นของ function signature ก็สามารถให้อนุมานด้วย type พิเศษ
anytypeได้ - วิธีนี้อธิบาย comptime ทั้งหมดไม่ได้ แต่เป็นจุดเริ่มต้นสำหรับทำความเข้าใจ งาน generic ที่พบได้บ่อยใน Zig
รันโค้ด Zig เดียวกันใน compile time
- Zig ใช้ภาษาเดียวกันใน runtime, comptime และ build system
- ตัวอย่าง Fizz Buzz เริ่มจากเขียนฟังก์ชัน
fizzBuzz(writer)เป็นฟังก์ชัน output แบบ runtime ทั่วไป- วนตั้งแต่
1ถึง100 - แสดง
fizzbuzzสำหรับจำนวนที่เป็นพหุคูณของ3และ5, แสดงfizzสำหรับพหุคูณของ3, แสดงbuzzสำหรับพหุคูณของ5, และแสดงตัวเลขสำหรับกรณีอื่น
- วนตั้งแต่
- เมื่อนำฟังก์ชัน
fizzBuzzเดียวกันไปรันในบล็อกcomptimeจะสามารถคำนวณ output ทั้งหมดไว้ล่วงหน้าได้- ใช้
std.io.countingWriter(std.io.null_writer)เพื่อคำนวณจำนวนไบต์ที่ต้องใช้ก่อน - สร้างอาร์เรย์
buffer: [cw.bytes_written]u8ด้วยความยาวที่คำนวณได้ - output อีกครั้งไปยัง
std.io.fixedBufferStream(&buffer)เพื่อสร้างค่าfull_fizzbuzz
- ใช้
- หากวัดเฉพาะช่วงหลัก เวอร์ชันที่คำนวณไว้ล่วงหน้าจะรันเร็วขึ้นประมาณ 9 เท่า
- ตัวอย่างมีขนาดเล็ก จึงทำให้เวลารันทั้งหมดขึ้นกับปัจจัยอื่นมากกว่า
- comptime และ runtime มีความแตกต่างกันในค่ากับฟังก์ชันที่เข้าถึงได้
- ค่า
comptime_int,comptime_float,typeเข้าถึงได้เฉพาะใน comptime - ฟังก์ชันบางตัวรับเฉพาะอาร์กิวเมนต์ comptime จึงแทบจะเป็นฟังก์ชันสำหรับ comptime โดยเฉพาะ
- system call หรือฟีเจอร์ที่ใช้ system call เข้าถึงได้เฉพาะใน runtime
- ค่า
- โค้ดที่ไม่ได้ใช้ฟีเจอร์เฉพาะเหล่านี้จะทำงานเหมือนกันทั้งใน comptime และ runtime
ทำความเข้าใจ comptime ผ่าน partial evaluation
- comptime สามารถมองได้ว่าเป็น partial evaluation ที่เกิดขึ้นระหว่างกระบวนการคอมไพล์
- partial evaluation คือวิธีที่เมื่อฟังก์ชันได้รับอาร์กิวเมนต์เพียงบางส่วน จะนำ expression ที่ใช้เฉพาะค่าที่รู้แล้วมาแทนที่และคำนวณก่อน แล้วสร้างฟังก์ชันใหม่ที่รับเฉพาะอาร์กิวเมนต์ที่ยังไม่รู้
- ตัวอย่างการรวมค่าอาร์เรย์แสดงกระบวนการประเมินทีละขั้น
- แยก
forloop ออกเป็นคำสั่งรายรอบของแต่ละ iteration - แทน
array[0],array[1],array[2]ด้วย1,2,3ตามลำดับ - อัปเดต
sumจาก0 → 1 → 3 → 6 - สุดท้ายถูกทำให้ง่ายลงจนถึงรูป
std.debug.print(..., .{6})
- แยก
- ตัวอย่างการรวมค่าฟิลด์ของ struct ก็อ่านได้ในแบบเดียวกัน
inline forรันใน comptime จึงถูกขยายเป็นคำสั่งรายตัวสำหรับฟิลด์"a","b","c"@field(my_struct, "a")ถูกเปลี่ยนเป็นmy_struct.a- ฟังก์ชันสุดท้ายมีรูปที่มี
sum += my_struct.a,sum += my_struct.b,sum += my_struct.cโดยตรง
- วิธีนี้ช่วยให้แสดงเจตนาเป็นโค้ดโดยตรง พร้อมลดความจำเป็นที่จะต้องฝากคอมเมนต์บอกให้แก้ฟังก์ชันรวมค่าด้วยมือเมื่อฟิลด์เปลี่ยน
การประเมินใน compile time และการปล่อยโค้ด runtime
- Zig comptime ยังสามารถมองได้ว่าเป็นการผสมระหว่าง compile-time evaluation กับการปล่อยโค้ด runtime
- โค้ดอินพุตถูกคอมไพเลอร์รัน
- คำสั่งที่รู้ได้ใน compile time จะถูกประเมินทันที
- คำสั่งที่ต้องใช้ค่า runtime จะถูกเพิ่มเข้าไปในโค้ดเอาต์พุต
- มุมมองนี้ใกล้เคียงกับวิธีประมวลผลจริงของคอมไพเลอร์ Zig มากที่สุด
- Zig เริ่มจาก parse ไวยากรณ์
- แปลงโค้ดเป็น bytecode สำหรับ virtual machine
- VM ประเมินส่วนที่ทำได้ และปล่อยส่วนที่ต้องประมวลผลใน runtime เป็น bytecode ใหม่
- bytecode ใหม่นี้จะถูกแปลงเป็น machine code ต่อไป
- เงื่อนไขอย่าง
ifที่มีอินพุต runtime จะปล่อยทั้งสองเส้นทางออกมา - dead code จะไม่ถูกวิเคราะห์ความหมาย
- แม้เขียนฟังก์ชันผิด ก็อาจไม่เกิด compile error จนกว่าจะถูกใช้งานจริง
- คุณสมบัตินี้ทำให้การคอมไพล์มีประสิทธิภาพขึ้น และทำให้ conditional compilation เป็นธรรมชาติโดยไม่ต้องใช้
#ifdef
- ชื่อ type ใน Zig ก็เป็น expression ที่ประเมินค่า
typeใน comptime- แม้ชื่อ type ที่ดูเรียบง่ายอย่าง type ของอาร์กิวเมนต์ฟังก์ชัน ก็เป็นผลลัพธ์ของ comptime evaluation
- คุณสมบัตินี้ทำให้ตัวอย่าง generic ก่อนหน้าเป็นไปได้
- หากจำเป็น สามารถคำนวณ type ด้วย expression ที่ซับซ้อนกว่านี้ได้
- static analysis ของ Zig ซับซ้อนกว่าภาษา static type อื่นจำนวนมาก
- หากต้องการรู้ type ทั้งหมด คอมไพเลอร์ส่วนใหญ่ต้องถูกเรียกใช้งาน
- จนกว่าเครื่องมือจะตามทัน ฟีเจอร์ใน editor อย่าง code completion อาจไม่ได้ทำงานดีเสมอไป
ความสัมพันธ์กับการสร้างโค้ดแบบข้อความ
- Zig comptime มีรูปแบบต่างจากการสร้างโค้ดแบบข้อความ แต่สามารถแก้ปัญหาคล้ายกันได้
- ฟังก์ชันสร้างโค้ด
writeSumFnใช้writer.printเพื่อพิมพ์ซอร์สโค้ดของฟังก์ชันsumFields- โค้ดที่รันใน generator สอดคล้องกับส่วน Zig comptime
- โค้ดที่ generator output ออกมาสอดคล้องกับโค้ด runtime
- เวอร์ชัน comptime ของ Zig แสดง logic เดียวกันได้ตรงกว่า
- ใช้
std.meta.fieldNames(MyStruct)เพื่อรับชื่อฟิลด์ - ใช้
inline forและ@fieldเพื่อประกอบโค้ดเข้าถึงฟิลด์
- ใช้
- การสร้างโค้ดที่ใช้ข้อมูล type เป็นอินพุตทำได้ง่ายกว่าใน Zig
- วิธีสร้างข้อความต้องแก้ต่างหากว่าจะเอาข้อมูลชื่อ type และชื่อฟิลด์มาจากไหน
- Zig สามารถจัดการตัว type เองและข้อมูลฟิลด์โดยตรงใน comptime
- หากใช้อินพุตอย่างสเปกภายนอก ก็สามารถรวมเนื้อหาไฟล์ด้วย
@embedFileแล้ว parse ตามปกติได้ - ในตัวอย่าง struct แบบ generic comptime จัดการการอ้างอิงชื่อ type ได้ตรงกว่าการสร้างโค้ดแบบข้อความ
- การสร้างข้อความต้องต่อชื่ออย่าง
MyStruct_{s}เป็นสตริงและทำให้สอดคล้องกัน - comptime ใช้ฟังก์ชันโดยตรง เช่น
GenericMyStruct(T)
- การสร้างข้อความต้องต่อชื่ออย่าง
- มีข้อยกเว้นเช่นกัน
- สามารถสร้าง type ที่ชื่อฟิลด์ถูกกำหนดใน comptime ได้
- ในกรณีนี้ต้องเรียก built-in function ด้วยสเปกที่บรรจุรายการนิยามฟิลด์
- type แบบนั้นไม่สามารถนิยาม declaration อย่าง method ได้
- ไม่ได้จำกัดความสามารถในการแสดงโค้ดโดยตรง แต่จำกัดรูปแบบ API ที่เปิดเผยให้โค้ดอื่นได้
- งานที่สมเหตุสมผลส่วนใหญ่ที่ทำได้ด้วย text macro แบบ C ก็ทำได้ด้วย comptime เช่นกัน แต่รูปร่างของโค้ดอาจแทบไม่เหมือนกัน
- Zig ไม่มีฟีเจอร์แทนที่คีย์เวิร์ดด้วยชื่ออื่นเหมือน text macro
อ่านเพิ่มเติมและตัวอย่างจาก standard library
- ตัวภาษา Zig เองไม่ได้พึ่งพา comptime เพียงอย่างเดียว และสามารถเรียนรู้เพิ่มเติมได้ที่ เว็บไซต์ทางการ
- language reference อธิบายฟีเจอร์เฉพาะของ comptime
- standard library ของ Zig มีตัวอย่างการใช้ comptime
- ฟังก์ชัน format ที่
std.debug.printใช้ เป็นฟังก์ชัน generic ที่ทรงพลัง- หลายภาษาจะ parse format string ใน runtime และอาจเพิ่ม validator แยกต่างหากเพื่อจับ error ล่วงหน้า
- Zig parse format string ใน comptime เพื่อสร้างโค้ด output ที่มีประสิทธิภาพ และทำ validation ทั้งหมดใน compile time
ArrayListเป็น generic container ที่ค่อนข้างเรียบง่ายแต่มีฟีเจอร์ครบถ้วน- ฟังก์ชัน
mainของ Zig สามารถมี return type ได้หลายแบบ และสิ่งนี้ไม่ใช่เวทมนตร์ของคอมไพเลอร์ แต่จัดการด้วย โค้ด comptime ทั่วไป
- ฟังก์ชัน format ที่
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
อยากให้พูดถึงประเด็นนี้ให้ลึกกว่านี้ แทนที่จะชื่นชม การเขียนโปรแกรมช่วงคอมไพล์ ต่อปัญหาที่เคยถูกค้นพบมาแล้วแบบไม่วิจารณ์
การเขียนโปรแกรมแบบหลายขั้นตอนไม่ใช่เรื่องใหม่ และตระกูล Lisp ก็ทำสิ่งคล้ายกันมาหลายสิบปีแล้ว แต่ระหว่างนั้นก็เผยให้เห็นการแลกเปลี่ยนเชิงออกแบบและปัญหาหลายอย่าง ตัวอย่างเช่น generic แบบนี้ทำลาย parametricity ทำให้อนุมานได้ยากจากการดูแค่ type signature ของฟังก์ชัน ยังไม่ชัดเจนด้วยว่า Zig จัดการกับ recursive generic type อย่างไร และการโต้ตอบกันระหว่างการตรวจชนิดกับการคำนวณช่วงคอมไพล์เกิดขึ้นเมื่อไรและตามลำดับใดก็เป็นทางเลือกสำคัญ บทความบอกว่าโค้ดช่วงคอมไพล์สามารถสร้างได้ไม่ใช่แค่ค่าแต่รวมถึงโค้ดด้วย แต่ไม่ได้พูดถึง hygiene เลย ดูบทความนี้ประกอบได้ที่ https://typesanitizer.com/blog/zig-generics.html
แต่ในสถานการณ์อื่น พอเห็น
comptimeในโค้ด Zig ก็มีความรู้สึกแบบ “อ่า...” อยู่บ้าง มันเหมือน macro ของ Lisp ตรงที่เอาไปใช้หลบปัญหาที่จริง ๆ ไม่ควรมี หรือหายไปได้ถ้าจัดโครงสร้างโค้ดให้ดีกว่านี้ ตัวอย่างในบทความต้นฉบับที่ไล่วนฟิลด์ของ struct เพื่อรวมค่าต่าง ๆ เป็นตัวอย่างคลาสสิกของวิธีที่คนใช้comptimeกันจริง แต่หลายกรณีใช้โครงสร้างข้อมูลที่วนได้จริงอย่างstd.enums.EnumArrayจะดีกว่าในเชิงทฤษฎีความกังวลนี้ชัดเจน แต่จะสำคัญแค่ไหนในภาษาที่เน้นใช้งานจริงก็เป็นอีกเรื่อง C++ template ก็ทำลาย parametricity ผ่าน template specialization เหมือนกัน แต่ในทางปฏิบัติก็มักไม่ใช่ปัญหาใหญ่ และกลับช่วยให้ทำ optimization ได้ เช่น implementation อย่าง
std::vector<bool>ที่เก็บแต่ละองค์ประกอบเป็น 1 บิตจริง ๆ ก็เกิดขึ้นได้เพราะมีความยืดหยุ่นแบบนี้มองว่า
comptimeแบบ Zig คือการแลกเปลี่ยนโดยตรงระหว่าง ความสามารถในการอนุมาน กับพลังในการแสดงออก เมื่อเทียบกับ function signature ที่กำหนดตายตัวกว่านี้ มันมีผลเฉพาะกับโค้ดที่ทำการอนุมานแบบนั้นจาก type และเป็นสิ่งที่เลือกใช้ได้ตามต้องการ เวลาที่ type signature ไม่พอ ผมมักอ่าน source เพิ่มอีกไม่กี่สิบบรรทัดก็พอเข้าใจวิธีใช้ได้ และไม่ค่อยเคยสับสนอยู่นาน ตัวอย่าง recursive generic type ถ้าใช้ชื่อที่กำลังประกาศอยู่จะได้ข้อความ “dependency loop detected” แต่มีวิธีอ้อมได้ และตัวอย่าง generic ในบทความก็อ้างถึงตัวเองเหมือนกัน การตรวจชนิดเกิดขึ้นระหว่างcomptimeเช่น@compileLog("Hi")ตัวแรกจะรัน แต่ถ้าหลังจากนั้นconst a: u32 = "42";ทำให้เกิด type error ก็จะไปไม่ถึง log ตัวที่สอง อาจมีปัญหาละเอียดอ่อนจากการตรวจชนิดระหว่างcomptimeกับรันไทม์ได้บ้าง แต่จะเห็นในโค้ดที่ซับซ้อนมากและแก้ได้ไม่ยาก อีกทั้งทีมหลักก็รับรู้อยู่และคาดว่าจะจัดการก่อน 1.0 ส่วน hygiene หมายถึงอะไรอย่างแม่นยำนั้นคงต้องมีคำอธิบายเพิ่มถ้าฟังก์ชันมีจุดประสงค์เชิงตรรกะที่ชัดเจน และชื่อกับชื่อพารามิเตอร์ถูกตั้งมาดี ก็อนุมานได้จากแค่ declaration เช่นกัน ส่วนพวก label ของพารามิเตอร์ ชื่อ type และสิ่งอื่นที่โปรแกรมเมอร์กำหนดได้ ก็ถือเป็นส่วนหนึ่งของชื่อได้ บทความที่ลิงก์มานั้นผมเห็นด้วยกับข้อสรุปในชื่อเรื่อง แต่ตัวบทความเองใกล้เคียงงานเขียนแบบทีมโต้วาทีที่พยายามเก็บคะแนน มากกว่าจะเป็นข้อถกเถียงที่มีสาระ กรอบที่ดีกว่าคือ ความยืดหยุ่น กับความซับซ้อน ระบบ generic แบบตายตัวหากออกแบบดีจะเรียบง่ายกว่าระบบที่โปรแกรมได้ แต่ก็ยืดหยุ่นน้อยกว่า จุดแข็งของ Zig คือภาษา metaprogramming แทบจะเหมือนกับภาษาปกติ และภาษาปกตินั้นเองก็เรียบง่าย จึงลดต้นทุนความซับซ้อนที่เพิ่มเข้ามาได้ อย่างไรก็ตาม ก็ยังมีความซับซ้อนจากการที่โค้ดช่วงคอมไพล์กับโค้ดรันไทม์ปะปนกันและมีเบาะแสน้อย ทำให้โปรแกรมเมอร์แยกได้ยากว่าอะไรจะรันเมื่อไร เลยคิดว่าถ้ามี “comptime shader” ใน language server หรือปลั๊กอิน editor ที่ลงสีพื้นหลังต่างกันให้โค้ด
comptimeก็น่าจะดีจากประสบการณ์กับ Rust หลายสิ่งที่คนพยายามทำด้วย const generic น่าจะง่ายกว่ามากถ้ามีฟีเจอร์แบบ
comptimeการอนุญาตให้มี arithmetic บน const generic โดยยังรักษา parametricity ไว้เป็นเรื่องที่ implement ยาก และถ้าสิ่งที่ต้องการจริง ๆ มีแค่ประมาณ “trait ของ hash function ที่ให้ขนาดผลลัพธ์เป็น N” การยอมทิ้ง parametricity เพื่อให้ trait ถูกสร้างจาก N ในขั้นสร้างโค้ดก่อนหน้านั้นก็อาจสมเหตุสมผลได้ แต่ Rust macro ทั้งยืดหยุ่นเกินไปและน่ารำคาญเกินกว่าจะใช้ในแนวนั้น ถึงอย่างนั้น ทันทีที่คุณแทนที่ parametric polymorphism ด้วยฟีเจอร์สร้างโค้ดแบบตรงไปตรงมาที่ไร้เดียงสา ชีวิตลำบากก็เริ่มต้นทันทีD มีสิ่งนี้มาตั้งแต่ 17 ปีก่อน ฟีเจอร์ของ D ก็ถูกย้ายไปยังภาษาอื่นอย่างต่อเนื่อง
Zig ใช้คีย์เวิร์ด
comptimeเพื่อระบุบล็อกที่จะรันระหว่างคอมไพล์ แต่ D ไม่ได้ใช้คีย์เวิร์ด โดยตัดสินจากการเป็น นิพจน์ค่าคงที่ ว่าจะรันหรือไม่ ตัวอย่างเช่นint s = sum(3, 4);จะรันตอนรันไทม์ และenum e = sum(3, 4);จะรันตอนคอมไพล์ หากหลีกเลี่ยง global ที่ไม่คงที่, การรับส่งข้อมูลเข้าออก, และการเรียกฟังก์ชันระบบอย่างmalloc()ฟังก์ชันจำนวนมากก็สามารถรันตอนคอมไพล์ได้โดยแทบไม่ต้องแก้ไขอะไรเลย และด้วยการจัดการหน่วยความจำอัตโนมัติของ D จึงสามารถจัดสรรหน่วยความจำได้ด้วยเมื่อก่อนต้องเขียนโปรแกรมแยกต่างหากเพื่อสร้างตารางแบบสแตติก แต่พอมีการรันฟังก์ชันตอนคอมไพล์ก็ไม่จำเป็นอีกแล้ว ค่าเริ่มต้นของอาร์เรย์
tytabมาจากแลมบ์ดาที่คำนวณอาร์เรย์แล้วคืนค่า ตัวอย่างเต็มอยู่ที่ https://github.com/dlang/dmd/blob/master/compiler/src/dmd/ba... อีกการใช้งาน CTFE ที่พบบ่อยคือการสร้าง DSLถ้าเครื่องที่รันคอมไพเลอร์กับเครื่องที่รันโปรแกรมเป็นคนละเครื่อง D คอมไพเลอร์รับประกันความถูกต้องอย่างไร? เช่น อยากรู้ว่าคอมไพเลอร์รู้ได้อย่างไรว่า
int s = sum(100000, 1000000)จะได้ค่าเดียวกันบนเครื่อง x86 ทุกเครื่อง อาจมีความต่างเล็กน้อยตามแต่ละรุ่นของ CPU แล้วจะรับประกันได้อย่างไรว่าผลการคำนวณบนโฮสต์จะออกมาเหมือนกันบนเครื่องเป้าหมาย หรือแค่สมมติว่าถ้าสถาปัตยกรรมเดียวกัน โฮสต์กับเป้าหมายนั้นใกล้เคียงกันพอถ้ามี
int sum(int a, int b) { return a + b; }ก็สามารถเขียนแบบ_Static_assert(sum(3, 4) == 7, "look ma, check at compile time!");ได้ ไม่รู้ว่าทำไมมาตรฐาน C ถึงไม่เพิ่มสิ่งนี้เข้าไป และมันก็ทำงานได้ดีcomptimesquare(2)คือการเรียกตอนรันไทม์ และcomptime square(3)คือการเรียกตอนคอมไพล์ การเรียกแบบcomptimeจะทำให้เกิดข้อผิดพลาดตอนคอมไพล์หากมีอะไรไม่รองรับการคอมไพล์ไทม์ ซึ่งมองว่าเป็นฟีเจอร์สำคัญ เพราะมันเป็นสัญญาณเตือนเมื่อโค้ดที่คาดว่าจะรันตอนคอมไพล์ถูกผลักไปเป็นการประเมินผลตอนรันไทม์โดยไม่ตั้งใจเพราะอินพุตเปลี่ยนZig ดูน่าสนใจ แต่ก็อยากให้มี operator overloading
เหตุผลคัดค้าน operator overloading ส่วนใหญ่ฟังแล้วไม่ค่อยน่าเชื่อ ข้ออ้างว่ามันทำให้ไม่รู้ว่าเบื้องหลังเกิดอะไรขึ้นนั้นไม่พอ เพราะฟังก์ชันชื่อ
addก็อาจถูกทำให้คูณแทนได้เหมือนกัน มักยกiostreamsหรือboost::spiritของ C++ มาเป็นตัวอย่างการใช้เกินเลย แต่ในภาษาอื่นที่มี operator overloading ก็ไม่ค่อยเห็นปัญหาแบบนั้น จึงดูเหมือนเป็นปัญหาเฉพาะของ C++ มากกว่าใน OCaml สามารถนิยาม operator ใหม่ได้ แต่ทำได้เฉพาะภายในบริบทของโมดูลอื่น เช่น ถ้า override
+ในโมดูลVec3ก็จะใช้แบบVec3.(a + b + c + d)หรือlet open Vec3 in a + b + c + dได้ เวลาอ่านซอร์สก็จะไม่สงสัยว่า “+ตัวนี้มาจากไหน?” และมันดีกว่าa.add(b).add(c).add(d)มาก แต่ Zig ก็ดูเหมือนกำลังเริ่มนิ่งตัวแล้ว เลยไม่น่าจะใส่การเปลี่ยนแปลงใหญ่เพื่อแก้เรื่องนี้ได้ง่าย__แนวทางของ Zig จึงดูสดใหม่ และความสามารถในการไล่ตามโค้ดได้สำคัญกว่าการพิมพ์เพิ่มอีกไม่กี่ตัวอักษรไม่กี่วินาที
ทางออกในอุดมคติคือภาษาควรมี operator ที่ผู้ใช้กำหนดเองได้และแสดงชัดเจนว่าเป็นการ overload เช่น ถ้าเป็นรูปแบบอย่าง
let c = a |+| bคนอ่านโค้ดก็จะรู้ได้ว่า operator|+|นี้จริง ๆ คือการเรียกฟังก์ชัน แม้จะยังใช้เกินเลยได้อยู่ แต่ก็ลดความกังวลหลักข้อหนึ่งลงได้ถ้าภาษามีไวยากรณ์คณิตศาสตร์เวกเตอร์ในตัวแบบภาษา shading ก็น่าจะโอเค อย่างน้อย Zig ก็มีชนิด
@Vector()ที่คล้าย Clang Vector Extension อยู่บ้าง แต่น่าเสียดายที่ไม่ใช่ Extended Vector Extension ดู https://ziglang.org/documentation/master/#Vector และ https://clang.llvm.org/docs/LanguageExtensions.html#vectors-...ไม่แน่ใจว่ามีข้อเสนอแบบนั้นอยู่แล้วหรือยัง
fieldNamesดูคล้ายfieldPairsของ Nim มาก และเป็นโครงสร้างที่สะดวกอย่างยิ่งมันทำให้การทำ serialization ที่มีประสิทธิภาพกลายเป็นเรื่องง่ายมาก และไม่นานมานี้ก็ใช้
fieldPairsทำ การตรวจสอบ thread safety ของ type ตอนคอมไพล์ ได้ในราว 20 บรรทัด คิดว่าฟีเจอร์แบบนี้ควรเป็นความสามารถมาตรฐานของภาษาโปรแกรม หนึ่งในสิ่งที่เสียดายที่สุดของ Rust ก็อยู่ตรงนี้ ตอนที่ลองล่าสุดมันยังถูกจำกัดอยู่ที่แมโครแบบไม่มี type เท่านั้น ซึ่งจำกัดมาก ทำได้แค่หวังว่า struct ใน crate จะมีserdeติดมาให้ และก็ไม่สามารถสร้าง struct ที่มีฟิลด์เหมือนกันขึ้นมาเองแบบโปรแกรมได้น่าเสียดายพอสมควร ถ้ามี compile-time reflection พวกอย่าง
serdeก็น่าจะทำได้ง่ายกว่านี้มากข้อแลกเปลี่ยนเพียงอย่างเดียวคือไลบรารีแบบนั้นจะต้องถูกรวมเข้ามาในรูปซอร์สโค้ด
หลังจากเคยลองเขียนไลบรารีพาร์เซอร์ภาษา C ที่ค่อนข้างสมบูรณ์มาระดับหนึ่ง ก็ไม่ค่อยแน่ใจนักกับข้ออ้างที่ว่าภาษาเองจำเป็นต้องมี เมตาโปรแกรมมิง
ถ้าอยากสร้าง struct, serialization, property, instrumentation ฯลฯ ก็แค่เขียนโปรแกรม C ทั่วไปที่ประมวลผลไฟล์ซอร์สแล้วสร้างไฟล์ซอร์สออกมา จากนั้นก็รันมันก่อนในสคริปต์ build ก็พอ อยากรู้เหมือนกันว่าคนที่ทำเมตาโปรแกรมแบบนี้ดีบักและทดสอบกันอย่างไร ของผมเป็นโปรแกรม C ธรรมดา ก็เลยใช้ดีบักเกอร์และเครื่องมือเดียวกับโค้ดอื่นได้ตรง ๆ
ตัวคอมไพเลอร์เองก็เขียนด้วยภาษาเดียวกัน จึงสามารถใส่ปลั๊กอินที่เข้าถึง AST แบบอ่านอย่างเดียวและปล่อยซอร์ส C# ออกมาได้ เรื่องดีบักอาจต้องลงแรงเพิ่มเล็กน้อยเพื่อทำเฟรมเวิร์กทดสอบที่ดี แต่หลังจากนั้นก็สามารถรันคอมไพเลอร์ที่ใส่ปลั๊กอินไว้ภายในเฟรมเวิร์ก unit test มาตรฐาน และดูด้วย interactive debugger ได้
“การรันโค้ดตอน compile time แบบตามอำเภอใจใน C:
cl /nologo /Zi metaprogram.c && metaprogram.exe,cl /nologo /Zi program.c. โค้ด compile-time รันด้วยความเร็วเนทีฟ ดีบักได้ และเป็นแบบ procedural เต็มรูปแบบกับทำอะไรก็ได้ คอมไพเลอร์ไม่จำเป็นต้องเป็นคนรันโค้ดให้” https://x.com/ryanjfleury/status/1875824288487571873ถ้าจะทำแบบนั้นใน C ก็ต้องเขียนพาร์เซอร์ C แบบสมบูรณ์สำหรับโปรแกรม C ที่ใช้ประมวลผลไฟล์ซอร์ส
คือเขียนโค้ด Python ที่สร้างโค้ด Python แล้วให้มันคอมไพล์อีกที มันเปราะบางและยุ่งเหยิงน่ากลัวมาก และนี่ก็เป็นหนึ่งในเหตุผลใหญ่ที่ทำให้ผมเลิกใช้ Python มันคล้ายกับการถามว่าทำไมไม่ส่งอาร์กิวเมนต์ของฟังก์ชันทั้งหมดเป็นสตริงไปเลย คนที่เขียนโค้ดแบบอิงสตริงก็มีอยู่ แต่ควรเป็นกรณีที่พบไม่บ่อย และภาษาควรมีทางให้หลีกเลี่ยงสิ่งนี้ได้
อีกแพตเทิร์นที่น่าสนใจอย่างหนึ่งคือสามารถ สร้าง struct ตอน compile time ได้
ผมเคยลองทำการทดลองที่สร้างไฟล์ JSON จาก PyTorch แล้วอ่านด้วย
@embedFileของ Zig จากนั้นสร้าง struct ที่มีเมธอดrunแบบเฉพาะเพื่ออิมพลีเมนต์ neural network ในทางทฤษฎีแล้วมันทำให้คอมไพเลอร์สามารถ optimize neural network ได้โดยตรง ผมยังพิสูจน์ข้อดีที่ชัดเจนไม่ได้ แต่ก็มีจุดหนึ่งคือทั้งเครือข่ายอยู่บน stack จึงไม่มีการจัดสรรหน่วยความจำแบบไดนามิก ไม่แน่ใจว่านี่เป็นข้อดีจริงไหมcomptimeไม่แน่ใจว่า
comptimeของ Zig จะเร็วเป็นพิเศษจริงหรือไม่ และก็ไม่อยากรันพาร์เซอร์ JSON ที่ใช้สร้าง struct ทุกครั้งด้วยมันก็น่าสนใจอยู่ แต่ในกรณีแบบนี้ผมกลับเห็นด้วยกับคอมเมนต์แนว “D ทำมาก่อนแล้ว”
ถ้าคุณทึ่งกับ
comptimeของ Zig ก็ควรลองดู Nim ด้วย เพราะมันมีทั้ง การประเมินโค้ดตอน compile time และระบบแมโคร AST แบบเต็มตัวมันมีปัญหาเรื่อง ecosystem แบบเดียวกับภาษากลุ่ม niche อื่น ๆ แล้วยังมีปัจจัยอย่างผู้ดูแลที่คนชอบไม่เหมือนกัน ผู้มีส่วนร่วมหลักที่ดูเหมือนจะไม่อยู่ยาว และการที่แหล่งทุนหลักมาจากบริษัทคริปโต ถ้าเป็นเมื่อ 10 ปีก่อนสิ่งเหล่านี้อาจไม่รบกวนใจผม แต่ตอนนี้ไม่ใช่แล้ว
ดูเหมือน Nim ก็พยายามถอยออกจาก exception เช่นกัน แต่ exception ทำให้ฟังก์ชันปนเปื้อน และทำให้คุณต้องคอยกังวลแม้จะไม่ได้ใช้ฟังก์ชันที่โยน exception ก็ตาม[1] ชีวิตมันสั้นเกินกว่าจะมานั่งรับมือกับ control flow ที่มองไม่เห็น [1]: https://github.com/status-im/nim-stew
โดยรวมแล้ว Zig เป็นภาษาที่ดีมากพอสมควรและทำสิ่งที่มันต้องทำ
การไม่หลุดออกจากจุดประสงค์เป็นเรื่องสำคัญมาก และเพราะแบบนั้นผมจึงไม่ค่อยชอบเวลาบางภาษาถูกเอาไปใช้ทุกที่เพียงเพราะมันเป็นภาษาที่คนนิยมใช้
อยากให้มีอะไรสักอย่างที่รวม ความสามารถด้านเมตาโปรแกรมมิง ของ Zig เข้ากับ ecosystem ขนาดใหญ่ ชุมชน และความปลอดภัยของ Rust
ถ้ามองแค่การออกแบบภาษา ผมชอบ Zig มากกว่า Rust มาก แต่ในฐานะโปรแกรมเมอร์สมัครเล่นที่ยังไม่เก่ง อย่างน้อยตอนนี้ผมยังไม่มั่นใจว่าจะเขียนอะไรที่มีประโยชน์หรือเชื่อถือได้จริงด้วย Zig ได้
เพราะคอมไพเลอร์ไม่ได้ช่วยจับปัญหาแบบนั้นให้ตั้งแต่ compile time ผมรู้ว่า Zig ไม่ได้สัญญาเรื่องนั้นอยู่แล้ว แต่สำหรับผมมันเป็นข้อเสียที่ตัดสินชะตาเลย จึงคิดว่า Zig คงไม่ใช่ภาษาที่เหมาะกับผม ในทางกลับกัน แนวคิดของ
comptimeกลับถูกใจผมมากกว่าแมโครของ Rust