Swift Digest
SE-0502 | Swift Evolution

Exclude private initialized properties from memberwise initializer

Proposal
SE-0502
Authors
Hamish Knight, Holly Borla
Review Manager
Tony Allevato
Status
Implemented (Swift Next)

01 何が問題だったのか

struct に対して自動合成されるmemberwise initializerは、すべてのstored propertyを引数にとってひとつずつ初期化してくれる便利な仕組みです。たとえば次のコードでは init(x: Int, y: Int) が合成されます。

struct S {
  var x: Int
  var y: Int
}

let s = S(x: 1, y: 2)

しかしこの合成には落とし穴がありました。privatefileprivate で初期値を持つpropertyをひとつでも追加すると、そのpropertyもmemberwise initializerに含まれてしまい、initializer全体のアクセスレベルがそのpropertyに合わせて下がってしまいます。

struct S {
  var x: Int
  var y: Int
  private var z = 0
}

let s = S(x: 1, y: 2) // error: 'S' initializer is inaccessible due to 'private' protection level

ここでは z に初期値があるため、本来は S(x:y:) という形で呼び出せれば十分なのに、z がinitializerの引数として含まれたうえでinitializer自体が private になってしまい、型の外から使えません。結果として、利用者は自分でmemberwise initializerを手書きで定義し直す羽目になりがちです。

この挙動はとくにproperty wrapper相当のふるまいを提供しようとする attached macro にとって厄介です。組み込みのproperty wrapperでは、コンパイラが private な backing property をmemberwise initializerから除外してくれますし、property自体も初期値を持ち fileprivateprivate ならば除外されます。

@propertyWrapper
struct Wrapper<T> {
  var wrappedValue: T
}

struct S {
  @Wrapper private var x = 1
  var y: Int
}
let s = S(y: 2) // Okay

同じことをmacroで再現しようとすると、macro展開で private な backing property を追加する必要があるため、現状のルールではmemberwise initializerが private に格下げされてしまい、ソース互換なまま置き換えることができません。

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

memberwise initializerに含めるpropertyの判定ルールを変更し、「initializer全体のアクセスレベルよりも低く、かつ初期値を持つproperty」は自動的に除外するようにします。あわせてソース互換のために旧シグネチャのinitializerも互換用オーバーロードとして合成されます。

新しい判定ルール

memberwise initializerのアクセスレベルは、memberwise initializableなpropertyのうち最も高いものに合わせて決まり、ただし上限は internal です。明示的なアクセス修飾子を持たないpropertyは、囲む型のアクセスレベル((file)private な型の場合は事実上 fileprivate)を持つものとして扱われます。

そのうえで、次のようなpropertyだけがmemberwise initializerの引数から外れます。

  • そのinitializerの最大アクセスレベルより低いアクセスレベルを持ち、かつ
  • 初期値を持っている

ここでいう「初期値を持つ」とは、private var x = 0 のように明示的な初期値がある場合だけでなく、private var x: Int? のように Optional などでデフォルト初期化される場合も含みます。init accessorを持つcomputed propertyでも、初期値があれば同様に対象になります。

このルール変更により、冒頭の例は次のようにそのまま使えるようになります。

struct S {
  var x: Int
  var y: Int
  private var z = 0
}

let s = S(x: 1, y: 2) // OK

xy の最大アクセスレベルが internal なので、memberwise initializerも internal になり、それより低い privatez は初期値を持つため除外されます。zfileprivate でも同じ結果になります。

ルールが働かないケース

すべてのpropertyが private など同じアクセスレベルのときは、initializer自体の最大アクセスレベルもそこまで下がるので、除外は起こりません。

struct S {
  private var x = 0
  private var y: Int?
}
// init(x: Int = 0, y: Int? = nil) は private のまま、両方とも含まれる

yfileprivate に変えれば、最大アクセスレベルが fileprivate に上がり、x が除外されて fileprivate init(y: Int? = nil) になります。

また、初期値を持たないpropertyは引き続きinitializerの引数として必須になるため、除外されません。従来と同様にinitializerのアクセスレベルがpropertyに引きずられます。

struct S {
  var x: Int
  private var y: Int
}

let s = S(x: 0, y: 1) // error: 'S' initializer is inaccessible due to 'private' protection level

型自体が (file)private のときは、無指定のpropertyも実質 fileprivate なので、最大アクセスレベルは fileprivate になり、どちらのpropertyも含まれます。

fileprivate struct S {
  var x: Int?
  fileprivate var y: Int?
}

let s = S(x: 0, y: 1) // OK

memberwise initializerの上限は internal なので、public propertyがあっても挙動は変わりません。次の例の合成initializerは internal init(x: Int = 0, y: Int = 0) のままです。

public struct S {
  public var x: Int = 0
  var y: Int = 0
}

互換用オーバーロード

ルール変更だけでは、既存コードが同一ファイル内で private propertyをmemberwise initializerから初期化していた場合にソース互換が壊れます。そこでコンパイラは、従来と同じ引数を取る互換用オーバーロードも引き続き合成します。新旧どちらの呼び出し方も同じファイル内からは動作するため、同一モジュール内の利用はほとんど壊れません(memberwise initializerはもともと最大でも internal なので、影響範囲は定義元のモジュールに限られます)。

それでも次の2つのケースは互換用オーバーロードでは救えません。

  • let fn = S.init のようにコンテキスト型を持たないベース名参照では、デフォルト引数が少ない新しいinitializerが選ばれるため、下流で引数不足のエラーになりうる
  • 同じ型のextensionで、新しいmemberwise initializerと同じシグネチャのinitializerがすでに定義されている場合、合成の抑制が働かないため redeclaration エラーになる

いずれも実運用ではごく稀なケースです。

今後の見通し(Future Directions)

将来の言語モードでは、互換用オーバーロードを段階的に取り除くことが検討されています。まずはwarningとfix-itで明示的なinitializerへの置き換えを促し、そのあと新しい言語モードでは互換用オーバーロードを完全に廃止する、というイメージです。upcoming feature flagを通じて、互換用オーバーロードなしの新しい挙動を先行して試せるようにする案も示されていますが、いずれも現時点では確定した仕様ではありません。

さらに先の方向性としては、どのpropertyをmemberwise initializerに含めるかをユーザーが明示的に指定できるような仕組み(属性や #memberInit(...) のようなmacro風構文、あるいは memberwise キーワードなど)も検討対象として挙げられていますが、こちらもあくまで将来の可能性の議論にとどまります。