포인터

메모리 주소으로 갖는 데이터 타입


1. 선언 방법

var a int
var p *int

p = &a

메모리주소를 가리킬 데이터타입형에 *를 붙이면 해당 타입의 메모리주소를 담는 포인트형을 선언 할 수 있다. &를 이용해서 변수의 메모리주소 시작값을 할당 할 수 있다.

메모리 주소 시작값은 하나의 값으로 일종의 숫자 값이다.



2. 사용 방법

var a int
var p *int
var b *int
var c *int

p = &a
b = &a
c = &a

*p = 20     //해당 메모리주소에 값을 할당
p = 20      //error

fmt.Println(*b)     //20

포인터 변수를 선언할때 말고 사용하는 시점에 변수앞에 *를 붙이면 메모리주소에 들어있는 값을 의미하고 이를 이용해 메모리주소에 값을 할당 할 수 있다.

만일, 포인터변수에 int값을 할당하려고 하면 포인터타입 != int이기 때문에 타입에러가 난다.



3. 특징

◾ 여러 변수에서 같은 메모리를 가리킬 수 있다.

var a int
var b *int
var c *int

b = &a
c = &a

a = 20

fmt.Println(*b)     //20
fmt.Println(*c)     //20

포인터타입 변수도 변수이기 때문에 위 코드의 b,c는 같은 메모리 공간(a의 메모리주소)을 가리킬 수 있다.


◾ 메모리주소는 16진수로 표현한다.

a := 1
b := &a

fmt.Println(b)      //0xc00007e010
fmt.Printf("%d", b) //824634236944

보통 메모리주소는 16진수로 표현되는데 이는 10진수로 표현하게 되면 숫자가 너무 커지고 컴퓨터가 관리하기 더 수월하기 때문이다.


◾ ==연산이 가능하다.

a := 1
b := &a
c := &a
d := c

fmt.Println(b == c) //true
fmt.Println(b == d) //true
fmt.Println(&a == d) //true
fmt.Println(*d)     //1

*int(&a) //error

메모리 주소도 하나의 으로 표현하고 관리하기 때문에 값 비교가 가능하다. C는 메모리주소를 포인터타입으로 변경이 가능하지만 Go는 다른 데이터타입에서 포인터타입으로의 형변환은 금지하고 있기 때문에 에러가 발생한다.


◾ 기본값은 nil

var p *int
if p == nil{
  fmt.Println("아무것도 안가리킵니다.")
}

null과 같은 의미로 nil이 아니라는 소리는 p가 유효한 메모리 주소를 가리킨다는 뜻이다.



4. 사용 목적

int변수 하나를 선언해서 함수에서 변수의 값을 증가시키는 프로그램을 만들어보면서 사용목적을 봐보자.

import (
    "fmt"
)

func addCount(count int){
    count++;
}

func main() {
    count := 0
    addCount(count)

    fmt.Println(count)    //0
}

함수의 매개변수로 기본데이터타입을 이용해서 넘겨주어 만들어보면 우리의 예상과는 다르게 값이 증가하지 않는 것을 볼 수 있다. 이는 각 함수마다 별도의 스택공간을 가지고있기 때문인데 이런이유로 서로다른 함수에서 같은 이름의 지역변수를 사용할 수 있는 것이다.

그런데 매개변수로 기본데이터타입을 넘겨주면 addCount함수에서 count변수를 하나 새로 생성해서 main함수의 count변수의 을 복사해오는 것이기 때문에 count라는 변수는 서로 엄연히 다른 변수이다. addCount에서 count를 아무리 증가시키고 줄이고 해도 main의 count는 아무런 영향이 없는 것이다.

한마디로 call by value이기 때문이다. 이를 해결하려면 call by reference로 참조할수 있는 메모리 주소를 넘겨주면 되는데 이때 Go포인터를 이용해서 해결한다.


func addCount(count *int){
    *count++;
}

func main() {
    count := 0
    addCount(&count)

    fmt.Println(count)    //1
}

포인터타입을 매개변수로 받고 값을 변경시킬 데이터의 메모리주소를 &이용해서 넘겨주어 제어하면 값이 변경되는 것을 볼 수 있다. 또한, call by value라면 인자의 메모리크기 그대로 새로 메모리에 할당하지만 포인터만 넘겨주게 되면 포인터 타입 크기(8byte)만큼만 할당되기 때문에 메모리를 절약할 수 있는 이점도 있다.

이런 이점도 있지만 데이터가 의도치 않게 손상되지 않도록 조심해야 한다.



5. 구조체 포인터 초기화

//case 1
var data Data   //Data타입 구조체 변수
var p1 *Data = &data

//case 2
var p2 *Data = &Data{}

Data타입의 구조체변수를 선언해서 이 주소를 할당하는 방식이 기본이며, 구조체를 익명 생성하자마자 그 메모리를 할당하는 방법도 존재한다.



6. 인스턴스

//1개의 인스턴스
var data Data
var p1 := &data
var p2 := &data
var p3 := &data

//4개의 인스턴스
var data1 := data
var data2 := data
var data3 := data

인스턴스는 실제로 메모리에 할당된 하나의 데이터로 위의 예제에서 p1~p3는 실제로는 data라는 변수의 한개의 데이터만을 가리키고있기 때문에 한개의 인스턴스가 생성된것으로 볼 수 있고 data1~data3는 값을 data의 값을 복사해서 새로운 메모리에 할당(인스턴스 샐성)한 것이기 때문에 각각의 인스턴스를 갖아 총 4개의 인스턴스가 생성된다.


◾ 삭제되는 시점

인스턴스는 어떤 곳에서도 사용하지 않으면 (가리키지 않으면) 사라지는데 이는 가비지 컬렉터가 다음 GC타임에 자동으로 삭제를 해준다. 포인터가 있는 c/c++과 비교해서 Go는 GC가 있어 메모리관리가 조금더 편리한 것 같다.

GC는 보통 Java에서 많이 들어봤는데 Java의 GC는 가상머신에서 동작하는데 비해 Go실행파일 안에 GC가 내장되어 생산성이 높고 Java같이 힙 압축, 세대별 GC등과 같은 기능을 수행하지 않고 CMS(Concurrent Mark & Sweep)만 수행하기 때문에 가벼운 특징이 있다.

Go의 GC에 대해 자세히 정리한 글


◾ 구조체 포인터를 이용해 객체 생성하기

type User struct{
  name string
  age int
}

func NewUser(name string, age int) *User {
  var u = User(name,age)
  return &u
}

func main(){
  userPointer := NewUser("AAA",23)

  fmt.Println(userPointer)    //&{AAA 23}
}

지금까지 설명을 잘 들었다면 위의 코드에서 이상한점을 느낄 것이다. 함수내의 변수는 스택영역에 할당된다고 했는데 NewUser()에서 지역변수인 u의 메모리 주소를 return하고 있기 때문이다. 우리 생각대로라면 u의 메모리는 함수가 끝났기 때문에 없어지고 userPointer에는 쓰레기 값이나 error가 발생할 것 같은데 정상적으로 잘 동작한다.

이는 Go의 특별한 기능인 Escape analyzing(탈출 분석)때문인데 컴파일 타임에 컴파일러가 메모리타입을 return할때 해당 변수가 지역변수라면 스택영역에서 영역으로 데이터를 옮겨주기 때문에 함수가 종료되어도 다른 함수내에서 선언한 변수를 접근할 수 있게 된다.

Go는 이런 특징을 이용해서 객체를 생성하는 생성자를 선언할 수 있다.


//case 1
fmt.Println((*userPointer).name)    //AAA

//case 2
fmt.Println(userPointer.name)       //AAA

userPointer는 포인터타입기 때문에 값에 접근하기 위해서는 case1과 같이 사용해야하는데 구조체 포인터라면 내부의 필드를 접근할때 *를 생략해서 작성해도 접근할 수 있도록 제공을 하고 있다.






Reference

『Tucker의 Go 언어 프로그래밍』 스터디 요약 노트

Tucker의 Go 강좌