Trang chủ

Macro Swift đầu tiên của tôi, và vì sao tôi xóa cái thứ hai

Macro trong Swift sinh code lúc biên dịch từ mã nguồn của bạn. Chúng chạy @Observable, #Predicate và @Model, và chúng thật sự có năng lực theo cách mà property wrapper và result builder thì không.

Chúng cũng là công cụ nặng nhất trong ngôn ngữ, và tôi đã viết ba cái: một cái tôi vẫn dùng và hai cái tôi đã xóa.

Cái tôi giữ lại

Sinh siêu dữ liệu kiểu CaseIterable cho các enum có giá trị đi kèm, thứ mà phần tuân thủ tiêu chuẩn không làm được:

@CaseDetection
enum LoadState {
    case idle
    case loading(progress: Double)
    case failed(Error)
}

// được sinh ra
extension LoadState {
    var isIdle: Bool { if case .idle = self { true } else { false } }
    var isLoading: Bool { if case .loading = self { true } else { false } }
    var isFailed: Bool { if case .failed = self { true } else { false } }
}

Đó là đoạn khuôn mẫu phình ra theo từng case, không viết generic được, và đúng là loại biến đổi máy móc mà macro sinh ra để làm.

Một macro được cấu trúc thế nào

Hai mảnh, nằm ở hai target. Phần khai báo trong thư viện của bạn:

@attached(extension, names: arbitrary)
public macro CaseDetection() = #externalMacro(
    module: "MyMacrosImplementation",
    type: "CaseDetectionMacro"
)

Và phần cài đặt, vốn là một plugin của trình biên dịch thao tác trên cây cú pháp:

import SwiftSyntax
import SwiftSyntaxMacros

public struct CaseDetectionMacro: ExtensionMacro {
    public static func expansion(
        of node: AttributeSyntax,
        attachedTo declaration: some DeclGroupSyntax,
        providingExtensionsOf type: some TypeSyntaxProtocol,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [ExtensionDeclSyntax] {
        guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
            throw MacroError.notAnEnum
        }

        let cases = enumDecl.memberBlock.members
            .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
            .flatMap(\.elements)

        let properties = cases.map { element in
            let name = element.name.text
            return "var is\(name.capitalized): Bool { if case .\(name) = self { true } else { false } }"
        }

        return [try ExtensionDeclSyntax("extension \(type)") {
            for property in properties { DeclSyntax(stringLiteral: property) }
        }]
    }
}

Target chứa phần cài đặt chạy trên máy bạn lúc biên dịch, không phải trên thiết bị. Nó là một chương trình Swift bình thường nhận vào cú pháp rồi trả về cú pháp.

Mẹo

Editor → Expand Macro trong Xcode hiện ra đoạn code được sinh, và đó là cách hợp lý duy nhất để gỡ lỗi một cái. Bạn cũng kiểm thử được một macro trực tiếp bằng assertMacroExpansion từ SwiftSyntaxMacrosTestSupport — so sánh mã nguồn đầu vào với đầu ra mong đợi, không cần build cả một ứng dụng.

Hai cái tôi đã xóa

Một macro ghi log. #log("thông điệp") bung ra kèm file, dòng và tên hàm. Đó chính là thứ mà các tham số mặc định #file, #line và #function vốn đã làm, trong một hàm duy nhất, không kèm chút chi phí build nào:

func log(_ message: String,
         file: String = #file,
         line: Int = #line,
         function: String = #function) { … }

Tôi đã với tới một macro vì nó cho cảm giác là công cụ hiện đại. Cái hàm thì tốt hơn ở mọi mặt.

Một macro tiêm phụ thuộc sinh ra các bộ khởi tạo từ các thuộc tính. Nó chạy được, và nó khiến mọi kiểu dữ liệu ấy trở nên không thể hiểu nổi nếu không bung macro ra. Cái bộ khởi tạo được sinh ra là bốn dòng mà tôi vừa gỡ khỏi đúng cái file mà một người đọc sẽ đi tìm chúng.

Đó là kiểu hỏng đáng được gọi tên: một macro chuyển code ra khỏi cái file và vào trong một mô hình tư duy về việc macro đó làm gì. Với @Observable, cuộc đổi chác đó hiển nhiên là đáng — ai cũng biết nó làm gì và nó thay thế rất nhiều thứ. Với một bộ khởi tạo bốn dòng thì không.

Các cái giá

Thời gian build. Phần cài đặt macro là một package riêng phải được build xong trước khi bất cứ thứ gì dùng nó biên dịch, và việc bung macro chạy ở từng chỗ dùng. Trên một codebase lớn thì thấy được.

Một phụ thuộc cứng vào swift-syntax. Nó lớn và phiên bản của nó gắn với phiên bản trình biên dịch.

Lời nhắc tin cậy. Xcode hỏi người dùng có tin plugin macro không, thứ làm những người dùng thư viện của bạn bất ngờ.

Gỡ lỗi. Một lỗi biên dịch trong code được sinh ra sẽ chỉ vào chỗ bạn dùng macro kèm một thông điệp về đoạn code bạn không hề viết.

Quy tắc tôi rút ra

Theo thứ tự ưu tiên: một hàm, rồi một extension của protocol, rồi một property wrapper, rồi một result builder, rồi mới đến một macro. Chỉ với tới macro khi mọi thứ phía trên nó thật sự không diễn đạt nổi điều bạn cần.

Phép thử cụ thể: cái này có sinh ra code thay đổi theo hình dạng của một khai báo không? Đọc các case của một enum, các thuộc tính lưu trữ của một struct, các yêu cầu của một protocol — đó là lãnh địa của macro, vì không công cụ nào khác nhìn thấy được cấu trúc của khai báo.

Nếu đoạn code được sinh ra là như nhau ở mọi lần thì đó là một cái hàm. Nếu nó chỉ khác nhau theo kiểu dữ liệu thì đó là một hàm generic. Cả hai đều rẻ hơn, dễ đọc hơn, và không cần đến một plugin.