포인터가 주소를 담는 변수라는 것까지는 익히셨을 겁니다. 그런데 C에서 배열과 포인터는 거의 한 몸처럼 움직이고, 함수에 배열을 넘기는 순간 이 관계가 전면에 드러납니다. 배열 이름이 왜 주소처럼 취급되는지, 함수에 넘긴 배열을 어떻게 원본까지 바꾸는지, 그 과정에서 크기 정보가 어디로 사라지는지를 예제로 따라가 봅니다.


18.1 배열 이름은 첫 원소의 주소

배열 이름을 값이 필요한 자리에 쓰면, 대부분의 경우 첫 원소의 주소로 변환됩니다. 즉 arr과 &arr[0]은 같은 주소입니다. 그래서 배열 이름을 그대로 포인터에 담을 수 있습니다.


int arr[3] = {10, 20, 30};

int *p = arr; // arr == &arr[0]

printf("%d\n", *p); // 10

printf("%d\n", *(p + 1)); // 20

printf("%d\n", *(p + 2)); // 30


실행하면 이렇게 나옵니다.


10

20

30


p는 arr의 첫 칸을 가리키고, *p로 그 칸의 값을 읽습니다. 배열을 복사한 게 아니라 같은 메모리를 다른 이름으로 들여다보는 셈입니다.


18.2 포인터 산술과 인덱스는 같은 말

*(p + i)는 arr[i]와 완전히 같은 표현입니다. 여기서 p + 1은 주소를 1바이트가 아니라 원소 하나 크기(int면 4바이트)만큼 옮깁니다. 컴파일러가 자료형 크기를 알아서 곱해 주기 때문입니다. 그래서 인덱스 없이 포인터만 움직여도 배열 전체를 순회할 수 있습니다.


int arr[4] = {5, 6, 7, 8};

for (int *p = arr; p < arr + 4; p++) {

printf("%d ", *p);

}

printf("\n");


출력은 다음과 같습니다.


5 6 7 8


끝 판정은 arr + 4, 즉 마지막 원소 바로 다음 칸의 주소로 합니다. 주의. 이 "마지막 다음" 주소는 비교에만 쓸 수 있고, *(arr + 4)처럼 실제로 값을 읽으면 배열 범위를 벗어난 접근이라 정의되지 않은 동작(undefined behavior)입니다.


[C 18] 포인터와 배열, 함수 인자


18.3 함수에 배열 넘기기

함수 매개변수에 배열을 적으면, 실제로는 포인터를 받는 것입니다. int arr[]라고 써도 컴파일러는 이것을 int *arr로 바꿔 읽습니다. 그래서 함수는 배열의 시작 주소만 건네받을 뿐, 그 배열이 몇 칸인지는 알 수 없습니다. 개수를 따로 인자로 넘겨야 하는 이유입니다.


int sumArray(int *arr, int n) {

int sum = 0;

for (int i = 0; i < n; i++) sum += arr[i];

return sum;

}


int main(void) {

int data[4] = {1, 2, 3, 4};

printf("합: %d\n", sumArray(data, 4));

return 0;

}


결과입니다.


합: 10


함수가 받는 것은 첫 칸의 주소뿐이고, 크기 4를 함께 넘겼기에 for문이 어디까지 돌지 알 수 있습니다.


18.4 배열 크기가 사라지는 순간

배열이 선언된 그 자리에서는 sizeof로 전체 바이트 수를 알 수 있고, 원소 개수는 sizeof(arr) / sizeof(arr[0])로 구합니다. 그런데 똑같은 계산을 함수 안에서 하면 전혀 다른 답이 나옵니다.


void wrong(int arr[]) {

// arr은 사실 포인터

printf("함수 안: %zu\n",

sizeof(arr) / sizeof(arr[0]));

}


int main(void) {

int data[5] = {1, 2, 3, 4, 5};

printf("main: %zu\n",

sizeof(data) / sizeof(data[0]));

wrong(data);

return 0;

}


64비트 PC에서의 출력입니다.


main: 5

함수 안: 2


함정. 함수로 넘어간 배열은 포인터로 붕괴(decay)합니다. 이때 sizeof(arr)는 배열 전체가 아니라 포인터 하나의 크기(64비트 PC에서 8바이트)를 재고, sizeof(arr[0])은 4바이트라서 8 나누기 4, 즉 2라는 엉뚱한 값이 나옵니다. 그래서 함수 안에서는 배열 길이를 sizeof로 절대 구할 수 없고, 앞의 sumArray처럼 개수를 반드시 인자로 함께 넘겨야 합니다.


18.5 원본을 바꾸는 함수

포인터를 넘기는 진짜 목적은 원본을 바꾸는 것입니다. 값을 그냥 넘기면 함수는 사본만 주무르다 끝나고 원본은 그대로입니다. 두 변수의 값을 맞바꾸는 swap이 대표적인 예입니다.


void swap(int *a, int *b) {

int t = *a;

*a = *b;

*b = t;

}


int main(void) {

int x = 1, y = 2;

swap(&x, &y); // 주소를 넘김

printf("x=%d, y=%d\n", x, y);

return 0;

}


출력은 이렇습니다.


x=2, y=1


&x, &y로 주소를 넘겼기에 함수가 원본 x, y를 직접 고칩니다. 배열은 여기서 한 걸음 편합니다. 애초에 주소로 넘어가므로, 함수 안에서 원소를 고치면 그대로 원본에 반영되고 &를 붙일 필요도 없습니다.


void doubleAll(int *arr, int n) {

for (int i = 0; i < n; i++) arr[i] *= 2;

}


int main(void) {

int data[3] = {1, 2, 3};

doubleAll(data, 3);

for (int i = 0; i < 3; i++)

printf("%d ", data[i]);

printf("\n");

return 0;

}


결과입니다.


2 4 6


swap은 정수 변수라 &를 붙여 주소를 만들었지만, 배열은 이름 자체가 이미 주소라 doubleAll(data, 3)처럼 그냥 넘깁니다. 이 차이가 배열과 포인터는 한 몸이라는 말의 실체입니다.


18.6 위치를 돌려주는 함수

함수가 값 대신 위치(주소)를 돌려줄 수도 있습니다. 배열에서 가장 큰 값이 든 칸의 주소를 반환하면, 값과 그 위치를 함께 알 수 있습니다.


int *findMax(int *arr, int n) {

int *best = arr;

for (int i = 1; i < n; i++) {

if (arr[i] > *best) best = &arr[i];

}

return best;

}


int main(void) {

int data[5] = {3, 9, 2, 9, 1};

int *m = findMax(data, 5);

printf("최댓값 %d (인덱스 %ld)\n",

*m, m - data);

return 0;

}


출력입니다.


최댓값 9 (인덱스 1)


포인터끼리 빼면 두 칸이 몇 개 떨어져 있는지, 즉 인덱스 차이가 나옵니다. m - data가 최댓값이 든 칸의 인덱스입니다. 주의. 이렇게 돌려주는 주소는 호출한 쪽에 살아 있는 배열을 가리킬 때만 안전합니다. 함수 안에서 만든 지역 배열의 주소를 반환하면, 함수가 끝나는 순간 그 메모리가 사라져 남은 주소를 쓰는 것은 정의되지 않은 동작이 됩니다.


배열과 포인터의 관계는 규칙 하나로 요약됩니다. 값이 필요한 자리의 배열 이름은 첫 원소의 주소로 바뀐다는 것입니다. 그 덕에 함수는 배열을 통째로 복사하지 않고 주소만 받아 원본을 다루지만, 대신 길이 정보를 잃습니다. 그러니 개수는 늘 함께 넘기고, sizeof는 배열이 살아 있는 그 자리에서만 믿으면 됩니다.