1 คะแนน โดย GN⁺ 4 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • pslang เริ่มต้นจากความสนใจในความสามารถในการทำม็อดของเกมขนาดใหญ่และแอสเซมบลีที่คอมไพเลอร์ C++ สร้างขึ้น และปัจจุบันทำงานได้มากพอที่จะเขียน Monte-Carlo path tracer ขนาดราว 1,000 LOC ได้
  • ภาษาสำหรับม็อดจำเป็นต้องมี การทำงานร่วมกับ C, การจัดการอาร์เรย์และพอยน์เตอร์ระดับล่าง, การทำแซนด์บ็อกซ์ได้ง่าย, คอมไพเลอร์ขนาดเล็ก และคอมไพล์ได้รวดเร็ว โดย Lua และโหมด native ของ C++ ต่างก็มีข้อจำกัดด้านการเชื่อมต่อประสิทธิภาพ การทำแซนด์บ็อกซ์ และการแจกจ่าย
  • pslang เป็นภาษาระดับล่างแบบ imperative, eager evaluation และ call-by-value พร้อมระบบชนิดแบบ static, strict และ nominal, ขอบเขตแบบอิงการเยื้อง, อาร์เรย์ในตัว, ชนิดฟังก์ชัน, พอยน์เตอร์ และ memory layout ที่รับประกันได้
  • คอมไพเลอร์แบ่งเป็น parser ที่อิง Bison, การตรวจชนิดบน AST, IR, interpreter และ JIT โดยปัจจุบันรองรับเฉพาะ Aarch64 Mac เท่านั้น และหลังจากนำ IR มาใช้ คุณภาพโค้ดที่สร้างขึ้นยังต่ำเพราะยังไม่มี register allocator
  • อิมพลีเมนต์ปัจจุบันมีโค้ด C++ ราว 10,000 บรรทัด และกำลังพิจารณาความสามารถในอนาคต เช่น register allocator, การเพิ่มประสิทธิภาพ IR, IR interpreter, การสร้างไฟล์ executable, ข้อมูลดีบัก, polymorphism, โมดูล และ standard library

ที่มาของการสร้าง pslang

  • หลังจากเขียนโปรแกรมมาราว 17 ปี ความอยากที่จะสร้างภาษาด้วยตัวเองที่ไม่ใช่แค่ของเล่น แต่ตั้งใจให้ใช้งานได้จริงในระดับหนึ่งก็เพิ่มมากขึ้น
  • ในอดีตเคยสร้าง interpreter สำหรับภาษาประหลาดอย่าง FALSE และ interpreter ของ lambda calculus หลายตัว แต่ก็ยังไม่ตอบโจทย์ความอยากสร้างภาษา “จริงจัง”
  • เกมขนาดใหญ่ ที่กำลังพัฒนาอยู่มีโครงสร้างที่เหมาะกับการทำม็อด จึงระหว่างคิดเรื่องแนวทางการม็อดก็พบว่าการสร้างภาษาโปรแกรมแบบคัสตอมเป็นหนึ่งในวิธีแก้ที่เรียบง่าย
  • ในเดือนธันวาคม 2025 ระหว่างดู Advent of Compiler Optimisations ของ Matt Godbolt ก็เริ่มไล่ดูแอสเซมบลีที่คอมไพเลอร์ C++ สร้างขึ้น และอยากกลับไปทำงานกับแอสเซมบลีอีกครั้ง
  • ตอนนี้ภาษายังห่างไกลจากคุณภาพระดับ production แต่พัฒนาไปถึงจุดที่สามารถเขียน Monte-Carlo path tracer ที่ทำงานได้จริงขนาดประมาณ 1,000 LOC ได้แล้ว

ความต้องการด้านม็อดและข้อจำกัดของตัวเลือกที่มีอยู่

  • เกมนี้จำลองเอนทิตีหลายแสนตัวด้วย custom ECS engine จึงต้องการให้ภาษาม็อดรับชุดของ component pointer และวนลูปได้เหมือน for loop ของ C
  • เนื่องจากม็อดควบคุมได้ยาก จึงต้องทำ แซนด์บ็อกซ์ ได้ง่ายเพื่อปกป้องผู้เล่น และในอุดมคติควรปิดการทำงาน I/O และความสามารถทำนองเดียวกันทั้งหมดได้ด้วยสวิตช์เดียว
  • การทำม็อดควรง่ายพอที่แค่วางสคริปต์ไว้ในโฟลเดอร์ที่กำหนดก็ใช้งานเป็นม็อดได้ทันที
  • Lua และภาษาสคริปต์แบบ JIT

    • Lua เป็นตัวเลือกมาตรฐาน แต่ดูเหมือนว่าจะต้องทำแซนด์บ็อกซ์ด้วยการใส่โค้ดพรีโปรเซสที่ลบฟังก์ชันเกี่ยวกับ I/O ออกจาก standard library ก่อนรันโค้ดที่ไม่น่าเชื่อถือ ซึ่งไม่รู้สึกว่าเป็นวิธีที่มั่นคงนัก
    • Lua เป็นภาษาระดับสูงแบบ dynamic type จึงไม่เข้าใจ C pointer โดยตรง ทำให้หากจะเชื่อมการวนลูปเอนทิตีของ ECS จะเกิดการสลับ native ↔ Lua ↔ native กับทุกเอนทิตี หรือไม่ก็ต้องสร้างเอนทิตี native เป็นอาร์เรย์ของ Lua แล้วค่อยแยกกลับอีกที
    • Lua มาตรฐานกับ LuaJIT แยกทางกันมาหลายเวอร์ชันแล้ว ซึ่งอาจสร้างความสับสนทั้งต่อผู้ทำม็อดและผู้พัฒนา
  • C++ และ native mod

    • ถ้าสร้างม็อดด้วย C++ ปัญหาการวนลูปเอนทิตีก็จะหายไป แต่การแจกจ่ายไบนารีจะต้องมีสภาพแวดล้อมพัฒนาและคลังเก็บ binary artifact สำหรับทุกแพลตฟอร์ม
    • หากจะแจกจ่ายเป็นซอร์สโค้ด ก็ต้องรวมคอมไพเลอร์ C++ ไปกับเกม ซึ่งแม้แต่การติดตั้ง LLVM พื้นฐานก็ใช้พื้นที่ดิสก์มากกว่าขนาดเกมปัจจุบันถึง 10~20 เท่า
    • ถ้า native DLL ประกาศและเรียกใช้ int open(); ก็แทบเป็นไปไม่ได้ที่จะป้องกันการเข้าถึงไฟล์ซิสเต็มหรือเครือข่าย ทำให้ไม่สามารถทำแซนด์บ็อกซ์ได้
    • ปัญหาเดียวกันนี้ใช้กับภาษา native อื่น ๆ อย่าง Rust ด้วย
    • แม้ว่าการทำม็อดจะเป็นหนึ่งในเป้าหมาย แต่ก็ยังไม่แน่ชัดว่าจะใช้ภาษานี้กับการม็อดเกมจริงหรือไม่ และไม่อยากทำให้มันเฉพาะทางกับกรณีใช้งานใดมากเกินไป

เป้าหมายในการออกแบบภาษา

  • ต้องการให้ การทำงานร่วมกับ C เป็นไปอย่างไร้รอยต่อ เพื่อให้การเชื่อมระหว่างโค้ดเกมแบบ native กับโค้ดม็อดเรียบง่ายพอ ๆ กับการเรียกฟังก์ชัน
  • เนื่องจากต้องจัดการอาร์เรย์เอนทิตีดิบ จึงต้องมีความสามารถระดับ low-level
  • ภาษาควรใช้งานได้จริงและใช้ง่ายพอสมควร เพื่อให้ผู้ทำม็อดเขียนโค้ดได้อย่างสะดวกในระดับที่เหมาะสม
  • ต้องทำแซนด์บ็อกซ์ได้ง่าย และตัวคอมไพเลอร์เองก็ควรมีขนาดเล็ก
  • ไม่อยากใส่คอมไพเลอร์ขนาด 1GB ลงไปในเกมขนาด 50MB จึงต้องการลด footprint ของคอมไพเลอร์
  • ต้องคอมไพล์ได้เร็วเพื่อไม่ให้ผู้เล่นต้องรอนานเวลาคอมไพล์ม็อด แม้บางส่วนจะบรรเทาได้ด้วยการแคชอย่างกว้างขวาง
  • ต้องการ cross-platform จริง แต่ก็ยอมรับสมมติฐานอย่างการรองรับเดสก์ท็อปแพลตฟอร์มหลักไม่กี่ตัว, 64 บิต และ IEEE754
  • ขอแค่เร็วในระดับที่สมเหตุสมผลเมื่อเทียบกับภาษาส่วนใหญ่แบบ dynamic ก็เพียงพอ
  • C++ เป็นภาษาหลักมานาน จึงมีอิทธิพลต่อมุมมองเรื่องภาษาอย่างมาก แต่ก็พยายามไม่สร้าง C++ ขึ้นมาใหม่ตรง ๆ หากเป็นไปได้

โมเดลภาษาปัจจุบันของ pslang

  • ชื่อที่ใช้ระหว่างพัฒนาคือ pslang ซึ่งมาจาก game engine psemek และเป็นภาษาระดับล่างแบบ imperative, eager evaluation และ call-by-value
  • ระบบชนิดประกอบด้วย static type system, strict type system และ nominal type system
  • ตัวอย่างพื้นฐานใช้ทั้งฟังก์ชัน, struct, ชนิดฟังก์ชัน และการคืนค่าอาร์เรย์ร่วมกัน
func min(x: i32, y: i32) -> i32:
    return if x < y then x else y

struct vec3i:
    x: i32
    y: i32
    z: i32

func apply(f: i32 -> i32, v: vec3i) -> vec3i:
    return vec3i(f(v.x), f(v.y), f(v.z))

func as_array(v: vec3i) -> i32[3]:
    return [v.x, v.y, v.z]

ขอบเขตและชนิดพื้นฐาน

  • ใช้ขอบเขตแบบอิงการเยื้อง เพื่อให้ดูคล้ายภาษาสคริปต์และรู้สึกเป็นมิตรกับผู้เริ่มต้นมากขึ้น
  • ปัจจุบันใช้ tab สำหรับการเยื้อง แต่ภายหลังอาจเปลี่ยนเป็น space
  • เนื้อหาของฟังก์ชัน, ลูป, if เป็นต้นจะสร้างขอบเขตใหม่ และสามารถนิยามฟังก์ชันกับ struct ได้ภายในขอบเขตใดก็ได้ โดยจะมองเห็นได้เฉพาะในขอบเขตนั้น
  • ฟังก์ชันท้องถิ่นไม่สามารถเข้าถึงตัวแปรในขอบเขตที่มันถูกนิยามไว้ จึงไม่ใช่ closure และขอบเขตมีผลต่อการ resolve ชื่อเท่านั้น
  • ขอบเขตระดับบนสุดถูกปฏิบัติเหมือนขอบเขตอื่น ๆ และมี entry point ที่จะถูกรันเมื่อโหลดหรือ initialze ไฟล์
  • ชนิดพื้นฐานมีทั้งหมด 13 ชนิด ได้แก่ bool, จำนวนเต็ม signed 4 ชนิด, จำนวนเต็ม unsigned 4 ชนิด, floating-point 3 ชนิด และ unit
i8  i16  i32  i64
u8  u16  u32  u64
    f16  f32  f64
  • ไม่ใส่ f8 เพราะ CPU เดสก์ท็อปส่วนใหญ่ไม่รองรับ และยังไม่มีข้อสรุปที่ชัดเจนเกี่ยวกับความหมายของ floating-point 8 บิต
  • แม้ f16 จะมีประโยชน์กับผู้ใช้ทั่วไปน้อยกว่า แต่ก็มักใช้ในงานกราฟิกอย่างสี HDR และ vertex attribute และ CPU เดสก์ท็อปสมัยใหม่ส่วนใหญ่ก็รองรับ IEEE754 f16 จึงรองรับเป็นพื้นฐาน
  • การคำนวณเลขจำนวนเต็มทั้งหมดใช้รูปแบบ two's complement ที่มี overflow ได้ และไม่มี undefined behavior
  • unit มีค่าเดียวคือ unit() และเป็นชนิดคืนค่าอย่างเป็นทางการของฟังก์ชันที่ไม่มีค่าคืนกลับ
  • ฟังก์ชันที่ละชนิดคืนค่าไว้จะคืน unit โดยอัตโนมัติ และถ้าละ return ท้ายฟังก์ชันประเภทนี้ก็จะถูกใส่ให้โดยอัตโนมัติ
  • ถ้าไม่ใช่ฟังก์ชัน unit แต่ไม่มีการคืนค่า จะถือเป็นข้อผิดพลาด

ลิเทอรัล, อาร์เรย์, ชนิดฟังก์ชัน, พอยน์เตอร์

  • ตัวเลข 10 มีชนิดเป็น i32 โดยปริยาย และระบุขนาดได้ด้วย suffix เช่น 10b, 10s, 10l
  • ลิเทอรัลแบบ unsigned เติม suffix u และเขียนเป็น 10ub, 10us, 10u, 10ul
  • ลิเทอรัลจำนวนจริงแบบมีจุดทศนิยมมีชนิดเป็น f32 โดยปริยาย โดย 10.0h คือ 16 บิต และ 10.0d คือ 64 บิต
  • ไม่สามารถละส่วนจำนวนเต็มหรือส่วนทศนิยมได้แบบ 10. หรือ .5 ต้องเขียนให้ครบเป็น 10.0, 0.5
  • ลิเทอรัลตัวเลขทั้งหมดมีชนิดที่ไม่กำกวม
  • อาร์เรย์เป็นชนิด built-in แบบ first-class และต่างจาก C/C++ ตรงที่สามารถส่งอาร์เรย์ทั้งก้อนไปยังฟังก์ชัน ส่งกลับจากฟังก์ชัน หรือกำหนดค่าให้กันได้
  • ขนาดอาร์เรย์ต้องทราบเสมอตอนคอมไพล์ และทำงานคล้าย struct ที่มีฟิลด์หลายตัวซึ่งเป็นชนิดเดียวกัน
  • ชนิดอาร์เรย์เขียนเป็น i32[5] และลิเทอรัลอาร์เรย์เขียนเป็น [1, 2, 3, 4, 5]
  • ชนิดฟังก์ชันมีลักษณะใกล้เคียงกับฟังก์ชันพอยน์เตอร์ของ C เขียนเป็น (a, b, c) -> d และถ้ามีพารามิเตอร์ตัวเดียวสามารถละวงเล็บเป็น a -> b ได้
  • ภายในแล้ว ชนิดฟังก์ชันคือฟังก์ชันพอยน์เตอร์ธรรมดาที่ไม่มีการส่งข้อมูลประกบไปด้วย และไม่ใช่ closure
  • ชนิดพอยน์เตอร์เขียนเป็น i32* โดยค่าปริยายเป็นพอยน์เตอร์ immutable และพอยน์เตอร์ mutable ประกาศเป็น i32 mut*
  • ที่อยู่ของตัวแปรใช้ &x, พอยน์เตอร์ mutable ใช้ &mut x, การ dereference ใช้ *p, และ pointer arithmetic ใช้แบบ *(p + 10)

Struct, การจัดวางหน่วยความจำ, ชนิดว่าง

  • Struct ประกาศด้วยคีย์เวิร์ด struct และรายการฟิลด์
struct string_view:
    size: u64
    data: u8*
  • Struct สร้างได้ด้วย constructor แบบฟังก์ชันที่มีมาในภาษา เช่น string_view(10, data) และเข้าถึงฟิลด์ด้วยจุดแบบ v.x
  • แม้เป็น struct pointer ก็เข้าถึงฟิลด์ด้วยไวยากรณ์จุดแบบเดียวกันได้
  • ฟิลด์ของ struct ไม่มีตัวระบุความ mutable แยกต่างหาก ฟิลด์ของอ็อบเจ็กต์ mutable จะ mutable และฟิลด์ของอ็อบเจ็กต์ immutable จะ immutable
  • ไม่มี access modifier และฟิลด์เป็น public เสมอ
  • อ็อบเจ็กต์ทั้งหมดมีการจัดวางหน่วยความจำที่รับประกันไว้ โดยชนิดพื้นฐานมี alignment เท่ากับขนาดของมัน และ bool มีขนาด 1 ไบต์
  • ชนิดพอยน์เตอร์และฟังก์ชันมีขนาด 64 บิตเสมอ และมี alignment เหมือนกัน
  • อาร์เรย์มี alignment เท่ากับสมาชิกของมัน และ struct จะมี padding เพื่อให้ตรงตามข้อกำหนด alignment
  • การรับประกันนี้มีไว้หลัก ๆ เพื่อทำให้การทำงานร่วมกับ C และการเขียนโปรแกรม GPU ง่ายขึ้น
  • unit และ struct ที่ไม่มีฟิลด์ถูกมองเป็น ชนิดว่าง ที่มีค่าใช้ได้เพียงค่าเดียว และมีขนาดจริง 0 ไบต์
  • แม้จะส่งชนิดว่างเข้าไปในฟังก์ชัน ประกาศเป็นตัวแปร หรือใส่เป็นฟิลด์ ก็ไม่ใช้หน่วยความจำและไม่ส่งผลต่อขนาดของ struct
  • ชนิดว่างสามารถใช้เป็นแท็กระดับชนิดตอนคอมไพล์ได้
  • การอ่าน/เขียนผ่านพอยน์เตอร์ของชนิดว่างยังไม่ได้ข้อสรุป และตอนนี้การทำ pointer arithmetic กับพอยน์เตอร์ชนิดนั้นถือว่าผิดกฎหมาย
  • ไม่มีข้อกำหนดแบบ C++ ที่ว่าอ็อบเจ็กต์แต่ละตัวต้องมีที่อยู่หน่วยความจำเฉพาะของตัวเอง

ตัวแปร, ฟังก์ชัน, control flow, ฟังก์ชันภายนอก

  • ตัวแปร immutable ประกาศเป็น let x = 10 และตัวแปร mutable ประกาศเป็น mut x = 20
  • ไม่สามารถสร้างพอยน์เตอร์ mutable ไปยังตัวแปร immutable ได้
  • สามารถระบุชนิดแบบชัดเจนได้ เช่น let x: i32 = 10 แต่ไม่จำเป็น เพราะภาษาออกแบบมาให้อนุมานชนิดของทุก expression ได้อย่างไม่กำกวม
  • ตัวแปรทั้งหมดต้องถูกกำหนดค่าเริ่มต้นเสมอ
  • ฟังก์ชันเขียนเป็น func foo(x: A, y: B) -> C: แล้วตามด้วยเนื้อฟังก์ชัน และถ้าละชนิดคืนค่าจะถือเป็น unit
  • ฟังก์ชันทั้งหมดใช้ native C ABI ของแพลตฟอร์มที่รันอยู่ ซึ่งตัดสินใจเช่นนี้เพื่อให้ทำงานร่วมกับ C, callback และการส่งเป็นฟังก์ชันพอยน์เตอร์ให้กับระบบอย่าง ECS ได้
  • ภายในสโคปเดียวกัน ลำดับการประกาศฟังก์ชันและ struct เป็นอิสระ จึงสามารถใช้ฟังก์ชันหรือ struct ที่ประกาศทีหลังได้ก่อน
  • พารามิเตอร์และชนิดคืนค่าของทุกฟังก์ชันต้องระบุครบถ้วน จึงทำให้การเปิดให้ประกาศสลับลำดับกันได้ไม่ทำให้การอนุมานชนิดซับซ้อนขึ้น
  • มีคำสั่ง if/else if/else และลูป while แต่ยังไม่มีลูป for
  • if แบบ expression ใช้เป็น if A then B else C
  • ฟังก์ชันภายนอกประกาศเป็น foreign func sin(x: f64) -> f64 และต้องลิงก์ implementation จากที่อื่นเข้ามา
  • ปัจจุบัน interpreter จะค้นหาฟังก์ชันเหล่านั้นจากตัว executable ของ interpreter เองด้วย dlsym
  • ฟังก์ชันภายนอกเป็นกลไกหลักในการทำงานร่วมกับไลบรารี C และไลบรารีของ third party โดยตัวอย่าง raytracer ใช้ความสามารถนี้สำหรับการคำนวณ square root, การเขียนไฟล์, การวัดเวลา และการสร้างเธรด

การแคสต์ชนิดและโอเปอเรเตอร์

  • ไม่มี implicit type casting เลยแม้แต่น้อย ส่วนการแคสต์แบบกำหนดเองใช้โอเปอเรเตอร์ as เช่น (x as f32)
  • ชนิดตัวเลขทั้งหมดแคสต์หากันได้ และชนิดพอยน์เตอร์ทั้งหมดก็แคสต์หากันได้เช่นกัน ยกเว้นการเปลี่ยนพอยน์เตอร์ immutable ให้เป็นพอยน์เตอร์ mutable
  • ชนิดพอยน์เตอร์แคสต์เป็น u64 ได้ และ u64 ก็แคสต์กลับเป็นชนิดพอยน์เตอร์ได้
  • bool แคสต์กับชนิดใด ๆ ไม่ได้
  • กำลังพิจารณาว่าจะเพิ่ม implicit cast เพียงอย่างเดียวจาก T mut* ไปเป็น T* หรือไม่
  • มีโอเปอเรเตอร์มาตรฐานส่วนใหญ่ครบ ทั้งเลขคณิต ตรรกะ และการเปรียบเทียบ
  • &, |, &&, || ใช้ได้ทั้งกับ boolean และ integer โดย & กับ | จะประเมินโอเปอแรนด์ทั้งสองฝั่งเสมอ ส่วน && กับ || จะทำ short-circuit evaluation
  • การคำนวณเลขคณิตและการเปรียบเทียบใช้ได้เฉพาะกับคู่ของชนิดตัวเลขที่เหมือนกัน และไม่มีการ promotion ของชนิดตัวเลข
  • แม้ตอนนี้ฟีเจอร์ของภาษาจะดูเหมือนไม่มาก แต่ก็สามารถเขียนโปรแกรมจริงได้ค่อนข้างสะดวกแล้ว

โครงสร้างคอมไพเลอร์

  • โปรเจ็กต์ถูกแยกเป็นหลายไลบรารี
    • types: นิยามระบบชนิด
    • ast: นิยาม abstract syntax tree และยูทิลิตี
    • parser: ตัวพาร์เซอร์
    • ir: ตัวแทนระดับกลาง
    • interpreter: อินเทอร์พรีเตอร์
    • jit: คอมไพเลอร์ JIT
  • แนวคิดคือให้ interpreter และ compiler เป็นแอป CLI แบบเรียบง่ายที่ใช้ไลบรารีเหล่านี้ โดยตอนนี้มีเพียง interpreter ในโหมด JIT
  • หากต้องการ embed ภาษา ก็ใช้ไลบรารี parser และ jit ได้

พาร์เซอร์และการจัดการ indentation

  • ใช้ Bison เป็นตัวสร้างพาร์เซอร์
  • โทเค็นนิยามไว้ใน lexer grammar และไวยากรณ์ภาษานิยามไว้ใน parser grammar
  • ไฟล์หนึ่งประกอบด้วยรายการของ statement โดย statement อาจเป็นการประกาศฟังก์ชัน, control flow operator, การประกาศตัวแปร, expression ฯลฯ และ expression ก็อาจเป็นลิเทอรัล ตัวแปร โอเปอเรเตอร์ การเรียกฟังก์ชัน ฯลฯ
  • ต้องแก้ปัญหา shift/reduce conflict ในไวยากรณ์อยู่หลายครั้ง และใช้แฟล็ก -Wcounterexamples ของ Bison เพื่อตรวจดูสถานการณ์ที่ก่อให้เกิด conflict อย่างแม่นยำ
  • ใช้ Bison skeleton แบบ lalr1.cc เพื่อสร้างคลาสพาร์เซอร์ C++
  • Bison แบบปกติจะสร้างพาร์เซอร์ C ที่เก็บสถานะของพาร์เซอร์ไว้ในตัวแปร global แต่สิ่งนี้ไม่เหมาะกับกรณีที่ต้องพาร์สหลายไฟล์พร้อมกันได้ เช่น ใน interpreter หรือ game mode
  • การรัน Bison ถูกใส่ไว้ในขั้นตอน build ของ CMake scripts
  • เอาต์พุตของพาร์เซอร์คืออ็อบเจ็กต์ C++ ที่แทน AST ของไฟล์ที่พาร์สแล้ว
  • เนื่องจากมี indentation ไวยากรณ์จึงไม่ใช่ context-free อย่างแท้จริง เพราะการที่ statement ใดจะอยู่ใน body ของ while หรือไม่นั้นขึ้นอยู่กับจำนวนโทเค็น indentation ก่อนหน้า
  • วิธีแก้คือพาร์สแต่ละบรรทัดเป็น statement อิสระพร้อมระดับ indentation ก่อน แล้วค่อยใช้ linear pass แบบง่าย เพื่อตัดสิน scope จากระดับ indentation
  • วิธีนี้ค่อนข้างแฮ็ก แต่ใช้งานได้และเร็วมาก จึงยอมรับได้
  • ใน pass เดียวกันนั้นยังตรวจด้วยว่า break และ continue อยู่ได้เฉพาะในลูป, return อยู่ได้เฉพาะในฟังก์ชัน, และการนิยามฟิลด์ต้องอยู่ภายใน struct เท่านั้น

การตรวจสอบประเภทและอินเทอร์พรีเตอร์

  • หลังจากพาร์สแล้ว พาสแรกจะทำการ resolve identifier ทั้งหมด โดยเชื่อมโหนด identifier เข้ากับโหนดนิยามตัวแปร ฟังก์ชัน หรือสตรักต์ที่เกี่ยวข้องโดยตรง
  • พาสหลักถัดมาคือการตรวจสอบและอนุมานประเภททั้งหมด
  • การอนุมานประเภทโดยรวมค่อนข้างเรียบง่าย และประกอบด้วยการตรวจสอบเงื่อนไขตามชนิดของโหนด AST แต่ละแบบ
  • ตัวอย่างเช่น ประเภทของนิพจน์ใน if หรือ while ต้องเป็น bool และตัวถูกดำเนินการทั้งสองของการบวกต้องเป็นชนิดตัวเลขเดียวกัน หรือไม่ก็ฝั่งหนึ่งเป็นจำนวนเต็มและอีกฝั่งเป็นพอยน์เตอร์
  • อินเทอร์พรีเตอร์รุ่นแรกเป็น tree-walking interpreter ที่เข้าเยี่ยมโหนด AST โดยตรงเพื่อรันโค้ด C++
  • ฟังก์ชันหลักคือ exec() และ eval() โดย exec() ใช้รันคำสั่งเดี่ยว และ eval() ใช้คำนวณและคืนค่านิพจน์เดี่ยว
  • เนื่องจาก C++ เป็นภาษาที่มี static type ดังนั้น eval() จึงคืนค่าเป็น variant ที่ครอบคลุมชนิดค่าที่เป็นไปได้ทั้งหมดของภาษา
  • สตรักต์ถูกแทนด้วยอาร์เรย์ของคู่ชื่อ-ค่าอย่างละหนึ่งต่อฟิลด์ และใช้ variant เดียวกันนี้ในการเก็บค่าของตัวแปรด้วย
  • จุดประสงค์ของอินเทอร์พรีเตอร์คือเพื่อรันโค้ดของภาษาแบบข้ามแพลตฟอร์ม และช่วยดีบักทั้งตัวภาษาและโปรแกรม ไม่ได้ทำมาเพื่อให้เร็ว
  • ปัจจุบันอินเทอร์พรีเตอร์อยู่ในสภาพพังมาก จึงมีแผนจะเขียนใหม่ทั้งหมดบนฐานของ IR
  • อินเทอร์พรีเตอร์เดิมไม่สามารถรันฟังก์ชัน foreign ได้
  • ฟังก์ชัน foreign ต้องถูกเรียกด้วย C calling convention และไม่อาจรู้จำนวนกับประเภทของอาร์กิวเมนต์ล่วงหน้า จึงอาจต้องใช้เทคนิค vararg หรือ libffi
  • อินเทอร์พรีเตอร์สามารถ dump สถานะภายในของมัน ได้แก่ ชื่อ ประเภท และค่าของตัวแปร ออกไปยัง stdout ได้ และนี่เคยเป็นวิธีหลักในการดีบักพาร์เซอร์กับอินเทอร์พรีเตอร์ก่อนจะมีคอมไพเลอร์ที่ใช้งานได้จริง

คอมไพเลอร์ JIT Aarch64 ตัวแรก

  • ช่วงต้นเดือนมกราคม 2026 ระหว่างวันหยุดมีเพียง M1 Mac อยู่ในมือ ดังนั้นสถาปัตยกรรมเป้าหมายตัวแรกของคอมไพเลอร์จึงกลายเป็น Aarch64 Mac
  • ตอนนี้ก็รองรับเพียงสถาปัตยกรรมนี้เท่านั้น
  • คอมไพเลอร์เป็นแบบ JIT โดยผลลัพธ์คือ memory blob ที่แมปด้วย executable bit และพอยน์เตอร์ไปยังจุดเริ่มต้นของแต่ละฟังก์ชัน
  • โครงสร้างระดับสูงแทบจะใกล้เคียงคอมไพเลอร์แบบ stack-based ดั้งเดิม แต่จะจัดวางผลลัพธ์ของนิพจน์ในแบบเดียวกับที่ฟังก์ชันชนิดคืนค่าเดียวกันวางค่าตาม AAPCS64 ซึ่งเป็น standard C calling convention บน Aarch64 Mac
  • จำนวนเต็มและพอยน์เตอร์จะคืนค่าผ่านเรจิสเตอร์ทั่วไป x0 ส่วนเลขทศนิยมจะคืนค่าผ่านเรจิสเตอร์ทศนิยม v0 และสตรักต์จะคืนค่าผ่านเรจิสเตอร์หรือสแตกตามขนาด
  • วิธีนี้ช่วยลดจำนวนการเข้าถึงหน่วยความจำ ทำให้โค้ดที่สร้างขึ้นเร็วขึ้นและทำให้การเรียกฟังก์ชันง่ายขึ้นด้วย
  • สแตกถูกใช้เป็นหลักกับค่ากลาง เช่น ในการดำเนินการแบบไบนารี
(eval A)         # the value of A is in x0
push x0          # the value of A is on stack top
(eval B)         # the value of B is in x0
pop x1           # the value of A is in x1
add x0, x0, x1   # the value of A+B is in x0
  • โครงสร้างควบคุมการไหลจะถูกแปลงเป็น conditional jump แต่ในการคอมไพล์แบบ single-pass นั้น ยังไม่ได้คอมไพล์บอดีของ if หรือ while จึงยังไม่รู้เป้าหมายของ jump
  • เพื่อแก้ปัญหานี้ จึงปล่อยคำสั่ง jump ที่มี offset เป็น 0 ออกไปก่อน แล้วค่อย inject jump offset จริงเมื่อทราบ offset ของเป้าหมายแล้ว
  • วิธีเดียวกันนี้ยังใช้กับการเรียกฟังก์ชันด้วย
  • การสร้างคำสั่งของ CPU เป้าหมายไม่ได้ใช้ไลบรารีภายนอก แต่เขียนเองเพื่อให้คอมไพเลอร์มีขนาดเล็ก
  • วิธีทำคือเปิดคู่มือ instructionแล้วใส่บิตที่ต้องการลงไปเอง

จุดที่ยุ่งยากบน Aarch64

  • คำสั่งทั้งหมดของ Aarch64 มีขนาด 32 บิต จึงดูเหมือนจะจัดการง่าย แต่ถ้าจะใส่ค่าคงที่ 32 บิตลงในเรจิสเตอร์ จำเป็นต้องมีทั้งบิตเลือกเรจิสเตอร์ บิตของคำสั่ง และบิตของค่าคงที่ ทำให้ไม่สามารถใส่ทั้งหมดลงในคำสั่ง 32 บิตเพียงคำสั่งเดียวได้
  • ค่าคงที่ 64 บิตยิ่งเป็นปัญหาหนักกว่า
  • ค่าคงที่จะต้องถูกประกอบจากชิ้นขนาด 16 บิต ด้วยคำสั่งที่โหลดไปยังตำแหน่งออฟเซ็ต 0, 16, 32 และ 48 บิต หรือไม่ก็เก็บไว้ใน constant memory แล้วโหลดจากตรงนั้น
  • สำหรับค่าคงที่แบบทศนิยม จะใช้วิธีโหลดจาก constant memory
  • ต่างจาก x86 ตรงที่ไม่มีคำสั่ง push/pop จึงต้องประกอบจากคำสั่งที่อ่าน/เขียนระหว่างเรจิสเตอร์กับที่อยู่หน่วยความจำ พร้อมกับปรับเรจิสเตอร์ที่อยู่
  • เพราะทุกคำสั่งมีขนาด 32 บิตเป๊ะ จึงต้องคอยระวังตลอดว่า offset เป็น signed หรือ unsigned ถูกคูณล่วงหน้าด้วยค่าคงที่บางตัวหรือไม่ และมีการแก้ไขเรจิสเตอร์ที่อยู่หรือไม่
  • เมื่อต้องอ่านและเขียนสแตกโดยอิงจากเรจิสเตอร์ SP ตัว stack pointer จะต้องจัดแนว 16 ไบต์เสมอ
  • offset ที่ใช้ได้ถูกจำกัดไว้ที่ 12 บิต ดังนั้นหาก stack frame ใหญ่เกินราว 16KB จะต้องมีโค้ดพิเศษ ซึ่งตอนนี้ยังไม่ได้ทำ
  • calling convention มีกรณีพิเศษของสตรักต์ที่ถูกส่งผ่านหรือคืนค่าผ่านเรจิสเตอร์ทั่วไปได้สูงสุด 2 ตัว ผ่านเรจิสเตอร์ทศนิยม หรือผ่าน memory pointer ซึ่งโค้ดของคอมไพเลอร์ต้องรองรับสิ่งเหล่านี้

การนำ IR มาใช้และคอมไพเลอร์ตัวที่สอง

  • หลังจากสร้างอินเทอร์พรีเตอร์และคอมไพเลอร์พื้นฐานแล้ว ก็มีการนำ intermediate representation (IR) เข้ามาเพื่อการใช้โค้ดซ้ำ การทำให้การเขียนคอมไพเลอร์สำหรับสถาปัตยกรรมอื่นง่ายขึ้น และเพื่อการปรับแต่งประสิทธิภาพ
  • IR เริ่มต้นจากแนวคิดที่คล้าย SSA แต่เพราะสามารถกำหนดค่าใหม่ให้โหนดเดิมได้และไม่ได้ใช้ phi node ดังนั้นจริง ๆ แล้วมันไม่ใช่ SSA
  • IR เป็นลำดับของ nodes โดยแต่ละโหนดแทนลิเทอรัล การดำเนินการที่มี input node, conditional/unconditional jump, การเรียกฟังก์ชัน เป็นต้น
  • โหนดที่แทนค่าจะเก็บประเภทของค่านั้นไว้ด้วย
  • เพราะอนุญาตให้กำหนดค่าใหม่ได้ จึงมีคำสั่ง IR assign สำหรับกำหนดค่าใหม่ให้ค่าของโหนดเดิม
  • conditional jump แยกเป็น jump_if_zero และ jump_if_nonzero ซึ่งโดยทั่วไปตรงกับคำสั่ง CPU คนละแบบ และเร็วกว่าการกลับค่าก่อนแล้วใช้คำสั่งฝั่งตรงข้าม
  • เนื่องจากรองรับ function pointer จึงมีทั้งคำสั่งสำหรับเรียก IR node ที่รู้จักอยู่แล้ว และคำสั่งสำหรับเรียกค่าพอยน์เตอร์ที่ไม่รู้ล่วงหน้า
  • เพื่อให้ลบหรือแทรกโหนดในตำแหน่งใดก็ได้ระหว่างการปรับแต่งประสิทธิภาพได้ง่าย โหนดจึงถูกเก็บไว้ใน std::list และอ้างอิงด้วย list iterator
  • ไม่สามารถสร้างลิเทอรัลที่เป็นค่าสตรักต์ได้ จึงมีโหนด alloc ที่แทนค่าสตรักต์ ซึ่งโดยปกติจะคอมไพล์เป็นการจองพื้นที่สตรักต์ที่ยังไม่ถูกกำหนดค่าไว้บนสแตก
  • สตรักต์ถูกประกอบขึ้นด้วยการกำหนดค่าให้แต่ละฟิลด์ทีละตัว
  • หากจะแทนฟิลด์สตรักต์แบบซ้อนอย่าง a.x.y แบบตรงไปตรงมา ก็จะต้องอ่าน a.x ออกมาเป็นโหนดใหม่ แล้วค่อยอ่าน y จากโหนดนั้น ซึ่งสิ้นเปลืองมาก
  • a.x.y = b ก็ไม่มีประสิทธิภาพเช่นกันหากแทนเป็น t = a.x, t.y = b, a.x = t ดังนั้น IR จึงมีการจัดการฟิลด์ซ้อนเป็นกรณีพิเศษ
  • โหนด copy สามารถดึงฟิลด์ซ้อนระดับใดก็ได้ออกมาจากสตรักต์ และโหนด assign สามารถกำหนดค่าให้ฟิลด์ซ้อนระดับใดก็ได้ของสตรักต์
  • ฟิลด์ซ้อนถูกแทนด้วยอาร์เรย์ของดัชนี เช่น “เลือกฟิลด์หมายเลข 0 จากนั้นเลือกฟิลด์หมายเลข 2 ข้างในนั้น แล้วเลือกฟิลด์หมายเลข 5 ข้างในนั้นอีกที”
  • หลังจากนั้นคอมไพเลอร์ Aarch64 ก็ถูกเขียนใหม่โดยแยกเป็นคอมไพเลอร์ AST → IR และคอมไพเลอร์ IR → Aarch64
  • ส่วน AST → IR ค่อนข้างตรงไปตรงมา แต่คอมไพเลอร์ IR → Aarch64 ตอนนี้อยู่ในสภาพที่แย่กว่าคอมไพเลอร์แบบ stack-based ตัวก่อนมาก
  • ตอนเริ่มฟังก์ชัน มันจะจองพื้นที่สแตกให้มากพอสำหรับ IR node ทั้งหมดของฟังก์ชันนั้น ทำให้แม้แต่ค่ากลางที่มีอายุสั้นส่วนใหญ่ก็ยังไปกินพื้นที่ใน stack frame
  • ฟังก์ชันหนึ่งใน raytracer ต้องถูกแยกเป็นสองส่วนเพื่อให้ stack frame ยังอยู่ภายในข้อจำกัด 12 บิตที่กล่าวถึงก่อนหน้านี้
  • คอมไพเลอร์ตัวนี้ถูกออกแบบโดยสมมติว่าจะมีตัวจัดสรรเรจิสเตอร์ ดังนั้นหลังจากนั้นจึงคาดว่าโค้ดที่สร้างจะดีขึ้นได้อีกหลายลำดับขั้น

แผนสำหรับคอมไพเลอร์และอินเทอร์พรีเตอร์

  • การติดตั้งใช้งานในปัจจุบันประกอบด้วยโค้ด C++ ประมาณ 10,000 บรรทัด และพอใจกับการที่คอมไพเลอร์มีขนาดเล็กตามมาตรฐานสมัยใหม่และใช้งานได้จริง
  • ตัวจัดสรรรีจิสเตอร์

    • คอมไพเลอร์ IR → Aarch64 ในปัจจุบันจำเป็นต้องมีตัวจัดสรรรีจิสเตอร์
    • มีแผนจะใช้ตัวจัดสรรแบบ linear scan มาตรฐานเพื่อแลกเปลี่ยนระหว่างความเร็วในการคอมไพล์กับคุณภาพของโค้ด
  • การปรับแต่ง IR ให้เหมาะสม

    • ต้องการเพิ่ม constant propagation, arithmetic simplification, dead code elimination, inlining และ loop unrolling บนพื้นฐานของ IR
    • เป้าหมายไม่ใช่การเอาชนะ GCC หรือ LLVM แต่ต้องการให้ฟังก์ชันง่าย ๆ อย่างการบวกเวกเตอร์ 3D ถูกคอมไพล์เป็นคำสั่ง CPU ให้น้อยที่สุดเท่าที่จะเป็นไปได้
  • อินเทอร์พรีเตอร์ IR

    • มีแผนจะเขียนอินเทอร์พรีเตอร์ใหม่ให้เป็นแบบประเมิน IR โดยตรง ซึ่งคาดว่าจะทำให้อินเทอร์พรีเตอร์เรียบง่ายขึ้นมาก
  • การสร้างไฟล์ปฏิบัติการ

    • ปัจจุบันคอมไพเลอร์สร้างได้เพียง JIT memory blob สำหรับรันได้ทันที
    • อยากให้สามารถสร้างไบนารีที่รันได้ตามฟอร์แมตเฉพาะของแต่ละแพลตฟอร์มด้วย จึงต้องไปขุดสเปกฟอร์แมตไบนารีอย่าง ELF, Mach-O และ PE
    • การพยายามสร้างไฟล์ปฏิบัติการให้มีขนาดเล็กที่สุดเท่าที่ทำได้ก็เป็นหนึ่งในเป้าหมายเช่นกัน
  • การดีบัก

    • เคยไล่ดู assembly ที่ JIT สร้างขึ้นใน lldb อยู่มาก และอยากให้สามารถดีบักตัวภาษาเองได้อย่างเหมาะสม
    • เพื่อสิ่งนี้ มีแนวโน้มสูงว่าจะต้องรองรับฟอร์แมตข้อมูลดีบัก DWARF ซึ่งตอนนี้ยังแทบไม่รู้อะไรเกี่ยวกับมันเลย

ฟีเจอร์ของภาษาที่อยากเพิ่ม

  • คอนสตรัคเตอร์ของ struct

    • ตอนนี้ struct สามารถทำได้แค่กำหนดทุกฟิลด์แบบ vec3i(1, 2, 3) หรือกำหนดค่าเริ่มต้นเป็น 0 แบบ vec3i()
    • กำลังพิจารณาวิธีที่ว่าถ้าประกาศฟังก์ชันชื่อเดียวกับ struct ก็ให้มันทำหน้าที่เป็นคอนสตรัคเตอร์แบบกำหนดเองได้
func vec3i(x: i32, y: i32) -> vec3i:
    return vec3i(x, y, 0)
  • แต่อีกใจก็คิดว่าการตั้งชื่อเฉพาะให้ฟังก์ชันเหล่านี้อาจจะดีกว่า จึงยังไม่ตัดสินใจ
  • ตัวแปรโกลบอล

    • ตอนนี้ยังไม่รองรับตัวแปรโกลบอล
    • มีแผนจะสร้างตัวแปรโกลบอลด้วยคีย์เวิร์ด global และการเข้าถึงก็ยังคงถูกจำกัดด้วยกฎของ scope ดังนั้นจึงสามารถสร้างตัวแปรโกลบอลแบบ local ต่อฟังก์ชันได้เหมือนตัวแปร static ใน C
    • ตัวแปรระดับบนสุดจะไม่ใช่โกลบอลจริง เว้นแต่จะใช้ global โดยจะเป็นตัวแปร local ของฟังก์ชัน entry point ของไฟล์แทน
    • โครงสร้างนี้อาจทำให้ผู้ใช้สับสน จึงกำลังพิจารณาตัวเลือกอื่นอยู่ด้วย
    • Mac ไม่อนุญาตให้แมปหน่วยความจำที่เขียนได้และรันได้พร้อมกัน ดังนั้นตัวแปรโกลบอลอาจต้องถูกจัดสรรแยกจากโค้ดและแมปด้วยแฟลกคนละชุด
    • การเข้าถึงโกลบอลอาจต้องทำผ่านแอดเดรสที่ตีความตอนรันไทม์ แทนที่จะใช้ออฟเซ็ตที่รู้ตั้งแต่คอมไพล์ไทม์
    • แต่ดูเหมือนว่าสามารถเปลี่ยนแฟลกของบางส่วนใน mapping ได้ด้วย mprotect() จึงมีแผนจะลองวิธีนั้นก่อน
  • ไวยากรณ์การเรียกเมธอด

    • เพื่อให้อ่านง่ายขึ้น อยากให้เมื่อเป็นไปได้ x.f(y) มีความหมายเป็น f(&x, y) หรือ f(&mut x, y)
  • พหุรูปแบบ

    • มองว่านี่เป็นฟีเจอร์ศักยภาพที่สำคัญที่สุด
    • ตัวเลือกที่มีน้ำหนักมากคือ function overloading แบบ C++ ร่วมกับ function template และ struct template ที่ไม่จำกัด หรือ trait แบบ explicit สไตล์ Haskell/Rust ร่วมกับ generic function และ struct ที่มี trait constraint
    • สไตล์ C++ ทรงพลังกว่า อ่านง่ายกว่าในกรณีที่ไม่ซับซ้อน และคอมไพเลอร์ก็ทำได้ง่ายกว่า แต่อาจทำให้ข้อความ error เข้าใจยากมาก
    • trait แบบ explicit อาจอ่านง่ายกว่าในบางกรณีและช่วยแก้ปัญหาเรื่องข้อความ error แต่ต้องมีระบบใหม่อย่าง trait และ trait bound ทำให้คอมไพเลอร์ยากขึ้น
    • ยังไม่ได้ตัดสินใจ แต่ถึงจะตั้งใจไม่สร้าง C++ ขึ้นมาใหม่ ก็ยังเอนเอียงไปทางตัวเลือกแรกอย่างมาก
struct vec2<t: type>:
    x: t
    y: t

func min<t: type>(x: t, y: t) -> t:
    return if x < y then x else y
  • และยังต้องการให้อนุมานอาร์กิวเมนต์ของฟังก์ชันได้เมื่อเป็นไปได้
  • การโอเวอร์โหลดโอเปอเรเตอร์

    • ไม่ว่าจะแบบไหนก็ต้องอาศัยพหุรูปแบบ
    • a + b อาจกลายเป็นการเรียกฟังก์ชันโอเวอร์โหลดอย่าง add(a, b) หรือเมธอดของ trait อย่าง Add::add
  • ลูป for

    • เนื่องจากเลียนแบบได้ด้วย while อยู่แล้ว จึงตั้งใจให้ for เป็นลูปบนคอลเลกชันคล้าย range-based loop ของ C++ หรือลูปแบบ Python
    • ซึ่งต้องมีอินเทอร์เฟซแบบ range/iterator และสุดท้ายก็ต้องใช้พหุรูปแบบอีก
  • การจัดการทรัพยากรอัตโนมัติ

    • มองว่าภาษาที่ใช้งานได้จริงและใช้ง่ายควรมีวิธีช่วยปลดปล่อยทรัพยากร เช่น หน่วยความจำ ไฟล์ socket และ mutex
    • ตัวเลือกที่เป็นไปได้คือ RAII และ move แบบ C++, defer แบบ Zig และ linear type
    • RAII มีข้อเสียตรงที่เป็นแบบ implicit จึงเพิ่มคำสั่งและ control flow ที่ซ่อนอยู่
    • defer เป็นแบบ explicit แต่ต้องใส่เองทุกครั้ง ไม่ช่วยป้องกันการลืม และไม่สะดวกเมื่อต้องปล่อย nested collection อย่างอาร์เรย์ของไฟล์
defer free(array)
defer for file in array:
    close(file)
  • linear type ดูมีอนาคตเพราะยังคงความชัดเจนของการเรียก free หรือ close ด้วยมือไว้ได้ พร้อมทั้งบังคับให้ object ถูก consume โดยฟังก์ชันปล่อยทรัพยากร
  • แต่เพราะมันผสมกับ nested collection อย่างอาร์เรย์ไฟล์แบบไดนามิกได้ยาก จึงยังไม่ตัดสินใจ
  • ลิเทอรัลแบบพหุรูปแบบ

    • อาร์เรย์ว่าง [] รู้ได้ว่ามีขนาด 0 แต่ไม่สามารถอนุมานชนิดของสมาชิกได้
    • null สามารถเป็น pointer ชนิดใดก็ได้ และลิเทอรัล inf ที่อยากเพิ่มก็สามารถเป็น floating-point ชนิดใดก็ได้
    • แนวทางที่กำลังพิจารณามีสามแบบ: ลิเทอรัลแบบพหุรูปแบบสไตล์ Haskell, ชนิดในตัวหรือชนิดไลบรารีพิเศษพร้อมการแปลงแบบ implicit อย่าง nullptr_t ของ C++, และลิเทอรัลพิเศษใน AST พร้อมการจัดการเฉพาะกิจในคอมไพเลอร์
    • ตอนนี้เอนเอียงไปทางวิธีสุดท้าย ซึ่งอนุญาต null ได้เฉพาะในตำแหน่งที่รู้ชนิด pointer ที่คาดหวัง เช่น การกำหนดค่าเริ่มต้นให้ตัวแปรที่ระบุชนิดชัดเจน หรือการส่งเป็นอาร์กิวเมนต์ของฟังก์ชัน
    • วิธีนี้ง่ายที่สุด แต่ขยายต่อไม่ได้ จึงไม่สามารถสร้างชนิดกำหนดเองจาก null ได้
  • การประเมินค่าตอนคอมไพล์

    • อยากให้สามารถประกาศตัวแปรคอมไพล์ไทม์ด้วยคีย์เวิร์ด const และนำไปใช้ในนิพจน์คอมไพล์ไทม์อย่างขนาดอาร์เรย์ได้
    • ค่า const ไม่สามารถกำหนดค่าใหม่ได้และไม่สามารถนำแอดเดรสไปใช้ได้
    • ฟังก์ชันที่เหมาะสมสามารถถูกเรียกในนิพจน์คอมไพล์ไทม์ได้ หากไม่มีการเข้าถึงตัวแปรโกลบอลหรือผลข้างเคียง
    • เนื้อหาฟังก์ชันจะทำงานเหมือนฟังก์ชันปกติ แต่จะถูกรันระหว่างคอมไพล์และผลลัพธ์จะกลายเป็นนิพจน์คอมไพล์ไทม์
    • จำเป็นต้องมีกลไกสำหรับทำเครื่องหมาย foreign function ที่ปลอดภัยพอจะเรียกระหว่างคอมไพล์ไทม์ได้ เช่น ฟังก์ชันคณิตศาสตร์หรือการจัดสรรหน่วยความจำ
  • การคำนวณชนิด

    • อยากรองรับการคำนวณเกี่ยวกับชนิดเพื่อใช้ทำ metaprogramming
    • ไม่อยากสร้าง encoding ของชนิดรันไทม์ในภาษาที่เป็น static type และประโยชน์ของชนิดรันไทม์ก็มีจำกัด จึงวางแผนให้ใช้ได้เฉพาะตอนคอมไพล์ไทม์
    • มองว่าฟีเจอร์คล้าย C++ concepts ก็น่าจะทำได้ด้วยการเรียกตอนคอมไพล์ไทม์โดยไม่ต้องมีไวยากรณ์แยกต่างหาก
func comparable(t: type) -> bool:
    // Implemented somehow...

func min<t: comparable type>(x: t, y: t) -> t:
    return if x < y then x else y
  • คอร์รูทีน

    • การเพิ่ม async/await สไตล์ Python หรือ JS ตอนนี้ยังใกล้เคียงกับความหวังมากกว่าแผนการจริง

แผนสำหรับไลบรารีและโมดูล

  • โมดูล

    • การเขียนโค้ดทั้งหมดไว้ในไฟล์เดียวเป็นเรื่องไม่ไหว จึงจำเป็นต้องมีโมดูล
    • วางแผนจะใช้คำสั่งง่าย ๆ อย่าง import lib.sublib ซึ่งสามารถวางไว้ตรงไหนในโค้ดก็ได้และยังคงเป็นไปตามกฎของสโคป
    • สโคปมีผลแค่ต่อการมองเห็นเท่านั้น ส่วนการโหลดจริงจะเกิดขึ้นตอนคอมไพล์ไทม์ และ entry point ของโมดูลที่นำเข้าจะถูกรันก่อนโมดูลปัจจุบัน
    • ชื่อไลบรารีจะสอดคล้องโดยตรงกับพาธในระบบไฟล์ โดยอิงจาก root path ที่ระบุให้คอมไพเลอร์หรืออินเทอร์พรีเตอร์
    • ถ้าเป็นไฟล์ซอร์สเดี่ยวก็จะนำเข้าแค่ไฟล์นั้น แต่ถ้าเป็นไดเรกทอรีก็จะนำเข้าทุกไฟล์ในไดเรกทอรีนั้นตามลำดับบางอย่าง
    • จำเป็นต้องมีไวยากรณ์สำหรับอ้างถึงไฟล์ในไดเรกทอรีเดียวกัน และกำลังพิจารณารูปแบบอย่าง import .another
    • ฟังก์ชันและตัวแปรโกลบอลที่นำเข้ามาจะสามารถใช้ได้โดยไม่ต้องมีคำนำหน้า และถ้ากำกวมก็สามารถใส่คำนำหน้าด้วยชื่อไลบรารีอย่าง io.print(x) ได้
    • entry point ของโมดูลมีแผนจะให้รันตามลำดับที่กำหนดได้แน่นอน โดยอิงจากลำดับ import และการจัดเรียงแบบทอพอโลยีของ recursive import ซึ่งอาจช่วยแก้ปัญหาลำดับการเริ่มต้นแบบใน C หรือ C++ ได้
    • การจัดวางหน่วยความจำของโปรแกรมหลายโมดูลยังไม่ได้ตัดสินใจ
    • อาจให้แต่ละโมดูลมี memory patch แยกกัน แล้วตีความการเรียกฟังก์ชันและการเข้าถึงตัวแปรโกลบอลตอนรันไทม์ หรืออาจสร้างเป็น memory mapping ขนาดใหญ่ก้อนเดียวแล้วใช้ relative offset
    • การแมปเป็นก้อนใหญ่ก้อนเดียวอาจเร็วกว่าในรันไทม์ แต่จะทำให้การคอมไพล์หลายโมดูลแบบขนานยากขึ้น
  • Prelude

    • เมื่อมีโมดูลแล้ว ก็สามารถใส่ยูทิลิตีพื้นฐานไว้ในโมดูล prelude ที่ถูกรวมเข้ามาแบบปริยายในทุกโปรแกรมได้
    • ตัวเลือกที่เป็นไปได้ ได้แก่ ฟังก์ชัน length() สำหรับอาร์เรย์แบบ built-in, อินเทอร์เฟซ iterator, ชนิด string_view และ numeric range แบบ range(n) ของ Python
  • สตริงลิเทอรัล

    • ตอนนี้ยังไม่มีสตริงลิเทอรัล และยังตัดสินใจไม่ได้ว่ามันควรมีความหมายอย่างไร
    • แผนคือมีชนิด string_view แบบ immutable อยู่ใน prelude, วางเนื้อหาสตริงไว้ที่ไหนสักแห่งในหน่วยความจำที่รันได้ และแปลงลิเทอรัลนั้นเองให้เป็น string_view ที่ชี้ไปยังหน่วยความจำนั้น
  • ไลบรารีมาตรฐาน

    • เมื่อมีโมดูลแล้ว ก็จำเป็นต้องมีไลบรารีมาตรฐานด้วย
    • ขอบเขตที่อยากรวมไว้คือไลบรารีคณิตศาสตร์ที่มีทั้งเวกเตอร์และเมทริกซ์, การจัดการหน่วยความจำในรูปแบบ alloc/free ที่ลิงก์มาจาก libc, อาร์เรย์แบบไดนามิก, สตริงแบบไดนามิกและการฟอร์แมต, hash table, console และ file IO, ตัวช่วยสำหรับ filesystem, ตัวช่วยด้านเวลาและนาฬิกา, และเครือข่าย

ลำดับความสำคัญในปัจจุบัน

  • ยังไม่ได้กำหนดว่าจะนำฟีเจอร์ที่วางแผนไว้มาใช้เมื่อไร หรือจะนำภาษานี้ไปใช้จริงกับการม็อดเกมหรือจุดประสงค์อื่นหรือไม่
  • ผู้เขียนมองว่าการเดินหน้าโปรเจกต์ที่ทะเยอทะยานหลายโปรเจกต์พร้อมกันอย่างจริงจังไม่ใช่เรื่องดี และลำดับความสำคัญตอนนี้ก็ยังคงเป็นการพัฒนาเกม
  • เพราะถ้ายังไม่มีเกม ก็ยังไม่มีอะไรให้ม็อดได้ งานพัฒนาภาษาจึงทำไปตามจังหวะที่อยากทำ

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

 
GN⁺ 4 시간 전
ความคิดเห็นจาก Lobste.rs
  • คอมเมนต์ที่นี่ให้ความรู้สึกว่า รุนแรง กว่าที่คาดจากคอมมูนิตี้นี้มาก
    เป็นไปได้ว่าใช้ภาษาอื่นอย่าง Lua ก็น่าจะพอแล้ว ผู้เขียนอาจกำลังจมอยู่กับการทำ yak shaving ครั้งใหญ่ก็ได้
    ถึงอย่างนั้นก็ชัดเจนว่าเขามีฝีมือมากและสนุกกับมันมากจริง ๆ และในบทความก็มีเนื้อหาเชิงเทคนิคที่น่าสนใจอยู่ด้วย
    ถ้าเป็นบทความของเพื่อนเนิร์ดที่ออกแบบภาษาสคริปต์สำหรับเกมเอนจินขึ้นมาอีกตัวหนึ่ง ฉันก็ยินดีอ่านอย่างเพลิดเพลิน ถ้าจะได้เลี่ยงบทความขยะที่ AI สร้างเกี่ยวกับ SaaS ห่วย ๆ ที่ทำด้วย vibecoding ซึ่งอ้างว่าจะช่วยโลกและทำให้ผู้เขียนรวยได้ แบบนั้นฉันอ่านบทความแนวนี้วันละพันชิ้นก็ยังไหว

  • ข้ออ้างว่า “Lua หรือภาษาสคริปต์แบบ JIT คอมไพล์อื่น ๆ เป็นตัวเลือกมาตรฐาน แต่การทำ sandbox นั้นยากมาก” เป็นสิ่งที่เข้าใจได้ยากจริง ๆ
    การทำ sandbox ของ Lua นั้นง่าย เป็นหนึ่งในข้อดีที่ใหญ่ที่สุดของมัน และให้ประโยชน์มากกว่ากรณี mod หรือ plugin เสียอีก เท่าที่ฉันเห็นยังไม่มีภาษาไหนเข้าใกล้เรื่องนี้ได้เลย

    • ทั้งย่อหน้านั้นอ่านแล้วเหมือน “ฉันเคยอ่านเกี่ยวกับภาษานี้มาบ้าง แต่ถึงมันจะเป็นตัวเลือกมาตรฐานมา 20 ปีแล้ว ฉันก็ไม่คิดจะเสียเวลาศึกษาสักไม่กี่ชั่วโมง”
      เรื่อง ปัญหาเวอร์ชันของ Lua ก็พอมีเหตุผลอยู่บ้าง แต่ในทางปฏิบัติฉันแทบไม่เห็นคนเดือดร้อนหนักกับมันนัก เว้นแต่ว่าคุณใช้ Lua แบบ “สมัยใหม่” กับงานบางอย่างแล้วต้องถอยกลับไปใช้ 5.1/5.2 เพราะงานอีกชิ้นหนึ่ง ดูเหมือนคนส่วนใหญ่จะใช้แค่อย่างใดอย่างหนึ่ง
    • การบอกว่าตัวเลือกที่ “พบได้ทั่วไป” มีแค่ Lua กับ C++ ตั้งแต่แรกก็ค่อนข้างแปลก เหมือนกับว่ามีภาษาอยู่แค่สองประเภทเท่านั้น
      มันให้ความรู้สึกแรงมากว่าไปค้นมาเพื่อหาเหตุผลมาสนับสนุนว่า “ฉันอยากสร้างภาษาของตัวเอง” ซึ่งก็ไม่เป็นไร แต่ถ้าจะทำแบบนั้น การพูดตรง ๆ ยังดีกว่าการกล่าวอ้างผิด ๆ เกี่ยวกับตัวเลือกที่มีอยู่
    • อีกจุดที่สะดุดในบทความนี้คือ ถ้าอยากเรียนรู้การออกแบบภาษา วิธีที่ดีกว่ามากคือเขียนคอมไพเลอร์สำหรับภาษาโฮสต์ที่ทำงานบน virtual machine หรือ runtime ที่มีอยู่แล้ว แทนที่จะไล่ลงไปทำตั้งแต่พื้นล่างสุด
      ถ้าคุณสนใจการออกแบบ virtual machine หรือส่วนที่ต่ำกว่านั้น วิธีที่บทความอธิบายก็แน่นอนว่ายังทำได้ แต่ก็ยังห่างไกลจากวิธีที่ดีที่สุดในการเรียนรู้ การออกแบบภาษา
    • เกมที่สร้างโดยโปรแกรมเมอร์ฝีมือดีก็เคยเจอการหลุดออกจาก Lua sandbox มาพอสมควร เช่น Factorio, Binding of Isaac และถ้าจะมอง cloud programming ว่าเป็นเกมประหลาดที่ทุกคนแพ้ ~~Redis~~ ก็รวมอยู่ด้วย จึงน่าสงสัยว่ามีบางอย่างผิดปกติกับวิธีนำเสนอ API หรือไม่
      ตัวอย่างที่ง่ายที่สุดคือ การหลุดผ่าน bytecode ซึ่งถ้ารู้ว่ามันมีอยู่ก็ปิดใช้งานได้ แต่ความจริงที่ว่าเรื่องแบบนี้เกิดซ้ำ ๆ แสดงให้เห็นปัญหาที่กว้างกว่านั้น คุณต้องเข้าใจว่าข้อกำหนดของ Lua ที่อยู่กระจัดกระจายคนละส่วนมาปฏิสัมพันธ์กันอย่างไร แล้วค่อยประกอบกฎ sandbox จากตรงนั้น มันไม่ได้ถูกออกแบบมาให้คุณสามารถประกอบโปรแกรมอย่างปลอดภัยจากองค์ประกอบพื้นฐานที่ชัดเจนว่าอนุญาตให้เกิดปฏิสัมพันธ์เพิ่มเติมอะไรได้บ้าง
      อีกตัวอย่างที่ฝืนกว่านั้นคือการปนเปื้อนของ prototype ระหว่าง environment ต่าง ๆ ภายใน Lua VM เดียวกัน ใน Redis นั้นสามารถปนเปื้อน metatable ของ string ได้ และจากนั้นก็สามารถรันโค้ดด้วยสิทธิ์ของผู้ใช้ฐานข้อมูลคนอื่นที่ใช้ฟีเจอร์ Lua ได้ Lua มีพื้นผิวให้เกิดการปนเปื้อน prototype น้อยกว่าอะไรอย่าง JavaScript แบบเทียบกันไม่ติด แต่ก็ตลกดีที่ทั้งที่มี global prototype อยู่ประมาณแค่ 2 ตัว ก็ยังทำเรื่องแบบเดียวกันได้ด้วยหนึ่งในนั้น
      ถึงอย่างนั้น Luau ก็มีวิธีแก้ปัญหานี้ที่ค่อนข้างเก่ง และฉันก็ไม่ค่อยเข้าใจว่าทำไมผู้เขียนถึงคิดว่าถ้าสร้าง sandbox ใหม่เองแล้วจะหลีกเลี่ยงปัญหาแบบเดียวกันทั้งหมดได้โดยปริยาย
  • ส่วนที่บอกว่า “เกมของฉันเน้นการจำลองอย่างมาก ฉันจำลองเอนทิตีหลายแสนตัวด้วย custom ECS engine ในอุดมคติแล้ว ภาษาสำหรับม็อดควรรับ component pointer หลายตัวแล้ววนลูปได้เหมือน C for loop” นั้นน่าจะตั้งอุดมคติให้ดีกว่านี้ได้
    โดยเฉพาะอย่างยิ่ง ควรลองเทียบดูว่าเอนจินเรนเดอร์อย่าง Unity, Unreal, Blender, Godot จัดการปัญหานี้กันอย่างไร การวนลูปจากภายนอกอาจเร็วไม่พอสำหรับการพูดถึงระดับเมกะพิกเซลต่อวินาที และอาจไม่เหมาะกับเอนทิตีหลายแสนตัวต่อวินาทีด้วย ตรงนี้ต้องคิดเรื่อง parallelism
    เอนจินใหญ่ ๆ ทั้งหมดเป็นมิตรกับ GPU และโดยมากใช้การบรรยาย dataflow ของอัลกอริทึมแบบไม่มี branch ที่ ขนานได้อย่างน่าตกใจ ผู้เขียนอาจไม่ชอบ visual editor และความคิดแบบนั้นก็พบได้ทั่วไป แต่ก็ไม่ได้แปลว่า for loop คือคำตอบ
    ถ้าผู้เขียนพูดถึงว่า ECS มีแก่นแท้เป็นกระบวนทัศน์เชิงสัมพันธ์ และภาษาที่มีภาระทางประวัติศาสตร์มากซึ่งควรใช้เป็นตัวเทียบคือ SQL ฉันอาจจะมองอย่างใจกว้างกว่านี้หน่อย