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

Zig 0.16 บทที่ 2: ชนิดข้อมูลและการควบคุมการทำงาน ที่ไม่มีอะไรซ่อนไว้

พี่โชว์Engineering

บทที่แล้วเราติดตั้ง Zig 0.16 และพิมพ์ข้อความออกจอได้แล้ว บทนี้ลงไปที่ตัวภาษาจริง ๆ

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

ตัวอย่างทุกชิ้นในบทนี้อยู่ในรูป test ที่รันผ่านจริงบน Zig 0.16.0 คุณ copy ไปวางในไฟล์แล้วสั่ง zig test ไฟล์.zig ได้เลย


const กับ var: ค่าคงที่มาก่อนเสมอ

Zig มีสองคำสำหรับประกาศตัวแปร

  • const — ผูกค่าครั้งเดียว เปลี่ยนไม่ได้
  • var — เปลี่ยนค่าได้

ต่างจากหลายภาษาตรงที่ ถ้าประกาศ var แล้วไม่เคยเปลี่ยนค่าเลย Zig จะฟ้องเป็น compile error ไม่ใช่แค่เตือน คือคอมไพล์ไม่ผ่านเลย บังคับให้เขียน const เมื่อควรเป็น const

กฎนี้ฟังดูจุกจิก แต่ผลคือเวลาอ่านโค้ดคนอื่น เห็น var เมื่อไหร่แปลว่าค่านั้นเปลี่ยนแน่นอน มีความหมายจริง ไม่ใช่แค่คนเขียนขี้เกียจ


จำนวนเต็ม: กว้างเท่าไหร่ก็ได้

ภาษาส่วนใหญ่ให้คุณเลือกได้แค่ 8, 16, 32, 64 บิต Zig ให้เลือก ความกว้างใดก็ได้ตั้งแต่ 0 ถึง 65535 บิต เขียน u3 ก็คือ unsigned 3 บิต (เก็บได้ 0 ถึง 7) เขียน i7 ก็คือ signed 7 บิต

const std = @import("std");

test "จำนวนเต็มความกว้างใดก็ได้" {
    const small: u3 = 7; // เก็บได้ 0..7 เท่านั้น
    const signed: i7 = -64; // เก็บได้ -64..63

    try std.testing.expectEqual(@as(u16, 3), @bitSizeOf(@TypeOf(small)));
    try std.testing.expectEqual(@as(i8, -64), signed);
    try std.testing.expectEqual(@as(u8, 255), std.math.maxInt(u8));
}

ประโยชน์จริงของมันไม่ใช่การประหยัดที่ แต่คือ การเข้ารหัสข้อกำหนดลงไปในชนิดข้อมูล ถ้าฟิลด์หนึ่งเก็บระดับความสำคัญ 0 ถึง 7 การประกาศเป็น u3 แปลว่าค่านอกช่วงนั้นเข้ามาไม่ได้ตั้งแต่ต้น ไม่ต้องมี if คอยตรวจทุกจุด

@bitSizeOf, @TypeOf, @as ที่เห็นคือ builtin function — ฟังก์ชันที่คอมไพเลอร์มีให้ ขึ้นต้นด้วย @ เสมอ จำง่ายว่าอะไรที่มี @ คือคอมไพเลอร์จัดการให้ ไม่ใช่โค้ดจาก standard library


การล้นของตัวเลข: Zig ถือว่าผิดกฎ ทั้ง signed และ unsigned

ตรงนี้ต่างจากภาษาอื่นชัดเจนที่สุด ลองดูว่าแต่ละภาษาทำอะไรเมื่อ u8 ที่เก็บค่า 250 บวก 10

ภาษาเกิดอะไรขึ้น
Csigned overflow เป็น undefined behavior ส่วน unsigned วนกลับเงียบ ๆ
Java / C#วนกลับเงียบ ๆ ได้ 4
Goวนกลับเงียบ ๆ ได้ 4
Rustpanic ตอน debug, วนกลับตอน release
Zigผิดกฎทั้ง signed และ unsigned — โหมด Debug/ReleaseSafe หยุดโปรแกรมพร้อมบอกจุด

Zig เรียกสิ่งนี้ว่า Illegal Behavior (ไม่ได้ใช้คำว่า undefined behavior) และแบ่งเป็นสองแบบ: แบบที่ตรวจได้จะใส่การตรวจให้ตอนรัน ส่วนแบบที่ตรวจไม่ได้ก็คือห้ามทำจริง ๆ

ถ้าคุณ ตั้งใจ จะให้มันวนกลับ ต้องบอกให้ชัดด้วยตัวดำเนินการคนละตัว

const std = @import("std");

test "ตัวดำเนินการสำหรับการล้น" {
    const a: u8 = 250;

    // +% = วนกลับ (wrapping) 250 + 10 = 260 → 260 - 256 = 4
    try std.testing.expectEqual(@as(u8, 4), a +% 10);

    // +| = ตันที่ค่าสูงสุด (saturating) ไม่วนกลับ
    try std.testing.expectEqual(@as(u8, 255), a +| 10);

    // @addWithOverflow = ได้ทั้งผลลัพธ์และธงบอกว่าล้นไหม
    const r = @addWithOverflow(a, 10);
    try std.testing.expectEqual(@as(u8, 4), r[0]);
    try std.testing.expectEqual(@as(u1, 1), r[1]); // 1 = ล้น

    // std.math.add = คืน error แทนที่จะหยุดโปรแกรม
    try std.testing.expectError(error.Overflow, std.math.add(u8, a, 10));
}

สี่ทางเลือกนี้ครอบคลุมทุกความตั้งใจ และที่สำคัญคือ คนอ่านโค้ดรู้ทันทีว่าคุณตั้งใจแบบไหน ต่างจากภาษาที่ + ตัวเดียวแปลว่าอะไรก็ได้


Optional: จบเรื่อง null pointer

ในภาษาอย่าง Java, C#, Go ตัวแปรที่เป็น reference/pointer เป็น null ได้เสมอ ต่อให้คุณไม่อยากให้เป็นก็ตาม บั๊ก NullPointerException เลยเป็นบั๊กยอดฮิตตลอดกาล (คนคิดคำนี้ยังเรียกมันว่า “ความผิดพลาดพันล้านดอลลาร์” ของตัวเอง)

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

const std = @import("std");

fn findFirstSpace(s: []const u8) ?usize {
    return std.mem.findScalar(u8, s, ' ');
}

test "optional" {
    // แบบที่ 1: เทียบตรง ๆ
    try std.testing.expectEqual(@as(?usize, 3), findFirstSpace("GET /path"));

    // แบบที่ 2: if พร้อมดึงค่าออกมาใช้ (|i| คือชื่อค่าที่ดึงได้)
    if (findFirstSpace("GET /path")) |i| {
        try std.testing.expectEqual(@as(usize, 3), i);
    }

    // แบบที่ 3: orelse = ถ้าว่างให้ใช้ค่านี้แทน
    const miss = findFirstSpace("nospace") orelse 999;
    try std.testing.expectEqual(@as(usize, 999), miss);

    const opt: ?u32 = null;
    try std.testing.expect(opt == null);
}

std.mem.findScalar คือฟังก์ชันหาตำแหน่งของค่าตัวเดียวใน slice ชื่อนี้เพิ่งเปลี่ยนใน 0.16 ของเดิมชื่อ indexOfScalar ถ้าเจอตัวอย่างที่ใช้ std.mem.indexOf แปลว่าเขียนไว้ก่อน 0.16

ที่น่าสนใจคือ optional ที่เป็น pointer ไม่กินที่เพิ่มเลย ?*i32 มีขนาดเท่ากับ *i32 เป๊ะ เพราะ Zig ใช้ที่อยู่ 0 แทนค่าว่าง อันนี้ไม่ใช่การ optimize ที่บังเอิญได้ แต่เป็นสิ่งที่ภาษารับประกันไว้ในเอกสาร (ผมวัดบนเครื่องแล้วได้ 8 ไบต์เท่ากันทั้งคู่) ส่วน ?u32 กินที่มากกว่า u32 เพราะต้องมีธงแยกว่ามีค่าหรือไม่


Array กับ Slice: ต่างกันตรงที่ “รู้ขนาดตอนไหน”

จุดนี้คนมาจาก Java/Python/Go สับสนบ่อยที่สุด

  • Array ([4]u8) — ขนาดเป็นส่วนหนึ่งของชนิดข้อมูล รู้ตั้งแต่ตอนคอมไพล์ เก็บค่าไว้ในตัวเอง
  • Slice ([]u8) — คือ pointer + ความยาว ชี้ไปที่ข้อมูลของคนอื่น ไม่ได้เก็บเอง ความยาวรู้ตอนรัน

พูดง่าย ๆ slice คือ “มุมมองหนึ่งของข้อมูลที่มีอยู่แล้ว” ใครที่เคยเขียน Go จะคุ้นทันที ส่วนคนที่มาจาก C++ ให้นึกถึง std::span หรือ string_view

const std = @import("std");

test "array, slice และ sentinel" {
    var arr = [_]u8{ 'a', 'b', 'c', 'd' }; // [_] = ให้คอมไพเลอร์นับเอง
    try std.testing.expectEqual(@as(usize, 4), arr.len);

    // slice ไม่ได้ copy ข้อมูล — มันชี้เข้าไปใน arr
    const slice: []u8 = arr[1..3];
    try std.testing.expectEqualStrings("bc", slice);

    // แก้ผ่าน slice = แก้ arr ด้วย เพราะเป็นข้อมูลก้อนเดียวกัน
    slice[0] = 'B';
    try std.testing.expectEqual(@as(u8, 'B'), arr[1]);

    // string literal มี 0 ปิดท้ายให้เข้ากับ C ได้ แต่ len ไม่นับตัวปิดท้าย
    const literal = "hello";
    try std.testing.expectEqual(@as(usize, 5), literal.len);
    const as_slice: [:0]const u8 = literal;
    try std.testing.expectEqual(@as(u8, 0), as_slice[as_slice.len]);
}

[:0]const u8 อ่านว่า “slice ของ u8 ที่แก้ไม่ได้ และมีค่า 0 ปิดท้าย” เรียกว่า sentinel-terminated slice มีไว้ส่งต่อให้ฟังก์ชันภาษา C ได้โดยไม่ต้อง copy — เป็นหนึ่งในเหตุผลที่ Zig เรียกใช้ไลบรารี C ได้ราบรื่นกว่าภาษาอื่น

เรื่องที่ต้องระวังตั้งแต่วันแรก: slice ไม่ได้เป็นเจ้าของข้อมูล ถ้าข้อมูลต้นทางหายไปหรือถูกย้ายที่ slice จะชี้ไปยังที่ที่ไม่มีอะไรอยู่แล้ว และ Zig ไม่มี borrow checker แบบ Rust ที่จะดักให้ตอนคอมไพล์ เรื่องนี้เป็นความรับผิดชอบของคนเขียน เอกสารทางการก็เขียนไว้ตรง ๆ แบบนี้ บทเรื่อง workshop ของชุดนี้จะจงใจสร้างบั๊กแบบนี้ให้ดูแล้วแก้ให้เห็นกันจริง ๆ


ลูป: วิ่งหลายชุดพร้อมกันได้ในบรรทัดเดียว

for ของ Zig วิ่งบนหลาย slice พร้อมกันได้ และขอ index มาด้วยได้ โดยเขียน 0.. ต่อท้าย

const std = @import("std");

test "for และ while" {
    const names = [_][]const u8{ "alpha", "beta", "gamma" };
    const sizes = [_]usize{ 1, 2, 3 };

    var total: usize = 0;
    for (names, sizes, 0..) |name, size, i| {
        total += name.len * size + i;
    }
    // (5*1+0) + (4*2+1) + (5*3+2) = 5 + 9 + 17
    try std.testing.expectEqual(@as(usize, 31), total);

    // while มีท่อนต่อท้ายด้วย : ( ) สำหรับสิ่งที่ทำทุกจบรอบ
    var n: u32 = 0;
    var steps: u32 = 0;
    while (n < 100) : (n += 7) steps += 1;
    try std.testing.expectEqual(@as(u32, 15), steps);
}

ถ้าความยาวของสองชุดไม่เท่ากัน โปรแกรมจะหยุดพร้อมแจ้งเตือนในโหมดที่เปิดการตรวจไว้ ไม่ใช่วิ่งไปจนล้นแบบเงียบ ๆ


Labeled block: ทำให้ const ครอบตรรกะหลายบรรทัดได้

ปัญหาคลาสสิกคือ อยากได้ค่าคงที่ค่าหนึ่งแต่ต้องคำนวณหลายบรรทัด เลยจำใจประกาศเป็น var ทั้งที่ไม่อยากให้ใครแก้

Zig แก้ด้วยการให้ block คืนค่าได้ ตั้งชื่อ label ให้ block แล้ว break ออกมาพร้อมค่า

const std = @import("std");

test "block ที่คืนค่าได้" {
    const category = blk: {
        const status: u16 = 404;
        if (status >= 500) break :blk "server";
        if (status >= 400) break :blk "client";
        break :blk "ok";
    };

    try std.testing.expectEqualStrings("client", category);
}

blk เป็นแค่ชื่อ ตั้งเป็นอะไรก็ได้ ผลคือได้ const จริง ๆ ที่คำนวณมาจากตรรกะหลายบรรทัด — บทบาทเดียวกับ block expression ของ Rust หรือ immediately-invoked function ใน JavaScript แต่ไม่มีค่าใช้จ่ายของการเรียกฟังก์ชัน


Labeled switch: เขียน state machine ให้อ่านรู้เรื่อง

ของใหม่ที่มีประโยชน์มากเวลาเขียนตัวแยกวิเคราะห์ข้อความหรือ state machine — switch ตั้งชื่อได้ แล้วสั่ง continue :ชื่อ ค่าใหม่ เพื่อกระโดดไปทำ case อื่นต่อ

const std = @import("std");

test "labeled switch เป็น state machine" {
    var log = std.mem.zeroes([4]u8);
    var idx: usize = 0;

    state: switch (@as(u8, 1)) {
        1 => {
            log[idx] = 'a';
            idx += 1;
            continue :state 2; // ไปทำ case 2 ต่อ
        },
        2 => {
            log[idx] = 'b';
            idx += 1;
            continue :state 3;
        },
        3 => {
            log[idx] = 'c';
            idx += 1;
        },
        else => unreachable,
    }

    try std.testing.expectEqualStrings("abc", log[0..3]);
}

ถ้าไม่มีท่านี้ คุณต้องเขียน while (true) ครอบ switch แล้วมีตัวแปร state คอยเปลี่ยนค่า ซึ่งอ่านยากกว่าและคอมไพเลอร์ปรับให้เร็วได้ยากกว่า

unreachable แปลว่า “บรรทัดนี้ไปถึงไม่ได้แน่นอน” ในโหมด Debug และ ReleaseSafe ถ้าไปถึงจริงโปรแกรมจะหยุดพร้อมข้อความ ส่วนโหมด ReleaseFast/ReleaseSmall คอมไพเลอร์จะเชื่อคุณและใช้ข้อมูลนั้นปรับโค้ด — ซึ่งแปลว่าถ้าคุณคิดผิด พฤติกรรมจะกลายเป็นอะไรก็ได้ ใช้เมื่อมั่นใจจริงเท่านั้น


defer: เก็บกวาดตรงที่สร้าง

defer สั่งให้โค้ดทำงานตอนออกจาก block ปัจจุบัน ไม่ว่าจะออกทางไหน

const std = @import("std");

test "defer ทำงานย้อนลำดับ" {
    const gpa = std.testing.allocator;
    var order: std.ArrayList(u8) = .empty;
    defer order.deinit(gpa);

    {
        defer order.append(gpa, '1') catch {};
        defer order.append(gpa, '2') catch {};
        try order.append(gpa, '0');
    } // ออกจาก block ตรงนี้ → '2' ก่อน แล้วค่อย '1'

    try std.testing.expectEqualStrings("021", order.items);
}

ลำดับเป็นแบบ ย้อนกลับ (ตัวที่เขียนทีหลังทำก่อน) ซึ่งถูกต้องตามธรรมชาติของการเก็บกวาด เพราะของที่สร้างทีหลังมักพึ่งพาของที่สร้างก่อน

คุณค่าจริงของ defer คือมันวางโค้ดปิดไว้ ติดกับ โค้ดเปิด คนอ่านเห็นทั้งคู่ในสายตาเดียว ไม่ต้องไล่หาว่าปิดตรงไหนหรือปิดครบไหม เทียบกับ try/finally ของ Java/C# ที่ต้องมี block ครอบ หรือ defer ของ Go ที่ทำงานตอนจบฟังก์ชัน ไม่ใช่จบ block — ของ Zig ละเอียดกว่า

Zig ไม่มี destructor แบบ C++/Rust ที่ทำงานเองตอนตัวแปรหมดอายุ defer คือคำตอบของ Zig สำหรับเรื่องนี้ แลกความสะดวกกับความชัดเจนว่าอะไรทำงานเมื่อไหร่


สรุปบทนี้

  • var ที่ไม่เคยเปลี่ยนค่าคือ compile error — เห็น var เมื่อไหร่แปลว่ามันเปลี่ยนจริง
  • จำนวนเต็มกว้างเท่าไหร่ก็ได้ ใช้เข้ารหัสข้อกำหนดลงในชนิดข้อมูลได้
  • การล้นเป็น Illegal Behavior ทั้ง signed และ unsigned ถ้าตั้งใจให้ล้นต้องใช้ +%, +|, @addWithOverflow หรือ std.math.add
  • ?T ทำให้ค่าว่างเป็นส่วนหนึ่งของชนิดข้อมูล ใช้ orelse หรือ if (x) |v| เพื่อดึงค่า และ ?*T ไม่กินที่เพิ่ม
  • array รู้ขนาดตอนคอมไพล์และเก็บข้อมูลเอง ส่วน slice คือ pointer+len ที่ยืมข้อมูลคนอื่นมา และ Zig ไม่มีตัวดักอายุข้อมูลให้
  • for วิ่งหลายชุดพร้อมกันได้ blk: { ... break :blk v; } ทำให้ const ครอบตรรกะยาว ๆ ได้
  • labeled switch เขียน state machine ได้ตรงไปตรงมา
  • defer ทำงานย้อนลำดับตอนจบ block และเป็นคำตอบของ Zig แทน destructor

บทหน้าเราจะประกอบข้อมูลเป็นก้อนใหญ่ขึ้น — struct, enum, tagged union และคอนเทนเนอร์ของ 0.16 อย่าง ArrayList ที่เปลี่ยนวิธีใช้ไปจากเดิมทั้งหมด