Trang chủ

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.