Swift Digest
SE-0153 | Swift Evolution

Compensate for the inconsistency of @NSCopying’s behaviour

Proposal
SE-0153
Authors
Torin Kwok
Review Manager
Doug Gregor
Status
Rejected

01 何が問題だったのか

Objective-C の copy プロパティ属性は、Swift では @NSCopying に対応します。プロパティに @NSCopying を付けておくと、セッタ経由で代入した値が自動的に copy(with:) でコピーされ、プロパティには元のオブジェクトとは独立した複製が格納されます。たとえばクラスの外部や通常のメソッドから self.employee = candidate と書けば、employee にはちゃんとコピー後のインスタンスが入ります。

問題は、イニシャライザの中だけはこの契約が守られないことでした。Swift のイニシャライザでは、self.プロパティ名 = ... と書いてもセッタは呼ばれず、ストレージへ直接書き込まれます。これはセッタの副作用による初期化途中の不整合を避けるための意図的な設計ですが、その副作用として @NSCopying の「代入時に自動でコピーする」という挙動だけがイニシャライザの中では発動しません。

結果として、同じ構文で書いたのにイニシャライザの中だけコピーが起きず、浅いコピー(参照の共有)になってしまうという非一貫性が生まれていました。

class Department: NSObject {
    // @NSCopying を付けているので、employee には
    // 常に複製が入ることを期待している
    @NSCopying var employee: Person

    init(employee candidate: Person) {
        // しかしイニシャライザ内ではセッタが呼ばれないため、
        // ここでは @NSCopying による自動コピーが起きず、
        // self.employee は candidate と同じインスタンスを指す
        self.employee = candidate
        super.init()
    }
}

let isaacNewton = Person(firstName: "Isaac", lastName: "Newton", job: "Mathematician")
let lab = Department(employee: isaacNewton)

isaacNewton.job = "Astronomer"
print(lab.employee)
// "Isaac Newton, Astronomer" と表示される
// 期待は "Isaac Newton, Mathematician"

一方、イニシャライザの外では @NSCopying の契約は正しく働きます。

lab.employee = isaacNewton       // ここでは自動的にコピーされる
isaacNewton.job = "Physicist"
print(lab.employee.job)          // "Astronomer"(lab.employee は独立したコピー)

同じ代入文が、書かれた場所によってコピーしたりしなかったりする、というのが @NSCopying の挙動の非一貫性です。特に Objective-C では self.name = name;(プロパティ経由)と self->_name = ...;(ivar 直接アクセス)を書き分けることで明示的に選択できましたが、Swift には ivar 直接アクセスの構文がないため、書き手が「いま @NSCopying が効いているかどうか」を構文上区別できません。Objective-C 出身の開発者ほど、イニシャライザ内の self.employee = candidate でもコピーが起きると思い込みやすく、バグの温床になっていました。この Proposal は、この非一貫性を埋めることを目的としていました。

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

この Proposal は、イニシャライザ内の @NSCopying プロパティへの代入にも自動コピーを適用するよう、コンパイラに「マジック」を追加することを提案していました。ストレージに直接書き込むという Swift のイニシャライザの基本モデルは変えず、@NSCopying が付いているプロパティに代入する場合だけ、コンパイラが裏で copy(with:) の呼び出しを差し込む、というアイデアです。

これにより、次のコードを書いただけで、self.employee には candidate のコピーが格納されるようになります。

init(employee candidate: Person) {
    // コンパイラが copy(with:) の呼び出しを挿入してくれるので、
    // これまで必要だった .copy() as! Person を手で書かなくてよい
    self.employee = candidate
    super.init()
}

従来はこの挙動を得るために、開発者がイニシャライザの中で明示的に self.employee = candidate.copy() as! Person と書く必要がありました。Proposal の案ではこの定型コードが不要になり、@NSCopying の契約がイニシャライザの内外で一貫するようになります。

代替案として、コンパイラが自動でコピーを挿入するのではなく、@NSCopying プロパティへの代入をコンパイル時に警告・エラーとして検出し、IDE の Fix-it で .copy() as! T を付け足すよう誘導する案も議論されました。

結末

この Proposal は 2017 年 3 月に一度は受理されたものの、実装は行われないまま時間が経ちました。Core Team は未実装のまま残っていた古い Proposal をまとめて整理する過程で、本 Proposal を遡及的に Rejected とすることを決定しています。したがって、提案された「イニシャライザ内での自動 copy(with:) 呼び出し」は Swift には取り込まれていません。

実運用上は、これまでどおりイニシャライザの中で @NSCopying プロパティにコピーを格納したい場合は、明示的に copy() を呼ぶ必要があります。

init(employee candidate: Person) {
    self.employee = candidate.copy() as! Person
    super.init()
}

なお、そもそも @NSCopyingNSCopying プロトコルに適合した Objective-C 由来のクラス型プロパティに対する仕組みであり、Swift らしい設計では値型(struct)を使うことで同等の「コピーセマンティクス」が言語レベルで保証できます。新しくコードを書く場面では、可能なら値型を使うことが推奨されます。