ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SE-0525] Safe loading API for RawSpan
    Swift 2026. 7. 26. 09:25

    안녕하세요. 그린입니다 🍏
    이번 포스팅에서는 SE-0525 — RawSpan을 위한 안전한 로딩 API에 대해 정리해보겠습니다 🙋🏻

    Intro

    Proposal: SE-0525

    Author: Guillaume Lessard

    Review Manager: Xiaodi Wu

    Status: Implemented (Swift 6.4)

    Related Proposals: SE-0447

    Motivation

    SE-0447에서 RawSpan을 도입하면서 임의 타입의 값을 로드할 수 있는 unsafe 함수들도 함께 들어왔어요 🙌

    네이티브 정수 타입처럼 그중 일부는 로드해도 실제로 안전한데도, unsafe라는 표시 때문에 사용하는 입장에서는 늘 의심이 생기기 마련입니다 🥲

    이번 제안은 byte-loading 연산 중 안전한 것들을 명확히 구분해주는 게 목표예요.

    그리고 안전한 byte-loading을 정의하려면 안전한 byte-storing도 함께 정리해야 해서, 이 제안은 그 부분도 함께 다룹니다.

     

    Proposed Solution

    두 개의 새 프로토콜을 제안합니다.

    초기화된 타입 값과 초기화된 raw 바이트 사이의 변환을 지원하는 프로토콜이에요.

    하나는 항상 안전하게 raw 바이트로 읽을 수 있는 타입이 채택하는 ConvertibleToBytes, 다른 하나는 raw 바이트에서 항상 안전하게 해석될 수 있는 타입이 채택하는 ConvertibleFromBytes입니다.

     

    ConvertibleToBytes

    어떤 타입의 값으로 메모리를 초기화할 때, 그 타입의 stride를 이루는 모든 바이트가 초기화되어야 한다면 ConvertibleToBytes를 채택할 수 있어요.

    @_marker protocol ConvertibleToBytes: Copyable {}

     

    메모리 표현에 패딩이 전혀 없는 타입, 즉 저장 프로퍼티 크기의 합이 stride와 같은 타입이 채택할 수 있습니다.

    예를 들어 Optional<Int16>은 stride 4바이트 중 3바이트만 쓰기 때문에 채택할 수 없지만, struct Pair { var a, b: Int16 }는 크기와 stride가 같아서 채택 가능해요.

     

    ConvertibleToBytes를 채택하려면 다음 조건을 만족해야 합니다.
    • 하나 이상의 저장 프로퍼티를 가질 것
    • 모든 저장 프로퍼티가 ConvertibleToBytes를 채택한 타입일 것
    • 저장 프로퍼티들이 패딩 없이 메모리에 연속적으로 저장될 것
    • 어떤 값도 자신의 바이트 일부를 무시하지 않을 것 (대부분의 enum이 여기서 제외됩니다)

    표준 라이브러리의 많은 기본 타입은 이 프로토콜을 채택하지만, 표준 라이브러리 바깥의 타입은 처음에는 채택할 수 없어요.

    ConvertibleToBytes 채택 선언은 그 타입을 담고 있는 모듈에서만 할 수 있습니다.

     

    ConvertibleFromBytes

    @_marker protocol ConvertibleFromBytes: BitwiseCopyable {}

     

    저장 프로퍼티를 이루는 모든 바이트의 모든 비트 패턴이 유효한 타입이라면 ConvertibleFromBytes를 채택할 수 있어요.

    내부나 trailing 패딩이 있는 타입도 허용되지만, 저장 프로퍼티 값에 의미론적인 제약이 있으면 안 됩니다.

     

    예를 들어 struct Point { var x, y: Int }는 x, y 사이에 아무 제약이 없어서 채택 가능하지만, Range<Int>lowerBound <= upperBound라는 의미론적 제약이 있어서 채택할 수 없어요.

    UnicodeScalar(일부 비트 패턴이 무효), 가상의 UTF8 SmallString(바이트 순서가 유효성에 영향), UnsafeRawPointer(런타임 전까지 값의 유효성을 알 수 없음) 등도 채택할 수 없는 예시예요.

     

    컴파일러가 ConvertibleFromBytes의 의미론적 요구사항을 강제할 수 없기 때문에, 표준 라이브러리 바깥의 타입은 unchecked conformance로만 채택할 수 있습니다.

    extension MyType: @unchecked ConvertibleFromBytes {}

     

    FullyInhabited

    typealias FullyInhabited = ConvertibleToBytes & ConvertibleFromBytes

     

    ConvertibleToBytesConvertibleFromBytes의 교집합이에요.

     

    RawSpan과 MutableRawSpan

    RawSpanMutableRawSpan에 제네릭한 load(as:) 함수가 새로 생겨요.

    포인터 정렬 제약 없이 ConvertibleFromBytes 값을 읽어올 수 있고, bounds-checked라서 이 함수는 안전합니다.

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

     

    추가로, ConvertibleFromBytesFixedWidthInteger를 동시에 채택한 타입을 위해 byte order를 지정할 수 있는 버전도 있어요.

     

    extension RawSpan {
      func load(
        fromByteOffset: Int,
        as: T.Type = T.self,
        _ byteOrder: ByteOrder
      ) -> T
    }
    
    @frozen
    enum ByteOrder: Equatable, Hashable, Sendable {
      case bigEndian, littleEndian
      
      static var native: Self { get }
    }

     

    ConvertibleFromBytes & FixedWidthInteger를 채택하는 표준 라이브러리 타입 목록은 UInt8, Int8, UInt16, Int16, UInt32, Int32, UInt64, Int64, UInt, Int, UInt128, Int128이에요.

     

    load(as:) 함수들은 atomic 연산이 아니고, unchecked byte offset 버전은 제공되지 않아요 (필요하면 기존 unsafeLoad(fromUncheckedByteOffset:as:)를 쓰면 됩니다).

     

    RawSpan 계열의 subscript

    UInt8을 위한 편의 기능으로, 기존 Span, MutableSpan, OutputSpan과 비슷한 subscript를 RawSpan, MutableRawSpan, OutputRawSpan에도 정의합니다.

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

     

    MutableRawSpan과 OutputRawSpan

    MutableRawSpan에는 storeBytes()의 새 오버로드가 추가돼요.

    extension MutableRawSpan {
      mutating func storeBytes(
        of value: T,
        toByteOffset offset: Int,
        as type: T.Type,
        _ byteOrder: ByteOrder
      ) where T: ConvertibleToBytes & BitwiseCopyable & FixedWidthInteger
      
      @unsafe
      mutating func storeBytes(
        repeating repeatedValue: T,
        count: Int,
        as type: T.Type
      ) where T: BitwiseCopyable
    
      mutating func storeBytes(
        repeating repeatedValue: T,
        count: Int,
        as type: T.Type,
        _ byteOrder: ByteOrder
      ) where T: ConvertibleToBytes & BitwiseCopyable & FixedWidthInteger
    }

     

    기존 T: BitwiseCopyable로 제약된 storeBytes 함수는 @unsafe로 표시됩니다.

    컴파일러 최적화로 인해 초기화되지 않은 바이트가 생길 수 있기 때문이에요.

    반복 저장 variant도 이번에 추가되는데, 이것도 @unsafe로 표시됩니다.

     

    OutputRawSpan에는 대응하는 append() 함수가 생겨요.

    extension OutputRawSpan {
      mutating func append(
        _ value: T,
        as type: T.Type
      ) where T: ConvertibleToBytes & BitwiseCopyable
    
      mutating func append(
        _ value: T,
        as type: T.Type,
        _ byteOrder: ByteOrder
      ) where T: ConvertibleToBytes & BitwiseCopyable & FixedWidthInteger
      
      mutating func append(
        repeating repeatedValue: T,
        count: Int,
        as type: T.Type
      ) where T: ConvertibleToBytes & BitwiseCopyable
      
      mutating func append(
        repeating repeatedValue: T,
        count: Int,
        as type: T.Type,
        _ byteOrder: ByteOrder
      ) where T: ConvertibleToBytes & BitwiseCopyable & FixedWidthInteger
    }

     

    기존 T: BitwiseCopyable만으로 제약된 append 함수도 @unsafe로 표시됩니다.

     

    여기서 제안하는 타입들의 메모리 레이아웃은 컴파일러/라이브러리 버전에 걸쳐 안정적이라고 보장되진 않아요.

    행 중인 프로세스끼리 데이터를 주고받거나 같은 프로세스에서 나중에 쓰려고 저장하는 용도라면 문제없지만, 네트워크 통신이나 파일 시스템 저장을 위한 직렬화처럼 더 정교한 용도에는 이 API가 building block 정도로만 쓰일 수 있어요.

     

    Span과 MutableSpan

    Span에는 init(viewing: RawSpan) 이니셜라이저가 새로 생겨서, Span.ElementConvertibleFromBytes를 채택했다면 정렬되지 않은 raw 메모리 범위를 typed Span으로 볼 수 있어요. 정렬과 bounds를 검사하는데, RawSpan의 포인터 정렬이 Element에 맞지 않거나 bounds가 stride의 배수가 아니면 트랩이 발생합니다.

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

     

    MutableSpan에도 MutableRawSpan의 메모리를 typed MutableSpan처럼 mutate할 수 있는 이니셜라이저가 추가돼요.

    마찬가지로 정렬과 bounds를 검사해서 맞지 않으면 트랩합니다.

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

     

    RawSpan에서 Span으로의 변환은 정렬이 잘 맞고 네이티브 byte order인 경우만 지원해요.

    더 복잡한 파싱이 필요하다면 swift-binary-parsing 패키지의 ParserSpan 타입을 참고하면 됩니다. RawSpan의 메모리 정렬을 확인하는 기능은 추후 별도 제안으로 다뤄질 예정이에요.

     

    기존 bytes, mutableBytes accessor도 Element가 각각 ConvertibleToBytes, ConvertibleToBytes & ConvertibleFromBytes를 채택했을 때 안전한 오버로드를 갖게 됩니다.

     

    Detailed Design

    표준 라이브러리 채택 목록

    여러 정수/부동소수점 타입, Duration, InlineArray, CollectionOfOne, 여러 SIMD 타입, 범위 타입 일부, Bool, ObjectIdentifier, 각종 포인터 타입 등에 다음과 같이 채택이 추가됩니다.

    extension UInt8:   ConvertibleToBytes, ConvertibleFromBytes {}
    extension Int8:    ConvertibleToBytes, ConvertibleFromBytes {}
    extension UInt16:  ConvertibleToBytes, ConvertibleFromBytes {}
    extension Int16:   ConvertibleToBytes, ConvertibleFromBytes {}
    extension UInt32:  ConvertibleToBytes, ConvertibleFromBytes {}
    extension Int32:   ConvertibleToBytes, ConvertibleFromBytes {}
    extension UInt64:  ConvertibleToBytes, ConvertibleFromBytes {}
    extension Int64:   ConvertibleToBytes, ConvertibleFromBytes {}
    extension UInt:    ConvertibleToBytes, ConvertibleFromBytes {}
    extension Int:     ConvertibleToBytes, ConvertibleFromBytes {}
    
    extension UInt128: ConvertibleToBytes, ConvertibleFromBytes {}
    extension Int128:  ConvertibleToBytes, ConvertibleFromBytes {}
    
    extension Float16: ConvertibleToBytes, ConvertibleFromBytes {}
    extension Float32: ConvertibleToBytes, ConvertibleFromBytes {} // 'Float'
    extension Float64: ConvertibleToBytes, ConvertibleFromBytes {} // 'Double'
    
    extension Duration: ConvertibleToBytes, ConvertibleFromBytes {}
    
    extension InlineArray: ConvertibleToBytes
      where Element: ConvertibleToBytes {}
    extension InlineArray: ConvertibleFromBytes
      where Element: ConvertibleFromBytes {}
    
    extension CollectionOfOne: ConvertibleToBytes
      where Element: ConvertibleToBytes {}
    extension CollectionOfOne: ConvertibleFromBytes
      where Element: ConvertibleFromBytes {}
    
    extension SIMD2: ConvertibleToBytes
      where Scalar: ConvertibleToBytes {}
    extension SIMD4: ConvertibleToBytes
      where Scalar: ConvertibleToBytes {}
    extension SIMD8: ConvertibleToBytes
      where Scalar: ConvertibleToBytes {}
    extension SIMD16: ConvertibleToBytes
      where Scalar: ConvertibleToBytes {}
    extension SIMD32: ConvertibleToBytes
      where Scalar: ConvertibleToBytes {}
    extension SIMD64: ConvertibleToBytes
      where Scalar: ConvertibleToBytes {}
    
    extension SIMD2: ConvertibleFromBytes
      where Scalar: ConvertibleFromBytes {}
    extension SIMD3: ConvertibleFromBytes
      where Scalar: ConvertibleFromBytes {}
    extension SIMD4: ConvertibleFromBytes
      where Scalar: ConvertibleFromBytes {}
    extension SIMD8: ConvertibleFromBytes
      where Scalar: ConvertibleFromBytes {}
    extension SIMD16: ConvertibleFromBytes
      where Scalar: ConvertibleFromBytes {}
    extension SIMD32: ConvertibleFromBytes
      where Scalar: ConvertibleFromBytes {}
    extension SIMD64: ConvertibleFromBytes
      where Scalar: ConvertibleFromBytes {}
    
    extension ClosedRange: ConvertibleToBytes
      where Bound: ConvertibleToBytes {}
    extension Range: ConvertibleToBytes
      where Bound: ConvertibleToBytes {}
    
    extension PartialRangeFrom: ConvertibleToBytes
      where Bound: ConvertibleToBytes {}
    extension PartialRangeFrom.Iterator: ConvertibleToBytes
      where Bound: ConvertibleToBytes {}
    extension PartialRangeThrough: ConvertibleToBytes
      where Bound: ConvertibleToBytes {}
    extension PartialRangeUpTo: ConvertibleToBytes
      where Bound: ConvertibleToBytes {}
    
    extension Bool: ConvertibleToBytes {}
    extension ObjectIdentifier: ConvertibleToBytes {}
    
    extension UnsafePointer: ConvertibleToBytes {}
    extension UnsafeMutablePointer: ConvertibleToBytes {}
    extension UnsafeRawPointer: ConvertibleToBytes {}
    extension UnsafeMutableRawPointer: ConvertibleToBytes {}
    extension OpaquePointer: ConvertibleToBytes {}
    
    extension UnsafeBufferPointer: ConvertibleToBytes {}
    extension UnsafeMutableBufferPointer: ConvertibleToBytes {}
    extension UnsafeRawBufferPointer: ConvertibleToBytes {}
    extension UnsafeMutableRawBufferPointer: ConvertibleToBytes {}

    위 목록 중 BitwiseCopyable 채택이 없던 타입은 이번에 함께 채택하게 됩니다.

    그리고 SIMD3SIMD4와 저장 공간이 같지만 한 원소만큼 tail padding이 있어서 ConvertibleToBytes를 채택하지 못해요.

     

    안전한 최상위 bitCast 함수

    두 프로토콜 덕분에 타입을 안전하게 재해석하는 함수도 정의할 수 있게 됐어요.

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

     


    Source Compatibility

    이번 제안은 오직 추가로만 이루어져 있어서 소스 호환성을 유지합니다.

    다만 오버로드를 추가하는 건 언제나 리스크가 있어서, 기존의 유효한 코드에 영향을 줄 가능성도 있어요.

    중대한 호환성 이슈가 없는지 확인하려면 테스트가 필요합니다.

     


    ABI Compatibility

    이번에 추가되는 함수들은 추가 ABI를 만들지 않는 방식으로 구현될 예정이에요.

    이 함수들은 Span의 존재를 전제로 하기 때문에, Darwin 플랫폼에서는 Swift 표준 라이브러리가 OS와 함께 배포되는 만큼 최소 배포 타깃이 있습니다.

    ByteOrder는 새 타입이라 자체 availability를 가지며, ByteOrder 인자를 쓰는 함수들도 같은 availability를 공유합니다.

     


    Implications on Adoption

    이번 제안의 추가 사항들은 새 버전의 Swift 표준 라이브러리를 필요로 합니다.

     

    Future Directions

    ConvertibleToBytes 프로토콜에 대한 검증

    ConvertibleToBytes 채택은 나중에 컴파일러가 추가로 검증하게 될 거예요.

    이 프로토콜은 addressable 메모리 상의 레이아웃에만 의존하기 때문에 컴파일 타임에 완전히 검증할 수 있고, BitwiseCopyable처럼 자동화도 가능할 것으로 보입니다.

    검증과 함께, 패딩 대신 저장된 null 바이트를 자동으로 넣어주는 방식도 고려할 수 있어요.

     

    ConvertibleFromBytes 프로토콜에 대한 부분적 검증

    ConvertibleFromBytes 채택도 나중에 어느 정도 검증될 수 있어요.

    파일러는 모든 저장 프로퍼티가 ConvertibleFromBytes를 채택했는지는 강제할 수 있지만, 의미론적 제약의 부재까지 직접 강제할 순 없습니다.

    다만 모든 저장 프로퍼티가 public이고 var인 경우처럼 우회적인 방법을 받아들일 수도 있어요.

     

    C에서 import된 타입 지원

    Clang importer가 어떤 기본 C 타입이 이 프로토콜들을 지원하는지 알도록 만들 수도 있어요.

    aggregate C 타입에 대해서도 이 프로토콜 채택을 선언할 방법이 있으면 좋겠는데, 이를 위해 "채택 선언은 타입을 담은 모듈에서만 가능하다"는 제약을 import된 C 타입에 한해 완화하는 것도 유용할 것 같습니다.

     

    튜플 지원

    ConvertibleToBytes 타입으로 구성된 튜플은 그 자체로 ConvertibleToBytes여야 하고, ConvertibleFromBytes도 마찬가지예요.

     

    RawSpan의 정렬 상태를 확인하는 유틸리티

    Span 이니셜라이저들은 올바르게 정렬된 RawSpan을 요구하는데, 특정 타입에 대해 잘 정렬된 오프셋을 확인하는 유틸리티가 있으면 좋을 것 같다고 제안하고 있어요.

     

    @unsafe 함수/프로퍼티 이름 재정비

    이전 제안에서 도입된 일부 함수/프로퍼티는 나중에 unsafe로 표시됐지만, 이름 자체에는 그 사실이 드러나지 않아요.

    strict memory safety 모드가 꺼져 있어도 unsafe함을 드러내는 이름들을 식별해서, 별도 제안을 통해 최소한의 혼란으로 이름을 바꾸는 계획이 필요합니다.

     

    OutputSpan과 OutputRawSpan 사이의 append 지원

    원래 이 제안에는 ElementConvertibleFromBytes일 때 OutputRawSpan으로 OutputSpan의 일부에 append하는 함수도 포함되어 있었어요.

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

     

    capacity를 위한 인자 레이블(여기선 "upTo")을 정하는 게 까다로운 주제라, UniqueArrayinsert, replace, append에도 같은 형태의 API가 필요하다는 점을 짚었어요.

     

    이 네이밍은 SE-0527(UniqueArray와 RigidArray) 쪽에서 충분히 논의된 뒤, OutputSpanOutputRawSpan에 적절한 variant와 함께 추가하자는 방향으로 정리됐습니다.

     

    Alternatives Considered

    함수 이름에 로드할 타입 이름을 인코딩하기

    loadInt32(fromByteOffset:_:), storeBytes(int32:toByteOffset:_:)처럼 구체적인 함수들을 따로 만들면 오버로드 문제를 피할 수 있어서 타입 체커 입장에서는 더 편할 수 있어요.

     

    컴파일러가 검증하는 ConvertibleToBytes 레이아웃 제약을 기다리기

    이 제안이 다루는 기능은 시급하고, 표준 라이브러리 추가만으로도 달성할 수 있어요.

    ConvertibleToBytes 레이아웃 제약의 검증에는 상당한 컴파일러 작업이 필요한데, 이 문서가 제안하는 API만으로도 충분히 가치가 있다고 판단했습니다.

     

    FixedWidthInteger & BitwiseCopyable, BinaryFloatingPoint & BitwiseCopyable로 제네릭화하기

    이 프로토콜들은 채택자가 완전히 inhabited되어야 한다는 걸 보장하지 않아서 충분한 제약이 아니에요.

     

    FullyInhabited 프로토콜 하나만 추가하기

    이 제안의 두 번째 pitch에서는 FullyInhabited 하나만 제안했었어요.

    논의 결과 결국 두 프로토콜 쌍이 필요하다는 게 드러났고, 구현 부담도 비슷했기 때문에 두 프로토콜을 각각 구현하는 쪽이 더 나은 선택으로 정리됐습니다.

     

    ByteOrder 파라미터 생략하기

    표준 라이브러리의 FixedWidthInteger에는 .bigEndian, .littleEndian 계산 프로퍼티가 있어서 이를 활용할 수도 있었어요.

    하지만 이 프로퍼티들은 Self를 반환해서 byte ordering과 유효한 값 자체를 혼동시키기 때문에 명확하지 않아요.

    이 제안은 byte ordering을 직렬화라는, 원래 있어야 할 자리에 적용합니다.

     

    안전한 load() 함수를 정렬된 연산으로 기본 설정하기

    UnsafeRawPointer의 원래 load()는 올바른 정렬을 요구했고, 나중에야 덜 제한적인 loadUnaligned()가 추가됐어요.

    이건 오랫동안 아쉬운 부분으로 여겨져 왔고, 이번 제안은 새 안전한 load() 함수들이 정렬되지 않은 연산을 수행하도록 만들어서 이를 개선하려 하고 있습니다.

     

    toByteOffset, fromByteOffset 파라미터를 0으로 기본값 설정하기

    원래는 이 파라미터들에 기본값을 두려고 했는데, 그러면 RawSpan이나 MutableRawSpan의 전체 길이를 반드시 써야 한다는 인상을 줄 수 있다는 지적이 있었어요.

    비슷한 기존 unsafe API에는 이미 기본값이 있다는 점을 감안해서, 이 결정은 나중에 조율이 필요할 수 있습니다.

     

    Conclusion

    ConvertibleToBytesConvertibleFromBytes 프로토콜 덕분에 그동안 unsafe 딱지가 붙어 있던 RawSpan byte-loading이 명확하게 안전한 영역과 그렇지 않은 영역으로 나뉘게 됐어요.

    정수, 부동소수점, SIMD, 포인터 타입 등 표준 라이브러리의 많은 타입이 바로 이 프로토콜들을 채택하고, 안전한 bitCast까지 함께 따라오면서 프로세스 간 데이터 전달이나 파싱 유틸리티를 만들 때 훨씬 믿음직한 building block이 생긴 셈입니다 🙌

     

    References

     

    swift-evolution/proposals/0525-rawspan-safe-loading-api.md at main · swiftlang/swift-evolution

    This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution

    github.com

Designed by Tistory.