ภาพวาดโทนมิ้นต์สว่าง อิกัวนาตัวเล็กนั่งอยู่หน้าแท่นพิมพ์ไม้ กำลังเรียงตัวพิมพ์ลงบล็อกเพื่อทำแม่พิมพ์ไว้ล่วงหน้า มีแผ่นที่พิมพ์เสร็จแล้วเรียงซ้อนอยู่ข้าง ๆ

Zig 0.16 บทที่ 5: comptime กลไกเดียวที่แทน macro, template และ reflection ทั้งหมด

พี่โชว์Engineering

ภาษาส่วนใหญ่มีเครื่องมือหลายชิ้นสำหรับ “เขียนโค้ดที่สร้างโค้ด” — 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 เฉย ๆ