|

C 언어 포인터와 배열 차이: decay, sizeof, 함수 인자까지 한 번에 이해하기

C 언어 포인터와 배열 차이를 설명하는 대표 이미지
decay, sizeof, 함수 인자 규칙까지 한 흐름으로 정리한다

C 언어 포인터와 배열 차이는 문법 표면만 보면 자꾸 같은 것처럼 보입니다. 하지만 둘을 같은 것으로 외우면 sizeof, 함수 인자, 주소 연산에서 거의 반드시 막히게 됩니다.

이 글은 정의부터 밀어넣지 않습니다. 먼저 메모리 그림으로 왜 헷갈리는지 보고, 그 다음에 array-to-pointer decay, sizeof, 함수 매개변수 규칙을 정리하겠습니다.


먼저 그림으로 보면 왜 다른가

int arr[4] = {10, 20, 30, 40};
int *ptr = arr;

대략 메모리에서는 이렇게 생각하면 됩니다. arr는 원소 4개를 담는 실제 저장공간이고, ptr는 그 시작 위치를 가리킬 수 있는 포인터 변수 하나입니다.

arr 자체
+----+----+----+----+
| 10 | 20 | 30 | 40 |
+----+----+----+----+
^
arr

ptr 자체
+------------------+
| arr[0]의 주소값   |
+------------------+
^
ptr

겉으로는 arr[2]도 되고 ptr[2]도 되니까 같아 보입니다. 하지만 arr[2]는 배열 시작 위치에서 두 칸 이동하고, ptr[2]는 포인터가 들고 있는 주소에서 두 칸 이동합니다. 결과가 같을 수는 있어도 정체성은 다릅니다.


C 언어 포인터와 배열 차이가 자꾸 흐려지는 이유

핵심은 배열 이름이 대부분의 표현식에서 첫 번째 원소를 가리키는 포인터로 바뀐다는 점입니다. 이걸 보통 array-to-pointer decay라고 부릅니다.

즉, arr는 선언 자체로는 배열이지만 식 안에서 쓰이면 많은 경우 &arr[0]처럼 동작합니다. 그래서 아래 코드는 자연스럽게 읽힙니다.

int arr[4] = {10, 20, 30, 40};
int *ptr = arr;

printf("%d\n", arr[1]);
printf("%d\n", ptr[1]);

둘 다 20을 읽습니다. 하지만 여기서 바로 배열은 포인터라고 외우면 안 됩니다. 더 정확한 표현은 배열은 포인터가 아니지만, 많은 표현식에서 첫 원소를 가리키는 포인터로 변환된다입니다.

decay가 일어나지 않는 예외

  • sizeof arr
  • &arr
  • 문자 배열 초기화에서의 문자열 리터럴

배열 이름이 항상 포인터처럼 바뀌는 것은 아닙니다. 이 예외를 모르면 sizeof와 주소 연산에서 계속 헷갈리게 됩니다.


sizeof 차이가 가장 직관적인 이유

#include <stdio.h>

int main(void)
{
    int arr[4] = {10, 20, 30, 40};
    int *ptr = arr;

    printf("sizeof(arr) = %zu\n", sizeof(arr));
    printf("sizeof(ptr) = %zu\n", sizeof(ptr));
    printf("원소 개수 = %zu\n", sizeof(arr) / sizeof(arr[0]));

    return 0;
}

이 코드에서 sizeof(arr)는 배열 전체 크기입니다. int가 4바이트인 환경이라면 보통 16이 나옵니다. 반면 sizeof(ptr)는 포인터 변수 하나의 크기입니다. 플랫폼마다 다를 수 있지만 64비트 환경에서는 자주 8이 나옵니다.

배열은 원소들의 실제 묶음이고, 포인터는 주소 하나를 담는 별도 객체라는 사실이 sizeof에서 가장 선명하게 드러납니다.


arr와 &arr은 왜 다를까

출력해 보면 주소값이 비슷해 보여서 더 혼란스럽지만, 핵심은 값이 아니라 타입입니다.

#include <stdio.h>

int main(void)
{
    int arr[4] = {10, 20, 30, 40};

    printf("arr     = %p\n", (void *)arr);
    printf("&arr    = %p\n", (void *)&arr);
    printf("arr + 1 = %p\n", (void *)(arr + 1));
    printf("&arr+1  = %p\n", (void *)(&arr + 1));

    return 0;
}
  • arr는 식에서 보통 int *로 변환됩니다
  • &amp;arr는 int (*)[4] 타입입니다

그래서 arr + 1은 다음 int 한 칸으로 가고, &arr + 1은 배열 전체 하나를 건너뜁니다. 시작 주소가 비슷해 보여도 포인터 산술의 의미는 완전히 다릅니다.

int arr[4]
+----+----+----+----+
| 10 | 20 | 30 | 40 |
+----+----+----+----+

arr + 1   -> 두 번째 칸
&arr + 1  -> 배열 전체 다음 위치

함수 인자에서 배열이 포인터처럼 보이는 이유

여기가 가장 유명한 함정입니다. 겉보기에는 배열을 받는 함수처럼 보여도, 함수 매개변수 목록의 int arr[]는 실제로 int *arr로 조정됩니다.

void print_array(int arr[]);
void print_array(int *arr);

위 두 선언은 같은 함수 선언입니다. 그래서 함수 안에서 sizeof(arr)를 하면 배열 전체 크기가 아니라 포인터 크기가 나옵니다.

#include <stdio.h>

void print_size(int arr[])
{
    printf("함수 안 sizeof(arr) = %zu\n", sizeof(arr));
}

int main(void)
{
    int arr[4] = {10, 20, 30, 40};

    printf("함수 밖 sizeof(arr) = %zu\n", sizeof(arr));
    print_size(arr);

    return 0;
}
함수 밖 sizeof(arr) = 16
함수 안 sizeof(arr) = 8

그래서 배열 길이가 필요하면 길이도 함께 넘겨야 합니다. 함수는 배열 전체를 전달받는 것이 아니라, 대부분 첫 원소를 가리키는 포인터를 전달받는다고 이해하면 실수가 크게 줄어듭니다.

void print_array(int arr[], size_t len)
{
    for (size_t i = 0; i < len; ++i)
    {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

배열 이름과 포인터 변수의 정체성 차이

int arr[4] = {10, 20, 30, 40};
int *ptr = arr;

ptr = ptr + 1;   // 가능
// arr = arr + 1; // 불가능

포인터 변수 ptr는 다른 위치를 가리키도록 바꿀 수 있습니다. 하지만 배열 이름 arr는 재대입 대상이 아닙니다. 이 감각도 둘이 같은 존재가 아니라는 강한 힌트입니다.


실무에서 이렇게 기억하면 덜 헷갈린다

  1. 배열은 원소들을 담는 실제 저장공간이다.
  2. 포인터는 주소를 담는 별도 변수다.
  3. 배열 이름은 많은 식에서 첫 원소를 가리키는 포인터로 변환된다.
  4. 하지만 sizeof, &amp;, 함수 매개변수 규칙에서는 차이가 확실히 드러난다.
  5. 배열 길이는 함수 안에서 자동으로 알 수 없으니 별도로 전달한다.

자주 하는 오해 4가지

  1. 배열은 포인터다.
  2. sizeof(arr)와 sizeof(ptr)는 비슷할 것이다.
  3. int arr[]로 받으면 함수가 배열 크기도 안다.
  4. arr와 &amp;arr은 완전히 같은 것이다.

한 번에 정리

C 언어 포인터와 배열 차이는 같으냐 다르냐의 문제가 아니라, 언제 비슷하게 동작하고 언제 정체성이 드러나느냐의 문제에 가깝습니다. 배열은 배열이고 포인터는 포인터입니다. 다만 배열 이름이 많은 표현식에서 포인터로 변환되기 때문에 둘이 자꾸 같은 것처럼 보일 뿐입니다.

관련해서 배열 자체에 더 익숙해지고 싶다면 원형 배열 문제 풀이, 구간 계산 감각까지 넓히고 싶다면 누적합과 차분 배열 글도 함께 읽어보면 좋습니다. 공식 성격의 설명은 cppreference의 배열 문서변환 문서를 참고하면 됩니다.

함께보면 좋은 글