쌍문동 척척박사
Spring / ReactJS / PS

C 함수와 구조체 이중 포인터

C 함수와 구조체 이중 포인터

C에서 가장 헷갈리는 개념인 함수와 포인터, 나아가 이중 포인터와 구조체에 대한 내용을 한번에 정리하려 한다.

1. 포인터

C에서 int, char, 포인터 변수는 각각 다음 형태의 값을 저장한다.

  • char : 문자형
  • int : 정수형
  • 포인터 : 메모리 주소
1
2
3
4
5
6
7
8
9
10
11
// 방법 1
int num = 30;
int* ptr = &num ;

// 방법 2
int num = 30;     // int형 변수에 정수형 값을 저장
int* ptr;           // int형 포인터를 선언
ptr = &num ;      // int형 포인터에 int형 변수의 주솟값을 저장

printf("%d", ptr);    // ptr에 저장된 주소값
printf("%d", *ptr);   // ptr에 저장된 주소값에 저장된 값
  • &는 주소 연산자로, 해당 변수의 메모리 주소값을 반환한다.
  • *는 간접지정 연산자로, 해당 메모리 주소값에 저장된 값에 접근한다. 위 문법을 통해 다음과 같은 연산이 이루어진다.

1.png

참고

Q. ptr에 저장된 값은 num의 주솟값인 &num이라 했는데, 그럼 이번엔 &ptr을 출력하면 어떤 값이 나올까?

A. 당연하게도 ptr 포인터 변수의 주소값인 0104가 출력된다. 포인터 변수도 기본 변수와 마찬가지로 메모리 영역에 선언되기 때문이다.

2. 배열과 포인터

배열은 연속된 데이터를 저장하기 위한 자료형으로, 각 데이터들에 대해 인덱스로 접근할 수 있다. 이때 인덱스도 주소를 통해 표현되기에, 포인터로 표현이 가능하다.

2.png

또한 배열에 저장된 값들의 메모리 주소는 서로 인접해있기에 다음과 같이 표현될 수 있다.

1
2
3
4
5
6
7
8
9
10
int arr[4] = {3,5,4,2};
char str[5] = "abcd";

printf("%d\n", *(arr + 2));
printf("%c\n", *(str + 2));

// int형 배열은 포인터 1 증가 시, 메모리 주소 4Byte 증가
printf("%d %d %d %d\n", arr, arr+0, arr+1, arr+2);
// char형 배열은 포인터 1 증가 시, 메모리 주소 1Byte 증가
printf("%d %d %d %d\n", str, str+0, str+1, str+2);

3.png

배열의 각 요소(데이터)에 할당된 메모리 크기는 해당 데이터의 자료형에 따라 결정된다. 따라서 포인터를 1 증가시키면 메모리 주소는 해당 자료형의 크기만큼 증가한다.

3. 함수와 포인터

1) 함수에 기본 변수 전달하기

다음 코드를 실행해보자. 과연 3 -> 10이 출력될까?

1
2
3
4
5
6
7
8
9
10
11
12
void func(int temp){
  printf("%d -> ", temp);
  temp = 10;
}

int main(){
  int num = 3;

  func(num);
  printf("%d\n", num);
  return 0;
}

하지만 예상과 다르게 그대로 3이 출력된다. 그 이유는 C에서는 함수에 매개변수를 넘길 때 해당 변수의 값을 복사해서 넘기기 때문이다.

4.gif

따라서 함수에 매개변수를 넘겨 값을 변경하고 싶을 때는 포인터를 이용한다. (추가적으로 별개의 포인터 변수는 선언하지 않아도 된다.)

1
2
3
4
5
6
7
8
9
10
11
12
void func(int* temp){
	printf("%d -> ", *temp);
	*temp = 10;
}

int main(){
	int num = 3;

	func(&num);
	printf("%d\n", num);
	return 0;
}

5.gif

2) 함수에 포인터 변수 전달하기

함수에 포인터 변수를 매개변수로 넘기는 방법은 다음과 같다. 원리는 곰곰이 생각해보자.

1
2
3
4
5
6
7
8
9
10
11
12
void func(int* temp){	// (int*) temp = ptr (=&num)
	printf("%d\n", *temp);
	*temp = 10;
}

int main(){
	int num = 3;
	int* ptr = &num ;

	func(ptr);
	return 0;
}

4. 이중 포인터

이중 포인터는 말 그대로 포인터의 포인터을 말한다. 함수에서 int형 변수의 값을 변경할 때 다음과 같이 int형 포인터를 사용한다.

1
2
3
4
5
6
7
8
9
10
void change(int* val) { // (int*) val = &a
	*val = 3;
}

int main() {
	int a = 5;
	change(&a);

	return 0;
}

포인터도 마찬가지로 함수에서 int형 포인터의 값을 변경할 때, int형 포인터의 포인터를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
void change(int** val) {  // (int*) *val = &old_ptr
	int value2 = 3;
	int *new_ptr = &value2;
	*val = new_ptr; // 이제부터 old_ptr의 주소는 new_ptr = value2의 주소를 가리킨다.
}

int main() {
	int value1 = 5;
	int *old_ptr = &value1;
	change(&old_ptr);

	return 0;
}

추가로 이중 포인터에 대한 포인터는 삼중 포인터이라 하며, n중 포인터의 포인터는 n+1중 포인터라 한다. 크게 어려운 개념은 아니다.

5. 이중 포인터의 사용 예 (연결 리스트)

이중 포인터의 대표적인 사용 사례는 연결 리스트(Linked List)의 구현에서 찾아볼 수 있다.

1) Node형 포인터

연결 리스트는 Node의 연결 상태로 표현되는데, Node 간의 연결 상태는 포인터로 표현되야 한다. 따라서 전체 노드의 시작점인 head는 Node형 포인터로 선언한다.

1
2
3
4
5
6
7
8
9
10
typedef struct NODE {
	int data;
	NODE* link;
}node;

int main() {
	node* head = NULL;

	return 0;
}

2) Node형 이중 포인터

연결 리스트에서 사용되는 함수의 매개변수는 다음과 같다.

  • insert / delete의 매개변수 : head의 이중 포인터
  • print의 매개변수 : head의 포인터
1
2
3
4
node* init_node(int data);						// Node 생성
void insert_node(node** head, int idx, int data);	// idx 위치에 Node 삽입
void delete_node(node** head, int idx);			// idx 위치의 Node 삭제
void print_node(node* head);						// 전체 리스트 출력

우선 print 함수부터 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void print_node(node* head) {
	for (node* temp = head; temp != NULL; temp = temp->link) {
		printf("%d", temp->data);
		if (temp->link != NULL) {
			printf("->");
		}
	}
}

int main() {
	node* head = NULL;

	print_node(head);

	return 0;
}

print 함수는 아래 로직으로 실행되며, main 함수의 head의 기존값을 변경시키지 않는다. 6.gif

3) 1중 Node 포인터로 insert 구현

그럼 이번엔 insert 함수를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void insert_node(node** head, int idx, int data) {
	node* prev = *head;
	node* nd = init_node(data);

	if (idx < 0) return;
	if (idx == 0) {
		nd->link = *head;
		*head = nd;
		return;
	}

	int i = 0;
	while (i++ < idx - 1 && prev->link != NULL) {
		prev = prev->link;
	}
	nd->link = prev->link;
	prev->link = nd;
}

int main() {
	node* head = NULL;
	insert_node(&head, 0, 1);
	insert_node(&head, 1, 3);
	insert_node(&head, 1, 2);
	insert_node(&head, 0, 0);
	insert_node(&head, 4, 4);

	return 0;
}

왜 이중포인터를 썼는지 감이 안 온다. 그럼 위 코드에서 insert의 인자를 1중 Node형 포인터로 바꾼 뒤 동작을 보면 다음과 같다.

7.gif

최종 리스트 결과가 문제없이 나타난다. 아직까지는 문제가 없다.

8.png

하지만 만약 Node의 삽입 위치가 head였을 때 로직 수행 결과는 다음과 같다.

9.gif

새 Node인 nd를 생성한 뒤 insert 함수 내 head가 nd를 가리키게 했지만, 해당 head는 main 함수의 head가 아닌, 함수 내 지역 변수로 선언된 head이기 때문이다. 따라서 insert 함수가 종료되면 해당 head와 nd는 main 함수에서는 가리키는 포인터가 없기 때문에 접근이 불가능하다. 즉, 연결 리스트의 가장 첫번째 Node인 head가 변경될 수가 없어 1중 포인터로는 insert의 로직을 완전히 충족시킬 수 없다.

10.png

5) 이중 포인터로 insert 구현 이를 해결하려면 다음과 같이 Node형 이중 포인터를 사용해야 한다. 11.gif

최종 상태는 다음과 같다. 12.png

이렇듯 함수에서 포인터가 가리키는 주소 자체를 바꾸려면, 해당 포인터의 주소값인 포인터의 포인터를 넘겨야 한다.

심심하면 직접 테스트해보자.

연결 리스트 전체 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <stdio.h>
#include <stdlib.h>

typedef struct NODE {
	int data;
	NODE* link;
}node;

node* init_node(int data) {
	node* nd = (node*)malloc(sizeof(node));
	nd->data = data;
	nd->link = NULL;
	return nd;
}
void insert_node(node** head, int idx, int data) {
	node* prev = *head;
	node* nd = init_node(data);

	if (idx < 0) return;
	if (idx == 0) {
		nd->link = *head;
		*head = nd;
		return;
	}

	int i = 0;
	while (i++ < idx - 1 && prev->link != NULL) {
		prev = prev->link;
	}
	nd->link = prev->link;
	prev->link = nd;
}
void delete_node(node** head, int idx) {
	node* prev = *head;

	if (idx < 0 || prev == NULL) return;
	if (idx == 0) {
		*head = (*head)->link;
		free(prev);
		return;
	}

	int i = 0;
	while (i++ < idx - 1 && prev->link != NULL) {
		prev = prev->link;
	}

	node* temp = prev->link;
	prev->link = temp->link;
	free(temp);
}
void print_node(node* head) {
	for (node* temp = head; temp != NULL; temp = temp->link) {
		printf("%d", temp->data);
		if (temp->link != NULL) {
			printf("->");
		}
	}
}

int main() {
	node* head = NULL;

	insert_node(&head, 0, 1);
	insert_node(&head, 1, 3);
	insert_node(&head, 1, 2);
	insert_node(&head, 0, 0);
	insert_node(&head, 4, 4);

	delete_node(&head, 4);

	print_node(head);
	return 0;
}

여담

참고로 알고리즘 시험장에서는 이렇게 복잡하게 함수와 n중 포인터를 사용하는 일은 생각보다 적다. 그렇게 복잡하게 고민하고 있을 시간에 그냥 전역변수로 다 때려버리면 되는데 굳이.. 하지만 C++의 기본이 되는 C에 대해 이렇게 한번 고민해보는 것도 다른 로직을 구현하는 데 있어 깊은 베이스가 될 수 있기 때문에 충분히 의미가 있다고 생각한다.

comments powered by Disqus