0%

C언어 포인터와 배열의 관계 이해하기

강의, 책을 보면서 노트화 했던 것을 정리하는 포스팅입니다.


포인터? 배열? 어떤 관계가 있지?

저번시간에 포인터는 메모리 주소 값을 가질 수 있는 녀석이라고 설명했어요.

그렇다면 배열은 어떤 녀석일까요? 배열은 여러 개의 데이터들을 한꺼번에 관리할 수 있는 녀석이에요.

배열은 어떻게 여러 개의 데이터들을 한꺼번에 관리할 수 있는 걸까요?

그리고 단일 포인터에 일차원 배열을 대입하면 포인터를 배열처럼 사용할 수 있어요.
어떻게 그렇게 할 수 있는 걸까요?

아래의 코드를 보면, 배열과 포인터는 가리키는 대상체가 다르다는 것을 알 수가 있는데 말이죠.

1
2
3
4
5
6
7
8
int a[4]={1,2,3,4} => array of 4 int
int *p=a; => pointer to int

sizeof(p) => 8
sizeof( int * ) => 8

sizeof(a) => 16
sizeof(int [4]) => 16

배열이 여러 개의 데이터들을 한꺼번에 관리할 수 있는 이유는 배열의 크기만큼 메모리에 공간을 할당하고, 배열 이름 그 자체가 첫번째 데이터 시작지점의 주소 값을 가지고 있기 때문이에요.

단일 포인터에 일차원 배열을 대입하면 포인터를 배열처럼 사용할 수 있는 이유는 형 변환을 통해서 포인터에서 배열로 대입되기 때문이에요.

이런 것을 가능하게 하는 기법이 Decay라고 불려요.
Decay 이란 “배열의 이름은 일반적으로 배열의 첫번째 원소의 주소로 해석한다.” 에요.

Decay 문법에서도 예외 조건이 있다고 해요.
sizeof() 연산자에서는 대상체의 타입을 구해오는 것이 핵심이고, & 연산자에서는 주소 값을 구하기 때문에 이 두가지는 예외적으로 취급해요.

그래서 sizeof(p)와 sizeof(a)의 크기가 다르고, p=a 와 p=&a[0] 는 똑같은 의미를 가진다는 것을 알 수가 있어요. 그렇다면 p=&a는 어떤 의미를 가지고 있을까요?

아래의 코드를 보면서, 좀 더 자세히 알아봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int main()
{
int a[4]={1,2,3,4};
int (*p)[4]=&a;

(*p)[3]=10;

printf("p=%p\n",p);
printf("p+1=%p\n",p+1);

printf("a[3]: %d\n",a[3]);
return 0;
}

10 line -> p=0x7ffeec239830
11 line -> p+1=0x7ffeec239840
13 line -> a[3]: 10

만약에 int (*p)[4]에 &a를 대입한게 아니라, int *p=&a를 대입했다면, 컴파일 할 때, 경고 문자를 볼 수 있어요. 그 이유는 &연산자는 Decay 문법의 적용 대상이 아니기 때문이죠.

warning: incompatible pointer types initializing ‘ int * ‘ with an
expression of type ‘int (*)[4]’ [-Wincompatible-pointer-types]
int *p=&a;


int *p=&a 는 int [4] 대상체의 주소를 int * 대상체에 집어 넣는 것이기 때문이죠. 즉, 자료형이 서로 다르기 때문에 경고 문자가 나타나는 거에요.

그에 반면, int (*p)[4]=&a 같은 경우에는 int [4] 대상체의 주소를 int [4]의 메모리 주소 값을 가질 수 있는 대상체에 집어 넣기 때문에 경고 문자 없이 깔끔하게 컴파일이 되는거에요.

그래서 코드의 출력 결과를 보면, p가 가리키고 있는 녀석이 int [4] 이기 때문에 주소 값이 16이 증가한 것을 볼 수 있고, (*p)[3]을 통해 a[3]의 값을 바꿀 수 있는 이유는 int [4]의 메모리 주소 값을 가지고 있는 p를 역참조해서 a의 메모리 주소로 이동하고 첨자 [3]을 통해서 a의 세번째 인덱스를 접근해 값을 변경할 수 있는 거에요.


포인터를 배열처럼 사용해보자.

포인터를 어떠게 배열처럼 사용해야 하는지 차근차근 알아봅시다.


배열을 포인터 접근으로 바꿔보자

배열로 선언한 것을 포인터를 이용해서 접근을 해봅시다.
아래의 처럼 선언했다고 생각해봅시다.

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

이때, a가 요소 1,2,3,4,5를 포인터를 이용해서 어떻게 접근해야할까요?
바로 답은 아래와 같습니다.

*(a) // 인덱스 0에 접근
*(a + 1) // 인덱스 1에 접근
*(a + 2) // 인덱스 2에 접근
*(a + 3) // 인덱스 3에 접근
*(a + 4) // 인덱스 4에 접근

배열의 이름은 배열의 요소의 첫번째 값을 가리키는 주소가 들어가 있습니다.
*(a + 1) 을 하면, 배열의 요소의 첫번째 값을 가리키는 주소에 변수 a가 가리키는 대상체인 int형의 크기 * 1을 더한 값을 역참조를 하기 때문에 배열의 요소의 2번째 값을 가리키는 녀석에 접근할 수 있는 것입니다.
마찬 가지로 인덱스 4에 접근을 할려면 *(a + 4)를 이용하면 됩니다.

이차원배열과 포인터

이러한 접근 방식을 이차원 배열에서 적용시켜 봅시다.
아래처럼 값을 선언했다고 생각해봐요.

1
2
3
int a[2][2]={1,2,3,4};
int *p=a;
p[1][1]=10;

이때, a의 메모리 주소는 1000이라고 생각해요.

p[1][1]
( *( p + 1 ) )[1]
( *( 1000 + sizeof(int) * 1 ) )[1]
( *1004 )[1]
*( *1004 + 1 )
*( 2 + 1 )
*3 // 이 때, 컴파일 에러가 발생, * 연산자를 이상한 주소 값을 가르켰기 때문.

변수 p의 메모리 번지가 바뀌는 것을 봅시다.
p[1][1]은 ( *(p + 1) )[1]로 변경이 가능합니다.
그 후에 p가 가지고 있는 값은 1000이 되고, p가 가리키고 있는 대상체는 int형이기 때문에 1은 int형의 크기 * 1 로 변환이 됩니다. -> ( *( 1000 + sizeof(int) * 1) )[1]
이를 간략하게 보면, ( *1004 )[1]이 되고, *( *1004 + 1)로도 표현이 가능합니다.
1004는 배열의 두번쨰 인덱스의 값인 2를 가리키게 되서, *( 2 + 1) 로 변환이 됩니다.
그렇다면, *(3) 이것의 의미는 3이라는 메모리 번지에 접근하는 것이기 때문에, 이상한 주소에 접근하게 되므로 컴파일 에러가 발생합니다.
요즘은 컴파일러가 좋아서인지 경고 문자를 보내고, 컴파일이 됩니다.
그리고 당연히 포인터를 이차원 배열처럼 사용은 불가능합니다.


이번에는 이중 포인터를 이용했다고 생각해봐요.

1
2
3
int a[2][2]={1,2,3,4};
int **p=a;
p[1][1]=10;

이때, a의 메모리 주소는 마찬가지로 1000 입니다.

p[1][1]
*( *(p + 1) + 1)
*( *(1000 + 1) + 1)
*( *(1000 + sizeof( int * ) * 1) + 1)
*( *(1008) + 1)
*( 0x0000000300000004 + 1)
*( 0x0000000300000004 + sizeof(int) * 1)
*( 0x0000000300000008 ) // 이 때, 잘못된 번지에 접근을 하기 때문에 run time 에러가 발생.

변수 p의 메모리 주소가 바뀌는 것을 봅시다.
p[1][1]은 * ( (p + 1) + 1) 변경이 가능합니다.
a의 메모리 주소가 1000이므로, *( *(1000 + 1) + 1) 으로도 변경이 가능합니다.
그리고 이때 p가 가리키는 대상체는 int * 이므로, *( *(1000 + sizeof( int * ) * 1) + 1) 으로 볼 수 있습니다. 이를 계산하면, *( *(1008) + 1) 으로 표현이 가능합니다.
이 때, int *
이므로 이를 역참조하면 int * 이므로, 역참조를 한다고 하더라도 주소 값을 가져오는 것이기 때문에 0x0000000300000004가 되어서, *(0x0000000300000004+1) 으로 표현이 가능합니다. 이 때, 가리키는 대상체가 int형이므로, *(0x0000000300000004+sizeof(int) * 1) 으로 표현이 가능합니다.
따라서 *0x0000000300000008 이라는 잘못된 번지에 접근을 하기 때문에 run time 에러가 발생하는 것입니다. 그 뿐만 아니라 컴파일 에러도 발생 합니다.


그렇다면, 포인터를 이차원 배열처럼 사용할러면 어떻게 선언하면 될까요?
아래처럼 선언하면 됩니다.

1
2
3
int a[2][2]={1,2,3,4};
int (*p)[2]=a;
p[1][1]=10;

이번에도 a의 메모리 주소는 이번에도 1000입니다.

p[1][1]
*( *(p + 1) + 1)
*( *(1000 + sizeof(int [2]) * 1 ) + 1)
*( *(1008) + 1)
*( 1008 + 1)
*( 1008 + sizeof(int) * 1)
*(1012)

p의 메모리 주소가 바뀌는 것을 한번더 봅시다.
p[1][1]을 포인터 접근으로 변환하면, *( *(p + 1) + 1)로 변환이 가능하고, p는 대상체 int [2]를 가리키기 때문에, *( *(1000 + sizeof(int [2]) * 1) + 1)로 바뀝니다.
이걸 간략화하면, *( *(1008) + 1) 으로 바꿀 수 있습니다. p 는 내부에 있는 이름 없는 배열을 가리키고 있기 때문에, Decay 문법에 의해 int [2] 타입이 int * 타입으로 변환되기 때문에 *( 1008 + 1)이 됩니다.
이 때, 가리키는 대상체는 int형 이기 때문에 *( 1008 + sizeof(int) * 1) 이 되고, 간략화하면, *(1012)가 되어서, a[1][1]에 해당하는 값인 4를 10으로 바꿀 수 있게 되는 것입니다.


즉, 배열과 포인터의 관계를 이해하기 위해서는 Decay 에 대해 알아야 하고, Decay 을 이용하면 배열의 이름이 첫 번째 원소의 주소로 해석된다는 것을 알고 있으면 됩니다.


배열을 포인터적으로 해석해보자

먼저 기본적으로 단일 포인터를 해석합시다.

타입 해석
int* p // pointer to int

자체 크기
sizeof( p )
sizeof( int * )
-> 64 bit는 8Byte, 32 bit는 4Byte

한번 움직이는 거리(offset)
p + 1
p + sizeof( *p ) + 1
p + sizeof( int ) * 1
p + 4

int * 타입은 자기 자신의 크기는 64비트 기준 8바이트 이고, 가리키는 대상체가 int 형이기 때문에 offset 값이 4입니다.


일차원 배열의 포인터적 해석

타입해석
int a[2] // array of 2 int
자체 크기
sizeof( a )
sizeof( int [2] ) // 8Byte

한번 움직이는 거리(offset)
a + 1
a + sizeof( *a ) * 1
a + sizeof( int ) * 1
a + 4

int [2] 타입은 자기 자신의 크기는 8바이트이고, 가리키는 대상체가 int 형이기 때문에 offset 값이 4입니다.

포인터 배열의 포인터적 해석

타입 해석
int *a[2] // array of 2 pointer to int

자체 크기
sizeof( a )
sizeof( int *[2] ) // 16Byte

한번 움직이는 거리(offset)
a + 1
a + sizeof( a ) * 1
a + sizeof( int
) * 1
a + 8

전치랑 후치가 존재할 때, 후치가 먼저 해석되기 때문에 배열 포인터가 아니라 포인터 배열로 해석된다.
int *[2] 타입은 자기 자신의 크기는 16바이트이고, 가리키는 대상체가 int *형이기 때문에 offset 값기 8입니다.

배열의 포인터의 포인터적 해석

int (*p)[2] // pointer to array of 2 int

자체크기
sizeof( p )
sizeof( int (*)[2] ) // 8Byte

한번 움직이는 거리(offset)
p + 1
p + sizeof( *p ) * 1
p + sizeof( int [2] ) + 1
p + 8

전치랑 후치가 존재할 때, 후치가 먼저 해석되기 때문에 배열 포인터를 사용하기 위해서는 괄호를 쳐서 우선 순위를 높여줘야 한다.
int *[2] 타입은 자기 자신의 크기는 8바이트이고, 가리키는 대창체가 int [2] 형이기 때문에 offset 값은 8입니다.

이중 포인터의 포인터적 해석

타입해석
int **p // pointer to pointer to int

자체크기
sizeof( p )
sizeof( int ** ) // 8Byte

한번 움직인느 거리(offset)
p + 1
p + sizeof( *p ) * 1
p + sizeof( int * ) * 1
p + 8

int ** 타입은 자기 자신의 크기는 8바이트이고, 가리키는 대상체가 int * 형이기 때문에 offset 값은 8입니다.

이차원 배열의 포인터적 해석

타입해석
int a[2][2] // array of 2 array of 2 int

자체 크기
sizeof( p )
sizeof( int [2][2] ) // 16Byte

한번 움직이는 거리(offset)
a + 1
a + sizeof( *a ) * 1
a + sizeof( int [2] ) * 1
a + 8

int [2][2] 타입은 자시 자신의 크기는 16바이트이고, 가리키는 대상체가 int [2] 형이기 때문에 offset 값은 8입니다.

삼차원 배열의 포인터적 해석

int a[2][2][2] // array of 2 array of 2 array of 2 int

자체크기
sizeof( a )
sizeof( int [2][2][2] ) // 32byte

a[0][0][0] : 0x7ffee4de57b0
a[0][0][1] : 0x7ffee4de57b4
a[0][1][0] : 0x7ffee4de57b8
a[0][1][1] : 0x7ffee4de57bc
a[1][0][0] : 0x7ffee4de57c0
a[1][0][1] : 0x7ffee4de57c4
a[1][1][0] : 0x7ffee4de57c8
a[1][1][1] : 0x7ffee4de57cc

한번 움직이는 거리(offset)
a + 1
a + sizeof( *a ) * 1
a + sizeof( int [2][2] ) * 1
a + 16

int [2][2][2] 타입은 자기 자신의 크기는 32바이트이고, 가리키는 대상체가 int [2][2] 형이기 때문에 offset 값은 16입니다.