The different categories of Swift protocols

Nói chung, vai trò chủ yếu của các protocols (hoặc interfaces) là cho phép các abstractions chung được xác định trên top các triển khai cụ thể - một kỹ thuật thường được gọi là đa hình, vì nó cho phép chúng ta thay đổi (hoặc biến hình) các triển khai của mình mà không ảnh hưởng đến public API của nó. Mặc dù Swift cung cấp hỗ trợ đầy đủ cho loại đa hình dựa trên interfaces đó, nhưng các protocol cũng đóng vai trò lớn hơn nhiều trong thiết kế tổng thể của ngôn ngữ và thư viện chuẩn của nó - là một phần chính của chức năng mà Swift cung cấp thực sự. Thiết kế hướng giao thức đó cũng cho phép chúng ta sử dụng các giao thức theo nhiều cách khác nhau trong code riêng của mình - tất cả chúng có thể được chia thành bốn loại chính. Trong bài viết này này, hãy cùng xem qua các loại đó vàcách Apple sử dụng các giao thức trong framework của nó và cách chúng ta có thể xác định các giao thức của riêng mình theo cách rất giống nhau.

Enabling unified actions

Hãy bắt đầu bằng cách xem các protocols yêu cầu các loại nào phù hợp với chúng để có thể thực hiện các hành động nhất định. Ví dụ: the standard library’s Equatable protocol được sử dụng để đánh dấu rằng một loại có thể thực hiện kiểm tra đẳng thức giữa hai trường hợp, trong khi giao thức Hashable được chấp nhận bởi các loại có thể được băm:

protocol Equatable {
    static func ==(lhs: Self, rhs: Self) -> Bool
}

protocol Hashable: Equatable {
    func hash(into hasher: inout Hasher)
}

Một lợi ích lớn trong thực tế là hai khả năng đó được xác định bằng cách sử dụng type system (thay vì được hard-coded vào trình biên dịch), tức là nó cho phép chúng ta viết mã chung bị ràng buộc với các protocols đó, từ đó cho phép chúng ta sử dụng đầy đủ của những khả năng trong code đó. Ví dụ, ở đây, cách chúng ta có thể mở rộng Array bằng một phương thức cho phép chúng ta đếm tất cả các lần xuất hiện của một giá trị, với điều kiện là kiểu các phần tử trong Array conforms với Equatable:

extension Array where Element: Equatable {
    func numberOfOccurences(of value: Element) -> Int {
        reduce(into: 0) { count, element in
            // We can check whether two values are equal here
            // since we have a guarantee that they both conform
            // to the Equatable protocol:
            if element == value {
                count += 1
            }
        }
    }
}

Nói chung, bất cứ khi nào chúng ta xác định các protocols dựa trên hành động, thông thường nên làm cho các protocols đó chung chung nhất có thể (giống như EquitableHashable), vì điều đó cho phép chúng vẫn tập trung vào chính các hành động, thay vì quá ràng buộc với bất kỳ domain cụ thể nào. Vì vậy, ví dụ, nếu chúng ta muốn thống nhất một số loại để load các đối tượng hoặc giá trị khác nhau, chúng ta có thể định nghĩa một Loadable protocol với một associated type - sẽ cho phép mỗi loại đó khai báo loại Result mà nó load:

protocol Loadable {
    associatedtype Result
    func load() throws -> Result
}

Tuy nhiên, không phải mọi protocol đều định nghĩa các hành động (xét cho cùng, đây chỉ là category đầu tiên trong số bốn loại protocol). Ví dụ, trong khi tên của Cachable protocol sau đây có thể gợi ý rằng nó chứa các hành động để lưu vào bộ đệm, thì nó thực sự chỉ được sử dụng để cho phép nhiều loại khác nhau xác định các caching keys của riêng mình:

protocol Cachable: Codable {
    var cacheKey: String { get }
}

So sánh ở trên với Codable protocol tích hợp mà Cachable thừa hưởng từ đó, nó xác định các hành động cho cả mã hóa và giải mã - và ta thấy rằng chúng đã kết thúc với một chút không khớp tên. Rốt cuộc, không phải tất cả các protocols đều cần sử dụng hậu tố "able". Trong thực tế, việc buộc hậu tố đó vào bất kỳ danh từ cụ thể nào chỉ để xác định một protocol cho nó có thể dẫn đến khá nhiều nhầm lẫn - như trong trường hợp này:

protocol Titleable {
    var title: String { get }
}

Điều mà có lẽ khó hiểu hơn nữa là khi sử dụng hậu tố "able" có thể đưa ra một tên có ý nghĩa hoàn toàn khác với những gì chúng ta dự định. Ví dụ, ở đây, chúng ta đã định nghĩa một protocol với mục đích để nó hoạt động như một API cho các color containers, nhưng tên của nó gợi ý rằng nó có thể được tô màu cho các loại mà chính chúng có thể được tô màu:

protocol Colorable {
    var foregroundColor: UIColor { get }
    var backgroundColor: UIColor { get }
}

Vậy làm thế nào chúng ta có thể cải thiện một số các protocols này - cả về cách đặt tên, cũng như cách cấu trúc chúng? Hãy bắt đầu bằng cách bước ra khỏi category số một và khám phá một vài cách khác nhau để xác định các protocols trong Swift.

Defining requirements

Category số hai dành cho các protocols được sử dụng để xác định các yêu cầu chính thức cho một loại đối tượng hoặc API nhất định. Trong standard library, các protocols như vậy được sử dụng để xác định ý nghĩa của những thứ như Collection, Numeric hoặc Sequence:

protocol Sequence {
    associatedtype Iterator: IteratorProtocol
    func makeIterator() -> Iterator
}

Định nghĩa trên của Sequence cho chúng ta biết rằng vai trò chính của bất kỳ Sequence Swift nào (chẳng hạn như Array, Dictionary hoặc Range) là hoạt động như một factory để tạo các vòng lặp - lần lượt được chính thức hóa thông qua các protocol sau đây:

protocol IteratorProtocol {
    associatedtype Element
    mutating func next() -> Element?
}

Với hai protocol trên, ngay bây giờ chúng ta sẽ quay trở lại các protocols CachableColorable mà chúng ta đã xác định trước đó, để xem liệu chúng có thể được cải thiện hay không, bằng cách chuyển đổi chúng thành các định nghĩa yêu cầu thay thế. Hãy bắt đầu bằng cách đổi tên Colorable thành ColorProvider, điều này mang lại cho giao thức đó một ý nghĩa hoàn toàn mới - ngay cả khi các yêu cầu của nó vẫn giống hệt nhau. Nó không còn nghe giống như nó được sử dụng để xác định các đối tượng có thể được tô màu, mà thay vào đó, nó về việc cung cấp thông tin màu cho một số phần khác trong hệ thống của chúng ta - đó chính xác là những gì chúng ta dự định:

protocol ColorProvider {
    var foregroundColor: UIColor { get }
    var backgroundColor: UIColor { get }
}

Tương tự như vậy, lấy cảm hứng từ IteratorProtocol tích hợp, chúng ta có thể đổi tên Cachable như thế này:

protocol CachingProtocol: Codable {
    var cacheKey: String { get }
}

Tuy nhiên, một cách tiếp cận thậm chí còn tốt hơn trong trường hợp này sẽ là tách rời khái niệm generate caching keys từ các loại thực sự đang được lưu trong bộ nhớ cache - điều này sẽ cho phép chúng ta giữ model code của chúng ta khỏi các thuộc tính cụ thể của bộ đệm. Một cách để làm điều đó là chuyển key generation code của chúng ta thành các loại riêng biệt - sau đó chúng tôi có thể chính thức hóa các yêu cầu để sử dụng protocol CacheKeyGenerator:

protocol CacheKeyGenerator {
    associatedtype Value: Codable
    func cacheKey(for value: Value) -> String
}

Một lựa chọn khác sẽ là mô hình hóa ở trên bằng closure, thường là một sự thay thế tuyệt vời cho các protocols chỉ chứa một yêu cầu duy nhất.

Type conversions

Tiếp theo, chúng ta hãy xem các protocols được sử dụng để khai báo rằng một loại có thể chuyển đổi sang và từ các giá trị khác. Chúng ta lại bắt đầu với một ví dụ từ standard library - CustomStringConvertible, có thể được sử dụng để cho phép bất kỳ loại nào được chuyển đổi thành custom description string:

protocol CustomStringConvertible {
    var description: String { get }
}

Kiểu thiết kế đó đặc biệt hữu ích khi chúng ta muốn có thể trích xuất một phần dữ liệu từ nhiều loại - hoàn toàn phù hợp với mục đích của Titleable protocol của chúng ta từ trước đó. Thay vào đó, bằng cách đổi tên protocol đó thành TitleConvertible, chúng ta không chỉ giúp dễ hiểu hơn, biết được giao thức đó để làm gì, chúng ta còn làm cho code của chúng ta phù hợp hơn với standard library:

protocol TitleConvertible {
    var title: String { get }
}

Type conversion protocols cũng có thể sử dụng các methods, thay vì các properties, thường phù hợp hơn khi chúng ta mong đợi các triển khai nhất định yêu cầu số lượng tính toán hợp lý - ví dụ: khi làm việc với image conversions:

protocol ImageConvertible {
    // Since rendering an image can be a somewhat expensive
    // operation (depending on the type being rendered), we're
    // defining our protocol requirement as a method, rather
    // than as a property:
    func makeImage() -> UIImage
}

Chúng ta cũng có thể sử dụng loại protocol này để cho phép các loại nhất định được thể hiện theo các cách khác nhau - một kỹ thuật, trong số những thứ khác, được sử dụng để thực hiện tất cả các hỗ trợ tích hợp Swift cho literals - chẳng hạn như string và array literals. Ngay cả nil assignments được thực hiện thông qua một protocol:

protocol ExpressibleByArrayLiteral {
    associatedtype ArrayLiteralElement
    init(arrayLiteral elements: ArrayLiteralElement...)
}

protocol ExpressibleByNilLiteral {
    init(nilLiteral: ())
}

Mặc dù có lẽ cách này không phổ biến để xác định các protocol riêng để kết nối các literals thành một instances của một loại, chúng ta có thể sử dụng cùng một thiết kế bất cứ khi nào chúng ta muốn khai báo một protocol để thể hiện một loại sử dụng một lower-level representation. Ví dụ, ở đây, cách thức chúng ta có thể xác định ExpressibleByUUID protocol cho các identifier types có thể được tạo bằng raw UUID:

protocol ExpressibleByUUID {
    init(uuid: UUID)
}

Abstract interfaces

Cuối cùng, chúng ta hãy xem cách phổ biến nhất để sử dụng protocol trong third party code - để define abstractions để giao tiếp với nhiều loại cơ bản. Một ví dụ thú vị về pattern này có thể được tìm thấy trong Apple’s Metal framework, đó là low-level graphics programming API. Vì GPU có xu hướng thay đổi rất nhiều giữa các thiết bị và Metal nhằm mục đích cung cấp API hợp nhất để lập trình để sử dụng mọi loại phần cứng mà nó hỗ trợ, nên nó sử dụng một protocol để xác định API của nó như một abstract interface - như sau:

protocol MTLDevice: NSObjectProtocol {
    var name: String { get }
    var registryID: UInt64 { get }
    ...
}

Khi sử dụng Metal, chúng ta có thể gọi hàm MTLCreateSystemDefaultDevice và hệ thống sẽ trả về một implementation của protcol trên mà phù hợp với thiết bị mà chúng ta hiện đang chạy:

func MTLCreateSystemDefaultDevice() -> MTLDevice?

Chúng ta cũng có thể sử dụng cùng một pattern bất cứ khi nào chúng ta muốn hỗ trợ nhiều triển khai của cùng một interface. Ví dụ: chúng ta có thể xác định protocol NetworkEngine để tách rời cách chúng tôi thực hiện các network calls từ bất kỳ phương tiện mạng cụ thể nào:

protocol NetworkEngine {
    func perform(
        _ request: NetworkRequest,
        then handler: @escaping (Result<Data, Error>) -> Void
    )
}

Với những điều đã nêu ở trên, chúng ta hiện có thể tự do định nghĩa triển khai mạng cơ bản mà chúng tôi cần - ví dụ: một ứng dụng dựa trên URLSession dùng cho production và một mocked version để test:

extension URLSession: NetworkEngine {
    func perform(
        _ request: NetworkRequest,
        then handler: @escaping (Result<Data, Error>) -> Void
    ) {
        ...
    }
}

struct MockNetworkEngine: NetworkEngine {
    var result: Result<Data, Error>

    func perform(
        _ request: NetworkRequest,
        then handler: @escaping (Result<Data, Error>) -> Void
    ) {
        handler(result)
    }
}

Conclusion

Việc triển khai các protocols của Swift chắc chắn là một trong những khía cạnh thú vị nhất của ngôn ngữ, số cách mà chúng có thể được định nghĩa và sử dụng thực sự cho thấy mức độ mạnh mẽ của chúng - đặc biệt là khi chúng ta bắt đầu sử dụng đầy đủ các tính năng như các associated types và protocol extensions. Do đó, điều quan trọng là không xử lý mọi protocols theo cùng một cách, mà là thiết kế chúng theo thể loại mà chúng thuộc về. Tóm lại, đây là bốn loại mà mình muốn chia các protocols thành:

  • Action enablers, cho phép chúng ta thực hiện một bộ hành động nhất định trên từng loại tuân thủ. Chúng thường có những cái tên kết thúc với able, như Equitable.
  • Requirement definitions cho phép chúng ta chính thức hóa các yêu cầu để trở thành một loại đối tượng nhất định, ví dụ như Sequence, Numeric hoặc ColorProvider.
  • Type conversion protocol được sử dụng để cho các loại khác nhau có thể chuyển đổi thành loại khác hoặc có thể hiển thị thông qua một raw value hoặc literal - như CustomStringConvertible hoặc ExpressibleByStringLiteral.
  • Abstract interfaces hoạt động như các API hợp nhất mà nhiều loại có thể triển khai, do đó cho phép chúng ta trao đổi các triển khai theo ý muốn, encapsulate third party code hoặc mock các objects trong tests.

Hy vọng bài viết sẽ có ích với các bạn

Reference: https://www.swiftbysundell.com/articles/different-categories-of-swift-protocols/


All Rights Reserved