6장 포인터

2024.03.19

6.1 빠른 포인터 입문

  • 포인터는 간단히 설명하면 값이 저장된 메모리의 위치 값을 가지고 있는 변수이다.
  • 모든 변수는 하나 혹은 그 이상의 연속적인 메모리 공간에 저장되는데, 그것을 주소라 부른다.
  • 포인터는 단순히 다른 변수가 저장된 주소를 내용으로 가지는 변수이다.
  • 포인터의 제로 값은 nil 이다.
  • & 는 주소 연산자이다. 변수 앞에 & 를 붙이면 해당 변수의 값이 저장된 메모리 위치의 주소를 반환한다.
x := "hello"
pointerToX := &x
  • * 는 간접 연산자이다. 포인터 타입의 변수 앞에 붙이면 가리키는 값을 반환한다. 이를 역 참조(dereferencing)라 부른다.
x := 10
pointerToX := &x
fmt.Println(pointerToX) // 메모리 주소 출력
fmt.Println(*pointerToX) // 10을 출력
  • 포인터 타입은 포인터가 어떤 타입을 가리키는지 나타낸다. 타입 이름 앞에 * 을 사용하여 작성한다.
x := 10
var pointerX *int
pointerToX = &x
  • 내장 함수 new 는 포인터 변수를 생성한다. 제공된 타입의 제로 값을 가리키는 포인터를 반환한다.
    • new 함수는 드물게 사용된다.
var x = new(int)
fmt.Println(x == nil) // false를 출력
fmt.Println(*x) // 0을 출력
  • 구조체를 위해 포인터 인스턴스를 만들려면 구조체 리터럴 앞에 & 를 사용한다. 기본 타입 리터럴(숫자, 불리언, 문자열)나 상수 앞에는 메모리 주소를 가지지 않기 때문에 & 를 사용할 수 없다. 기본 타입을 위한 포인터가 필요하다면, 변수를 선언하고 해당 변수를 가리키도록 하자.
x := &Foo{}
var y string
z := &y

6.2 포인터를 두려워 말라

  • 자바, 자바스크립트, 파잉썬, 루비와 같은 언어에 익숙하다면 포인터가 위협적일 수 있다. 하지만 포인터는 실제 클래스의 동작과 유사하다.
  • 자바스크립트에서 클래스의 인스턴스가 다른 변수에 할당되거나 함수 혹은 메서드로 넘겨진다면 아래와 같이 처리된다.
class Foo {
  constructor(x) {
    this.x = x;
  }
}

function outer() {
  let f = new Foo(10);
  inner1(f);
  console.log(f.x);
  inner2(f);
  console.log(f.x);
  let g = null;
  inner2(g);
  console.log(g == null);
}

function inner1(f) {
  f.x = 20;
}

function inner2(f) {
  f = new Foo(30);
}

outer();

// 아래와 같이 출력된다.
// 20
// 20
// True
  • 자바, 파이썬, 자바스크립트 및 루비에서는 다음과 같은 것들이 적용되기 때문이다.
    • 클래스의 객체를 함수로 넘기고 해당 클래스 내의 항목 값을 수정하면, 해당 변경은 전달된 변수에 반영이 된다.
    • 파라미터로 재할당이 되면, 해당 변경은 전달된 변수에 반영되지 않는다.
    • nill/null/None 을 파라미터 값으로 전달하면, 파라미터 자체를 새 값으로 설정해도 호출 함수의 변수가 수정되지 않는다.
  • 어떤 사람들은 클래스 객체를 참조에 의한 전달이기 때문에 해당 결과가 나온다고 설명하지만 이것은 틀린 말이다. 진짜 참조에 의한 전달이라면, 두 번째, 세 번째 경우에도 호출 함수의 변수가 변경되어야 한다.
  • 우리가 보고 있는 이런 언어의 모든 클래스 인스턴스는 포인터로 구현이 된다. 클래스 인스턴스를 함수나 메서드로 넘길 때, 복사된 값은 인스턴스의 포인터이다.
  • Go에서 포인터 변수나 파라미터를 사용할 때, 똑같은 수행을 보인다. Go와 이런 언어들 간에 차이는 원시 값과 구조체 모두를 위해 값으로 사용할지 포인터로 사용할지에 대한 선택을 제공한다.

6.3 포인터는 변경 가능한 파라미터를 가리킨다

  • Go는 값에 의한 호출을 사용하는 언어이기 때문에, 함수로 전달된 값은 복사된다. 하지만 포인터가 함수로 전달되면 함수는 포인터의 복사를 얻게 된다. 이는 호출된 함수에서 원본 데이터를 수정할 수 있다는 의미이다.
  • 포인터를 역 참조하여 값을 설정하면, 원본과 복사된 포인터가 가리키는 메모리 위치에 새로운 값을 넣을 수 있다.
func failedUpdate(px *int) {
    x2 := 20
    px = &x2
}

func update(px *int) {
    *px = 20
}

func main() {
    x := 10
    failedUpdate(&x)
    fmt.Println(x) // 10 출력
    update(&x)
    fmt.Println(x) // 20 출력
}
  • 위 예제에서는 아래와 같은 동작이 수행된다.
    • main에서 x에 10의 값을 넣는다.
    • failedUpdate가 호출될 때, x의 주소를 복사하여 px 파라미터에 넣는다.
    • failedUpdate에서 x2를 선언하고 20을 설정한다. 그리고 px는 x2의 주소를 가리키도록 한다.
    • main으로 돌아왔을 때, x의 값은 변하지 않는다.
    • update가 호출될 때, x의 주소를 복사하여 px 다시 넣는다.
    • update에서 px가 가리키는 main의 x값을 변경한다.
    • main으로 돌아왔을 때, x는 변경되어 있다.

6.4 포인터는 최후의 수단

  • 포인터들은 데이터 흐름을 이해하기 어렵게 만들며 가비지 컬렉터에게 추가적인 작업을 준다.
  • 함수로 구조체 전달을 포인터로 하여 항목을 채우는 것보다 함수 내에서 구조체를 초기화하고 반환하는 것이 좋다.
func MakeFoo(f *Foo) error {
    f.Field1 = "val"
    f.Field2 = 20
    return nil
}

// 위처럼 작성하지 말고 아래처럼 작성하자
func MakeFoo() (Foo, error) {
    f := Foo{
        Field1: "val",
        Field2: 20,
    }
    return f, nil
}
  • 함수에서 값을 반활할 때는 값 타입을 사용하는 것을 선호해야 한다. 데이터 타입 내에 수정될 필요가 있는 상태 정보를 갖고 있는 경우에만 포인터를 반환 타입으로 사용한다.
  • 추가적으로 동시성을 사용하면서 반드시 포인터로 넘겨줘야 하는 데이터 타입이 있다.

6.5 포인터로 성능 개선

  • 구조체가 충분히 커진다면, 입력 파라미터나 반환값으로 구조체에 대한 포인터를 사용하여 성능을 향상시킬 수 있다.
  • 포인터는 모든 데이터 타입을 함수로 전달할 때 상수 시간이 걸리는데, 보통 1 나노초 정도이다. 모든 데이터 타입을 위한 포인터의 크기는 항상 동일하다.
  • 대부분의 경우에서 포인터의 사용과 값의 차이는 프로그램 성능에 영향을 주지 않는다. 하지만 함수 간에 메가바이트 데이터를 전달한다면, 데이터를 변경할 수 없는 경우에도 포인터 사용을 고려해보자.

6.6 제로 값과 값없음의 차이

  • Go에서 포인터의 다른 일반적인 사용은 제로 값이 할당된 변수나 항목과 아무런 값도 할당되지 않은 변수나 항목의 차이를 나타낼 수 있다.
  • 이런 구분이 프로그램에서 중요하다면 할당되지 않은 변수나 구조체 항목을 나타내기 위해 nil 포인터를 사용하자.
  • nil 포인터를 파라미터나 파라미터의 한 항목으로 넘긴다면, 값을 어디에도 저장할 수 없기 때문에 함수 내에서 값을 설정할 수 없다는 것을 기억하자.
  • 포인터는 값이 없음을 나타내는 쉬운 방법을 제공하지만, 값을 수정할 일이 없다면 대신에 불리언과 쌍을 이루는 값의 타입을 사용하자.

6.7 맵과 슬라이스의 차이

  • Go 런타임 내에서 맵은 구조체를 가리키는 포인터로 구현되어 있다. 함수로 맵을 넘기는 것은 포인터를 복사한다는 의미이다.
  • 이런 이유 때문에, 공용 API에서 입력 파라미터나 반환값으로 맵의 사용을 피해야하는 것이다. API 설계 단게에서 맵은 어떤 값이 포함되어 있는지 알수가 없기 때문에 나쁜 선택이 된다.
  • Go는 강한 타입 언어이다. 맵으로 넘기기 보다는 구조체를 사용하도록 한다.
  • 함수로 슬라이스를 넘기는 것은 조금 더 복잡한 행동을 한다.
  • 슬라이스는 3개의 항목을 가지는 구조체로 구현이 되어 있다.
    • 길이를 위한 정수 항목
    • 수용력을 위한 정수 항목
    • 메모리 블록을 가리키는 포인터
  • 슬라이스가 다른 변수로 복사되거나 함수로 전달될 때, 길이, 수용력, 포인터를 복사하게 된다.
  • 슬라이스의 내용을 수정하는 것은 원본 변수에 반영이 되지만, append 를 통해 길이를 변경하는 것은 수용력의 길이보다 큰 경우 조차도 원본 변수에 반영이 되지 않는다. ( 복사본만 변경되기 때문 )
  • 결과적으로 함수로 넘겨진 슬라이스는 해당 내용을 수정할 수 있지만, 슬라이스의 크기를 재조정할 수 없다는 것을 알 수 있다.
  • 슬라이스는 Go 프로그램에서 자주 전달이 되는데, 기본적으로 함수에 의해 수정을 할 수 없다고 가정하자.

6.8 버퍼 슬라이스

  • 외부 자원(파일이나 네트워크 연결과 같은)에서 데이터를 읽어 들일 때, 많은 언어들이 다음과 같이 코드를 사용한다.
r = open_resource()
while r.has_data() {
    data_chunk = r.next_chunk()
    process(data_chunk)
}
close(r)

// data_chunk는 매번 할당되어야 한다.
// 이것은 많은 불필요한 메모리 할당을 하게 만든다.
  • 가비지 컬렉션을 사용하는 언어는 자동으로 이런 할당들을 처리하지만, 이런 일들은 처리가 끝나고 난 뒤에 정리를 해줘야 할 필요가 있다.
  • Go도 가비지 컬렉션을 사용하지는 언어이지만, 관용적 Go로 작성하면 불필요한 할당을 피할 수 있다.
file, err := os.Open(fileName)
if err != nil {
    return err
}
defer file.Close()
data := make([]byte, 100)
for {
    count, err := file.Read(data)
    if err != nil {
        return er
    }
    if count == 0 {
        return nil
    }
    process(data[:count])
}
  • 데이터 소스에서 매번 읽을 때마다 새 할당을 반환하기보다, 바이트 슬라이스를 생성하고 데이터 소스를 읽어 들이는 버퍼로 사용한다.
  • 함수로 넘겨진 슬라이스의 길이와 수용력은 바꿀 수 없지만 현재 길이에서 해당 내용을 변경할 수 있다는 것을 기억하자.
  • 100 바이트 버퍼를 만들어 매 루프에서 다음 블록의 바이트(100 바이트까지)만큼 슬라이스에 복사한다. 버퍼에 채워진 곳 까지만 process 함수로 넘겨 처리할 수 있도록 했다.

6.9 가비지 컬렉션 작업량 줄이기

  • 가비지는 더 이상 어떤 포인터도 가리키지 않는 데이터를 의미한다. 어떤 포인터도 가리키지 않는 데이터가 차지하고 있던 메모리는 재사용 될 수 있다.
  • 가비지 컬렉터의 역할은 자동으로 사용되지 않은 메모리를 발견하고 재사용할 수 있도록 복구하는 것이다.
  • 힙에 저장되는 모든 데이터는 스택의 포인터 타입 변수가 접근하는 동안에는 유효하다. 더 이상 해당 데이터로 가리키는 포인터가 없다면, 그 데이터는 가비지가 되고 가비지 컬렉터의 작업에서 정리될 것이다.

C언어에서 일반적인 코드의 버그는 로컬 변수의 포인터를 반환하는 것이다. C언어에서는 이런 경우에 유효하지 않은 메모리를 가리키는 포인터가 된다. Go 컴파일러는 더 똑똑하다. 지역변수에 대한 포인터가 반환되면 지역 변수의 값이 힙에 저장된다.

  • Go 컴파일러에 의해 진행되는 탈출 분석(escape analysis)는 완벽하지 않다.

Go의 탈출 분석(escape analysis)는 컴파일 시간에 수행되는 프로세스로, 변수가 함수의 스택 프레임을 벗어나서 살아남을 수 있는지(즉, "탈출"할 수 있는지) 결정한다. 이 분석의 목적은 메모리 할당을 최적화하여 성능을 향상시키는 것이다.


변수가 함수 범위를 벗어나서 사용될 경우(예를 들어, 포인터가 함수 외부로 반환되거나 전역 변수에 저장되는 경우), 해당 변수는 > 힙에 할당된다. 반면, 변수가 함수 내에서만 사용되고 탈출하지 않는다면 스택에 할당될 수 있으며, 이는 보통 더 효율적이다. > 스택 메모리는 할당과 해제가 빠르기 때문에 성능 상 이점을 제공한다.


탈출 분석을 통해 Go 컴파일러는 어떤 변수가 힙에 할당해야 하는지, 어떤 변수가 스택에 머물러도 되는지를 결정합니다. 이를 통해 불필요한 힙 할당을 줄이고, 가비지 컬렉션의 부하를 감소시킵니다. 결과적으로, 프로그램의 실행 속도가 빨라지고 메모리 사용이 최적화됩니다.
  • 램(RAM)은 임의 접근 메모리(random access memory)를 의미하지만 메모리를 빠르게 읽기 위해서는 연속적으로 접근해야 한다. Go에서 구조체 슬라이스는 모든 데이터가 메모리에 연속적으로 배치되어 빠르게 로드하고 빠르게 처리할 수 있게 한다.
  • Go가 포인터를 드물게 사용하도록 권장하는 이유는 데이터들을 가능한 많이 스택에 저장하도록 하여 가비지 컬렉터의 작업량을 줄이는 것이다.
  • 메모리 할당에 대한 최적화는 시기 상조처럼 보일 수 있지만 Go의 관용적 접근 방식도 가장 효율적이다.