
Zig 0.16 บทที่ 3: struct, enum, union และคอนเทนเนอร์ที่เปลี่ยนไปทั้งหมด
สองบทแรกเราจัดการชนิดข้อมูลเดี่ยว ๆ ไปแล้ว บทนี้เอามาประกอบเป็นก้อน
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.16switchบน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 ไปหมดแล้ว
Navigation
- ก่อนหน้า: บทที่ 2: ชนิดข้อมูลและการควบคุมการทำงาน
- ถัดไป: บทที่ 4: I/O และข้อความในโลกของ Zig 0.16
- ดูทั้งชุด: บทความแท็ก zig