
Zig 0.16 บทที่ 2: ชนิดข้อมูลและการควบคุมการทำงาน ที่ไม่มีอะไรซ่อนไว้
บทที่แล้วเราติดตั้ง 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
| ภาษา | เกิดอะไรขึ้น |
|---|---|
| C | signed overflow เป็น undefined behavior ส่วน unsigned วนกลับเงียบ ๆ |
| Java / C# | วนกลับเงียบ ๆ ได้ 4 |
| Go | วนกลับเงียบ ๆ ได้ 4 |
| Rust | panic ตอน 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 ที่เปลี่ยนวิธีใช้ไปจากเดิมทั้งหมด
Navigation
- ก่อนหน้า: บทที่ 1: เริ่มต้น Zig 0.16 ตั้งแต่ติดตั้ง
- ถัดไป: บทที่ 3: struct, enum, union และคอนเทนเนอร์ของ 0.16
- ดูทั้งชุด: บทความแท็ก zig