📢 42서울 7기 라피신을 대비하여 모두의 코드 '씹어먹는 C 언어' 강좌의 내용을 정리한 게시물로, 이전 블로그로부터 옮겨 온 글입니다. (원문)
먼저, 배열이란 간단히 다음과 같았다:
여기서 2차원, 3차원 등 고차원 배열도 만들 수 있었지만 결국 이것들도 메모리 상에서는 1차원 평면 위에 연속되게 놓여 있는 형태가 된다.
/* 배열의 존재 상태? */
#include <stdio.h>
int main() {
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int i;
for (i = 0; i < 10; i++) {
printf("arr[%d] 의 주소값 : %p \n", i, &arr[i]);
}
return 0;
}
위 코드를 컴파일해보면, arr[0]
부터 arr[9]까
지 각 원소들의 주소값이 4씩 증가하며 연속적으로 위치하고 있음을 확인할 수 있다.
바로 앞에서 우리는 포인터에 + 1
을 해주면 (ex. int* pa = &a; int* pb = pa + 1
) 그 다음 값에 접근할 수 있음을 확인했다. 그 말인 즉슨, 배열의 첫 번째 원소의 주소값을 포인터에 넣어 준다면 그 포인터에 + 1
, + 2
를 해주는 것으로 그 다음 값, 다다음 값에 접근할 수 있다는 뜻이다.
포인터는 자신이 가리키는 데이터의 '형'의 크기를 곱한 만큼 덧셈을 수행한다. 즉 p
라는 포인터가 int a;
를 가리킨다면 p + 1
을 할 때 p
의 주소값에 사실은 1*4
가 더해지고, p + 3
을 하면 p 의 주소값에 3 * 4
인 12 가 더해진다는 것이다.
그러면 한번 배열의 원소를 가리키는 포인터를 만들어서 이를 확인해보자:
#include <stdio.h>
int arr[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
int* parr = &arr
int i;
for (i = 0; i < 10; i++>)
printf("arr[%d]의 주소값: %p", i, &arr[i]);
printf("(parr + %d) 의 값 : %p ", i, (parr + i));
if (&arr[i] == (parr + i)) {
printf( " [일치하쥬?]\n");
} else {
printf( " [앗...불일치하쥬?]\n");
}
위 코드를 컴파일해보면 &arr[i]
, 즉 배열의 i
번째 원소의 실제 주소값과 첫 번째 원소의 주소로부터 i
만큼 떨어진 메모리의 주소(parr + i)
가 일치한다는 것을 확인할 수 있다. parr
이 int
형이므로 + i
를 하면 주소값에는 사실상 4*i
가 더해지게 되는 것이다.
즉, 배열의 주소값을 저장하는 포인터를 만들고, 그 포인터에 연산자 *
를 사용하여 그 주소에 해당하는 실제 값을 의미하도록 만드는 것은 배열의 특정 원소에 접근하는 것과 동일한 역할을 하게 된다.
앞서 배열의 주소값을 포인터에 전해주기 위해 아래와 같은 코드를 사용했다.
int* parr = &arr
그랬더니 포인터 parr
에 배열의 첫 번째 원소의 주소값이 전해졌다. (그래서 parr + i
를 통해 이후 원소들에 순차적으로 접근했었다)
#include <stdio.h>
int main() {
int arr[3] = {1, 2, 3};
printf("arr 의 정체 : %p \n", arr);
printf("arr[0] 의 주소값 : %p \n", &arr[0]);
return 0;
}
위 코드를 컴파일하면, 배열의 이름인 arr
과 배열의 첫 번째 원소의 주소인 &arr[0]
이 동일한 값을 나타내고 있음을 알 수 있다.
따라서 배열에서 배열의 이름은 배열의 첫 번째 원소의 주소값을 나타내고 있다는 사실을 알 수 있다. 그렇다면 배열의 이름이 배열의 첫 번째 원소를 가리키는 포인터라고 할 수 있을까?
먼저 한 마디로 정리하자면, 배열 이름은 변경 불가능한 값이고 포인터는 변수로서 변경 가능한 값이다.
#include <stdio.h>
int main() {
int arr[3] = {1, 2, 3};
int* parr = arr;
printf("Sizeof(arr) : %d \n", sizeof(arr));
printf("Sizeof(parr) : %d \n", sizeof(parr));
}
위 코드를 컴파일해보면 배열과 포인터는 본질적으로 크기 면에서 애초에 다르다는 것을 알 수 있다. 배열인 arr
는 12바이트(int 원소가 3개이므로 4*3) 즉 배열의 실제 크기가 나온다. 반면 포인터 parr
는 (64비트 컴퓨터의 경우) 8바이트다.
즉 배열의 이름( arr
, 배열 그 자체)과, 첫 번째 원소의 주소값( parr
, 배열의 시작 주소)은 엄밀히 다른 것이다.
그렇다면 도대체 왜 두 값을 출력 했을 때 같은 값이 나올까?
그 이유는 C 언어 상에서 배열의 이름이 sizeof
연산자나 주소값 연산자( &
)와 사용될 때를 빼면, 배열의 이름을 사용할 때 암묵적으로 첫 번째 원소를 가리키는 포인터로 타입 변환되기 때문이다.
그러나, 이 때에 배열의 이름은 그 값을 바꿀 수 없는 상수 형태의 포인터이다. 따라서 int arr[3]; arr++
같이 배열 이름에 직접 증가나 감소 연산자를 사용할 수는 없다. 물론 *(arr + i)
는 된다. 하지만 arr
의 값 즉 원본을 수정할 수는 없다. 배열의 시작 위치가 만약 변경된다면 배열의 원소 전체가 흐트러지게 되므로 메모리의 이상한 공간에 잘못 접근하게 될 수도 있는 노릇이다.
[]
연산자사실 포인터 연산자는 세 가지로, *
과 &
, 마지막으로 []
도 있다. 앞서 보았듯 이 포인터 연산자들은 변수가 위치한 메모리의 주소를 구하거나( &
), 포인터 변수가 갖는 주소에 저장된 값을 구하는 데( *
) 쓰인다.
스택오버플로우를 살짝 찾아보자.
e1[e2] means *(e1+e2)
라고 한다. 또한
A postfix expression followed by an expression in square brackets
[]
is a subscripted designation of an element of an array object. The definition of the subscript operator[]
is thatE1[E2]
is identical to(*((E1)+(E2)))
. Because of the conversion rules that apply to the binary+
operator, ifE1
is an array object (equivalently, a pointer to the initial element of an array object) andE2
is an integer,E1[E2]
designates theE2
-th element ofE1
(counting from zero).
라고도 한다. 즉, 후위 연산자[]
는 배열 첨자 연산자로 배열의 특정한 원소를 나타내는 데 사용하는 기호가 맞는데, 그렇게 특정 원소를 나타낼 수 있는 원리가 마치 포인터와 같이 해당 원소에 접근하는 것이다.
C 언어 상에서 E1[E2]
는 *((E1)+(E2))
와 같다. 이 때 이항 연산자 +
의 타입 변환 규칙에 따라, E1
이 배열이고 E2
가 정수라면 E1(E2)
는 *((E1)+(E2))
즉 배열 E1
의 E2
번째 요소의 주소에 저장되어 있는 실제 값을 가져오는 것이다.
그러니까 사실 E1[E2]
나 E2[E1]
이나 괄호 안에서 +
로 연결되면 순서가 상관없기 때문에 동일한 결과를 나타낸다. 즉 arr[3]
이나 3[arr]
이나 똑같다.