若要合併兩個 Swift 結構體值,請撰寫一個函式,為每個欄位選擇來源並傳回新值。這個 macOS 範例會合併兩種合成的繪圖面板樣式,並明確規定取代、保留、清除選用值及處理無效輸入的規則。

撰寫合併程式前,先定義優先順序

讀者要在 Mac 上根據兩個現有值計算出一種繪圖面板樣式。將輸入稱為 base 和 incoming:這些名稱說明各自的角色,而欄位層級的契約則決定哪個值優先。計算只會使用記憶體中的合成值。

Swift 將結構體說明為值型別,並支援透過初始化器建立實例。Apple 文件中的 merge(_:uniquingKeysWith:) API 屬於 Dictionary,會透過閉包解決重複索引鍵的問題。對於這個自訂結構體,請定義合併函式,讓其實作明確表達預期的欄位規則。Structures and Classes、Dictionary merge(_:uniquingKeysWith:)。

以下是說明用的契約。這些是本範例的設計選擇,並非 Swift 自動提供的行為。

欄位 規則 範例結果
title 使用傳入的文字,移除前後的空白字元與換行字元,然後拒絕空字串 " Tile Study \n" 會變成 \"Tile Study\"
spacing 保留基底值;值必須是非負數 基底 12 優先於傳入的 30
showsGrid 原樣採用傳入的布林值 傳入的 false 會取代基底的 true
caption 原樣採用傳入的選用值 傳入的 nil 會清除基底說明文字

這張表區分了三種容易混淆的操作:選擇來源、正規化所選的值,以及驗證結果。傳入值的間距刻意不予採用。即使它與基底間距不同,也不會改變輸出。

Foundation 的 trimmingCharacters(in:) 會傳回移除兩端符合字元後的新字串。Apple 文件說明可使用 .whitespacesAndNewlines 處理前後空白;若字串全由這些字元組成,結果會是空字串。拒絕此空字串是我們額外制定的規則。Foundation trimmingCharacters(in:)。

在 Mac 上執行完整的本機範例

此範例使用 Swift 6 工具鏈。Swift.org 提供透過 Swiftly 在 macOS 上安裝的說明,並將 Xcode 列為 Apple 平台開發的選項。開啟 Terminal 並執行 swift --version,確認該命令可用。如果找不到命令,請先依照安裝說明操作,再繼續。Install Swift on macOS、Getting Started。

將以下完整程式儲存為工作資料夾中的 MergeStyles.swift。程式會匯入 Foundation、定義兩個輸入值、執行合併、檢查預期結果,並測試兩種拒絕情況。此計算不會發出網路請求,也不會儲存任何應用程式資料。

import Foundation

struct PanelStyle: Equatable, Sendable {
    let title: String
    let spacing: Int
    let showsGrid: Bool
    let caption: String?
}

enum StyleMergeError: Error {
    case emptyTitle
    case negativeSpacing
}

func mergeStyles(
    base: PanelStyle,
    incoming: PanelStyle
) throws -> PanelStyle {
    let title = incoming.title.trimmingCharacters(
        in: .whitespacesAndNewlines
    )
    guard !title.isEmpty else {
        throw StyleMergeError.emptyTitle
    }
    guard base.spacing >= 0 else {
        throw StyleMergeError.negativeSpacing
    }

    return PanelStyle(
        title: title,
        spacing: base.spacing,
        showsGrid: incoming.showsGrid,
        caption: incoming.caption
    )
}

let base = PanelStyle(
    title: "Shape Study",
    spacing: 12,
    showsGrid: true,
    caption: "Blue squares"
)
let incoming = PanelStyle(
    title: "  Tile Study \n",
    spacing: 30,
    showsGrid: false,
    caption: nil
)

let expected = PanelStyle(
    title: "Tile Study",
    spacing: 12,
    showsGrid: false,
    caption: nil
)

let result = try mergeStyles(base: base, incoming: incoming)
precondition(result == expected)
precondition(base.title == "Shape Study")
precondition(base.caption == "Blue squares")
precondition(incoming.title == "  Tile Study \n")

print(result.title)
print("spacing: \(result.spacing)")
print("grid: \(result.showsGrid)")
print("caption cleared: \(result.caption == nil)")

let blankTitle = PanelStyle(
    title: " \n\t",
    spacing: 30,
    showsGrid: false,
    caption: nil
)
do {
    _ = try mergeStyles(base: base, incoming: blankTitle)
    preconditionFailure("Expected emptyTitle")
} catch StyleMergeError.emptyTitle {
    print("Rejected blank title")
}

let negativeBase = PanelStyle(
    title: "Shape Study",
    spacing: -1,
    showsGrid: true,
    caption: "Blue squares"
)
do {
    _ = try mergeStyles(base: negativeBase, incoming: incoming)
    preconditionFailure("Expected negativeSpacing")
} catch StyleMergeError.negativeSpacing {
    print("Rejected negative spacing")
}

在檔案所在的資料夾中執行:

swift MergeStyles.swift

預期輸出如下。研究環境中無法使用 Swift,因此程式已檢視,但未在該環境執行。

Tile Study
spacing: 12
grid: false
caption cleared: true
Rejected blank title
Rejected negative spacing

初始化器是這裡的核心判斷依據:每個引數都指出所選的輸入欄位或具名的中間值。兩個輸入都不會被修改。Swift 的值語意適合這種做法;此處的欄位本身都是值型別。如果之後加入類別型別的欄位,複製外層結構體並不會另外複製它所參照的類別實例。Structures and Classes。

驗證會在建立實例前進行。傳入空白標題時會擲出錯誤,而不會默默還原為基底標題;基底間距為負數時也會擲出錯誤,而不會默默將它限制為零。如果兩種情況都不符合,會先回報 emptyTitle,因為該 guard 會先執行。呼叫端應先處理錯誤,再使用結果。

決定選用值代表清除還是保留

主要範例將 incoming.caption 視為完整的預期說明文字。因此,nil 代表「結果沒有說明文字」。即使其表示方式只是選用值,契約仍明確定義了清除行為。

另一種需求可能會將傳入的 nil 解讀為「保留基底說明文字」。若採用此政策,取代初始化器的引數會是 caption: incoming.caption ?? base.caption。Swift 的 nil 合併運算子會在左運算元有值時選用左運算元,否則使用右運算元。這是一段標示清楚的取代程式片段,並非第二個完整程式。Nil-coalescing operator。

採用這種回退政策時,就不能再用相同的傳入 nil 來要求清除。請先決定其意義,再採用該運算式。空字串則是另一種不同的值:無論採用哪種政策,只要傳入的說明文字是 \"\",結果就會保留空說明文字,除非你明確加入正規化處理。

未來的編輯操作若需要保留、取代和清除這三種動作,就應另外表示這些動作。以下獨立的完整程式示範如何使用自訂列舉來處理僅針對說明文字的操作:

enum CaptionChange {
    case keep
    case set(String?)
}

func applying(
    _ change: CaptionChange,
    to caption: String?
) -> String? {
    switch change {
    case .keep:
        return caption
    case .set(let replacement):
        return replacement
    }
}

let original: String? = "Blue squares"
precondition(applying(.keep, to: original) == original)
precondition(applying(.set("Green circles"), to: original)
    == "Green circles")
precondition(applying(.set(nil), to: original) == nil)
print("Caption actions checked")

此列舉是對契約的說明性擴充。如果兩個輸入都是完整樣式,請保留原本的雙值函式;只有在輸入實際描述編輯操作時,才引入動作型別。

使用刻意不同的值檢查優先順序

有用的合併檢查會讓互相競爭的欄位值彼此不同。如果兩個輸入的間距都是 12,而且都啟用格線,錯誤的來源選擇可能不會被發現。範例改用 12 與 30,以及 true 與 false,因此預期結果能清楚顯示這些選擇。

調整函式前,請依照預期契約檢查下列邊界情況:

  • 傳入的 false 必須維持為 false;不要用布林 OR 來表達取代。
  • 在主要政策下,傳入的說明文字 nil 必須清除說明文字。
  • 標題前後的空白必須移除,內部空格則須保留。
  • 傳入的標題若全是空白字元,必須擲出錯誤,不得回退到基底值。
  • 基底間距 0 必須通過;基底間距若為負數則必須擲出錯誤。
  • 交換引數後,規則必須套用於重新指定的角色。

最後一點很重要,因為這種合併會受到順序影響。將範例輸入對調後,會選出 \"Shape Study\"、間距 30、格線 true,以及說明文字 \"Blue squares\"。這是依照既定規則得出的結果。在呼叫點為兩個引數加上名稱,讓它們的角色清楚可見。

新增欄位時,請擴充表格、初始化器,以及一項使用不同競爭值的檢查。如果未來的巢狀樣式包含多個彼此獨立的設定,請決定要取代整個巢狀值,還是為其中每個欄位各自設定優先順序。目前的實作只定義了上面列出的四個欄位。

將並行處理與合併政策分開

此計算是同步的:它會讀取引數、驗證所選值,然後傳回結果或擲出錯誤。它不需要 task 或 actor 來決定優先順序。Swift 將非同步函式說明為可能暫停的函式;加入非同步執行並不會決定哪個欄位優先。Concurrency。

範例將 PanelStyle 宣告為 Sendable,表示其值可以跨越並行處理邊界。Apple 規定,可傳送的結構體必須包含可傳送的成員。這項遵循性與傳送值有關;欄位選擇規則仍完全由 mergeStyles 決定。Sendable。

對於這項本機繪圖面板任務,請將契約放在函式旁,並使用差異明顯的範例輸入。結果值應能逐欄說明:經正規化的傳入標題、保留的基底間距、傳入的格線設定,以及可明確清除先前值的傳入說明文字。

來源