Tạo model từ data không hoàn chỉnh - Phần I
Model object thường được tạo ra từ các data riêng lẻ. Việc này có thể trở nên khá loằng ngoằng khi ta phải tổng hợp số data đó từ một chuỗi màn, hoặc từ nhiều màn riêng biệt.
struct User {
let email: String
let password: String
let name: String
let address: String
}
//ViewControllerA => input email
//...
//ViewControllerB => input password
//...
//ViewControllerC => input name
//...
//ViewControllerD => input address => create User
//...
Giả sử ở luồng login, ta cần người dùng nhập email
, password
, name
, address
ở 4 controller khác nhau trước khi dùng những thông tin đó để khởi tạo User
.
Giải pháp phổ biến
1. Optional properties
Chính vì toàn bộ data User
cần chỉ đầy đủ tại controller cuối cùng, ta có thể nghĩ ngay tới việc định nghĩa các optional property trong model User
.
struct User {
var email: String
var password: String?
var name: String?
var address: String?
}
Hướng tiếp cận này nhanh nhưng không gọn bởi nó tồn tại 2 nhược điểm lớn:
- ❌ Việc sử dụng các property của
User
trở nên cồng kềnh hơn khi luôn phải unwrap/check optional.User
càng được sử dụng ở nhiều nơi thì càng tạo ra nhiều code dư thừa. - ❌ Nếu bản chất các property trên không phải optional, định nghĩa chúng như vậy sẽ làm nhiễu logic của tất cả các đoạn code tương tác với
User
, ảnh hưởng trực tiếp đến việc suy luận và đọc hiểu code. Bạn của tương lai hoặc developer đến sau sẽ không thể nắm rõ tại sao các property đó là optional và không dám mạnh tay refactor để cải thiện codebase.
2. Default values
Thay vì sử dụng optional, một cách thường gặp khác là cung cấp giá trị mặc định cho các property của User
.
struct User {
var email: String = ""
var password: String = ""
var name: String = ""
var address: String = ""
}
Cách này tốt hơn optional property nhưng không chặt chẽ về mặt logic nếu các giá trị mặc định không nằm trong tập các giá trị chấp nhận được của biến. Trong ví dụ trên, các property password
, email
, name
không thể rỗng. Ta hoàn toàn có thể quên gán giá trị cho password
hay email
và vô tình tạo ra một User
vô nghĩa. Hơn nữa, không phải lúc nào ta cũng có thể cung cấp giá trị mặc định nếu type của property là một struct hay class phức tạp.
3. Các biến/model trung gian
Phương pháp thứ ba và cũng thủ công nhất là tạo ra các biến hoặc model ở các controller trung gian để truyền data đến controller cuối cùng.
class ViewControllerA {
var email: String?
func goToB() { //... }
}
class ViewControllerB {
var email: String
var password: String?
func goToC() { //... }
}
class ViewControllerC {
var email: String
var password: String
var name: String?
func goToD() { //... }
}
class ViewControllerD {
// ... bạn hiểu vấn đề rồi đấy 😥
}
Nếu controller không tương tác với các property trên mà chỉ đơn giản là truyền chúng đến màn tiếp theo thì cách này khá cực và làm nhiễu code với những thông tin không cần thiết. Lặp data cũng khiến việc refactor User
vất vả hơn. Những nhược điểm này sẽ càng bộc lộ rõ khi số property hoặc số màn trong luồng tăng lên.
Tổng hợp data với closure
1. Đặc tính của function
Trước khi tìm hiểu mẹo dùng closure để tổng hợp data tạo nên User
hoàn chỉnh ta cần biết một tính chất quan trọng của function
Về bản chất, function là một dạng đặc biệt của closure. Ngoại trừ protocol, mọi function trong swift đều tồn tại một phiên bản closure tương ứng. Ngoài ra, tất cả instance function của class/struct/enum đều có thể được sử dụng dưới dạng type function là một closure nhận instance của type đó làm input đầu tiên.
Ví dụ với instance method dequeueReusableCell
của UITableView
UITableView.dequeueReusableCell(withIdentifier:for:)
sẽ là phiên bản type function ở dạng closure. Closure này được định nghĩa (UITableView) -> (String, IndexPath) -> UITableViewCell
. Nếu bạn để ý, ouput của nó mô phỏng chính xác hình dạng của function ban đầu.
Tính chất trên cũng đúng với function của các custom class/struct. Hàm init
của User
nhận 4 String
input là email
, password
, name
, address
nên closure sẽ có dạng (String, String, String, String) -> User
.
Ta có thể sử dụng typealias
để dễ hình dung hơn
struct User {
typealias Email = String
typealias Address = String
typealias Name = String
typealias Address = String
let email: String
let password: String
let name: String
let address: String
}
//User.init có dạng (Email, Password, Name, Address) -> User
2. Curry
Curry là phương pháp biến closure với nhiều input thành closure mới dưới dạng chuỗi function nhận vào từng input một. Trong swift, function curry
dùng để transform closure với 2 input được định nghĩa
func curry<A, B, C>(_ f: @escaping (A, B) -> C) -> (A) -> (B) -> C {
return { a in
return { b in
return f(a, b)
}
}
}
Lợi ích lớn nhất của curry là việc ta có thể truyền vào từng input một trước khi chạy function. Hay nói cách khác, thay vì phải truyền toàn bộ input tại cùng thời điểm, giờ đây ta đã có khả năng tổng hợp data theo từng bước 💡!
// Closure truyền thống nhận toàn bộ input cùng lúc
let plus: (Int, Int) -> Int = { $0 + $1 }
plus(2, 3) == 5 // true
plus(2, 6) == 8 // true
// Biến plus thành closure nhận từng input một
let curriedPlus: (Int) -> (Int) -> Int = curry(plus)
// Dùng curriedPlus để tạo function cộng số bất kì với 2
let plusTwo = curriedPlus(2)
plusTwo(3) == 5 // true
plusTwo(6) == 8 // true
Từ function curry
2 input, ta có thể suy ra curry
với 4 input có dạng
func curry<A, B, C, D, E>(_ f: @escaping (A, B, C, D) -> E) -> (A) -> (B) -> (C) -> (D) -> E {
return { a in
return { b in
return { c in
return { d in f(a, b, c, d) }
}
}
}
}
Quay lại bài toán hiện tại, ta sẽ áp dụng curry cho closure init (Email, Password, Name, Address) -> User
ở trên và tạo thành closure mới (Email) -> (Password) -> (Name) -> (Address) -> User
.
Giờ đây, thay vì lặp data ở nhiều biến trung gian, ta chỉ cần lưu closure đại diện cho từng bước cấu tạo nên User
và truyền nó sang màn tiếp theo.
typealias UserEmailResult = (User.Password) -> (User.Name) -> (User.Address) -> User
typealias UserPasswordResult = (User.Name) -> (User.Address) -> User
typealias UserNameResult = (User.Address) -> User
class ViewControllerA {
func goToB() {
guard let email = emailTextField.text else { return }
let emailResult: UserEmailResult = curry(User.init)(email)
//... truyền emailResult khởi tạo ViewControllerB
}
}
class ViewControllerB {
let emailResult: UserEmailResult
func goToC() {
guard let password = passwordTextField.text else { return }
let passwordResult: UserPasswordResult = emailResult(password)
//... truyền passwordResult khởi tạo ViewControllerC
}
}
class ViewControllerC {
let passwordResult: UserPasswordResult
func goToD() {
guard let name = nameTextField.text else { return }
let nameResult: UserNameResult = passwordResult(name)
//... truyền nameResult khởi tạo ViewControllerD
}
}
class ViewControllerD {
let nameResult: UserNameResult
func createUser() {
guard let address = addressTextField.text else { return }
let user: User = nameResult(address)
//...
}
}
- ✅ So với 2 phương pháp đầu tiên, closure giúp ta mô tả luồng tạo
User
một cách logic và trực quan hơn. Ta cũng không còn phải chắp váUser
với optional properties hoặc các default value vô nghĩa. - ✅ So với phương pháp thứ 3 là tạo biến ở các màn trung gian, dùng closure giúp giảm số biến dư thừa phải tạo. Closure cũng ngăn việc truy cập hoặc gán nhầm ở các màn trung gian nếu bạn không muốn các biến đó được sử dụng ở các màn đấy (❌ đây cũng có thể là con dao 2 lưỡi nếu bạn thay đổi ý định).
- ❌ Một nhược điểm nữa của closure là ta vẫn sẽ phải cập nhật type của closure khi tên hoặc type của property trong
User
thay đổi.
Tổng kết
Curried closure có thể không phải là đáp án hoàn hảo của bài toán này nhưng nó là một công cụ cực kì quan trọng, đặc biệt là trong functional programming. Có thể mình sẽ nói thêm về khía cạnh này ở một bài viết không xa. Còn ở phần tới, chúng ta sẽ cùng tìm hiểu phương pháp mình ưng ý nhất, khắc phục hoàn toàn mọi nhược điểm của 4 phương pháp trên 🤐.