
Zig 0.16 บทที่ 5: comptime กลไกเดียวที่แทน macro, template และ reflection ทั้งหมด
ภาษาส่วนใหญ่มีเครื่องมือหลายชิ้นสำหรับ “เขียนโค้ดที่สร้างโค้ด” — C มี preprocessor, C++ มี template กับ constexpr กับ macro, Rust มี generics กับ declarative macro กับ procedural macro, Java/C# มี generics กับ annotation กับ reflection ตอนรัน
Zig มีชิ้นเดียว ชื่อ comptime และมันคือ ภาษา Zig ตัวเดิมนั่นแหละ แต่รันตอนคอมไพล์
ไม่มีไวยากรณ์ชุดพิเศษให้เรียนเพิ่ม ไม่มีภาษา macro ซ้อนอยู่ข้างใน วนลูป for แบบเดิม เขียน if แบบเดิม เรียกฟังก์ชันแบบเดิม ต่างแค่ผลลัพธ์ถูกคำนวณเสร็จตั้งแต่ก่อนโปรแกรมเริ่มทำงาน
เริ่มจากของง่ายที่สุด: คำนวณตอนคอมไพล์
const std = @import("std");
fn fib(n: u32) u64 {
return if (n < 2) n else fib(n - 1) + fib(n - 2);
}
const fib30 = blk: {
@setEvalBranchQuota(10_000_000);
break :blk fib(30);
};
test "คำนวณตอนคอมไพล์" {
// ค่านี้ถูกคำนวณเสร็จตั้งแต่ตอนคอมไพล์ ตอนรันแค่หยิบมาใช้
try std.testing.expectEqual(@as(u64, 832040), fib30);
comptime {
std.debug.assert(fib(10) == 55);
}
}
fib เป็นฟังก์ชันธรรมดา ไม่ได้ทำเครื่องหมายอะไรเป็นพิเศษ แต่เมื่อเรียกมันในบริบทที่ต้องรู้ค่าตั้งแต่คอมไพล์ (เช่นกำหนดค่าให้ const ระดับไฟล์) Zig จะรันมันให้เลย binary ที่ได้มีแค่เลข 832040 ไม่มีโค้ดคำนวณ fib เหลืออยู่
comptime { std.debug.assert(...) } คือการตรวจสอบตอนคอมไพล์ ถ้าเงื่อนไขไม่จริง คอมไพล์ไม่ผ่าน ไม่ใช่รอไปพังตอนรัน
@setEvalBranchQuota — กำแพงที่ทุกคนจะชน
ค่าเริ่มต้นคือ Zig ยอมให้โค้ด comptime วนย้อนกลับได้ 1000 ครั้ง เกินนั้นคอมไพเลอร์หยุดพร้อมข้อความ
ผมชนกำแพงนี้ตอนเขียนตัวอย่างข้างบนจริง ๆ ข้อความที่ได้คือ:
error: evaluation exceeded 1000 backwards branches
return if (n < 2) n else fib(n - 1) + fib(n - 2);
~~~^~~~~~~
note: use @setEvalBranchQuota() to raise the branch limit from 1000
กำแพงนี้มีไว้กันคุณเขียนลูปไม่รู้จบแล้วคอมไพเลอร์ค้าง แก้ด้วยการบอกไปตรง ๆ ว่ายอมให้ทำงานหนักแค่ไหน แต่จำไว้ว่า ทุกอย่างที่คำนวณตอนคอมไพล์คือเวลาคอมไพล์ที่เพิ่มขึ้น ของฟรีไม่มีในโลก แค่ย้ายไปจ่ายคนละเวลา
สร้างตารางล่วงหน้าตั้งแต่ตอนคอมไพล์
นี่คือการใช้งานที่คุ้มที่สุดของ comptime — ตารางที่เคยต้องคำนวณตอนโปรแกรมเริ่ม กลายเป็นข้อมูลนิ่ง ๆ ที่ฝังอยู่ใน binary เลย
const std = @import("std");
const crc_table = blk: {
@setEvalBranchQuota(20000);
var table: [256]u32 = undefined;
for (&table, 0..) |*entry, i| {
var c: u32 = @intCast(i);
for (0..8) |_| {
c = if (c & 1 != 0) 0xEDB88320 ^ (c >> 1) else c >> 1;
}
entry.* = c;
}
break :blk table;
};
test "ตาราง CRC ที่สร้างตอนคอมไพล์" {
try std.testing.expectEqual(@as(u32, 0), crc_table[0]);
try std.testing.expectEqual(@as(u32, 0x77073096), crc_table[1]);
}
ในภาษา C คุณมีสองทาง: เขียนตัวเลข 256 ตัวลงไปในซอร์ส (แก้ยาก อ่านไม่รู้เรื่อง) หรือคำนวณตอนโปรแกรมเริ่ม (เสียเวลาตอนบูตทุกครั้ง) Zig ให้ทางที่สาม — เขียนเป็นลูปที่อ่านรู้เรื่อง แล้วได้ผลเป็นข้อมูลนิ่งเหมือนพิมพ์เองทุกตัว
Generic: ฟังก์ชันที่คืน type
Zig ไม่มีไวยากรณ์ <T> แบบภาษาอื่น เพราะ type เป็นค่าธรรมดาที่ส่งไปมาได้ตอนคอมไพล์ generic เลยเป็นแค่ฟังก์ชันที่รับ type แล้วคืน type
เอกสารทางการเขียนไว้ตรง ๆ ว่า “โครงสร้างข้อมูลแบบ generic ก็คือฟังก์ชันที่คืน type”
const std = @import("std");
fn Stack(comptime T: type) type {
return struct {
items: std.ArrayList(T) = .empty,
const Self = @This(); // @This() = ชนิดที่กำลังประกาศอยู่นี้
fn push(self: *Self, gpa: std.mem.Allocator, v: T) !void {
try self.items.append(gpa, v);
}
fn pop(self: *Self) ?T {
return self.items.pop();
}
fn deinit(self: *Self, gpa: std.mem.Allocator) void {
self.items.deinit(gpa);
}
};
}
test "generic container" {
const gpa = std.testing.allocator;
var s: Stack(u32) = .{};
defer s.deinit(gpa);
try s.push(gpa, 1);
try s.push(gpa, 2);
try std.testing.expectEqual(@as(?u32, 2), s.pop());
// เรียกด้วย type เดิม ได้ type เดิมเป๊ะ ๆ (Zig จำผลลัพธ์ไว้)
try std.testing.expect(Stack(u32) == Stack(u32));
try std.testing.expect(Stack(u32) != Stack(u8));
}
Stack(u32) กับ Stack(u8) เป็นคนละชนิดกันจริง ๆ แต่ละตัวถูกสร้างโค้ดแยกกันเหมือนคุณเขียนเองสองรอบ — เหมือน template ของ C++ และต่างจาก generics ของ Java ที่ลบข้อมูลชนิดทิ้งตอนรัน
ข้อดีคือไม่มีค่าใช้จ่ายตอนรันเลย ข้อเสียคือใช้กับหลายชนิดมาก ๆ แล้ว binary จะโตและคอมไพล์นานขึ้น
anytype: ตรวจสอบตอนคอมไพล์แบบไม่ต้องประกาศเงื่อนไข
เอกสาร Zig เรียกวิธีนี้ว่า “compile-time duck typing” — ฟังก์ชันรับอะไรก็ได้ที่ใช้งานได้จริง ถ้าใช้ไม่ได้ก็คอมไพล์ไม่ผ่าน
const std = @import("std");
fn sumAll(items: anytype) @TypeOf(items[0]) {
var total: @TypeOf(items[0]) = 0;
for (items) |v| total += v;
return total;
}
test "anytype" {
try std.testing.expectEqual(@as(u32, 6), sumAll([_]u32{ 1, 2, 3 }));
try std.testing.expectEqual(@as(f64, 1.5), sumAll([_]f64{ 0.5, 1.0 }));
}
เทียบกับภาษาอื่น: Rust ต้องประกาศ trait bound (T: Add<Output = T>), Java/C# ต้องมี interface, C++ รุ่นใหม่ใช้ concept ส่วน Zig ไม่ต้องประกาศอะไร — ถ้าเนื้อในทำงานได้ก็ผ่าน
ข้อดีคือเขียนเร็วและยืดหยุ่นสุด ข้อเสียคือ ข้อความ error ตอนใช้ผิดจะชี้ไปที่เนื้อในของฟังก์ชัน ไม่ใช่จุดที่คุณเรียก ซึ่งอ่านยากกว่าภาษาที่ประกาศเงื่อนไขไว้ล่วงหน้า ถ้าเขียนไลบรารีให้คนอื่นใช้ ควรตรวจเงื่อนไขเองด้วย @compileError เพื่อให้ข้อความชัด
Reflection: อ่านโครงสร้างของ type แล้วสร้างโค้ดจากมัน
ตรงนี้คือจุดที่ comptime ทำสิ่งที่ภาษาอื่นต้องใช้ annotation processor, code generator หรือ reflection ตอนรัน
const std = @import("std");
const LogLine = struct {
ip: []const u8,
status: u16,
bytes: u64,
};
fn fieldNames(comptime T: type) []const [:0]const u8 {
// ตัวช่วยนี้ใช้ได้ทั้ง 0.16 และ 0.17 แม้โครงสร้างภายในของ @typeInfo จะเปลี่ยน
return std.meta.fieldNames(T);
}
fn writeAsTsv(w: *std.Io.Writer, value: anytype) !void {
const T = @TypeOf(value);
// inline for = คลี่ลูปออกตอนคอมไพล์ ได้โค้ดตรง ๆ ไม่มีลูปเหลือตอนรัน
inline for (comptime fieldNames(T), 0..) |field_name, i| {
if (i != 0) try w.writeByte('\t');
const v = @field(value, field_name);
switch (@typeInfo(@FieldType(T, field_name))) {
.pointer => try w.print("{s}", .{v}),
else => try w.print("{d}", .{v}),
}
}
}
test "อ่านโครงสร้าง struct แล้วสร้างโค้ด" {
const names = comptime fieldNames(LogLine);
try std.testing.expectEqual(@as(usize, 3), names.len);
try std.testing.expectEqualStrings("status", names[1]);
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try writeAsTsv(&w, LogLine{ .ip = "10.0.0.1", .status = 200, .bytes = 512 });
try std.testing.expectEqualStrings("10.0.0.1\t200\t512", w.buffered());
}
writeAsTsv เขียนครั้งเดียว ใช้ได้กับทุก struct และถ้าวันหนึ่งมีคนเพิ่มฟิลด์ใน LogLine โค้ดนี้รองรับให้เองทันทีโดยไม่ต้องแก้
inline for ต่างจาก for ธรรมดาตรงที่มันคลี่ลูปออกตั้งแต่ตอนคอมไพล์ ผลคือ binary ที่ได้เหมือนคุณเขียน w.print สามบรรทัดเรียงกันเอง — ไม่มีลูป ไม่มี reflection ตอนรัน ไม่มีค่าใช้จ่ายอะไรเลย ต่างจาก Java/C# ที่ reflection ต้องจ่ายตอนรันทุกครั้ง
ตัวอย่างใช้ std.meta.fieldNames กับ @FieldType เป็นสะพานที่รันผ่านทั้ง Zig 0.16 และ 0.17 เพราะ 0.17 เปลี่ยนข้อมูลภายในของ @typeInfo(...).@"struct" จากรายการ fields เป็นอาร์เรย์ field_names/field_types แยกกัน หลักการ comptime เดิมไม่เปลี่ยน แต่การอ้างโครงสร้างภายในตรง ๆ จะผูกโค้ดกับ compiler version มากกว่า
@compileError: หยุดตั้งแต่คอมไพล์ พร้อมข้อความที่คุณเขียนเอง
const std = @import("std");
const LogLine = struct { ip: []const u8, status: u16, bytes: u64 };
fn getField(value: anytype, comptime name: []const u8) @TypeOf(@field(value, name)) {
if (!@hasField(@TypeOf(value), name)) {
@compileError("ไม่มีฟิลด์ชื่อ " ++ name ++ " ใน " ++ @typeName(@TypeOf(value)));
}
return @field(value, name);
}
test "ตรวจตั้งแต่คอมไพล์" {
const l = LogLine{ .ip = "1.1.1.1", .status = 404, .bytes = 0 };
try std.testing.expectEqual(@as(u16, 404), getField(l, "status"));
}
ถ้ามีใครเรียก getField(l, "statuz") จะได้ข้อความว่า ไม่มีฟิลด์ชื่อ statuz ใน LogLine ตอนคอมไพล์ ไม่ใช่ตอนรัน
หลักคิดของ Zig ข้อหนึ่งที่พิมพ์อยู่ในคำสั่ง zig zen คือ “Compile errors are better than runtime crashes” — และ @compileError คือเครื่องมือที่ให้คุณทำแบบนั้นกับตรรกะของตัวเอง
@Int และเพื่อน ๆ: ของใหม่ที่มาแทน @Type ใน 0.16
ก่อน 0.16 การสร้าง type จากข้อมูลใช้ @Type ตัวเดียวรับ union ก้อนใหญ่ 0.16 แยกออกเป็นหลาย builtin ตามชนิดที่จะสร้าง: @Int, @Struct, @Union, @Enum, @Fn, @Pointer, @Tuple, @EnumLiteral
const std = @import("std");
fn UIntFor(comptime max: comptime_int) type {
return @Int(.unsigned, std.math.log2_int_ceil(u64, max + 1));
}
test "@Int สร้างชนิดจำนวนเต็มตามขนาดที่ต้องการ" {
try std.testing.expectEqual(@as(u16, 3), @bitSizeOf(UIntFor(7))); // 0..7 พอดี 3 บิต
try std.testing.expectEqual(@as(u16, 10), @bitSizeOf(UIntFor(1000))); // 10 บิต
}
นี่คือหนึ่งในการเปลี่ยนแปลงที่ทำให้ตัวอย่าง metaprogramming เก่าทุกชิ้นใช้ไม่ได้ ถ้าเจอโค้ดที่เขียน @Type(.{ .int = ... }) หรือ std.meta.Int(...) แปลว่าเขียนไว้ก่อน 0.16
StaticStringMap: ตารางข้อความที่ไม่มีค่าใช้จ่ายตอนเริ่มโปรแกรม
ตัวนี้เป็นตัวอย่างที่สวยที่สุดของ comptime ใน standard library — map จากข้อความไปเป็นค่า ที่สร้างเสร็จตั้งแต่ตอนคอมไพล์
const std = @import("std");
const Severity = enum { info, warn, err };
const severity_map = std.StaticStringMap(Severity).initComptime(.{
.{ "INFO", .info },
.{ "WARN", .warn },
.{ "ERROR", .err },
});
test "StaticStringMap" {
try std.testing.expectEqual(Severity.warn, severity_map.get("WARN").?);
try std.testing.expect(severity_map.get("TRACE") == null);
try std.testing.expectEqual(@as(usize, 3), severity_map.kvs.len);
}
ไม่ต้องขอหน่วยความจำ ไม่ต้อง deinit ไม่ต้องสร้างตอนโปรแกรมเริ่ม เพราะทุกอย่างเป็นข้อมูลนิ่งใน binary แล้ว เทียบกับ Java ที่ต้องมี static final Map แล้วเติมค่าใน static block ตอนคลาสถูกโหลด
สิ่งที่ comptime ทำไม่ได้
comptime ไม่ใช่เวทมนตร์ ข้อจำกัดที่ควรรู้ตั้งแต่ต้น:
- เรียกฟังก์ชันภายนอกไม่ได้ โค้ดที่ต้องเรียกไลบรารี C ตอนคอมไพล์จะได้ error ว่า
comptime call of extern function - ไม่มี allocator ตอนคอมไพล์ ต่อ array ต้องใช้
++ซึ่งสร้างก้อนใหม่ทุกครั้ง - อ่านไฟล์ไม่ได้ ยกเว้นผ่าน
@embedFileและ@importที่ภาษาเตรียมไว้ให้ - มีเพดานจำนวนสาขา ตามที่เห็นข้างบน
- ยิ่งใช้เยอะ ยิ่งคอมไพล์นาน ข้อนี้จริงและวัดได้
สรุปบทนี้
comptimeคือภาษา Zig ตัวเดิมที่รันตอนคอมไพล์ — ไม่มีภาษา macro ซ้อน ไม่มีไวยากรณ์ชุดใหม่- ค่าที่คำนวณตอนคอมไพล์ไม่เหลือโค้ดคำนวณใน binary
- generic = ฟังก์ชันที่รับ type แล้วคืน type และแต่ละชนิดถูกสร้างโค้ดแยกกันจริง
anytypeคือ duck typing ตอนคอมไพล์ — ยืดหยุ่นสุด แต่ error อ่านยากกว่า@typeInfo+inline forทำ reflection ที่ ไม่มีค่าใช้จ่ายตอนรัน ต่างจาก Java/C#@compileErrorทำให้ตรรกะของคุณฟ้องได้ตั้งแต่คอมไพล์@Typeถูกแทนด้วย@Int,@Struct,@Enumและเพื่อน ๆ ใน 0.16@setEvalBranchQuotaคือกำแพงที่ทุกคนจะชน และ comptime แลกด้วยเวลาคอมไพล์เสมอ
จบพื้นฐานเท่านี้ บทหน้าเข้าเรื่องที่แยก Zig ออกจากภาษาอื่นชัดที่สุด — ระบบ error ที่ไม่มี exception ไม่มีการคลี่ stack และคอมไพเลอร์ไม่ยอมให้คุณเมิน error เฉย ๆ
Navigation
- ก่อนหน้า: บทที่ 4: I/O และข้อความในโลกของ Zig 0.16
- ถัดไป: บทที่ 6: error handling ที่เมินไม่ได้
- ดูทั้งชุด: บทความแท็ก zig