열혈C - Chapter 18 다차원 배열과 포인터의 관계

2024. 10. 18. 22:24Programming Language/C

18-1 2차원 배열이름의 포인터 형

2차원 배열 이름의 포인터형은 무엇일까

예를들어 

int arrd[3][4];

라는 2차원 배열이 있다고 했을때 이 배열의 이름의 포인터형은 무엇일까에 대해서 생각해보자.

이는 꽤나 쉽지는 않다.

 

우리가 잘아는 1차원 배열을 두개 선언해보자.

int arr[3];
int arr2[4];

가 있을 때 각 배열의 이름의 포인터의 타입은 int * 이다

그 이유는 배열의 이름은 배열의 첫요소인 arr[0]을 가리키고 있기 때문이며 그 타입은 int형이기 때문이다.

그런데 이런 방식이 이차원 배열에서는 통용되지 않는다.

 

2차원 배열을 하나 더 만들어보자.

int arr2d[3][4];
int arr2d2[3][3];

과 같은 2차원 배열이 있다고 보면 우리는 배열의 길이는 상관없이 1차원 배열이면 배열의 이름이 int형 포인터로 그 형태가 모두 동일했었다.

 

그런데 2차원 배열의 경우는 배열의 길이, 두번째 들어오는 숫자가 다르면 포인터의 형이 달라진다.

 

이건 기존의 방식에서 어느정도 방법을 가져와서 이해하려고 한다면 어려울 수 있으니 아예 새로운 내용이라고 인식하고 공부해보자.

 

그전에 왜 이렇게 다른지에 대해서 먼저 설명해보자면 타입의 결정이란 메모리에 접근하는 방법, 포인터 대상의 증감연산에 대한 정보가 주어진다는 것이다.

예를 들면 ptr이라는 포인터가 있을 때, *ptr로 접근했을때 int형으로 read/write한다면 ptr은 int형 포인터변수 일 것이다.

같은 결로 ptr++를 할 경우 ptr에 증가되는 숫자는 sizeof( int ) 만큼의 크기만큼 증가한다는 것이다.

이 두 방법이 동일하다면 타입이 동일하다는 것이고 이를 거꾸로 보면 메모리에 접근하는 방법과 포인터의 대상의 증감연산이 동일하다는 의미는 타입이 동일하다는 의미이다.

 

그런데 2차원 배열의 경우는 배열의 크기가 다르면, 메모리에 접근하는 방법과 포인터의 증감연산이 서로 달라진다.

그렇기에 이전에 1차원 배열에서 배웠던 개념으로는 2차원 배열이름의 포인터형을 추론할 수 는 없다는 것이다.

 

이제 2차원 배열의 접근 방법에 대해서 한번 파악해보자.

 

1차원 배열의 경우는

int arr[3];

이라는 배열이 선언 되었을 경우 배열의 이름인 arr에 1을 더하면 arr[1]을 가리키게 된다.

 

그렇다면

int arr[3][4];

와 같은 2차원 배열은 배열의 이름에 +1을 더하면 arr[0][1]을 가리킬까?

 

2차원 배열이름에 증감 연산을 하면 배열의 길이가 아닌 배열의 갯수에 덧셈을 하는 것과 같다.

그래서 arr[0][1]이 아니라 arr[1][0]에 접근하게 된다.

간단하게 가로로 이동하는게 아니라 세로로 이동한다.

그래서 연산이 가로길이에 의존을 하게 된다.

 

왜냐면 메모리에 나란히 할당되기 때문에arr + 1의 연산 결과가 arr[0][0]에서 arr[1][0]으로 12 증가했다는 것을 알 수 있다.

그렇다면 가로길이가 2라면 arr + 1의 연산은 12가 아니라 8이 증가 했을 것이다.

이 처럼 가로 길이에 따라서 배열의 증감연산의 방법이 달라지게 된다.

 

이는 결국 가로길이(배열의 길이)에 따라서 증감연산의 방법이 달라지기 때문에 서로 타입이 다르다는 것을 알 수 있다

 

구체적으로 왜 길이에 따라서 12와 8의 값이 나왔는지 보자면 배열은 나란히 있기 때문에 arr[0][0] 다음은 arr[0][1]일 거고 동일하게 arr[0][2]로 가게 될것이다.

이 다음이 arr[1][0]인 것이고 일차원 배열이 였다면 arr + 3한것과 동일한 값의 차이였을 것이고 int형이기 때문에 sizeof( int )*3만큼의 크기인 12가 늘어난 것이다.

 

결국 2차원 배열의 연산에 의한 크기의 변화는 sizeof(int)*3과 같다고 보면 된다.

여기서 3은 가로길이가 되는 것이고 요소가 int인 것인데 이를 보면 결국 이전에 포인터가 정보를 가지고 있어야 연산과 값에 접근할 수 있다고 했는데 그 정보가 되는 것이다.

그래서 2차원배열의 이름을 결정짓는 두가지 요소는 무엇으로 이루어져있는지, 타입과 가로길이가 얼만큼으로 구성되어 있는지의 두가지 요소를 갖고 포인터형이 정해지는 것이다.

그래서 2차원 배열이름의 포인터형은 이 두가지가 담겨야 하고 이 두가지를 담을 수 있도록 포인터를 별도로 선언하도록 제공하고 있다.

 

1차원 배열이름과 2차원 배열이름의 포인터형

이 1차원 배열의 경우는 배열의 이름인 arr은 int형 포인터(int *)의 타입을 갖고 있다.

1차원 포인터 배열이므로 배열 이름인 parr은 int형 이중 포인터(int **)의 타입을 갖게 된다.

 

int형 1차원 배열도, int형 포인터 형 1차원 배열도 아니므로 arr2d는 int형 포인터 형도, int형 이중 포인터 형도 아니다.

2차원 배열의 이름의 포인터형을 결정짓는 방법은 1차원 배열과는 다른 방법을 사용한다.

 

2차원 배열 이름이 가리키는 것들은?

2차원 배열이름의 포인터형을 결정지으러면 우선 2차원 배열이름이 가리키는 대상이 무엇인지 알아야한다.

그런데 1차원 배열과 달리 이것만으로 포인터형이 결정나지는 않는다.

그 이유는 위에서 말했던것처럼 예를 들어 

int arr2d[3][3];

arr2d라는 2차원 배열의 이름은 arr2d[0][0]를 가리키고 있는데

arr2d[1]과 arr2d[2]도 각각 arr2d[1][0]과 arr2d[2][0]을 가리키는 주소값의 의미를 갖기 때문이다.

그러면 arr2d도arr2d[0][0]을 가리키고 arr2d[0]도 arr2d[0][0]를 가리킨다면 arr2d arr2d[0]일 수 있는 거 아니냐는 생각이 든다

 

그럼 arr2d와 arr2d[0]는 같은것인가?

이 코드를 보자 보면 arr2d와 arr2d[0]와 arr2d[0][0]의 주소값을 확인하고 arr2d[1]와 arr2d[1][0]의 주소값을 확인하고 arr2d[2]와 arr2d[2][0]의 주소값을 확인하고 있다.

그 결과 값을 보면

그런데 여기서 보면 arr2d의 sizeof연산을 보면 36으로 배열 전체를 의미하는 것을 알 수 있고, arr2d[0]의 사이즈가 12인 것을 보면 2차원 배열의 첫 배열을 의미하는 것을 볼 수 있다.

 

그래서 결론적으로 arr2d와 arr2d[0]은 같은것은 아니다.

배열이름 기반의 포인터 연산

int iarr[3];          // iarr은 int형 포인터
double darr[7];       // darr은 double형 포인터

그렇기 때문에 각 포인터에 증감 연산을 할 경우 sizeof(type)*n만큼의 크기가 증감한다

printf("%p", iarr+1);
printf("%p", darr+1);

의 출력으로 각각 4, 8만큼의 크기가 증가하는 것을 확인할 수 있다.

 

이렇게 포인터 연산의 결과는 포인터의 타입에 의존적이다.

이 포인터의 연산에 집중해보면 2차원 배열이름의 포인터의 형을 결정짓는데 큰 힌트가 된다.

 

2차원 배열 이름 대상의 포인터 연산 결과

2차원 배열이름을 대상으로 값을 1씩 증가 및 감소하는 경우, 그 결과는 각 행의 첫번째 요소의 주소값이 된다.

그래서 그 결과는 

확인해보면 배열의 이름에 1씩 더했을때 값의 증감이 arr1의 경우는 배열의 길이가 2인 int형 배열로 이루어진 2차원 배열이기에 값이 sizeof(int) * 2 인 8이 증가하는 것을 볼 수 있고 arr2의 경우는 배열의 길이가 3인 int형 배열로 이루어진 2차원 배열이기에 값이 sizeof(int) * 3인 12가 증가하는 것을 볼 수 있다.

 

이렇기 때문에 그냥 단순하게 2차원 배열 이름의 포인터형을 전에 배운 방식으로 함부로 결정할 수 없는 것이다.

 

정리하자면..

  • 2차원 배열의 포인터형을 결정짓는 두가지 요소는
    1. 가리키는 대상이 무엇인가
    2. 배열이름(포인터)를 대상으로 값을 1 증감시 얼마나 증가하는가

2차원 배열의 경우는 이 두 정보를 알아야지만 배열이름의 포인터형을 결정지을 수 있다.

 

이제 2차원배열의 이름의 포인터형을 결정해보자.

지금 우리는 타입과 배열의 길이를 알아 냈다면 그걸 어떻게 포인터로 표현 할 수 있을까?

 

예를 들어서 

int arr[2][8];

의 배열의 이름의 포인터 형을 결정하기 위한 단서는 int형이라는 점, 배열의 길이가 8이라는 것이다.

 

우선 생각해보면 1차원 배열의 경우는 아래와 같이 

int arr[3];
int * ptr = arr;

으로 써 arr의 ptr에 넣을 수있 었다.

 

2차원 배열의 경우도 먼저 ptr이 포인터 형임을 명확하게 하기 위해서 *ptr을 적어주고 이게 int형이면서 8의 배열의 길이를 가졌더라라고 선언을 해줘야 한다.

int (*ptr) [8];

이 포인터의 타입을 명확하게 읽어서 명칭으로 이야기 할 수는 없고 그냥 2차원 배열의 포인터 형인데 요소가 int이면서 가로의 길이가 8인 포인터이다 라고 할 수 밖에는 없다.

 

여기서 (*ptr)은 포인터임을 강조하기 위해서 작성된 것이다.

 

최종결론: 2차원 배열이름의 포인터 형 2

int arr[3][4]에 존재하는

1. 포인터를 가리키는 대상        => int형 변수
2. 포인터 연산의 결과            => sizeof(int) * 4 의 크기단위로 값이 증가 및 감소

라는 정보를 기반으로 포인터 변수 ptr을 선언한다면

이렇게 만들면 결과적으로

int arr[3][8];
int (*ptr)[8];
ptr = arr;

과 같이 ptr에 arr을 넣을 수 있게 된다.

 

2차원 배열 이름의 포인터 형 결정 연습

char (*arr1)[4];
double (*arr2)[7];

arr1은 char형 변수를 가리키면서 포인터 연산시 sizeof(char)*4의 크기 단위로 값이 증감하는 포인터 변수이고,

arr2는 double형 변수를 가리키면서 포인터 연산시 sizeof(double)*7의 크기 단위로 값이 증감하는 포인터 변수이다.

 

int형 변수를 가리키면서 포인터 연산시 sizeof(int)*2의 크기 단위로 증감하는 포인터 변수 ptr1과 

float형 변수를 가리키면서 포인터 연산시 sizeof(float)*5의 크기 단위로 증감하는 포인터 변수 ptr2는 어떻게 될까

int (*ptr1)[2];
float (*ptr2)[5];

가 된다.

 

2차원 배열 이름의 포인터 관련 예제

을 보면 알다 싶이 배열의 길이(가로길이)가 같다면 같은 포인터타입을 가진다는 것을 볼 수 있다.

 

18-2 2차원 배열이름의 특성과 주의사항

'배열 포인터'와  '포인터 배열'을 혼동하지 말자

포인터 배열은 포인터 변수로 이루어진 배열을 의미하고

배열 포인터는 배열을 기리킬 수 있는 포인터 변수이다.

 

이 내용을 보면 알 수 있듯이 포인터 배열은 포인터를 배열에 넣어 만든 형태를 의미하고 배열 포인터는 2차원 배열의 이름의 자료형을 의미한다.

 

 

2차원 배열을 함수의 인자로 전달하기

int arr1[2][7];             => int (*ptr)[7]
double arr2[4][5];          => double (*dptr)[5]
SimpleFunc(arr1, arr2);

으로써 함수가 받는 매개변수는 결국 

void SimpleFunc(int(*ptr)[7], int(*dptr)[5]);

으로 선언해주면 2차원 배열을 받 을 수 있다

동일하게

void SimpleFunc(int ptr[][7], dptr[][5]);

와 같이도 사용이 가능하다.

 

매개변수의 선언 위치에만 동일한 선언으로 간주해준다.

이는 일차원배열의 경우에서 매개변수로 포인터 변수를 만들어 받았던것과 동일한 형태이다.

 

2차원 배열을 함수의 인자로 전달하는 예제

정의된 두 함수의 인자로 전달되는 2차원 배열의 가로 길이는 결정되어 있다.

반면 세로 길이 정보는 결정되어 있지 않고 두번째 인자를 통해서 추가로 전달하고 있다는 점에 주의해보자.

그래서 이중 for문 내부에 가로 길이는 고정으로 4를 설정하고 세로 길이, 배열의 갯수에 해당하는 내용인 sizeof(arr) / sizeof(arr[0])을 전달하면서 동적으로 세로길이를 받아 반복분을 실행시키고 있다.

이게 위에서 말한 세로길이의 정보는 결정되지 않고 전달하는 것이다.

 

동일하게 가로길이를 유동적으로 전달받는 형태의 함수를 만드는 것은 지양하는게 좋을것이라고 한다.

2차원 배열에서도 arr[i]은 *(arr + i)과 같다

이전에 1차원 배열과 포인터 변수에서 이야기 했던 내용도 2차원 배열에서 동일하게 적용된다.

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

라는 배열이 있을때 접근 하는 방식은 

arr[2][1] = 4; // 기본적인 2차원 배열의 접근 방식
(*(arr + 2))[1] = 4; // 배열의 갯수를 포인터 방식으로 전환한 방식
*(arr[2] + 1) = 4; // 배열의 길이를 포인터 방식으로 전환한 방식
*(*(arr + 2) + 1) = 4; // 배열의 갯수와 길이를 전부 포인터 방식으로 전환한 방식

과 같다.

이 위에 작성된 모든 방식이 동일한 결과를 보여주는 문장이다.

이 방식에서 조금 첨언을 하자면 arr과 [2]와 [1]은 연산자이고 arr이 [2]와 연산을 한 그 결과를 [1]과 연산을 한다

이렇게 [2]와 [1]은 각자의 기능을 수행하기에 *(arr[2] + 1)이나 (*(arr+2))[1]과 같은 방식으로 사용이 가능한 것이다.

arr[2][1];

 ↓       arr[2]를 A로 치환

A[1]
 
 ↓       arr[2]를 *(arr+2)로 변경후 다시 A에 넣기

*(arr+2)[1]

와 같은 과정을 거치면서 변경이 된다.

이 코드에서 결과를 확인해보면 그 결과를 조금 더 명확하게 확인이 가능하다.

 

물론 대부분의 코드는 arr[2][1]과 같은 방식으로 접근하는게 좋다.

여기서는 학습적인 측면에서 해당 내용을 알아야하기에 작성해보지만 코드란게 누구든 알 수 있게 짜는것이 더 중요하다고 생각하며, 모두들 사용하는 방식을 사용해야하기 때문에 *(*(arr +2) +1)과 같은 코드는 앞으로 잘 쓰게 될지는 잘 모르겠다.