Linh Tạ

Tạo model từ data không hoàn chỉnh - Phần II

phần trước mình đã giới thiệu 4 cách tổng hợp data để tạo thành model hoàn chỉnh:

Optional properties Default value Biến trung gian Curried closures
Tiện 🟢 🟢 🔴 🟡
Logic chặt chẽ 🔴 🔴 🟡 🟢
Dễ refactor 🔴 🔴 🔴 🟡

Tạo optional property và default value khiến logic code không chặt chẽ, trong khi định nghĩa các biến trung gian lại cồng kềnh và khó refactor. Dùng curried closure khắc phục hoàn toàn điểm yếu của 2 hướng tiếp cận đầu tiên nhưng ta vẫn phải định nghĩa lại các closure khi model User thay đổi. Một điểm yếu khác của closure là việc ta không thể inspect các giá trị đã được gán.

Trong bài viết ngày hôm nay, chúng ta sẽ cùng tìm hiểu phương pháp thứ 5 và cũng là phương pháp tốt nhất cho bài toán tổng hợp data này.

Tiêu chí chấp thuận

Đáp án thoả mãn yêu cầu của bài toán phải đạt 6 tiêu chí quan trọng:

Nguồn GIPHY

KeyPath

Để làm được điều này, ta cần sự trợ giúp từ KeyPath. KeyPath<Root, Value> là class chứa thông tin truy cập property thuộc type Value của type Root bất kì. Ta có thể dùng nó để truy cập hoặc thay đổi giá trị property mà nó trỏ đến.


struct User {
    let email: String
    let password: String
    let name: String
    let address: String
}

let user = User(email: "[email protected]",
                password: "linhdeptrai",
                name: "Linh Ta",
                address: "Trong tim em")

// namePath trỏ đến property name thuộc type String của User
let namePath: KeyPath<User, String> = \User.name

// dùng namePath để lấy giá trị
let name: String = user[keyPath: namePath] // Linh Ta

// ngoài ra ta có thể truy cập trực tiếp như sau
let email: String = user[keyPath: \.email] // [email protected]

Số keyPath của một type chính bằng số non-private stored hoặc computed property của type đó. Model User trên có 5 stored property thuộc type String, ứng với 5 keyPath type KeyPath<User, String>.

Vì keyPath luôn được định nghĩa bởi RootValue, ta không thể dùng biến type KeyPath để lưu các keyPath cùng Root nhưng có type Value khác nhau.


struct VIP {
    let name: String
    let email: String
    let point: Double
    let subscriptionDate: Date
}

// path có dạng KeyPath<VIP, String>
var path = \VIP.name

// ✅ có thể gán vì \VIP.email cùng dạng với \VIP.name
path = \VIP.email

// ❌ không thể gán KeyPath<VIP, Double> cho KeyPath<VIP, String>
path = \VIP.point

// ❌ không thể gán KeyPath<VIP, Date> cho KeyPath<VIP, String>
path = \VIP.subscriptionDate

Trong trường hợp này, ta phải sử dụng class PartialKeyPath<Root> thay vì KeyPath<Root, Value>. PartialKeyPath chỉ chứa thông tin về type của Root mà không quan tâm đến Value.


// KeyPath có thể cast trực tiếp sang PartialKeyPath
var path = \VIP.name as PartialKeyPath<VIP>

// ✅ có thể gán vì cùng Root type
path = \VIP.email

// ✅ có thể gán vì cùng Root type
path = \VIP.point

// ✅ có thể gán vì cùng Root type
path = \VIP.subscriptionDate

// ❌ Không thể gán PartialKeyPath<User> cho PartialKeyPath<VIP>
path = \User.name

Ta sử dụng PartialKeyPath như một KeyPath bình thường.


let vip = VIP(name: "VIP",
              email: "[email protected]",
              point: 1000,
              subscriptionDate: .now)

let path = \VIP.email as PartialKeyPath<VIP>
let email = vip[keyPath: path] // [email protected]

Bạn có thể tìm hiểu thêm về các dạng KeyPath tại Swift language guide hoặc Apple document.

MultiTypeDictionary - Dictionary đa hệ

Nhờ việc KeyPath lưu reference của property và cho phép truy cập property một cách type safe, kết hợp với PartialKeyPath ta đã có chìa khoá để giải bài toán ban đầu.

Đầu tiên, ta sẽ tạo một struct chứa dictionary thuộc type [PartialKeyPath<Root>: Any] để lưu các keyPath ứng với từng property và giá trị được gán.


struct MultiTypeDictionary<Root> {
    private var dict: [PartialKeyPath<Root>: Any] = [:]
}

Root ở đây chính là class/struct mà ta muốn giới hạn tập giá trị của keyPath. Nói một cách khác, dictionary trên chỉ có thể nhận vào các property hợp lệ của Root. Điều này cực kì quan trọng bởi compiler sẽ ngăn ta gán các key vô nghĩa và đảm bảo tính chặt chẽ về mặt logic. Tuyệt hơn nữa khi ta không cần phải thay đổi 1 dòng coden nào khi model của Root thay đổi.

Tiếp theo, để MultiTypeDictionary nhận vào và trả ra đúng type của property ứng với keyPath, ta sẽ cung cấp phiên bản custom của subscript.


struct MultiTypeDictionary<Root> {
    private var dict: [PartialKeyPath<Root>: Any] = [:]

    subscript<Value>(keyPath: KeyPath<Root, Value>) -> Value? {
        get { dict[keyPath] as? Value }
        set { dict[keyPath] = newValue }
    }
}

// thu thập data và lưu vào userInfo
var userInfo = MultiTypeDictionary<User>()
userInfo[\.email] = "[email protected]"
userInfo[\.password] = "123"
userInfo[\.name] = "Linh Ta"
userInfo[\.address] = "Trong tim em"

Function subscript nhận vào một KeyPath<Root, Value>Root chính là Root khi khởi tạo MultiTypeDictionary, và Value là type của property ứng với keyPath. Với subscript, ta vừa có thể truy cập MultiTypeDictionary như một dictionary bình thường, vừa có thể đảm bảo 100% rằng ta không thể gán nhầm type cho giá trị của property.

Can't assign wrong type to keyPath
Không thể gán sai type của property mà keyPath trỏ đến

Optional properties

MultiTypeDictionary đã gần như hoàn chỉnh tuy nhiên ta vẫn chưa thể dừng lại tại đây bởi nó không hoạt động đúng với các biến optional.

Wrong return type for optional property

Biến age ở trên có type Int? nhưng lại được MultiTypeDictionary trả ra dưới dạng Int??. Để hiểu được nguyên nhân, ta cần biết cách type casting hoạt động trong swift.


struct MultiTypeDictionary<Root> {
    // ...

    subscript<Value>(keyPath: KeyPath<Root, Value>) -> Value? {
        get { dict[keyPath] as? Value }
        // ...
    }
}

Khi lấy giá trị từ dictionary với KeyPath<Root, Value>, subscript dùng as? để cast nó về dạng Value. Đây chính là mấu chốt của vấn đề bởi:

as? luôn trả ra dạng optional của type được cast. Type T bất kỳ sẽ được bọc trong Optional và trở thành Optional<T>

Điều này giải thích cho việc vipInfo[\.age] trả ra Int?? bởi biến generic Value ở đây chính là type Int? của age, và bị as? cast thành Optional<Int?> hay Int??.

Để giải quyết vấn đề này, ta cần một function subscript thứ 2 chuyên dành cho các biến optional.


struct MultiTypeDictionary<Root> {
    private var dict: [PartialKeyPath<Root>: Any] = [:]

    // được gọi với các Value bình thường
    subscript<Value>(keyPath: KeyPath<Root, Value>) -> Value? {
        get { dict[keyPath] as? Value }
        set { dict[keyPath] = newValue }
    }

    // được gọi với các optional Value
    subscript<Value>(keyPath: KeyPath<Root, Value?>) -> Value? {
        get { dict[keyPath] as? Value }
        set { dict[keyPath] = newValue }
    }
}

Phiên bản này nhận vào keyPath dạng <Root, Value?>. Tại thời điểm truyền keyPath của age, Value? sẽ là Int?, với Value thuộc type Int 💡. Khi type cast với as?, Value được bọc lại thành Optional<Value> hay chính là type optional Int? ban đầu!

Quay lại bài toán tổng hợp data để tạo model User. Giờ đây, tất cả những gì ta cần làm chỉ là thu thập data từ các màn, gán vào MultiTypeDictionary, và khởi tạo model hoàn chỉnh khi đã đủ dữ liệu.


if let email = userInfo[\.email],
   let password = userInfo[\.password],
   let name = userInfo[\.name],
   let address = userInfo[\.address] {
    // tạo User từ userInfo
    let user = User(email: email, password: password, name: name, address: address)
}


Tổng kết

MultiTypeDictionary thoả mãn 6 tiêu chí chúng ta đặt ra:

Source code: MultiTypeDictionary