Swift Digest
SE-0069 | Swift Evolution

Mutability and Foundation Value Types

Proposal
SE-0069
Authors
Tony Parker
Review Manager
Chris Lattner
Status
Implemented (Swift 3.0)

01 何が問題だったのか

Swift のコアな原則のひとつに「必要なときだけミュータブル」という考え方があります。Swift では let で定数を、var で変数を宣言し、同じ型であっても let で束縛すればミュータブルな操作はコンパイルエラーになります。

しかし Swift 2 時点では、Foundation の主要な型の多くがこの原則を十分に活かせていませんでした。NSDateNSData / NSMutableDataNSURLNSURLComponentsNSDateComponentsNSIndexSet / NSMutableIndexSetNSCharacterSet / NSMutableCharacterSetNSUUID などは Objective-C のクラスとしてそのまま Swift にインポートされており、参照型として振る舞います。

letvar の区別が意味を持たない

参照型として使われる Foundation の型では、let で束縛してもオブジェクトそのものをミュータブルに扱えてしまいます。逆に、ミュータブルな変更を表すために「新しいインスタンスを返すメソッド」で書くしかない型もあり、その場合は次のように letvar を入れ替えてもコードの意味が変わりません。

let myDate = NSDate()
let myLaterDate = myDate.dateByAddingTimeInterval(60)
// var に書き換えてもコードの挙動は同じ。let / var の宣言が語るものがない。

Swift が最も基本的な言語機能として掲げている let / var の区別が、Foundation の型に対しては機能していない状態でした。

ミュータブルなペアに分かれていてぎこちない

NSData / NSMutableDataNSIndexSet / NSMutableIndexSetNSCharacterSet / NSMutableCharacterSet のように、Foundation には「イミュータブル版とミュータブル版が別クラスで用意されている型」があります。Swift 標準ライブラリの StringArraylet / var だけで書き分けられるのに対し、こうした型は宣言時にどちらを選ぶかで後からの拡張性が決まってしまい、扱いがぎこちなくなっていました。

参照型を内包する値型での落とし穴

参照型である Foundation の型を、別の値型(structenum)にフィールドとして持たせると、一見「値のコピー」に見える代入が内部の参照を共有してしまい、意図せず状態を共有する問題が起きます。たとえば NSMutableData を内部に持つ struct を考えると、

struct IncrementingCode {
    private var data: NSMutableData
    init() { /* data に 0 を書き込む */ }
    func increment() {
        // data の値を読み、+1 して書き戻す
    }
}

var aCode = IncrementingCode()
aCode.increment() // 値は 1

// enum に包んで「値として」保持したつもり
let barcode = Barcode.specialCode(aCode)

aCode.increment()
// barcode 側の値も 2 に変わってしまう(let なのに不変ではない)

のように、let barcode がイミュータブルであるはずの場面で中身が書き換わってしまいます。これを避けるためには、利用者側で都度 copy-on-write の仕組みを自前で実装する必要があり、やり忘れればランタイムでしかバグが表面化しません。

mutating キーワードが効かない

struct に参照型のプロパティがあると、コンパイラはその参照先の変更を「struct の変更」として認識できません。本来なら func increment() をそのまま書くと「self はイミュータブル」としてコンパイルエラーになってほしい場面でも、参照先への変更はコンパイラを素通りします。Swift が mutating キーワードで与えてくれる静的な安全網が働かず、誤りはやはりランタイムに回されます。

API のインピーダンスミスマッチ

Swift 標準ライブラリと Objective-C から取り込まれる Cocoa SDK の API は、Swift にインポートされる際に NSStringStringNSArrayArray のような自動ブリッジが行われます。標準ライブラリがほぼ値型で構成されている一方、Foundation の主要型だけが参照型として取り残されているため、「値型主体の世界」と「参照型主体の世界」が混ざり合い、API 全体としての一貫性も失われていました。

02 どのように解決されるのか

Foundation の主要な型に対応する Swift 値型(struct を新たに用意し、Swift からはそちらを標準で使うようにします。対応する NS プレフィックスのクラス型は Swift から消えるわけではなく、値型との間で自動的にブリッジされます。Swift 3.0 で実装されています。

新しく用意される値型

次の型が Swift オーバーレイに追加されます。NSData / NSMutableData のようにイミュータブル/ミュータブルのペアになっていたものは、ひとつのミュータブルな struct に統合されます。

値型 対応する既存クラス
AffineTransform NSAffineTransform
CharacterSet NSCharacterSet, NSMutableCharacterSet
Date NSDate
DateComponents NSDateComponents
Data NSData, NSMutableData
IndexSet NSIndexSet, NSMutableIndexSet
IndexPath NSIndexPath
Notification NSNotification
PersonNameComponents NSPersonNameComponents
URL NSURL
URLComponents NSURLComponents
URLQueryItem NSURLQueryItem
UUID NSUUID

Swift からは原則としてこれらの値型を使い、必要に応じてクラス型へ(またはその逆へ)ブリッジする、というモデルになります。

letvar が意味を持つ

値型になったことで、let / var の区別が他の Swift の値型と同じように効くようになります。var で束縛したインスタンスには mutating なメソッドで直接変更を加えられ、let で束縛したインスタンスには変更できません。

var myDate = Date()
myDate.addTimeInterval(60) // OK

let myOtherDate = Date()
myOtherDate.addTimeInterval(60) // Error, as expected

NSData / NSMutableData のペアで書き分けていたコードも、Data ひとつに統一できます。必要なときは var に、不要なら let に、という Swift らしい書き方で済むようになります。

copy-on-write によるコピー意味論

DataDateComponentsURLComponents のような比較的大きな値を持つ型は、内部的に参照型をひとつ保持する copy-on-write で実装されます。変数のコピーやメソッド呼び出しでは実データは共有されたままで、ミュータブルな操作が加わったタイミングで初めて複製が作られます。これにより、値セマンティクスを提供しつつ参照型並みの性能が保たれます。

let d = Data(bytes: buffer1, length: buffer1Size)

// この時点ではデータ本体はコピーされない
var d2 = d

// 変更が入るこのタイミングで、必要ならコピーが行われる
d2.appendBytes(buffer2, length: buffer2Size)

// d の中身は元のまま。d2 だけが変わっている

DateAffineTransformNotification といった小さな型や、高頻度のミュータブル操作が想定される型では、参照を経由せず値そのものを直接保持する実装になります。たとえば DateNSTimeInterval 相当の 8 バイトをそのまま抱え、NSDate ポインタと同じサイズに収まります。

参照型を通した拡張

値型になったことで、これらの型は継承できなくなります。そこで、カスタマイズしたい場合は 対応するクラス(NSData など)をサブクラス化 し、それを値型にキャストすることで同じように扱えるようにしています。

class AllOnesData: NSMutableData {
    // getBytes などをオーバーライドして独自の挙動を与える
}

let allOnes = AllOnesData(length: 5) as Data
// allOnes は Data として扱える。内部では AllOnesData が生きている。

値型は、Data(...) のように直接イニシャライザで作ることもできますし、既存の NSData インスタンスを as Data でラップすることもできます。逆に someData as? NSData のようにクラス型を取り出すこともできます。値型側のメソッドは、内部で保持するクラスインスタンスに委譲する形で実装されるため、サブクラスの挙動もそのまま尊重されます。

これらの値型は内部的に ReferenceConvertible というプロトコルに適合し、対応するクラス型との結びつきや EquatableHashableCustomStringConvertible / CustomDebugStringConvertible などの振る舞いを共有します。

Objective-C との自動ブリッジ

既存の Objective-C API は、Swift から使うときに自動的に値型へ/からブリッジされます。

  • Objective-C メソッドが NSDate * を返す場合、Swift では Date として受け取れます。
  • Swift から Objective-C メソッドに Date を渡す場合、呼び出し時に NSDate が作られて渡されます。

Data のような参照保持型では、ブリッジの際にデータ本体のコピーは原則行われません。値型が内部に持っている NSData ポインタをそのまま Objective-C 側に渡すことで、ブリッジのコストを抑えつつ値セマンティクスを維持します。

Objective-C 側が NSData ** のようなポインタのポインタとして受け取る稀なケースでは、Swift 側でも従来どおり参照型(AutoreleasingUnsafeMutablePointer<NSData>)として現れます。

mutating が静的に効く

参照型を抱える struct と違い、値型として再構成されたこれらの型は、struct の正しい mutating セマンティクスに従います。例えば struct IncrementingCode の中で Data を保持していれば、

struct IncrementingCode {
    private var data: Data
    func increment() {
        data.append(...) // コンパイルエラー: self がイミュータブル
    }
}

のようにコンパイラが静的に誤りを指摘してくれます。mutating を付ければ通るようになり、逆にミュータブルな操作が許されない場所(let で束縛したとき、let プロパティ経由、Barcode.specialCode(...) のような enum に埋め込んだ値を let で持っているとき、など)ではコンパイルが通りません。「参照型を内包した値型」で起きていた意図しない共有バグが、言語レベルで防げるようになります。

性能への影響

  • Date のような小さな値型は、NSDate のポインタと同じサイズで、objc_msgSend を介さずにフィールドへ直接アクセスできるため、マイクロベンチマーク上でメンバ参照が約 15% 速くなります。
  • Date.addTimeInterval によるミュータブル更新は、従来 dateByAddingTimeInterval で新しい NSDate を毎回生成していたパターンと比べて、タグ付きポインタから外れる領域で malloc / free のコストを削減でき、ミュータブル更新で約 40 倍の高速化が測定されています。
  • 関数呼び出しでの引数受け渡しも、retain / release の省略が効くため、およそ 2 倍程度速くなります。

大きな値型は内部が copy-on-write なので、未変更のコピーには実コストがかからず、NSData を値としてやりとりしていたときと同等の性能が保たれます。

エンコード / コピー

エンコード・デコードは、従来どおり対応するクラス型へブリッジして NSCoding の仕組みを使います。また、値型のコピーは copy-on-write によって自動・オンデマンドで行われるため、NSCopying を自前で適合させる必要はありません。Objective-C 側に値型を渡した結果として得られる参照を保持したい場合には、従来どおり Objective-C の作法で copy を呼んで保護することになります。独自のサブクラスを作る場合は、copyWithZone / mutableCopyWithZone を適切に実装しておけば、値型側の複製ロジックがそれを利用します。

既存 Swift コードへの影響

Objective-C のクライアントには影響はありません。Swift 側では、既存の SDK API が値型を扱うように変わるため、NSData を直接扱っていたコードは Data を介するように書き換える必要が出てきます。マイグレータは最小限の書き換えしか行わないため、新しい値型のメリット(let / var の使い分け、mutating 更新など)を活かすには、開発者側で段階的に API を乗り換えていくことが想定されています。サブクラス化していた既存コードはそのまま動き続け、必要に応じて as Data などで値型に乗せ替えられます。

Future Directions

今回の値型は NS クラスをカスタマイズ点として使っていますが、将来的にはサブクラスに頼らない仕組み(Foundation のクラスクラスタを表現するための専用プロトコルなど)へと置き換えていく余地があります。また、LocaleCalendarNSAttributedStringNSErrorPredicateOrderedSet / CountedSetNSURLSession 系などは、識別性や自動更新の扱い、設計の複雑さから今回は見送られており、将来別の提案として検討対象になり得ます(いずれも speculative な見通しであり、実現を約束するものではありません)。