Swift Digest
SE-0458 | Swift Evolution

Opt-in Strict Memory Safety Checking

Proposal
SE-0458
Authors
Doug Gregor
Review Manager
John McCall
Status
Implemented (Swift 6.2)

01 何が問題だったのか

Swift は参照カウントによる lifetime safety、コレクションの bounds 検査、as?enum による type safety、definite initialization による initialization safety、そして Swift 6 の strict concurrency による thread safety と、5 つの次元でメモリ安全性を提供しています。しかし同時に、実用性とのバランスを取るため、意図的に「安全でない」構成要素も持っています。

代表的なものとして次のようなものが挙げられます。

  • UnsafePointer / UnsafeBufferPointer / UnsafeRawPointer などのポインタ型
  • Optional.unsafelyUnwrappedunsafeBitCastunsafeDowncast
  • UnmanagedUnsafeContinuationUnsafeCurrentTask
  • unowned(unsafe)nonisolated(unsafe)@exclusivity(unchecked) などの言語機能
  • -Ounchecked-enforce-exclusivity=unchecked といったコンパイラフラグ
  • C / C++ との相互運用(ポインタを含む API は安全性が保証されません)

多くの Swift 利用者にとっては、これは「デフォルトで十分に安全、必要なときだけ unsafe を使う」という妥当な設計です。しかし、セキュリティ上の要件が厳しいプロジェクトや、よりメモリ安全な部分と unsafe な部分を明確に分離したいプロジェクトでは、unsafe な構成要素が使われている箇所を見落とさずに把握し、コードレビューや監査の対象にしたいという需要があります。

問題は、現在の Swift では次のような点です。

  • UnsafePointer を使っているかどうかはシグネチャを読めば分かりますが、unsafeBitCastOptional.unsafelyUnwrapped のように式の中で使われる unsafe な操作は、目視でコードを追わないと見つかりません。
  • ある関数が内部で unsafe を使っているか、それとも unsafe を一切使わずに済ませているかは、呼び出し側からは区別がつきません。
  • プロジェクト全体で「unsafe が使われていないこと」を機械的にチェックする仕組みがありません。

また、C / C++ と相互運用する Swift コードでは、インポートされた API の多くが UnsafePointer を介してやり取りされるため、一見 Swift らしい呼び出しの裏に unsafe な型が潜んでいることがあります。こうした箇所も「ここは unsafe だ」と目印を付けてレビュー対象にできると安心です。

strict concurrency checking のように Swift 全体を段階的に厳格化していく流れの中で、メモリ安全性についても同様に、オプトインで厳格な検査を有効化し、unsafe な箇所をソースコード上に明示できる仕組みが求められていました。

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

unsafe なコードをソースコード上で明示するためのオプトインの strict memory safety checking モードを導入します。このモードは新しい言語モードではなく、モジュール単位で有効にできる追加の診断モードです。中心となる要素は次の 4 つです。

  • コンパイラフラグ -strict-memory-safety(SwiftPM からは .strictMemorySafety() として設定可能)
  • 宣言に付与する @unsafe 属性と、それを打ち消す @safe 属性
  • 式単位で unsafe を明示する unsafe 式演算子
  • #if hasFeature(StrictMemorySafety) で検出できる StrictMemorySafety フィーチャ

このモードで報告される診断はすべて警告で、StrictMemorySafety という診断グループに属するため、SE-0443 の仕組みで -Werror StrictMemorySafety のようにエラーに昇格させることもできます。@unsafeunsafe 式自体は strict checking が無効でも使えますが、意味論には影響せず、strict mode でのみ診断が出る仕組みです。

@unsafe 属性と「implicitly @unsafe

@unsafe は宣言に付与し、「使うとメモリ安全性を損ないうる」ことを示します。標準ライブラリでは次のような型・API が @unsafe となります。

@unsafe
public struct UnsafeBufferPointer<Element> { ... }

重要なのは、シグネチャに unsafe な型が含まれる宣言は暗黙的に @unsafe と見なされるという点です。したがって次の関数も、ユーザー側で @unsafe と書かなくても implicitly @unsafe となります。

// シグネチャに UnsafePointer があるので implicitly @unsafe
func sumIntBuffer(_ address: UnsafePointer<Int>?, _ count: Int) -> Int { ... }

strict mode でこの関数を呼び出すと、呼び出しおよび UnsafeBufferPointer.baseAddress の参照に対して「unsafe な関数/プロパティを使っている」という警告が出ます。

extension Array<Int> {
  func sum() -> Int {
    withUnsafeBufferPointer { buffer in
      // warning: use of unsafe function 'sumIntBuffer' and unsafe property 'baseAddress'
      sumIntBuffer(buffer.baseAddress, buffer.count)
    }
  }
}

unsafe 式演算子

この警告を抑止するのが unsafe 式です。tryawait と同様、式の前に unsafe を付けることで、その式に含まれる unsafe な箇所を受け入れたことを表明します。

extension Array<Int> {
  func sum() -> Int {
    withUnsafeBufferPointer { buffer in
      unsafe sumIntBuffer(buffer.baseAddress, buffer.count)
    }
  }
}

一つの unsafe が式の中の複数の unsafe な箇所(sumIntBuffer の呼び出し、bufferbuffer.baseAddress)をまとめてカバーします。try とは異なり、unsafe は外側の文脈に伝播しません。つまり sum 関数自体を @unsafe にする必要はなく、シグネチャに unsafe な型が現れていなければ、その関数は「unsafe を内部で封じ込めた安全な関数」として扱われます。

@safe 属性

シグネチャに unsafe な型を含むが、実際には安全に使える API には @safe を付けます。代表例は Array.withUnsafeBufferPointer で、UnsafeBufferPointer を渡すもののバッファの lifetime と bounds は Array が面倒を見るため、呼び出し自体は安全です。

extension Array {
  @safe func withUnsafeBufferPointer<R, E>(
    _ body: (UnsafeBufferPointer<Element>) throws(E) -> R
  ) throws(E) -> R
}

また @safe 宣言に直接渡された unsafe 型の変数は、そのアクセス自体も診断されません。たとえば buffer.count は unsafe 型の値の参照ですが、count@safe ならば警告は出ません。

extension UnsafeBufferPointer {
  @safe public let count: Int
  @safe public var startIndex: Int { 0 }
  @safe public var endIndex: Int { count }
}

unsafe なストレージを持つ型

stored property に unsafe な型や unsafe な conformance を含む型は、それ自体を @safe または @unsafe のどちらかで明示的にマークする必要があります。どちらでもない場合は警告になります。

public struct DataWrapper {
  var buffer: UnsafeBufferPointer<UInt8>
}
// warning: type `DataWrapper` that includes unsafe storage
// must be explicitly marked `@unsafe` or `@safe`

unsafe なポインタを内部に抱えつつ、外向きには安全なインタフェースを提供する「ラッパ」を書く場合は @safe を付け、unsafe 式で unsafe 操作を封じ込めます。

@safe
struct ImmortalBufferWrapper<Element>: Collection {
  let buffer: UnsafeBufferPointer<Element>

  @unsafe init(_ withImmortalBuffer: UnsafeBufferPointer<Element>) {
    self.buffer = unsafe withImmortalBuffer
  }

  subscript(index: Index) -> Element {
    precondition(index >= 0 && index < buffer.count)
    return unsafe buffer[index]
  }
}

unsafe な conformance と override

ある型が protocol に適合する方法自体が unsafe である場合は、extension T: @unsafe P { ... } のように conformance 自体を @unsafe にします。標準ライブラリでは UnsafeBufferPointerSequence / Collection への適合、UnsafePointer などの Strideable への適合が @unsafe conformance となります。

extension UnsafeBufferPointer: @unsafe Collection { ... }

安全な要件を unsafe なメソッドで満たそうとすると警告が出ます。これは conformance に @unsafe を付けることで受け入れられます。同様に、safe なメソッドを @unsafe な override で上書きする場合は、サブクラス自体を @unsafe にする必要があります(Super に変換された時点で unsafe 性が見えなくなるため、クラス単位での明示が必要です)。

for..in ループ

for..inSequence / IteratorProtocol を介して展開されるため、conformance が @unsafe だとループ全体が unsafe な反復になります。このとき SE-0298 に倣い、for の直後に unsafe を置いて反復自体の unsafe 性を受け入れます。シーケンスを返す式も unsafe なら、さらにその側にも unsafe が要ります。

for unsafe x in unsafe someBuffer {
  // ...
}

try await が二重に現れるのと同じ要領です。

unsafe な言語構成とコンパイラフラグ

次の言語機能は常に unsafe として扱われます。

  • unowned(unsafe)unsafeAddressor / unsafeMutableAddressor@exclusivity(unchecked)
  • strict concurrency 下では nonisolated(unsafe)@preconcurrency import

また -Ounchecked-enforce-exclusivity=unchecked / none-strict-concurrencycomplete 以外、-disable-access-control を指定していると、strict memory safety mode と組み合わせた時点で診断が出ます。

C / C++ 相互運用

C / C++ からインポートされる API のうち、ポインタ型を含むものは Swift 側で自然に UnsafePointer などにマップされるため、シグネチャ経由で自動的に implicitly @unsafe になります。たとえば char *strstr(const char *, const char *)UnsafePointer<CChar>? を持つ Swift 関数として取り込まれ、strict mode では unsafe として扱われます。

ユーザー定義の C / C++ 型は、ポインタや C++ の参照を含むメンバーを持つ場合に implicitly @unsafe になります(例: struct ListNode { void *element; struct ListNode *next; })。一方、struct Point { double x, y; } のような純粋な値のみを持つ struct や、C の enum は安全と見なされます。

段階的な導入

-strict-memory-safety を有効にすると、既存コードはほぼ Fix-It だけで対応できます(@unsafeunsafe を適切な場所に入れていくだけで警告は消えます)。strict concurrency と違って型システムには影響しないため、ライブラリが strict memory safety を採用していなくてもクライアントは採用でき、逆にライブラリが採用してもクライアントはすぐに追随する必要がありません。そしてこのモードは「コードをより安全にする」ものではなく、「unsafe な箇所を明示してレビュー・監査しやすくする」ためのものです。安全な代替(たとえば Span / RawSpan)が用意されている場合は、警告をきっかけにそちらへ移行することが推奨されます。

このモードはデフォルト化を目指すものではなく、Span のような安全な代替の普及状況や C / C++ 相互運用の事情を踏まえて、当面は opt-in のままとされる見通しです。

Future Directions

今後の課題として、SerialExecutor / Actor プロトコルの unownedExecutor 要件を安全化する方向が挙げられています。非エスケープ可能な UnownedSerialExecutorSE-0446 に基づく)と、新しい safe な要件を導入し、あらゆる actor が自動的に unsafe とならないようにする案が示されています。また、マクロ展開に含まれる unsafe コードを利用側から抑止する仕組みや、@unsafe と書かれた enum ケースをパターンマッチで扱う際の診断の扱いについても、今後の拡張候補として挙げられています(いずれも現時点では方向性の議論にとどまります)。