더블 포인터에 대한 고찰

모기·2025년 4월 15일
#include <stdio.h>
void change_num(int* input_ptr, int* address) {
  input_ptr = address;
}
void change_double(int** input_ptr, int* address) {
  *input_ptr = address;
}
int main() {
  int* a;
  int** double_a;
  int b = 8;
  a = &b;
  double_a = &a;
  int c= 15;
  change_num(a, &c);
//1번 결과
  printf("%d", *a);
  printf("\n");
  change_double(double_a, &c);
//2번 결과
  printf("%d", *a);
  return 0;
}

더블 포인터를 왜 사용하는 지 알 수 있는 코드이다.
혹시 1번 결과와 2번 결과가 같다고 생각했다면 아래 글을 읽고
다르다고 생각했다면 컴퓨터 끄고 자도 된다.

우선
1번 결과는 8이 출력되고
2번 결과는 15가 출력된다.

누군가는 그렇게 생각할 수도 있다.
주소를 바꿔줬으니 된 거 아니야?

주소를 바꾼 건 맞다.
누구의 주소를 바꿨는지가 중요하다.

change_num의 함수는 '포인터'와 '주소'를 매개 변수로 받고
'포인터의 값'을 '매개 변수로 가져온 주소'로 바꾼다.
근데 이런 형태, 어디서 많이 봤다.

#include <stdio.h>
void change_num(int a, int b) {
  a = b;
}
int main() {
  int a = 10;
  int b = 8;
  change_num(a, b);
//1번 결과
  printf("%d", a);
  printf("\n");
}

과연 a의 값은 바뀔까?
그렇지 않다.
혹시 이게 이해가 되지 않는다면 포인터보다 지역 변수에 대한 개념부터 이해를 하도록 하자.
지역 변수란 무엇인가? 컴퓨터 구조에 대한 개념부터 이해를 하도록 하자.

일단 메모리에는 이렇게 정리된다.

가상 메모리
커널 가상 메모리
유저 스택
↓
↑
공유 라이브러리 메모리 지역
↑
런타임 힙
읽기/쓰기 데이터
읽기 전용 코드 및 데이터

Bryant, R. E., & O’Hallaron, D. R. (2023). Computer systems: A programmer’s perspective (3rd ed.). Pearson.

여기서 런타임 힙에는 유저가 동적 메모리할당을 통해 힙 공간을 요청한 경우에 변수가 저장된다. 사실 이렇게 말하면 나도 무슨 말인지 모른다. 궁금해져서 AI한테 물어보니 perplexity 말로는 언어마다 다르다고 한다.

언어/환경힙에 저장되는 데이터의 예시설명
C/C++malloc(), calloc(), new로 할당된 데이터동적 메모리 할당을 통해 생성된 데이터. 수동으로 해제 필요 (free, delete).
Java객체(new 키워드로 생성된 데이터)모든 객체는 힙에 저장되며, 가비지 컬렉터가 메모리를 관리함.
C#참조 타입(배열, 클래스 인스턴스 등)힙에 저장되며, 가비지 컬렉터가 자동으로 메모리를 해제함.
JavaScript객체, 배열, 함수비원시(non-primitive) 데이터는 힙에 저장됨.
Python리스트, 딕셔너리, 클래스 인스턴스 등복잡한 데이터 구조는 모두 힙에 저장되며, 가비지 컬렉터가 관리함.
일반적인 경우큰 데이터 구조(대형 배열, 연결 리스트 등)크기가 커서 스택에 적합하지 않은 데이터는 힙에 저장됨.

그새 정신 차렸는지 이제는 마크다운 표로 잘 보여준다.
이렇게 보니 그렇게 많이 접했던 가비지 컬렉터의 역할을 알 것 같다.

여기부터가 중요하다.

call by value

그리고 스택에는 함수에서 사용되는 것들이 저장되는데, 함수를 호출한 주소(반환 주소)나 함수의 기본 정보, 함수에서 사용되는 변수, 매개 변수(매개 변수의 수나 크기에 따라 다르다) 등등이 저장된다.
아래는 유저스택을 확대한 것이다.

유저 스택
이전 함수
매개변수
...
매개변수
반환 주소
저장된 레지스터
지역 변수
...

함수가 호출되면 크게 호출한 함수와 실행하는 함수 부분으로 구분할 수 있다.
중요한 건 이 함수가 종료되면 이 스택 부분이 사라진다는 것이다.

예를 들어

#include <stdio.h>
void change_num(int input_a, int input_b) {
  input_a = input_b;
}
int main() {
  int a = 10;
  int b = 8;
  change_num(a, b);
//1번 결과
  printf("%d", a);
  printf("\n");
}

이 코드를 스택으로 표현하면 다음과 같이 된다.
놀랍?게도 메인 함수 역시 스택에 저장된다.

유저 스택
-main 함수-
int a = 10
int b = 8
(함수 호출)
-change_num 함수-
int input_a = 10
int input_b = 8
(함수 호출)의 주소

막 함수가 호출됐을 때는 이렇게 되고
input_a = input_b를 실행하게 되면

유저 스택
-main 함수-
int a = 10
int b = 8
(change_num 함수 호출)
-change_num 함수-
int input_a = 8
int input_b = 8
(change_num 함수 호출)의 주소

위와 같이 된다.

그리고 함수가 종료되면

유저 스택
-main 함수-
int a = 10
int b = 8

위와 같이 되며 printf 함수가 실행된다.

유저 스택
-main 함수-
int a = 10
int b = 8
(printf 함수 호출)
-printf 함수-
printf 값 = 10 // 10 출력
(printf 함수 호출)의 주소

이렇게 된다. 위에서도 알 수 있겠지만, a의 값은 다른 함수의 스택 프레임에 복사가 될 뿐 a가 전달되는 것은 아니다. 즉, 값으로 전달(call by value)되면 원본은 건드리지 않고 함수 내에서만(지역 변수) 쓰인다.

그렇다면 이 원본을 바꾸기 위해서는 어떻게 해야할까?

call by reference

바로 참조, 주소, 가르키는 것을 주어야 한다.

어떤 값이 어디에 저장되었는지 알기 위해서는 반드시 메모리 주소를 알아야 한다.

메모리 주소유저 스택
0x1000-main 함수-
0x1004int a = 10
0x1008int b = 8
0x100C(change_num 호출)

C에서는 해당 메모리 주소를 알기 위해 &(앰퍼샌드)라는 연산자를 사용한다.
예를 들어 &(a)하면 0x1004가 나올 것이고 &(b)하면 0x1008이 나올 것이다.
그럼 이제 살짝 비틀어보자.

얘에 대해서 알아볼 것이다.

#include <stdio.h>
void change_num(int* input_ptr, int input_int) {
  *input_ptr = input_int;
}
int main() {
  int a = 10;
  int b = 8;
  int* ptr_a = &a;
  change_num(ptr_a, b);
  printf("%d", a);
  printf("\n");
}

참고로 포인터의 크기는 아키텍처마다 다르고 32bit에서는 8byte를 차지한다.
다만, 여기서는 편의를 위해 4byte로 가정하고 설명을 진행하겠다.

메모리 주소유저 스택
0x1000-main 함수-
0x1004int a = 10
0x1008int b = 8
0x100Cint* ptr_a = 0x1004
0x1010change_num(ptr_a, b) 호출
-------------------------------------
0x1014-change_num 함수-
0x1018int* input_ptr = 0x1004
0x101Cint input_b = 8
0x1020리턴 주소(main으로 복귀)

여기서 주목할 것은 chnage num 함수의 스택 프레임에 0x1004라는 주소가 있다는 것이다.
그러면 이제 change_num은 자신의 스택 프레임 바깥의 주소에 접근할 수 있다.
따라서 *ptr_a를 통해

이런 형태로 접근해서

메모리 주소유저 스택
0x1000-main 함수-
0x1004'int a = 8'
0x1008int b = 8
0x100Cint* ptr_a = 0x1004
0x1010change_num(ptr_a, b) 호출
-------------------------------------
0x1014-change_num 함수-
0x1018int* input_ptr = 0x1004
0x101Cint input_b = 8
0x1020리턴 주소(main으로 복귀)

*(0x1004) = 8로 해버린다.

이렇게 주소로 접근하는 것이 call by reference의 기본 개념이다.

call by reference가 왜 중요한가?
우선 객체 지향 언어에서는 일부 객체와 인스턴스는 참조의 형태로 사용된다. 참조형 데이터 역시 참조하는 형태로 사용된다.
그리고 참조한 객체를 수정하는 함수를 쓸 경우 높은 확률로 원본 데이터를 손상시킨다.

예를 들어보자.
형식적인 측면에서 파이썬이 간편하므로 파이썬을 예시코드로 들겠다.

def change_array(input_array):
	input_array[0] = 5

array_a = [1, 2, 3]
change_array(array_a)
print(array_a)

위의 코드에서 결과가 어떻게 나올 것 같은가?

#include <stdio.h>
void change_num(int input_a, int input_b) {
  input_a = input_b;
}
int main() {
  int a = 10;
  int b = 8;
  change_num(a, b);
//1번 결과
  printf("%d", a);
  printf("\n");
}

여기서 10이 나왔으니 파이썬 코드에서도 [1, 2, 3]이 나올 것이라고 생각하는가?
정답은 [5, 2, 3]이 나온다.
파이썬이라서 그런 거 아니냐고?
오히려 C로 접근하면 더욱 명확해진다.
C에서는 array_a를 포인터로 취급한다. 포인터는 주소를 가지고 있는 놈이고 따라서 array_a는 주소를 담고 있는 놈이다.
편하게 생각하기 위해 객체들은 힙에 저장된다고 생각하자.

메모리 주소힙
0x2000array_a[0] = 1
0x2004array_a[1] = 2
0x2008array_a[2] = 3

메모리 주소스택
0x1000-main 함수-
0x1004array_a = 0x2000
0x1008change_array(array_a) 호출
-------------------------------------------
0x100C-change_array 함수-
0x1010input_array = 0x2000

따라서 change array 함수의 스택 프레임이라도 원본 객체에 접근할 수 있는 것이다.

이런 식으로 말이다.
객체 지향 언어의 객체들도 위와 같다고 생각하면 된다.
따라서 원본 객체가 손상되는 것을 막고 싶으면

from copy import deepcopy

def change_array(input_array):
	new_array = deepcopy(input_array)
    new_array[0] = 5

array_a = [1, 2, 3]
change_array(array_a)
print(array_a)

OUTPUT
[1, 2, 3]

위와 같이 깊은 복사(deepcopy)를 사용하면 된다.
그러면 다음과 같이 작동할 것이다.
물론 예시처럼 연속으로 할당되지는 않을 것이다.

메모리 주소힙
0x2000array_a[0] = 1
0x2004array_a[1] = 2
0x2008array_a[2] = 3
0x200Cnew_array[0] = 1
0x2010new_array[1] = 2
0x2014new_array[2] = 3

메모리 주소스택
0x1000-main 함수-
0x1004array_a = 0x2000
0x1008change_array(array_a) 호출
-------------------------------------------
0x100C-change_array 함수-
0x1010input_array = 0x2000
0x1014new_array = 0x200C

그럼 참조에 대한 설명은 여기까지하고, 진짜를 만나러 가자.

더블 포인터

를 만나기 전에 우선 *연산자와 자료형*에 대해서 알아보자.

1) 자료형 포인터

처음에 얘네 만나면 헷갈린다.
많이 헷갈린다.
엄청 헷갈린다.
그 놈이 그 놈 같은데, 사실 다른 것 같고, 또 알고 보면 같은 것 같고...
...

자료형*은 그냥 포인터 선언할 때 쓴다고 생각하면 된다. 그리고 해당 변수는 주소가 들어간다고 생각하면 된다.
자료형*은 변수 선언할 때와 함수의 매개변수로 넣을 때 말고는 볼 일이 없다.
그래도 둘의 느낌이 미묘하게 다르니까 설명하도록 하겠다.
내가 느끼는 느낌이 다르다는 말이지 똑같은 놈이다.

int* a;

인트를 가진 값의 주소를 담는 인트형 포인터 a를 선언한 것이다.
주소를 담기 때문에 보통 &를 단 값을 할당한다.
&는 주소를 반환하는 연산자이기 때문이다.

int b = 0;
int* a = &b;

따라서 위에서는 b의 주소를 반환하고 이를 a에 할당했다고 보면 된다.
그리고 실제로 메모리 내에도 b의 주소가 저장되어 있다.
때문에

printf("%d", a);

를 하면 b의 값인 0이 나오는게 아니라 주소같은 값이 나온다.
주소 같은 값이라고 한 이유는 포인터 변수는 %p와 같은 형태로 출력해야 더 정확한 주소가 나오기 때문이다.
여기서 나오는 주소 같은 값은 당연히 b의 주소이다.

사실 헷갈리는 건 함수다.

void testPtr(int* a) {
	//test code
}

위와 같이 있으면 당최 위 함수에 뭘 어떻게 넣어야할지 막막해진다.

int b = 0;
int* a = &b;

testPtr(b)  ?
testPtr(a)  ?
testPtr(&b) ?
testPtr(*a) ?

넷 중에 무엇이 가능할 거 같은가?
연산자 *의 의미에 대해 후술하겠지만, 간단하게 '~가 가리키는 곳의 값'이라고 나는 해석한다.
앞서 설명했듯 int*면 주소를 담아야 한다.
위의 4개 중 주소에 해당하는 것은

testPtr(a)
testPtr(&b)

이 둘이다.
&b는 주소인 걸 알겠는데 왜 a가 주소냐고?

printf("%d", a);

를 하면 주소가 나오니까
잘 모르겠으면 항상 프린트를 찍어보는 걸 추천한다.
금속활자 같은 걸로 안찍어도 되는데 얼마나 다행인가

그리고 * 앞에 붙는 자료형 따라 int*면 int형 포인터, double*이면 double형 포인터 ... 라고 생각하면 된다.
마지막으로 자료형에 붙은 *을 읽을 때는 '~의 주소를 담은'이라고 생각하면서 읽는다.
int* a는 'int의 주소를 담은' a인 것이다.
선행학습이지만
int** a는 'int의 주소를 담은' 'int*의 주소를 담은' a인 것이다.

2) 연산자 포인터

우리가 헷갈리는 것은 *가 연산자로써 작용할 때이다.
나는 *를 '~가 가리키는 곳의 값'이라고 읽는다.

int a = 10;
int* ptr_a = &a;
메모리 주소?
0x1004int a = 10
0x1008int* ptr_a = 0x1004

연습해보자
printf("%d", ptr_a)를 하면 무엇이 나올 것 같나?

당연히
0x1004이다.

printf("%d", *ptr_a)를 하면 무엇이 나올 것 같나?
ptr_a'가 가리키는 곳(0x1004)의 값'인 10이 나온다.

사실 여기까지는 할만하다.

진짜 더블 포인터(real_double_ptr)

변수명을 재밌게 쓰면 프로그래밍이 재밌어진다.

알고리즘을 풀기 위해 유니온 파인드를 쓰다가 문득 real_min_num을 썼던 게 생각이 났다.
어쩔 수 없었다. 루트 노드를 딕셔너리로 구현하려다보니 그렇게 됐다.
진짜 작은 놈은 루트 노드(예정)이다.

각설하고
더블 포인터란 무엇인가?
int**
내 해석을 곁들이면 int의 주소를 담은 int*의 주소를 담은 놈이다.
그렇다면 주소를 담은 놈의 주소를 담는다는 과연 무슨 말일까?

int a = 10;
int* ptr_a = &a;
int** double_ptr_a = &ptr_a;
메모리 주소?
0x1004int a = 10
0x1008int* ptr_a = 0x1004
0x100Cint** double_ptr_a = 0x1008

보다시피이다.

double_ptr_a는 a의 주소를 담은 ptr_a의 주소를 가지고 있다.
그렇다면 *연산자를 통해 얘네들을 해석해보자.

먼저 *double_ptr_a를 하면 어떤 값이 나올까?
그 방식대로 해석해보면
double_ptr_a가 가리키고 있는 곳의 값
double_ptr_a가 가리키고 있는 곳의 값(0x1008)
즉 0x1008의 값인 0x1004가 나온다.

한 발자국 더 들어가서
**double_ptr_a는 뭐가 나올까?
double_ptr_a가 '가리키고 있는 곳의 값'이 '가리키고 있는 곳의 값'이 나온다.
즉, double_ptr_a가 '가리키고 있는 곳의 값(0x1004)'이 '가리키고 있는 곳의 값(10)'이 나온다.

그럼 이 복잡한 놈을 대체 왜 쓰는 걸까?
그것이 바로 서두에

#include <stdio.h>
void change_num(int* input_ptr, int* address) {
  input_ptr = address;
}
void change_double(int** input_ptr, int* address) {
  *input_ptr = address;
}
int main() {
  int* a;
  int** double_a;
  int b = 8;
  a = &b;
  double_a = &a;
  int c= 15;
  change_num(a, &c);
//1번 결과
  printf("%d", *a);
  printf("\n");
  change_double(double_a, &c);
//2번 결과
  printf("%d", *a);
  return 0;
}

를 쓴 이유이다. 또한 대표적으로 링크드리스트의 헤드 노드를 변경할 때 쓰기도 한다.
코드를 보면서 무슨 말인지 이해를 해보겠다.
총 3가지 코드 더미를 제시하겠다.

#include <stdio.h>
void change_num(int* input_ptr, int input_int) {
  *input_ptr = input_int;
}
int main() {
  int a = 10;
  int b = 8;
  int* ptr_a = &a;
  change_num(ptr_a, b);
  printf("%d", a);
  printf("\n");
}
#include <stdio.h>
void change_ptr(int* input_ptr, int* address) {
  input_ptr = address;
}
int main() {
  int a = 10;
  int b = 8;
  int* ptr_a = &a;
  change_ptr(ptr_a, &b);
  printf("%d", *ptr_a);
}
#include <stdio.h>
void change_double(int** input_ptr, int* address) {
  *input_ptr = address;
}
int main() {
  int a = 10;
  int b = 8;
  int* ptr_a;
  int** double_a;
  ptr_a = &a;
  double_a = &ptr_a;
  change_double(double_a, &b);
  printf("%d", *ptr_a);
  return 0;
}

1번에서 a값이 8로 바뀌는 것은 확인했다.
주소에 있는 값을 직접 건드리기 때문이다.
그렇다면 2번은 어떻게 될까?
스택 메모리를 통해서 과정을 살펴보자.

우선 다음과 같이 호출될 것이다.

메모리 주소유저 스택
0x1000-main 함수-
0x1004int a = 10
0x1008int b = 8
0x100Cint* ptr_a = 0x1004
0x1010change_ptr(ptr_a, &b) 호출
-------------------------------------
0x1014-change_num 함수-
0x1018int* input_ptr = 0x1004
0x101Cint* address = 0x1008
0x1020리턴 주소(main으로 복귀)

여기서 우리가 하는 연산은 input_ptr에 address를 할당하는 연산이다.
따라서 스택 0x1018의 값은 다음처럼 바뀔 것이다.

메모리 주소유저 스택
0x1000-main 함수-
0x1004int a = 10
0x1008int b = 8
0x100Cint* ptr_a = 0x1004
0x1010change_ptr(ptr_a, &b) 호출
-------------------------------------
0x1014-change_ptr 함수-
0x1018int* input_ptr = '0x1008'
0x101Cint* address = 0x1008
0x1020리턴 주소(main으로 복귀)

이 말인 즉슨 원본 데이터는 변하지 않는다는 것이다.
ptr_a처럼 0x1004를 가리키는 포인터가 하나 더 생기고 그 친구가 0x1008을 가리키는 것으로 바꼈을 뿐이다.
따라서 10이 출력된다.

3번은 어떻게 진행될까? 이것도 역시 스택을 살펴보자.

메모리 주소유저 스택
0x1000-main 함수-
0x1004int a = 10
0x1008int b = 8
0x100Cint* ptr_a = 0x1004
0x1010int** double_a = 0x100C
0x1014change_double(double_a, &b) 호출
-------------------------------------
0x1018-change_double 함수-
0x101Cint** input_ptr = 0x100C
0x1020int* address = 0x1008
0x1024리턴 주소(main으로 복귀)

여기서 우리가 하는 연산은 *double_a = address 연산이다.
즉 double_a가 가리키고 있는 곳(0x100C)의 값을 0x1008로 바꾸는 것이다.

메모리 주소유저 스택
0x1000-main 함수-
0x1004int a = 10
0x1008int b = 8
0x100Cint* ptr_a = '0x1008'
0x1010int** double_a = 0x100C
0x1014change_double(double_a, &b) 호출
-------------------------------------
0x1018-change_double 함수-
0x101Cint** input_ptr = 0x100C
0x1020int* address = 0x1008
0x1024리턴 주소(main으로 복귀)

그러면 위와 같이 변하게 된다.
따라서 *ptr_a의 값은 8이 나온다.

1번과 다른 게 없어보일지도 모르지만, 큰 차이가 있다.
바로 원본데이터의 손실 여부이다.


1번에서는

메모리 주소유저 스택
0x1000-main 함수-
0x1004'int a = 8'
0x1008int b = 8
0x100Cint* ptr_a = 0x1004
0x1010change_num(ptr_a, b) 호출
-------------------------------------
0x1014-change_num 함수-
0x1018int* input_ptr = 0x1004
0x101Cint input_b = 8
0x1020리턴 주소(main으로 복귀)

a의 값이 바뀌었다.
따라서 printf("%d", a)를 하면 8이 출력된다.
그러나


3번에서는

메모리 주소유저 스택
0x1000-main 함수-
0x1004int a = 10
0x1008int b = 8
0x100Cint* ptr_a = '0x1008'
0x1010int** double_a = 0x100C
0x1014change_double(double_a, &b) 호출
-------------------------------------
0x1018-change_double 함수-
0x101Cint** input_ptr = 0x100C
0x1020int* address = 0x1008
0x1024리턴 주소(main으로 복귀)

가리키는 곳만 바꿨기 때문에 a의 값은 바뀌지 않았다.
따라서 printf("%d", a)를 하면 10이 출력된다.

profile
안녕

1개의 댓글

comment-user-thumbnail
2025년 4월 16일

글 너무 잘봤습니다.

답글 달기