이 글에서는 C의 배열과 포인터 관계에서 이상하게 느껴지는 점, 내가 다르게 설계했을 법한 부분, 그리고 몇 가지 관련된 생각을 이야기한다.
이 글에서는 내가 그것들에 대해 이상하다고 느끼는 점, 내가 무엇을 다르게 했을지, 그리고 몇 가지 관련된 이야기를 장황하게 풀어보겠다.
기술적으로 말하면, 배열 타입 T[n] (어떤 _n_에 대해)은 포인터 타입 T *와 구별된다. 타입 T[n]의 값은 메모리 안에 연속적으로 놓인 T 값들의 열을 나타내며, 길이는 _n_이다.
하지만 실제로는 타입 T[n]의 값을 가리킬 수 없다. 그 타입이 될 모든 식은 즉시 포인터, 즉 T * 타입으로 변환되며, 구체적으로는 첫 번째 원소를 가리키는 포인터가 된다.
배열 인덱싱 연산자 arr[ix]는 실제로 포인터에 대해 동작하며 *(arr + ix)처럼 작동하므로, 배열은 기본적으로 포인터처럼 다룰 수 있다.
이 일이 일어나지 않는 중요한 경우는 sizeof arr인데, 이것은 sizeof(T) × _n_을 반환한다.
int arr[3] = {10, 20, 30};
int *arr_ptr = arr;
size_t arr_size = sizeof(arr);
size_t ptr_size = sizeof(arr_ptr);
// These may (and likely will) be different
추가로, 함수 시그니처에서는 인수에 배열 타입을 적더라도 실제로는 대신 포인터로 해석된다. 크기를 나타내는 _n_은 완전히 버려진다. 즉, 예외의 예외로서, 인수가 T arr[n]인 함수 안에서의 sizeof arr는 sizeof(T) × _n_으로 평가되지 않는다.
size_t foo(char buf[6]) {
return sizeof(buf);
}
char msg[6] = "!! ??";
size_t msg_size = sizeof(msg);
size_t msg_size_in_fn = foo(msg);
// These may (and likely will) be different
char buf[static 8]처럼 써서 길이를 “강제”할 수는 있다는 점을 알아두자. 하지만 이것은 더 짧은 배열에 대한 포인터를 넘기면 미정의 동작이 되게 할 뿐이다. restrict와 비슷하게, 이것이 하는 일은 컴파일러의 최적화를 돕는 것뿐이다.
대신 배열에 대한 포인터를 인수로 사용할 수 있다. T *, 즉 첫 번째 원소에 대한 포인터로 decay되는 대신, 호출 위치에서 참조를 취해 T (*)[n]을 얻을 수 있다. 실행 시점에는 사실상 같은 것이지만, 이렇게 하면 길이 정보가 보존된다. 다만 쓰기 불편하고 혼란스럽다.
size_t foo(char (*buf)[6]) {
return sizeof(*buf);
}
char msg[6] = "?? !!";
size_t msg_size = sizeof(msg);
size_t msg_size_in_fn = foo(&msg);
// These will be the same
흥미롭게도, C에는 아주 비슷하게 동작하지만 훨씬 덜 혼란스러운 두 번째 타입이 있다. 그것은 함수 타입이다.
배열과 마찬가지로, 함수 값은 즉시 함수 포인터로 강제 변환된다. 하지만 배열과 달리, 함수를 가리키는 변수를 역참조한 것, 예를 들어 *fn은 일반 심볼과 같은 방식으로 그 함수를 호출할 수 있게 해 준다.
void foo() {}
(*foo)();
foo();
배열에 대해 &arr를 쓰면 실제로 배열에 대한 포인터 타입 T (*)[n]을 얻게 되지만, &fn은 fn과 완전히 동등하다. 이는 배열 arr가 &arr로 decay되는 것이 아니라 &arr[0]로 decay되기 때문이며, 반면 함수 fn은 정확히 &fn으로 자동 변환되기 때문이다.
배열과 함수 모두에서, & 연산자의 인수로 주어질 때는 decay되지 않는다는 점도 알아두자. 그래서 &arr는 포인터에 대한 포인터가 아니다.
추가로, 함수 인수 목록에서 T fn()이나 T (*fn)()를 쓰는 것도 동일하다. 두 번째 것은 자동으로 첫 번째 것으로 고쳐지는데, 이는 배열 타입이 자동으로 포인터 타입으로 고쳐지는 것과 매우 비슷하다.
근본적으로 배열 타입은 모든 멤버가 같은 타입인 구조체와 비슷하다. 하지만 배열은 구조체와는 다른 방식으로 자주 사용된다. 우리는 구조체의 두 번째 멤버의 주소를 얻는 일을 거의 하지 않는다. 아마 배열은 시작 위치를 옮겨도 여전히 배열이고, 단지 크기만 달라지기 때문일 것이다. 우리는 배열의 크기를 자주 무시하거나 모르고 있으므로, 이것은 배열을 다루는 자연스러운 방식이다.
나는 C가 배열과 포인터를 엄격히 분리했더라면 이 상황을 머릿속으로 모델링하기 훨씬 쉬웠을 것이라고 생각한다.
배열은 구조체처럼 정확히 동작해야 한다. char[5]를 함수에 넘기면 그 배열 안의 실제 다섯 값이 전달되어야 한다. 함수에 char 인수 다섯 개가 있는 것과 같아야 한다.
int compute(int arr[3]) {
arr[2] += arr[1];
arr[1] *= arr[0];
arr[0] *= (arr[1] + arr[2]);
return arr[0] - arr[2];
}
int arr[3] = {10, 20, 30};
int result = compute(arr);
// arr is not modified
따라서 배열에 대한 포인터는 간접 참조 한 단계만 수반하게 된다. 배열을 포인터처럼 다루고 싶다면 arr의 첫 번째 원소에 대한 포인터를 얻기 위해 &arr[0]를 직접 써야 한다.
void toggle(bool *flag) {
*flag = !*flag;
}
bool arr[2] = {true, true};
toggle(&arr[1]);
가장 분명한 즉각적 이점은 이것이 언어를 배우기에 덜 혼란스럽게 만든다는 점이다. 초보자는 함수 안에서 배열에 쓰면 함수 밖의 배열도 바뀌는데, 구조체는 그렇지 않다는 사실에 아주 쉽게 혼란스러워질 수 있다.
보통 C에서는 참조의 존재가 이것을 매우 명시적이고 이해하기 쉽게 만들어 준다. 사실 이 점에서 C는 Python처럼 객체가 기본적으로 포인터인 언어나, 호출 위치의 변화 없이 함수 시그니처에 따라 인수가 참조로 전달될 수 있는 C++보다 훨씬 단순하고 이해하기 쉽다.
가장 즉각적인 단점은 배열이 항상 복사된다는 점이다. 나는 그것이 반드시 이 아이디어의 가치를 깎는다고는 생각하지 않는다. 단지 그것을 똑똑하게 사용해야 한다는 뜻일 뿐이고, 프로그래머에게 선택지를 줄이는 것이 아니라 더 많이 주는 것이다. (그래도 혹시 걱정한다면, C++ 같은 것만큼 압도적으로 많은 선택지는 아니다)
물론 컴파일러는 목적에 맞을 때 이런 배열들을 포인터를 사용해 구현하기로 선택할 수도 있고, 심지어 선택적으로 그렇게 할 수도 있다. 그러면 더 직관적인 의미론은 그대로 유지될 수 있다.
@ 연산자포인터로부터 그런 배열을 어떻게 만들까? (char[3]){*arr, *(arr + 1), *(arr + 2)}라고 쓰는 것은 정말 몹시 지루할 것이다. 다행히도, 이에 대한 선례가 있다.
디버거인 GDB에는 표현식 시스템이 있고, 그것은 C 문법을 @ 연산자로 확장하는데, 이 연산자는 메모리 주소에 길이를 부여해 그것을 배열로 만드는 데 쓰인다.
하지만 이것은 실제로 메모리 주소를 피연산자로 받지 않는다. 대신 주소인 식에 작동하는 것이 아니라, *ptr처럼 주소를 가진 식에 작동한다.
(gdb) list
1 int main() {
2 int arr[4] = {10, 20, 30, 40};
3 int *at_ix_1 = arr + 1;
4 }
(gdb) break 4
(gdb) run
Breakpoint 1, main ()
4 }
(gdb) print *at_ix_1
$1 = 20
(gdb) print *at_ix_1@1
$2 = {20}
(gdb) print *at_ix_1@2
$3 = {20, 30}
(gdb) print *(at_ix_1 + 1)@2
$4 = {30, 40}
(gdb) print *(at_ix_1 - 1)@4
$5 = {10, 20, 30, 40}
(이 예제에서는 GDB 진단 출력이 약간 단순화되었다)
이것은 = 같은 것들이 이미 작동하는 방식과 비슷하다. *ptr은 단순한 값이 아니라 메모리 안의 특정 위치를 가진 값이어서 쓸 수 있으므로, 우리는 *ptr = 2라고 쓸 수 있다. 2 = 2는 쓸 수 없다. 우리는 이런 식을 장소 식 또는 _lvalue_라고 부른다.
마찬가지로, 첫 번째 원소가 *ptr이고 그 뒤에 원소 9개가 더 있는 배열을 얻기 위해 *ptr@10을 쓴다. 하지만 2@10은 쓸 수 없다. 먼저 2에 장소를 부여해야 한다.
int x = 2;
int x_arr[1] = x@1;
나는 이것이 이 연산자가 작동하는 꽤 멋진 방식이라고 생각한다. 이론적으로는 다음 같은 것도 허용하도록 확장할 수 있다.
struct coords_3d {
int x;
int y;
int z;
} some_point;
struct coords_2d {
int x;
int y;
} some_point_projected = some_point.x@2;
이 경우에는 이것이 약간 부자연스럽게 느껴진다. 아마 배열과 달리 구조체 타입의 일부는 원래 구조체 타입과 그리 쉽게 연결되지 않기 때문인 것 같다. 우리는 어떤 필드들만 아는 구조체를 거의 다루지 않는데, 이것은 크기를 모르는 배열과 비슷한 상황일 수 있다. Berkeley 소켓 API처럼 구조체를 잘라 쓰는 경우는 드물고, 약간 해킹처럼 느껴진다.
우리가 크기를 모르는 배열을 포인터로 이해하는 방식은 사실 더 넓은 패턴의 한 예인데, 그 패턴에서는 직접 다룰 수 없는 어떤 객체를 불투명한 핸들 뒤에 숨긴다. 그리고 실제로 그 객체에 대해 작업하기 위해 빠진 정보를 제공하는 어떤 방법을 둔다.
C 배열에서는 그 빠진 정보가 길이일 수 있고, 그러면 그 정보는 여러 출처 가운데 어디서든 공급될 수 있다.
우리는 그 정보를 배열과 나란히 저장할 수 있다. 메모리 안에서 배열 옆에, 하지만 고정된 오프셋에 두거나, 혹은 우리의 지역 변수 안에서 포인터와 함께 저장할 수도 있다. (또는 그 포인터가 존재하는 다른 어떤 곳에서든)
포인터와 함께 저장하는 것을 우리는 와이드 포인터라고 부른다. 예를 들어 이것은 C++의 std::vector가 구현될 수 있는 방식이며, Rust가 배열 같은 크기 미지 타입에 대한 참조 &[T]를 취할 때 길이를 자동으로 저장하도록 사용하는 방식이기도 하다.
사실 C에서 size_t len, char *buf 같은 매개변수를 받을 때 우리는 이미 사실상 이것을 하고 있다. 인수 두 개를 받는 것은 멤버 둘짜리 구조체 하나를 받는 것과 같고, 그 둘짜리 구조체를 별도의 타입으로 뽑아낸다면 그것은 와이드 포인터다.
실제 데이터 바로 앞의 메모리에 그 추가 데이터를 저장하는 것은 예를 들어 가상 메서드를 가진 C++ 파생 클래스가 하는 일이다. 각주 1
이제 내가 생각하는 개선된 C 배열로 돌아오면, 따라서 다음과 같이 앞뒤로 변환할 수 있을 것이다.
char arr[4] = {'x', 'y', 'z', 'w'};
char *arr_ptr = &arr[0];
char arr_again[4] = *arr_ptr@4;
이 문법에서는 배열을 슬라이스하는 것이 매우 자연스럽다.
int iota[4] = {0, 1, 2, 3};
int one_two[2] = iota[1]@2;
물론 역참조 없이 ptr@n 같은 문법을 두는 것도 똑같이 가능할 것이다. 그래도 (&iota[2])@3 같은 것은 여전히 쓸 수 있다. 다만 나는 그것이 덜 보기 좋고, 장소 식 같은 것들이 어떻게 작동하는지에 대한 통찰도 덜 준다고 생각한다.
여기에는 약간 거친 부분들이 있다. 배열의 시작만 옮기고 싶다면 다음처럼 쓴다.
int arr[2] = {10, 20};
arr = &arr[1]@1;
하지만 그러려면 새 길이를 명시적으로 적어야 한다. sizeof(arr)/sizeof(T)처럼 정의된 배열 크기 연산자가 있다면 그것을 사용할 수 있을 것이다. 그래도 지루하고 보기 흉한 것은 마찬가지다.
세 가지 뻔한 해법은 arr + 1을 허용하거나, arr[1]@... 같은 특별한 문법으로 길이를 자동 추론하게 하거나, arr +@ 1 같은 새 사용자 정의 연산자를 만드는 것이다.
나는 실제로 C를 다시 설계할 수 없고, 지금 새 언어를 쓰고 있는 것도 아니며, 이것도 아마 그렇게 흔한 일은 아니므로, 구체적인 추천은 하지 않겠다.
->마지막으로 -> 연산자를 언급하고 싶다. 이것은 포인터를 다루는지 장소 식을 다루는지가 다소 임의적이라는 점에서 @ 연산자와 비슷하다.
지금 ptr->foo라는 식은 무료로 역참조가 포함된 값(*ptr).foo를 뜻한다. 주소를 얻으려면 &ptr->foo를 쓴다. 하지만 이것은 &(*ptr).foo로 정의될 수도 있었을 것이다. 그러면 값을 얻기 위해서는 *ptr->foo를 썼을 것이다.
지금 구조체에 대한 포인터에서 중첩된 값을 얻으려면 ptr->foo.bar라고 쓴다. 대안적인 ->에서는 (포인터에 대해서) ptr->foo->bar라고 썼을 것이다.
누군가는 ptr->foo.bar가 실제로 따라가는 포인터는 하나뿐이며 ptr->foo 자체는 포인터가 아니라는 점을 보여준다고 말할 수 있다. 하지만 대안 문법도 그 점을 보여줄 수 있는데, 실제 값을 얻기 위해 *ptr->foo->bar라고 썼을 것이기 때문이다.
이것은 근거가 아주 빈약한 느낌일 뿐이고, 어쩌면 완전히 틀렸을지도 모르지만, 나는 ptr->foo->bar 쪽을 아주 약간 더 선호한다. 완전히 포인터의 영역 안에서 작업하는 것이, 컴파일러가 실제로 해야 하는 일이 오프셋 하나를 적용하는 것뿐이라는 사실을 내게는 조금 더 잘 반영하는 것처럼 느껴진다.
하지만 ptr->foo.bar는 장소 식, 역참조 연산자, 주소 취하기 연산자 사이의 멋진 상호작용을 더 잘 반영한다. 위에서 내가 그것을 그렇게 칭찬했으니, 어쩌면 내 감정 중 일부는 위선적일지도 모르겠다.