Menu

C 언어 포인터와 배열: 붕괴, arr[i] = *(arr+i), 그리고 sizeof 함정

C에서 배열 이름은 쓰이는 거의 모든 자리에서 첫 원소를 가리키는 포인터로 바뀝니다. 이 페이지는 그 붕괴, arr[i]가 글자 그대로 *(arr+i)인 이유, 배열 크기가 함수에 따로 전달되어야 하는 이유, 그리고 배열 포인터와 포인터 배열의 차이를 설명합니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

배열과 포인터는 C에서 서로 다른 것인데 대부분의 경우 같은 방식으로 행동합니다. 그 "대부분의 경우"가 이 주제를 미끄럽게 만듭니다. 배열을 포인터처럼 다루는 코드는 보통 잘 돌아가다가, 차이가 중요한 두 자리 중 하나에 떨어지는 날 조용히 틀린 답을 냅니다.

이 페이지는 그 선을 정확히 긋습니다.

배열 붕괴: 모든 것의 배후에 있는 규칙 하나

거의 모든 식에서 배열 이름은 자동으로 첫 원소를 가리키는 포인터로 변환됩니다. 이를 붕괴라고 합니다. arr은 타입 int *&arr[0]이 됩니다.

배열 이름과 첫 원소의 주소가 똑같이 출력됩니다. 그것이 작동 중인 붕괴이고, & 없이 배열을 포인터에 대입할 수 있는 이유입니다.

붕괴가 일어나지 않는 자리는 정확히 셋입니다. sizeof의 피연산자일 때, &의 피연산자일 때, 그리고 문자열 리터럴로 문자 배열을 초기화할 때입니다. 그 밖의 모든 곳에서는 붕괴한다고 보면 됩니다.

arr[i]는 글자 그대로 *(arr + i)

C 표준은 첨자 연산자를 포인터 산술로 정의합니다. a[b]*(a + b)를 뜻합니다. 인덱싱은 별개의 기능이 아니라 축약입니다.

결과가 둘 따라 나옵니다. 첫째, 포인터에 첨자를 붙일 수 있습니다. p[2]*(p + 2)이고, 그래서 포인터를 받는 함수도 익숙한 arr[i] 문법을 그대로 쓸 수 있습니다. 둘째, 2[arr]이 컴파일됩니다. *(2 + arr)*(arr + 2)와 같은 주소입니다. 퀴즈 밖에서 그렇게 쓰는 사람은 없지만, 인덱싱이 "진짜로" 포인터 산술인지에 대한 논쟁을 끝내 줍니다.

함수에 배열 넘기기

붕괴 때문에 함수는 절대 배열을 받지 않습니다. 포인터를 받습니다.

이 예제에서 끌어낼 것이 셋 있습니다.

int arr[100]의 크기는 문서일 뿐 그 이상이 아닙니다. 컴파일러는 매개변수를 int *arr로 다시 쓰고, 여러분이 100개를 넘겼는지 절대 확인하지 않습니다.

길이는 따로 전달되어야 합니다. 함수는 주소 하나만 가지고 있고 데이터가 어디까지인지 전혀 모릅니다. 그래서 배열을 받는 거의 모든 C 함수가 개수를 함께 받습니다. memcpy(dst, src, n), fread(ptr, size, count, f), qsort(base, nmemb, size, cmp)처럼요. 문자열이 예외인 것은 자기 끝 표시인 '\0'을 가지고 다니기 때문입니다.

포인터를 넘기는 것은 싸고, 데이터는 공유됩니다. 배열의 사본이 만들어지지 않으므로 함수가 호출자의 원소를 수정할 수 있고, 종종 그것이 바라는 바입니다.

print_all처럼 읽기 전용 매개변수는 const int *로 표시하세요. 의도를 문서화하고, 실수로 쓰는 것을 컴파일러가 잡아 줍니다.

sizeof 함정

모든 C 프로그래머가 한 번은 걸리는 버그입니다. sizeof는 붕괴가 일어나지 않는 두 자리 중 하나이므로, 배열이 선언된 곳에서는 진짜 배열 크기를 주고 배열이 붕괴한 곳에서는 포인터의 크기를 줍니다.

main에서 sizeof data는 40바이트이고 길이는 10으로 계산됩니다. inspect 안에서 sizeof arr은 포인터의 크기 - 64비트 기계에서 8 - 이고 "길이"는 2로 나옵니다. 코드는 똑같아 보이는데 조용히 틀립니다.

따라 나오는 규칙은 이렇습니다. 배열의 길이는 그 배열이 선언된 스코프에서만 계산하고, 거기서부터 함께 전달하세요.

배열의 &는 타입이 다르다

붕괴가 일어나지 않는 나머지 한 자리는 & 연산자입니다. &arr은 첫 원소가 아니라 배열 전체를 가리키는 포인터입니다. 둘은 같은 주소를 담지만, "1을 더한다"가 무엇을 뜻하는지가 다릅니다.

시작 주소는 같고 보폭이 다릅니다. arr은 타입이 int *라 원소 하나씩 나아가고, &arr은 타입이 int (*)[5]라 배열 전체만큼 나아갑니다. 이것이 2차원 배열의 한 행을 넘기는 일을 가능하게 하는 메커니즘이며, 다차원 배열에서 다룹니다.

배열 포인터와 포인터 배열

닮아 보이지만 정반대를 뜻하는 두 선언입니다.

int *p[5];     // int를 가리키는 포인터 5개의 배열
int (*q)[5];   // int 5개짜리 배열을 가리키는 포인터

[]*보다 우선순위가 높으므로 int *p[5]는 "p는 배열이고, 포인터들의"로 파싱됩니다. int (*q)[5]의 괄호는 *가 먼저 묶이게 합니다. "q는 포인터이고, 배열을 가리키는"입니다.

int *p[5]                     int (*q)[5]

 p[0] -> [ int ]               q -> [ int | int | int | int | int ]
 p[1] -> [ int ]                     연속된 5개짜리 덩어리 하나
 p[2] -> [ int ]
 p[3] -> [ int ]
 p[4] -> [ int ]
 서로 떨어진 다섯 개의 주소

포인터 배열 쪽이 흔하고, 이미 써 본 적도 있습니다. char *argv[]명령줄 인자 하나당 하나씩, 문자열을 가리키는 포인터의 배열입니다. 길이가 제각각인 문자열 목록을 공간 낭비 없이 담는 방법이기도 합니다.

(*q)[0]을 보세요. 포인터를 역참조해 배열을 얻고, 그다음 첨자를 붙입니다. 괄호는 여기서도 같은 우선순위 이유로 필요합니다.

배열과 포인터가 진짜로 다른 곳

이 짧은 표를 기억하면 혼란이 사라집니다.

배열 int arr[10]포인터 int *p
정체메모리에 늘어선 int 10개주소를 담은 변수 하나
sizeof40 - 덩어리 전체8 - 포인터만
대입 가능불가: arr = x;는 오류가능: p = x;는 괜찮음
&의 결과int (*)[10]int **
메모리 출처배열과 함께 선언됨가리키는 곳이 어디든

배열 이름은 주소를 담은 변수가 아닙니다. 그것 자체가 저장 공간이고, 주소는 필요할 때 계산됩니다. 대입할 수 없는 이유가 그것입니다.

자주 묻는 질문

C에서 배열과 포인터는 같은 것인가요?

아닙니다. 다만 배열 이름이 거의 모든 식에서 첫 원소를 가리키는 포인터로 붕괴하기 때문에 헷갈리기 쉽습니다. 배열 자체는 크기가 알려진 원소들의 덩어리이고, 포인터는 주소 하나를 담은 변수입니다. sizeof&가 그 차이가 드러나는 두 자리입니다.

함수 안에서 sizeof(arr)가 왜 엉뚱한 답을 주나요?

매개변수가 배열이 아니기 때문입니다. void f(int arr[])는 조용히 void f(int *arr)로 다시 쓰이므로, sizeof(arr)는 원래 배열이 아니라 포인터(대부분의 기계에서 8바이트)를 잽니다. 길이는 별도 매개변수로 넘기세요.

int *p[5]와 int (*p)[5]의 차이는 무엇인가요?

int *p[5]는 int 포인터 5개의 배열입니다. int (*p)[5]는 int 5개짜리 배열을 가리키는 포인터 하나입니다. 괄호가 *를 이름에 먼저 묶습니다. 괄호가 없으면 우선순위가 더 높은 []가 이깁니다.

arr[i]가 정말 *(arr + i)와 같나요?

네. 표준이 a[b]*(a + b)로 정의합니다. 그래서 이상해 보이는 i[arr]도 컴파일되고 동작합니다. 덧셈은 교환법칙이 성립하므로 *(i + arr)은 같은 원소입니다. 실제 코드에 쓰지는 마세요. 다만 규칙을 증명해 줍니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기