
Zig 0.16 บทที่ 7: error ไม่มีข้อมูลแนบ แล้วจะบอกรายละเอียดยังไง
บทที่แล้วจบลงด้วยข้อจำกัดใหญ่ข้อหนึ่ง: 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 ไม่ได้ลอกมาจากที่ไหน
| Debug | ReleaseSafe | ReleaseFast | ReleaseSmall | |
|---|---|---|---|---|
| ปรับให้เร็วขึ้น | ไม่ | ใช่ | ใช่ | ใช่ (เน้นเล็ก) |
| ตรวจความปลอดภัยตอนรัน | ใช่ | ใช่ | ไม่ | ไม่ |
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
Navigation
- ก่อนหน้า: บทที่ 6: error ที่คอมไพเลอร์ไม่ยอมให้คุณเมิน
- ถัดไป: บทที่ 8: allocator คืออะไร และใครเป็นเจ้าของหน่วยความจำ
- ดูทั้งชุด: บทความแท็ก zig