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:
- 1️⃣ Không ảnh hưởng định nghĩa của model
- 2️⃣ Không ảnh hưởng logic code tương tác với nó
- 3️⃣ Không tạo code dư thừa
- 4️⃣ Đơn giản, dễ đọc hiểu, dễ debug
- 5️⃣ Tiện lợi khi sử dụng
- 6️⃣ Dễ refactor khi model thay đổi
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 Root
và Value
, 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>
có 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.
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.
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. TypeT
bất kỳ sẽ được bọc trongOptional
và trở thànhOptional<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:
- 1️⃣ 2️⃣ Không ảnh hưởng định nghĩa của model hay logic code tương tác: nó hoạt động độc lập với bất kì class/struct nào và không cần config gì đặc biệt ✅
- 3️⃣ Không tạo code dư thừa: sử dụng như một dictionary bình thường, không cần quá 1 biến trung gian, không cần mô phỏng lại model ban đầu ✅
- 4️⃣ Đơn giản, dễ đọc hiểu, dễ debug: ✅
- 5️⃣ Tiện lợi khi sử dụng: ✅
- 6️⃣ Dễ refactor khi model thay đổi:
KeyPath
và compiler bảo vệ khỏi mọi sai lầm và thay đổi, cho dù có là thêm biến, đổi tên, hay chỉnh type ✅ ✅ ✅
Source code: MultiTypeDictionary