Ném lỗi, cho sập, và chọn giữa hai bên
Swift có bốn cơ chế riêng biệt để xử lý chuyện hỏng hóc, và dùng nhầm cơ chế chính là cách biến một tình huống khôi phục được thành một báo cáo sập ứng dụng.
| Cơ chế | Dùng cho |
|---|---|
throws |
Thất bại lường trước được mà bên gọi xử lý được: dữ liệu vào sai, thiếu tệp, mạng từ chối |
Optional |
Sự vắng mặt không cần giải thích: không có khoá đó, không có phần tử đầu |
precondition / fatalError |
Lỗi của lập trình viên: một bất biến do chính code bạn bảo đảm đã bị phá |
assert |
Như trên, nhưng chỉ ở bản debug |
Ranh giới đáng vạch ra: nếu thất bại do thế giới bên ngoài gây ra thì throw. Nếu do lập trình viên gây ra thì cho sập. Một bộ phân tích cú pháp nhận dữ liệu hỏng thì nên throw; nhưng nếu chính nó phát hiện độ lệch nội bộ của mình vượt phạm vi thì nên sập, vì không có cách khôi phục đúng đắn nào từ một bất biến đã gãy.
Error không có yêu cầu nào. Kiểu nào cũng làm lỗi được, và enum là lựa chọn tự nhiên:
enum LoadError: Error {
case notFound(path: String)
case corrupted(offset: Int)
case tooLarge(bytes: Int, limit: Int)
}
Hãy đính kèm bối cảnh ngay tại nơi ném lỗi, chỗ mà nó còn được biết. Một lỗi chỉ nói .corrupted và
không gì khác buộc mọi tầng phía trên phải đoán.
Với thứ người dùng có thể nhìn thấy, hãy thêm LocalizedError:
extension LoadError: LocalizedError {
var errorDescription: String? {
switch self {
case .notFound(let path): "Không có tệp nào ở \(path)."
case .corrupted(let offset): "Tệp bị hỏng tại byte \(offset)."
case .tooLarge(let bytes, let limit): "\(bytes) byte vượt giới hạn \(limit) byte."
}
}
}
Cho tới gần đây, mọi throws đều mang nghĩa “ném ra cái gì đó, tự lo lấy”. Swift 6 cho phép gọi tên
nó:
func load(_ path: String) throws(LoadError) -> Data
Giờ trình biên dịch biết trọn bộ các thất bại có thể xảy ra, catch vét cạn được mà không cần
default, và lỗi có thể là một enum đóng băng trong ngữ cảnh nhúng, không cần hộp existential nào.
Hãy dùng nó ở nơi tập lỗi thật sự đóng và là một phần của hợp đồng — một trình phân tích cú pháp,
một bộ giải mã, một thư viện nhỏ. Tránh dùng ở ranh giới API còn sẽ mở rộng: thêm một case vào hàm
typed throws là thay đổi phá vỡ mã nguồn với mọi catch vét cạn, đúng cái bẫy của enum không đóng
băng. throws không kiểu vẫn là lựa chọn mặc định đúng cho code ứng dụng.
rethrows nói rằng “tôi chỉ ném lỗi nếu closure bạn đưa tôi ném lỗi”:
extension Sequence {
func count(where predicate: (Element) throws -> Bool) rethrows -> Int {
var total = 0
for element in self where try predicate(element) { total += 1 }
return total
}
}
Bên gọi truyền closure không ném lỗi thì không cần try. Không có rethrows, mọi người gọi mọi hàm
bậc cao đều bị ép phải xử lý lỗi mà họ không hề cần.
defer chạy trên đường ra khỏi phạm vi, ra kiểu gì cũng chạy — return, throw, hay rơi hết thân hàm:
func process(_ path: String) throws -> Report {
let handle = try FileHandle(forReadingFrom: URL(filePath: path))
defer { try? handle.close() }
let header = try readHeader(handle)
guard header.isSupported else { throw LoadError.corrupted(offset: 0) }
return try buildReport(from: handle, header: header)
}
Lệnh đóng nằm ngay cạnh lệnh mở, và chỉ có đúng một lệnh đóng dù sau này hàm mọc thêm bao nhiêu lối
ra. Nhiều khối defer chạy theo thứ tự ngược, khớp với cách các tài nguyên lồng nhau.
Cảnh báo
defer lấy giá trị tại thời điểm khối lệnh chạy, không phải lúc nó được viết ra. Một
defer { print(count) } đặt ở đầu hàm sẽ in ra count cuối cùng, không phải số 0. Điều này gần
như luôn là thứ bạn muốn, và đôi khi gây bối rối dữ dội.
Result<Success, Failure> biến một lỗi thành giá trị mà bạn lưu được, truyền đi được, hoặc đưa vào
completion handler. Khi async/await đã phủ gần hết các API kiểu callback, chỗ dùng còn lại của
nó khá hẹp: lưu đệm một thất bại, gom kết quả của một lô việc, hoặc đi qua một API không thể async.
let outcomes: [Result<Data, LoadError>] = paths.map { path in
Result { try load(path) }.mapError { $0 as! LoadError }
}
Nếu bạn thấy mình vừa tạo Result xong đã đổi ngược về throws bằng try result.get(), thì có lẽ
ngay từ đầu hàm đó nên là throws.