여기서는 go의 unsafe.Pointer를 사용해서 타입을 캐스팅하는 방법을 정리해 보자. unsafe.Pointer 자체는 다른 페이지에서 정리해보면 될 것 같다.

uintptr과 Arbitrary Type

패키지 unsafe의 몇몇 함수들을 사용하기 위해서는 우선 타입 uintptr을 알아야 한다.

type uintptr uintptr

uintptr is an integer type that is large enough to hold the bit pattern of any pointer.

이게 뭐냐하면 어떤 포인터든 그걸 가리킬만한 충분한 크기의 주소를 담는 포인터라고 할 수 있겠다. *int의 경우 int타입의 변수를 가리키는 주소를 저장하는 역할이고 *struct의 경우 구조체를 가리키는 역할을 하겠지만 uintptr은 메모리의 어떤 곳이든 가리킬 수 있는 그런 일반화된 포인터라고 생각하면 된다.

그 다음은 Arbitrary Type을 보자.

type ArbitraryType int

It represents the type of an arbitrary Go expression.

그냥 간단히 이야기하면 뭔지 모르는 타입을 대강 알려주는 타입이라고 생각하면 된다. 쉽게 말해서… C로 치면 void * 형? 실제로 밑에서 설명하겠지만 굉장히 C에서의 void와 같은 느낌으로 쓰며 그래서 원하는대로 쓰려면 마찬가지로 이를 다시 다른 타입으로 컨버전을 해야 한다.

unsafe.Pointer

이 글의 목적인 unsafe.Pointer에 대해서 보자.

type Pointer *ArbitraryType

Pointer represents a pointer to an arbitrary type.

패키지 unsafe의 Pointer타입은 그게 무엇이든지 어떤 임의의 타입을 가리키는 포인터라고 할 수 있다. 그래서 리턴이 *ArbitraryType다. 뭔지는 모르지만 하여간 그걸 가리키고 있는 상황인 것이다.

unsafe.Pointer는 타입이다. 함수처럼 사용하지만 실제로는 타입이고, 타입 컨버전이므로 함수의 ()과 같이 괄호를 사용한다. 따라서 어떤 변수든간에 이 unsafe.Pointer 타입으로 conversion이 가능하다. 그리고 컨버전의 결과가 *ArbitraryType이다.

package main

import (
        "fmt"
        "unsafe"
)

func main() {
        i := 42
        ptr := unsafe.Pointer(&i)

        fmt.Println("ptr?: ", ptr)
}
// ptr?:  0xc0000b8000
// https://play.golang.org/p/Jk_FEgpBg7G

이런 코드가 있는 경우, ptr은 i를 가리키는 포인터 *ArbitraryType를 가진다. 왜 unsafe.Pointer의 파라미터가 포인터인가? 하면 그냥 unsafe.Pointer의 제약이라고 보면 된다. 제약은 다음과 같다.

  • A pointer value of any type can be converted to a Pointer.
  • A Pointer can be converted to a pointer value of any type.
  • A uintptr can be converted to a Pointer.
  • A Pointer can be converted to a uintptr.

영어를 잘 못해서 모르겠지만 대충 이야기하면 1,2번이 서로 상호 변환이 가능하고 3,4번도 서로 상호 변환 가능하다는 뜻이다.

conversion-raw

어떠한 타입의 pointer라도 unsafe.Pointer로 바꿀 수 있다. 반대로 어떠한 unsafe.Pointer라도 다시 특정 타입의 pointer로 변경할 수 있다. 또 uintptr도 unsafe.Pointer로, unsafe.Pointer도 uintptr로 변경이 가능하다. 그리고 이 이야기는… pointer에서 uintptr로 상호 변환도 가능하다는 뜻일 것이다.

그래서 우선 1,2번만 다시 말하면… 어떤 타입이든 unsafe.Pointer를 이용한다면 어떠한 타입으로도 변환할 수 있다는 뜻이다. 아래에서는 몇 가지 그런 케이스를 테스트해 보자.

테스트: int8 -> rune

rune 타입은 type alias된 int32로, 글자 하나를 표현할 때 쓴다. 글자 하나를 표현하는데 int32를 쓴다고? 왜냐하면 유니코드는 u+10FFFF만큼의 크기가 최대이며 이게 21비트를 차지하므로 최소 3바이트가 있어야 유니코드 한 글자를 표현할 수 있기 때문이다. 글자를 바이트 단위로 처리하는 것이 아닌 유니코드 단위로 처리하려는 타입으로 생각하면 된다. 어쨌든, 결국 이 캐스팅 문제는 int8을 int32로 변환하는 문제다. 그리고 이런 경우 일반적으로 conversion, 타입 컨버전을 통해 처리할 수 있다.

package main

import (
        "fmt"
)

func main() {
        var source int8
        var dest rune

        source = 64
        dest = rune(source)

        fmt.Println("dest?: ", dest)
}
// dest?: 64
// https://play.golang.org/p/fwuFO-JP16N

그러면 이걸 unsafe.Pointer를 사용해서 변경한다면?

package main

import (
        "fmt"
        "unsafe"
)

func main() {
        var source int8
        var dest rune

        source = 64
        dest = *(*rune)(unsafe.Pointer(&source))

        fmt.Println("dest?: ", dest)
}
// dest?: 64
// https://play.golang.org/p/td34aj4xusp

음? 이게 더 복잡한 것 아닌가? 걍 괄호쓰면 변환되는데… 라고 생각할 수 있지만 어떤 타입으로도 변환할 수 있다는 것을 생각해 보자. 다른 케이스를 살펴보자.

func main() {
        var source rune
        var dest uint8

        source = '곿' // U+ACFF
        dest = *(*uint8)(unsafe.Pointer(&source))

        fmt.Printf("source?: %04X\n", source)
        fmt.Printf("dest?: %04X\n", dest)
}
// source?: ACFF
// dest?: 00FF
// https://play.golang.org/p/IHwgRaYFcPk

이것도 타입 컨버전으로 가능하긴 한데, 어쨌든 rune->int8, uint8->rune으로 변환된 것을 확인했다.

int32 -> [4]int8

이번엔 int32를 4개의 int8 배열로 변경해 보자.

func main() {
        var source rune
        var dest [4]uint8

        source = '곿' // U+ACFF
        dest = *(*[4]uint8)(unsafe.Pointer(&source))

        fmt.Printf("source?: %04X\n", source)

        for i, v := range dest {
                fmt.Printf("dest idx %d, %X\n", i, v)
        }
}
// source?: ACFF
// dest idx 0, FF
// dest idx 1, AC
// dest idx 2, 0
// dest idx 3, 0
// https://play.golang.org/p/ULRkh4fRR_B

이런 식으로는 컨버전이 되지 않기 때문에 변환을 위해서는 반드시 unsafe.Pointer를 사용하거나 int32에서 하나씩 꺼내서 값을 배열에 담아야 한다…

slice -> 구조체

이번에는 slice를 구조체에 담아 보자. go의 slice하면 유명한 그림이 있다.

slice-internal

slice는 실제 길이와 용량, 그리고 데이터의 배열을 가지는 컬렉션인데, slice의 길이와 용량을 확인하기 위해 len(), cap()을 사용한다. 어쨌든 구조는 이런 모양이니 이걸 그냥 구조체로 캐스팅해 보려고 한다. 코드는 이런 식이다.

type SliceDisassembler struct {
        data *[5]int
        len int
        cap int
}

func (s SliceDisassembler) Len() int {
        return s.len
}

func (s SliceDisassembler) Cap() int {
        return s.cap
}

func (s SliceDisassembler) Data() *[5]int {
        return s.data
}

func main() {
        source := []int{1,2,3,4,5}
        var dest SliceDisassembler

        dest = *(*SliceDisassembler)(unsafe.Pointer(&source))

        fmt.Println("len, cap?: ", dest.len, dest.cap)
        for _, v := range dest.Data() {
                fmt.Println("dest?: ", v)
        }
}
// len, cap?:  5 5
// dest?:  1
// dest?:  2
// dest?:  3
// dest?:  4
// dest?:  5
// https://play.golang.org/p/Yzelmoxydw3

구조체에 잘 바인딩된 것을 확인할 수 있다. slice SliceDisassembler의 경우 필드 data는 포인터 타입이므로 실제 source의 배열을 가리키고 있어서, source를 고치면 SliceDisassembler의 data도 함께 변경된다.

        // ...
        // ...
        source[2] = 64

        for _, v := range dest.Data() {
                fmt.Println("dest?: ", v)
        }
        // ...

// dest?:  1
// dest?:  2
// dest?:  64
// dest?:  4
// dest?:  5
// https://play.golang.org/p/qwjfQ_SyGhj

이 외에도 다양한 캐스팅 방법이 있고, 앞으로도 추가할 예정.

go 내부에서도 패키지 reflect에서 이런 캐스팅을 사용한다. 해당 부분을 정리하면서 같이 자세히 정리해보면 좋을 것 같다.