Trang chủ

Protocol

Bảng nhân chứng, some với any, và lời gọi thật sự đi đâu

Protocol là một bản hợp đồng. Điều làm protocol trong Swift trở nên thú vị là cùng một hợp đồng có thể được thoả mãn theo hai cách hoàn toàn khác nhau lúc chạy, mà cú pháp thì gần như không đổi.

protocol Shape {
    var area: Double { get }
}

func describe(_ shape: any Shape) -> String   // existential: một hàm, mọi hình
func describe<S: Shape>(_ shape: S) -> String // generic: mỗi hình một bản, lúc biên dịch

Bản generic được chuyên biệt hoá: trình biên dịch sinh ra một bản describe riêng cho từng kiểu cụ thể nó nhìn thấy, nội tuyến lời gọi area, và thường xoá sạch được lớp trừu tượng. Bản existential biên dịch thành đúng một hàm nhận vào một cái hộp: con trỏ tới giá trị (hoặc chính giá trị nếu nó vừa trong ba từ máy) kèm con trỏ tới bảng nhân chứng — danh sách các cài đặt của kiểu đó. Mọi lời gọi area đều đi qua bảng ấy.

Không cái nào là “đúng”. Existential là một tính năng thật sự: nó cho phép chứa nhiều kiểu khác nhau trong một mảng, trả về các kiểu khác nhau từ một hàm, và giữ kích thước nhị phân nhỏ. Nhưng nó tốn một lời gọi động và có thể tốn một lần cấp phát heap, và đó là thứ nên soi đầu tiên khi một vòng lặp nóng chạy chậm hơn mức đáng ra phải có.

some và any

Swift bắt bạn nói rõ mình muốn cái nào.

  • some Shapekiểu mờ: đúng một kiểu cụ thể, do phần cài đặt chọn, giấu khỏi bên gọi. Trình biên dịch biết nó tĩnh.
  • any Shapeexistential: kiểu nào cũng được, quyết định lúc chạy, đóng hộp.
func makeShape() -> some Shape { Circle(radius: 1) }  // luôn là Circle; bên gọi không biết
func loadShape() -> any Shape { randomShape() }       // có thể là bất cứ gì

Nguyên tắc thực dụng: dùng some cho tham số và giá trị trả về khi chỉ có một kiểu chảy xuyên qua, dùng any chỉ khi bạn thật sự cần chứa nhiều kiểu khác nhau. some ở vị trí tham số chỉ là cách viết tắt của tham số generic — func draw(_ shape: some Shape)func draw<S: Shape>(_ shape: S) biên dịch ra cùng một thứ.

Associated type, và vì sao nó từng khó chịu

Associated type là chỗ trống mà bên tuân thủ điền vào:

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(index: Int) -> Item { get }
}

Suốt nhiều năm điều này khiến protocol không dùng được như một kiểu: any Container bị từ chối, vì trình biên dịch không nói được subscript trả về cái gì. Swift 5.7 gỡ bỏ giới hạn đó bằng primary associated type, cho phép ràng buộc chúng trong ngoặc nhọn:

protocol Container<Item> {          // Item là primary associated type
    associatedtype Item
    var count: Int { get }
    subscript(index: Int) -> Item { get }
}

func sum(_ container: any Container<Int>) -> Int {
    (0..<container.count).reduce(0) { $0 + container[$1] }
}

any Container<Int> giờ đã là một kiểu dùng được. Đây cũng chính là tính năng làm cho any Collection<String>some Sequence<Int> hoạt động, và nó xoá bỏ phần lớn lý do khiến người ta từng phải tự viết các lớp bọc xoá kiểu kiểu AnyContainer.

Extension và cái bẫy điều phối lời gọi

Extension có thể cung cấp cài đặt mặc định — đó là cách thư viện chuẩn tặng bạn hàng trăm phương thức trên mọi thứ tuân thủ Sequence. Có đúng một luật mà ai cũng vấp:

protocol Greeter {
    func hello() -> String        // một yêu cầu của protocol
}

extension Greeter {
    func hello() -> String { "hello" }
    func goodbye() -> String { "bye" }   // KHÔNG phải yêu cầu
}

struct Friendly: Greeter {
    func hello() -> String { "HELLO" }
    func goodbye() -> String { "BYE" }
}

let greeter: any Greeter = Friendly()
greeter.hello()    // "HELLO" — là yêu cầu, điều phối qua bảng nhân chứng
greeter.goodbye()  // "bye"   — không phải yêu cầu, điều phối tĩnh theo `Greeter`

goodbye không nằm trong protocol nên không có ô nào trong bảng nhân chứng để ghi đè. Qua một biến kiểu Greeter, trình biên dịch phân giải tĩnh về bản trong extension. Cùng một đối tượng, cùng một tên phương thức, kết quả khác nhau tuỳ theo kiểu khai báo của biến.

Cảnh báo

Nếu một phương thức được thiết kế để cho phép tuỳ biến, hãy khai báo nó trong thân protocol. Phương thức chỉ nằm ở extension là tiện ích, không phải điểm tuỳ biến — mà khác biệt ấy chỉ lộ ra khi đi qua existential, đúng chỗ khó phát hiện nhất.

Thiết kế bằng protocol

Một protocol xứng đáng tồn tại khi thật sự có nhiều cài đặt, hoặc khi bạn cần một đường nối để kiểm thử. Hai bên tuân thủ mà một bên là mock thì đó là đường nối hợp lý. Một bên tuân thủ duy nhất và không có bản giả nào thì đó chỉ là thêm một file.

Hãy ưu tiên protocol nhỏ. Equatable có đúng một yêu cầu và được nửa thư viện chuẩn cài đặt; một ViewModelProtocol mười bốn thành viên thì được đúng một kiểu cài đặt, tồn tại chỉ để ai đó viết được any ViewModelProtocol vào một thuộc tính. Kết hợp — some Codable & Sendable — tốt hơn một protocol to, vì bên gọi nói chính xác thứ mình cần.