Mục lục
- Mục Tiêu Bài Học
- Variant Chứa Data — Tagged Union
- Tuple-Like Variant
- Struct-Like Variant
- Mixed Variant — Enum Message Kinh Điển
- Destructure Trong
match - Memory Layout — Discriminant + Max Variant Size
- Method Trên Enum Với Data
- Use Case Thực Tế: Response, AST, Event
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu enum Rust là tagged union / sum type: mỗi variant có thể mang data riêng, mạnh hơn nhiều so với enum C chỉ là integer alias.
- Viết được tuple-like variant (
Circle(f64),Rectangle(f64, f64)) — payload không tên, truy cập theo thứ tự. - Viết được struct-like variant (
Circle { radius: f64 }) — payload có tên field, đọc code tự document hơn. - Mix được unit + tuple + struct variant trong cùng enum (kinh điển là
Messagetrong Rust Book). - Destructure từng variant trong
matchđể bind data ra biến local — đây là cách duy nhất an toàn để lấy payload ra. - Hiểu memory layout enum: discriminant chọn variant + payload kích thước bằng variant lớn nhất; variant nhỏ vẫn pay cost cho variant to nhất.
- Viết được
implblock với methodmatch selftruy cập data trong từng arm (ví dụShape::area()). - Nhận diện được các use case nên dùng enum-with-data thay struct/object hierarchy: HTTP Response body, JSON AST node, UI event, state machine.
Variant Chứa Data — Tagged Union
Ở Bài 90, enum Direction chỉ là 4 tag không kèm data — gần giống enum C. Nhưng enum Rust có một thuộc tính rất quan trọng: mỗi variant có thể mang theo data riêng, và data của các variant không cần cùng kiểu. Đây chính là khái niệm tagged union (hay sum type, discriminated union) trong lý thuyết kiểu.
So sánh với C để thấy sự khác biệt:
- C enum: chỉ là một
intđược đặt tên —enum Color { RED, GREEN, BLUE };tương đươngRED = 0, GREEN = 1, BLUE = 2. Không có cách nào để "RED đi kèm thêm intensity, GREEN đi kèm 2 số float". - C union: nhiều field chia chung một vùng bộ nhớ — không có cách an toàn biết hiện tại union đang chứa field nào, lập trình viên phải tự nhớ.
- Rust enum: kết hợp cả hai — tag (discriminant) cho biết variant nào đang active + payload chứa data tương ứng. Compiler bắt buộc bạn xử lý đủ mọi variant trong
matchnên không thể đọc nhầm field.
Vì sao gọi là "sum type"? Vì số trạng thái khả dĩ của enum = tổng số trạng thái của từng variant. Ngược lại struct là "product type" — số trạng thái = tích số trạng thái của từng field. Sum type rất mạnh để model "hoặc cái này, hoặc cái kia" — kiểu logic OR có dữ liệu kèm theo.
Một ví dụ thực tế: hình học. Một Shape có thể là hình tròn (chỉ cần bán kính) hoặc hình chữ nhật (cần chiều rộng + chiều cao). Hai variant có shape data khác hẳn nhau, không thể nhét chung vào một struct mà không lãng phí hoặc lằng nhằng Option. Enum-with-data là công cụ thiết kế hoàn hảo cho tình huống này.
Tuple-Like Variant
Dạng đầu tiên: variant chứa data theo cú pháp tuple — danh sách kiểu trong cặp ngoặc tròn, không có tên field.
#[derive(Debug)]
enum Shape {
Circle(f64), // 1 field: bán kính
Rectangle(f64, f64), // 2 field: width, height
Triangle(f64, f64, f64), // 3 field: cạnh a, b, c
}
fn main() {
// Tạo instance: gọi như function constructor
let c = Shape::Circle(2.5);
let r = Shape::Rectangle(3.0, 4.0);
let t = Shape::Triangle(3.0, 4.0, 5.0);
println!("c = {c:?}"); // Circle(2.5)
println!("r = {r:?}"); // Rectangle(3.0, 4.0)
println!("t = {t:?}"); // Triangle(3.0, 4.0, 5.0)
}
Vài điểm chú ý cú pháp:
- Khai báo variant:
VariantName(Type1, Type2, ...)— danh sách kiểu giống tuple struct. - Khởi tạo instance:
Shape::Circle(2.5)— viết như gọi function. Thực tế Rust tạo ra một constructor function ngầm cùng tên với variant. - Không có tên field, truy cập data phải qua
matchhoặcif letđể destructure ra biến — không có syntaxc.0giống tuple struct (sẽ thấy ở mục 6).
Tuple-like variant phù hợp khi:
- Variant chỉ có 1-2 data, ý nghĩa của data rõ từ tên variant — như
Circle(f64)ai cũng đoán đây là bán kính. - Cần cú pháp gọn, không muốn dài dòng tên field.
- Tương thích pattern matching ngắn gọn ở phía consumer.
Nhưng khi variant có 3+ field hoặc semantics field dễ nhầm (ví dụ Rectangle(f64, f64) — đâu là width, đâu là height?), nên cân nhắc dạng struct-like ở mục tiếp theo.
Struct-Like Variant
Dạng thứ hai: variant chứa data theo cú pháp struct — field có tên rõ ràng, viết trong cặp ngoặc nhọn.
#[derive(Debug)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { a: f64, b: f64, c: f64 },
}
fn main() {
// Tạo instance: y hệt khởi tạo struct, có tên field
let c = Shape::Circle { radius: 2.5 };
let r = Shape::Rectangle { width: 3.0, height: 4.0 };
let t = Shape::Triangle { a: 3.0, b: 4.0, c: 5.0 };
println!("c = {c:?}");
// Circle { radius: 2.5 }
println!("r = {r:?}");
// Rectangle { width: 3.0, height: 4.0 }
println!("t = {t:?}");
// Triangle { a: 3.0, b: 4.0, c: 5.0 }
}
Khác biệt chính so với tuple-like:
- Khai báo:
VariantName { field1: Type1, field2: Type2 }— block field giống struct thường. - Khởi tạo:
Shape::Rectangle { width: 3.0, height: 4.0 }— phải nêu tên field, không thể nhầm thứ tự. - Debug print thân thiện hơn: hiện cả tên field, dễ đọc khi log/debug.
Struct-like variant phù hợp khi:
- Variant có nhiều field (3 trở lên) hoặc field cùng kiểu dễ nhầm thứ tự (như
Rectangle(f64, f64)ai biết đâu là width). - Muốn code self-documenting — nhìn vào lệnh tạo instance là biết ngay từng số dùng làm gì.
- Có khả năng thêm field mới trong tương lai mà không phá vỡ thứ tự tham số ở callsite.
Hai dạng tuple-like và struct-like tương đương về biểu đạt — chọn dạng nào phụ thuộc gu code và độ rõ ràng. Nhiều thư viện Rust nổi tiếng dùng struct-like cho variant phức tạp và tuple-like cho variant đơn giản 1 field.
Mixed Variant — Enum Message Kinh Điển
Quy tắc không bắt buộc tất cả variant của một enum phải cùng dạng. Một enum có thể đồng thời chứa unit variant (không data), tuple variant, và struct variant. Ví dụ kinh điển từ The Rust Programming Language Book:
#[derive(Debug)]
enum Message {
Quit, // unit variant - không data
Move(i32, i32), // tuple variant - 2 i32
Write(String), // tuple variant - 1 String
ChangeColor { r: u8, g: u8, b: u8 }, // struct variant
}
fn main() {
let messages = [
Message::Quit,
Message::Move(10, 20),
Message::Write(String::from("Hello, blogcode.vn!")),
Message::ChangeColor { r: 255, g: 128, b: 0 },
];
for m in &messages {
println!("{m:?}");
}
// Quit
// Move(10, 20)
// Write("Hello, blogcode.vn!")
// ChangeColor { r: 255, g: 128, b: 0 }
}
Đây chính là sức mạnh của enum Rust: một loại nhưng có nhiều dạng khác hẳn nhau về data shape. So với hệ thống OOP truyền thống (Java/C#), bạn sẽ phải tạo một abstract class Message và 4 subclass — code lằng nhằng, không có compile-time check cho việc xử lý đủ mọi loại message.
Nếu viết bằng Rust với struct, bạn sẽ phải:
- Tạo 4 struct riêng
QuitMsg,MoveMsg,WriteMsg,ChangeColorMsg. - Tạo một trait
Messagechung vàBox<dyn Message>để có polymorphism — kéo theo dynamic dispatch. - Không có cách bắt compiler kiểm tra "đã xử lý đủ mọi loại message" ở consumer code.
Với enum, tất cả gói gọn trong 6 dòng và compiler ép bạn xử lý đủ mọi variant. Đây là lý do enum-with-data được dùng rộng rãi trong Rust ecosystem cho mọi tình huống sum type.
Destructure Trong match
Đã chứa data trong variant rồi thì làm sao đọc data ra? Câu trả lời là pattern matching qua match — destructure variant để bind data thành biến local trong từng arm.
#[derive(Debug)]
enum Message {
Quit,
Move(i32, i32),
Write(String),
ChangeColor { r: u8, g: u8, b: u8 },
}
fn process(msg: Message) {
match msg {
Message::Quit => {
println!("Thoát chương trình");
}
Message::Move(x, y) => {
// x, y là biến local bind từ tuple variant
println!("Di chuyển tới ({x}, {y})");
}
Message::Write(text) => {
// text bind từ String trong Write
println!("Ghi: {text}");
}
Message::ChangeColor { r, g, b } => {
// destructure struct variant - field-init shorthand
println!("Đổi màu sang RGB({r}, {g}, {b})");
}
}
}
fn main() {
process(Message::Quit);
process(Message::Move(10, 20));
process(Message::Write(String::from("hello")));
process(Message::ChangeColor { r: 255, g: 128, b: 0 });
}
Cú pháp destructure tương ứng với cú pháp khởi tạo, chỉ khác là thay value bằng tên biến:
- Unit variant:
Message::Quit => ...— không destructure gì, chỉ match tag. - Tuple variant:
Message::Move(x, y) => ...— bind 2 i32 thành 2 biến localx,ytheo thứ tự. - Struct variant:
Message::ChangeColor { r, g, b } => ...— bind theo tên field (field-init shorthand giống struct destructure ở Bài 84).
Vài mẹo hữu ích:
- Không cần dùng hết field:
Message::Move(x, _) => ...—_bỏ qua field không cần. - Rename biến khi destructure struct variant:
Message::ChangeColor { r: red, g: green, b: blue } => .... - Compiler bắt buộc bạn cover đủ mọi variant (exhaustiveness check) — quên một variant là compile error, không phải warning. Đây là điểm an toàn rất quý.
- Nếu thêm variant mới vào enum, mọi
matchđang tồn tại sẽ compile-fail cho tới khi xử lý variant mới — refactor cực kỳ an toàn.
Memory Layout — Discriminant + Max Variant Size
Hiểu memory layout của enum quan trọng khi optimize hiệu năng và FFI. Quy tắc cơ bản:
Size của enum = size của discriminant (tag) + size của variant lớn nhất (payload).
Trong đó discriminant là một số nguyên nhỏ để compiler biết hiện tại enum đang chứa variant nào. Payload dùng chung một vùng bộ nhớ cho mọi variant, kích thước phải đủ chứa variant to nhất.
use std::mem::size_of;
enum Small {
A, // 0 byte payload
B, // 0 byte payload
}
enum WithBig {
Tag, // 0 byte payload
Pair(u32, u32), // 8 byte payload
Huge([u8; 1024]), // 1024 byte payload
}
fn main() {
println!("size Small = {}", size_of::<Small>());
// 1 - chỉ cần 1 byte discriminant, không payload
println!("size WithBig = {}", size_of::<WithBig>());
// 1028 - discriminant + 1024 byte (variant Huge) + padding
// CẢ variant Tag (không data) cũng pay 1028 byte!
}
Hệ quả thực hành rất quan trọng:
- Một variant to làm toàn bộ enum to. Nếu 99% trường hợp dùng variant nhỏ và 1% dùng variant to, vẫn pay cost 1028 byte cho mọi instance. Đây là lý do
Result<T, BigError>đôi khi nên đổi thànhResult<T, Box<BigError>>để giảm size — payload chỉ còn 1 pointer. - Niche optimization: Rust compiler thông minh — nếu một variant có "khoảng giá trị không dùng" (như pointer không bao giờ null), compiler có thể nhét discriminant vào đó để enum không to thêm. Đây là lý do
Option<Box<T>>chỉ tốn đúng 8 byte (kích thước pointer) — None encode bằng null pointer. - Discriminant size: default Rust chọn nhỏ nhất đủ chứa số variant (1 byte cho <=256 variant). Có thể ép kiểu qua attribute
#[repr(u8)],#[repr(u16)]cho mục đích FFI / serialization. - Layout không xác định: thứ tự discriminant và payload trong memory không cố định trong Rust mặc định — compiler tự sắp xếp tối ưu. Khi cần layout cụ thể cho FFI, dùng
#[repr(C)]hoặc#[repr(C, u8)]để compiler dùng layout giống C.
Tóm: không cần micro-optimize enum size cho hầu hết code thông thường, nhưng cần để ý khi: (a) variant chênh lệch size lớn, (b) enum được tạo hàng triệu instance, (c) FFI sang ngôn ngữ khác cần layout cố định.
Method Trên Enum Với Data
Y hệt struct, enum có thể có method qua impl block. Method thường match self để xử lý từng variant — đây là cách tổ chức code rất tự nhiên cho sum type.
#[derive(Debug)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { a: f64, b: f64, c: f64 },
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => {
std::f64::consts::PI * radius * radius
}
Shape::Rectangle { width, height } => {
width * height
}
Shape::Triangle { a, b, c } => {
// Công thức Heron
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
fn name(&self) -> &'static str {
match self {
Shape::Circle { .. } => "hình tròn",
Shape::Rectangle { .. } => "hình chữ nhật",
Shape::Triangle { .. } => "hình tam giác",
}
}
}
fn main() {
let shapes = [
Shape::Circle { radius: 2.0 },
Shape::Rectangle { width: 3.0, height: 4.0 },
Shape::Triangle { a: 3.0, b: 4.0, c: 5.0 },
];
for s in &shapes {
println!("{} - diện tích = {:.2}", s.name(), s.area());
}
// hình tròn - diện tích = 12.57
// hình chữ nhật - diện tích = 12.00
// hình tam giác - diện tích = 6.00
}
Một số điểm cú pháp quan trọng:
&selftrong method — match trênselfsẽ destructure ra reference đến field, không phải move. Trong arm dùngradius,width... là&f64, Rust tự deref khi tính toán.Shape::Circle { .. }trong armname()— dấu..bỏ qua tất cả field, chỉ match variant tag mà không cần bind data ra biến.- Có thể có nhiều
impl Shape { ... }block khác nhau — method nằm trong block nào cũng được, compiler gom chung.
Pattern "method = match self trên enum" thay thế đẹp cho virtual method / dynamic dispatch trong OOP — code tập trung tại một chỗ thay vì phân tán trong nhiều subclass, dễ đọc hơn cho domain nhỏ và không có dynamic cost.
Use Case Thực Tế: Response, AST, Event
Enum-with-data là công cụ design nền tảng — dưới đây vài ví dụ thường gặp trong production.
HTTP Response body
Một HTTP response có thể trả về text (HTML, plain text), JSON object, hoặc binary (image, file). Mô hình bằng enum tự nhiên:
use serde_json::Value;
#[derive(Debug)]
enum ResponseBody {
Empty,
Text(String),
Json(Value),
Binary(Vec<u8>),
}
fn content_length(body: &ResponseBody) -> usize {
match body {
ResponseBody::Empty => 0,
ResponseBody::Text(s) => s.len(),
ResponseBody::Json(v) => v.to_string().len(),
ResponseBody::Binary(bytes) => bytes.len(),
}
}
fn content_type(body: &ResponseBody) -> &'static str {
match body {
ResponseBody::Empty => "",
ResponseBody::Text(_) => "text/plain; charset=utf-8",
ResponseBody::Json(_) => "application/json",
ResponseBody::Binary(_) => "application/octet-stream",
}
}
Không thể có response vừa là text vừa là binary cùng lúc — sum type biểu đạt đúng ý đồ.
JSON parser AST
Cây cú pháp trừu tượng (AST) của JSON value là ví dụ nguyên bản cho sum type — một JSON value là một trong 6 dạng:
use std::collections::HashMap;
#[derive(Debug)]
enum JsonValue {
Null,
Bool(bool),
Number(f64),
String(String),
Array(Vec<JsonValue>),
Object(HashMap<String, JsonValue>),
}
fn pretty(v: &JsonValue) -> String {
match v {
JsonValue::Null => "null".to_string(),
JsonValue::Bool(b) => b.to_string(),
JsonValue::Number(n) => n.to_string(),
JsonValue::String(s) => format!("\"{s}\""),
JsonValue::Array(arr) => {
let items: Vec<String> = arr.iter().map(pretty).collect();
format!("[{}]", items.join(","))
}
JsonValue::Object(map) => {
let items: Vec<String> = map.iter()
.map(|(k, v)| format!("\"{k}\":{}", pretty(v)))
.collect();
format!("{{{}}}", items.join(","))
}
}
}
Đây cũng là cách serde_json::Value được định nghĩa — một enum 6 variant, mỗi variant chứa data tương ứng. Cú pháp pattern matching giúp viết visitor/serializer cực kỳ gọn.
UI Event
Mọi UI framework đều phải biểu diễn event — click, keypress, scroll, resize... với data khác hẳn nhau:
#[derive(Debug)]
enum UiEvent {
Click { x: i32, y: i32, button: u8 },
KeyPress(char),
Scroll { delta_x: f32, delta_y: f32 },
Resize { width: u32, height: u32 },
Close,
}
fn handle(event: UiEvent) {
match event {
UiEvent::Click { x, y, button } => {
println!("Click ({x},{y}) button {button}");
}
UiEvent::KeyPress(c) => println!("Phím: {c}"),
UiEvent::Scroll { delta_x, delta_y } => {
println!("Cuộn ({delta_x}, {delta_y})");
}
UiEvent::Resize { width, height } => {
println!("Resize {width}x{height}");
}
UiEvent::Close => println!("Đóng cửa sổ"),
}
}
Khi thêm loại event mới (ví dụ DoubleClick), compiler sẽ điểm danh mọi nơi match chưa cover — không bao giờ quên xử lý event mới như trong các framework dùng string event name. Tương tự tư duy là state machine: enum ConnectionState với các variant Disconnected / Connecting { since: Instant } / Connected { session_id: String } / Failed { error: String }.
Tổng Kết
- Enum Rust là tagged union / sum type — mỗi variant có thể mang data riêng, mạnh hơn nhiều enum C (chỉ là int) hoặc union C (không có tag an toàn).
- Tuple-like variant:
Circle(f64),Rectangle(f64, f64)— payload không tên, khởi tạo như gọi functionShape::Circle(2.5); phù hợp variant ít field và semantics rõ. - Struct-like variant:
Circle { radius: f64 }— payload có tên field, khởi tạo như structShape::Circle { radius: 2.5 }; phù hợp variant nhiều field hoặc cần self-document. - Mixed variant trong cùng enum:
Message::Quit(unit) +Message::Move(i32, i32)(tuple) +Message::ChangeColor { r, g, b }(struct) — hoàn toàn hợp lệ và phổ biến. - Destructure trong
match:Message::Move(x, y) => ...,Message::Write(text) => ...,Message::ChangeColor { r, g, b } => ...— bind data ra biến local; compiler ép cover đủ mọi variant. - Memory layout: size enum = discriminant (1 byte mặc định nếu <=256 variant) + max(variant payload size); variant nhỏ vẫn pay cost variant to nhất. Niche optimization giúp
Option<Box<T>>chỉ tốn 1 pointer. - Method trên enum qua
impl: thườngmatch selfdestructure trong từng arm; thay cho virtual method OOP, không có dynamic dispatch cost. - Use case: HTTP Response body, JSON AST (như
serde_json::Value), UI event, state machine — bất cứ tình huống "hoặc cái này, hoặc cái kia, kèm data khác nhau".
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Thiết kế enum
Vehiclevới 3 variant:Car(struct variant, cóplate: Stringvàseats: u8),Motorbike(tuple variant, có 1Stringbiển số),Bicycle(unit variant). Viết hàmdescribe(v: &Vehicle) -> Stringmatch từng variant in ra mô tả phù hợp. - Cho enum
Shapeở mục 8 (struct-like Circle/Rectangle/Triangle). Viết methodperimeter(&self) -> f64trả về chu vi: hình tròn2 * PI * r, hình chữ nhật2 * (w + h), hình tam giáca + b + c. - Cho enum
Event { Click(i32, i32), KeyPress(char), Quit }. Viết armmatchchoEvent::Click(x, y)chỉ inxmà bỏ quay— sử dụng pattern gì? - Một enum
Payloadcó 2 variant:Tiny(u8)vàBig([u8; 4096]). Ước tínhsize_of::<Payload>()và giải thích vì sao tổng size lớn dùTinychỉ chứa 1 byte. Đề xuất cách giảm size nếu phần lớn instance làTiny. - Khi nào nên dùng tuple-like variant và khi nào nên dùng struct-like variant? Nêu 2 tiêu chí cụ thể cho mỗi trường hợp.
Đáp án
enum Vehicle { Car { plate: String, seats: u8 }, Motorbike(String), Bicycle, } fn describe(v: &Vehicle) -> String { match v { Vehicle::Car { plate, seats } => format!("Ô tô biển {plate}, {seats} chỗ"), Vehicle::Motorbike(plate) => format!("Xe máy biển {plate}"), Vehicle::Bicycle => String::from("Xe đạp"), } }impl Shape { fn perimeter(&self) -> f64 { match self { Shape::Circle { radius } => 2.0 * std::f64::consts::PI * radius, Shape::Rectangle { width, height } => 2.0 * (width + height), Shape::Triangle { a, b, c } => a + b + c, } } }- Dùng dấu gạch dưới
_để bỏ qua field không cần:Event::Click(x, _) => println!("x = {x}")._match bất cứ giá trị nào mà không bind tên — tránh được cảnh báo unused variable. size_of::<Payload>()≈ 4097 byte (4096 choBig+ 1 byte discriminant, có thể padding lên 4104). Tất cả instanceTinycũng tốn 4097 byte dù chỉ chứa 1 byte data thực — vì payload phải đủ chứa variant lớn nhất. Cách giảm: dùngBox<[u8; 4096]>trong variantBigđể payload chỉ còn 1 pointer (8 byte):enum Payload { Tiny(u8), Big(Box<[u8; 4096]>) }→ size chỉ còn ~16 byte, phần lớn data sống trên heap khi cần.- Tuple-like khi: (a) variant chỉ 1-2 field và ý nghĩa rõ từ tên variant (như
Some(T),Circle(f64)); (b) ưu tiên cú pháp gọn ở callsite. Struct-like khi: (a) variant có 3+ field hoặc field cùng kiểu dễ nhầm thứ tự (nhưRectangle { width, height }); (b) cần code tự document, dễ thêm field mới mà không phá ordering ở callsite.
Bài Tiếp Theo
Bài 92: Option<T> — Some / None Thay Cho Null — sang một enum quan trọng nhất trong stdlib Rust: enum Option<T> { Some(T), None }. Rust không có null — mọi giá trị "có thể vắng mặt" đều mô hình qua Option. Bài sau sẽ phân tích lý do, các method tiện dụng (unwrap, expect, unwrap_or, map, toán tử ?) và pattern if let Some(x) = opt.
Bài này khởi đầu cụm enum chứa data (Bài 91-97). Các bài tiếp theo trong Nhóm 13 sẽ giới thiệu các enum chuẩn quan trọng (Option, Result), method trên enum, derive macro, discriminant explicit, và recursive enum cần Box.
