1 คะแนน โดย GN⁺ 2025-06-08 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • การปรับแต่งระดับต่ำคือการทำให้คอมไพเลอร์เข้าใจ เจตนาและข้อจำกัด ของโค้ดได้ดีขึ้น และ Zig เหมาะกับเป้าหมายนี้ เพราะระบุข้อมูลเรื่อง type, alignment, alias และ compile-time ได้ง่าย
  • แม้แต่คอมไพเลอร์ที่ปรับแต่งโค้ดอย่าง LLVM ก็ไม่ได้สร้างโค้ดที่ดีที่สุดเสมอไป ดังนั้นในจุดคอขวดก็ยังจำเป็นต้องตรวจสอบโค้ดที่สร้างออกมาและปรับโค้ดอยู่ดี
  • Zig สามารถส่ง noalias, align, ขนาดอาร์เรย์คงที่ และชนิดของอิลิเมนต์ ณ เวลา compile ได้ ทำให้สร้าง โค้ดแบบ vectorized ที่เล็กกว่าตัวอย่าง JavaScript
  • comptime ทำให้สามารถรันโค้ด Zig ปกติ ณ เวลา compile เพื่อทำ metaprogramming เช่น สร้างค่าคงที่, implement generic, ทำ type reflection และปรับแต่งการเปรียบเทียบสตริง
  • จุดแข็งของ Zig อยู่ที่การรันโค้ด ณ เวลา compile ที่ผสานอยู่ในภาษา มากกว่า macro ที่แก้ AST โดยตรง และยังสามารถ dispatch ค่าบางส่วนจาก runtime ไปยังฟังก์ชันที่ specialize ณ compile-time ได้ด้วย

ทำไมจึงต้องเชื่อคอมไพเลอร์แต่ก็ต้องตรวจสอบ

  • การปรับแต่งไม่ได้เป็นแค่เทคนิคเพื่อทำให้โปรแกรมเร็วขึ้น แต่ยังเชื่อมโยงกับการลดต้นทุน เพิ่มความสามารถในการขยายระบบ และรักษาความเรียบง่ายของระบบ
  • คอมไพเลอร์สมัยใหม่ให้ผลลัพธ์ที่น่าประทับใจด้วย backend อย่าง LLVM แต่ในบางสถานการณ์ก็ยังสร้าง โค้ดที่ปรับแต่งได้ต่ำกว่าที่ควร
  • เหตุผลที่ภาษาระดับต่ำทำงานเร็ว ไม่ได้มีแค่เพราะมี overhead จาก garbage collection หรือ interpreter น้อยกว่า แต่ยังเพราะสามารถแสดง ข้อมูลเจตนา ที่คอมไพเลอร์เข้าใจได้มากกว่า
  • คอมไพเลอร์ไม่สามารถเปลี่ยน algorithm หรือ programming paradigm เองได้ และโดยทั่วไปจะทำ optimization ภายในขอบเขตจำกัด เช่น loop

ตัวอย่างค่าสูงสุดของอาร์เรย์ใน JavaScript และ Zig

  • ตัวอย่าง JavaScript เก็บค่าสูงสุดรายอิลิเมนต์ของสองอาร์เรย์ไว้ใน x ในรูปแบบ x[i] = y[i] > x[i] ? y[i] : x[i]
  • สำหรับมนุษย์ โค้ดนี้ชัดเจน แต่ bytecode ที่ V8 สร้างออกมานั้นพองตัวมาก
  • ตัวอย่าง Zig ระบุข้อมูลที่จำเป็นต่อการ optimize ไว้ในอาร์กิวเมนต์ของฟังก์ชันอย่างเจาะจงกว่า
    • noalias x: x ไม่เป็น alias กับ pointer อื่น
    • *align(64): alignment 64 ไบต์
    • [65536]f64: ขนาดอาร์เรย์และชนิดของอิลิเมนต์
    • const: อาร์กิวเมนต์แบบอ่านอย่างเดียว
  • ด้วยข้อมูลเหล่านี้ คอมไพเลอร์จึงสร้างโค้ดที่ดีกว่าได้ และในตัวอย่างนี้มีการสร้าง assembly แบบ vectorized
  • โค้ด Rust ที่เทียบเท่ากันก็สร้าง assembly แทบเหมือนกัน

จุดที่ Zig ได้เปรียบต่อการ optimize และข้อจำกัด

  • Zig ยอมให้เขียนแบบละเอียด เพื่อส่งข้อมูลของโค้ดไปยัง LLVM ได้มาก
  • องค์ประกอบหลักที่ Zig มีให้เกี่ยวกับการ optimize มีดังนี้
  • memory model ของ Rust ทำให้คอมไพเลอร์สามารถสันนิษฐานได้เสมอว่าอาร์กิวเมนต์ของฟังก์ชันไม่สร้าง alias แต่ใน Zig ต้องระบุเอง
  • หากคอมไพเลอร์ไม่รู้ว่าอาร์กิวเมนต์ของฟังก์ชัน Zig ไม่มี alias ฟังก์ชัน Zig ที่ไม่มี annotation อาจช้ากว่าฟังก์ชัน Rust
  • แม้ยึดเพียง LLVM IR ที่ annotate ไว้ดี Zig ก็ให้ผลลัพธ์ที่ดี แต่จุดแข็งที่ใหญ่กว่าคือ การรันโค้ด ณ เวลา compile

บทบาทของ comptime

  • comptime ของ Zig เป็นฟีเจอร์สำหรับ การสร้างโค้ด ณ เวลา compile
  • งานที่ทำได้ ณ เวลา compile ได้แก่
    • สร้างค่าคงที่และรวมเข้าไปใน binary
    • ไม่ต้องเขียนโครงสร้าง hashmap เดิมซ้ำสำหรับหลาย data type
    • ชี้นำการ optimize ให้ตัดโค้ดที่ไม่จำเป็นออก โดยอิงจากข้อมูลที่รู้ ณ เวลา compile
    • ตรวจสอบ, reflect และสร้าง type เพื่อ implement generic
  • โค้ด comptime คือโค้ด Zig ปกติที่รัน ณ เวลา compile และไม่สามารถมี side effect อย่าง network IO ได้
  • เครื่อง emulation ณ เวลา compile สอดคล้องกับ target ที่จะ compile ไปหา
  • โค้ด Zig แทบทั้งหมดสามารถรัน ณ เวลา compile ด้วย comptime ได้ และ type ทั้งหมดสามารถถูกตรวจสอบ, reflect และสร้างขึ้น ณ เวลา compile ได้

ต่างจาก macro อย่างไร

  • เป้าหมายของ comptime คล้ายกับ macro แต่วิธีทำงานต่างกัน
  • macro บางแบบเปลี่ยนข้อความดิบ และบางแบบแก้ AST ของโปรแกรมโดยตรง
  • comptime ของ Zig ไม่ได้เปลี่ยน AST โดยตรง และไม่มีฟีเจอร์แบบ macro ที่นำ token มาต่อกัน
  • Zig ตั้งเป้าเป็นภาษาที่อ่านง่าย จึงไม่เข้ากับสไตล์ macro ที่สร้างหรือแก้ตัวแปรใน scope ที่ไม่เกี่ยวข้อง
  • งานที่ macro ทำได้ แต่ Zig comptime ทำโดยตรงไม่ได้ ได้แก่
    • นิยาม macro อื่น
    • เปลี่ยน AST
    • implement mini language หรือ DSL โดยตรง
  • อย่างไรก็ตาม Zig ก็สามารถสร้าง DSL ได้เช่นกัน และฟังก์ชัน print ของ Zig จะ parse format string ด้วย comptime เพื่อประกอบ graph ของฟังก์ชันสำหรับ serialize ข้อมูล
  • ตัวอย่างได้แก่ TigerBeetle account testing DSL, comath, zilliam

การ optimize การเปรียบเทียบสตริงด้วย comptime

  • การเปรียบเทียบสตริงทั่วไปจะคืนค่า false หากความยาวต่างกัน และถ้าความยาวเท่ากันก็จะเปรียบเทียบแต่ละไบต์ตามลำดับ
  • วิธีนี้ต้องอ่านไบต์จากสตริงทั้งสองฝั่งมาเปรียบเทียบ
  • บ่อยครั้งที่สตริงฝั่งหนึ่งรู้ค่าอยู่แล้ว ณ เวลา compile ดังนั้นใน Zig จึงสามารถบังคับให้อาร์กิวเมนต์หนึ่งเป็น comptime ได้
    • fn staticEql(comptime a: []const u8, b: []const u8) bool
  • เมื่อเปรียบเทียบกับสตริง static อย่าง "Hello!\n" คอมไพเลอร์จะสร้างโค้ดที่ประกอบด้วยการเปรียบเทียบความยาวและการเปรียบเทียบค่าคงที่ของแต่ละไบต์
  • จุดประสงค์ของส่วนนี้ไม่ได้มีแค่แสดง optimization ที่คอมไพเลอร์ทำได้อัตโนมัติ แต่ยังแสดงว่า comptime สามารถ บังคับ การแปลงเพื่อเปิดโอกาสที่คอมไพเลอร์มองไม่เห็นได้

การเปรียบเทียบเป็นหน่วยที่ใหญ่ขึ้นและการใช้ SIMD

  • การเปรียบเทียบสตริงด้วย comptime แบบง่ายยังคงเปรียบเทียบทีละไบต์
  • เวอร์ชันที่ปรับปรุงใช้ std.simd.suggestVectorLength(u8) หรือ @sizeOf(usize) เพื่อกำหนดขนาด block ที่จะเปรียบเทียบ
  • หลังจากตรวจความยาวของสตริงก่อนแล้ว จะคำนวณจำนวน block ใหญ่ที่เปรียบเทียบได้และจำนวนไบต์ที่เหลือ
  • แต่ละ block จะถูก @bitCast ไปเป็นชนิดจำนวนเต็มที่สร้างด้วย std.meta.Int(.unsigned, block_len * 8) แล้วนำมาเปรียบเทียบ
  • ไบต์ที่เหลือก็เปรียบเทียบด้วยชนิดจำนวนเต็มแยกต่างหาก
  • assembly ที่สร้างสำหรับตัวอย่าง "Hello, World!\n" ใช้ register ที่ใหญ่ขึ้นและลดจำนวน conditional branch
  • ในการเปรียบเทียบสตริงที่ใหญ่ขึ้น จะมีการสร้าง assembly ที่ใช้ SIMD register ขนาดใหญ่ขึ้น

ใช้ค่าจาก runtime ร่วมกับ compile-time specialization

  • comptime ของ Zig ไม่ได้จำกัดอยู่เฉพาะข้อมูลที่รู้ ณ เวลา compile เท่านั้น
  • ในกรณีง่าย ๆ สามารถสร้าง procedure หลายแบบไว้ ณ เวลา compile แล้ว dynamic dispatch ไปยัง procedure ที่เหมาะสมตามค่าจาก runtime ได้
  • โค้ดตัวอย่างส่งค่าช่วง inline 0...100 ใน switch (runtime_val) ไปที่ staticFn(comptime_val) และจัดการค่าที่เหลือด้วย runtimeFn(runtime_val)
  • หากไม่ต้องการให้ขนาด binary เพิ่มขึ้น ก็สามารถ fallback ไปยัง implementation แบบ runtime ล้วนได้

สรุป

  • comptime ของ Zig ทำหน้าที่แทน template, macro, generic และการสร้างโค้ดด้วยมือ
  • ภาษาอื่นก็ทำสิ่งที่คล้ายกันได้ แต่ใน Zig นั้น comptime ผสานเข้ากับภาษาได้เป็นธรรมชาติกว่า
  • Zig ทำให้เขียนโค้ดประสิทธิภาพดีได้ง่ายขึ้นในสถานการณ์ที่มีประโยชน์จริง และต่างจาก Turing tar-pit ที่ทุกอย่างเป็นไปได้ แต่งานที่น่าสนใจกลับทำได้ยาก
  • สำหรับสงครามภาษา ยังคงมีทั้งมุมมองกว้าง ๆ ว่าแค่ Turing completeness ก็เพียงพอ และในขณะเดียวกันผู้คนก็สามารถมีภาษาที่ตนชอบได้
  • คำกล่าวที่มองตัวภาษาเป็นเป้าหมายของ benchmark เช่น “C เร็วกว่า Python” อาจผิดได้ เพราะสิ่งที่ benchmark จริง ๆ ไม่ใช่ภาษา แต่เป็นโค้ดและ implementation เฉพาะเจาะจง

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

 
GN⁺ 2025-06-08
ความคิดเห็นจาก Hacker News
  • สิ่งที่ดึงดูดที่สุดใน Zig คือการมุ่งเน้นไปที่ ความง่ายของระบบบิลด์, การคอมไพล์ข้ามแพลตฟอร์ม และรอบการพัฒนาที่รวดเร็ว
    ในฐานะนักพัฒนาเกม ผมมีข้อกำหนดด้านประสิทธิภาพ แต่คิดว่าภาษาส่วนใหญ่ก็ให้ประสิทธิภาพที่จำเป็นได้เพียงพอ จึงไม่ใช่ปัจจัยอันดับหนึ่งในการเลือกภาษา
    จะเขียนโค้ดที่ทรงพลังด้วยภาษาอะไรก็ได้ แต่ประเด็นสำคัญคือการเลือก เฟรมเวิร์กที่รองรับอนาคตได้สูง ซึ่งสามารถดูแลรักษาโค้ดแบบโมดูลาร์ไปได้หลายสิบปี
    C/C++ เคยเป็นคำตอบพื้นฐานเพราะรองรับได้ทุกที่ และรู้สึกว่า Zig ก็น่าจะไปถึงระดับนั้นได้เช่นกัน

    • แม้จะชอบ Zig แต่ผมมองว่าความสามารถในการดูแลระยะยาวและความเป็นโมดูลาร์กลับเป็นหนึ่งในจุดที่อ่อนที่สุด
      Zig ไม่เป็นมิตรกับ encapsulation และไม่สามารถทำให้สมาชิกของ struct เป็น private ได้: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
      ใจความสำคัญที่ยกมาคือ “private fields และ getter/setter เป็นแอนติแพตเทิร์นที่ Java ทำให้แพร่หลาย และ field คือข้อมูลที่มีอยู่จริง ดังนั้นควรตั้งชื่ออย่างระมัดระวังและจัดทำเอกสารให้เป็นส่วนหนึ่งของ public API”
      หากซ่อน representation ภายในไม่ได้ ก็ยากที่จะสร้าง สัญญา API ซึ่งเป็นรากฐานของความเป็นโมดูลาร์ในซอฟต์แวร์ได้อย่างเหมาะสม และควรสามารถเปลี่ยน representation ภายในได้โดยไม่ทำให้โค้ดของผู้ใช้พัง
      จุดยืนของ Zig ดูเหมือนจะเป็นว่าไม่ควรมีสิ่งที่เรียกว่า representation ภายในแยกต่างหาก และควรเปิดเผย·ทำเอกสาร·รับประกัน representation นั้นเอง หวังว่าสักวันจะกลับคำตัดสินนี้และรองรับ private fields
    • ลองเอา Zig ไปรันเล่น ๆ บนอุปกรณ์ Kindle รุ่นเก่าที่ใช้ Linux 4.1.15 แบบถูกตัดทอนออกไปหลายอย่าง เป็นประสบการณ์ที่ค่อนข้างน่าสนใจ และรู้สึก pleasantly surprised กับระดับความพร้อมของ Zig
      หลายอย่างทำงานได้ทันที และยังดีบักบั๊กแปลก ๆ ด้วย GDB รุ่นเก่าได้ด้วย
      ผมเองก็ถูก Zig โน้มน้าวแล้ว และเขียนเรื่องนี้ไว้ที่นี่: https://news.ycombinator.com/item?id=44211041
    • เคยลองใช้ Rust นิดหน่อยและชอบ แต่พอได้ยินว่ามีเสียงวิจารณ์ไม่ดีเลยหยุดไปพักหนึ่ง จากนั้นกลับมาลองอีกครั้งก็ยังชอบอยู่ดี
      ไม่ค่อยเข้าใจว่าทำไมถึงเกลียดกันขนาดนั้น
      generic ที่ดูรกตาก็มีใน C# หรือ TypeScript เหมือนกัน และ borrow checker ก็เป็นแนวคิดที่ยอมรับได้หากเคยทำงานระดับ low-level มาแล้ว
    • Zig ดูเหมือน Rust ที่เรียบง่ายกว่า และ Go ที่ดีกว่า
      เครื่องมือที่สร้างบน Zig ซึ่งผมประทับใจจริง ๆ คือ bun และหลังจากใช้ bun ชีวิตก็เรียบง่ายขึ้นมาก
      พูดแบบเดียวกันกับ uv ที่สร้างด้วย Rust ได้เช่นกัน
    • สงสัยว่า Zig จะทำงานบนคอนโซลได้อย่างไร
      โดยทั่วไปคอนโซลมักไม่ชอบสิ่งที่ไม่ใช่ C/C++ แต่ Zig แปลงเป็น C ได้ ดังนั้นอาจไม่ได้ถูกตัดออกไปโดยสิ้นเชิง
  • ต่อข้ออ้างที่ว่า “แม้แต่คอมไพเลอร์สมัยใหม่ก็ยังละเมิดสเปกภาษา (Clang ถือว่าทุกลูปที่ไม่มี side effect จะสิ้นสุดลง)” ผมไม่สงสัยว่าคอมไพเลอร์ละเมิดสเปกเป็นครั้งคราว แต่ในกรณีนั้น Clang อย่างน้อยก็น่าจะถูกต้องตามมาตรฐานตั้งแต่ C11 เป็นต้นมา
    ใน C11 ระบุว่า ลูปที่ control expression ไม่ใช่ constant expression และไม่มีการทำอินพุต/เอาต์พุต, การเข้าถึง volatile, การซิงโครไนซ์ หรือ atomic operation นั้น implementation สามารถสันนิษฐานได้ว่าจะสิ้นสุด

    • C++ พูดแบบนั้นกับลูปทั้งหมดจนกว่าจะมี C++26 ออกมา แต่ตามที่ชี้ไว้ C เองไม่ใช่แบบนั้น และใช้เฉพาะกรณีที่ “control expression ไม่ใช่ constant expression” เท่านั้น
      ดังนั้นลูปไม่สิ้นสุดแบบง่าย ๆ ใน C อย่าง for (;;); จึงควรถูกคอมไพล์เป็นลูปไม่สิ้นสุดจริง ๆ และ loop {} ของ Rust ที่ไม่คลุมเครือน้อยกว่าก็ควรเป็นเช่นนั้นด้วย
      แต่เพราะ LLVM ถูกสร้างโดยคนที่บางครั้งลืมไปว่าพวกเขาไม่ได้ทำคอมไพเลอร์ C++ เสมอไป จึงเคยมีจุดที่เมื่อ Rust บอกว่า “ขอลูปไม่สิ้นสุด” LLVM กลับตอบว่า “ตาม C++ แล้วไม่มีของแบบนั้น ดังนั้นจะ optimize ทิ้ง” ซึ่งเป็นการนำไปใช้กับภาษาอื่นอย่างผิดพลาด
  • ไม่จำเป็นต้องมี comptime เสมอไปเพื่อ inline และ unroll การเปรียบเทียบสตริง
    ใน C ก็ทำได้: https://godbolt.org/z/6edWbqnfT
    แก้คำพิมพ์ผิดแล้ว

    • ถูกต้อง และตัวอย่างแรกนั้นง่ายเกินไป
      ตัวอย่างที่ดีกว่าคือ https://github.com/RetroDev256/comptime_suffix_automaton
      อย่างไรก็ตาม โค้ด godbolt ที่ลิงก์มาก็แสดงหนึ่งในสองตัวอย่างที่ไม่ค่อยดีจริง ๆ อยู่ด้วย
  • ผมคิดว่าการเทียบตัวอย่าง JavaScript กับตัวอย่าง Zig/Rust ไม่ใช่การเปรียบเทียบที่ดี
    ให้คอมไพเลอร์ Zig กับ Rust เลือก CPU เป้าหมายที่ทันสมัยมาก แต่ดูเหมือน V8 ไม่ได้อยู่ภายใต้เงื่อนไขเดียวกัน
    optimizing JIT ก็ทำ vectorization ได้เหมือนกันถ้าเงื่อนไขเหมาะสม
    อ้างอิงไว้ว่า ภาษาสมัยใหม่ส่วนใหญ่ทำ optimization แบบเดียวกันกับสตริง และตัวอย่าง C++ อยู่ที่นี่: https://godbolt.org/z/TM5qdbTqh

    • โดยรวมเหมือนเอาแอปเปิลไปเทียบกับสลัดผลไม้ แต่ก็เหมาะกับการแสดงความต่างของกรณีใช้งานระหว่าง JS กับ Zig
      ตัวอย่าง Zig ใช้อาร์เรย์ชนิดที่รู้แน่และมีขนาดคงที่ ส่วนโค้ด JS เป็น “generic” ตอน runtime ดังนั้น x และ y อาจเป็น object อะไรก็ได้
      ใน JS การต้องจ่ายต้นทุนตรงนั้นเป็นเรื่องถูกต้อง แต่ที่ย้อนแย้งคือในตัวอย่างเฉพาะนี้ เราสามารถส่งข้อมูลชนิดให้ JIT ได้ดีกว่า
      ถ้าทำให้ฟังก์ชันนี้ถูกเรียกด้วย Float64Array ขนาดเดิมเสมอ JIT จะรู้เรื่องนี้และสร้างลูปที่เร็วขึ้นได้ ถึงจะไม่ใช่ vectorization ก็ยังดีขึ้นมาก
      อย่างไรก็ตาม typed array มีต้นทุนการ initialize สูง จึงไม่ค่อยใช้จริง เว้นแต่จะ allocate typed array ขนาดใหญ่ครั้งเดียวแล้วนำกลับมาใช้ซ้ำมาก ๆ
      อีกอย่าง ในบทความบอกว่า bytecode ของ JS พองขึ้นพอสมควร ซึ่งน่าจะส่วนใหญ่เป็นเพราะ JIT รับประกันไม่ได้ว่า 65536 เท่ากับความยาวของอาร์เรย์ทั้งสอง จึงต้องใส่ guard
      แต่ในความเป็นจริงคงไม่มีใครเขียน for loop แบบนั้น และจะเขียนเป็น i < x.length แทน ซึ่งในกรณีนี้ JIT จะลบการตรวจสอบอาร์เรย์ออกได้อย่างน้อยหนึ่งรายการ
    • ในตัวอย่าง godbolt ของ Rust กับ Zig สามารถเปลี่ยน target เป็น CPU ที่เก่ากว่าได้
      ขอโทษที่ไม่ได้คิดถึงข้อจำกัดของ target ฝั่ง JS
      ตัวอย่าง C++ ที่ลิงก์ไว้เป็นตัวอย่างที่ดีว่า Clang ทำอะไรกับ C++ ได้บ้าง แต่ถึงจะคำนึงว่า Zig คอมไพล์โดยเจาะจง CPU แล้ว assembly ที่สร้างออกมาก็ดูค่อนข้างน่าผิดหวัง
      ถ้าได้เห็นพอร์ต C++ ของ https://github.com/RetroDev256/comptime_suffix_automaton ก็น่าจะน่าสนใจมาก
      นี่เป็นการใช้ comptime ที่คอมไพเลอร์ C++ ไม่สามารถอนุมานได้อย่างสะอาด
  • ผมไม่แน่ใจว่าประโยค “ภาษา high-level ขาดเจตนาที่มีอยู่มากมายในภาษา low-level” ถูกต้องจริงหรือไม่
    การแสดงเจตนา ดูไม่ใช่องค์ประกอบของสเปกตรัม high-level/low-level และในทางกลับกัน ยิ่งมีวิธีแสดงเจตนาได้ละเอียดมากเท่าไร ก็ควรยิ่งใกล้เคียง high-level มากขึ้นเท่านั้น

    • เห็นด้วย และยิ่งไปกว่านั้น ผมคิดว่าความต่างพื้นฐานระหว่างภาษา high-level กับ low-level คือ ในภาษา high-level เราแสดงเจตนา ส่วนในภาษา low-level เราจำเป็นต้องแสดงกลไก underlying
    • เจตนาที่พูดถึงตรงนี้น่าจะใกล้กับ “เลื่อนบิตไบต์นี้ไปทางซ้ายสามตำแหน่ง” มากกว่า “คำนวณอัตราภาษีของการซื้อนี้”
      เป็นเจตนาเกี่ยวกับ อยากให้เครื่องทำอะไร มากกว่าสิ่งที่ต้องการบรรลุ
      โค้ดอย่าง purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?; เต็มไปด้วยเจตนา แต่เราไม่รู้ว่าในท้ายที่สุด machine code แบบไหนจะถูกสร้างออกมา
  • ไวยากรณ์ for loop นั่นแย่มาก
    คือมีลิสต์สองอันวางคู่กัน และตำแหน่งของรายการในลิสต์หนึ่งสอดคล้องกับตำแหน่งของรายการในอีกลิสต์หนึ่งงั้นหรือ?
    แค่ดูก็ปวดตาแล้ว
    ดูเหมือนภาษาใหม่ ๆ จะเดินผิดทางเมื่อเริ่มเพิ่ม “เวทมนตร์” สารพัดลงใน parser และโปรยสัญลักษณ์เล็ก ๆ ไปทั่วโค้ด
    ไม่ใช่รูปแบบที่อยากนั่งจ้องเป็นชั่วโมง ๆ

    • อาร์เรย์แบบนั้นเป็นแพตเทิร์นที่พบบ่อยมากใน โค้ด low-level ไม่ว่าจะเป็นภาษาไหน และการวนขนานกันก็เช่นกัน
      ดังนั้นจึงเป็นเรื่องธรรมชาติที่ Zig จะมีไวยากรณ์ที่ทำงานนั้นได้สะดวก ขณะเดียวกันก็ทำให้เห็นชัดว่าเกิดอะไรขึ้น
      ส่วนตัวคิดว่าทำได้ค่อนข้างดี เลยสงสัยว่าทำไมถึงปวดตา
  • ชอบ โมเดล allocator ของ Zig มาก
    อยากให้ Go ใช้อะไรอย่าง allocator ต่อ request แทน garbage collection ได้บ้าง

    • ใน Go ก็ทำ custom allocator และ arena ได้ และมีอยู่จริง แต่ใช้งานยากมากและใช้ให้ถูกก็ยาก
      เพราะตัวภาษาเองไม่มีวิธีแสดงและบังคับใช้กฎ ownership สุดท้ายจึงกลายเป็นการเขียน C ที่แค่ไวยากรณ์ต่างกันเล็กน้อยแล้วหวังว่ามันจะออกมาดี
      ภายใต้เงื่อนไขว่าไม่มี garbage collection แม้แต่ C++ ก็ยังปลอดภัยกว่า Go มาก
  • memory model ของ Rust ทำให้คอมไพเลอร์สามารถสันนิษฐานได้เสมอว่า arguments ของฟังก์ชันจะไม่มีทางเป็น aliasing reference กัน แต่ใน Zig ต้องระบุสิ่งนี้เอง
    การระบุ alias แบบนี้กำลังถูกหลีกเลี่ยง
    เพราะมีคนเข้าใจน้อย และถ้าใช้ผิดก็อาจทำให้เกิดบั๊กที่เข้าใจยากในโค้ดได้

  • ต่อข้ออ้างที่ว่า ความยืดหยุ่นของ comptime ใน Zig นำการปรับปรุงที่ดีไปสู่ภาษาอื่น ๆ นั้น, การเรียกใช้ฟังก์ชันตอน compile time และฟังก์ชันที่รับ argument แบบค่าคงที่ D ได้นำเข้ามาตั้งแต่ปี 2007 และทำให้หลายภาษารับสิ่งที่คล้ายกันไปใช้
    https://dlang.org/spec/function.html#interpretation

  • ผมชอบ Zig แต่คำว่า “ชอบความเยิ่นเย้อของ Zig” ฟังดูแปลก
    C หลวมเกินไปในหลายจุดก็จริง แต่ตามมาตรฐานปัจจุบัน Zig เหวี่ยงไปอีกด้านมากไปหน่อย จนบางครั้งมี สัญญาณรบกวนจากการระบุชนิดที่เหมือนคอมเมนต์ มากเกินไป
    โดยเฉพาะการแคสต์จำนวนเต็มแบบชัดเจนในนิพจน์ และผมเขียนเรื่องนี้ไว้เล็กน้อยที่นี่: https://floooh.github.io/2024/08/24/zig-and-emulators.html
    ในแง่ประสิทธิภาพ ถ้าโค้ด Zig เร็วกว่าโค้ด C ที่คล้ายกัน ส่วนใหญ่มักเป็นเพราะ การตั้งค่า optimization ของ LLVM ที่ก้าวร้าวกว่าของ Zig
    เช่น โดยค่าเริ่มต้น Zig ใช้ -march=native และการปรับแต่งทั้งโปรแกรม และคอมไพล์โค้ด Zig ทั้งหมดในโปรเจกต์เป็นหน่วยคอมไพล์เดียว
    “ทริก” แทบทั้งหมด เช่น การใช้ unreachable เป็นคำใบ้สำหรับ optimizer ก็ทำได้ใน C เช่นกัน เพียงแต่บางครั้งต้องใช้ส่วนขยายภาษาที่ไม่ใช่มาตรฐาน
    คอมไพเลอร์ C โดยเฉพาะ Clang ก็ทำ constant folding อย่างก้าวร้าวมาก และแม้จะมี call stack ลึก ก็สามารถยุบพื้นที่โค้ดขนาดใหญ่ที่พับเป็นค่าคงที่ได้
    ดังนั้นถ้าดูเฉพาะ code generation หลายครั้งก็แทบไม่ต่างจาก comptime ของ Zig
    ข้อดีของ comptime คือมันจะไม่เงียบ ๆ ถอยกลับไปเป็นโค้ดรันไทม์ และโค้ดที่ไม่ใช่ comptime ก็ยังเป็นเป้าหมายของการ optimize แบบ constant folding เหมือน C
    ตัวอย่างเช่น หากฟังก์ชัน non-comptime ที่ “บริสุทธิ์” ถูกเรียกด้วยอาร์กิวเมนต์ค่าคงที่ คอมไพเลอร์ก็ยังคงแทนที่การเรียกฟังก์ชันด้วยผลลัพธ์นั้นได้
    สรุปคือ ถ้าโค้ด C ช้ากว่าโค้ด Zig ควรตรวจสอบการตั้งค่าคอมไพเลอร์ C เพราะท้ายที่สุดงานหนักของการ optimize ทั้งหมดเกิดขึ้นอยู่ข้างใต้ใน LLVM

    • สำหรับตัวอย่างการแคสต์ สามารถห่อ cast ไว้ในฟังก์ชันได้
      fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); }
      export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); }
      มันคอมไพล์ออกมาเป็น assembly เดียวกัน นำกลับมาใช้ซ้ำได้ และเจตนาก็ชัดเจนขึ้น
    • Zig มีไอเดียที่น่าสนใจ และผมคิดว่าบทความจะโฟกัสเรื่องการปรับแต่งระดับต่ำมากกว่านี้ แต่สุดท้ายเป็นเนื้อหาว่า “comptime กับการคอมไพล์ทั้งโปรแกรมนั้นยอดเยี่ยม”
      ผมเห็นด้วยกับคำพูดนั้น
      Virgil ใช้ภาษาได้ทั้งภาษาใน compile time มาตั้งแต่ปี 2006 และรองรับการคอมไพล์ทั้งโปรแกรมด้วย
      แต่เนื่องจาก Virgil ไม่ได้ target ไปที่ LLVM การเทียบความเร็วจึงกลายเป็นการเทียบ backend ของคอมไพเลอร์สองตัว
      Virgil พึ่งพา reachability analysis และ specialization optimization ที่โมเดลการคอมไพล์นี้ทำให้เป็นไปได้อย่างมาก
      เช่น devirtualize การเรียกเมธอดอย่างก้าวร้าว ลบฟิลด์และอ็อบเจ็กต์ที่ไปไม่ถึง ยกระดับค่าคงที่ผ่านฟิลด์และ heap object และทำให้โค้ด polymorphic กลายเป็น monomorphic ทั้งหมด
    • เมื่อ x86 backend ใหม่ออกมา เราอาจได้เห็นความต่างด้านประสิทธิภาพระหว่าง C กับ Zig ที่สามารถโยนให้เป็นผลของโปรเจกต์ Zig เองเท่านั้น
    • เรื่องการแคสต์จำนวนเต็มแบบชัดเจน ดูเหมือนจะมีการจัดระเบียบในเร็ว ๆ นี้: https://ziggit.dev/t/short-math-notation-casting-clarity-of-...
    • ในอีกไม่กี่ปีข้างหน้า แนวโน้มของภาษาโดยรวมดูจะเคลื่อนไปทาง ภาษาที่เยิ่นเย้อและชัดเจนมากขึ้น ค่อนข้างแรง
      เพราะมันทำให้ AI จัดการได้ง่ายขึ้นเท่านั้น
      การใช้ AI ในการเขียนโค้ดเป็นความคิดที่ดีหรือไม่ และดีแค่ไหน เป็นอีกประเด็นหนึ่ง แต่มีนักพัฒนาจำนวนมากเชื่อเช่นนั้น และภาษาต่าง ๆ ก็น่าจะพยายามรองรับพวกเขา