Swift Digest
SE-0189 | Swift Evolution

Restrict Cross-module Struct Initializers

Proposal
SE-0189
Authors
Jordan Rose
Review Manager
Ted Kremenek
Status
Implemented (Swift 4.1)

01 何が問題だったのか

Swift のstructはライブラリ側で柔軟に進化できるように設計されており、リリース間でstored propertyの構成を変えられることも想定されています。しかし、別モジュールから宣言されたイニシャライザがstored propertyを直接代入できる仕組みがあったため、ライブラリ作者が意図した進化や不変条件の保証が壊れやすいという問題がありました。

stored propertyの追加がソース破壊になってしまう

イニシャライザは、self を使う前にすべてのstored propertyを初期化する必要があります。その方法は2通りあります。

  • すべてのstored propertyを個別に代入する
  • self.init(…) または self = … でまとめて初期化する

前者はstructのstored property一覧を知っていることが前提です。それらがすべて public であれば、別モジュールのクライアントがextensionで独自のイニシャライザを書けてしまいます。すると、ライブラリ側でstored propertyを1つでも追加(public かどうかにかかわらず)した瞬間、そのクライアントコードはコンパイルが通らなくなり、ソース破壊となってしまいます。

let プロパティによる不変条件が壊されてしまう

stored propertyを直接代入できると、ライブラリ作者がイニシャライザで守らせている不変条件を、クライアント側のextensionがすり抜けて壊せてしまいます。たとえば次のstructでは、positivenegative が常に符号反転の関係にあることが期待されています。

public struct BalancedPair {
  public let positive: Int
  public let negative: Int
  public init(absoluteValue: Int) {
    assert(absoluteValue >= 0)
    self.positive = absoluteValue
    self.negative = -absoluteValue
  }
}

しかし、別モジュールから次のようなイニシャライザを追加できてしまいます。

import ContrivedExampleKit
extension BalancedPair {
  init(positiveOnly value: Int) {
    self.positive = value
    self.negative = 0
  }
}

これでは let で守っているはずの不変条件が崩れてしまい、structの設計意図が保てません。

なお、クラスについては以前から「別モジュールのイニシャライザは convenience initializer でなければならない」という制約がありましたが、structには同様の制約がなく、一貫性も欠けていました。

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

structを宣言しているモジュールとは別のモジュールでイニシャライザを定義する場合、self に触れる前に必ず self.init(…)self = … を呼ばなければならない、という制約が導入されます。stored propertyに直接代入して初期化することは認められなくなります。

この制約違反は、Swift 4では警告、Swift 5以降ではエラーとなります。クラスでの cross-module イニシャライザが convenience initializer に限られているのと揃った形の制約です。

書けなくなる例と書き直し方

たとえば次のような別モジュールからのextensionは、これまで通用していましたが、今後は認められません。

// 別モジュール
import ContrivedExampleKit
extension BalancedPair {
  init(positiveOnly value: Int) {
    self.positive = value // error (Swift 5): 直接代入は不可
    self.negative = 0
  }
}

別モジュールから新しいイニシャライザを追加したい場合は、ライブラリ側で用意された既存イニシャライザに委譲する形に書き直します。

extension BalancedPair {
  init(positiveOnly value: Int) {
    self.init(absoluteValue: value) // OK: 既存イニシャライザに委譲
  }
}

ライブラリ作者の対応

別モジュールからstored propertyを個別に指定して初期化したいという需要にこたえたい場合、ライブラリ側で明示的に public なメンバワイズイニシャライザを提供することが推奨されます。そうしておけば、クライアントはそのイニシャライザ経由で初期化でき、ライブラリ側も「どのプロパティが外部から直接初期化され得るか」を自覚した上で設計を進められます。

C structについて

Cから取り込まれるstructもこの制約の対象ですが、Cのstructにはインポート時にメンバワイズイニシャライザが自動的に用意されるため、通常はそれを使えば問題になりません。多くのCのstructには、どのメンバも _Nonnull でなければゼロ初期化する引数なしイニシャライザも用意されます。

ライブラリ進化への効果

この制約によって、structに新しいstored propertyを追加することがソース互換な変更となります(Swift 4で警告を無視していたクライアントを除きます)。バイナリ互換性の観点でも、public / 非 public いずれのstored propertyを追加してもバイナリ互換な変更となります。ただし、既存の public なstored propertyを削除することは引き続きバイナリ互換な変更ではありません。

テストでの回避策

以前は @testable インポート時にこの制約を緩める案も検討されましたが、採用されていません。同一モジュール内でのテスト用途であれば、ライブラリ側に internal なイニシャライザを用意し、@testable import 経由で使う形で十分対応できます。

public struct ExportConfiguration {
  public let speed: Int
  public let signature: String
  public init(from fileURL: URL) { /* ... */ }
  internal init(manualSpeed: Int, signature: String) { /* ... */ }
}
import XCTest
@testable import MyApp

class ExportTests: XCTestCase {
  func testSimple() {
    let config = ExportConfiguration(manualSpeed: 5, signature: "abc")
    // ...
  }
}