C (8) - 다차원 배열, 포인터 배열, 응용 포인터

다차원 배열

 

2차원 배열

 

형태가 같은 배열이 여러 개 필요한 경우 이들을 모아 배열을 만들 수 있습니다. 이 배열을 2차원 배열이라고 합니다. 즉, 2차원 배열은 1차원 배열을 요소로 갖는 배열입니다. 

 

int arr[3][4];

예를 들어 위와 같이 2차원 배열을 선언한다면 4는 1차원 배열 요소의 개수, 3은 1차원 배열을 요소로 가지는 2차원 배열 요소의 개수입니다. 즉, 2차원 배열은 1차원 배열을 여러 개 갖는 구조이므로 논리적으로 행과 열로 생각하면 됩니다.

위 2차원 배열은 논리적으로 3행 4열의 행렬과 같은 구조를 가집니다.
이때 각 행은 1차원 배열의 형태이며, 이를 2차원 배열의 "부분 배열"이라고 부릅니다.

부분 배열은 2차원 배열의 하나의 요소로 간주되며, 배열명과 첨자를 사용해 접근합니다.

 

예를 들어, 위 arr 배열은 arr[0], arr[1], arr[2] 라는 부분배열을 가집니다.

이 각각은 1차원 배열로, 각 행을 독립적으로 다룰 때 배열명처럼 사용할 수 있습니다.

int arr[3][4];

// arr == &arr[0]
// arr[0] == &arr[0][0]

 

또한, 2차원 배열의 행의 수는 전체 배열의 크기를 부분 배열 하나의 크기로 나누어 구할 수 있습니다.

count = sizeof(arr) / sizeof(arr[0])

 

 

 

메모리에서의 2차원 배열

 

메모리에서의 2차원 배열은 실은 1차원 배열과 같습니다. 2차원 배열은 논리적으로는 행렬의 구조를 가지고 있지만, 물리적으로는 1차원 배열의 형태로 메모리에 할당됩니다. 할당되는 방법은 한 행씩 차례로 할당되어 다음과 같이 메모리에 연속적으로 할당됩니다.

 

2차원 배열 초기화

 

2차원 배열을 함수 내에서 선언하면 자동 변수와 같이 메모리에 남아 있는 쓰레기 값을 지니게 됩니다. 따라서 배열의 저장 공간에 특정 값을 저장할 필요가 있을 때는 선언과 동시에 초기화해야 합니다. 2차원 배열의 초기화는 중괄호 쌍을 2개 써서 행 부분도 표시합니다.

 

전체 초기화

int num[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12},
};

 

일부 초기값 생략

기본 배열과 동일하게 값을 차례로 저장하고 남는 요소는 0으로 자동 초기화됩니다.

int num[3][4] = {{1}, {5,6}, {9, 10, 11}};

// {1, 0, 0, 0} {5, 6, 0, 0}, {9, 10, 11, 0}

 

행의 수 생략

행의 수를 생략하면 컴파일러는 행의 중괄호의 개수로 행의 수를 결정해 저장 공간을 할당합니다.

int num[][4] = {{1}, {5,6}, {9, 10, 11}};

// {1, 0, 0, 0} {5, 6, 0, 0}, {9, 10, 11, 0}

 

1차원 배열처럼 초기화

2차원 배열도 물리적으로는 1차원 배열의 나열이기 때문에, 1차원 배열을 초기화하는 방식으로도 할 수 있습니다.

int num[3][4] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
// {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}

int num[3][4] = {1, 2, 3, 4, 5, 6}
// {1, 2, 3, 4}, {5, 6, 0, 0}, {0, 0, 0, 0}

int num[3][4] = {0}
// {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}

 

2차원 char 배열

 

하나의 문자열을 저장하기 위해서는 1차원 char 배열이 필요하고 여러 개의 문자열을 저장하려면 1차원 char 배열이 여러 개 필요합니다. 이때 2차원 char 배열을 사용합니다.

 

2차원 char 배열을 초기화하는 방법은 2가지가 있습니다.

첫 번째는 다른 2차원 배열처럼 배열 요소를 하나씩 초기화하는 방법이고, 두 번째는 각 행의 단위를 문자열로 초기화하는 방법입니다.

이때 남은 저장 공간들은 널 문자로 채워집니다.

#include <stdio.h>

int main(void)
{
    char animal1[3][10] = {
        {'d', 'o', 'g', '\0'},
        {'t', 'i', 'g', 'e', 'r', '\0'},
        {'c', 'a', 't', '\0'}
    }

    char animal2[][10] = {"dog", "tiger", "cat"};

    return 0;
}

두 번째 방법으로 초기화하는 경우 행의 수를 생략할 수 있습니다.

 

3차원 배열

 

1차원 배열에서 2차원 배열을 만드는 과정을 이해하면 3차원 이상의 배열을 만드는 것도 어렵지 않습니다. 3차원 배열은 2차원 배열을 요소로 가지며 3개의 첨자를 사용해 선언합니다. 이는 2차원 배열이 1차원 배열을 요소로 가지는 것과 같은 이치입니다.

2차원 배열에서 각 행은 1차원 배열로서 2차원 배열을 구성하는 하나의 부분배열이 되는데, 3차원 배열에서는 2차원 배열이 부분배열이 되며 그 부분배열은 다시 1차원 부분배열들로 구성됩니다.

 

2차원 배열을 행렬의 구조로 본다면 3차원 배열은 면, 행, 열의 구조가 됩니다.

 

또한 3차원 배열의 초기화는 면을 구분하는 중괄호가 추가되어 중괄호를 3쌍 사용하며, 방식은 2차원 배열과 같습니다.

 

 

포인터 배열

 

포인터는 주소를 저장하는 특별한 용도로 쓰이지만, 일반 변수처럼 메모리에 저장 공간을 갖는 변수입니다. 따라서 같은 포인터가 많이 필요한 경우 배열을 사용하는 것이 좋습니다. 포인터 배열은 같은 자료형의 포인터를 모아 만든 배열입니다.

 

포인터 배열 선언

 

포인터 배열의 선언 방식은 일반적인 배열 선언 방식과 같습니다. 단, 각 배열 요소의 자료형이 포인터형이므로 배열명 앞에 *을 붙입니다.

int *parr1[3];
char *parr2[5];

 

포인터 배열 초기화

 

int a = 10, b = 20, c = 30;
int *parr1[3] = {&a, &b, &c};

char *parr2[5] = {"dog", "tiger", "cat"};

char 포인터 배열은 char 2차원 배열과 초기화 방법이 같지만 분명한 차이가 있습니다.

포인터 배열의 초기화는 문자열의 시작 주소만 배열요소에 저장하며, 2차원 char 배열의 초기화는 문자열 자체를 배열의 공간에 저장합니다.

 

2차원 배열처럼 활용

 

포인터 배열은 첨자를 하나 사용하는 1차원 배열입니다. 그러나 2차원 배열로 활용하는 방법도 있습니다. 1차원 배열을 포인터 배열로 연결하면 2차원 배열처럼 쓸 수 있습니다.

char arr1[20] = "dog";
char arr2[20] = "tiger";
char arr3[20] = "cat";
char *parr[3] = {arr1, arr2, arr3};

 

포인터 배열 parr은 1차원 배열임에도 불구하고 아래와 같이 2차원 배열의 요소를 출력하듯이 사용할 수 있습니다. 

printf("%c", parr[2][0]);

 

포인터는 자신이 가리키는 변수의 형태를 알고 있으므로 정수 연산을 통해 원하는 위치를 찾아갈 수 있기 때문입니다.

 

 

응용 포인터

 

이중 포인터

 

포인터도 메모리에 저장 공간을 갖는 하나의 변수입니다. 따라서 주소 연산으로 포인터의 주소도 구할 수 있습니다. 

 

예를 들어 변수 a를 가리키는 포인터 pa가 있고, 이 포인터 pa가 할당된 메모리의 시작 위치가 200번지일 때 그 주소를 구하면 다음과 같습니다.

그리고 이 주소를 저장하는 포인터가 이중 포인터입니다. 포인터의 주소를 저장한 이중 포인터에 간접 참조 연산을 수행하면 가리키는 대상인 포인터를 쓸 수 있습니다.

#include <stdio.h>

int main(void)
{
    int a = 10;
    int *pa;
    int **ppa;

    pa = &a
    ppa = &pa;
    
    printf("변숫값: u%\n", ppa);
    printf("&연산: u%\n", &ppa);
    printf("*연산: u%\n", *ppa);
    printf("**연산: u%\n", **ppa);

    return 0;
}

이중 포인터는 7행과 같이 *을 2개 붙여 선언합니다. 이때 두 별은 각각 다른 의미를 가집니다.

첫 번째 *은 ppa가 가리키는 자료형이 포인터임을 뜻하며, 두 번째 *은 ppa 자신이 포인터임을 뜻합니다.

 

이중 포인터 활용 1

 

이중 포인터는 포인터의 값을 바꾸는 함수의 매개변수에 사용합니다.

 

다음은 두 포인터가 다른 문자열을 연결하도록 포인터의 값을 바꾸는 함수의 예시입니다.

#include <stdio.h>

void swap_ptr(char **ppa, char **ppb);

int main(void)
{
    char *pa = "success";
    char *pb = "failure";

    swap_ptr(&pa, &pb);
    printf("pa: %s, pb: %s\n", pa, pb);		// pa: failure, pb: success

    return 0;
}

void swap_ptr(char **ppa, char **ppb)
{
    char *pt;
    
    pt = *ppa;
    *ppa = *ppb;
    *ppb = pt;
}

10행에서 바꾸고자 하는 변수 pa, pb는 포인터이므로 함수의 인수로 포인터의 주소를 줘야 하고 결국 그 값을 받는 매개변수로 이중 포인터가 필요합니다.

 

이중 포인터 활용 2

 

이중 포인터는 포인터 배열을 매개변수로 받는 함수에도 사용합니다.

 

다음은 여러 개의 문자열을 출력하는 함수의 예시입니다.

#include <stdio.h>

void print_str(char **pps, int cnt);

int main(void)
{
    char *ptr_arr[] = {"dog", "tiger", "cat"};
    int count;

    count = sizeof(ptr_arr) / sizeof(ptr_arr[0]);
    print_str(ptr_arr, count);

    return 0;
}

void print_str(char **pps, int cnt)
{
    int i;

    for (i = 0; i < cnt; i++)
    {
    	printf("%s\n", pps[i]);
    }
}

11행에서 인수로 주는 ptr_arr는 포인터 배열의 이름이므로, 포인터의 주소입니다. 따라서 배열명 ptr_arr을 인수로 받는 함수의 매개변수는 이중 포인터를 선언해야 합니다. 

 

배열 요소의 주소와 배열의 주소

 

지금까지 배열명을 첫 번째 요소의 주소로 사용해 왔습니다. 그렇다면 배열의 주소 &arr과 배열명 arr은 어떤 차이가 있는지 살펴보겠습니다.

#include <stdio.h>

int main(void)
{
    int arr[5];

    printf("arr: %u\n", arr);
    printf("&arr: %u\n", &arr);
    printf("arr + 1: %u\n", arr + 1);
    printf("&arr + 1: %u\n", &arr + 1);

    return 0;
}
arr: 1826714196
&arr: 1826714196
arr + 1: 1826714200
&arr + 1: 1826714216

 

실행결과에서 알 수 있듯이 arr이 주소로 쓰일 때와 arr에 주소 연산자를 사용한 &arr의 값은 모두 배열의 시작 위치입니다. 그러나 가리키는 대상이 다르므로 두 주소에 1을 더한 결과는 다릅니다. arr 자체가 주소로 쓰일 때는 첫 번째 요소를 가리키므로 대상의 크기는 4가 됩니다. 반면 배열의 주소 &arr은 배열 전체를 가리키므로 가리키는 대상의 크기는 20이 됩니다.

 

배열 포인터

 

2차원 배열은 1차원 배열들이 모인 배열이고, 그래서 2차원 배열에서의 "배열 요소"는 각각의 1차원 배열입니다.
그리고 배열 이름(arr)은 "첫 번째 요소의 주소"를 의미하므로, 2차원 배열 arr의 이름은 첫 번째 행(arr[0])의 주소가 됩니다.
이렇게 2차원 배열의 이름은 '1차원 배열(부분 배열)의 주소'라고 볼 수 있습니다.

 

배열 포인터는 배열을 가리키는 포인터로 2차원 배열의 이름을 저장할 수 있습니다.

#include <stdio.h>

int main(void)
{
    int arr[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};
    int (*pa)[4];

    pa = arr;

    printf("arr[1][3]: %d\n", pa[1][3]);

    return 0;
}

 

👉 포인터 배열  vs  배열 포인터

int *pa[4];		// 포인터 배열

위 수식은 int 자료형을 가리키는 포인터를 4개 갖고 있는 배열 pa 라는 의미입니다.

 

int (*pa)[4];		// 배열 포인터

위 수식은 int [4] 자료형을 가리키는 포인터 pa 라는 의미입니다.

 

배열 포인터의 활용

 

배열 포인터는 2차원 배열을 매개변수로 받는 함수에서 사용됩니다.

#include <stdio.h>

void print_arr(int (*)[4]);

int main(void)
{
    int arr[3][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12}};

    print_arr(arr);

    return 0;
}

void print_arr(int (*parr)[4])
{
    int i, j;

    for (i = 0; i < 3, i++)
    {
        for (j = 0; j < 4; j++)
        {
        	printf("%5d", parr[i][j]);
        }
        printf("\n");
    }
}

9행에서 print_arr 함수를 호출할 때 2차원 배열명을 인수로 주면 함수에는 첫 번째 부분배열의 주소가 전달됩니다.

 

2차원 배열 요소 참조 원리

 

2차원 배열은 1차원 배열과 같이 모든 저장 공간이 메모리에 연속으로 할당됩니다. 이 공간을 2차원의 논리적 공간으로 사용할 수 있는 것은 배열명이 1차원 배열의 주소로서 1차원 배열 전체를 가리키기 때문입니다. 따라서 배열 포인터를 사용하면 1차원의 물리적 공간을 2차원의 논리적 구조로 사용할 수 있습니다.

 

2차원 배열이 메모리에 할당되었을 때 물리적 요소를 참조하는 과정을 살펴보겠습니다.

우선 요소 arr[1][2]는 두 번째 부분배열 arr[1]에 속하므로 먼저 arr[1]의 주소를 구해야 합니다.

 

그 다음 부분배열 arr[1]의 세 번째 요소를 찾아야 합니다.

따라서 간접 참조 연산자(*)를 사용해 arr[1]의 주소로부터 arr[1] 배열에 접근한 뒤 세 번째 요소의 주소를 구합니다.

 

그리고 마지막으로 arr[1][2]의 주소에 간접 참조 연산을 수행합니다.

 

이렇게 첫 번째 부분배열의 주소인 2차원 배열명(arr)을 사용하면 배열의 모든 공간과 값을 사용할 수 있습니다.

같은 원리로 arr을 배열 포인터에 저장하면 배열 포인터를 배열처럼 쓰는 것이 가능해집니다.

 

👉  더 알아보기

다음 4가지 수식은 모두 같은 값을 가지지만 그 의미와 자료형이 다르기 때문에 구분이 필요합니다.

 

함수 포인터

 

지금까지의 주소는 변수나 배열과 같이 데이터의 주소였습니다. 그러나 이제 명령어 집합인 함수의 주소를 살펴보고 포인터를 통해 함수의 기능을 사용해 보겠습니다.

#include <stdio.h>

int sum(int, int);

int main(void)
{
    int (*fp)(int, int);
    int res;

    fp = sum;
    res = fp(10, 20);
    printf("result: %d\n", res);

    return 0;
}

int sum(int a, int b)
{
    return (a + b);
}

프로그램을 컴파일하면 함수도 실행 파일의 한 부분을 차지하므로 프로그램을 실행하면 함수도 메모리에 올려집니다. 메모리에 올려진 함수를 실행시키기 위해서는 그 위치를 알아야 합니다. 이때 컴파일이 끝나면 함수명이 함수가 올려진 메모리 주소로 바뀌므로 함수를 호출할 때 함수명을 사용하면 됩니다.

 

함수  포인터의 활용

 

함수의 형태만 같으면 기능과 상관없이 모든 함수에 함수 포인터를 사용할 수 있습니다. 따라서 형태가 같은 다양한 기능의 함수를 선택적으로 호출할 때 함수 포인터를 주로 사용합니다. 

#include <stdio.h>

void func(int (*fp)(int ,int));
int sum(int a, in b);
int mul(int a, in b);
int max(int a, in b);

int main(void)
{
    int sel;

    printf("1, 2, 3 중에서 선택: ");
    scanf("%d", &sel);

    switch(sel)
    {
    case 1: func(sum); break;
    case 2: func(mul); break;
    case 3: func(max); break;
    }

    return 0;
}

void func(int (*fp)(int, int))
{
    int a, b;
    int res;

    printf("두 정수 입력: ");
    scanf("%d%d", &a, &b);
    res = fp(a, b);
    printf("result: %d", res);
}

int sum(int a, int b)
{
    return (a + b);
}

int mul(int a, int b)
{
    return (a * b);
}

int max(int a, int b)
{
    if (a > b) return a;
    else return b;
}

int (*fp)(int, int)		// 위와 같은 형태의 함수를 가리키는 포인터 fp

 

물론 위 예시에서 굳이 함수 포인터를 쓰지 않아도 func 함수는 필요한 함수를 직접 호출해 같은 기능을 수행하는 코드를 만들 수 있습니다.

 

그러나 만약 func 함수만 따로 만들 때, 만드는 시점에서 연산 방법을 결정할 수 없다면 일단 함수 포인터를 쓰고 나중에 func 함수를 호출하는 곳에서 연산 방법을 함수로 구현할 수 있습니다.

 

또는 하나의 프로그램이 여러 개의 파일로 분리되어 있는 경우 다른 파일에 있는 정적 함수(static function)를 호출하는 방법으로 함수 포인터를 쓸 수 있습니다.

 

void 포인터

 

가리키는 자료형이 일치하는 포인터에만 주소를 대입할 수 있습니다. 따라서 가리키는 자료형이 다른 주소를 저장하는 경우라면 void 포인터를 사용해야 합니다.

#include <stdio.h>

int main(void)
{
    int a = 10;
    double b = 3.5;
    void *vp;

    vp = &a;
    printf("a: %d\n", *(int *)vp);

    vp = &b;
    printf("b: %.1lf\n", *(double *)vp);

    return 0;
}

7행에서 void 포인터를 선언했는데, void는 가리키는 자료형을 결정하지 않겠다는 뜻입니다.

따라서 void 포인터에는 어떤 주소든 저장할 수 있습니다.

 

또한 같은 이유로 간접 참조 연산이나 정수를 더하는 포인터 연산이 불가능합니다. 간접 참조 연산을 하려면 몇 바이트를 어떤 형태로 읽어야 하는지 알아야 하는데, 어떤 주소가 올지 알 수 없으므로 연산이 불가능하며, 정수 연산도 마찬가지입니다.

 

10행, 13행과 같이 연산을 위해서는 형 변환을 해야 합니다.

(int *)		// int * 로 형변환
(double *)	// double * 로 형변환

 

다른 포인터에 대입하는 경우 형 변환 없이 가능하나, 이 또한 명시적으로 형 변환해 사용하는 것이 좋습니다.

int *pi = (int *)vp;

'Lang > C' 카테고리의 다른 글

C (10) - 사용자 정의 자료형  (1) 2025.09.01
C (9) - 메모리 동적 할당  (2) 2025.08.28
C (7) - 변수 영역과 데이터 공유  (0) 2025.08.21
C (6) - 문자와 문자열  (2) 2025.08.18
C (5) - 배열과 포인터 ②  (2) 2025.08.15