- 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 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
คอมเมนต์ที่นี่ให้ความรู้สึกว่า รุนแรง กว่าที่คาดจากคอมมูนิตี้นี้มาก
เป็นไปได้ว่าใช้ภาษาอื่นอย่าง Lua ก็น่าจะพอแล้ว ผู้เขียนอาจกำลังจมอยู่กับการทำ yak shaving ครั้งใหญ่ก็ได้
ถึงอย่างนั้นก็ชัดเจนว่าเขามีฝีมือมากและสนุกกับมันมากจริง ๆ และในบทความก็มีเนื้อหาเชิงเทคนิคที่น่าสนใจอยู่ด้วย
ถ้าเป็นบทความของเพื่อนเนิร์ดที่ออกแบบภาษาสคริปต์สำหรับเกมเอนจินขึ้นมาอีกตัวหนึ่ง ฉันก็ยินดีอ่านอย่างเพลิดเพลิน ถ้าจะได้เลี่ยงบทความขยะที่ AI สร้างเกี่ยวกับ SaaS ห่วย ๆ ที่ทำด้วย vibecoding ซึ่งอ้างว่าจะช่วยโลกและทำให้ผู้เขียนรวยได้ แบบนั้นฉันอ่านบทความแนวนี้วันละพันชิ้นก็ยังไหว
ข้ออ้างว่า “Lua หรือภาษาสคริปต์แบบ JIT คอมไพล์อื่น ๆ เป็นตัวเลือกมาตรฐาน แต่การทำ sandbox นั้นยากมาก” เป็นสิ่งที่เข้าใจได้ยากจริง ๆ
การทำ sandbox ของ Lua นั้นง่าย เป็นหนึ่งในข้อดีที่ใหญ่ที่สุดของมัน และให้ประโยชน์มากกว่ากรณี mod หรือ plugin เสียอีก เท่าที่ฉันเห็นยังไม่มีภาษาไหนเข้าใกล้เรื่องนี้ได้เลย
เรื่อง ปัญหาเวอร์ชันของ Lua ก็พอมีเหตุผลอยู่บ้าง แต่ในทางปฏิบัติฉันแทบไม่เห็นคนเดือดร้อนหนักกับมันนัก เว้นแต่ว่าคุณใช้ Lua แบบ “สมัยใหม่” กับงานบางอย่างแล้วต้องถอยกลับไปใช้ 5.1/5.2 เพราะงานอีกชิ้นหนึ่ง ดูเหมือนคนส่วนใหญ่จะใช้แค่อย่างใดอย่างหนึ่ง
มันให้ความรู้สึกแรงมากว่าไปค้นมาเพื่อหาเหตุผลมาสนับสนุนว่า “ฉันอยากสร้างภาษาของตัวเอง” ซึ่งก็ไม่เป็นไร แต่ถ้าจะทำแบบนั้น การพูดตรง ๆ ยังดีกว่าการกล่าวอ้างผิด ๆ เกี่ยวกับตัวเลือกที่มีอยู่
ถ้าคุณสนใจการออกแบบ virtual machine หรือส่วนที่ต่ำกว่านั้น วิธีที่บทความอธิบายก็แน่นอนว่ายังทำได้ แต่ก็ยังห่างไกลจากวิธีที่ดีที่สุดในการเรียนรู้ การออกแบบภาษา
ตัวอย่างที่ง่ายที่สุดคือ การหลุดผ่าน 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 ฉันอาจจะมองอย่างใจกว้างกว่านี้หน่อย