Rust không có ngoại lệ (exception) theo kiểu Java hay C++. Thay vào đó nó chia lỗi làm hai loại rạch ròi, và đối xử với chúng rất khác nhau. Lỗi lường trước được — file không tồn tại, chuỗi không parse được, chia cho 0 — là giá trị trả về bình thường, bọc trong Result<T, E>, và người gọi buộc phải xử lý. Lỗi không thể phục hồi — truy cập mảng ngoài biên, unwrap một None, một bất biến bị phá vỡ — thì panic, tháo cuộn ngăn xếp và (mặc định) kết thúc luồng. Sự phân đôi này không chỉ về phong cách: cái giá lúc chạy của hai con đường chênh nhau hàng nghìn lần, và bài này đo chính xác bao nhiêu, trong container rust:1 (rustc 1.99.1).
Result là một giá trị, và tối ưu niche áp vào nó
Result<T, E> chỉ là một enum hai biến thể: Ok(T) hoặc Err(E). Như bài 3 đã đo với enum, kích thước của nó là biến thể lớn nhất cộng cờ phân biệt — và tối ưu niche cũng áp vào:
u64 = 8 byte
Result<u64, LoiChia> = 16 byte (cờ Ok/Err + u64, u64 dùng hết bit nên cần cờ riêng)
Result<(), LoiChia> = 1 byte (Ok rỗng → niche, chỉ cần phân biệt Ok/Err)
Option<u64> = 16 byte
Result<u64, E> tốn 16 byte vì u64 dùng hết mọi mẫu bit (không có niche), phải thêm cờ. Nhưng Result<(), E> — kiểu trả về của hàm "thành công thì không có gì, thất bại thì có lỗi" — chỉ 1 byte, vì chỉ cần phân biệt hai trạng thái. Điểm mấu chốt: Result là dữ liệu thường trên stack, không phải cơ chế đặc biệt. Truyền nó đi, match nó, ? nó — tất cả là thao tác trên giá trị bình thường. Đó là lý do nó rẻ.
Trên đường thành công: Result gần như miễn phí
Câu hỏi hiệu năng thật sự: bọc mọi thứ vào Result rồi kiểm tra ở mỗi bước — có làm chậm đường thành công (đường chạy 99,99% thời gian) không? Đo bằng cùng một phép tính, một bản trả Result<u64, E>, một bản trả u64 trần, 500 triệu lần gọi:
500.000.000 lần gọi (cùng phép tính, khác ở việc bọc Result):
(A) trả Result<u64, Loi> : 383,9 ms (=9578198925699153536)
(B) trả u64 trần : 385,6 ms (=9578198925699153536)
chênh lệch: 0,4%
Chênh 0,4% — nằm trong nhiễu đo. Bọc kết quả vào Ok(...) và kiểm if let Ok(v) ở phía gọi biên dịch ra gần như cùng một mã với việc trả giá trị trần: cái cờ Ok/Err là một lần so sánh thanh ghi, trình tối ưu gấp nó vào luồng sẵn có. Nói cách khác, Result thuộc nhóm trừu tượng chi phí gần bằng không như iterator ở bài 4: bạn được kiểm lỗi tường minh ở mọi bước mà không trả giá tốc độ trên đường thành công. Đây là nền tảng khiến ? — toán tử lan truyền lỗi — dùng thoải mái khắp nơi mà không ai lo hiệu năng.

Hình 1: Result là dữ liệu thường, gần như miễn phí trên đường thành công (0,4%); panic tháo cuộn ngăn xếp, đắt gấp hàng nghìn lần — hai con đường cho hai loại lỗi.
Panic thì ngược lại: rất đắt
panic được thiết kế cho tình huống "chương trình đã sai, dừng lại cho an toàn" — nên nó không cần nhanh, và nó không nhanh. Khi panic, Rust (mặc định) tháo cuộn ngăn xếp (unwind): đi ngược từng khung hàm, gọi drop cho mọi giá trị trên đường, để giải phóng tài nguyên sạch sẽ trước khi luồng chết. Ta đo chi phí đó bằng cách bắt panic với catch_unwind và so với báo lỗi bằng Result:
100.000 lần BÁO LỖI:
(A) Result (trả Err) : 0,03 ms (dưới ngưỡng đo — tối ưu gần hết)
(B) panic + catch_unwind : 158,50 ms (1585 ns/lần)
=> panic đắt hơn Result hàng nghìn lần
Một lần panic được bắt lại tốn khoảng 1585 ns — so với Result gần như bằng 0. Tháo cuộn ngăn xếp là một quá trình nặng: tra bảng unwind, gọi drop từng khung, băng qua ranh giới catch_unwind. Kết luận không thể rõ hơn: đừng bao giờ dùng panic cho luồng điều khiển — đừng panic rồi catch_unwind như try/catch. Panic là để báo "có bug", không phải để báo "input sai"; input sai là việc của Result. (Đây cũng là lý do một thư viện tốt gần như không bao giờ panic vì dữ liệu người dùng — nó trả Result và để người gọi quyết định.)
panic=abort: cắt mã tháo cuộn khỏi nhị phân
Bản thân việc hỗ trợ tháo cuộn cũng tốn chỗ: trình biên dịch phải sinh bảng unwind và mã gọi drop cho mọi điểm có thể panic (mỗi v[i] có kiểm biên là một điểm như vậy). Nếu chương trình của bạn chỉ cần "panic thì chết luôn" chứ không cần dọn dẹp, biên dịch với panic=abort để loại bỏ toàn bộ máy móc đó:
panic=unwind (mặc định) : 4.620.576 byte
panic=abort : 4.614.584 byte
chênh: 5.992 byte mã tháo cuộn bị loại bỏ
Gần 6 KB biến mất chỉ từ một chương trình nhỏ xíu; trên nhị phân thật với nhiều điểm panic, phần tiết kiệm lớn hơn, và mã cũng gọn hơn (ít điểm rẽ nhánh hơn cho trình tối ưu). panic=abort thường dùng cho hệ nhúng, cho nhị phân cần nhỏ, hoặc khi bạn chủ ý muốn một panic là kết thúc ngay lập tức. Đổi lại, bạn mất khả năng catch_unwind — một đánh đổi hợp lý khi bạn vốn không định bắt panic.

Hình 2: Số đo thật — kích thước Result, benchmark đường thành công 0,4%, chi phí panic 1585 ns/lần, và nhị phân panic=abort nhỏ hơn ~6 KB.
Gói lại
Rust tách lỗi làm hai và tính giá hai con đường rất khác nhau. Result<T, E> cho lỗi lường trước: là dữ liệu thường (16 byte cho Result<u64,E>, 1 byte cho Result<(),E> nhờ niche), gần như miễn phí trên đường thành công (chênh 0,4% so với giá trị trần), nên ? dùng thoải mái. panic cho lỗi không phục hồi: tháo cuộn ngăn xếp tốn ~1585 ns mỗi lần, đắt hơn Result hàng nghìn lần, tuyệt đối không dùng cho luồng điều khiển; và panic=abort cắt luôn ~6 KB mã tháo cuộn nếu bạn không cần dọn dẹp. Quy tắc rút ra một câu: dùng Result cho mọi lỗi bạn lường trước, để dành panic cho bug. Bài sau bước sang lãnh địa Rust nổi tiếng nhất — đồng thời không sợ hãi: data race bị chặn ngay lúc biên dịch, đo bằng chính lỗi Send/Sync mà bài 5 đã nếm trước.