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

Zig 0.16 บทที่ 3: struct, enum, union และคอนเทนเนอร์ที่เปลี่ยนไปทั้งหมด

พี่โชว์Engineering

สองบทแรกเราจัดการชนิดข้อมูลเดี่ยว ๆ ไปแล้ว บทนี้เอามาประกอบเป็นก้อน

Zig มีเครื่องมือประกอบข้อมูลอยู่สามตัว — struct (มีทุกฟิลด์พร้อมกัน), enum (เป็นได้ค่าเดียวจากรายการ) และ union (เป็นได้แบบเดียวจากหลายแบบ) ไม่มี class ไม่มี inheritance ไม่มี interface ในภาษา ซึ่งฟังดูน้อยจนน่ากลัวถ้ามาจาก Java หรือ C# แต่พอใช้จริงจะพบว่ามันครอบคลุมเกือบทุกอย่างที่เคยทำ

ท้ายบทจะพูดถึงคอนเทนเนอร์ของ 0.16 ซึ่ง เปลี่ยนวิธีใช้ไปจากทุกเวอร์ชันก่อนหน้า และเป็นจุดที่โค้ดเก่าพังมากที่สุดจุดหนึ่ง


struct: ข้อมูลกับพฤติกรรมอยู่ด้วยกันได้ แต่ไม่มี inheritance

const std = @import("std");

const Counter = struct {
    hits: u32,
    label: []const u8,

    // ค่าคงที่ประจำชนิด เรียกใช้ได้เป็น .empty
    const empty: Counter = .{ .hits = 0, .label = "" };

    // ฟังก์ชันสร้าง เรียกใช้ได้เป็น .init("...")
    fn init(label: []const u8) Counter {
        return .{ .hits = 0, .label = label };
    }

    // รับ *Counter = แก้ค่าตัวเองได้
    fn bump(self: *Counter, n: u32) void {
        self.hits += n;
    }

    // รับ Counter เฉย ๆ = อ่านอย่างเดียว
    fn rate(self: Counter, total: u32) f64 {
        if (total == 0) return 0;
        return @as(f64, @floatFromInt(self.hits)) / @as(f64, @floatFromInt(total));
    }
};

test "struct, method และ decl literal" {
    var c: Counter = .init("/index.html");
    c.bump(3);
    c.bump(1);

    try std.testing.expectEqual(@as(u32, 4), c.hits);
    try std.testing.expectEqual(@as(f64, 0.5), c.rate(8));

    const zero: Counter = .empty;
    try std.testing.expectEqual(@as(u32, 0), zero.hits);
}

สังเกตสามเรื่อง

เรื่องแรก — self เป็นพารามิเตอร์ธรรมดา ไม่ใช่คำสงวน ฟังก์ชันที่จะแก้ค่าต้องรับ *Counter ส่วนที่แค่อ่านรับ Counter ก็พอ พออ่าน signature ก็รู้ทันทีว่าฟังก์ชันนี้แก้ข้อมูลไหม ไม่ต้องเดาแบบภาษาที่ทุกเมธอดแก้ได้หมด

เรื่องที่สอง — .init(...) และ .empty คือ decl literal เมื่อ Zig รู้อยู่แล้วว่าฝั่งซ้ายเป็นชนิดอะไร คุณละชื่อชนิดได้ เขียน var c: Counter = .init("/x") แทน Counter.init("/x") ท่านี้เจอทั่ว standard library ของ 0.16 — .empty, .init, .limited(n) ที่เห็นในบทก่อนก็เรื่องเดียวกัน

เรื่องที่สาม — ไม่มี inheritance ไม่มี extends ไม่มี virtual method ไม่มี super ถ้าต้องการพฤติกรรมหลายแบบใช้ tagged union (เดี๋ยวถึง) หรือถ้าต้องการให้ขยายได้จริง ๆ ใช้รูปแบบ interface แบบที่ std.mem.Allocator ใช้ ซึ่งจะเล่าละเอียดในบทเรื่องหน่วยความจำ


enum: switch ต้องครบทุกกรณี ไม่งั้นคอมไพล์ไม่ผ่าน

const std = @import("std");

const Method = enum {
    get,
    post,
    delete,

    fn isSafe(self: Method) bool {
        return switch (self) {
            .get => true,
            .post, .delete => false,
        };
    }
};

test "enum" {
    const m: Method = .post;

    try std.testing.expectEqualStrings("post", @tagName(m));
    try std.testing.expect(!m.isSafe());
    try std.testing.expect(Method.get.isSafe());
    try std.testing.expectEqual(@as(usize, 3), std.enums.values(Method).len);
}

จุดสำคัญคือ switch บน enum ต้องครอบคลุมทุกค่า ถ้าวันหนึ่งมีคนเพิ่ม .patch เข้าไปใน Method ทุก switch ในโปรเจกต์ที่ไม่ได้จัดการค่าใหม่จะคอมไพล์ไม่ผ่านทันที คอมไพเลอร์พาคุณไปยืนตรงจุดที่ต้องแก้ให้ครบเอง

เทียบกับ Java/C#/Go ที่ switch ขาดกรณีแล้วเงียบ ๆ ตกไปที่ default — บั๊กแบบนั้นไปโผล่ตอน production กันมาแล้วทุกคน จะได้พฤติกรรมแบบ Zig ต้องพึ่ง linter หรือ sealed interface ซึ่งไม่ใช่ค่าเริ่มต้น

@tagName แปลง enum เป็นข้อความ ใช้กับ log ได้ดีมาก และเวลา format ข้อความ 0.16 มีตัวย่อ {t} ให้ใช้แทน

enum เลือกจำนวนเต็มเบื้องหลังเองได้ด้วย ถ้าอยากผูกกับค่าจริงเช่น HTTP status

const std = @import("std");

const StatusClass = enum(u16) {
    ok = 200,
    not_found = 404,
    server_error = 500,
};

test "enum ที่มีจำนวนเต็มกำกับ" {
    try std.testing.expectEqual(@as(u16, 404), @intFromEnum(StatusClass.not_found));

    const parsed: StatusClass = @enumFromInt(200);
    try std.testing.expectEqual(StatusClass.ok, parsed);
}

ระวัง @enumFromInt กับค่าที่ไม่ตรงกับรายการไหนเลย — ในโหมดที่เปิดการตรวจ โปรแกรมจะหยุดพร้อมบอกว่าค่าไม่ถูกต้อง ถ้ารับค่าจากภายนอกควรตรวจเองก่อน


tagged union: หนึ่งค่าที่เป็นได้หลายแบบ

union(enum) คือชนิดที่เก็บได้ทีละแบบ และ จำได้ว่าตอนนี้เป็นแบบไหน ตรงกับ sealed interface ของ Java, record + pattern matching ของ C#, หรือ enum ของ Rust

const std = @import("std");

const Field = union(enum) {
    number: u64,
    text: []const u8,
    missing, // แบบที่ไม่มีข้อมูลแนบ

    fn describe(self: Field, buf: []u8) ![]const u8 {
        return switch (self) {
            .number => |n| try std.fmt.bufPrint(buf, "number({d})", .{n}),
            .text => |t| try std.fmt.bufPrint(buf, "text({s})", .{t}),
            .missing => "missing",
        };
    }
};

test "tagged union" {
    var buf: [64]u8 = undefined;

    try std.testing.expectEqualStrings("number(42)", try (Field{ .number = 42 }).describe(&buf));
    try std.testing.expectEqualStrings("text(hi)", try (Field{ .text = "hi" }).describe(&buf));
    try std.testing.expectEqualStrings("missing", try (Field{ .missing = {} }).describe(&buf));
}

|n| และ |t| คือการดึงข้อมูลที่แนบมากับแบบนั้นออกมาใช้ ในกิ่งที่จับคู่แล้วเท่านั้น — เข้าถึงฟิลด์ที่ไม่ตรงกับแบบปัจจุบันคือความผิดพลาดที่โหมดปลอดภัยจะดักให้

จุดที่คนสะดุดเกือบทุกคน: Field.missing ไม่ใช่ ค่า union แต่เป็นค่าของ enum ที่เป็นป้ายกำกับของมัน ถ้าเขียน Field.missing.describe(...) จะได้ error ว่าไม่มีเมธอดนี้ใน tag type ต้องเขียน Field{ .missing = {} } (ผมชนกับข้อนี้ตอนเขียนบทความนี้จริง ๆ)


การจัดวางในหน่วยความจำ: ปล่อยให้คอมไพเลอร์เรียงเถอะ

คำแนะนำที่คนชอบส่งต่อกันคือ “เรียงฟิลด์จากใหญ่ไปเล็กเพื่อลดที่ว่าง” ซึ่งเป็นเรื่องจริงใน C แต่ ใน Zig ปกติไม่จำเป็น เพราะภาษาไม่รับประกันลำดับฟิลด์ คอมไพเลอร์จัดให้เอง

ผมวัดบนเครื่องจริงแล้ว ผลอยู่ในเทสข้างล่างนี้ทั้งหมด

const std = @import("std");

// เรียงแบบ "แย่" คือสลับเล็กใหญ่ไปมา
const Auto = struct { a: u8, b: u64, c: u8, d: u32 };

// extern struct = บังคับให้เรียงตามที่เขียน เพื่อให้ตรงกับ C
const Ext = extern struct { a: u8, b: u64, c: u8, d: u32 };

// packed struct = อัดลงบิต ระบุขนาดรวมได้
const Flags = packed struct(u8) {
    keep_alive: bool,
    gzip: bool,
    tls: bool,
    _pad: u5 = 0,
};

test "การจัดวางหน่วยความจำ" {
    // struct ธรรมดา: คอมไพเลอร์เรียงใหม่ให้ ได้ 16 ไบต์
    try std.testing.expectEqual(@as(usize, 16), @sizeOf(Auto));

    // extern struct: เรียงตามที่เขียนจริง เลยเสียที่ว่างเป็น 24 ไบต์
    try std.testing.expectEqual(@as(usize, 24), @sizeOf(Ext));

    // packed struct: bool 3 ตัว + padding 5 บิต = 1 ไบต์
    try std.testing.expectEqual(@as(usize, 1), @sizeOf(Flags));

    const f: Flags = .{ .keep_alive = true, .gzip = false, .tls = true };
    try std.testing.expectEqual(@as(u8, 0b101), @as(u8, @bitCast(f)));
}

สรุปให้จำ

  • struct ธรรมดา — คอมไพเลอร์เรียงเอง เรียงมือเพิ่มเองไม่ช่วยอะไร (16 ไบต์ทั้งคู่ ไม่ว่าจะเขียนลำดับไหน)
  • extern struct — ลำดับตรงตามที่เขียน ใช้เมื่อต้องคุยกับ C หรืออ่านเขียนไฟล์ไบนารี ตรงนี้แหละที่ลำดับฟิลด์มีผลจริง 24 ไบต์ vs 16 ไบต์
  • packed struct — อัดระดับบิต ใช้กับ flag, header ของโปรโตคอล, รีจิสเตอร์ฮาร์ดแวร์

อย่าเชื่อคำแนะนำเรื่องเรียงฟิลด์โดยไม่วัด — พิมพ์ @sizeOf ออกมาดูใช้เวลาสิบวินาที


ArrayList ของ 0.16: ทุกเมธอดต้องส่ง allocator

นี่คือจุดที่โค้ดเก่าพังเยอะที่สุด ตั้งแต่ 0.15.1 std.ArrayList เป็นแบบ unmanaged คือ list ไม่เก็บ allocator ไว้ในตัวเอง คุณต้องส่งให้ทุกครั้งที่มันต้องขอหน่วยความจำ

const std = @import("std");

const Counter = struct {
    hits: u32,
    label: []const u8,

    fn init(label: []const u8) Counter {
        return .{ .hits = 0, .label = label };
    }

    fn bump(self: *Counter, n: u32) void {
        self.hits += n;
    }
};

test "ArrayList แบบ 0.16" {
    const gpa = std.testing.allocator;

    var list: std.ArrayList(Counter) = .empty; // ไม่ใช่ .init(gpa) แล้ว
    defer list.deinit(gpa); // deinit ก็ต้องส่ง gpa

    try list.append(gpa, .init("/a")); // append ก็ต้องส่ง gpa
    try list.append(gpa, .init("/b"));

    try std.testing.expectEqual(@as(usize, 2), list.items.len);

    list.items[0].bump(5);
    try std.testing.expectEqual(@as(u32, 5), list.items[0].hits);
}

เทียบของเก่ากับของใหม่

0.14 และก่อนหน้า0.16
var list = std.ArrayList(T).init(gpa);var list: std.ArrayList(T) = .empty;
list.deinit();list.deinit(gpa);
try list.append(x);try list.append(gpa, x);
std.ArrayListUnmanaged(T)เป็นชื่อเดียวกับ std.ArrayList(T) แล้ว

ทำไมถึงเปลี่ยน? เพราะการเก็บ allocator ไว้ในทุก list หมายถึงเสียที่ 16 ไบต์ต่อ list หนึ่งตัว และมันซ่อนคำถามสำคัญไว้ว่า “ใครเป็นคนจ่ายหน่วยความจำให้โครงสร้างนี้” พอย้ายมาเป็นพารามิเตอร์ คำถามนั้นก็ตอบได้จากหน้าโค้ดเลย

ของเดิมยังมีอยู่ในชื่อ std.array_list.Managed แต่ถูกทำเครื่องหมายว่าเลิกใช้แล้ว อย่าเริ่มโปรเจกต์ใหม่ด้วยตัวนั้น

list ที่ไม่แตะ heap เลย

std.BoundedArray ถูกลบไปแล้ว ของแทนคือให้ ArrayList ยืม buffer บน stack

const std = @import("std");

test "list บน stack แทน BoundedArray" {
    var backing: [4]u32 = undefined;
    var stack = std.ArrayList(u32).initBuffer(&backing);

    try stack.appendSliceBounded(&.{ 1, 2, 3 });
    try std.testing.expectEqual(@as(usize, 3), stack.items.len);

    // เกินขนาด buffer → ได้ error ไม่ใช่ไปขอ heap เพิ่ม
    try std.testing.expectError(error.OutOfMemory, stack.appendSliceBounded(&.{ 4, 5 }));
}

ท่านี้มีประโยชน์มากในงานที่ห้ามแตะ heap เช่นระบบฝังตัว หรือใน hot path ที่รู้ขนาดสูงสุดอยู่แล้ว standard library เองก็ใช้ท่านี้ เช่นตอนเดินโครงสร้างโฟลเดอร์


HashMap: ตัวนี้ยังเป็น managed อยู่

จุดที่ทำให้งงคือ HashMap ยังมีทั้งสองแบบ และตัวที่ชื่อสั้นยังเป็นแบบ managed ต่างจาก ArrayList

const std = @import("std");

test "StringHashMap แบบ managed และ unmanaged" {
    const gpa = std.testing.allocator;

    // แบบ managed: จำ allocator ไว้ในตัว
    var managed = std.StringHashMap(u32).init(gpa);
    defer managed.deinit();
    try managed.put("GET", 1);
    try std.testing.expectEqual(@as(u32, 1), managed.get("GET").?);

    // แบบ unmanaged: ส่ง allocator ทุกครั้ง ประหยัดที่กว่าเมื่อมี map เยอะ
    var unmanaged: std.StringHashMapUnmanaged(u32) = .empty;
    defer unmanaged.deinit(gpa);
    try unmanaged.put(gpa, "POST", 2);
    try std.testing.expectEqual(@as(u32, 2), unmanaged.get("POST").?);
}

เลือกยังไง: ถ้ามี map ไม่กี่ตัวและอายุยาว ใช้ managed ก็สบายดี ถ้าจะเก็บ map เป็นฟิลด์ในโครงสร้างที่มีเป็นหมื่นตัว ใช้ unmanaged เพื่อไม่ให้เสียที่เก็บ allocator ซ้ำ ๆ

หมายเหตุสำคัญ: StringHashMap ไม่ได้ copy ข้อความ key ให้ มันเก็บแค่ slice ที่ชี้ไปที่ข้อความเดิม ถ้าข้อความต้นทางหายไปหรือถูกเขียนทับ map จะพัง เรื่องนี้จะลงรายละเอียดพร้อมวิธีจัดการในบท workshop และบท optimization


MultiArrayList: แยกฟิลด์ออกเป็นคนละแถว

ตัวนี้ไม่ค่อยมีในภาษาอื่นและเป็นของที่ Zig ภูมิใจ ปกติ list ของ struct จะเก็บแบบ “ทีละตัว เรียงต่อกัน” แต่ MultiArrayList เก็บแบบ “แยกฟิลด์ ฟิลด์ละแถว”

const std = @import("std");

const Row = struct { status: u16, bytes: u64, cached: bool };

test "MultiArrayList" {
    const gpa = std.testing.allocator;

    var rows: std.MultiArrayList(Row) = .empty;
    defer rows.deinit(gpa);

    try rows.append(gpa, .{ .status = 200, .bytes = 1024, .cached = true });
    try rows.append(gpa, .{ .status = 404, .bytes = 128, .cached = false });

    // ขอเฉพาะฟิลด์ที่ต้องใช้ ได้ slice ที่ต่อเนื่องกันจริง
    const statuses = rows.items(.status);
    try std.testing.expectEqual(@as(u16, 404), statuses[1]);

    var sum: u64 = 0;
    for (rows.items(.bytes)) |b| sum += b;
    try std.testing.expectEqual(@as(u64, 1152), sum);
}

ได้อะไร: เวลาวนอ่านแค่ฟิลด์เดียวจากข้อมูลหลายล้านแถว CPU ไม่ต้องลากฟิลด์ที่ไม่ใช้ขึ้นมาด้วย และไม่มีที่ว่างจากการจัดเรียงเหลือทิ้ง ผมวัดกับ Row ตัวนี้ได้ 16 ไบต์ต่อแถวแบบปกติ เทียบกับ 11 ไบต์ต่อแถวเมื่อเก็บแยกฟิลด์

โครงสร้างนี้ไม่ใช่ของสาธิต — คอมไพเลอร์ Zig ใช้เก็บ token และ AST ของตัวเองจริง ๆ บทเรื่อง optimization ของชุดนี้จะวัดให้เห็นทั้งเวลาและหน่วยความจำ รวมถึงกรณีที่มัน แย่กว่า แบบปกติด้วย


สรุปบทนี้

  • struct มีเมธอดได้ self เป็นพารามิเตอร์ธรรมดา และ *T กับ T บอกได้ว่าฟังก์ชันแก้ข้อมูลไหม
  • .init(...), .empty คือ decl literal ที่เจอทั่ว std ของ 0.16
  • switch บน enum ต้องครบทุกกรณี ไม่งั้นคอมไพล์ไม่ผ่าน — คอมไพเลอร์พาไปหาจุดที่ต้องแก้เอง
  • union(enum) ใช้แทน inheritance และ Field.missing เป็น tag ไม่ใช่ค่า union
  • struct ธรรมดาคอมไพเลอร์เรียงฟิลด์ให้เอง ลำดับมีผลเฉพาะ extern struct (วัดได้ 16 vs 24 ไบต์)
  • ArrayList ของ 0.16 เป็น unmanaged: .empty แล้วส่ง gpa ทุกเมธอด ส่วน BoundedArray ถูกลบ ใช้ initBuffer แทน
  • StringHashMap ยังเป็น managed และ ไม่ copy key ให้
  • MultiArrayList เก็บแยกฟิลด์ ประหยัดที่และเร็วกว่าเมื่ออ่านไม่ครบทุกฟิลด์

บทหน้าคือหัวใจของ 0.16 — เรื่อง I/O ที่ต้องส่ง io ไปทุกที่ ทำไมถึงออกแบบแบบนั้น อ่านไฟล์ทีละบรรทัดยังไง และ format ข้อความที่เปลี่ยน signature ไปหมดแล้ว