C언어에서 가장 큰 첫번째 혼란을 야기하는 부분을 꼽으라면 포인터가 아닐까 싶다. 포인터와 배열을 같이 다루는 문제를 만나게 되어 설명을 듣다보니 '배열을 포인터로 쓸 수 있다?'하는 부분에서 의문이 생겨서 찾아보게 되었다.
배열이 포인터로 사용되는거면..
배열 자체가 포인터라는건가? 아님 포인터가 배열로 구성되어있다는 걸까?
결론부터 말하자면, 둘 다 아니다.
배열과 포인터는 서로 다른개념인데, 다만 C에서는 배열 이름이 대부분의 식에서 배열의 첫번째 원소를 가리키는 포인터로 변환되어 사용된다.
.... 이게 뭔소리고
우선 배열부터 보자.
int arr[3] = {10, 20, 30};
위 코드를 실행하면 메모리에 실제로 int 3개가 연속적으로 만들어진다.
arr[0] arr[1] arr[2]
↓ ↓ ↓
+----+ +----+ +----+
| 10 | | 20 | | 30 |
+----+ +----+ +----+
ISO/IEC 9899:2024 N3220 working draft, 6.3.2.1 3항
Except when it is the operand of the sizeof operator, or typeof operators, or the unary & operator,
or is a string literal used to initialize an array, an expression that has type "array of type" is converted
to an expression with type "pointer to type" that points to the initial element of the array object and
is not an lvalue. If the array object has register storage class, the behavior is undefined.
C에서는 배열 이름이 식에서 사용되면, 대부분의 경우 그 배열의 첫 번째 원소를 가리키는 포인터 값으로 변환된다.
즉,
arr
↓
첫 번째 원소의 주소
↓
&arr[0]
그래서 다음 코드가 가능해진다.
int *p = arr;
-> arr이 &arr[0]으로 변환
-> int *p = &arr[0];와 같은 의미가 됨
배열이
int arr[3] = {10, 20, 30}; 이라면
arr → &arr[0]
arr + 1 → &arr[1]
arr + 2 → &arr[2]
이다.
그리고 그 주소에 *를 사용하면 해당 위치의 값을 가져온다.
*arr → arr[0] → 10
*(arr + 1) → arr[1] → 20
*(arr + 2) → arr[2] → 30
그래서 배열에서는 이런 식으로 볼 수 있다.
arr[0] == *arr
arr[1] == *(arr + 1)
arr[2] == *(arr + 2)
즉, arr[i]는 결국 arr에서 i칸만큼 이동한 주소의 값을 가져오는 거랑 같은 의미.
정리하면 arr[i] == *(arr + i)라고 볼 수 있다.
int arr[3] = {10, 20, 30};
int *p = arr;
p는 주소를 저장하는 포인터 변수이고, 현재 arr[0]의 주소를 저장하고 있다.
p ──────────┐
▼
+----+----+----+
arr ⇨ | 10 | 20 | 30 |
+----+----+----+
↑
arr[0]
따라서 *p는 10이고, *(p + 1)은 20이다.
그리고 포인터에도 배열 표기법을 사용할 수 있다.
p[0] == *p
p[1] == *(p + 1)
p[2] == *(p + 2)
따라서
arr[1]
*(arr + 1)
p[1] => 모두 같은 배열 요소 arr[1]에 접근하며, 값은 20
*(p + 1)
ISO/IEC 9899:2024 N3220 working draft, 6.5.3.2 Array subscripting
The definition of the subscript operator [] is that E1[E2] is identical
to (*((E1)+(E2))).
C에서 p[i] 라는 표현 자체가 사실상 *(p + i) 라는 의미이기 때문이다.
그래서 포인터 p가 배열의 첫 번째 원소를 가리키고 있다면:
p[0]
p[1]
p[2]
처럼 배열처럼 사용할 수 있다.
하지만 이것이 포인터 자체가 배열이라는 뜻은 아니다.
void sub(int b[], int n)
{
b[0] = 4;
b[1] = 5;
b[2] = 6;
}
int main(void)
{
int a[3] = {1, 2, 3};
sub(a, 3); // 배열의 시작주소, 배열 원소 개수
}
sub(a, 3)에서 a를 함수에 넘길 때 a 배열 전체가 넘어가는 게 아니라,
a → &a[0]
으로 바뀌어서 첫 번째 원소의 주소가 함수에 전달된다.
main의 배열 a
+---+---+---+
| 1 | 2 | 3 |
+---+---+---+
↑
│
b가 이 배열을 가리킴
따라서 함수 내부에서 b[0] = 4;를 하면 원본 배열 a[0]이 수정된다.
결과:
호출 전
1 2 3
호출 후
4 5 6
함수의 매개변수에서 void sub(int b[], int n) 와 void sub(int *b, int n) 는 사실상 동일하게 처리된다.
함수 호출 시 배열 이름은 대부분의 경우 배열의 첫 번째 원소를 가리키는 포인터로 변환된다.
예를 들어
int a[3] = {1, 2, 3};
sub(a, 3);
에서 a는 &a[0]을 가리키는 포인터로 변환되어 전달된다.
한편 함수 매개변수에서 void sub(int b[], int n)처럼 배열 형태로 선언한 b는 별도의 규칙에 의해 int *b로 처리된다.
따라서 함수 내부의 b는 전달받은 배열의 첫 번째 원소의 주소를 가지고 있고, b[0] , b[1] 과 같이 접근하면 원본 배열의 요소에 접근하게 된다.
다만 이 규칙은 함수의 매개변수에서 []를 사용할 때의 특별한 규칙이다.
함수 밖에서는 int b[3]; 와 int *b;는 완전히 다르다.
int b[3]
→ int 데이터 3개를 저장할 배열 공간을 직접 만듦
+---+---+---+
| | | |
+---+---+---+
int *b
→ 주소 하나를 저장할 포인터 변수만 만듦
+---------+
| address |
+---------+
즉,
이라고 이해하면 된다.
int arr[3] = {10, 20, 30};
이 코드를 실행하면 메모리에는 int형 데이터 3개가 연속해서 저장된다.
배열 arr
┌───────────────┐
↓ ↓
+----+----+----+
| 10 | 20 | 30 |
+----+----+----+
↑
arr[0]
여기서 arr은 본질적으로 배열 이름이다.
다만 C에서는 arr을 대부분의 식에서 사용하면
배열의 첫 번째 원소를 가리키는 주소인 &arr[0]으로 변환된다.
arr → &arr[0]
그래서 int *p = arr; 이 가능하고,
arr[0] == *arr
arr[1] == *(arr + 1)
p[0] == *p
p[1] == *(p + 1)
처럼 사용할 수 있다.
즉, 배열과 포인터는 서로 다른 개념이지만, C에서 배열 이름은 많은 상황에서 첫 번째 원소를 가리키는 포인터로 변환되기 때문에 배열을 포인터처럼 사용할 수 있다.