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

Zig 0.16 บทที่ 7: error ไม่มีข้อมูลแนบ แล้วจะบอกรายละเอียดยังไง

พี่โชว์Engineering

บทที่แล้วจบลงด้วยข้อจำกัดใหญ่ข้อหนึ่ง: error ของ Zig เป็นแค่ตัวเลข ไม่มีข้อมูลแนบไปด้วย

error.BadNumber บอกได้แค่ว่าเลขไม่ถูก บอกไม่ได้ว่าบรรทัดไหน คอลัมน์ไหน ฟิลด์อะไร ต่างจาก Go ที่ห่อ error ซ้อนกันได้ด้วย fmt.Errorf("...: %w", err) หรือ Rust ที่ใส่ข้อมูลอะไรก็ได้ลงใน E หรือ Java ที่ exception พก stack trace มาให้ครบ

บทนี้ตอบว่า Zig แก้ปัญหานี้ยังไงในงานจริง แล้วต่อด้วยเรื่องที่สำคัญพอ ๆ กัน — การทดสอบเส้นทางที่หน่วยความจำเต็ม และความจริงเรื่อง safety ในแต่ละ build mode ที่ผมรันวัดเองทั้งหมด


รูปแบบ diagnostics struct

วิธีที่ standard library ใช้จริง (ดูได้ใน std.json.Scanner และ std.zon.parse) คือ ส่ง struct สำหรับรับรายละเอียดเข้าไปเป็นพารามิเตอร์ แยกจากตัว error

const std = @import("std");

const ParseError = error{ MissingField, BadNumber };

const Diagnostics = struct {
    line_no: usize = 0,
    column: usize = 0,
    field: []const u8 = "",

    fn report(self: Diagnostics, w: *std.Io.Writer) !void {
        try w.print("บรรทัด {d} คอลัมน์ {d}: ฟิลด์ {s}", .{ self.line_no, self.column, self.field });
    }
};

// diag เป็น optional pointer → คนเรียกที่ไม่สนใจรายละเอียดส่ง null ได้
fn parseField(text: []const u8, line_no: usize, diag: ?*Diagnostics) ParseError!u32 {
    const at = std.mem.findScalar(u8, text, '=') orelse {
        if (diag) |d| d.* = .{ .line_no = line_no, .column = 0, .field = text };
        return error.MissingField;
    };

    const value = text[at + 1 ..];
    return std.fmt.parseInt(u32, value, 10) catch {
        if (diag) |d| d.* = .{ .line_no = line_no, .column = at + 2, .field = text[0..at] };
        return error.BadNumber;
    };
}

test "diagnostics struct ให้รายละเอียดที่ error เก็บไม่ได้" {
    var diag: Diagnostics = .{};

    try std.testing.expectEqual(@as(u32, 8080), try parseField("port=8080", 1, &diag));

    try std.testing.expectError(error.BadNumber, parseField("port=abc", 7, &diag));
    try std.testing.expectEqual(@as(usize, 7), diag.line_no);
    try std.testing.expectEqualStrings("port", diag.field);

    var buf: [128]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    try diag.report(&w);
    try std.testing.expectEqualStrings("บรรทัด 7 คอลัมน์ 6: ฟิลด์ port", w.buffered());

    // คนที่ไม่สนใจรายละเอียดส่ง null ไปเลย ไม่ต้องจ่ายอะไรเพิ่ม
    try std.testing.expectError(error.MissingField, parseField("noequals", 1, null));
}

ข้อดีของรูปแบบนี้เทียบกับการห่อ error แบบ Go หรือ Rust:

  • ไม่ต้องขอหน่วยความจำเพื่อสร้างข้อความ ซึ่งสำคัญมาก เพราะ error ที่พบบ่อยที่สุดตัวหนึ่งคือ “หน่วยความจำไม่พอ” — ถ้าการรายงาน error ต้องขอหน่วยความจำก่อน คุณจะรายงานตอนวิกฤตไม่ได้
  • คนเรียกเลือกได้ว่าจะจ่ายไหม ส่ง null แล้วต้นทุนเป็นศูนย์
  • โครงสร้างชัด ไม่ต้องมานั่ง errors.As เพื่อแกะว่าข้างในเป็นชนิดอะไร

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


ทดสอบเส้นทางที่หน่วยความจำเต็ม

นี่คือของที่ผมคิดว่า Zig ทำได้ดีกว่าทุกภาษาที่เคยใช้ และแทบไม่มีใครพูดถึง

ในภาษาที่มี GC คุณแทบทดสอบไม่ได้เลยว่าโค้ดรับมือกับหน่วยความจำเต็มยังไง ใน C/C++ ต้องไปแทรก malloc ปลอม แต่ใน Zig — allocator เป็นพารามิเตอร์อยู่แล้ว ก็แค่ส่งตัวที่แกล้งพังเข้าไป

std.testing.checkAllAllocationFailures ไปไกลกว่านั้นอีก มัน รันฟังก์ชันซ้ำหลายรอบ โดยแต่ละรอบทำให้การขอหน่วยความจำครั้งที่ i พังทีละครั้ง จนครบทุกครั้งที่มีในโค้ด

const std = @import("std");

fn buildIndex(gpa: std.mem.Allocator, words: []const []const u8) !std.StringHashMapUnmanaged(u32) {
    var map: std.StringHashMapUnmanaged(u32) = .empty;
    errdefer map.deinit(gpa); // ถ้าไม่มีบรรทัดนี้ เทสข้างล่างจะฟ้อง leak

    for (words) |wd| {
        const gop = try map.getOrPut(gpa, wd);
        if (!gop.found_existing) gop.value_ptr.* = 0;
        gop.value_ptr.* += 1;
    }
    return map;
}

fn buildIndexForTest(gpa: std.mem.Allocator) !void {
    var m = try buildIndex(gpa, &.{ "a", "b", "a", "c", "b", "a" });
    defer m.deinit(gpa);
    if (m.get("a").? != 3) return error.WrongCount;
}

test "ทดสอบทุกจุดที่หน่วยความจำอาจเต็ม" {
    try std.testing.checkAllAllocationFailures(std.testing.allocator, buildIndexForTest, .{});
}

ฟังก์ชันที่ส่งเข้าไปต้องรับ std.mem.Allocator เป็นพารามิเตอร์แรกและคืน !void ถ้าเงื่อนไขไม่ตรง Zig จะฟ้องตั้งแต่คอมไพล์

สิ่งที่มันจับได้และเทสธรรมดาจับไม่ได้:

  • error.SwallowedOutOfMemoryError — คุณแกล้งให้พังแล้ว แต่ฟังก์ชันกลับไม่ส่ง error.OutOfMemory ออกมา แปลว่ามีที่ไหนสักแห่งกลืน error ทิ้ง
  • error.MemoryLeakDetected — พังกลางทางแล้วมีของค้าง แปลว่า errdefer ไม่ครบ

ลอง comment บรรทัด errdefer map.deinit(gpa); ออกแล้วรันดู เทสจะฟ้องทันที นี่คือความแตกต่างระหว่างโค้ดที่ “ทำงานได้ตอนทุกอย่างปกติ” กับโค้ดที่ “ไม่พังตอนเครื่องใกล้เต็ม”

ถ้าอยากคุมเองว่าจะให้พังครั้งที่เท่าไหร่ ใช้ FailingAllocator ตรง ๆ

const std = @import("std");

fn buildIndex(gpa: std.mem.Allocator, words: []const []const u8) !std.StringHashMapUnmanaged(u32) {
    var map: std.StringHashMapUnmanaged(u32) = .empty;
    errdefer map.deinit(gpa);
    for (words) |wd| {
        const gop = try map.getOrPut(gpa, wd);
        if (!gop.found_existing) gop.value_ptr.* = 0;
        gop.value_ptr.* += 1;
    }
    return map;
}

test "FailingAllocator แกล้งให้พังที่ครั้งที่กำหนด" {
    var fa = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 });
    try std.testing.expectError(error.OutOfMemory, buildIndex(fa.allocator(), &.{"a"}));
}

FailingAllocator ยังนับให้ด้วยว่าขอหน่วยความจำไปกี่ครั้ง รวมกี่ไบต์ ซึ่งจะได้ใช้หนักมากในชุดบทความเรื่อง optimization


assert, unreachable และ safety ในแต่ละโหมด

std.debug.assert คือการประกาศสัญญาภายในโค้ด เนื้อในของมันคือ if (!ok) unreachable;

const std = @import("std");

fn percentile(sorted: []const u64, p: u8) u64 {
    std.debug.assert(sorted.len > 0); // สัญญา: ต้องมีข้อมูลอย่างน้อยหนึ่งตัว
    std.debug.assert(p <= 100); // สัญญา: p เป็นเปอร์เซ็นต์
    const idx = (sorted.len - 1) * p / 100;
    return sorted[idx];
}

test "assert เป็นสัญญาภายใน ไม่ใช่การตรวจ input" {
    const data = [_]u64{ 10, 20, 30, 40, 50 };
    try std.testing.expectEqual(@as(u64, 30), percentile(&data, 50));
    try std.testing.expectEqual(@as(u64, 50), percentile(&data, 100));
}

ข้อควรระวังที่เอกสารเขียนไว้เอง: ในโหมด ReleaseFast และ ReleaseSmall assert จะถูกตัดทิ้ง และคอมไพเลอร์ยัง เอาเงื่อนไขนั้นไปใช้ปรับโค้ดต่อ ด้วย ฉะนั้น assert ใช้กับสัญญาที่คุณควบคุมได้เองเท่านั้น ห้ามใช้ตรวจข้อมูลจากผู้ใช้ — ข้อมูลจากภายนอกต้องตรวจด้วย if แล้วคืน error

ผลจริงที่ผมรันทั้งสามโหมด

ผมเขียนโปรแกรมที่บวก u8 จนล้น แล้วรันด้วยโหมดต่าง ๆ (ใช้ตัวแปรระดับไฟล์เพื่อไม่ให้คอมไพเลอร์คำนวณล่วงหน้าได้) — บล็อกข้างล่างเป็นโค้ดบางส่วน ตัดบรรทัด @import ออก

var seed: u8 = 250;

pub fn main() void {
    const c = seed + 10; // 260 ล้น u8
    std.debug.print("result={d}\n", .{c});
}

ผลจริงที่ได้

=== Debug ===
thread 117476 panic: integer overflow
a7safety.zig:8:17: 0x10289dc5b in main (a7safety)
    const c = a + b;
                ^

=== ReleaseSafe ===
thread 117598 panic: integer overflow

=== ReleaseFast ===
result=4

สรุปเป็นตาราง — ทุกช่องมาจากการรันจริงบน Zig 0.16.0 ไม่ได้ลอกมาจากที่ไหน

DebugReleaseSafeReleaseFastReleaseSmall
ปรับให้เร็วขึ้นไม่ใช่ใช่ใช่ (เน้นเล็ก)
ตรวจความปลอดภัยตอนรันใช่ใช่ไม่ไม่
assert ทำงานใช่ใช่ไม่ไม่
error return trace โดยปริยายใช่ไม่ไม่ไม่
u8 ล้นหยุดพร้อมชี้บรรทัดหยุด (แต่ชี้บรรทัดหยาบกว่า)ได้ 4 เงียบ ๆเงียบ ๆ

ข้อสังเกตที่เห็นจากผลจริง: ใน ReleaseSafe โปรแกรมยังหยุดให้ แต่ตำแหน่งที่ชี้ไม่ตรงบรรทัดจริงเพราะโค้ดถูกปรับแล้ว — ยังบอกได้ว่า “อะไร” แต่บอก “ตรงไหน” ได้ไม่ดีเท่า Debug

คำแนะนำที่ใช้ได้จริง: ส่งงานขึ้น production ด้วย ReleaseSafe เป็นค่าเริ่มต้น แล้วค่อยขยับเป็น ReleaseFast เฉพาะส่วนที่วัดแล้วว่าคุ้ม ความเร็วที่ได้เพิ่มมักไม่คุ้มกับการเปลี่ยนบั๊กที่หยุดทันทีให้กลายเป็นข้อมูลผิดเงียบ ๆ

Zig เรียกพฤติกรรมกลุ่มนี้ว่า Illegal Behavior ไม่ใช่ undefined behavior และแบ่งเป็น “แบบที่ตรวจได้” (มีการตรวจให้ในโหมดปลอดภัย) กับ “แบบที่ตรวจไม่ได้” (ห้ามทำจริง ๆ) รายการที่ตรวจได้มีเช่น เข้าถึงนอกช่วง, ตัวเลขล้น, หารด้วยศูนย์, แกะ optional ที่ว่าง, แปลง enum ผิดค่า, อ่าน union ผิดฟิลด์

ถ้าอยากปิดการตรวจเฉพาะจุดร้อนจริง ๆ มี @setRuntimeSafety(false) ให้ใช้ระดับ block — แต่ต้องรู้ว่ากำลังแลกอะไรอยู่


เขียน panic handler เอง

ตอนโปรแกรมจะตาย บางทีเราอยากทำอะไรก่อน เช่นเขียน log ปิดไฟล์ หรือส่งสัญญาณไปที่ระบบ monitoring รูปแบบของ 0.16 เปลี่ยนไปจากเวอร์ชันก่อน ของเดิมเป็นฟังก์ชันสามพารามิเตอร์ ของใหม่เป็นค่าคงที่ที่สร้างจาก std.debug.FullPanic — บล็อกข้างล่างเป็นโค้ดบางส่วนของไฟล์ที่ผมรันจริง

pub const panic = std.debug.FullPanic(myPanic);

fn myPanic(msg: []const u8, first_trace_addr: ?usize) noreturn {
    _ = first_trace_addr;
    std.debug.print("[เก็บ log ก่อนตาย] {s}\n", .{msg});
    std.process.exit(97);
}

เมื่อเกิดการล้นของ u8 ผลที่ได้คือ

[เก็บ log ก่อนตาย] integer overflow
exit=97

ได้ข้อความ panic ตามจริง และคุมรหัสจบโปรแกรมเองได้ ถ้าเจอตัวอย่างที่เขียน pub fn panic(msg, trace, ret_addr) แปลว่าเป็นของก่อน 0.16 — รูปแบบนั้นยังทำงานได้แต่ถูกทำเครื่องหมายว่าเลิกใช้แล้ว

standard library ยังมี std.debug.simple_panic (พิมพ์สั้น ๆ) และ std.debug.no_panic (ไม่พิมพ์อะไรเลย ใช้ลดขนาด binary ในงานฝังตัว) ให้เลือกใช้


ข้ามเทสตามเงื่อนไข

const std = @import("std");

test "ข้ามเทสเมื่อไม่เข้าเงื่อนไข" {
    if (@import("builtin").os.tag == .wasi) return error.SkipZigTest;
    try std.testing.expect(true);
}

error.SkipZigTest เป็น error พิเศษที่ตัวรันเทสรู้จัก ผลจะขึ้นเป็น SKIP ไม่ใช่ FAIL — มีประโยชน์กับเทสที่ต้องใช้ของเฉพาะระบบปฏิบัติการ


สรุปบทนี้

  • error ไม่มีข้อมูลแนบ → ใช้ diagnostics struct ที่ส่งเป็น ?*T ให้คนเรียกเลือกได้ว่าจะจ่ายไหม (standard library ใช้รูปแบบนี้เอง)
  • checkAllAllocationFailures ทดสอบทุกจุดที่ขอหน่วยความจำได้อัตโนมัติ จับทั้ง error ที่ถูกกลืนและ leak บนเส้นทางพลาด
  • FailingAllocator คุมได้ว่าจะให้พังครั้งที่เท่าไหร่ และนับจำนวนครั้ง/ไบต์ให้ด้วย
  • assert ใช้กับสัญญาภายในเท่านั้น ห้ามใช้ตรวจข้อมูลจากผู้ใช้ เพราะถูกตัดทิ้งในโหมด ReleaseFast/ReleaseSmall
  • ผลรันจริง: u8 ล้น → Debug/ReleaseSafe หยุด, ReleaseFast ได้ 4 เงียบ ๆ
  • ส่ง production ด้วย ReleaseSafe เป็นค่าเริ่มต้น
  • panic handler ของ 0.16 เขียนเป็น pub const panic = std.debug.FullPanic(myPanic);

จบเรื่อง error สองบทเท่านี้ บทหน้าเข้าเรื่องหน่วยความจำ — หัวใจที่แท้จริงของการเขียน Zig และเป็นเหตุผลที่ทุกฟังก์ชันในภาษานี้ต้องรับ allocator