Swift 속의 C Pointer 이야기 - UnsafeRawPointer, UnsafeMutableRawPointer
이번 이야기는 Raw Pointer 대충 번역하면 생포인터에 대한 이야기다. 쉽게 표현하자면 이 생포인터는 타입이 지정되지 않은(Untyped) 포인터이다. 포인터 시작편에서 언급했지만 이 생포인터는 타입이 명시되지 않았다는 점 때문에 Swift 에서 배척(?)받을 지도 모르는 존재일지도 모르겠다. (뇌내망상)
이 생짜포인터를 액세스 하기 위해 Swift 에서는 UnsafeRawPointer 와 UnsafeMutableRawPointer 등의 컨테이너를 제공한다.
참고로 이 글은 UnsafeMutablePointer 의 기본 API를 안다고 가정한다. 이 컨테이너는 Swift에서 포인터의 기본에 해당하고 그래서 주소를 다루는 것은 비슷하기 때문이다.
언급하기에 앞서 C의 유명한 함수인 memcpy()를 잠깐 살펴볼까 한다. memcpy 함수의 원형은 아래와 같은 형식이다. (참고로 내 맥에서 man 커맨드로 확인한 내용이다)
이 memcpy() 함수는 Swift 에서는 아래와 같은 타입으로 보인다.
간단한 예제를 하나 보자. 앞서 본 memcpy() 를 직접 호출해 보는 예제이다.
pointerA 와 pointerB 는 생짜 포인터로 생성된다. 여기서 alignedTo 값을 1로 지정해 줬는데 이는 메모리가 1바이트 단위로 구성되어 있다는 의미이다. 즉, allocate 코드에서 할당은 Int 의 크기(8 바이트)로 하는데 메모리 연산은 1바이트 단위라는 말이다.
이후 포인터가 가리키는 메모리를 액세스 할 때는 기존 포인터와는 다르게 storeBytes 혹은 load 라는 특수한 메소드를 사용한다. 이 메소드의 특징은 반드시 타입을 기재하게 되어 있다는 점이다. 생포인터에는 포인터 자체에 타입이 없기 때문에 어떤 크기 만큼 값을 읽어야 하는지 알 수 없으니 참조할 수 있는 타입을 함께 알려주는 것이다.
예제에서는 소스데이터를 만들기 위해 pointerA 가 가리키는 메모리에 한 바이트씩 숫자를 0 부터 차례대로 써 넣고 있다.
그리고 memcpy() 함수를 이용해 pointerA가 가리키는 내용을 pointerB 의 메모리에 그대로 복사한다.
이 후 pointerB 의 내용을 Int8 단위로 load 해서 읽어보면 pointerA 와 내용이 같음을 알 수 있다.
아래 코드는 위의 예제에서 계속 이어지는 코드로 가정하자.
위의 이 코드는 0에서 7까지의 숫자, 즉 pointerB가 가리키는 내용을 바이트 단위로 끊어서 출력했던 것과 동일한 결과를 콘솔에 표시한다.
이렇게 bindMemory() 메소드로 가져온 포인터는 실제 동일한 메모리를 가리킨다. 위에서 계속 이어지는 예제이다.
여기서 레퍼런스라는 개념과 포인터의 개념이 비슷하다는 말이 나온다. 포인터 연산은 주소를 주고 받으며 메모리의 내용을 공유하는 스타일이다. 레퍼런스 개념도 비슷하지 않은가? ;-)
capacity 를 계산하는 코드를 보면 알 수 있겠지만, 이 경우 Int32 가 4 바이트이기 때문에 2개의 capacity 를 가지는 메모리라고 가정된다. 그래서 그 아래 for 문에서는 2개의 데이터를 액세스 하고 있다. (안타깝게도 포인터 자체에서 capacity 가 얼마인지 알려주지 않는데 이유는 모르겠다.)
이 코드는 바이트 배열의 데이터를 강제로 Int32 데이터 메모리에 그대로 채워넣고 이 값이 실제로 어떻게 표시되는지 알 수 있는 코드이다.
그리고 같은 방식으로 계속 확장한다. Int16 는 16비트 즉 2바이트 단위 데이터다. 바이트를 일렬로 쭈욱 늘어놓고 여기서 2개씩 끊어서 읽으면 이는 Int16 형식의 데이터로 액세스 된다.
이런 규칙을 안다면 왜 타입이 없는 포인터가 이용되는지 그리고 어떻게 이용하는 알 수 있다. 타입이 없는 포인터를 쓰는 이유는 타입과 무관하게 바이트 단위로 데이터를 액세스 하기 위함이다. 또한 단위를 특정하게 되면 특정 단위 데이터로 쉽게 변신이 가능하다.
그런데 상당히 난해한 내용이긴 하다. 일단 C 포인터 부터 제대로 쓸 줄 알아야 이해가 될지도 모르겠다.
[돌아가기] Swift 속의 C Pointer 이야기 - 시작
[관련글] Swift 속의 C Pointer 이야기 - UnsafePointer, UnsafeMutablePointer
[관련글] 스위프트(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) 가이드
댓글