Menu

Choice và match trong Zero: tagged union và so khớp mẫu

Cách choice khai báo một tagged union trong Zero và cách match rẽ nhánh đầy đủ theo các biến thể của nó: phiên bản kiểu tổng và so khớp mẫu của Zero.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Tagged union theo kiểu Zero

choice khai báo một kiểu mà giá trị của nó là một trong nhiều biến thể có tên, mỗi biến thể mang payload riêng:

choice Result {
    ok:  i32,
    err: String,
}

Một giá trị Result hoặc là ok mang một i32, hoặc là err mang một String. Không bao giờ là cả hai, không bao giờ là không có gì. Hệ thống kiểu đảm bảo điều đó, và match giúp bạn xử lý nó một cách tiện lợi.

Đây cũng chính là ý tưởng mà các ngôn ngữ khác gọi là "tagged union", "sum type", "discriminated union" hay "kiểu dữ liệu đại số". Zero gọi nó là choice và giữ ngữ pháp thật nhỏ.

Khai báo một choice

choice Name {
    variantA: PayloadTypeA,
    variantB: PayloadTypeB,
}

Mỗi dòng liệt kê một biến thể. Tên do bạn đặt; kiểu sau dấu hai chấm là payload mà biến thể đó mang. Một biến thể không cần payload thì dùng Void:

choice Token {
    word:   String,
    number: i32,
    eof:    Void,
}

Token.eof là biến thể không có payload hữu ích (kiểu payload của nó là Void), rất tiện cho các trường hợp kiểu điểm kết thúc.

Tạo một giá trị choice

Tạo giá trị bằng cách ghi tên kiểu, rồi tên biến thể, rồi truyền payload:

let success = Result.ok(42)
let failure = Result.err("validation failed")

Kiểu payload phải khớp với payload đã khai báo cho biến thể. Result.ok("hello") sẽ là lỗi biên dịch vì ok cần một i32.

Suy luận kiểu cũng hoạt động ở đây. Nếu vế phải xác định đầy đủ kiểu, bạn có thể viết let success = Result.ok(42) và kiểu của biến là Result. Ghi kiểu tường minh cũng được khi bạn muốn nó hiện rõ ngay tại chỗ khai báo:

let success: Result = Result.ok(42)

So khớp một choice

match là cách bạn đọc một giá trị choice. Cấu trúc như sau:

match value {
    .variantA => binding { /* body when value is variantA, with payload in `binding` */ }
    .variantB => binding { /* body when value is variantB */ }
}

Một ví dụ hoàn chỉnh lấy từ repo chính thức của Zero. Nhấn Chạy để xem nhánh .ok được thực thi:

Đọc match theo đúng nghĩa đen: "tùy vào biến thể mà result đang giữ, chạy nhánh tương ứng và gắn payload vào tên đã chọn". Trong nhánh .ok, value là payload kiểu i32. Trong nhánh .err, message là payload kiểu String. Mỗi nhánh là một phạm vi riêng; biến chỉ nhìn thấy được bên trong phần thân của chính nhánh đó.

Tính đầy đủ

Đây là lợi ích lớn của match so với chuỗi if/else if: trình biên dịch kiểm tra mọi biến thể đều có nhánh. Nếu bạn quên trường hợp .err, bạn không nhận được một lần rơi xuống nhánh mặc định lúc chạy, mà nhận một lỗi biên dịch:

{
    "code": "MAT001",
    "message": "match is not exhaustive: missing variant 'err'",
    "line": 9
}

(Mã lỗi chỉ mang tính minh họa; điều cốt lõi là cam kết.)

Thêm một biến thể mới vào choice, chẳng hạn Result.timeout: Void, và mọi match trên Result trong codebase sẽ thành lỗi biên dịch cho tới khi bạn xử lý trường hợp mới. Đó là tính năng, không phải việc vặt: trình biên dịch đang chỉ cho bạn chính xác chỗ nào cần chú ý đến trường hợp mới.

Khi bạn không cần payload

Nếu payload của một biến thể là Void hoặc bạn đơn giản không quan tâm đến nó ở nhánh này, bạn có thể bỏ qua biến gắn, nhưng vẫn phải viết nhánh đó để thỏa mãn tính đầy đủ:

match token {
    .word   => w { /* use w */ }
    .number => n { /* use n */ }
    .eof    => _ { /* nothing to bind */ }
}

Cách viết chính xác để "bỏ qua payload" có thể thay đổi trong Zero trước 1.0 (bạn có thể thấy _ hoặc đơn giản là bỏ biến gắn). Ý tưởng cốt lõi, mọi biến thể đều có nhánh dù có payload hay không, là phần ổn định.

Các mẫu thường gặp

Kiểu lỗi dạng Result

Đây chính là ví dụ mà repo chính thức sử dụng:

choice Result {
    ok:  i32,
    err: String,
}

Các hàm có thể thành công kèm giá trị hoặc thất bại kèm thông báo sẽ trả về một Result. Nơi gọi dùng so khớp mẫu để lấy ra giá trị hoặc thông báo. Hệ thống raises/check của Zero lo việc lan truyền lỗi cho các thao tác có thể thất bại; Result hữu ích khi bạn muốn giữ lại một giá trị thành công hoặc thất bại như dữ liệu.

Token của bộ phân tích cú pháp

choice Token {
    word:   String,
    number: i32,
    eof:    Void,
}

Một bộ tách token tạo ra một luồng các Token. Mỗi nơi tiêu thụ so khớp theo biến thể để quyết định làm gì: in từ ra, cộng số lại, thoát khi gặp eof.

Máy trạng thái

choice State {
    waiting:    Void,
    processing: i32,
    done:       String,
}

processing mang ID của tác vụ hiện tại; done mang kết quả cuối cùng. Mỗi lần chuyển trạng thái là một giá trị State mới, không có trường khả biến nằm rải rác trong một shape.

Choice kết hợp generic

choice có thể dùng generic giống như shape:

choice Maybe<T> {
    some: T,
    none: Void,
}

Maybe<i32> là "một số nguyên có thể có hoặc không". Maybe<String> là "một chuỗi có thể có hoặc không". Mẫu này cũng xuất hiện trong thư viện chuẩn của Zero và phù hợp hơn nhiều so với một giá trị null làm dấu hiệu: một khi đã match theo kiểu này, bạn không thể quên trường hợp .none.

Khi nào dùng choice, shape hay enum

Tóm tắt nhanh từ shape và enum:

  • Shape: bản ghi có nhiều trường, tất cả cùng hiện diện.
  • Enum: một trong N nhãn, không có dữ liệu thêm.
  • Choice: một trong N biến thể, mỗi biến thể mang một payload.

Hầu hết mô hình dữ liệu trong một chương trình thực tế là sự kết hợp của ba loại này. Sự rõ ràng khi bắt đầu từ câu hỏi "đây là và, hoặc, hay hoặc kèm dữ liệu?" là một trong những lợi ích bị đánh giá thấp khi làm việc với một ngôn ngữ nhỏ.

Tiếp theo: capability World

choice và match bao quát phần dữ liệu của Zero. Chương tiếp theo nói về hiệu ứng: cách chương trình Zero tương tác với thế giới bên ngoài. Nó bắt đầu với capability World, đối tượng kiểm soát mọi thao tác I/O.

Câu hỏi thường gặp

Choice trong Zero là gì?

choice là kiểu tagged union của Zero: một giá trị thuộc về một trong nhiều biến thể có tên, mỗi biến thể mang kiểu payload riêng. Ví dụ: choice Result { ok: i32, err: String }. Một giá trị Result hoặc là ok mang một i32, hoặc là err mang một String. Bạn tạo nó bằng Result.ok(42) hoặc Result.err("bad").

Match hoạt động thế nào trong Zero?

match value { .variantA => binding { ...body } .variantB => binding { ...body } } rẽ nhánh theo biến thể mà value đang giữ. Mỗi nhánh so khớp một biến thể, đặt tên cho biến gắn với payload và chạy phần thân của nó. Trình biên dịch kiểm tra rằng bạn đã xử lý mọi biến thể: tính đầy đủ chính là lợi ích lớn nhất so với if/else if.

Làm sao để tạo một giá trị choice?

Tạo nó bằng cách ghi tên kiểu và tên biến thể rồi truyền payload: let r: Result = Result.ok(42) hoặc let r = Result.err("validation failed"). Kiểu payload phải khớp với payload đã khai báo cho biến thể; truyền sai kiểu là lỗi biên dịch.

Choice và enum khác nhau thế nào?

Các biến thể của enum chỉ là nhãn, không có payload. Mỗi biến thể của choice mang một giá trị thuộc kiểu đã khai báo. Nếu bạn cần gắn dữ liệu vào một trong các trường hợp (thông báo lỗi, kết quả thành công, token đã phân tích), hãy dùng choice. Nếu các trường hợp chỉ là nhãn thuần, hãy dùng enum.

Vì sao nên dùng match thay vì if-else với choice?

match đầy đủ ngay từ cấu trúc: trình biên dịch kiểm tra mọi biến thể đều được xử lý, nên khi thêm biến thể mới về sau, bạn buộc phải cập nhật mọi chỗ rẽ nhánh theo kiểu đó. Một chuỗi if/else if thì âm thầm rơi xuống nhánh cuối, che giấu trường hợp bị thiếu cho tới khi nó lộ ra thành bug trên production.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu