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

Zig 0.16 บทที่ 4: ทำไม I/O ต้องส่ง io ไปทุกที่ และข้อความที่เขียนไม่เหมือนเดิม

พี่โชว์Engineering

ถ้าคุณจะอ่านบทเดียวจากชุดนี้เพื่อเข้าใจว่า 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 แล้วรู้เลย ไม่ต้องไล่โค้ดข้างใน

ได้อะไรกลับมาสามอย่าง

  1. เขียนเทสได้จริง — ส่ง io ตัวอื่นเข้าไปแทนได้ standard library มี std.testing.io ให้ใช้ในเทส และมี Io.failing ที่แกล้งทำเป็นว่าทำอะไรไม่ได้เลย สำหรับทดสอบเส้นทางที่ I/O พัง
  2. สลับวิธีทำ I/O ได้โดยไม่แก้โค้ดที่เรียก — ฟังก์ชันเดิมทำงานได้ทั้งบนโมเดล thread ปกติและโมเดลอื่น ๆ ที่กำลังพัฒนา
  3. เขียนไลบรารีที่ใช้ได้ทุกที่ — ไลบรารีไม่ต้องเดาว่าปลายทางรันบนอะไร

ต้นทุนคือคุณต้องส่ง 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 ของภาษาอื่นทั้งหมด