프로그래밍

포인터와 배열·함수

배열 이름, 포인터의 덧셈, 함수에 전달하는 주소.
셋은 하나의 규칙으로 이어집니다.

학습 목표

  1. 배열 이름이 식에서 첫 요소의 주소로 바뀐다는 것을 알고 포인터에 저장할 수 있다.
  2. 포인터에 1을 더하면 가리키는 자료형의 크기만큼 주소가 커진다는 것을 설명할 수 있다.
  3. 함수에 변수의 주소를 전달해 호출한 쪽의 변수를 바꿀 수 있다.
  4. 배열을 함수에 전달할 때 무엇이 전달되고 무엇이 전달되지 않는지 설명할 수 있다.

배열 이름의 정체

「배열 이름에는 &를 붙이지 않는다」의 이유입니다.

문자열을 입력받을 때는 scanf("%19s", name)이라고 씁니다. 변수에는 &를 붙이는데 배열 이름에는 붙이지 않습니다. 배열 이름이 이미 주소를 나타내기 때문입니다. 주소를 출력해 확인해 봅니다.

#include <stdio.h>

int main(void) {
    int score[5] = {88, 95, 74, 61, 90};
    int *p = score;                       // &를 붙이지 않고 그대로 대입한다

    printf("score     %p\n", (void *) score);
    printf("&score[0] %p\n", (void *) &score[0]);
    printf("p         %p\n", (void *) p);
    // 셋 다 같은 주소를 출력한다

    printf("*p = %d\n", *p);              // 88 — 첫 요소의 값

    return 0;
}
int score[5];    int *p = score; 88 [0] 95 [1] 74 [2] 61 [3] 90 [4] score  =  &score[0]  =  p 셋 다 첫 요소의 주소를 나타낸다

셋이 같은 주소를 출력합니다

score·&score[0]·p가 모두 같은 주소를 출력합니다. 배열 이름을 식에 쓰면 그 배열의 첫 요소의 주소로 바뀝니다. 이미 주소를 나타내고 있으니 &를 앞에 더 붙일 필요가 없었던 것입니다.

그래서 int *p = score;처럼 & 없이 바로 대입할 수 있습니다. 포인터에는 주소를 저장하는데, 식에 쓴 배열 이름은 첫 요소의 주소로 바뀌기 때문입니다. 대입한 뒤에 *p로 역참조하면 첫 요소의 값 88을 얻습니다.

그렇다고 배열 이름이 포인터인 것은 아닙니다

포인터는 주소를 저장하는 변수라서 저장된 주소를 바꿀 수 있습니다. 그런데 score = &n;이라고 쓰면 컴파일 오류가 납니다.

배열 이름은 요소 다섯 개로 이루어진 배열 전체의 이름이지, 첫 요소의 주소를 따로 저장해 둔 변수가 아닙니다. 식에 쓰일 때 첫 요소의 주소로 바뀔 뿐 그 주소를 저장한 메모리 공간이 없으니 다른 곳을 가리키게 바꿀 수 없습니다. 같은 주소를 나타낸다는 것과 같은 것이라는 말은 다릅니다 — p는 다른 곳을 가리키게 바꿀 수 있지만 score는 그럴 수 없습니다. sizeof(score)가 포인터 하나의 크기가 아니라 배열 전체의 크기인 것도 그래서입니다. sizeof의 피연산자로 쓰면 배열 이름이 주소로 바뀌지 않습니다.

스스로 확인

int *p = score; 다음에 p = p + 1;은 되는데 score = score + 1;은 왜 안 될까요?

1을 더하면 다음 요소

포인터의 덧셈은 정수의 덧셈과 다릅니다.

포인터에 저장된 주소는 바이트의 번호이니 1을 더하면 주소가 1 커질 것 같습니다. 그런데 그렇지 않습니다.

#include <stdio.h>

int main(void) {
    int score[5] = {88, 95, 74, 61, 90};
    char word[5] = "abcd";

    int *pi = score;
    char *pc = word;

    printf("int  : %p %p %p\n", (void *) pi, (void *) (pi + 1), (void *) (pi + 2));
    printf("char : %p %p %p\n", (void *) pc, (void *) (pc + 1), (void *) (pc + 2));
    // 1을 더할 때마다 int *는 4, char *는 1씩 주소가 커진다

    return 0;
}
같은 열두 바이트인데 한 걸음의 크기가 다르다 p p+1 p+2 p p+1 p+2 int * 한 걸음이 4바이트 — 주소가 4씩 는다 char * 한 걸음이 1바이트 — 주소가 1씩 는다 둘 다 「다음 요소」를 가리킨다

int *는 4바이트씩, char *는 1바이트씩 이동합니다

실행 결과에서 int 쪽은 주소가 4씩 커지고 char 쪽은 1씩 커집니다. 포인터에 1을 더한다는 것은 「가리키는 자료형 하나의 크기만큼 다음으로」라는 뜻이기 때문입니다. 이처럼 포인터에 정수를 더하거나 빼는 계산을 포인터 연산(pointer arithmetic)이라고 합니다.

포인터를 선언할 때 쓴 자료형은 역참조할 때 「몇 바이트를 읽을지」를 정합니다. 같은 자료형이 1을 더할 때 「몇 바이트를 이동할지」도 함께 정합니다. int가 4바이트인 환경에서는 int *에 1을 더하면 주소가 4 커집니다.

이 규칙 덕분에 요소 하나가 몇 바이트인지 몰라도 다음 요소로 이동할 수 있습니다. 배열의 요소를 가리키는 포인터라면 자료형이 무엇이든 + 1이 다음 요소를 가리킵니다.

배열 이름이 첫 요소의 주소로 바뀌고 + 1이 다음 요소를 가리킨다면, 대괄호 없이도 요소에 접근할 수 있습니다.

for (int i = 0; i < 5; i = i + 1) {
    printf("%d ", score[i]);          // 익숙한 대괄호
}
printf("\n");

for (int i = 0; i < 5; i = i + 1) {
    printf("%d ", *(score + i));      // 주소에 i를 더해 역참조하기
}
printf("\n");

score[i]와 *(score + i)는 같은 뜻입니다

두 반복문이 똑같은 결과를 출력합니다. 우연이 아니라 대괄호가 이 계산을 줄여 쓴 표기이기 때문입니다. score[3]은 「첫 요소에서 세 요소만큼 이동한 곳의 값」이고, 인덱스가 첫 요소에서 떨어진 거리라서 0부터 시작하는 것입니다.

대괄호를 계속 쓰면 됩니다. 읽기 쉽고 실수할 여지가 적습니다. 다만 대괄호가 어떤 계산을 줄여 쓴 것인지 알아 두면 배열과 포인터가 왜 그렇게 자주 함께 쓰이는지 이해할 수 있습니다.

스스로 확인

double 배열을 가리키는 포인터에 1을 더하면 주소가 얼마나 커질까요?

함수에 주소를 전달하면

함수가 호출한 쪽의 변수를 바꾸는 방법입니다.

함수에 전달되는 것은 값의 사본이라, 변수의 값만 전달해서는 함수가 호출한 쪽의 변수를 바꿀 수 없습니다. 값 대신 주소를 전달하면 이야기가 달라집니다.

#include <stdio.h>

void change(int *p) {              // 값이 아니라 주소를 전달받는다
    *p = 100;                      // 전달받은 주소를 역참조해 값을 저장한다
}

int main(void) {
    int n = 7;

    change(&n);                    // n의 주소를 전달한다

    printf("n = %d\n", n);         // 100 — 이번에는 바뀌었다

    return 0;
}

사본이 전달되는 것은 그대로입니다

규칙이 바뀐 것이 아닙니다. 이번에도 전달된 것은 사본입니다. 다만 복사된 것이 「값」이 아니라 「주소」입니다. 이 방식을 흔히 「참조에 의한 전달(call by reference)」이라고 부르지만, C의 인자 전달은 언제나 값에 의한 전달이고 여기서는 그 값이 주소일 뿐입니다.

쪽지에 적힌 주소를 베껴 건네면, 받은 쪽은 쪽지가 달라도 같은 집을 찾아갑니다. 그래서 *p = 100;이 호출한 쪽의 n을 바꿉니다. 값만 복사해 전달하면 원본에 접근할 방법이 없지만, 주소를 알려 주면 방법이 생깁니다.

이제 맞바꾸기를 다시 해 봅니다. 논리는 그대로 두고 매개변수를 포인터로 바꾼 뒤, 함수 안에서는 *로 역참조합니다.

#include <stdio.h>

void swap(int *a, int *b) {
    int temp = *a;                 // a가 가리키는 곳의 값을 temp에 저장해 둔다
    *a = *b;
    *b = temp;
}

int main(void) {
    int x = 1;
    int y = 2;

    swap(&x, &y);

    printf("x = %d, y = %d\n", x, y);   // 2 1 — 호출한 쪽의 변수까지 바뀌었다

    return 0;
}
swap(x, y) — 값을 전달한다 x = 1 y = 2 a = 2 b = 1 사본 그대로 swap(&x, &y) — 주소를 전달한다 x = 2 y = 1 a b 전달받은 주소를 따라가 호출한 쪽의 값을 바꾼다.

이번에는 호출한 쪽의 변수까지 바뀝니다

swap(&x, &y)로 두 변수의 주소를 전달했고, 함수 안에서는 *a·*b로 그 주소에 있는 값을 바꾸었습니다. 함수 안에서만 바뀌고 끝나던 것이 호출한 쪽까지 이어집니다.

temp에 저장할 때 *a라고 쓴 것을 눈여겨보세요. a는 주소이고 *a가 값입니다. int temp = a;라고 쓰면 값이 아니라 주소를 int 변수에 저장하려는 것이 되어 컴파일러가 경고나 오류를 냅니다.

scanf에 &를 붙이는 이유가 설명됩니다

scanf도 누군가 만들어 둔 함수입니다. 이 함수가 하는 일은 입력받은 값을 호출한 쪽의 변수에 저장하는 것인데, 변수의 값만 전달해서는 그 변수를 바꿀 방법이 없습니다.

그래서 &를 붙여 변수의 주소를 전달해야 했던 것입니다. printf에 &를 붙이지 않는 것도 같은 이유로 설명됩니다 — 출력만 하는 함수는 변수를 바꿀 일이 없기 때문입니다.

스스로 확인

swap(x, y)처럼 &를 빠뜨리면 어떻게 될까요? 컴파일러가 어떤 메시지를 낼지도 생각해 보세요.

배열을 전달할 때

함수가 전달받는 것은 배열 전체가 아닙니다.

배열의 크기(요소의 개수)는 sizeof(배열) / sizeof(요소 하나)로 구할 수 있지만, 배열을 함수에 전달한 뒤에는 이 계산이 맞지 않습니다. 함수 안에서 sizeof를 출력해 보면 이유를 알 수 있습니다.

#include <stdio.h>

void look(int data[], int len) {
    printf("함수 안에서 sizeof(data) = %d\n", (int) sizeof(data));

    data[0] = 0;                   // 전달받은 배열의 요소를 바꾼다
    printf("len으로 전달받은 요소의 개수 = %d\n", len);
}

int main(void) {
    int score[5] = {88, 95, 74, 61, 90};

    printf("함수 밖에서 sizeof(score) = %d\n", (int) sizeof(score));

    look(score, 5);

    printf("score[0] = %d\n", score[0]);   // 0 — 함수 안에서 바꾼 것이 남았다

    return 0;
}

같은 배열인데 함수 밖과 안의 sizeof가 다릅니다

밖에서는 배열 전체의 크기가 나오고, 안에서는 포인터 하나의 크기가 나옵니다. 배열 전체가 복사되어 전달된 것이 아니라 첫 요소의 주소만 전달되었기 때문입니다. 매개변수를 int data[]로 선언해도 컴파일러는 이것을 int *data로 다루므로 함수가 실제로 전달받은 것은 포인터 하나입니다.

gcc 같은 컴파일러는 이 부분에서 경고를 냅니다 — 「배열 매개변수에 sizeof를 쓰면 포인터의 크기가 나온다」고 알려 줍니다. 실수를 막아 주는 경고이니 무시하지 않습니다.

그래서 배열을 전달받는 함수에는 배열의 크기를 따로 전달해야 합니다. look이 매개변수 len을 함께 받는 이유입니다.

대신 함수가 배열의 요소를 바꿀 수 있습니다

data[0] = 0;이 호출한 쪽의 score[0]까지 바꿔 놓았습니다. 주소가 전달되었으니 같은 요소에 접근하는 것입니다.

정수 하나를 전달할 때와 배열을 전달할 때가 다르게 보이는 이유가 여기 있습니다. 전달 규칙이 다른 것이 아니라, 배열은 이름을 인자로 쓰는 순간 첫 요소의 주소로 바뀌어 처음부터 「주소를 전달하는 방식」이 되는 것입니다.

결과를 여러 개 돌려받을 수 있습니다

return으로는 값을 하나만 반환할 수 있습니다. 그런데 결과를 저장할 변수의 주소를 함수에 전달하면 개수 제한이 사라집니다. 아래 함수는 가장 작은 값과 가장 큰 값을 한 번에 구해 줍니다.

#include <stdio.h>

void min_max(int data[], int len, int *small, int *big) {
    *small = data[0];
    *big = data[0];

    for (int i = 1; i < len; i = i + 1) {
        if (data[i] < *small) {
            *small = data[i];
        }
        if (data[i] > *big) {
            *big = data[i];
        }
    }
}

int main(void) {
    int score[5] = {88, 95, 74, 61, 90};
    int lo;
    int hi;

    min_max(score, 5, &lo, &hi);   // 결과를 저장할 두 변수의 주소를 함께 전달한다

    printf("가장 작은 값 %d, 가장 큰 값 %d\n", lo, hi);   // 61, 95

    return 0;
}

min_max는 반환형이 void라서 아무 값도 반환하지 않습니다. 대신 &lo·&hi로 전달받은 주소에 결과를 직접 저장합니다. 호출한 쪽에서 보면 「결과를 여기에 넣어 줘」라고 빈 그릇을 건네는 셈입니다.

무엇이 바뀌는지 호출하는 줄에서는 보이지 않습니다

주소를 전달하면 함수가 호출한 쪽의 값을 마음대로 바꿀 수 있습니다. 편리하지만, 호출한 쪽에서는 무엇이 바뀔지 코드만 봐서는 알기 어렵습니다. min_max(score, 5, &lo, &hi);라는 한 줄이 lo와 hi를 바꾼다는 것은 함수 안을 봐야 압니다.

그래서 함수 이름을 잘 지어 두는 것이 더욱 중요해집니다. 바꿀 값이 하나뿐이라면 return으로 반환하는 편이 읽기 쉽습니다 — 포인터를 쓸 수 있다고 늘 쓰는 것이 낫지는 않습니다.

스스로 확인

min_max를 호출하기 전에 lo와 hi를 초기화하지 않았는데도 괜찮았습니다. 왜일까요?

핵심 정리

배열 이름은 식에서 첫 요소의 주소

식에 쓰이면(sizeof의 피연산자일 때 등은 빼고) 첫 요소의 주소로 바뀌므로 &를 붙이지 않는다. 다만 주소를 저장한 변수가 아니라서 가리킬 곳을 바꿀 수는 없다 — 포인터와 같은 주소를 나타내도 포인터와 같은 것은 아니다.

1을 더하면 다음 요소

포인터에 1을 더하면 주소가 1 커지는 것이 아니라 가리키는 자료형의 크기만큼 커진다. 그래서 a[i]는 *(a + i)와 같은 뜻이다.

주소를 전달하면 원본을 바꿀 수 있다

전달되는 것이 사본이라는 규칙은 그대로다. 복사된 것이 값이 아니라 주소일 뿐이고, 그 주소를 역참조하면 호출한 쪽의 변수를 바꿀 수 있다.

배열 전체가 복사되지는 않는다

첫 요소의 주소만 전달되므로 배열의 크기를 따로 전달해야 하고, 함수 안의 sizeof는 포인터의 크기를 낸다. 대신 함수가 배열의 요소를 바꿀 수 있다.

확인 퀴즈

배운 내용을 확인해 봅시다.

1. int a[5];일 때 a와 같은 값을 내는 것은?

2. int *p가 어떤 곳을 가리킬 때 p + 1이 가리키는 곳은?

3. 배열을 함수에 전달했을 때 알맞은 설명은?