weak, unowned, và cú sập tôi đã phát hành
Lời khuyên bạn thấy ở khắp nơi là: dùng unowned khi tham chiếu sẽ không bao giờ nil, còn lại thì
dùng weak. Câu đó đúng về mặt kỹ thuật và không đủ để làm việc, vì “sẽ không bao giờ nil” là một
khẳng định về tương lai.
Tôi đã phát hành một cú sập dựa trên khẳng định đó.
Đoạn code
Một view controller sở hữu một coordinator, và coordinator gọi ngược lại:
final class Coordinator {
unowned let viewController: DetailViewController
init(viewController: DetailViewController) {
self.viewController = viewController
}
func finish() {
viewController.dismiss(animated: true)
}
}
Dùng unowned vì coordinator được view controller sở hữu, nên hiển nhiên view controller sống lâu
hơn nó. Lập luận ấy vững chắc, và các báo cáo sự cố thì phản đối nó khoảng mỗi tuần một lần.
Thứ thật sự đang xảy ra
Coordinator đang làm việc với mạng. Khi người dùng rời màn hình giữa lúc request đang chạy, view
controller bị hủy. Phản hồi về sau đó một giây, completion handler gọi finish(), và unowned làm
đúng việc của unowned: truy cập vùng nhớ đã giải phóng rồi sập.
Đồ thị sở hữu đúng y như tôi mô tả. Vấn đề là cái callback sống lâu hơn cả hai.
viewController → coordinator → (completion mạng, do URLSession giữ)
Mũi tên thứ ba là cái tôi đã không vẽ ra. Completion handler giữ coordinator sống qua cái chết của
view controller, và tham chiếu unowned của coordinator giờ trỏ vào vùng nhớ đã được giải phóng.
Cảnh báo
unowned không phải “weak bỏ đi phần optional”. Nó là một lời khẳng định rằng tham chiếu này sẽ
còn hợp lệ ở mọi lần được đọc, và hình phạt cho việc khẳng định sai là một cú sập —
EXC_BAD_ACCESS, trong bản release, trên máy khách hàng. weak trả về nil là một nhánh bạn xử
lý. unowned sai là một báo cáo lỗi.
Quy tắc tôi dùng bây giờ
Không phải “nó có nil không” mà là: tôi có chỉ được ra đoạn code nào bảo đảm rằng đối tượng này bị hủy trước cái mà nó trỏ tới không?
Nếu câu trả lời là một dòng cụ thể — một deinit, một phạm vi kết thúc, một chuỗi lời gọi đồng bộ —
thì unowned an toàn. Nếu câu trả lời có chứa chữ “đáng lẽ” hay “bình thường thì”, hãy dùng weak.
Cách đặt lại vấn đề đó bắt được trường hợp ở trên. Không có dòng code nào bảo đảm coordinator chết
trước view controller, vì một closure do URLSession giữ có thể giữ nó sống lâu tùy ý.
Chỗ nào mỗi cái thật sự đúng
weak cho bất cứ thứ gì vượt qua một ranh giới sở hữu mà bạn không kiểm soát hoàn toàn:
- Delegate, luôn luôn. Delegate sống lâu hơn bên ủy nhiệm là trường hợp bình thường.
- Mọi lần bắt biến trong một closure thoát ra mà có thể bị gọi muộn — completion mạng, đồng hồ, các observer thông báo.
- Tham chiếu tới cha trong một cái cây.
api.fetch { [weak self] result in
guard let self else { return }
self.handle(result)
}
unowned ở chỗ các vòng đời thật sự lồng vào nhau và nhìn thấy được ngay tại chỗ:
- Một closure được lưu trên một đối tượng, bắt chính đối tượng đó, khi closure không thể sống lâu hơn nó.
- Hai đối tượng được tạo cùng nhau và hủy cùng nhau, một cái được cái kia sở hữu chặt chẽ.
final class Loader {
private lazy var onComplete: (Data) -> Void = { [unowned self] data in
self.cache.store(data) // closure là một thuộc tính; nó chết cùng self
}
}
Trường hợp đó an toàn vì closure được lưu trên self và không thể bị gọi sau khi self đã mất. Có
một dòng code bảo đảm điều đó: chính lúc thuộc tính ấy được giải phóng.
Không cái nào — tức là bắt mạnh — khi closure thật sự nên giữ đối tượng sống. Đây là trường hợp người ta quên mất là nó tồn tại:
uploadQueue.enqueue {
self.finishUpload() // mạnh một cách có chủ đích: lượt tải lên phải hoàn tất
}
Thêm [weak self] ở đây là một cái lỗi. Nó nghĩa là một lượt tải lên lặng lẽ dừng nếu người dùng rời
màn hình, thứ chẳng ai muốn. Câu hỏi “việc này có nên hoàn tất kể cả khi không ai theo dõi không?” có
một câu trả lời thật, và đôi khi câu trả lời là có.
guard let self và điều đã thay đổi
Điệu nhảy cũ là guard let self = self else { return }. Từ Swift 5.8, chỉ guard let self là đủ,
thứ dẹp đi lý do cuối cùng để viết bản vụng về kia.
Đáng biết rằng cái guard làm cho tham chiếu trở nên mạnh trong suốt phần còn lại của closure. Đó
thường là điều bạn muốn — nó ngăn đối tượng bị hủy giữa chừng hàm của bạn, thứ vốn có thể xảy ra
giữa hai lần dùng self?.
// self có thể bị hủy giữa hai dòng này
self?.startSpinner()
self?.load()
// self được bảo đảm còn sống cho cả hai
guard let self else { return }
startSpinner()
load()
Công cụ gỡ lỗi
Nếu bạn nghi một chỗ unowned là sai, công cụ Zombies cho bạn biết ngay. Nó giữ lại các đối tượng
đã hủy dưới dạng bia mộ và báo chính xác class nào cùng thông điệp nào khi có ai chạm vào. Mười phút
với nó ăn đứt một buổi chiều ngồi đọc đồ thị sở hữu.
Để tìm những vòng giữ tham chiếu vốn khiến bạn phải với tới weak ngay từ đầu, Memory Graph Debugger
trong Xcode — cái biểu tượng đồ thị nhỏ trên thanh debug — vẽ ra các quan hệ giữ tham chiếu và đánh
dấu các vòng bằng màu tím.
Tôi đã thật sự đổi những gì
Mọi chỗ unowned trong codebase đó, khoảng một tá, đều được rà lại theo quy tắc mới. Chín cái thành
weak. Ba cái ở lại, tất cả đều là closure được lưu dưới dạng thuộc tính trên chính đối tượng mà
chúng bắt.
Từ đó tới nay: không còn EXC_BAD_ACCESS nào từ lớp lỗi đó nữa. Cái giá là thêm vài dòng
guard let self, một cuộc đổi chác mà tôi sẽ chấp nhận mọi lần.