Swiftの構造体は、代入後に変更可能なオブジェクトを共有することがあります。コピーを独立させるには、値型のプロパティを使うか、クラスのストレージをコピーオンライトで隠します。このガイドでは、Macのデスクトッププレビュー用に合成的なタイル行計算機を作り、一方のコピーを編集しても他方が変わらないことを確認します。
構造体をコピーしても元の値が変わるのはなぜですか?
デスクトッププレビューでタイル行の幅を計算するとします。別のタイル幅を試すために設定をコピーし、元のプレビューは変わらないと期待します。両方の設定値に同じ変更可能なオブジェクトへの参照が含まれている場合、その期待は裏切られます。
Swiftは、構造体を別の変数に代入すると、格納されている値をコピーします。クラスプロパティの場合、その格納値は参照であり、参照先のオブジェクトは共有されたままです。ラッパーは依然として構造体ですが、観察可能な動作には共有された変更が含まれることがあります。この規則は、Swiftが文書化している値型と参照型の違いに基づいています。
次の完全な例で問題を示します。Swift標準ライブラリのみを使うため、インポートは不要です。
final class TileDimensions {
var width: Int
init(width: Int) {
self.width = width
}
}
struct SharedLayout {
let dimensions: TileDimensions
}
let original = SharedLayout(
dimensions: TileDimensions(width: 40)
)
let edited = original
edited.dimensions.width = 60
assert(original.dimensions === edited.dimensions)
assert(original.dimensions.width == 60)
print(original.dimensions.width) // 60
プロパティやラッパーをletで宣言しても、オブジェクトの変更可能なプロパティが固定されるわけではありません。防げるのは、その参照や構造体の値を置き換えることです。Swiftの構造体とクラスの章では、定数の参照と不変のオブジェクト状態が明確に区別されています。
こうしたオブジェクトを配列に入れても、共有されている同一性は保たれます。配列のコピーは要素を個別に管理しますが、クラス型の要素自体は参照です。要素を置き換えると一方の配列にだけ影響しますが、両方の配列から参照されているオブジェクトを変更すると、どちらからもその変更が見えます。AppleはArrayのドキュメントでこの違いを説明しています。
計算結果を独立した値にするには、どの設計が適していますか?
実装を選ぶ前に契約を定めます。var edited = originalの後、どちらのレイアウトに対するサポート対象の編集も、もう一方のレイアウトで計算される幅に影響してはなりません。その型を読む側が、内部でストレージを共有しているかどうかを知る必要はありません。
次の判断表では、この契約をローカルプレビューのモデルに適用します。これは、文書化されたコピーの規則から導いた設計上の推奨です。
| 状況 | 適した設計 | 独立性を保つ条件 |
|---|---|---|
| 設定が数値、文字列、その他の値セマンティクスを持つ要素で構成される | それらの値を構造体に直接格納する | 入れ子の要素も、観察可能な値の独立性を備えているか確認する |
| 既存のクラスベースのストレージを維持する必要がある | 非公開のコピーオンライトストレージを使う | 変更のたびに、共有された変更可能な状態を先に切り離す |
| 特定の操作時だけ別個のスナップショットが必要 | 明示的なコピー操作を提供する | ラッパー自体が独立性を保証しない限り、通常の代入ではオブジェクトが共有されることを文書化する |
| プログラムの複数の部分で1つのオブジェクトを意図的に編集する | 参照セマンティクスを維持する | 共有オブジェクトの動作をAPIに記述する |
3つの整数設定なら、構造体に直接プロパティを持たせるのが最も簡単です。以下のクラスベースの実装は、既存のストレージ設計を適応させる場合や、境界の仕組みを学ぶ場合に役立ちます。この小さな計算で性能上の利点が得られることを示すものではありません。
コピーオンライトでは、一方が変更されるまで2つの値でストレージを共有できます。Appleは、クラスを構造体でラップしてコピーオンライトを適用する方法を、別個に割り当てられたストレージと値セマンティクスを両立させる手段としてSwiftのパフォーマンスを探るで説明しています。クラスをラップするだけでは、この動作は実装されません。
明示的なcopy()メソッドだけでは不十分です。呼び出し側がそのメソッドを使わず、通常の代入を行うこともできるからです。代入を値の契約に含めるなら、ラッパーの変更経路が独立性を自動的に保証する必要があります。
コピーオンライトをどう実装し、確認しますか?
非公開のストレージオブジェクトを1つ使い、ラッパー内で生成して、計算済みの値と制御された変更メソッドだけを公開します。ストレージを変更する直前にisKnownUniquelyReferencedを呼び出します。一意性が確認できない場合は、ストレージを独立したコピーに置き換えます。Appleは、この手順をコピーオンライトの実装について具体的に説明しています。
この計算例では、説明用の入力として、幅がそれぞれ40ポイントのタイルを3個、隣り合うタイル間の間隔を10ポイントとします。間隔は2つなので、行の幅は3 * 40 + 2 * 10 = 140ポイントです。タイル幅を60に増やすと、200ポイントになります。
Macで完全なプログラムを試すには、まずターミナルでswift --versionを実行し、Swiftの入門ガイドの推奨どおり、Swiftツールチェーンが利用可能であることを確認します。次のコードをLayout.swiftとして保存し、そのディレクトリからswift Layout.swiftを実行します。プログラムはメモリ内で計算し、結果を出力します。
struct TileLayout {
private final class Storage {
let columns: Int
var tileWidth: Int
var gap: Int
init(columns: Int, tileWidth: Int, gap: Int) {
self.columns = columns
self.tileWidth = tileWidth
self.gap = gap
}
func copy() -> Storage {
Storage(
columns: columns,
tileWidth: tileWidth,
gap: gap
)
}
}
private var storage: Storage
init(columns: Int, tileWidth: Int, gap: Int) {
precondition((1...1000).contains(columns))
precondition((0...1000).contains(tileWidth))
precondition((0...1000).contains(gap))
storage = Storage(
columns: columns,
tileWidth: tileWidth,
gap: gap
)
}
var rowWidth: Int {
storage.columns * storage.tileWidth
+ (storage.columns - 1) * storage.gap
}
private mutating func ensureUniqueStorage() {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy()
}
}
mutating func setTileWidth(_ width: Int) {
precondition((0...1000).contains(width))
ensureUniqueStorage()
storage.tileWidth = width
}
mutating func setGap(_ gap: Int) {
precondition((0...1000).contains(gap))
ensureUniqueStorage()
storage.gap = gap
}
}
var original = TileLayout(columns: 3, tileWidth: 40, gap: 10)
var edited = original
edited.setTileWidth(60)
assert(original.rowWidth == 140)
assert(edited.rowWidth == 200)
original.setGap(5)
assert(original.rowWidth == 130)
assert(edited.rowWidth == 200)
let snapshot = edited
edited.setGap(20)
assert(snapshot.rowWidth == 200)
assert(edited.rowWidth == 220)
print(original.rowWidth, snapshot.rowWidth, edited.rowWidth)
// Expected output: 130 200 220
このプログラムにインポートや外部パッケージは不要です。受け付ける範囲は意図的に設けた例示用の制約で、列数は1~1,000、幅と間隔は0~1,000ポイントです。これにより整数計算の範囲を限定できます。範囲外の引数では事前条件が失敗し、自動的に範囲内へ調整されることはありません。これらのセッターが後にデスクトップインターフェースから編集可能なテキストを受け付ける場合は、呼び出す前に入力を検証し、ユーザーが修正できる手段を用意してください。
この確認では、観察可能な3つの要件を対象にしています。コピーしたレイアウトを編集しても元の値が保たれること、元の値を編集してもコピーが保たれること、そして保持した定数のスナップショットが後の編集中も計算結果を保つことです。特定の割り当て回数を要求せずに動作を検証します。調査環境ではSwiftを利用できなかったため、この例は確認しましたが実行していません。示した出力は期待される結果です。
ラッパーを拡張するとき、何を維持する必要がありますか?
この例で独立性が保たれるのは、API境界全体のおかげです。ストレージは非公開で、外部から保持されたストレージオブジェクトを受け取るイニシャライザーはなく、ゲッターからストレージが返されることもありません。どちらの変更メソッドも、同じ切り離し処理を通ります。ゲッターはストレージを変更せずに結果を計算します。
機能を追加するときは、観察可能な状態を変える可能性のある新しい経路をすべて確認してください。新しいセッター、サブスクリプト、編集コールバックにも、既存のメソッドと同じ処理が必要です。ゲッター自体が読み取り専用でも、変更可能な子オブジェクトを返すと、呼び出し側が切り離し処理を迂回できてしまいます。
ストレージのコピーは、その値に関係する変更可能な状態をすべて対象にする必要もあります。ここではStorage.copy()が3つの整数をコピーするため、これで十分です。ストレージに変更可能なクラス型の子が加わった場合、その子を新しいストレージへ代入するだけでは、別の共有参照が残ります。子を適切にコピーするか、すでに値セマンティクスを備えた要素に置き換えてください。Arrayについて文書化されたクラス要素の動作も、同じ境界を示しています。
計算機を拡張するときは、次の手順を使います。
- 新しい編集操作を試す前に、レイアウトをコピーする。
- 変更しなかったレイアウトで、以前の結果が保たれていることを確認する。
- 役割を入れ替えて繰り返す。
- スナップショットを保持した状態で後から複数回編集し、再度確認する。
- 返されるすべてのプロパティとイニシャライザーの引数について、ラッパーの外へ漏れる可能性がある変更可能な参照がないか調べる。
finalストレージクラスを使うとサブクラスによる振る舞いの追加を防げますが、プロパティが不変になるわけではありません。同様に、操作をmutatingとして宣言すると構造体を更新できるようになります。この例で独立性を保つのは、切り離しのロジックです。
Foundationオブジェクトはどのようにコピーすべきですか?
既存のモデルでNSMutableStringなどのFoundationオブジェクトを格納している場合は、ストレージを切り離す際に、具体的な型で文書化されているコピー動作を使ってください。Swiftの代入は、Foundationのコピー用メソッドを自動的に呼び出しません。
FoundationのNSCopyingプロトコルは機能的なコピーを定義しますが、その具体的な意味はクラスによって異なります。クラスとその内容がどちらも不変なら、元のオブジェクトを返してもよいこともドキュメントに記載されています。そのため、オブジェクトの同一性だけでは、値の契約を満たすコピーかどうかを普遍的に判定できません。
可変型と不変型の両方を持つ型から変更可能な結果が必要な場合は、NSMutableCopyingを参照してください。Appleはその用途にmutableCopy(with:)と便利なメソッドmutableCopy()を文書化しています。そのような区別がないクラスは、代わりにNSCopyingを採用してください。
タイル計算機で実際に守るべき境界は明確です。入力として受け取ったFoundationオブジェクトを、独立しているはずの設定に対する外部から変更可能な別名として残してはいけません。受け取り時に適切な独立スナップショットを作り、編集前に適切に切り離し、内部の変更可能なオブジェクトではなく値のスナップショットを返します。コピー操作に頼る前に、具体的なクラスが入れ子の変更可能な内容をどう扱うか確認してください。
コピーオンライトで型はSendableになりますか?
値の独立性と並行処理への適合性は、別々に考える必要があります。AppleのSendableドキュメントによると、検査対象のSendable構造体では、格納メンバーがSendableでなければなりません。非公開の変更可能なストレージクラスは、外側の型が構造体だからといって自動的にこの要件を満たすわけではありません。
一意性の確認も同期処理ではありません。Appleは、同時アクセス中にisKnownUniquelyReferencedがtrueを返すことがあると警告し、この関数を使う際には適切な同期を求めています。この関数が確認するのは強参照です。弱参照や非所有参照の別名が存在する場合もあります。そのため、一意性を確認することに加え、ラッパーの外からストレージにアクセスできないようにすることも重要です。標準ライブラリ関数の文書化された制限を参照してください。
この例は、同期的な計算コンテキスト内で使ってください。将来、並行処理の領域間で設定を渡す必要が生じた場合は、Sendableな値型プロパティを持つ構造体を使い、検査付きのSendable適合を宣言するのが簡単です。@unchecked Sendableを追加すると、正しさに対する責任が実装者に移るだけで、不足している調整処理が補われるわけではありません。AppleはSendableのリファレンスでこれらの要件を説明しています。
小さなデスクトップ計算では、要件を満たすなら値型のプロパティを直接使ってください。変更可能なクラスストレージを維持する必要がある場合は、非公開の所有権、完全なストレージコピー、制御された編集、観察可能な独立性の確認によって契約を保ちます。これにより、プレビュー用の設定を変更しても、保持されている各レイアウト値がそれぞれの計算結果を持つという、予測可能な結果が得られます。
出典
- Swiftの値型と参照型 — Swift.org。
- 構造体とクラス — The Swift Programming Language、Swiftプロジェクト。
- Array — Apple Developer Documentation。
- Swiftのパフォーマンスを探る — Apple Developer。
- isKnownUniquelyReferenced(_:) — Apple Developer Documentation。
- 入門 — Swift.org。
- NSCopying — Apple Developer Documentation。
- NSMutableCopying — Apple Developer Documentation。
- Sendable — Apple Developer Documentation。