Linh Tạ

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:

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

Instance function of UITableView in type function form
dequeueReusableCell dưới dạng type function 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.

Instance function of UITableView in type function form after passing in an instance of UITableView
Sau khi truyền vào UITableView object, closure trả ra có hình dạng của instance function dequeueReusableCell

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.

Closure form of User init function
Dạng closure của hàm khởi tạo của struct 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.

Curried form of User.init
Áp dụng curry với User.init

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)
        //...
    }
}

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 🤐.

  1. https://oleb.net/blog/2014/07/swift-instance-methods-curried-functions/
  2. https://stackoverflow.com/a/27646945/10002969