Swift Digest
SE-0525 | Swift Evolution

RawSpanの安全な読み込みAPI

Safe loading API for RawSpan

Proposal
SE-0525
Authors
Guillaume Lessard
Review Manager
Xiaodi Wu
Status
Accepted with Modifications

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

SE-0447 で導入された RawSpan は、連続した生バイト列を安全に借用するための型でした。ただし、そこからメモリ上の値を「型」として取り出す API は、今のところいずれも unsafe としてマークされています。

extension RawSpan {
    // properly aligned であることが前提
    public func unsafeLoad<T>(
        fromByteOffset offset: Int = 0, as: T.Type
    ) -> T

    // アライン不問。T は BitwiseCopyable に限る
    public func unsafeLoadUnaligned<T: BitwiseCopyable>(
        fromByteOffset offset: Int = 0, as: T.Type
    ) -> T
}

unsafeLoad / unsafeLoadUnaligned は、呼び出し側が「この型なら任意のビットパターンを値として解釈しても問題ない」ことを自分で保証しなければなりません。たとえばネイティブな整数型であれば実際には安全に読み出せますが、Bool のように一部のバイトしか使わない型や、Range<Int> のように lowerBound <= upperBound といったセマンティックな制約を持つ型を読み出してしまうと未定義動作になります。Optional<Int16> のように stride に対してパディングが入る型では、書き込みの側にもケアが必要です。

こうした判断を毎回利用者に委ね、ネイティブ整数のように本来安全に読み書きできる場面まで unsafe の旗を立て続けるのは、プロセス間通信用のバイト列を組み立てる場面やバイナリパーサを書く場面での使い勝手を大きく損ねます。書き込み側も同様で、MutableRawSpan.storeBytesOutputRawSpan.appendBitwiseCopyable な任意の型を受け付けますが、パディングを含む型を書き込むと未初期化バイトが残りうるため、やはり unsafe 相当の注意が必要でした。

また、RawSpan から型付きの Span<T> を作る操作、ネットワーク越しのエンコード/デコードで必然的に発生するバイトオーダーの指定なども、標準ライブラリとしての受け皿がなく、利用者が手元で組み立てるしかありませんでした。

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

RawSpan / MutableRawSpan / OutputRawSpan に対する安全なロード・ストア API を、2 つのマーカープロトコルと、それらを使った load(from:as:) / storeBytes / append のオーバーロード群として追加します。併せて、バイトオーダーを明示する ByteOrder や、RawSpan を型付きの Span として見るイニシャライザも提供されます。

ConvertibleToBytesConvertibleFromBytes

「型付きの値」と「生バイト列」の変換可能性を、2 つのマーカープロトコルに切り分けます。

@_marker public protocol ConvertibleToBytes: Copyable {}
@_marker public protocol ConvertibleFromBytes: BitwiseCopyable {}

public typealias FullyInhabited = ConvertibleToBytes & ConvertibleFromBytes

ConvertibleToBytes は「値を書き込んだとき、stride ぶんのすべてのバイトが確実に初期化される」ことを表します。size と stride が一致する(= パディングがない)こと、stored property がすべて ConvertibleToBytes に適合していること、値の意味がバイトの一部を無視して決まらないこと(多くの enum はここで弾かれる)などが条件です。Optional<Int16> は 4 バイト中 3 バイトしか使わないため適合できません。

ConvertibleFromBytes は「stored property の各バイトがどんなビットパターンでも有効な値になる」ことを表します。BitwiseCopyable を継承したうえで、セマンティックな制約がないことが要件です。struct Point { var x, y: Int } は適合できますが、Range<Int>lowerBound <= upperBound という制約があるため適合できません。Bool(1 バイトの一部しか使わない)、UnicodeScalar(一部のビットパターンが無効)、ポインタ型(実行時の状態で有効値が決まる)なども適合できません。

どちらのプロトコルも、当該モジュール側でのみ適合宣言できます。ConvertibleFromBytes はコンパイラ側で完全検証ができないため、標準ライブラリ外のユーザー型は @unchecked な適合として宣言します。

extension MyType: @unchecked ConvertibleFromBytes {}

標準ライブラリ側では、整数型(UInt8 / Int8 / … / Int128)、浮動小数(Float16 / Float / Double)、DurationInlineArray / CollectionOfOne(要素が適合しているとき)などが両方に適合します。Bool やポインタ型は ConvertibleToBytes にのみ適合します(書き込みは安全だが、任意のバイト列を読み戻すのは安全ではない)。

RawSpan / MutableRawSpan への load(from:as:)

RawSpanMutableRawSpan に、ConvertibleFromBytes 型に対する安全な load(fromByteOffset:as:) が追加されます。境界チェックされ、ポインタのアラインメントは要求されません。

extension RawSpan {
    public func load<T: ConvertibleFromBytes>(
        fromByteOffset offset: Int,
        as type: T.Type = T.self
    ) -> T
}

fromByteOffset にはデフォルト値が用意されていません。デフォルト値があると「RawSpan の全長を使って読み書きするのが基本である」かのような印象を与えるため、明示的に指定する設計となりました。

さらに、ConvertibleFromBytes & FixedWidthInteger 型には ByteOrder を受け取るオーバーロードが用意され、バイト列のエンコード順を明示して読み出せます。

@frozen
public enum ByteOrder: Equatable, Hashable, Sendable {
    case bigEndian, littleEndian
    public static var native: Self { get }
}

extension RawSpan {
    public func load<T: ConvertibleFromBytes & FixedWidthInteger>(
        fromByteOffset offset: Int,
        as type: T.Type = T.self,
        _ byteOrder: ByteOrder
    ) -> T
}

使い方は次のようになります。既存の unsafeLoad / unsafeLoadUnaligned のような unsafe 呼び出しが不要になります。

let bytes: RawSpan = /* どこかから借りた生バイト列 */

// アライン不問・境界チェックあり・安全
let tag = bytes.load(fromByteOffset: 0, as: UInt32.self)

// ネットワークから来たビッグエンディアンの値を読む
let length = bytes.load(fromByteOffset: 4, as: UInt32.self, .bigEndian)

従来の unsafeLoad には「バイトオフセットを境界チェックしない」版がありましたが、安全側の load には対応するオーバーロードは用意されません。そのユースケースでは既存の unsafeLoad(fromUncheckedByteOffset:as:) を引き続き使います。また、ロードはアトミック操作ではありません。

UInt8 のバイトアクセス用 subscript

単一バイトを読み書きする頻用ケースには、UInt8 の subscript が追加されます。Span / MutableSpan / OutputSpan の subscript に揃った形で、境界チェックありとチェックなし(@unsafe)の 2 種類があります。

extension RawSpan {
    public subscript(_ byteOffset: Int) -> UInt8 { get }
    @unsafe public subscript(unchecked byteOffset: Int) -> UInt8 { get }
}

extension MutableRawSpan {
    public subscript(_ byteOffset: Int) -> UInt8 { get set }
    @unsafe public subscript(unchecked byteOffset: Int) -> UInt8 { get set }
}

OutputRawSpan にも同様の subscript が追加されます。

MutableRawSpan / OutputRawSpan への安全な書き込み

MutableRawSpan.storeBytesOutputRawSpan.append には、ConvertibleToBytes & BitwiseCopyable を要求する安全なオーバーロードが加わります。FixedWidthInteger 版では ByteOrder も指定できます。

extension OutputRawSpan {
    public mutating func append<T>(
        _ value: T, as type: T.Type
    ) where T: ConvertibleToBytes & BitwiseCopyable

    public mutating func append<T>(
        _ value: T, as type: T.Type, _ byteOrder: ByteOrder
    ) where T: ConvertibleToBytes & BitwiseCopyable & FixedWidthInteger

    public mutating func append<T>(
        repeating repeatedValue: T, count: Int, as type: T.Type
    ) where T: ConvertibleToBytes & BitwiseCopyable
    // ByteOrder 付きの repeating オーバーロードもあり
}

これに伴い、従来の BitwiseCopyable だけを要求していた storeBytes(_:toByteOffset:as:) / append(_:as:)@unsafe として再マークされます。パディングを含む型ではコンパイラ最適化の結果として未初期化バイトが残りうるためで、安全に書き込みたい場合は上記のオーバーロードを使うことが推奨されます。

エンコーダ的な使い方は次のようになります。

var output: OutputRawSpan = /* どこかから借りた未初期化領域 */

output.append(UInt8(0x01), as: UInt8.self)
output.append(UInt32(payloadLength), as: UInt32.self, .bigEndian)
output.append(Float(1.5), as: Float.self)

RawSpan と型付き Span の相互変換

Span.init(viewing:) などを通じて、RawSpan を型付きの Span<T> として見ることもできるようになります。こちらは「同じメモリをその場で再解釈する」操作なので、load(as:) とは違い、アラインメントと境界(byteCountElement.stride の倍数か)が実行時に検査され、満たせない場合はトラップします。

extension Span {
    @_lifetime(copy bytes)
    public init(viewing bytes: RawSpan) where Element: ConvertibleFromBytes
}

extension MutableSpan {
    @_lifetime(&mutableBytes)
    public init(mutating mutableBytes: inout MutableRawSpan)
        where Element: ConvertibleToBytes & ConvertibleFromBytes

    @_lifetime(copy mutableBytes)
    public init(mutableBytes: consuming MutableRawSpan)
        where Element: ConvertibleToBytes & ConvertibleFromBytes
}

逆方向として、Span.bytes には Element: ConvertibleToBytes の場合の安全オーバーロードが追加され、MutableSpan.mutableBytes には Element: ConvertibleToBytes & ConvertibleFromBytes の場合の安全オーバーロードが追加されます。

アライメントが合わないメモリを扱ったり、ネットワーク・ファイル越しに値を読み書きしたりする場合は、その場での再解釈ではなく上述の load(as:) / storeBytes(_:as:_:) を使うことが想定されています。より高機能なパーサ用途には、別パッケージの swift-binary-parsing にある ParserSpan が引き続き推奨されます。

トップレベルの安全な bitCast

ConvertibleToBytesConvertibleFromBytes の組み合わせにより、これまで unsafeBitCast でしか書けなかった「同じサイズの型同士の再解釈」が安全に行えるようになります。

public func bitCast<T, U>(_ original: T, to type: U.Type) -> U
    where T: ConvertibleToBytes, U: ConvertibleFromBytes

メモリレイアウトの安定性について

適合対象となる型のメモリレイアウトは、コンパイラや標準ライブラリのバージョンをまたいで安定であるとは限りません。プロセス間通信や、同じプロセスが後で読み直すための一時保存には十分ですが、ネットワーク越しのプロトコルや永続化フォーマットを直接組み立てる場合には、本 API はあくまで土台として使い、バージョン間の互換性は利用者側で担保することが想定されています。

03 今後の見通し

提案では、次のような発展方向が挙げられています。いずれも将来の構想であり、実現を約束するものではありません。

ConvertibleToBytes のコンパイラによる検証

ConvertibleToBytes は、addressable なメモリ上のレイアウトだけで完全に判定できる性質なので、BitwiseCopyable と同じようにコンパイラ側で適合を検証・自動導出できる見込みです。あわせて、適合を選択した型についてはパディングの代わりに 0 バイトをストアする扱いに切り替える、といった案も検討されています。

ConvertibleFromBytes の部分的な検証

ConvertibleFromBytes の方はセマンティックな制約の有無まではコンパイラからは見えませんが、stored property がすべて ConvertibleFromBytes に適合していること、といった条件はコンパイラで強制できます。さらに、stored property がすべて public かつ var であれば「セマンティックな制約がない」とみなす、といった迂回的な判定の導入も考えられています。

C から取り込んだ型への対応

Clang importer に基本的な C 型がこれらのプロトコルに適合することを教え込み、aggregate な C 型についても適合を宣言できるようにする方向です。具体的には、「適合は型を所有するモジュールでしか宣言できない」という制限を、C から取り込んだ型に限って緩和する、といった案が示されています。

タプルや SIMD 型への対応

要素がすべて ConvertibleToBytes に適合するタプルは、それ自身も ConvertibleToBytes として扱えるはずで、ConvertibleFromBytes についても同様です。標準ライブラリの SIMD 型もこれらのプロトコルと相性が良いと考えられています。

RawSpan のアラインメントを調べるユーティリティ

Span(viewing:) などの Span イニシャライザは正しいアラインメントを持つ RawSpan を要求するため、ある型に対してどのオフセットが well-aligned なのかを調べるためのユーティリティが必要になりそうです。

名前に「unsafe」を含まない @unsafe API のリネーム

過去のProposalで導入された関数やプロパティの中には、後から @unsafe 注釈が付いたものの、名前自体に unsafety が表れていないものがあります。strict memory safety モードを無効にしている場合でも unsafety が関わるものについては、名前を整理する方向です。影響範囲が大きいため、別Proposalで慎重に扱うことが想定されています。

OutputRawSpan / OutputSpan をまたいだ append

当初の Proposal には、OutputRawSpan の一部を OutputSpan<T> として初期化する append や、OutputSpan の一部を OutputRawSpan として初期化する append が含まれていましたが、受理に際してこれらは Proposal から外され、今後の課題となりました。

span.append(upTo: capacity) { for _ in 0..<$0.capacity { $0.append(UInt8.zero) } }

ここで upTo のような capacity 用の引数ラベルをどう名付けるかは難しい論点で、同じ形のAPIは UniqueArray / RigidArray(SE-0527)の insert / replace / append にも必要になります。先にそちら側で命名を整理し、決着がついてから OutputSpan / OutputRawSpan 側にも揃った形で追加していく方針です。