
Zig 0.16 บทที่ 4: ทำไม I/O ต้องส่ง io ไปทุกที่ และข้อความที่เขียนไม่เหมือนเดิม
ถ้าคุณจะอ่านบทเดียวจากชุดนี้เพื่อเข้าใจว่า Zig 0.16 ต่างจากของเก่ายังไง ให้อ่านบทนี้
หมายเหตุการปล่อยเวอร์ชัน 0.16 เขียนไว้ตรง ๆ ว่า “ตั้งแต่ Zig 0.16.0 เป็นต้นไป งาน input และ output ทั้งหมดต้องได้รับ Io ที่ถูกส่งเข้ามา” ประโยคเดียวนี้ทำให้โค้ดตัวอย่างเกือบทุกชิ้นบนอินเทอร์เน็ตใช้ไม่ได้ และเป็นเหตุผลที่ std.fs.File กับ std.fs.cwd() ที่คุณเจอในทุก tutorial ไม่มีอยู่แล้ว
บทนี้จะอธิบายว่าทำไมถึงทำแบบนี้ แล้วสอนท่ามาตรฐานที่ใช้ได้จริง
ทำไม I/O ถึงกลายเป็นพารามิเตอร์
Zig มีหลักคิดข้อหนึ่งที่ยึดมาตลอด: ฟังก์ชันไหนขอหน่วยความจำ ต้องรับ allocator เป็นพารามิเตอร์ ไม่มี allocator ตั้งต้นให้ใช้ลอย ๆ เหตุผลคือคนเรียกต้องเห็นว่าฟังก์ชันนี้จะขอหน่วยความจำ และเลือกได้ว่าจะให้ขอจากไหน
0.16 เอาหลักคิดเดียวกันมาใช้กับ I/O เอกสารเขียนว่า “อะไรก็ตามที่อาจหยุดการทำงานของโปรแกรมไว้ หรือทำให้ผลลัพธ์ไม่แน่นอน ควรเป็นของที่ interface I/O เป็นเจ้าของ”
พูดง่าย ๆ คือถ้าฟังก์ชันหนึ่งรับ io แปลว่ามัน อาจไปคุยกับโลกภายนอกและอาจต้องรอ ถ้าไม่รับ แปลว่ามันคำนวณอยู่ในหน่วยความจำล้วน ๆ อ่าน signature แล้วรู้เลย ไม่ต้องไล่โค้ดข้างใน
ได้อะไรกลับมาสามอย่าง
- เขียนเทสได้จริง — ส่ง
ioตัวอื่นเข้าไปแทนได้ standard library มีstd.testing.ioให้ใช้ในเทส และมีIo.failingที่แกล้งทำเป็นว่าทำอะไรไม่ได้เลย สำหรับทดสอบเส้นทางที่ I/O พัง - สลับวิธีทำ I/O ได้โดยไม่แก้โค้ดที่เรียก — ฟังก์ชันเดิมทำงานได้ทั้งบนโมเดล thread ปกติและโมเดลอื่น ๆ ที่กำลังพัฒนา
- เขียนไลบรารีที่ใช้ได้ทุกที่ — ไลบรารีไม่ต้องเดาว่าปลายทางรันบนอะไร
ต้นทุนคือคุณต้องส่ง io ต่อ ๆ กันไปทุกชั้น ซึ่งน่ารำคาญจริง และก็มีคนถกเถียงว่านี่คือการย้าย “สี” ของฟังก์ชันจากคำว่า async มาไว้ที่พารามิเตอร์เฉย ๆ หรือเปล่า — ข้อถกเถียงนี้ยังไม่จบ และตัว API ชุดนี้ผู้สร้างภาษาก็บอกเองว่า “ยังไม่ตายตัว” ฉะนั้นอย่าเพิ่งไปยึดกับส่วนลึก ๆ ของมัน
io ได้มาจากไหน? จาก main นั่นเอง — บล็อกข้างล่างเป็นโค้ดบางส่วน (fragment) ที่ตัดมาเฉพาะบรรทัดที่เกี่ยวข้อง
pub fn main(init: std.process.Init) !void {
const io = init.io;
const gpa = init.gpa;
// ...
}
ส่วนในเทสให้ใช้ std.testing.io ซึ่งเป็น Io สำเร็จรูปที่ standard library เตรียมไว้ให้
เขียนข้อความออกไป: buffer เป็นของคุณ และต้อง flush
const std = @import("std");
const Row = struct { path: []const u8, hits: u32 };
fn writeReport(io: std.Io, file: std.Io.File, rows: []const Row) !void {
var buf: [4096]u8 = undefined; // buffer เป็นของเรา เลือกขนาดเอง
var fw: std.Io.File.Writer = .init(file, io, &buf);
const w = &fw.interface;
for (rows) |r| try w.print("{s} {d}\n", .{ r.path, r.hits });
try w.flush(); // ไม่มีบรรทัดนี้ = ข้อมูลหายเงียบ ๆ
}
test "เขียนไฟล์แบบมี buffer" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const file = try tmp.dir.createFile(io, "r.txt", .{});
try writeReport(io, file, &.{
.{ .path = "/a", .hits = 2 },
.{ .path = "/b", .hits = 5 },
});
file.close(io);
const got = try tmp.dir.readFileAlloc(io, "r.txt", gpa, .limited(1024));
defer gpa.free(got);
try std.testing.expectEqualStrings("/a 2\n/b 5\n", got);
}
โครงที่ต้องจำมีสี่บรรทัด: ประกาศ buffer, สร้าง Writer จาก (file, io, &buf), หยิบ &fw.interface มาใช้เขียน, แล้วปิดท้ายด้วย flush()
ทำไมต้องมี buffer? เพราะการเขียนลงไฟล์หรือหน้าจอแต่ละครั้งคือการเรียกระบบปฏิบัติการ ซึ่งแพงกว่าการเขียนลงหน่วยความจำหลายสิบเท่า ถ้าโปรแกรมพิมพ์ทีละบรรทัดโดยไม่มี buffer แล้วมีล้านบรรทัด ก็คือเรียกระบบล้านครั้ง พอมี buffer 64 กิโลไบต์ ก็เหลือเรียกไม่กี่พันครั้ง
เวอร์ชันก่อน ๆ Zig มี BufferedWriter ให้ห่อเอา แต่คนลืมห่อกันประจำ 0.16 เลยตัดปัญหาด้วยการ บังคับให้ส่ง buffer มาตั้งแต่แรก ถ้าอยากได้แบบไม่มี buffer จริง ๆ ก็ส่ง &.{} เข้าไป — ซึ่งเป็นการตัดสินใจที่ต้องเขียนออกมาให้เห็น ไม่ใช่ค่าเริ่มต้นที่หลุดมา
flush() คือบั๊กแรกที่ทุกคนเจอ ข้อความยังกองอยู่ใน buffer ถ้าโปรแกรมจบก่อนโดยไม่ flush มันก็หายไปเฉย ๆ แม่แบบที่ zig init สร้างให้ยังเขียนคอมเมนต์เตือนไว้เลยว่า “Don’t forget to flush!”
อ่านไฟล์ทีละบรรทัด
const std = @import("std");
fn countLines(io: std.Io, dir: std.Io.Dir, name: []const u8) !usize {
const file = try dir.openFile(io, name, .{});
defer file.close(io); // close ก็ต้องส่ง io
var buf: [64]u8 = undefined;
var fr = file.reader(io, &buf);
const r = &fr.interface;
var n: usize = 0;
while (try r.takeDelimiter('\n')) |line| {
_ = line; // line ยืมมาจาก buf ห้ามเก็บไว้ข้ามรอบ
n += 1;
}
return n;
}
test "นับบรรทัดจากไฟล์จริง" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "x.log", .data = "one\ntwo\nthree\n" });
try std.testing.expectEqual(@as(usize, 3), try countLines(io, tmp.dir, "x.log"));
}
takeDelimiter('\n') คืน optional — ได้ null เมื่อหมดไฟล์ เลยเขียนเป็น while (...) |line| ได้สวย ๆ (มีอีกตัวชื่อ takeDelimiterExclusive ที่คืน error.EndOfStream แทน null เลือกใช้ตามสไตล์)
เรื่องที่ต้องระวังให้มาก: line ที่ได้มาคือ slice ที่ ชี้เข้าไปใน buf ไม่ได้ copy ให้ พอวนรอบถัดไป reader จะเติมข้อมูลใหม่ทับ buf แล้ว line เดิมจะกลายเป็นขยะทันที ถ้าจะเก็บไว้ใช้ทีหลังต้อง copy เอง
นี่คือดาบสองคมของ Zig — เร็วเพราะไม่ copy อะไรที่ไม่จำเป็น แต่ ไม่มีตัวดักให้ตอนคอมไพล์แบบ borrow checker ของ Rust บท workshop ของชุดนี้จะสร้างบั๊กนี้ให้ดูของจริงแล้วแก้ให้เห็น
ไฟล์จาก Windows: อย่าลืม \r
ไฟล์ข้อความที่สร้างบน Windows จบบรรทัดด้วย \r\n ไม่ใช่ \n เฉย ๆ พอตัดที่ \n จะเหลือ \r ติดท้ายมาด้วย
const std = @import("std");
fn trimEol(line: []const u8) []const u8 {
return std.mem.trimEnd(u8, line, "\r");
}
test "รองรับไฟล์ที่จบบรรทัดแบบ Windows" {
try std.testing.expectEqualStrings("hello", trimEol("hello\r")); // CRLF
try std.testing.expectEqualStrings("hello", trimEol("hello")); // LF
}
บรรทัดเดียวจบ แต่ถ้าลืมแล้วโปรแกรมไปเทียบข้อความ ผลจะไม่ตรงแบบหาสาเหตุยากมาก (เพราะ \r มองไม่เห็นบนหน้าจอ) เครื่องมือทุกตัวที่จะทำในชุดนี้ใส่บรรทัดนี้ไว้ตั้งแต่ต้น
อ่านทั้งไฟล์: ต้องบอกเพดานเสมอ
const std = @import("std");
test "readFileAlloc ต้องกำหนดเพดาน" {
const io = std.testing.io;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "big.txt", .data = "0123456789" });
// เกินเพดาน → error ไม่ใช่กินแรมจนเครื่องหน่วง
try std.testing.expectError(
error.StreamTooLong,
tmp.dir.readFileAlloc(io, "big.txt", gpa, .limited(4)),
);
const ok = try tmp.dir.readFileAlloc(io, "big.txt", gpa, .limited(64));
defer gpa.free(ok);
try std.testing.expectEqual(@as(usize, 10), ok.len);
}
.limited(n) คือเพดานที่บังคับให้ระบุ ไม่มีค่าเริ่มต้นแบบ “อ่านมาให้หมดเท่าที่มี” เพราะฟังก์ชันแบบนั้นคือช่องโหว่รอเกิด — ผู้ใช้ชี้ไปที่ไฟล์ 50 กิกะไบต์แล้วโปรแกรมก็ตายไป
ถ้าอยากได้ “ไม่จำกัด” จริง ๆ มี .unlimited ให้ใช้ แต่ต้องเขียนออกมาให้เห็นว่าตั้งใจ
Format: สิ่งที่เปลี่ยนจนโค้ดเก่าพัง
const std = @import("std");
const Level = enum { info, warn };
const Version = struct {
major: u16,
minor: u16,
// signature ของ 0.16: รับแค่ writer ไม่มี fmt string ไม่มี options
pub fn format(self: Version, w: *std.Io.Writer) std.Io.Writer.Error!void {
try w.print("v{d}.{d}", .{ self.major, self.minor });
}
};
test "ตัวระบุรูปแบบที่มีจริงใน 0.16" {
try std.testing.expectFmt("hi", "{s}", .{"hi"}); // ข้อความ
try std.testing.expectFmt("255", "{d}", .{255}); // ฐานสิบ
try std.testing.expectFmt("ff", "{x}", .{255}); // ฐานสิบหก
try std.testing.expectFmt("11111111", "{b}", .{255}); // ฐานสอง
try std.testing.expectFmt("A", "{c}", .{@as(u8, 65)}); // ตัวอักษร
try std.testing.expectFmt("warn", "{t}", .{Level.warn}); // ชื่อ tag
try std.testing.expectFmt("v0.16", "{f}", .{Version{ .major = 0, .minor = 16 }});
try std.testing.expectFmt(" 7|7 ", "{d:>3}|{d:<3}", .{ 7, 7 }); // จัดชิด
}
สองเรื่องที่ทำให้โค้ดเก่าพัง
หนึ่ง — signature ของ format เปลี่ยน ของเดิมรับสี่ตัว (comptime fmt, options, writer และ self) ของใหม่รับแค่ self กับ w: *std.Io.Writer เท่านั้น เขียนง่ายกว่าเดิมมาก แต่แปลว่าทุก struct ที่เคยมีเมธอด format ต้องแก้หมด
สอง — ต้องเรียกด้วย {f} เท่านั้น เขียน {} เฉย ๆ กับ struct ที่มี format จะได้ compile error บอกให้เลือกว่าจะใช้ {f} (เรียกเมธอด) หรือ {any} (พิมพ์โครงสร้างดิบ) การบังคับให้เลือกแบบนี้ทำให้อ่านโค้ดแล้วรู้ทันทีว่าบรรทัดนั้นเรียกโค้ดของใครอยู่ — ตรงกับหลัก “ไม่มีอะไรเกิดขึ้นลับหลัง”
{t} เป็นตัวย่อที่สะดวกมากสำหรับ enum และ error — ได้ชื่อ tag ออกมาเลย ไม่ต้องเรียก @tagName
หมายเหตุ: ตัวระบุ
{D}สำหรับระยะเวลาถูกถอดออกใน 0.16 แล้ว ถ้าเจอในตัวอย่างเก่าให้เปลี่ยนไปใช้std.Io.Durationกับ{f}แทน
ต่อข้อความในหน่วยความจำ
ไม่ใช่ทุกอย่างจะเขียนลงไฟล์ บางทีเราแค่อยากได้ข้อความก้อนหนึ่ง
const std = @import("std");
test "ต่อข้อความสามแบบ" {
const gpa = std.testing.allocator;
// 1. ยาวเท่าไหร่ก็ได้ ขอหน่วยความจำเพิ่มเอง
var aw: std.Io.Writer.Allocating = .init(gpa);
defer aw.deinit();
try aw.writer.print("{s}:{d}", .{ "port", 8080 });
const owned = try aw.toOwnedSlice();
defer gpa.free(owned);
try std.testing.expectEqualStrings("port:8080", owned);
// 2. รู้ขนาดสูงสุดอยู่แล้ว ใช้ buffer บน stack ไม่แตะ heap เลย
var buf: [32]u8 = undefined;
const s = try std.fmt.bufPrint(&buf, "{s}:{d}", .{ "port", 443 });
try std.testing.expectEqualStrings("port:443", s);
// 3. แปลงข้อความกลับเป็นตัวเลข
const n = try std.fmt.parseInt(u16, "8080", 10);
try std.testing.expectEqual(@as(u16, 8080), n);
}
Allocating เป็น writer ที่มี ArrayList อยู่ข้างใน ใช้เมื่อไม่รู้ความยาวล่วงหน้า และ ไม่ต้อง flush ส่วน bufPrint เร็วกว่าเพราะไม่แตะ heap เลย ใช้เมื่อรู้เพดานความยาว
ตัดข้อความ: tokenize กับ cut
งานอ่าน log หรือ CSV ต้องตัดข้อความตลอด Zig มีทั้งแบบวนทีละชิ้นและแบบตัดครั้งเดียว
const std = @import("std");
test "tokenize และ cut" {
const line = "203.0.113.7 - - GET /index.html 200 1024";
// วนทีละชิ้น ข้ามช่องว่างซ้ำให้เอง
var it = std.mem.tokenizeScalar(u8, line, ' ');
try std.testing.expectEqualStrings("203.0.113.7", it.next().?);
_ = it.next();
_ = it.next();
try std.testing.expectEqualStrings("GET", it.next().?);
// ตัดครั้งเดียวได้สองท่อน — ของใหม่ใน 0.16
const head_rest = std.mem.cutScalar(u8, line, ' ').?;
try std.testing.expectEqualStrings("203.0.113.7", head_rest[0]);
try std.testing.expect(std.mem.startsWith(u8, head_rest[1], "- -"));
}
ทุกฟังก์ชันในกลุ่มนี้คืน slice ที่ชี้กลับเข้าไปในข้อความเดิม ไม่มีการ copy ไม่มีการขอหน่วยความจำ นี่คือเหตุผลที่ Zig แยกวิเคราะห์ข้อความได้เร็วมาก และก็เป็นเหตุผลเดียวกับที่ต้องระวังเรื่องอายุของข้อมูลต้นทาง
ชื่อในกลุ่มนี้ เปลี่ยนใน 0.16 — std.mem.indexOf เป็น std.mem.find, indexOfScalar เป็น findScalar และมี cut, cutScalar, cutPrefix, cutSuffix เพิ่มเข้ามาใหม่ ชื่อเดิมยังอยู่ในฐานะ alias ที่เลิกใช้แล้ว
สรุปบทนี้
- 0.16 บังคับส่ง
ioให้ทุกงาน I/O ด้วยหลักคิดเดียวกับ allocator — เห็นจาก signature ว่าฟังก์ชันไหนแตะโลกภายนอก std.fs.Fileและstd.fs.cwd()ไม่มีแล้ว ใช้std.Io.Fileและstd.Io.Dir.cwd()- writer ต้องส่ง buffer เอง และต้อง
flush()ไม่งั้นข้อมูลหาย - อ่านทีละบรรทัดด้วย
takeDelimiterและ บรรทัดที่ได้ยืมมาจาก buffer ห้ามเก็บข้ามรอบ - ไฟล์จาก Windows ต้อง
trimEnd(line, "\r") readFileAllocบังคับใส่เพดาน.limited(n)เพื่อไม่ให้ไฟล์ใหญ่ทำเครื่องล่มformatเปลี่ยน signature แล้ว และต้องเรียกด้วย{f}เท่านั้น- ต่อข้อความใช้
Io.Writer.Allocating(ไม่รู้ขนาด) หรือbufPrint(รู้ขนาด ไม่แตะ heap) indexOf*เปลี่ยนชื่อเป็นfind*แล้ว
บทหน้าเป็นของสนุก — comptime กลไกเดียวที่ Zig ใช้แทน macro, template, generics และ reflection ของภาษาอื่นทั้งหมด
Navigation
- ก่อนหน้า: บทที่ 3: struct, enum, union และคอนเทนเนอร์
- ถัดไป: บทที่ 5: comptime และ generics
- ดูทั้งชุด: บทความแท็ก zig