+1

Generics trong Swift

Generics là một trong những tính năng mạnh mẽ nhất trong Swift programming language. Nhưng tuy nhiên sẽ hơi khó hiểu lúc mới bắt đầu. Trong bài viết này chúng ta sẽ xem cách mà generics hoạt động trong Swift, và những điều thú vị bạn có thể làm với nó.

Đây sẽ là những điều mà bạn sẽ hiểu sau khi đọc bài viết này:

  • Generics dùng để giải quyết vấn đề gì?
  • Placeholder types và generic functions
  • Generic type và sự ràng buộc với protocols
  • Làm việc với associated types
  • Kết hợp protocols và generics

Làm sao để sử dụng Generics trong Swift

Bạn cũng biết Swift có một hệ thống Strong type. Một khi bạn declared một variable là một String thì bạn không thể gán integer vào nó. Kiểu như thế này:

var text:String = "Hello world!"
text = 5
// Output: error: cannot assign value of type 'Int' to type 'String'

Một String luôn luôn là String. Bạn không thể gán một giá trị kiểu Int vào một biến kiểu String/

Sự nghiêm ngặt này nhìn chung là một điều tốt, vì nó giúp bạn tránh code lỗi. Nhưng nếu bạn muôn làm việc với một kiểu data mà không muốn sự nghiêm ngặt này?

Hãy xem ví dụ sau. Bạn tạo một function thêm một số vào một số khác. Như thế này:

func addition(a: Int, b: Int) -> Int
{
    return a + b
}

let result = addition(a: 42, b: 99)
print(result)
// Output: 141

Function lấy 2 parameter ab có kiểu Int, và return một giá trị của kiểu Int. Toán tử + sẽ cộng số và return kết quả.

Còn nếu bạn muốn mở rộng function của bạn để nó cũng có thể thêm một kiểu số khác như FloatDouble thì sao? Có phải bạn sẽ viết một function mới kiểu:

func addition(a: Double, b: Double) -> Double
{
    return a + b
}

Nhìn có vẻ code của bạn đã bị lặp. Nhìn chung điều này khá là tệ vì theo DRY principle thì Don’t repeat yourself.

Vậy để có thể tái sử dụng lại code của bạn mà không cần chỉ định một type cụ thể như addition(a:b:) thì Generics được sinh ra.

Làm việc với Generic Functions và Placeholder Types

Với generics bạn có thể viết một cách clear, linh hoạt và có thể tái sử dụng code. Bạn có thể tránh việc viết code trùng lặp. Hãy lấy lại function cũ addition(a:b) và sửa nó thành generic function. Như thế này:

func addition<T: Numeric>(a: T, b: T) -> T
{
    return a + b
}

Cú pháp <T: Numeeric> thêm một ràng buộc type vào placeholder. Nó định nghĩ rằng T cần conform theo protocol Numeric. Đây là một protocol được tích hợp sẵn trong Swifr cho bất kỳ giá trị số nào như Int, Double.

Nói cách khác, bạn không thể sử dụng function addition(a:b:) để add 2 đối tượng UIViewController hoặc UILabel. Nó không có ý nghĩa. Bạn chỉ có thể sử dụng giá trị conform theo Numeric protocol.

Hãy thử một ví dụ khác. Đây là một generic function có thể tìm index của một value trong array:

func findIndex<T>(of foundItem: T, in items: [T]) -> Int?
{
    for (index, item) in items.enumerated()
    {
        if item == foundItem {
            return index
        }
    }

    return nil
}

Function phía trên dùng parameter foundItem để tìm đến item trong array bằng cách sử dụng vòng lặp. Khi nó được tìm thấy, nó return index của item tìm được. Function return nil nếu nó không thể tìm thấy item, nên return type của findIndex(of:in:) sẽ là Int?

Placeholder type T được sử dụng trong function declaration. Nó nói với Swift rằng function này có thể tìm bất cứ item trong bất cứ array, với điều kiện foundItem và items trong array là cùng type. Nghĩa là bạn muốn tìm giá trị T trong mảng T.

Còn đây sẽ là cách sử dụng function:

let names = ["Ford", "Arthur", "Trillian", "Zaphod", "Deep Thought"]

if let result = findIndex(of: "Zaphod", in: names) {
    print(result)
    // Output: 3
}

Nhưng có vể function trên không thể comple. Chúng ta cần một ràng buộc type khác trên T.

Vì chúng ta sử dụng toán tử == trong function để xem 2 items có bằng nhau hay không, vậy nên T cần phải conform theo protocol Equatable. Nếu không bạn k thể sử dụng == operator. Như thế này:

findIndex<T: Equatable>(of foundItem: T, in items: [T]) -> Int?

Giống như tên của nó - Equatable protocol, nó là protocol declares toán tử ==. Toán tử == được dùng để quyết định xem 2 giá trị có bằng nhau hay không.

Swfit cung cấp một vài protocol cơ bản:

  • Equatable để so sánh bằng hoặc k bằng của 2 giá trị.
  • Comparable để so sánh value, giống như a > b
  • Hashable cho các giá trị có thể được "hased", là một đại diện integer duy nhất của giá trị đó (thường được sử dụng cho các dictionary keys)
  • CustomStringConvertible cho các giá trị có thể được biểu thị dưới dạng String, một protocol hữu ích để nhanh chóng chuyển các đối tượng tùy chỉnh thành String có thể print được.
  • NumericSignedNumeric cho các giá trị là numeric, như 42, 3.1415
  • Strideable cho các giá trị có thể offset và đo lường, như sequences, steps và ranges

Và tất nhiên bạn cũng có thể tự định nghĩa protocol của chính bạn để generic placeholder có thể conform theo. Tiếp theo chúng ta sẽ thảo luận sâu hơn về protocol và generic.

Kết hợp Generics, Protocols và Associated Types.

Vậy chúng ta đã xem xét:

  • Generic function sử dụng placeholder value để chỉ định input và output của function
  • protocol thì ràng buộc những điều đó

Bạn đã nghe về protocol trước đây đúng chứ? Một protocol chỉ định các function mà một class khi conform theo sẽ phải áp dụng nó. Khi nó thuông qua các chứ năng được yêu cầu thì class đó được cho là comform theo protocol đó.

Thử xem ví dụ. Tưởng tượng bạn có một nhà hàng bán một số sản phẩm thức ăn. Một khách hàng tới nhà hàng của bạn, và muốn ăn gì đó. Anh ta k cần quan tâm chính xác cái anh ta ăn là gì. Miễn là nó có thể ăn được.

Khách hàng định nghĩa một protocol:

protocol Edible {
    func eat()
}

Bất kỳ class nào muốn conform theo Edible cần phải hiện thược eat() funciton.

class Apple: Edible
{
    func eat() {
        print("Omnomnom!")
    }

Protocol giúp bạn viết code một cách linh hoạt và có thể tái sử dụng. Nó cũng giúp bạn kết nối code một cách không chặt chẽ. Khách hàng không cần biết chính xác phần hiện thực của cái gì mà anh ta ăn, chỉ cần biết nó có function eat(). Anh ta có thể ăn bất cứ gì, miễn là Edible.

Nhưng điều này thì có liên quan gì đến genenric?

Hãy bắt đầu với một tình huống giả định khác. Bạn đang đến một cửa hàng bách hóa, để mua một tủ sách. Và bạn có hai yêu cầu đối với tủ sách đó:

  • Bạn không nhất thiết phải đặt sách vào tủ sách
  • Nó thậm chí không cần phải là một tủ sách, nó cũng có thể là một hộp lưu trữ, một tủ đựng đồ, một tủ quần áo hoặc một tủ quần áo
  • Hmm… bạn chỉ muốn “thứ gì đó” mà bạn có thể đặt “vật phẩm” vào và lấy vật phẩm ra

Vậy chúng ta có một Storage protocol:

protocol Storage
{
    func store(item: Book)
    func retrieve(index: Int) -> Book
}

Protocol Storage khai báo hai function, một để lưu trữ sách và một để truy xuất sách, theo chỉ mục của nó. Hãy giả sử rằng Sách là một Struct đơn giản với tên sách và tác giả.

struct Book {
    var title = ""
    var author = ""
}

Bất cứ class nào cũng có thể tuân theo Storage protocol để lưu sách và truy xuất sách. Giống như BookcaseBooktrunk class sau đây:

class Bookcase: Storage
{
    var books = [Book]()

    func store(item: Book) {
        books.append(item)
    }

    func retrieve(index: Int) {
        return books[index]
    }
}

Class Bookcasee trên lưu trữ sách trong mảng sách. Nó thông qua các chức năng từ protocol Storage để lưu trữ và truy xuất sách. Bạn CHỈ có thể sử dụng Bookcase để lưu trữ các đối tượng Book.

Tuy nhiên bạn lại không chỉ muốn lưu Book, bạn muốn lưu bất cứ thứ gì trong bất cứ storage. Từ đó generic được sử dụng vào.

Chúng ta thay đổi một ít trong code. Đầu tiên ta cần thêm một associated type trong Storage. Bạn có thể định nghĩ một generic type trong protocol bằng cách sử dụng associated type. Nó giống như placeholder type mà chúng ta đã xem xét qua. Vậy nên:

protocol Storage
{
    associatedtype Item
    func store(item: Item)
    func retrieve(index: Int) -> Item
}

Ở đây bạn đã thay đổi:

  • Thêm Item associated type với associatedtype keyword.
  • Các hàm store(item:)retrieve(index:) bây giờ có thể sử dụng associated type Item

Thấy chứ, nó giống với placeholder type. Thay vì chỉ sử dụng Book, class conform theo Storage protocol bây giờ có thể lưu bất cứ kiểu này của Item. Bới vì chúng ta làm việc với protocol, nên class có thể tự quyết định cách để lưu item đó.

Hãy nghĩ rằng associated type giống như liên kết một generic type với một protocol mà k cần định nghĩa type. Bản thân generic chưa được định nghĩa vì nó phụ thuộc vào class mà tuân theo protocol. Nó được hiện thực chi tiết

Bây giờ chúng ta hiện thực nột Storage protocol trong Trunk class. Class Trunk này có thể lưu bất cứ item, không chỉ một book.

class Trunk<Item>: Storage
{
    var items:[Item] = [Item]()

    func store(item: Item) {
        items.append(item)
    }

    func retrieve(index: Int) -> Item {
        return items[index]
    }
}

Hãy xem cách định nghĩa class Trunk đính kèm <Item>. Nó là một placeholder, và nó sử dụng trong toàn bộ class. Trunk class có một array đơn giản có thể lưu và truy xuất items.

Chúng ta tạo một trunk để lưu book:

let bookTrunk = Trunk<Book>()
bookTrunk.store(item: Book(title: "1984", author: "George Orwell"))
bookTrunk.store(item: Book(title: "Brave New World", author: "Aldous Huxley"))
print(bookTrunk.retrieve(index: 1).title)
// Output: Brave New World

Trong dòng đầu tiên của code, kiểu Book được sử dụng khi khao báo kiểu của bookTrunk. Lúc này Trunk class sử dụng Book struct thay vì Item placeholder.

Như vậy, chắc chắn code sẽ trở nên linh hoạt hơn. Chúng ta định nghĩa một Shoe class với sizebrand. Chúng ta có thể lưu nó trong trunk được k? Tất nhiên là có.

let shoeTrunk = Trunk<Shoe>()
shoeTrunk.store(item: Shoe(size: 42, brand: "Nike"))
shoeTrunk.store(item: Shoe(size: 99, brand: "Adidas"))
print(shoeTrunk.retrieve(index: 0).brand)
// Output: Nike

Và bây giờ chúng ta có thể lưu mọi thứ.

Vậy nói chung:

  • Protocol Storage định nghĩa một associated type. Type này phải được quyết định bởi class tuân theo Storage protocol.
  • Trunk class sử dụng generic placeholder để hiện thực function.

=> Associated type và generic placeholder được cụ thể hoá khi chúng ta xác định được Trunk sẽ là Book. Điều này cho biết các type cụ thể mà code sẽ sử dụng. Khi biết phát triển chúng ta có thể tự xác định các type này một cách linh hoạt.

Generic Storage protocol chỉ xác định khi class nào tuân theo nó sẽ cần phải bao gồm một function để lưu và một function để truy xuất. Nó k chỉ rõ item này được lưu hay truy xuất như thế nào, cũng như type của item đó. Kết quả chúng ta có thể tạo bất kỳ storage nào có thể lưu bất kỳ item.

Từ Swift 5.1 chúng ta có cách tiếp cận khác với để làm việc generic: opaque types. some keyword giúp bạn ẩn đi type cụ thể mà property hay function trả về. Kiểu cụ thể được quyết định bới việc hiện thực chính nó. Vậy nên opaque type thỉnh thoảng được gọi là reverse generics.

Nguồn: https://learnappmaking.com/generics-swift-how-to/#working-with-generic-functions-and-placeholder-types


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.