Swift 속의 C Pointer 이야기 - UnsafeRawPointer, UnsafeMutableRawPointer

이번 이야기는 Raw Pointer 대충 번역하면 생포인터에 대한 이야기다. 쉽게 표현하자면 이 생포인터는 타입이 지정되지 않은(Untyped) 포인터이다. 포인터 시작편에서 언급했지만 이 생포인터는 타입이 명시되지 않았다는 점 때문에 Swift 에서 배척(?)받을 지도 모르는 존재일지도 모르겠다. (뇌내망상)

이 생짜포인터를 액세스 하기 위해 Swift 에서는 UnsafeRawPointer 와 UnsafeMutableRawPointer 등의 컨테이너를 제공한다.

참고로 이 글은 UnsafeMutablePointer 의 기본 API를 안다고 가정한다. 이 컨테이너는 Swift에서 포인터의 기본에 해당하고 그래서 주소를 다루는 것은 비슷하기 때문이다.

언급하기에 앞서 C의 유명한 함수인 memcpy()를 잠깐 살펴볼까 한다. memcpy 함수의 원형은 아래와 같은 형식이다. (참고로 내 맥에서 man 커맨드로 확인한 내용이다)
void *memcpy(void *restrict dst, const void *restrict src, size_t n);
뭔가 복잡해 보이는데 간단하게 정리하면 아래와 같다.
void *memcpy(void *dst, const void *src, size_t n);
void *’ 는 무형의 포인터라고 번역이 가능하다. 타입이 지정되지 않은 순수한 메모리 어드레스를 가리키는 포인터다.

이 memcpy() 함수는 Swift 에서는 아래와 같은 타입으로 보인다.
memcpy(__dst: UnsafeMutableRawPointer!, __src: UnsafeRawPointer, __n: Int)
  -> UnsafeMutableRawPointer!
여기서 UnsafeMutableRawPointer 혹은 UnsafeRawPointer 라는 이름들이 보일 것이다. 이게 바로 생(Raw)포인터다. 아주 신선한 생짜… -_-

타입이 없는 포인터

타입이 없다는 것은 포인터 연산을 할 때 곤란함을 유발시킨다. 예를 들어, successor() 와 같은 다음 주소를 돌려주는 메소드가 과연 어떤 값을 돌려주어야 하는지 알 수가 없다.
C의 예를 들자면, 정수형 포인터(int *)의 주소에 1을 더한 포인터 주소는 sizeof(int) 만큼 증가한 값이다. 즉 타입의 크기 단위로 포인터가 이동한다. 이 타입을 모른다면 어떤 단위로 포인터가 이동해야 하는가? C 에서 void * 타입은 시스템과 OS마다 다르지만 제법 큰 단위로 주소가 이동된다.
이를 위해서 생포인터는 Memory Align 이라는 정보를 활용한다. 이 Align 정보는 메모리가 어떤 간격으로 배치되어 있는지를 알려준다.

간단한 예제를 하나 보자. 앞서 본 memcpy() 를 직접 호출해 보는 예제이다.
let bytes = MemoryLayout<Int>.size  // 8
let pointerA = UnsafeMutableRawPointer.allocate(bytes: bytes, alignedTo: 1)
let pointerB = UnsafeMutableRawPointer.allocate(bytes: bytes, alignedTo: 1)

defer {
  pointerA.deallocate(bytes: bytes, alignedTo: 1)
  pointerB.deallocate(bytes: bytes, alignedTo: 1)
}

for i in 0..<bytes {
  // pointerA 가 가리키는 메모리의 i 번째 바이트에 i 값을 쓴다.
  pointerA.advanced(by: i).storeBytes(of: Int8(i), as: Int8.self)
}

let _ = memcpy(pointerB, pointerA, bytes)

for i in 0..<bytes {
  // 포인터 B 가 가리키는 메모리의 i 번째 바이트를 읽어온다.
  let value = pointerB.advanced(by: i).load(as: Int8.self)
  print("\(value)")
}
첫 줄의 MemoryLayout 은 Swift 3 부터 sizeof 를 대체하기 위해 투입된 메모리 레이아웃을 다루는 구조체다. 여기서는 Int 타입의 바이트 사이즈를 알기 위해 사용한다.

pointerA 와 pointerB 는 생짜 포인터로 생성된다. 여기서 alignedTo 값을 1로 지정해 줬는데 이는 메모리가 1바이트 단위로 구성되어 있다는 의미이다. 즉, allocate 코드에서 할당은 Int 의 크기(8 바이트)로 하는데 메모리 연산은 1바이트 단위라는 말이다.

이후 포인터가 가리키는 메모리를 액세스 할 때는 기존 포인터와는 다르게 storeBytes 혹은 load 라는 특수한 메소드를 사용한다. 이 메소드의 특징은 반드시 타입을 기재하게 되어 있다는 점이다. 생포인터에는 포인터 자체에 타입이 없기 때문에 어떤 크기 만큼 값을 읽어야 하는지 알 수 없으니 참조할 수 있는 타입을 함께 알려주는 것이다.

예제에서는 소스데이터를 만들기 위해 pointerA 가 가리키는 메모리에 한 바이트씩 숫자를 0 부터 차례대로 써 넣고 있다.

그리고 memcpy() 함수를 이용해 pointerA가 가리키는 내용을 pointerB 의 메모리에 그대로 복사한다.

이 후 pointerB 의 내용을 Int8 단위로 load 해서 읽어보면 pointerA 와 내용이 같음을 알 수 있다.

타입이 있는 포인터로의 변환

일단 생짜 포인터 자체로 쓰기에는 좀 불편한 감이 없지 않으니 이를 좀 더 편한 포인터 타입으로 바꾸는 방법이 있다. bindMemory() 메소드를 이용하면 타입이 지정된 일반 포인터(UnsafePointer 등) 를 구할 수 있다.

아래 코드는 위의 예제에서 계속 이어지는 코드로 가정하자.
let pointerBByteType = pointerB.bindMemory(to: Int8.self, capacity: bytes)
for i in 0..<bytes {
  print("\(pointerBByteType[i])")
}
pointerB 로 복사된 내용을 Int8 타입의 UnsafeMutablePointer 로 가져오는 예제이다. 즉 pointerBBypeType 상수는 UnsafeMutablePointer<Int8> 타입이다.

위의 이 코드는 0에서 7까지의 숫자, 즉 pointerB가 가리키는 내용을 바이트 단위로 끊어서 출력했던 것과 동일한 결과를 콘솔에 표시한다.

이렇게 bindMemory() 메소드로 가져온 포인터는 실제 동일한 메모리를 가리킨다. 위에서 계속 이어지는 예제이다.
pointerBByteType.pointee = 10

for i in 0..<bytes {
  let value = pointerB.advanced(by: i).load(as: Int8.self)
  print("\(value)")
}
위 예제가 실행되면서 pointerB가 가리키는 내용을 바이트 단위로 출력해보면 결과는 10, 1, 2, ... 7 이다. 분명 바꾼건은 pointerBBypeType 의 데이터인데 pointerB 가 가리키는 메모리의 데이터가 변경되었다. 마치 동일한 변수의 레퍼런스를 다루는 것 처럼 말이다.

여기서 레퍼런스라는 개념과 포인터의 개념이 비슷하다는 말이 나온다. 포인터 연산은 주소를 주고 받으며 메모리의 내용을 공유하는 스타일이다. 레퍼런스 개념도 비슷하지 않은가? ;-)
물론 레퍼런스와 포인터는 다른 개념이다. 포인터는 데이터를 메모리 주소 개념이고 그래서 다음이나 이전 주소라는 개념이 있다. 하지만, 레퍼런스는 한 객체(인스턴스)의 주소를 표현하기 위한 개념이기 때문에 이전 혹은 다음 주소라는 개념을 사용하지 않는다.
지금까지는 계속 바이트 단위의 액세스만 했는데 다른 타입 단위로도 해 보자.
let pointerBInt32Type = pointerB.bindMemory(to: Int32.self, capacity: bytes / MemoryLayout<Int32>.size)
for i in 0..<2 {
  print("\(pointerBInt32Type[i])")
}
위 코드는 Int32 타입의 포인터로 변환한 후 내용물을 찍어본 것이다. 결과에 대해서는 딱히 적진 않겠다. 왜냐하면 숫자 2개가 찍히는데 엔디안에 따라 틀릴 수도 있기 때문이다.

capacity 를 계산하는 코드를 보면 알 수 있겠지만, 이 경우 Int32 가 4 바이트이기 때문에 2개의 capacity 를 가지는 메모리라고 가정된다. 그래서 그 아래 for 문에서는 2개의 데이터를 액세스 하고 있다. (안타깝게도 포인터 자체에서 capacity 가 얼마인지 알려주지 않는데 이유는 모르겠다.)

이 코드는 바이트 배열의 데이터를 강제로 Int32 데이터 메모리에 그대로 채워넣고 이 값이 실제로 어떻게 표시되는지 알 수 있는 코드이다.

마무리

디지털 데이터는 비트로 구성되어 있다. 0과 1 말이다. 그리고 바이트는 (일반적으로) 8개의 비트로 구성된 데이터다. 즉 비트를 쭈욱 나열해 놓고 8개씩 끊으면 그게 바이트 단위 데이터다.

그리고 같은 방식으로 계속 확장한다. Int16 는 16비트 즉 2바이트 단위 데이터다. 바이트를 일렬로 쭈욱 늘어놓고 여기서 2개씩 끊어서 읽으면 이는 Int16 형식의 데이터로 액세스 된다.

이런 규칙을 안다면 왜 타입이 없는 포인터가 이용되는지 그리고 어떻게 이용하는 알 수 있다. 타입이 없는 포인터를 쓰는 이유는 타입과 무관하게 바이트 단위로 데이터를 액세스 하기 위함이다. 또한 단위를 특정하게 되면 특정 단위 데이터로 쉽게 변신이 가능하다.

그런데 상당히 난해한 내용이긴 하다. 일단 C 포인터 부터 제대로 쓸 줄 알아야 이해가 될지도 모르겠다.

[돌아가기] Swift 속의 C Pointer 이야기 - 시작
[관련글] Swift 속의 C Pointer 이야기 - UnsafePointer, UnsafeMutablePointer
[관련글] 스위프트(Swift) 가이드

댓글

이 블로그의 인기 게시물

버전(Version)을 제대로 이해하기

소수점 제거 함수 삼총사 ceil(), floor(), round()