Safe loading API for RawSpan
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.storeBytes や OutputRawSpan.append は BitwiseCopyable な任意の型を受け付けますが、パディングを含む型を書き込むと未初期化バイトが残りうるため、やはり unsafe 相当の注意が必要でした。
また、RawSpan から型付きの Span<T> を作る操作、ネットワーク越しのエンコード/デコードで必然的に発生するバイトオーダーの指定なども、標準ライブラリとしての受け皿がなく、利用者が手元で組み立てるしかありませんでした。
02 どのように解決されるのか
RawSpan / MutableRawSpan / OutputRawSpan に対する安全なロード・ストア API を、2 つのマーカープロトコルと、それらを使った load(from:as:) / storeBytes / append のオーバーロード群として追加します。併せて、バイトオーダーを明示する ByteOrder や、RawSpan を型付きの Span として見るイニシャライザも提供されます。
ConvertibleToBytes と ConvertibleFromBytes
「型付きの値」と「生バイト列」の変換可能性を、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 はコンパイラ側で完全検証ができないため、標準ライブラリ外のユーザー型は @unsafe な適合として宣言します。
extension MyType: @unsafe ConvertibleFromBytes {}
標準ライブラリ側では、整数型(UInt8 / Int8 / … / Int128)、浮動小数(Float16 / Float / Double)、Duration、InlineArray / CollectionOfOne(要素が適合しているとき)などが両方に適合します。Bool やポインタ型は ConvertibleToBytes にのみ適合します(書き込みは安全だが、任意のバイト列を読み戻すのは安全ではない)。
RawSpan / MutableRawSpan への load(from:as:)
RawSpan と MutableRawSpan に、ConvertibleFromBytes 型に対する安全な load(fromByteOffset:as:) が追加されます。境界チェックされ、ポインタのアラインメントは要求されません。
extension RawSpan {
public func load<T: ConvertibleFromBytes>(
fromByteOffset offset: Int = 0,
as: T.Type = T.self
) -> T
}
さらに、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 = 0,
as: 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.storeBytes と OutputRawSpan.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:) とは違い、アラインメントと境界(byteCount が Element.stride の倍数か)が実行時に検査され、満たせない場合はトラップします。
extension Span where Element: ConvertibleFromBytes {
@_lifetime(copy bytes)
public init(viewing bytes: RawSpan)
}
extension MutableSpan {
@_lifetime(&mutableBytes)
public init(mutating mutableBytes: inout MutableRawSpan)
where Element: ConvertibleToBytes & ConvertibleFromBytes
// consuming な初期化も用意される
}
逆方向として、Span.bytes / MutableSpan.mutableBytes は Element: ConvertibleToBytes の場合の安全オーバーロードが追加されます。
アライメントが合わないメモリを扱ったり、ネットワーク・ファイル越しに値を読み書きしたりする場合は、その場での再解釈ではなく上述の load(as:) / storeBytes(_:as:_:) を使うことが想定されています。より高機能なパーサ用途には、別パッケージの swift-binary-parsing にある ParserSpan が引き続き推奨されます。
OutputRawSpan / OutputSpan をまたいだ初期化
OutputRawSpan には、生バイト領域の一部を型付き OutputSpan<T> として初期化させるクロージャ API が追加されます。逆に OutputSpan の側には、型付き領域の一部を生バイトとして初期化させるクロージャ API が追加されます。いずれも入る前にアライメントと境界のチェックが行われ、満たせなければトラップします。
extension OutputRawSpan {
@_lifetime(copy self)
public mutating func append<T, E: Error>(
elements n: Int,
as type: T.self,
initializingWith initializer: (inout OutputSpan<T>) throws(E) -> Void
) throws(E) where T: ConvertibleToBytes & BitwiseCopyable
}
extension OutputSpan where Element: ConvertibleFromBytes {
@_lifetime(copy self)
public mutating func append<E: Error>(
elements n: Int,
initializingWith initializer: (inout OutputRawSpan) throws(E) -> Void
) throws(E)
}
トップレベルの安全な bitCast
ConvertibleToBytes と ConvertibleFromBytes の組み合わせにより、これまで unsafeBitCast でしか書けなかった「同じサイズの型同士の再解釈」が安全に行えるようになります。
public func bitCast<T, U>(_ original: T, to: U.Type) -> U
where T: ConvertibleToBytes, U: ConvertibleFromBytes
メモリレイアウトの安定性について
適合対象となる型のメモリレイアウトは、コンパイラや標準ライブラリのバージョンをまたいで安定であるとは限りません。プロセス間通信や、同じプロセスが後で読み直すための一時保存には十分ですが、ネットワーク越しのプロトコルや永続化フォーマットを直接組み立てる場合には、本 API はあくまで土台として使い、バージョン間の互換性は利用者側で担保することが想定されています。
Future Directions(今後の見通し)
提案では、次のような発展方向が挙げられています。いずれも speculative で、実現を約束するものではありません。
ConvertibleToBytesのコンパイラによる自動検証・自動導出(BitwiseCopyableと同様に layout ベースで完結させられる見込み)。ConvertibleFromBytesの部分的な検証(stored property 側の条件だけでも自動チェックする、など)。- C から取り込んだ型や、タプル・SIMD 型への適合対応。
RawSpanのアラインメントを調べるユーティリティ(Span(viewing:)を安全に使うための補助)。- 名前に「unsafe」が入っていない既存の
@unsafeAPI のリネーム整理。