Trang chủ

Viết một result builder, và khi nào thì đừng

@ViewBuilder không phải phép màu trong SwiftUI; nó là một tính năng ngôn ngữ tổng quát, và bạn tự dựng được một cái. Cơ chế đó tốn của tôi một buổi chiều để hiểu và nó nhỏ hơn nhiều so với những gì đống code SwiftUI dùng nó gợi ra.

Mức tối thiểu

Một result builder là một kiểu có các phương thức static mà trình biên dịch gọi để gộp các câu lệnh lại.

@resultBuilder
struct StringBuilder {
    static func buildBlock(_ parts: String...) -> String {
        parts.joined(separator: " ")
    }
}

@StringBuilder
func greeting(name: String) -> String {
    "Xin chào,"
    name
    "mừng bạn quay lại."
}

greeting(name: "Tuấn")     // "Xin chào, Tuấn mừng bạn quay lại."

buildBlock là phương thức duy nhất bắt buộc. Trình biên dịch viết lại thân hàm thành một lời gọi tới nó, truyền từng câu lệnh vào làm một tham số.

Các phương thức bạn thêm khi cần

Mỗi tính năng điều khiển luồng cần một phương thức để hỗ trợ. Bỏ sót một cái và cú pháp đó thành lỗi biên dịch bên trong builder — và đó là lý do vòng for không chạy được trong @ViewBuilder suốt nhiều năm.

@resultBuilder
struct StringBuilder {
    static func buildBlock(_ parts: String...) -> String {
        parts.joined(separator: " ")
    }

    static func buildOptional(_ part: String?) -> String {
        part ?? ""                                    // cho phép `if` không có `else`
    }

    static func buildEither(first: String) -> String { first }
    static func buildEither(second: String) -> String { second }
                                                      // cho phép `if`/`else`

    static func buildArray(_ parts: [String]) -> String {
        parts.joined(separator: " ")                  // cho phép `for`
    }

    static func buildExpression(_ part: String) -> String { part }
                                                      // biến đổi từng biểu thức
    static func buildFinalResult(_ part: String) -> String {
        part.trimmingCharacters(in: .whitespaces)     // chạy một lần ở cuối
    }
}

buildExpression là cái thú vị: nó cho phép bạn nhận vào những kiểu khác nhau rồi chuẩn hóa chúng. Đó là cách @ViewBuilder nhận được một Text, một Image và một view tự viết trong cùng một khối.

Một cái thật: bộ dựng truy vấn

Trường hợp tôi thật sự muốn dùng đến nó — dựng các mảnh SQL với cú pháp dễ đọc:

@resultBuilder
struct PredicateBuilder {
    static func buildBlock(_ conditions: Condition...) -> Condition {
        .and(conditions)
    }

    static func buildOptional(_ condition: Condition?) -> Condition {
        condition ?? .always
    }

    static func buildEither(first: Condition) -> Condition { first }
    static func buildEither(second: Condition) -> Condition { second }
}

func query(@PredicateBuilder _ build: () -> Condition) -> Query {
    Query(condition: build())
}

Dùng như thế này:

let results = query {
    Column("status") == "active"
    Column("created_at") > startDate

    if let author {
        Column("author_id") == author.id
    }
}

Cái if mới là thứ khiến bộ máy này đáng công. Không có builder, một điều kiện tùy chọn nghĩa là dựng một mảng rồi thêm vào có điều kiện, thứ dài gấp ba lần và đọc lên như đoạn nối ống chứ không phải như một truy vấn.

Cảnh báo

Thông báo lỗi bên trong một result builder thì tệ một cách nhất quán. Một lỗi lệch kiểu ở dòng bốn lại báo vào cả khối, còn thiếu buildArray thì nói “closure containing a control flow statement cannot be used with result builder” chứ không gọi tên cái vòng lặp. Hãy tính trước cho việc này — nó là cái giá chính của tính năng.

Khi nào đừng viết một cái

Tôi muốn nói thẳng chuyện này, vì result builder viết ra thì vui và phần lớn là không cần thiết.

Nếu một mảng viết thẳng là đủ, hãy dùng một mảng viết thẳng. [.name, .email, .age] rõ ràng hơn một builder tạo ra đúng thứ đó, và nó chẳng cần giải thích gì cho người đọc.

Nếu không có điều khiển luồng thì chẳng có ý nghĩa gì. Toàn bộ giá trị của một builder so với một mảng nằm ở if, for và switch bên trong cái khối. Một builder chỉ nối chuỗi lại là thủ tục rườm rà.

Nếu nó chỉ được dùng ở một chỗ, một hàm nhận vào một mảng thì tốt hơn. Cái giá của builder là một kiểu dữ liệu mà chưa ai khác từng thấy.

Phép thử tôi dùng: cái khối này có chứa một if hay một for ở ít nhất một nửa số lần được viết ra không? Với @ViewBuilder thì câu trả lời hiển nhiên là có — view có điều kiện là trường hợp bình thường. Với một danh sách các tùy chọn cấu hình thì là không, và một cái mảng mới là hình dạng lương thiện.

Chỗ chúng xứng đáng

Giao diện khai báo — trường hợp nguyên bản.

Các DSL truy vấn và vị từ, như ở trên, nơi các điều kiện thường là tùy chọn.

Dữ liệu mẫu cho test với phần thiết lập có điều kiện.

Bất cứ đâu mà phương án thay thế là một mảng khả biến cùng một loạt lời gọi append, vì đó chính xác là khuôn mẫu mà một builder thay thế, và bản dùng builder không thể bị viết sai bằng cách thêm vào nhầm thứ tự.

Cái cuối là tín hiệu rõ ràng nhất. Nếu bạn thấy mình đang viết:

var conditions: [Condition] = []
conditions.append(.status("active"))
if let author { conditions.append(.author(author.id)) }

…thì đó là một result builder đang chờ được sinh ra. Còn nếu bạn không thấy mình viết như thế thì không phải.