Update API Naming Guidelines and Rewrite Set APIs Accordingly
01 何が問題だったのか
Swift の API デザインガイドラインは、副作用のあるメソッドは動詞句として、副作用のないメソッドは名詞句として読めるように命名することを求めています。ミュータブルな操作とイミュータブルな操作をペアで提供する場合のガイドラインもあり、「基本の操作を動詞で表せる」という前提に立って、ed や ing の接尾辞を付けたものを非破壊(非ミュータブル)側の名詞句として使うことになっていました。
しかし、この規則は 操作の自然な名前が最初から名詞である ケースをうまく扱えません。たとえば二つの集合の union(和集合) や、二つの整数を割ったときの remainder(剰余) は、動詞ではなく名詞として定着した用語です。この場合、非破壊側は union という名詞句がすでに適切な名前として存在する一方で、それに対応する破壊的(ミュータブル)なメソッドをどう命名するかの規則が定まっていませんでした。
Set API 側の問題
SE-0006 で標準ライブラリ全体に API ガイドラインが適用された際も、この命名規則の欠落のせいで、SetAlgebra / Set<T> / OptionSet<T> の API だけは整理の対象から外されていました。結果として、集合操作のメソッド名は新ガイドラインと整合しないまま残されていました。
あわせて、Set と OptionSet の API には細かな使い勝手の問題もありました。
SetAlgebraには要素同士が「互いを包含する/互いに素である」という概念に基づく static メソッドが定義されていましたが、これはremoveの意味を説明するためだけに使われていて、一般の利用者にとっては分かりにくいものでした。OptionSetで複数ビットをまとめた「複合オプション」をremoveに渡したとき、元の集合にそのうち一部のビットしか立っていないと戻り値がnilになり、「何が取り除かれたのか」を表現できませんでした。Set.insert(_:)は、すでに等しい要素が入っていても新しい要素で置き換える仕様でした。クラスのインスタンスのように==では等しくても===では区別できる要素を使う場合、この挙動が意図せずインスタンスを差し替えてしまうことがあり、NSMutableSet.insertの挙動とも食い違っていました。また、挿入が実際に行われたかどうかを呼び出し側が知る手段もありませんでした。
02 どのように解決されるのか
この提案では、まず API デザインガイドラインに form 接頭辞 の規則を追加し、そのうえで SetAlgebra / Set / OptionSet の API を新ガイドラインに沿って書き直しました。Swift 3.0 で実装されています。
form 接頭辞による命名規則
操作の自然な名前が名詞である場合、非破壊側にはその名詞をそのまま使い、破壊的な対応物には form を接頭辞として付けて動詞句 にします。たとえば union に対しては formUnion を、intersection に対しては formIntersection を用意します。意味としては次の二つが等価になります。
x.formUnion(y)
// は
x = x.union(y)
// と等価
この規則によって、「名詞で定着している操作」についても、非破壊側の名詞句とミュータブル側の動詞句を一貫した形で並べられるようになりました。
Set / SetAlgebra / OptionSet の新しい API
SetAlgebra プロトコルへの変更が Set と OptionSet にも波及します。利用イメージは次のとおりです。
x = y.union(z)
y.formUnion(z) // y = y.union(z)
x = y.intersection(z)
y.formIntersection(z) // y = y.intersection(z)
x = y.subtracting(z)
y.subtract(z) // y = y.subtracting(z)
x = y.symmetricDifference(z)
y.formSymmetricDifference(z) // y = y.symmetricDifference(z)
if x.contains(c) { /* ... */ }
y.insert(a)
y.remove(b)
y.update(with: c)
if x.isSubset(of: y)
&& y.isStrictSubset(of: z)
&& z.isDisjoint(with: x)
&& y.isSuperset(of: z)
&& x.isStrictSuperset(of: z)
&& !y.isEmpty { /* ... */ }
subtract / subtracting だけは例外的に「動詞 / 動詞 + ing」の組になっています。これは subtraction の動詞形 subtract がすでに広く使われているためで、formDifference のような名前よりも自然です。
insert(_:) の挙動と戻り値の変更
Set に対する insert(_:) の意味が次のように変わりました。
- すでに等しい要素が入っていれば 何もしない(以前は等しい要素を新しいものに置き換えていました)。これは
NSMutableSet.insertの挙動とも揃い、多くの場合により効率的です。 - 戻り値として、挿入が実際に起きたかどうかと、挿入後に集合の中にある「等しい要素」を返すタプル(
@discardableResult)を返すようになりました。呼び出し側が戻り値を捨てても従来どおり動きますが、必要なら結果を参照できます。
var set: Set<String> = ["a", "b"]
let result = set.insert("a")
// result.inserted == false
// result.memberAfterInsert == "a"(もともと入っていた方)
let result2 = set.insert("c")
// result2.inserted == true
// result2.memberAfterInsert == "c"
以前のような「等しい要素があっても新しい値で置き換える」挙動が必要な場合は、新設された update(with:) を使います。update(with:) は置き換えられた古い要素(存在しなかった場合は nil)を返します。
var set: Set<Box> = [Box(id: 1, label: "old")]
// 同じ id を持つ新しいインスタンスで上書きしたい
let replaced = set.update(with: Box(id: 1, label: "new"))
// replaced は置き換え前のインスタンス(label == "old")
OptionSet.remove の戻り値の意味の整理
OptionSet 側では、複数ビットをまとめた「複合オプション」e を s.remove(e) に渡したとき、s がそのビットの一部しか立てていない場合でも戻り値を nil にせず、s.intersection(e)(実際に取り除かれた部分) を返すようになりました。これにより、「何が実際に取り除かれたか」を常に知ることができます。この挙動変更は OptionSet にのみ影響し、通常の Set の remove の挙動には影響しません。
あわせて、SetAlgebra のドキュメントから「要素同士が包含する/互いに素である」という概念と、それに紐付いていた static メソッド群が取り除かれました。remove の意味論はこれらに頼らなくても説明できるため、API が整理されています。
既存コードへの影響
メソッド名の変更はソース互換性を壊しますが、大半はマイグレータで機械的に置き換え可能です。注意が必要なのは次の二点です。
set.insert(x)は 等しい要素がすでにあると何もしない ようになりました。従来の「常に置き換える」挙動に依存していたコードはset.update(with: x)に書き換える必要があります。実用上、この挙動差は==で等しいが===では区別できる要素(クラスのインスタンスなど)を扱う場合にしか現れません。OptionSet.removeの戻り値の意味が前述のとおり変わっているため、戻り値を使っているコードは意図した意味と合っているかを確認する必要があります。