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

Zig 0.16 บทที่ 6: error ที่คอมไพเลอร์ไม่ยอมให้คุณเมิน

พี่โชว์Engineering

ทุกภาษามีวิธีจัดการความผิดพลาด และวิธีที่เลือกบอกอะไรเกี่ยวกับภาษานั้นเยอะมาก

  • C คืนค่า -1 หรือ NULL แล้วให้ไปดู errno เอง — เมินได้ง่ายมาก และคนก็เมินจริง
  • C++, Java, C# ใช้ exception ที่โยนข้ามฟังก์ชันไปโผล่อีกที่ — ฟังก์ชันที่คุณเรียกอาจกระโดดหนีกลางทางโดยที่ signature ไม่ได้บอก
  • Go คืน error เป็นค่าที่สอง — ชัดเจนดี แต่เขียน _ ทิ้งได้ และคนก็ทิ้งจริง
  • Rust ใช้ Result<T, E> กับ ? — ใกล้เคียง Zig ที่สุด

Zig เลือกทางที่คล้าย Rust แต่ตัดให้เหลือน้อยกว่า: error เป็นแค่ค่าตัวหนึ่ง ไม่มี payload ไม่มีการคลี่ stack ไม่มี destructor ที่ต้องรอทำงาน และที่สำคัญที่สุดคือ เมินไม่ได้ เอกสารทางการเขียนไว้ตรง ๆ ว่า “การไม่ตรวจ error เป็น compile error”


error set: รายการความผิดพลาดที่เป็นชนิดข้อมูล

const std = @import("std");

const ParseError = error{
    EmptyLine,
    MissingStatus,
    BadStatus,
};

const LogLine = struct { path: []const u8, status: u16 };

// !LogLine อ่านว่า "ได้ LogLine หรือไม่ก็ error จากชุด ParseError"
fn parseLine(line: []const u8) ParseError!LogLine {
    if (line.len == 0) return error.EmptyLine;

    const cut = std.mem.cutScalar(u8, line, ' ') orelse return error.MissingStatus;
    const status = std.fmt.parseInt(u16, cut[1], 10) catch return error.BadStatus;
    if (status < 100 or status > 599) return error.BadStatus;

    return .{ .path = cut[0], .status = status };
}

test "error set พื้นฐาน" {
    const ok = try parseLine("/index.html 200");
    try std.testing.expectEqual(@as(u16, 200), ok.status);

    try std.testing.expectError(error.EmptyLine, parseLine(""));
    try std.testing.expectError(error.MissingStatus, parseLine("/onlypath"));
    try std.testing.expectError(error.BadStatus, parseLine("/x abc"));
    try std.testing.expectError(error.BadStatus, parseLine("/x 999"));
}

ParseError!LogLine เรียกว่า error union — ค่าที่ได้เป็นอย่างใดอย่างหนึ่ง ไม่มีทางเป็นทั้งสองหรือไม่เป็นอะไรเลย

เบื้องหลัง error แต่ละตัวคือจำนวนเต็ม (ปกติกว้าง 16 บิต) ที่คอมไพเลอร์แจกหมายเลขให้ตอนคอมไพล์ ไม่มี object ไม่มีการขอหน่วยความจำ ไม่มีข้อความแนบมาด้วย การส่ง error กลับไปจึงเป็นแค่การคืนค่าตัวเลขกับธงบอกสถานะ — ถูกกว่าการโยน exception มาก

try คือคำสั่งที่ใช้บ่อยที่สุด try f() มีความหมายเท่ากับ f() catch |err| return err เป๊ะ ๆ — ถ้าพลาดก็ส่งต่อขึ้นไปให้คนเรียกจัดการ


catch: จัดการตรงนั้นเลย

const std = @import("std");

const ParseError = error{ EmptyLine, MissingStatus, BadStatus };
const LogLine = struct { path: []const u8, status: u16 };

fn parseLine(line: []const u8) ParseError!LogLine {
    if (line.len == 0) return error.EmptyLine;
    const cut = std.mem.cutScalar(u8, line, ' ') orelse return error.MissingStatus;
    const status = std.fmt.parseInt(u16, cut[1], 10) catch return error.BadStatus;
    if (status < 100 or status > 599) return error.BadStatus;
    return .{ .path = cut[0], .status = status };
}

test "catch หลายแบบ" {
    // 1. พลาดแล้วใช้ค่าสำรอง
    const a = parseLine("") catch LogLine{ .path = "?", .status = 0 };
    try std.testing.expectEqual(@as(u16, 0), a.status);

    // 2. ดู error ก่อนแล้วค่อยตัดสินใจ
    const b = parseLine("/x abc") catch |err| switch (err) {
        error.BadStatus => LogLine{ .path = "/x", .status = 500 },
        else => return err,
    };
    try std.testing.expectEqual(@as(u16, 500), b.status);

    // 3. ดึงชื่อ error ออกมาเป็นข้อความ
    const e = parseLine("");
    if (e) |_| unreachable else |err| {
        try std.testing.expectEqualStrings("EmptyLine", @errorName(err));
        try std.testing.expectFmt("EmptyLine", "{t}", .{err}); // {t} ย่อของ 0.16
    }
}

catch unreachable เป็นอีกท่าที่เจอบ่อย แปลว่า “ตรงนี้พลาดไม่ได้แน่นอน” ในโหมด Debug และ ReleaseSafe ถ้าพลาดจริงโปรแกรมจะหยุดพร้อมข้อความ attempt to unwrap error: ... ส่วนโหมด Release อื่นคอมไพเลอร์จะเชื่อคุณ — ใช้เมื่อมั่นใจจริงเท่านั้น


รวม error set ด้วย || แล้ว switch ให้ครบ

const std = @import("std");

const ParseError = error{ EmptyLine, MissingStatus, BadStatus };
const IoError = error{ Closed, Timeout };
const AppError = ParseError || IoError; // รวมสองชุดเข้าด้วยกัน

fn handle(e: AppError) []const u8 {
    return switch (e) {
        error.EmptyLine, error.MissingStatus, error.BadStatus => "input",
        error.Closed, error.Timeout => "transport",
    };
}

test "รวม error set" {
    try std.testing.expectEqualStrings("input", handle(error.BadStatus));
    try std.testing.expectEqualStrings("transport", handle(error.Timeout));
}

switch บน error ก็ต้องครบทุกกรณีเหมือน enum ถ้าวันหนึ่งมีคนเพิ่ม error.Refused เข้าไปใน IoError ฟังก์ชัน handle จะคอมไพล์ไม่ผ่านทันที — คอมไพเลอร์พาไปยืนตรงจุดที่ต้องตัดสินใจ

เทียบกับ Java: exception ใหม่ที่เพิ่มเข้ามาจะไหลผ่าน catch (Exception e) ไปเงียบ ๆ ไม่มีใครรู้ว่ามีกรณีใหม่เกิดขึ้น

error set แบบอนุมานเอง (!T)

ถ้าเขียน !LogLine เฉย ๆ โดยไม่ระบุชุด Zig จะรวบรวมเองว่าฟังก์ชันนี้คืน error อะไรได้บ้าง สะดวกมากตอนเขียน แต่เอกสารเตือนไว้ว่า ฟังก์ชันที่ใช้ error set แบบอนุมานจะกลายเป็น generic ทำให้เอา function pointer ยากขึ้น และ ใช้กับ recursion ไม่ได้

ใช้แบบอนุมานในโค้ดภายใน แต่ระบุชุดให้ชัดที่ขอบของไลบรารีที่คนอื่นเรียก


errdefer: คืนของเฉพาะตอนพลาด

defer ทำงานทุกเส้นทางออก ส่วน errdefer ทำงาน เฉพาะตอนออกด้วย error ซึ่งเป็นสิ่งที่ต้องการพอดีเวลาสร้างของทีละชิ้นแล้วอาจพังกลางทาง

const std = @import("std");

const LogLine = struct { path: []const u8, status: u16 };

fn parseLine(line: []const u8) !LogLine {
    const cut = std.mem.cutScalar(u8, line, ' ') orelse return error.MissingStatus;
    const status = std.fmt.parseInt(u16, cut[1], 10) catch return error.BadStatus;
    return .{ .path = cut[0], .status = status };
}

fn parseAll(gpa: std.mem.Allocator, text: []const u8) !std.ArrayList(LogLine) {
    var out: std.ArrayList(LogLine) = .empty;
    errdefer out.deinit(gpa); // คืนเฉพาะตอนพลาด ถ้าสำเร็จคนเรียกเป็นเจ้าของต่อ

    var it = std.mem.tokenizeScalar(u8, text, '\n');
    while (it.next()) |line| {
        try out.append(gpa, try parseLine(line));
    }
    return out;
}

test "errdefer คืนของเมื่อพลาดกลางทาง" {
    const gpa = std.testing.allocator;

    var ok = try parseAll(gpa, "/a 200\n/b 404\n");
    defer ok.deinit(gpa);
    try std.testing.expectEqual(@as(usize, 2), ok.items.len);

    // บรรทัดที่สองพัง → errdefer คืนหน่วยความจำให้ ไม่มี leak
    try std.testing.expectError(error.BadStatus, parseAll(gpa, "/a 200\n/b zzz\n"));
}

เทสนี้ใช้ std.testing.allocator ซึ่งจะทำให้เทส ล้มเหลว ถ้ามีหน่วยความจำหลงเหลือ ฉะนั้นการที่เทสผ่านคือหลักฐานว่า errdefer ทำงานจริง ถ้าลบบรรทัด errdefer ออกเทสจะฟ้อง leak ทันที

นี่คือของที่ C ต้องใช้ goto cleanup และ C++ ต้องพึ่ง RAII — Zig ให้เขียนตรงจุดที่สร้าง มองเห็นคู่กัน

ลำดับของ defer กับ errdefer

ทั้งคู่อยู่บนกองเดียวกันและทำงานย้อนลำดับการประกาศ ผมพิสูจน์ด้วยเทสนี้

const std = @import("std");

test "ลำดับ defer และ errdefer" {
    const gpa = std.testing.allocator;
    var log: std.ArrayList(u8) = .empty;
    defer log.deinit(gpa);

    const Inner = struct {
        fn run(l: *std.ArrayList(u8), a: std.mem.Allocator, fail: bool) !void {
            try l.append(a, 'A');
            defer l.append(a, 'd') catch {}; // ประกาศก่อน
            errdefer l.append(a, 'e') catch {}; // ประกาศหลัง
            try l.append(a, 'B');
            if (fail) return error.Boom;
        }
    };

    // เส้นทางสำเร็จ: errdefer ไม่ทำงาน
    try Inner.run(&log, gpa, false);
    try std.testing.expectEqualStrings("ABd", log.items);

    // เส้นทางพลาด: ตัวที่ประกาศทีหลังทำงานก่อน → e แล้วค่อย d
    log.clearRetainingCapacity();
    try std.testing.expectError(error.Boom, Inner.run(&log, gpa, true));
    try std.testing.expectEqualStrings("ABed", log.items);
}

Zig 0.17: errdefer ยังทำงานเมื่อพลาด แต่ไม่ capture error แล้ว

ใน Zig 0.16 เขียน errdefer |err| เพื่อดึงค่า error ได้ แต่ 0.17 ตัดรูปแบบ capture นี้ออก เหลือหน้าที่หลักของ errdefer: รันงานเก็บกวาดหรือบันทึกบริบทเมื่อฟังก์ชันกำลังคืน error ถ้าต้องแยกชนิด error ให้จัดการด้วย catch |err| ตรงจุดที่ต้องตัดสินใจแทน

const std = @import("std");

fn parseStatus(text: []const u8) !u16 {
    return std.fmt.parseInt(u16, text, 10) catch error.BadStatus;
}

fn withDiagnostic(text: []const u8, failed: *bool) !u16 {
    errdefer failed.* = true; // ที่จริงมักคืน resource หรือเพิ่มบริบทของ log ตรงนี้
    return try parseStatus(text);
}

test "errdefer ทำงานเฉพาะเส้นทาง error" {
    var failed = false;
    try std.testing.expectError(error.BadStatus, withDiagnostic("zzz", &failed));
    try std.testing.expect(failed);
}

error return trace: ของที่ไม่มีในภาษาอื่น

เพราะ Zig ไม่มี exception จึงไม่มี stack trace แบบที่คุ้นเคย แต่มีของที่ต่างออกไปและมีประโยชน์กว่าในหลายกรณี — เส้นทางที่ error เดินทางผ่านมา

ผมเขียนโปรแกรมสั้น ๆ นี้แล้วรันจริง (บล็อกข้างล่างเป็นโค้ดบางส่วน ตัดบรรทัด @import ออกให้สั้น)

fn readConfig() !void {
    return error.FileNotFound;
}

fn loadSettings() !void {
    try readConfig();
}

pub fn main() !void {
    try loadSettings();
}

ผลที่ได้ตอนรันด้วย zig run โหมด Debug คือ

error: FileNotFound
a6trace.zig:4:5: 0x102e1594b in readConfig (a6trace)
    return error.FileNotFound;
    ^
a6trace.zig:8:5: 0x102e159b7 in loadSettings (a6trace)
    try readConfig();
    ^
a6trace.zig:12:5: 0x102e15a1b in main (a6trace)
    try loadSettings();
    ^

หน้าตาเหมือน stack trace แต่ไม่ใช่ stack trace เอกสารทางการถึงกับเขียนกำกับไว้ว่า “ดูให้ดี นี่ไม่ใช่ stack trace”

ความต่างคือ stack trace บอกว่า “ตอนที่พังอยู่ โปรแกรมซ้อนฟังก์ชันอะไรอยู่บ้าง” ส่วน error return trace บอกว่า “error ตัวนี้ถูกส่งต่อผ่านจุดไหนมาบ้าง” ซึ่งข้ามขอบเขตของ stack ได้ ถ้า error ถูกเก็บไว้แล้วส่งต่อทีหลัง เส้นทางก็ยังตามได้อยู่

และเพราะ Zig ไม่ต้องคลี่ stack เพื่อสร้างมัน ต้นทุนจึงแค่บันทึกที่อยู่หนึ่งค่าต่อการส่งต่อหนึ่งครั้ง

ข้อที่ผมวัดเองแล้วพบว่าเอกสารส่วนใหญ่บอกผิด

หลายแหล่ง (รวมถึงบทความที่แชร์กันเยอะ) บอกว่า error return trace เปิดใน Debug และ ReleaseSafe ผมรันจริงทั้งสี่โหมดบน Zig 0.16.0 แล้วได้ผลนี้

โหมดผลจริงที่ได้
Debugมี trace เต็ม ตามด้านบน
ReleaseSafeไม่มี trace — ได้แค่ error: FileNotFound
ReleaseFastไม่มี trace
ReleaseSmallไม่มี trace
ReleaseSafe + -ferror-tracingมี trace กลับมา

หมายเหตุการปล่อยเวอร์ชัน 0.16 เขียนตรงกับผลที่วัดได้ คือเปิดโดยปริยายเฉพาะ Debug และธง -ferror-tracing มีคำอธิบายว่า “เปิด error tracing ใน release builds”

ผลในทางปฏิบัติ: ถ้าคุณจะพึ่ง trace นี้ตอนไล่บั๊กบนเซิร์ฟเวอร์จริงที่ build ด้วย ReleaseSafe ต้องเติม -ferror-tracing เอง ไม่งั้นจะได้แค่ชื่อ error ลอย ๆ


เทียบกับภาษาอื่น

หัวข้อZigC++ / Java / C#GoRust
กลไกerror union !Texceptionคืนค่าที่สองResult<T, E>
เมินได้ไหมไม่ได้ คอมไพล์ไม่ผ่านได้ (catch {} ว่าง)ได้ (_)เตือน แต่ปิดเสียงได้
ข้อมูลแนบไม่มี เป็นตัวเลขล้วนมี object + stack traceมี (interface)มี (ชนิดใดก็ได้)
คลี่ stackไม่มีมีไม่มีไม่มี (ยกเว้น panic)
ต้นทุนตอนไม่มี errorเกือบศูนย์เกือบศูนย์ตรวจ nil ทุกครั้งเกือบศูนย์
ตรวจครบทุกกรณีบังคับไม่ไม่บังคับเมื่อ match

ข้อแลกที่ต้องพูดให้ชัด: error ของ Zig ไม่มีข้อมูลแนบ คุณบอกไม่ได้ว่า “แยกวิเคราะห์ไม่ผ่านที่บรรทัด 42 คอลัมน์ 7” ผ่านตัว error เอง ไม่มี errors.Wrap แบบ Go ไม่มี thiserror แบบ Rust

ทางแก้ที่ standard library ใช้จริงคือ ส่ง struct สำหรับเก็บรายละเอียดเข้าไปเป็นพารามิเตอร์ ซึ่งจะเป็นเนื้อหาหลักของบทหน้า


สรุปบทนี้

  • error เป็นค่าจำนวนเต็ม ไม่มี object ไม่มีการขอหน่วยความจำ ไม่มีการคลี่ stack
  • !T คือ error union และ try คือ catch |err| return err แบบย่อ
  • catch จัดการตรงจุดได้หลายแบบ ส่วน catch unreachable ใช้เมื่อมั่นใจจริงเท่านั้น
  • || รวม error set และ switch บน error ต้องครบทุกกรณี
  • errdefer คืนของเฉพาะเส้นทางพลาด และทำงานย้อนลำดับร่วมกองเดียวกับ defer (พิสูจน์แล้วได้ ABed)
  • error return trace ไม่ใช่ stack trace และ ปิดอยู่ใน ReleaseSafe ต้องใส่ -ferror-tracing เอง
  • error ไม่มีข้อมูลแนบ — ต้องใช้ struct รับรายละเอียดแยก

บทหน้าว่าด้วยเรื่องนั้นพอดี: จะบอกรายละเอียดของความผิดพลาดยังไงเมื่อ error เป็นแค่ตัวเลข พร้อมวิธีทดสอบเส้นทางที่หน่วยความจำเต็มแบบอัตโนมัติ