Bối cảnh
Thất bại trong Zero không phải là một luồng điều khiển riêng chạy song song. Nó là một phần của luồng điều khiển thông thường, được khai báo trên chữ ký hàm và được xác nhận tại mọi nơi gọi. Hai thành phần làm nên cơ chế này:
raisestrên chữ ký hàm: "hàm này có thể thất bại."checktại nơi gọi: "nếu lời gọi này thất bại, hãy làm hàm của tôi thất bại với cùng lỗi đó."
Kết hợp hai thứ này là đủ để diễn đạt những gì hầu hết các ngôn ngữ phải dùng try/catch hoặc kiểu Result để xử lý.
Khai báo một hàm có thể thất bại
Thêm raises sau kiểu trả về:
fun validate(ok: Bool) -> i32 raises { InvalidInput } {
if ok == false {
raise InvalidInput
}
return 42
}
Có hai cách đọc chữ ký này:
- "
validatetrả về mộti32hoặc ném raInvalidInput." - "Tập các kết quả có thể có là {
i32,InvalidInput}."
Cả hai cách đọc đều đúng. Trình biên dịch theo dõi cả hai khả năng và yêu cầu nơi gọi xử lý tường minh từng khả năng.
Bạn cũng có thể viết một mệnh đề raises trơn:
pub fun main(world: World) -> Void raises {
check world.out.write("hello\n")
}
raises (không có danh sách lỗi) nghĩa là "hàm này có thể thất bại với bất kỳ lỗi nào". Với main, đây là dạng quen thuộc: chương trình có thể thoát với mã trạng thái khác 0 nếu có gì đó sai, và runtime lo việc hiển thị lỗi.
Với các hàm nằm sâu hơn trong ngăn xếp lời gọi (call stack), hãy ưu tiên dạng tường minh raises { ErrorA, ErrorB } để các trường hợp thất bại được ghi rõ ở mỗi tầng.
Ném một lỗi
Bên trong một hàm có thể thất bại, raise thoát khỏi hàm với lỗi được chỉ định:
fun validate(ok: Bool) -> i32 raises { InvalidInput } {
if ok == false {
raise InvalidInput
}
return 42
}
raise InvalidInput tạo ra một lỗi InvalidInput tại dòng đó. Hàm không chạy tiếp sau điểm này: quyền điều khiển quay về nơi gọi, và nơi gọi nhận được lỗi thay vì một i32. Mệnh đề raises liệt kê những kiểu lỗi duy nhất mà hàm này được phép ném; ném một lỗi không có trong danh sách sẽ gây lỗi biên dịch.
Truyền lỗi bằng check
Nơi gọi một hàm có thể thất bại phải xác nhận khả năng thất bại đó. Cách xác nhận phổ biến nhất là check:
fun run() -> Void raises { InvalidInput } {
check validate(true)
}
check validate(true) làm hai việc:
- Gọi
validate(true). - Nếu
validateném lỗi, truyền lỗi đó lên trên:runném cùng lỗi đó cho nơi gọi nó.
Để việc truyền lỗi được phép, run phải khai báo trong mệnh đề raises của chính nó rằng nó có thể ném InvalidInput (hoặc một lỗi tương thích). Trình biên dịch sẽ kiểm tra điều này. Nếu chữ ký của run ghi raises { OtherError }, việc truyền lỗi sẽ không biên dịch được vì InvalidInput không nằm trong tập lỗi.
Một ví dụ hoàn chỉnh lấy từ các mẫu chính thức của ngôn ngữ. Nhấn Chạy để xem lỗi được truyền thành công:
Kiểu lỗi đi theo chữ ký hàm suốt dọc ngăn xếp lời gọi. raises trơn của main chấp nhận mọi lỗi mà run có thể ném, nên lỗi được truyền lên an toàn.
Vì sao không dùng try/catch?
Nguyên tắc thiết kế đằng sau raises/check là thất bại không bao giờ bị ẩn tại nơi gọi. Trong một ngôn ngữ dùng try/catch, một ngoại lệ có thể âm thầm đi xuyên qua một hàm mà hàm đó thậm chí không biết mình có liên quan: hàm trông có vẻ thuần khiết, nhưng một lời gọi sâu đâu đó trong thân hàm ném ngoại lệ và ngoại lệ đó đi ngược lên xuyên qua nó.
Điều đó tiện cho người viết đoạn code ném lỗi. Nhưng lại tốn kém cho tất cả những người còn lại:
- Người đọc (và agent) không thể nhìn chữ ký mà biết một hàm có tham gia vào các nhánh thất bại hay không.
- Tái cấu trúc code trở nên đáng lo: chuyển một lời gọi sang hàm khác có thể thay đổi những ngoại lệ nào có thể xảy ra.
- Code khôi phục nằm xa chỗ thực sự biết phải làm gì.
Zero trả chi phí ngay từ đầu, bằng chú thích trên mọi hàm có thể thất bại và check ở mọi lời gọi có thể thất bại, để có được đặc tính "các trường hợp thất bại mà một hàm tham gia đều nhìn thấy được từ chữ ký của nó." Đó là đặc tính mà cả con người lẫn agent đều có thể dựa vào.
Sao không chỉ dùng Result<T, E>?
Bạn có thể diễn đạt cùng ý đó bằng một choice: một kiểu Result<T, E> với hai biến thể ok và err. Zero cũng hỗ trợ mẫu này; nó hữu ích khi thất bại là dữ liệu mà bạn muốn kiểm tra, lưu trữ hoặc truyền đi.
Điều mà raises/check bổ sung là một quy ước ở mức cú pháp cho trường hợp phổ biến: "nếu lời gọi này thất bại, hãy làm hàm của tôi thất bại theo cùng cách." Không có nó, mọi lời gọi sẽ phải bọc trong một match mà gần như lúc nào cũng chỉ đóng gói lại lỗi vào Result của chính hàm gọi. check là lối tắt cho việc đó, với trình biên dịch đảm bảo việc truyền lỗi đúng kiểu.
Tóm lại:
Result<T, E>(một choice): khi bạn muốn kiểm tra hoặc mang theo thất bại như một giá trị.raises+check: khi bạn chỉ muốn truyền thất bại lên trên ngăn xếp lời gọi.
Cả hai đều có sẵn; chúng đáp ứng những nhu cầu tiện dụng khác nhau.
Nhiều kiểu lỗi
Một hàm có thể ném nhiều loại lỗi:
fun parse(input: String) -> i32 raises { Empty, Malformed } {
if std.mem.len(input) == 0 {
raise Empty
}
// ... parsing logic ...
raise Malformed
}
Nơi gọi có thể:
- Truyền lỗi bằng
check parse(input)nếu chữ ký của chính nó liệt kê cảEmptyvàMalformed(hoặc một tập lớn hơn). - Xử lý tường minh một hoặc cả hai lỗi bằng
matchhoặc các cấu trúc kiểutrymà ngôn ngữ cung cấp cho mục đích này.
Cú pháp chính xác cho việc xử lý chi tiết (so khớp từng kiểu lỗi cụ thể hay truyền tiếp các lỗi khác) là một trong những phần có thể thay đổi ở Zero trước bản 1.0. Phần ổn định là hợp đồng: mọi kiểu lỗi mà một hàm có thể ném đều nằm trong chữ ký của nó.
Một mô hình tư duy
Hệ thống xử lý thất bại của Zero là phiên bản trung thực và chặt chẽ của thứ mà mọi ngôn ngữ mệnh lệnh đều có:
| Khái niệm | Try/Catch | Zero |
|---|---|---|
| Đánh dấu hàm có thể thất bại | không có (ngầm định) | raises { ... } |
| Ném một lỗi | throw e | raise E |
| Truyền lên nơi gọi | nổi lên mà không ai thấy | check call(...) |
| Xử lý tại chỗ | try { ... } catch(e) { ... } | match trên một Result được trả về, hoặc một dạng xử lý có kiểu |
Khác biệt về hành vi là nhỏ. Khác biệt về chú thích thì lớn, và đó là có chủ ý. Hiệu ứng phải tường minh. Thất bại cũng là hiệu ứng.
Lưu ý về phong cách
- Dùng
checkthoải mái. Đó là lựa chọn mặc định đúng khi bạn không có việc gì cụ thể cần làm ở tầng này. - Tránh
raisestrơn trên các hàm hỗ trợ nội bộ. Tập lỗi càng hẹp, chữ ký càng hữu ích. - Đi kèm các thao tác có thể thất bại với capability mà chúng cần. Một hàm dùng
Worldđể ghi ra stdout gần như luôn cầnraisesvì thao tác ghi có thể thất bại.
Tiếp theo: chẩn đoán JSON
raises/check hoạt động cùng với các thông báo chẩn đoán của trình biên dịch Zero: khi bạn viết check cho một hàm không khai báo đúng lỗi, trình biên dịch cho bạn biết chính xác chỗ sai ở dạng có cấu trúc. Tài liệu tiếp theo trình bày về chẩn đoán JSON, luồng dữ liệu máy đọc được mà agent dùng để sửa code.
Câu hỏi thường gặp
raises trong Zero có nghĩa là gì?
raises trên chữ ký hàm khai báo rằng hàm đó có thể thất bại. Một raises trơn cho phép mọi kiểu lỗi. Dạng cụ thể như raises { InvalidInput } giới hạn hàm chỉ được thất bại với các kiểu lỗi đã liệt kê. Nơi gọi phải xác nhận khả năng thất bại này, bằng check hoặc bằng một cách xử lý tường minh khác.
Toán tử check làm gì?
check expr tính expr, và nếu nó tạo ra lỗi thì truyền lỗi đó lên nơi gọi hàm hiện tại. Hãy hiểu nó là 'chạy cái này, nếu thất bại thì làm hàm gọi tôi thất bại với cùng lỗi đó.' Để mệnh đề raises của hàm gọi cho phép việc truyền lỗi này, bản thân hàm gọi phải khai báo rằng nó có thể ném một lỗi tương thích.
Ném lỗi trong Zero như thế nào?
Dùng raise ErrorName bên trong một hàm có mệnh đề raises chứa lỗi đó. Ví dụ: if ok == false { raise InvalidInput }. Hàm thoát ngay tại điểm đó; lỗi trở thành kết quả của hàm, và nơi gọi có thể check kết quả này.
Vì sao Zero không dùng try/catch?
Try/catch để ngoại lệ âm thầm đi xuyên qua những hàm không hề biết về chúng. Thiết kế của Zero là chữ ký của mỗi hàm phải thừa nhận các trường hợp thất bại mà nó tham gia. Không có luồng điều khiển ẩn: nếu một hàm có thể thất bại, chữ ký của nó nói rõ điều đó, và mọi nơi gọi phải xác nhận tường minh bằng check.
Một hàm trong Zero có thể ném nhiều kiểu lỗi không?
Có. Hãy liệt kê chúng trong mệnh đề raises { ... }, phân tách bằng dấu phẩy (hoặc theo cú pháp tập lỗi hiện tại của Zero). Tập các lỗi có thể xảy ra là một phần trong hợp đồng của hàm, giống như kiểu tham số và kiểu trả về. Nơi gọi có thể so khớp mẫu để biết lỗi nào đã được ném, hoặc chỉ cần truyền tiếp bằng check.