포인터 관련 메모

타입화된 포인터

Swift는 C API와의 상호작용 혹은 고성능 자료구조의 구현등을 위해 포인터를 통한 메모리 액세스를 제한적으로 지원하고 있습니다. 기본적으로 Swift 타입 혹은 Swift 에서 인식할 수 있는 C 타입에 대한 포인터는 ‘타입화된(typed)’ 포인터라고 하며, UnsafePointer를 사용합니다. UnsafePointer는 제네릭 struct 타입으로 특정 Swift 타입에 대한 포인터로 기능합니다. UnsafePointer는 메모리 주소를 통한 액세스를 허용해주기는 하지만 값의 불변성을 보장하기 때문에 해당 포인터가 가리키는 값(pointee)를 변경하는 것을 허용하지 않습니다. 변수에 대한 포인터는 UnsafeMutablePointer를 별도로 사용합니다.

상수 포인터 사용법

일단 상수포인터는 ‘만들 수 없음’을 기본으로 합니다. 실질적으로 UnsafePointer는 다른 포인터를 받는 이니셜라이저가 있긴 합니다만, 공식 문서상에서는 노출하고 있지 않습니다. 왜냐하면 Swift에서 포인터는 기본적으로 안정성을 위해서 한정적인 라이프 사이클을 가져야 하기 때문이고, inout 표현을 통해서 메소드나 함수로 전달되는 값은 실제 주소가 아니라 브릿징된 포인터이기 때문입니다.

또 Swift는 모든 객체에 대한 메모리 관리를 ARC에 의존하고 있습니다. 이것은 객체가 참조를 얻거나 잃고 해제되는 시점에 대한 정보를 프로그래머가 코드를 작성하는 중에 정확하게 알기 어렵다는 문제가 있습니다. 어떤 객체에 대한 포인터가 해당 객체의 라이프사이클보다 오래 유지된다면, 객체가 해제된 이후에 포인터를 통한 메모리 액세스를 막을 방법이 없고 이 경우 그 결과는 정의되지 않습니다.

따라서 우리가 코드를 작성하는 경우 다루는 모든 타입화된 불변 포인터는 명시적으로 할당하거나 생성하지 않고, API로부터 반환받은 것이거나 함수 및 메소드의 인자로 전달받은 것으로 간주하면 됩니다.

포인터의 액세스

  • pointee : 포인터가 가리키는 주소에서 T 타입 인스턴스를 읽어서 리턴합니다. 상수 포인터에서 이 프로퍼티는 get 만 존재합니다.
  • subscript(Int) : ptr[i] 와 같은 표현으로 i번째 오프셋에 위치한 T 타입 인스턴스를 읽어서 리턴합니다. 역시 상수 포인터에서는 get만 가능합니다.

암묵적 캐스팅과 브릿징

앞서 언급된 암묵적 캐스팅과 브릿징(implicit casting and bridged pointer)에 대해서 살펴보겠습니다. C API를 사용하는 방법과 관련하여 애플 공식문서는 UnsafePointer를 인자로 요구하는 함수에 대해서 다음과 같은 방법을 추천합니다.

  1. T타입 포인터에 대해서 호환포인터를 전달합니다.
  2. T타입 변수의 inout 표현을 전달합니다.
  3. T타입 배열이 있는 경우, 배열의 이름을 전달합니다. (상수포인터)
  4. 가변포인터로 전달하는 경우, 배열 이름을 inout 형식으로 전달합니다.

https://developer.apple.com/documentation/swift/unsafepointer

예를 들어 다음의 printInt(atAddress:) 함수는 인자로 UnsafePointer<Int> 타입을 받습니다. 보통 이를 호출하기 위해서는 Int 값을 가리키는 포인터를 전달합니다.

func printInt(atAddress p: UnsafePointer<Int>) {
  print(p.pointee)
}

...

printInt(atAddress: intPointer)

변경가능한 포인터 타입은 Pointee 타입이 같은 불변 포인터로 암묵적 캐스팅이 가능하기 때문에, UnsafeMutablePointer를 대신 넘겨줄 수도 있습니다. (물론 이 역의 경우는 동작할 수 없습니다.)

let mutableIntPointer = UnsafeMutablePointer(mutating: intPointer)
printInt(atAddress: mutableIntPointer)
// OK

암묵적 브릿징은 inout 표현이나 배열을 넘기는 것을 말합니다. 아래 예시는 inout 문법을 사용하여 브릿지된 포인터를 넘깁니다.

var value: Int = 23
printInt(atAddress: &value)
// "23"

이러한 함수에 배열을 전달하면 암묵적으로 임시 불변 포인터가 생성됩니다. (C랑 비슷하죠?) 아래 예는 배열을 넘겨서 암묵적 브릿징을 사용하는 예입니다.

let numbers = [5, 10, 15, 20]
printInt(atAddress: numbers)
// "5"

이 경우에는 &numbers로 넘겨도 호환됩니다. printInt(atAddress:)가 불변 포인터를 받기 때문에 이 문법은 유효하지만 굳이 필요하지는 않습니다. 배열의 경우 inout 표현으로 넘겨주는 것은 가변포인터(UnsafeMutablePointer)를 받는 경우입니다.

포인터를 인자로 넘기는 이러한 방법 중 어떤 것을 사용하더라도 Swift의 타입 안정성은 함수가 필요로하는 포인터를 넘겨주는 것을 보장해줍니다.

다만, 배열 이름의 형태로 전달되거나 inout 표현을 사용하는 경우에 생성한 브릿지된 포인터는 포인터를 받게 된 함수의 실행 시간동안만 유효합니다. 따라서 함수의 바깥으로 이 포인터를 이스케이프해서는 안됩니다. 아래 코드는 흔히 할 수 있는 초보적인 실수이며, intPointer를 이후 액세스하는 것은 문제가 될 수 있습니다.

UnsafePointer는 기본적으로 이니셜라이저를 갖고 있지만, 이러한 이유로 공식문서에서는 이니셜라이저가 언급되지 않습니다.

var a: Int = 32
let p = UnsafePointer(&a) 
// 이 코드 이후에 p를 액세스하는 것은 안전하지 않음

가변 포인터

가변 포인터는 가리키는 대상의 값을 변경할 수 있는 포인터로 UnsafeMutablePointer 로 타입화된 가변포인터를 표현할 수 있습니다.

기존에 구해둔 포인터가 있으면 이를 이니셜라이저에서 받아서 바로 가변 포인터로 만들 수 있습니다. 가변포인터로 새로운 가변 포인터를 만들 수 있음은 물론이고, 불변 포인터로도 동일한 동작을 할 수 있습니다. (init(mutating:)) 그런데 불변 포인터를 통해서 가변 포인터를 만들 때에는 포인터가 가리키고 있는 객체값은 변경가능해야 한다는 기본적인 사실은 변하지 않습니다. 변경 가능하지 않은 객체에 대해서 변경 가능 포인터를 생성해서는 안됩니다.

새 메모리 할당

가변 포인터는 특정 주소의 메모리 내용을 변경할 수 있습니다. 이는 ‘정의되지 않은 상태’의 메모리를 원하는 값으로 써서 초기화할 수 있다는 의미이기도 합니다. 따라서 불변 포인터와는 달리, 가변 포인터를 사용하면 새로운 메모리를 할당하는 것이 가능합니다.

새로운 메모리를 할당받으려 할 때 타입 메소드인 allocate(capacity:)를 사용합니다. 이 메소드가 반환하는 포인터가 가리키는 영역은 초기화되지 않았으므로 Pointee와 범위가 같은 타입의 포인터 혹은 특정 값으로 초기화할 수 있습니다. 이 경우 pointee 속성을 사용해서 값을 쓰는 것이 아니라 별도의 메소드를 사용해야 합니다.

포인터에서 초기화는 단순히 초기값을 채워넣는 것 이상의 의미를 갖습니다. 초기값을 채운 후 해당 메모리의 상태를 initialized로 전환합니다. 즉 사용가능한 상태가 됩니다. 초기화에 사용되는 메소드들은 다음과 같습니다.

  • initialize(from: count:) – 특정 포인터로부터 지정한 개수만큼 Pointee 값을 가져와서 메모리를 초기화합니다.
  • initialize(repeating: count:) – 주어진 T 타입 값을 count 만큼 반복하여 메모리를 초기화합니다.
  • initialize(to:) – 단일 포인터를 주고 그 값을 사용하여 메모리를 초기화합니다. 할당 받은 영역이 더 크더라도 한 곳만 초기화됩니다.

메모리할당과 관련해서는 메모리 상태에 대해 알아둘 필요가 있습니다. Objective-C와 달리 Swift는 할당받은 메모리를 0으로 초기화하지 않습니다. 따라서 해당 메모리 영역에는 쓰레기값이 들어가 있을 수 있습니다. 안정성을 위해 Swift는 새로이 할당한 영역에 대해서는 초기화여부를 검사하고, 초기화되지 않은 포인터를 액세스하는 것을 막습니다. 이렇게 할당만 되고 초기화되지 않은 공간을 uninitialized memory라고 합니다.

초기화를 시행한 메모리 주소는 정상적인 Pointee 타입 값이 있는 것이 보장되고 이 때부터 사용이 가능합니다. 이 상태를 Initailized memory라고 합니다. 물론 사용중이던 메모리를 deinitialize(count:) 를 사용해서 사용할 수 없는 영역으로 표시하는 것도 가능합니다. (deinitialize 라고 하니 ‘역초기화’라는 이상한 번역이 되는데, 초기화 되지 않은 상태가 곧 사용할 수 없는 상태이니 이렇게 이해하는 편이 좋겠습니다.)

다음 예는 정수 포인터로 액세스가능한 메모리 영역(64바이트)을 할당하고 이를 정수 배열의 값을 사용해서 초기화합니다. 그리고 할당된 시작 주소와 그 다음 영역에 초기화된 값을 출력합니다.

let numbers = Array(1...100)
let ptr = UnsafeMutablePointer<Int>.allocate(capacity:8)

ptr.initialize(from: numbers, count: 8)
// count는 반드시 capacity보다 작거나 같아야 합니다.
print(ptr.pointee) // '1'
print((ptr + 1).pointee) // '2'

ptr.deallocate()

포인터는 자신이 참조하고 있는 메모리 영역을 직접 관리해주지 않습니다. 따라서 수동으로 할당한 영역이 있다면 반드시 직접 해제해야 합니다. 할당했던 메모리를 해제하려면 dealloccate() 메소드를 호출합니다. 이 때 해당 포인터는 반드시 allocate된 시작 번지를 가리켜야 합니다. 그외에 해당 메모리가 초기화되지 않았거나(사용할 수 없다고 표현하거나), Pointee가 trivial 타입이어야 한다는 조건이 있습니다.

Geneally, native Swift type that do not contain strong or weak references or other forms of indirection are trivial, as are import C structs and enums.

https://developer.apple.com/documentation/swift/unsafemutablerawbufferpointer

trivial 타입에 대해서는 공식 문서의 다음 설명을 참고하도록 합니다.

trivial type can be copied bit for bit with no indirection or reference-counting operations. Generally, native Swift types that do not contain strong or weak references or other forms of indirection are trivial, as are imported C structs and enums. Copying memory that contains values of nontrivial types can only be done safely with a typed pointer. Copying bytes directly from nontrivial, in-memory values does not produce valid copies and can only be done by calling a C API, such as memmove().

타입을 달리하여 포인터를 액세스하기

포인터 인스턴스를 액세스할 때 Pointee타입은 바운드된 메모리에 대해 변하지 않아야 합니다. 만약 다른 타입에 바운드된 메모리처럼 사용하고 싶다면 Swift가 제공하는 타입안전한 방법을 사용하여 일시적 혹은 영구적으로 메모리의 바운드 타입을 변경하거나 raw 메모리로부터 직접 타입화된 값을 액세스할 수 있습니다.

다음 예는 가변포인터를 사용해서 새롭게 메모리를 할당하고 기존 배열을 사용하여 초기화하는 예입니다. 기존 배열을 사용해서 초기화하고는 있지만, 실질적으로 배열을 브릿징한 포인터를 사용하는 것이므로 배열의 사본을 만드는 것이 아닙니다.

var bytes: [UInt8] = [39, 77, 111, 111, 102, 33, 39, 0]
let uint8Pointer = UnsafeMutablePointer<UInt8>.allocate(capaticity: 8)
uint8Pointer.initialize(from: &bytes, count: 8)

특정한 API를 호출하기 위해서 등의 목적으로일시적으로 다른 타입의 포인터처럼 액세스하려면 withMemoryRebound(to:capacity:) 메소드를 사용할 수 있다. 예를 들어 strlen() 함수는 Int8 포인터 타입을 인자로 받습니다. 그런데 우리는 UInt8 포인터를 가지고 있고요. 이 때 UInt8은 Int8과 호환될 수 있는 타입이기 때문에(일단 타입의 사이즈가 동일합니다.) 일시적으로 리바운드한 포인터를 만들어서 함수에 전달할 수 있습니다.

let length = uint8Pointer.withRemoundMemoery(to: Int8.self, capacity: 8) {
  return strlen($0)
}

만약 영구적으로 새로운 타입으로 변환된 포인터를 사용하고자 한다면 먼저 raw 포인터를 만듭니다. 그리고 이 raw 포인터를 새로운 타입으로 바운드합니다.

let uint64Pointer = UnsafeMutableRawPointer(uint8Pointer)
            .bindMemory(to: UInt64.self, capacity: 1)

하지만 일단 이런식으로 포인터의 대상 타입을 간접적으로 변경했다면, 이후에는 이전 포인터로 액세스하는 것은 적절하지 않습니다. 또는 raw 포인터의 load(as:) 를 사용해서 같은 포인터를 동시에 다른 타입의 값으로 액세스하는 것도 가능합니다. 아 물론 쓰기 시에는 storeBytes(of: toByteOffest: as:) 를 사용할 수 있습니다.

Raw 포인터의 사용방법에 대해서는 다음번에 별도의 포스트에서 설명하도록 하겠습니다.