UnsafeRawPointer API
01 何が問題だったのか
Swift は通常、型安全なメモリアクセスと strict aliasing(ある領域のメモリは単一の型を通じてアクセスされる、というコンパイラの前提)を保証しますが、UnsafePointer / UnsafeMutablePointer を使えばこの保証をすり抜けることができ、そこから容易に未定義動作が生じていました。
たとえば次のコードは、ポインタを別の型にキャストして値を読み書きしており、いわゆる type punning を行っています。
let ptrT: UnsafeMutablePointer<T> = ...
// この番地に T の値を書き込む
ptrT[0] = T()
// 同じ番地から U の値として読み出す
let u = UnsafePointer<U>(ptrT)[0]
コンパイラは strict aliasing を前提に最適化を行うため、こうしたコードは「クラッシュする・メモリが壊れる・本来実行されるはずのコードが消える・実行されないはずのコードが実行される」といった未定義動作を引き起こす可能性があります。
さらに問題なのは、見た目が無害な引数変換でも同じ事態を招ける点です。
func takesUIntPtr(_ p: UnsafeMutablePointer<UInt>) -> UInt {
return p[0]
}
func takesIntPtr(q: UnsafeMutablePointer<Int>) -> UInt {
// UnsafeMutablePointer<Int> を UnsafeMutablePointer<UInt> に変換してしまう
return takesUIntPtr(UnsafeMutablePointer(q))
}
UnsafeMutablePointer の初期化子 init<U>(_ from: UnsafeMutablePointer<U>) によって、互換性のない型同士のポインタ変換が簡単にできてしまっていたため、ちょっとした書き換えが strict aliasing 違反につながってしまっていたのです。
加えて、当時の Swift には 型を持たない生のメモリを扱う API がありませんでした。UnsafePointer<Pointee> はあくまで「Pointee という型のメモリ領域」を指すポインタで、コンパイラは同じ領域への他のアクセスも Pointee と整合していると仮定します。そのため、次のような用途を安全に書く手段が欠けていました。
- 生のバイト列として
memcpy相当の読み書きを行う - ヘッダとペイロードのように複数の型を手動でレイアウトしたメモリを扱う
- 同じメモリ領域を、時点ごとに違う型として(再初期化しながら)使い回す
- C 由来の「
UInt8のバッファ」と「CCharのバッファ」のように、互換はあるが別型として扱われる API に同じバッファを渡す
これらを UnsafePointer のキャストで無理やり実現しているコードは、実際には strict aliasing を踏んで未定義動作に足を踏み入れていました。相互運用や高性能なデータ構造のために UnsafePointer は不可欠ですが、「何をしてよくて何をすると未定義なのか」のルールも、それを安全に書ける API も揃っていないのが当時の状況でした。
02 どのように解決されるのか
この提案では、メモリに「型が紐付いている/いない」という概念を明確化し、生メモリ用の新しいポインタ型 UnsafeRawPointer / UnsafeMutableRawPointer を導入します。そのうえで、strict aliasing を破るようなポインタ変換を禁止し、代わりに「いつメモリがどの型に紐付いているのか」を型レベルで追えるようにします。
生ポインタと型付きポインタの役割分担
新たに UnsafeRawPointer / UnsafeMutableRawPointer(raw pointer)が加わり、従来の UnsafePointer<T> / UnsafeMutablePointer<T>(typed pointer)と役割が分かれます。
- raw pointer は「型のないメモリ」を表します。読み書きは C の
memcpyと同じ意味論で、任意の型とエイリアスし得ます。strict aliasing の制約は課されません。 - typed pointer は「ある型
Tに束縛されたメモリ」を表します。アクセスは strict aliasing の対象で、コンパイラはこの前提に基づいて積極的に最適化できます。
メモリは常に「未初期化/初期化済み」のいずれかの状態にあり、初期化済みメモリにはかならず「束縛された型(bound type)」が存在します。deinitialize しても束縛型は維持され、別の型で再初期化されるまで残ります。
アロケーションと初期化
生メモリの確保・解放は UnsafeMutableRawPointer の静的メソッドで行います。生メモリを特定の型で初期化すると、戻り値として型付きポインタが得られます。
let rawPtr = UnsafeMutableRawPointer.allocate(
bytes: MemoryLayout<A>.stride, alignedTo: MemoryLayout<A>.alignment)
// 生メモリを A として初期化 -> UnsafeMutablePointer<A>
let pA = rawPtr.initializeMemory(as: A.self, to: A(value: 42))
// 使い終わったら deinitialize -> UnsafeMutableRawPointer に戻る
let uninitPtr = pA.deinitialize(count: 1)
uninitPtr.deallocate(bytes: MemoryLayout<A>.stride,
alignedTo: MemoryLayout<A>.alignment)
initializeMemory(as:to:) で T を明示するのは偶然ではなく、この時点でメモリの束縛型が決まるためです。型を値から推論させると、思わぬ型に束縛されてしまい、後続のアクセスで未定義動作を招くおそれがあります。
これまで通り「確保と同時に型を束縛したい」ケースでは、UnsafeMutablePointer<T>.allocate(capacity:) を使えば一手で済みます。
メモリを型に束縛する: bindMemory
初期化を伴わずに、生メモリを特定の型に束縛しておくこともできます。
let ptrA = rawPtr.bindMemory(to: A.self, capacity: 1)
ptrA.initialize(to: A())
bindMemory(to:capacity:) は「この領域を今後 A として扱う」とコンパイラに宣言するための操作です。すでに別の型で初期化されているメモリに対して呼ぶと、束縛型を上書き(rebind)します。束縛型を変えた後は、旧型の typed pointer を使ってアクセスしてはいけません(strict aliasing 違反となり未定義動作)。
一方、初期化されたメモリの束縛型を一時的だけ切り替えたいときは withMemoryRebound(to:capacity:) を使います。クロージャの実行中だけ別型のポインタとしてアクセスでき、抜けると自動的に元の束縛に戻ります。
// UInt8 として確保・初期化したバッファを、一時的に CChar として扱う
buffer.withMemoryRebound(to: CChar.self, capacity: size + 1) {
readCStr($0)
}
readBytes(buffer) // 元の UInt8 として引き続き安全に使える
withMemoryRebound はクロージャが self をキャプチャしない限り安全です。
型消しからの復帰: assumingMemoryBound
「すでに T に束縛されていることが分かっているが、今手元にあるのは生ポインタ」という場面もあります。この場合は rebind 不要な assumingMemoryBound(to:) で typed pointer を取り出せます。
let p: UnsafeMutableRawPointer = initRawAB() // (A, B) を順に配置した生領域
let pA = p.assumingMemoryBound(to: A.self)
printA(pA)
// 2 つ目の要素(オフセット = MemoryLayout<Int>.stride)を B として扱う
printB((p + MemoryLayout<Int>.stride).assumingMemoryBound(to: B.self))
これは静的には検証されませんが、「束縛型は呼び出し側が保証する」という契約を明示的に引き受ける形なので、無言のキャストよりも安全性を議論しやすくなります。
生メモリに対する読み書き: load / storeBytes
UnsafeRawPointer / UnsafeMutableRawPointer は、束縛型に関係なく(レイアウト互換さえ満たせば)load(fromByteOffset:as:) と storeBytes(of:toByteOffset:as:) で直接読み書きできます。これによりメモリを rebind することなく別型として再解釈できます。
// 生メモリに A を書き込み、その後 B として読み直す(再解釈)
let p = UnsafeMutableRawPointer.allocate(
bytes: MemoryLayout<Int>.stride, alignedTo: MemoryLayout<Int>.alignment)
p.initializeMemory(as: A.self, to: A(value: 42))
let b = p.load(as: B.self) // raw pointer 経由なら合法
ここで重要なのは、「typed pointer で A を B として読む」のは相変わらず違法で、raw pointer 経由でのみ合法だという点です。storeBytes は値をビット単位でコピーするだけで、既存の値のデインイニシャライズも、新しい値の構築も行いません。そのため、書き込みは「メモリが未初期化」または「対象の型が trivial(参照カウントや間接参照を持たない型。整数や浮動小数点、Bool、trivial 型のみを含む struct / enum など)」の場合に限ります。
バイト単位のポインタ演算
UnsafeRawPointer はバイト列として Strideable に適合し、+・- によるバイト単位のアドレス計算や、distance(to:)・advanced(by:) が使えます。これにより、ヘッダ + 可変長末尾など、手動でレイアウトしたメモリを素直に扱えます。
// 型 A のヘッダの後ろに count 個の B を並べる
let numBytes = MemoryLayout<A>.stride + count * MemoryLayout<B>.stride
let raw = UnsafeMutableRawPointer.allocate(
bytes: numBytes, alignedTo: MemoryLayout<A>.alignment)
let pA = raw.initializeMemory(as: A.self, to: A(value: 42))
UnsafeMutableRawPointer(pA + 1).initializeMemory(
as: B.self, count: count, to: B(value: 13))
UnsafePointer 間の暗黙変換を禁止
もっとも事故を招いていた Unsafe[Mutable]Pointer<Pointee>.init<U>(_:) による型の違うポインタ間の変換は廃止されます。これ以降、型の異なる typed pointer への乗り換えは bindMemory・withMemoryRebound・assumingMemoryBound のいずれか、あるいはいったん raw pointer を経由する形でしか書けません。結果として、ポインタの型変換が起きているコードは常にどれかの API 呼び出しとして目に見え、前提条件と事後条件が明示されるようになります。
未定義動作を踏んでいたパターンが明示的に安全になる
同じアドレスを時点ごとに違う型として使う、というよくあるパターンも、新 API ではそれぞれのステップが意味する操作が名前として表れ、安全に書けるようになります。
let raw = UnsafeMutableRawPointer.allocate(
bytes: MemoryLayout<Int>.stride, alignedTo: MemoryLayout<Int>.alignment)
// A として初期化(メモリは A に束縛)
let pA = raw.initializeMemory(as: A.self, to: A(value: 42))
printA(pA)
// deinitialize すると生ポインタに戻り、
let uninit = pA.deinitialize(count: 1)
// 改めて B として初期化する(束縛型が B に切り替わる)
let pB = uninit.initializeMemory(as: B.self, to: B(value: 13))
printB(pB)
旧来の UnsafePointer<A> → UnsafePointer<B> のキャストで書いていたコードは、どれも「どこで型を束縛/解放しているのか」が曖昧なまま strict aliasing を踏んでいました。新 API では各ステップが呼び出しとして表面化するため、違反しているかどうかがレビュー可能になります。
将来的な拡張
本 Proposal のスコープ外ですが、将来の可能性として、パックされた struct メンバーなどに備えて UnsafeRawPointer の load / initialize にアラインメント非依存(unaligned)版を追加することが言及されています(あくまで見通しであり、実現を約束するものではありません)。