C언어 (4)
1. 포인터
1-1. 포인터란?
포인터는 다른 변수의 메모리 주소를 저장하는 변수 다.
일반 변수가 값을 직접 담고 있다면, 포인터는 "그 값이 어디에 있는지"를 담고 있다. 비유하자면 일반 변수는 서랍 안의 물건이고, 포인터는 그 서랍의 위치를 적어둔 메모지다.
int x = 10;
int *p = &x; // p는 x의 주소를 저장
printf("x의 값: %d\n", x); // 10
printf("x의 주소: %p\n", &x); // 0x7ffeefbff4ac (예시)
printf("p의 값: %p\n", p); // 0x7ffeefbff4ac (같은 주소)
printf("p가 가리키는 값: %d\n", *p); // 10
여기서 두 가지 연산자가 핵심이다.
& 는 주소 연산자 로, 변수의 메모리 주소를 반환한다. &x는 "x가 메모리 어디에 있는지"를 알려준다.
* 는 역참조(dereference) 연산자 로, 포인터가 가리키는 주소에 저장된 값을 가져온다. *p는 "p가 가리키는 곳에 가서 값을 읽어라"는 뜻이다.
선언할 때의 *와 사용할 때의 *는 의미가 다르다. int *p에서 *는 "p가 포인터다"라는 선언이고, *p에서 *는 "p가 가리키는 값"이라는 역참조다. 모양은 같지만 맥락이 다르다.
포인터를 통해 원본 변수의 값을 바꿀 수도 있다.
int x = 10;
int *p = &x;
*p = 50;
printf("%d\n", x); // 50 (원본이 바뀜)
이전에 함수에서 call by value 때문에 원본을 수정할 수 없다고 했는데, 포인터를 사용하면 이 문제를 해결할 수 있다.
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main(void) {
int x = 10, y = 20;
swap(&x, &y);
printf("x=%d, y=%d\n", x, y); // x=20, y=10
return 0;
}
함수에 주소를 넘기고, 함수 안에서 그 주소를 통해 원본을 직접 수정하는 것이다. scanf에서 &를 붙였던 이유도 이것과 같다.
1-2. 포인터의 연산
포인터에 정수를 더하거나 빼면 자료형 크기만큼 주소가 이동한다. 이것을 포인터 산술(pointer arithmetic) 이라고 한다.
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // 배열 이름은 첫 번째 요소의 주소
printf("%d\n", *p); // 10 (arr[0])
printf("%d\n", *(p + 1)); // 20 (arr[1])
printf("%d\n", *(p + 2)); // 30 (arr[2])
p + 1은 주소값에 1을 더하는 게 아니다. int 포인터이므로 4바이트(int 크기)만큼 주소가 이동한다. p가 0x1000이라면 p + 1은 0x1004다.
이 덕분에 포인터와 배열은 밀접하게 연결된다. arr[i]는 사실 *(arr + i)와 같은 표현이다. 배열의 인덱스 접근은 내부적으로 포인터 연산으로 변환된다.
int arr[3] = {100, 200, 300};
printf("%d\n", arr[2]); // 300
printf("%d\n", *(arr + 2)); // 300 (같은 의미)
포인터끼리의 뺄셈도 가능하다. 두 포인터 사이에 요소가 몇 개 있는지를 반환한다.
int arr[5] = {10, 20, 30, 40, 50};
int *p1 = &arr[1];
int *p2 = &arr[4];
printf("%ld\n", p2 - p1); // 3 (요소 3개 차이)
단, 포인터끼리의 덧셈은 의미가 없으므로 허용되지 않는다. 주소 + 주소는 아무런 의미를 가지지 않기 때문이다.
++와 --도 사용할 수 있다. p++는 다음 요소로, p--는 이전 요소로 이동한다.
int arr[3] = {10, 20, 30};
int *p = arr;
printf("%d\n", *p); // 10
p++;
printf("%d\n", *p); // 20
p++;
printf("%d\n", *p); // 30
1-3. 포인터에 여러 가지 자료형이 있는 이유
포인터는 결국 메모리 주소를 저장하는 것이고, 주소는 시스템에 따라 4바이트 또는 8바이트 정수다. 그러면 왜 int *, char *, double *처럼 타입을 구분해야 할까? 어차피 주소 크기는 같은데 전부 void * 하나로 쓰면 안 될까?
이유는 역참조할 때 몇 바이트를 읽어야 하는지 를 결정하기 위해서다.
int *p로 *p를 하면 그 주소에서 4바이트를 읽어서 정수로 해석한다. char *p로 *p를 하면 1바이트만 읽어서 문자로 해석한다. double *p로 *p를 하면 8바이트를 읽어서 실수로 해석한다.
만약 타입 정보가 없다면, *p를 했을 때 그 주소에서 몇 바이트를 읽어야 하는지, 그리고 읽은 비트를 정수로 해석할지 실수로 해석할지 알 수 없다.
int x = 10;
int *ip = &x;
char *cp = (char *)&x;
printf("%d\n", *ip); // 10 (4바이트를 int로 읽음)
printf("%d\n", *cp); // 10일 수도, 아닐 수도 (1바이트만 읽음)
포인터 산술에서도 타입이 중요하다. 앞서 p + 1이 자료형 크기만큼 주소를 이동한다고 했다. int *에서 p + 1은 4바이트 뒤지만, char *에서 p + 1은 1바이트 뒤다. 타입이 없으면 이 계산도 불가능하다.
정리하면 포인터의 타입은 역참조 시 읽을 크기와 포인터 연산의 단위 를 결정한다.
2. 배열과 포인터
배열과 포인터는 C에서 밀접하게 연결되어 있지만, 같은 것은 아니다.
배열 이름은 첫 번째 요소의 주소를 가리키는 상수 포인터 처럼 동작한다. "상수"라는 것은 배열 이름 자체에 다른 주소를 대입할 수 없다는 뜻이다.
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // OK — 배열의 시작 주소를 포인터에 대입
p++; // OK — 포인터는 이동 가능
// arr++; // 에러! — 배열 이름은 이동 불가 (상수)
배열을 포인터로 순회하는 것은 C에서 매우 흔한 패턴이다.
int arr[5] = {10, 20, 30, 40, 50};
// 인덱스 방식
for (int i = 0; i < 5; i++) {
printf("%d ", arr[i]);
}
// 포인터 방식
int *p = arr;
for (int i = 0; i < 5; i++) {
printf("%d ", *(p + i));
}
// 포인터 증가 방식
int *q = arr;
for (int i = 0; i < 5; i++) {
printf("%d ", *q);
q++;
}
세 가지 모두 같은 결과를 출력한다. arr[i], *(arr + i), *(p + i) 는 전부 같은 의미다.
함수에 배열을 넘기면 배열이 통째로 복사되는 것이 아니라 첫 번째 요소의 주소(포인터)만 전달 된다. 그래서 함수 안에서 배열을 수정하면 원본이 바뀐다.
void doubleAll(int *arr, int size) {
for (int i = 0; i < size; i++) {
arr[i] *= 2; // 원본 배열이 수정됨
}
}
int main(void) {
int nums[3] = {1, 2, 3};
doubleAll(nums, 3);
// nums는 이제 {2, 4, 6}
return 0;
}
함수 매개변수에서 int arr[]와 int *arr는 같은 의미다. 둘 다 포인터를 받는다.
3. 함수 포인터
C에서는 함수도 메모리에 존재하므로 주소를 가진다. 함수 포인터 는 함수의 주소를 저장하는 포인터다.
선언 문법이 조금 복잡하다.
반환타입 (*포인터이름)(매개변수타입들);
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
int main(void) {
int (*op)(int, int); // 함수 포인터 선언
op = add;
printf("%d\n", op(3, 5)); // 8
op = subtract;
printf("%d\n", op(3, 5)); // -2
return 0;
}
int (*op)(int, int) 에서 op는 "int 두 개를 받아서 int를 반환하는 함수"를 가리킬 수 있는 포인터다. add든 subtract든, 시그니처가 같은 함수라면 어디든 가리킬 수 있다.
함수 이름 자체가 함수의 주소이므로 op = add에서 &를 붙이지 않아도 된다. 배열 이름이 배열의 주소인 것과 같은 원리다.
괄호 위치가 중요하다. int (*op)(int, int)는 함수 포인터이고, int *op(int, int)는 int *를 반환하는 함수 선언이다. *op를 괄호로 감싸야 포인터라는 뜻이 된다.
함수 포인터가 유용한 대표적인 사례는 콜백(callback) 패턴이다. 어떤 함수에 "실행할 함수"를 인자로 넘기는 것이다. JavaScript에서 addEventListener에 콜백을 넘기는 것과 같은 개념이다.
void calculate(int a, int b, int (*operation)(int, int)) {
printf("결과: %d\n", operation(a, b));
}
int main(void) {
calculate(10, 3, add); // 결과: 13
calculate(10, 3, subtract); // 결과: 7
return 0;
}
calculate 함수는 어떤 연산을 할지 모른다. 호출할 때 연산 함수를 넘겨주면 그걸 실행할 뿐이다. 이렇게 하면 calculate 함수를 수정하지 않고도 새로운 연산을 추가할 수 있다.
C 표준 라이브러리의 qsort도 함수 포인터를 활용한 대표적인 예다. 정렬 기준을 함수 포인터로 받아서, 오름차순/내림차순 등 다양한 방식으로 정렬할 수 있게 한다.
#include <stdlib.h>
int compare(const void *a, const void *b) {
return (*(int *)a - *(int *)b);
}
int main(void) {
int arr[5] = {50, 20, 40, 10, 30};
qsort(arr, 5, sizeof(int), compare);
// arr는 이제 {10, 20, 30, 40, 50}
return 0;
}
마무리
포인터는 C언어에서 가장 중요하면서도 가장 어렵다고 느끼는 개념이다. 핵심은 &(주소를 구함)와 *(주소로 가서 값을 읽음) 이 두 연산자의 의미를 정확히 아는 것이다. 포인터의 타입은 역참조할 때 몇 바이트를 읽을지, 포인터 연산에서 몇 바이트씩 이동할지를 결정한다. 이 원리를 이해하면 배열과 포인터의 관계, 함수 포인터까지 자연스럽게 따라온다.