이 글은 [Must Have Tucker의 Go 언어 프로그래밍(세종도서 선정작)]에서 발췌했습니다.
골든래빗 출판사
포인터는 메모리 주소를 값으로 갖는 타입입니다. 포인터를 이용하면 동일한 메모리 공간을 여러 변수가 가리킬 수 있습니다. 본 글에서는 포인터를 사용하는 방법과 인스턴스 개념을 알아봅시다. 총 2편으로 구성했습니다. 1편에서는 포인터의 정의와 사용법을 정리했습니다.
포인터란?
포인터는 메모리 주소를 값으로 갖는 타입입니다. 예를 들어 int 타입 변수 a가 있을 때 a는 메모리에 저장되어 있고 속성으로 메모리 주소를 가지고 있습니다. 변수 a의 주소가 0x0100번지라고 했을 때 메모리 주솟값 또한 숫자값이기 때문에 다른 변수의 값으로 사용될 수 있습니다. 이렇게 메모리 주솟값을 변숫값으로 가질 수 있는 변수를 포인터 변수라고 합니다.
그림에서 int 타입 변수 a의 메모리 주소는 0x0100번지이고, 값으로 3을 갖습니다.
p = &a
위 구문은 포인터 변수 p에 a의 주소를 대입하는 구문입니다. 포인터 변수 p의 값은 변수 a의 주소인 0x0100이 되고, 이것을 ‘포인터 변수 p가 변수 a를 가리킨다’고 말합니다. 이렇게 메모리 주소를 값으로 가져 메모리 공간을 가리키는 타입을 포인터라고 합니다.
포인터를 이용하면 여러 포인터 변수가 하나의 메모리 공간을 가리킬 수도 있고 포인터가 가리키고 있는 메모리 공간의 값을 읽을 수도 변경할 수도 있습니다.
포인터 변수 선언
포인터 변수는 가리키는 데이터 타입 앞에 *을 붙여서 선언합니다. int 타입 변수를 가리키는 포인터 변수를 선언해볼까요?
var p *int
p는 int 타입 데이터의 메모리 주소를 가리키는 포인터 변수입니다. float64 타입을 가리키면 float64, User 구조체를 가리키면 *User라고 선언하면 됩니다.
이제 포인터 변수를 선언하는 법을 알았으니 포인터 변수에 값을 채워보겠습니다. 포인터는 메모리 주소를 값으로 갖습니다. 그러면 어떻게 메모리 주소를 알아올까요? 데이터 앞에 &를 붙이면 됩니다.
var a int
var p *int
p = &a // 1 a의 메모리 주소를 포인터 변수 p에 대입합니다.
❶ 변수 a의 메모리 주소를 포인터 변수 p의 값으로 대입합니다.포인터 변수 p가 변수 a의 메모리 주소를 값으로 가집니다. 이제 p를 이용해서 변수 a의 값을 변경할 수 있습니다. 포인터 변수 앞에 *를 붙이면 그 포인터 변수가 가리키는 메모리 공간에 접근할 수 있습니다.
*p = 20
p가 가리키는 메모리 공간의 값을 20으로 변경합니다. p가 변수 a의 메모리 공간을 가리키기 때문에 a값이 20으로 변경됩니다.
예제를 살펴보며 포인터 변수 사용법을 익혀봅시다.
//ch14/ex14.1/ex14.1.go package main import "fmt" func main() { var a int = 500 var p *int // ❶ int 포인터 변수 p 선언 p = &a // ❷ a의 메모리 주소를 변수 p의 값으로 대입(복사) fmt.Printf("p의 값: %p\n", p) // ❸ 메모리 주솟값 출력 fmt.Printf("p가 가리키는 메모리의 값: %d\n", *p) // ❹ p가 가리키는 메모리의 값 출력 *p = 100 // ➎ p가 가리키는 메모리 공간의 값을 변경합니다. fmt.Printf("a의 값: %d\n", a) // ➏ a값 변화 확인 }
p의 값: 0xc00002c008
p가 가리키는 메모리의 값: 500
a의 값: 100
❶ int 타입 포인터 변수 p를 선언합니다. ❷ 변수 a의 주소를 p에 대입합니다. ❸ p값을 출력합니다. 메모리 주솟값은 %p로 출력합니다. ❹ p가 가리키는 메모리 주소에 담긴 값을 출력합니다. 포인터 변수 앞에 *를 붙여서 접근했습니다. p는 a 변수의 메모리 주소를 값으로 가지고 있기 때문에 *p는 a의 값(500)이 됩니다.
❺ 포인터 변수 p가 가리키는 메모리 공간의 값을 100으로 변경합니다. p는 a의 메모리 주소를 가리키기 때문에 *p = 100은 곧 a값을 100으로 변경하는 것과 같습니다. ❻ a값이 변경된 것을 알 수 있습니다.
포인터 변숫값 비교하기
== 연산을 사용해 포인터가 같은 메모리 공간을 가리키는지 확인할 수 있습니다. 다음 예제를 보겠습니다.
//ch14/ex14.2/ex14.2.go package main import "fmt" func main() { var a int = 10 var b int = 20 var p1 *int = &a // ❶ p1은 a의 메모리 공간을 가리킵니다. var p2 *int = &a // ❷ p2는 a의 메모리 공간을 가리킵니다. var p3 *int = &b // ❸ p3는 b의 메모리 공간을 가리킵니다. fmt.Printf("p1 == p2 : %v\n", p1 == p2) fmt.Printf("p2 == p3 : %v\n", p2 == p3) }
p1 == p2 : true
p2 == p3 : false
❶ p1은 a의 메모리 공간을 가리킵니다.
❷ ****p2도 역시 a의 메모리 공간을 가리킵니다. 즉 p1과 p2는 같은 메모리 주소를 값으로 가집니다. ❸ ****p3는 b의 메모리 공간을 가리킵니다. ❹ ****p1과 p2는 같은 메모리 주솟값으로 가지고 있기 때문에 p1 == p2는 true가 되고 p3는 다른 메모리 주소를 가지고 있기 때문에 p2 == p3는 false가 됩니다.
포인터의 기본값 nil
포인터 변숫값을 초기화하지 않으면 기본값은 nil입니다. 이 값은 0이지만 정확한 의미는 유효하지 않는 메모리 주솟값 즉 어떤 메모리 공간도 가리키고 있지 않음을 나타냅니다. 아래 예는 포인터 변수 p가 유효한 메모리 주소를 가리키는지 검사하는 구문입니다.
var p *int if p != nil { // p가 nil이 아니라는 얘기는 p가 유효한 메모리 주소를 가리킨다는 뜻입니다. }
포인터는 왜 쓰나?
그럼 포인터를 언제 써야 할까요? 변수 대입이나 함수 인수 전달은 항상 값을 복사하기 때문에 많은 메모리 공간을 사용하는 문제와 큰 메모리 공간을 복사할 때 발생하는 성능 문제를 안고 있습니다. 또한 다른 공간으로 복사되기 때문에 변경 사항이 적용되지도 않습니다. 포인터를 사용하지 않는 예를 살펴보겠습니다.
//ch14/ex14.3/ex14.3.go package main import "fmt" type Data struct { // ❶ Data형 구조체 value int data [200]int } func ChangeData(arg Data) { // ❷ 파라미터로 Data를 받습니다. arg.value = 999 arg.data[100] = 999 } func main() { var data Data ChangeData(data) // ❸ 인수로 data를 넣습니다. fmt.Printf("value = %d\n", data.value) fmt.Printf("data[100] = %d\n", data.data[100]) // ❹ data 필드 출력 }
value = 0
data[100] = 0
❷ ChangeData() 함수는 ❶ Data 타입 구조체를 매개변수로 받습니다. ❸ ChangeData() 함수를 호출하면서 data 변숫값을 인수로 넣습니다. ❷ data 변숫값이 모두 복사되기 때문에 ChangeData( ) 함수의 매개변수 arg와 data는 서로 다른 메모리 공간을 갖는 변수입니다.
❸ arg 매개변숫값을 변경합니다. data 변수와는 다른 메모리 공간을 가지기 때문에 ❷ arg값을 변경해도 data값은 변경되지 않습니다.
❹ data값을 출력하지만 값이 변경되지 않았습니다.
이 예제에서 문제점을 다시 한번 살펴봅시다.
ChangeData( ) 함수 호출 시 data 변숫값이 모두 복사되기 때문에 구조체 크기만큼 복사됩니다. Data 구조체는 int 타입 value와 크기가 200인 int 타입 배열 data로 구성되어 있어 총 1608바이트입니다. ChangeData() 함수를 한 번 호출할 때마다 1608바이트가 복사됩니다. 만약 ChangeData( ) 함수가 짧은 시간에 많이 호출되면 성능 문제가 발생할 수 있습니다.
이 문제를 한방에 해결해주는 해결사가 포인터입니다. 포인터를 이용해서 앞 예제를 다시 만들어 보겠습니다.
//ch14/ex14.4/ex14.4.go package main import "fmt" type Data struct { value int data [200]int } func ChangeData(arg *Data) { // ❶ 파라미터로 Data 포인터를 받습니다. arg.value = 999 // ❸ arg 데이터 변경 arg.data[100] = 999 } func main() { var data Data ChangeData(&data) // ❷ 인수로 data의 주소를 넘깁니다. fmt.Printf("value = %d\n", data.value) // ❹ data 필드값 출력 fmt.Printf("data[100] = %d\n", data.data[100]) }
value = 999
data[100] = 999
❶ ChangeData() 함수 매개변수로 Data 구조체의 포인터를 받는 것으로 변경했습니다. ❷ 이제 data 변숫값이 아니라 data의 메모리 주소를 인수로 전달합니다. 메모리 주소는 8바이트 숫자값*이기 때문에 1608바이트의 구조체 전부가 복사되는 게 아닌 8바이트만 복사됩니다.
* 64비트 컴퓨터에서 메모리 주소는 8바이트이고 32비트 컴퓨터에서는 4바이트 크기를 갖습니다.
❸ arg 포인터 변수가 가리키는 구조체의 값을 변경합니다. arg 포인터의 값은 main() 함수의 data 구조체 주솟값이기 때문에 arg 포인터가 main( ) 함수의 data 변수를 가리키게 됩니다. 그래서 data값이 변경됩니다.*
*arg는 포인터 변수이기 때문에 (*arg).value = 999라고 써야 하지만 Go 언어에서는 arg.value라고만 써도 동작합니다.
❹ data의 value와 data 배열의 101번째 값이 변경됐습니다.
포인터를 이용하면 data 변수의 메모리 주소만 복사되기 때문에 메모리 주솟값인 8바이트만 복사됩니다. 또, arg 포인터 변수가 data 변수의 메모리 주소를 값으로 가지고 있어서 Data 구조체의 내부 필드값을 변경할 수 있습니다. 이처럼 포인터를 이용하면 더 효율적으로 데이터를 조작할 수 있습니다.
Data 구조체를 생성해 포인터 변수 초기화하기
구조체 변수를 별도로 생성하지 않고, 곧바로 포인터 변수에 구조체를 생성해 주소를 초깃값으로 대입하는 방법을 알아보겠습니다.
Data 타입 포인터 변수 p에 Data 구조체를 생성해 그 주소를 대입했습니다. 이렇게 하면 (메모리에 실제로 있는 구조체 데이터의 실체를 가리키게 되므로) 포인터 변수 p만 가지고도 구조체의 필드값에 접근하고 변경할 수 있습니다.
1 Comment