ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 배열과 포인터의 차이
    Programming/TIL 2024. 8. 27. 05:09

    C언어에서 배열과 포인터의 차이점을 상세히 설명합니다.
    2차원 배열을 이중 포인터로 전달할 때 발생하는 문제와 배열 붕괴(decay) 현상을 통해, 배열과 포인터가 실제로는 다른 개념임을 설명합니다.

    Intro

    배열을 포인터로 넘겨주기

    가로 x 세로 크기 2차원 퍼즐 게임을 만든다고 해봅시다. 일단 아래와 같이 18줄 정도 코드를 작성했습니다.

    struct puzzle
    {
        int n_rows;
        int n_cols;
        int **board;
    };
    
    void print_puzzle(struct puzzle *p)
    {
        for (int i = 0; i < p->n_rows; i++)
        {
            for (int j = 0; j < p->n_cols; j++)
            {
                printf("%d ", p->board[i][j]);
            }
            printf("\n");
        }
    }

    프로그램은 외부로부터 파일을 읽고, 퍼즐 정보를 struct puzzle에 저장해야 합니다.

    C언어에서 파일을 읽고, 적절한 구조로 파싱하고, 불건전한 입력이나 오류를 잡는 과정은 꽤 번거롭습니다. 퍼즐 데이터 구조가 개발 과정에서 변경될 가능성도 있고요.

    지금은 개발 초기 단계이니 기본 로직을 먼저 구현하고 싶습니다.
    그래서 간단한 테스트용 퍼즐을 먼저 코드 안에서 정의해서 개발을 진행하기로 합니다.

    테스트 퍼즐을 만들고

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

    구조체에 넣으면

    struct puzzle p = {
        .n_rows = 2,
        .n_cols = 3,
        .board = test_puzzle
    };

    포인터 타입이 맞지 않다 는 경고가 뜹니다.

    warning: initialization of ‘int **’ from incompatible pointer type ‘int (*)[3]’ [-Wincompatible-pointer-
    types]
       32 |         .board = test_puzzle
          |                  ^~~~~~~~~~~

    억지로 실행해 버리면 segmentation fault를 만나게 됩니다.

    int ** 는 알겠는데, 포인터 타입이 int (*)[3]라는 건 무슨 뜻일까요?

    어떻게 하면 이 배열을 struct puzzle에 알맞게 넣어줄 수 있을까요?

    이렇게 하면 됩니다.

    int test_puzzle[2][3] = {
        {1, 2, 3},
        {4, 5, 6},
    };
    
    int *row_ptrs[2] = {test_puzzle[0], test_puzzle[1]};
    struct puzzle p = {2, 3, row_ptrs};
    
    print_puzzle(&p);

    이렇게 하면 오류가 나지 않겠다고 직관적으로 생각은 바로 들었지만, 왜 이렇게 하는지 제대로 설명하기는 어려웠습니다. 어딘가 정확하지 않게 잘못 이해하고 있는 부분이 있는 게 분명했습니다.

    이 글은 그래서 알아본 내용을 정리한 글입니다.

    C에서 배열과 포인터는 다르다.

    먼저 배열과 포인터의 차이점을 인지해야 합니다.

    지금까지 저는 배열을 조금 특별한 포인터 정도로 인식하고 있었습니다.

    사실 C에서 배열과 포인터는 다릅니다.
    배열은 배열이고 포인터는 포인터일 뿐입니다.

    대신 배열은 눈치가 매우 빠릅니다. 배열 대신 포인터가 적절한 자리에선 알아서 포인터가 되어 줍니다.

    다시 말해, 대부분 상황에서 배열은 포인터로 암묵적 형 변환(Implicit Conversion)이 됩니다.

    아래 코드에서 int 1이 알아서 float으로 변신하듯이요.

    int one = 1;
    printf("%f\n", one + 1.1);

    배열은 포인터에 대입하면 알아서 포인터로 변합니다. 정확히는 배열 첫 번째 원소를 가리키는 포인터로 변환됩니다.

    즉, int *a_ptr = a; 는 사실 int *a_ptr = &a[0]인 셈이지요

    배열 붕괴와 예외 상황

    그러면 어떤 환경에서 배열이 포인터로 변할까요? 또 어떤 상황에서는 변하지 않을까요?

    먼저 sizeof 연산 시에는 변환이 일어나지 않습니다.

    #define STR(x) #x
    #define PRINT_SIZE(x) printf("Size of %s: %zu\n", STR(x), sizeof(x))
    
    int main(void)
    {
        int  a[4] = {3, 1, 4, 1};
        char b[2] = {'M', 'F'};
        char c[]  = "Spam";
    
        PRINT_SIZE(a); // 16
        PRINT_SIZE(b); // 2
        PRINT_SIZE(c); // 5
    }

    배열을 포인터에 대입하면, 변환이 일어납니다.

    // Implicit conversion (array to pointer)
    int  *a_ptr = a;
    char *b_ptr = b;
    
    PRINT_SIZE(a_ptr); // 8
    PRINT_SIZE(b_ptr); // 8

    변환 이전과 이후에 크기가 달라진 게 보이시나요?

    배열은 담고 있는 원소 수, 크기 정보를 담고 있습니다.

    한편, 포인터는 특정 타입을 point 할 뿐입니다.
    연속으로 나열된 데이터를 포함하는 크기 정보를 담고 있지 않습니다.

    그래서 배열이 포인터로 변환되는 것을 배열 붕괴(decay) 라고도 합니다. 크기 정보를 잃어버리니까요.

    함수의 인자로 배열을 보낼 때 역시 붕괴가 일어납니다.

    void please_print_array_size(int a[4])
    {
        printf("The function will try to print the size of the array\n");
        PRINT_SIZE(a); // 8, Sadly
    }
    
    int main(void)
    {
        int  a[4] = {3, 1, 4, 1};
        char b[2] = {'M', 'F'};
    
        please_print_array_size(a);
    }

    위 함수는 배열을 인자로 받는 것처럼 보입니다.
    인자로 int a[4]라고 크기까지 명시한 데다가, 함수명에는 please라는 단어도 적어놓았습니다.

    하지만 함수는 배열이 아닌, 배열이 붕괴 되어 변한 포인터를 받습니다.

    따라서 PRINT_SIZE(a)는 포인터 크기, 8을 출력합니다.

    다행히도 컴파일러가 이상한 짓을 하는 걸 눈치채고 경고를 띄워줍니다.

    warning: ‘sizeof’ on array function parameter ‘a’ will return size of ‘int *’ [-Wsizeof-array-argument]
        4 | NT_SIZE(x) printf("Size of %s: %zu\n", STR(x), sizeof(x))

    문서에 따르면 배열이 lvalue expression으로 쓰일 경우, 다음 4가지 상황을 제외하고는 배열이 포인터로 붕괴 된다고 합니다.

    • adrress-of-operator(&)에 쓰일 때
    • sizeof operator에 쓰일 때
    • type of 나 typeof unqaul에 쓰일 때
    • 배열을 문자열로 초기화할 때 char arr[] = "hi"

    address of operator &을 취해도 배열이 포인터로 붕괴 되지 않는다는 게 무슨 의미일까요?

    int  a[4] = {3, 1, 4, 1};
    char b[2] = {'M', 'F'};
    
    int **a_ptr = &a; // Warning!

    여기서 &aint **a_ptr에 대입해도 괜찮아 보입니다. 배열의 주소니까 이중 포인터가 적절해 보이죠.

    하지만 &a는 붕괴가 일어나지 않은 배열 a, "(int 3개를 담은 배열의) 포인터" 입니다.

    따라서 대입 시 정확한 문법은 다음과 같습니다.

    int  a[4] = {3, 1, 4, 1};
    char b[2] = {'M', 'F'};
    
    int (*a_ptr)[4] = &a;

    (*a_ptr)[4] 가 무슨 의미인지 알기 위해, 여기서 잠깐 C 포인터 문법을 이해하고 가면 좋겠습니다.

    저도 한동안 봐도 잘 모르다, 한 번 정리하고 나니 더는 어렵지 않더라고요.

    원리는 간단합니다.
    이름과 가까운 순으로, 포인터를 가장 낮은 순위로 해석하면 됩니다.

    개인적으로, 그리고 구조상 영어로 해석하는 편이 편하니, 영어로 설명해 보겠습니다. 영어를 못하시면 정말 죄송합니다.

    int *a[]를 해석해봅시다.
    이름과 가까운 순으로 [], int, 마지막으로 포인터 * 순서로 읽어나가면 됩니다.

    let a be ... array of ... integer ... pointer.
    즉 a는 배열인데, 배열의 원소는 정수 포인터입니다.

    단 여기서 포인터와 함께 괄호가 붙으면 포인터를 먼저 해석해 줍니다.
    int *(*a)[]
    괄호 먼저 *, 이름과 가까운 [], int, 마지막으로 포인터 * 순서입니다.

    let a be ... pointer to ... array of ... integer ... pointer.
    즉 a는 포인터인데 배열을 가리키는 포인터이고, 배열의 원소는 정수 포인터입니다.

    함수 포인터도 동일합니다. () 가 있으면 함수입니다.
    int *(*a)()
    괄호 * -> () -> int -> 마지막 *

    let a be ... pointer to ... function returning ... integer ... pointer.
    a는 포인터인데 함수를 가리키는 포인터이고, 함수의 반환 값은 정수 포인터입니다.

    최종으로 더 복잡한 예시를 보겠습니다.
    int *(*(*a)())[]
    * -> () -> * -> [] -> int -> *

    let a be .. pointer to ... function returning ... pointer to ... array of ... integer ... pointer.
    a는 포인터인데 함수를 가리키는 포인터입니다.
    함수의 반환 값은 포인터인데 배열을 가리키는 포인터이고, 배열의 원소는 정수 포인터입니다.

    이제 int (*a_ptr)[4]는 간단합니다.
    * -> [4] -> int. 크기가 4인 정수 배열을 가리키는 포인터인거죠.

    배열은 크기를 가지니까요.

    다차원 배열과 이중 포인터

    배열과 포인터의 차이가 무엇인지, 이해가 조금 잡혔습니다.

    이제 처음 제시했던 포인터 타입이 맞지 않았던 상황을 다시 돌아보겠습니다.

    int test_puzzle[2][3] = {
        {1, 2, 3},
        {4, 5, 6}
    };
    
    int **board = test_puzzle;

    이때 아래와 같은 경고가 났었죠.

    warning: initialization of ‘int **’ from incompatible pointer type ‘int (*)[3]’ [-Wincompatible-pointer-
    types]
       32 |         .board = test_puzzle
          |                  ^~~~~~~~~~~

    int *a_ptr = a; 이 사실은
    int *a_ptr = &a[0];와 같은 표현이라는 내용 기억 하시나요?

    같은 논리로 문제의 대입문을 int **board = &test_puzzle[0]로 바꾸어 해석이 가능합니다.

    여기서 test_puzzle[0]는 퍼즐 첫 행, {1, 2, 3} 배열이고, & 연산으로는 배열이 붕괴하지 않습니다.

    따라서 &test_puzzle[0]는 배열의 포인터, 즉 pointer to array of 3 integers int (*)[3] 이 됩니다.
    int ** 와는 다르죠.

    int (*)[3]은 포인터가 가리키는 주소에 배열이 있을 것을 기대합니다.
    다차원 배열의 그림

    int **는 포인터가 있을 거라고 기대합니다.
    이중 포인터의 그림

    "배열 포인터"를 억지로 ""이중 포인터""로 만들면, 배열 내용을 포인터로 인식하기 시작합니다.

    표현식 p[0][0]p[0]에 있는 데이터, 정수 1과 2를 묶어서 포인터로 인식합니다.
    그래서 이에 해당하는 주소 0x20000001에 있는 정수 값을 가져오려고 하죠.

    마침, 운이 좋게도 이는 허용되지 않은 메모리 영역입니다. 그래서 Segmentation Fault가 일어납니다.

    Segmentation Fault

    운이 안 좋으면 이런 일도 일어날 수 있죠.

    int my_precious_money = 1000;
    int main(void)
    {
        int test_puzzle[2][3] = {0, };
        randomize_puzzle(test_puzzle);
    
        printf("The account balance is %d\n", my_precious_money);
        printf("puzzle[0][0]: %d, puzzle[0][1]: %d\n", test_puzzle[0][0], test_puzzle[0][1]);
    
        int **puzzle_p = (int **) test_puzzle;
        puzzle_p[0][0] = 1;
        printf("puzzle[0][0]: %d, puzzle[0][1]: %d\n", test_puzzle[0][0], test_puzzle[0][1]);
    
        printf("The account balance is %d\n", my_precious_money);
    }
    The account balance is 1000
    puzzle[0][0]: 1903570960, puzzle[0][1]: 22059
    puzzle[0][0]: 1903570960, puzzle[0][1]: 22059
    The account balance is 1

    test_puzzle[0][0]1903570960, test_puzzle[0][1]22059가 들어있었습니다.

    포인터로 인식하면 정말 놀랍게도 제 계좌가 담긴 주소를 가리키는 주소가 돼버립니다. 이전과 달리 이 주소는 허용되지 않은 메모리 영역이 아닙니다. Segmentation Fault도 일어나지 않고, 아무런 경고도 없이 제 계좌 잔고만 날아가 버립니다.

    잔고를 지키려면, 이중 포인터는 포인터를 원하기 때문에, 포인터를 가리키는 포인터를 전해줘야 합니다.

    그래서 아래와 같은 코드가 필요한 거죠.

    포인터(test_puzzle[0], &test_puzzle[0][0])를 가리키는 포인터(row_ptrs, &row_ptrs[0])를 전해주는 방법입니다.

    int test_puzzle[2][3] = {
        {1, 2, 3},
        {4, 5, 6},
    };
    
    int *row_ptrs[2] = {test_puzzle[0], test_puzzle[1]};
    struct puzzle p = {2, 3, row_ptrs};
    
    print_puzzle(&p);

    근데 정말 이렇게까지 해야만 하는 걸까요?
    C언어에서의 다차원 데이터 할당 방법 포스트에서 유사한 내용을 더 다뤄 보겠습니다.

    Outro

    이번 내용은 뭔가 제가 잘 안다고 생각 했는데 정확히 모르는 내용을 정리해서 적어보았습니다.
    이제 헷갈릴 일은 없겠네요.

    도움이 되었길 바랍니다.

    댓글