포인터는 주소를 담고, 주소는 숫자입니다. 그러니 거기에 산술이 통하리라 기대하는 것은 합리적입니다. 통하기는 하는데, 평범한 정수 산술과 꼭 같지는 않습니다. C는 모든 포인터 연산을 가리키는 대상의 크기로 비례시키며, 이것이 "1을 더한다"를 훨씬 유용한 "다음 원소로 이동한다"로 바꿔 줍니다.
이 설계 결정 하나 덕분에, 포인터로 배열을 훑는 것이 C에서는 인덱스로 훑는 것만큼이나 자연스럽습니다.
1을 더하면 1바이트가 아니라 객체 하나만큼 움직인다
포인터를 잡고 1을 더한 뒤 두 주소를 출력해 봅시다.
int *는 4바이트, double *는 8바이트, char *는 1바이트 뜁니다. 각각 자기 타입의 다음 객체가 시작될 바로 그 자리에 떨어집니다. 규칙은 간단합니다.
ptr + n == (ptr의 주소) + n * sizeof(*ptr)
포인터에 타입이 있는 이유가 바로 이것입니다. 타입이 붙지 않은 맨 주소는 "다음"이 얼마나 떨어져 있는지 알 길이 없습니다.
포인터로 배열 훑기
이제 그 보상입니다. 배열의 원소는 연속된 메모리에 놓여 있으므로, 포인터를 한 칸씩 옮기면 순서대로 방문하게 됩니다.
두 반복문 모두 같은 다섯 숫자를 출력합니다. 두 번째에서 scores는 첫 원소를 가리키는 포인터로 붕괴하고, p++는 int 하나만큼 전진하며, *p는 그 자리에 있는 원소를 읽습니다. 메모리에 펼치면 이렇습니다.
scores[0] scores[1] scores[2] scores[3] scores[4]
+---------+---------+---------+---------+---------+
| 88 | 92 | 75 | 60 | 100 |
+---------+---------+---------+---------+---------+
^ ^ ^
p p+1 scores+5
(시작) (4바이트 뒤) (끝 다음 한 칸)
두 형태 사이의 관계는 정확하고 외워 둘 만합니다. scores[i]는 *(scores + i)로 정의됩니다. 자세한 내용은 포인터와 배열에 있습니다.
증가, 감소, 그리고 *p++ 함정
++와 --는 같은 비례 규칙으로 포인터에 동작합니다.
모두를 헷갈리게 하는 식은 *p++입니다. 후위 ++가 *보다 강하게 묶이므로, "현재 p를 취하고, p를 전진시키고, 이전 값을 역참조하라"는 뜻입니다.
식 셋, 서로 다른 세 의미입니다.
| 식 | 이후 포인터 | 만들어지는 값 |
|---|---|---|
*p++ | 전진함 | 이동 전의 원소 |
*++p | 전진함 | 이동 후의 원소 |
(*p)++ | 그대로 | 이전 값, 그리고 원소가 증가함 |
*p++는 관용적인 C입니다. 문자열과 버퍼 복사가 이렇게 쓰입니다. 하지만 다른 것을 뜻한다면 괄호를 쓰세요.
포인터 둘 빼기
포인터에서 포인터를 빼면 바이트 수가 아니라 둘 사이의 원소 개수가 나옵니다.
결과 타입은 <stddef.h>의 부호 있는 정수 타입 ptrdiff_t이고 %td로 출력합니다. 서로 다른 배열을 가리키는 포인터를 빼면 결과가 미정의입니다. 표준은 한 객체 안에서만 이 연산을 정의합니다.
포인터에서 정수를 뺄 수도 있지만(p - 3은 원소 세 개 뒤로 갑니다), 포인터 둘을 더할 수는 없습니다. "두 주소의 합"은 아무것도 가리키지 않습니다.
포인터 비교
관계 연산자는 같은 배열 안을 가리키는 포인터에 동작하고, 메모리 배치가 암시하는 바로 그 뜻입니다. p < q는 p가 더 앞의 원소를 가리킬 때 참입니다.
front < back이 이 반복문의 종료 이야기 전부이고, 두 포인터가 모두 한 배열 안에 머물기 때문에 올바릅니다. ==와 !=도 반복문을 끝 표시와 비교하는 자연스러운 방법이며, 그것이 이 모든 것을 합법으로 만드는 규칙으로 이어집니다.
끝 다음 한 칸 규칙
C는 배열의 마지막 원소 다음 한 칸을 가리키는 포인터를 만드는 것을 명시적으로 허용합니다. 계산하고, 저장하고, 비교할 수 있습니다. 역참조는 안 됩니다.
int arr[4];
arr[0] arr[1] arr[2] arr[3] (원소 없음)
+------+ +------+ +------+ +------+ +- - - -+
| | | | | | | | | |
+------+ +------+ +------+ +------+ +- - - -+
^ ^
arr arr + 4
사용 가능 만들고 비교하는 것은 합법
역참조는 절대 불가
그 규칙이 표준적인 반복문 모양을 유효하게 만듭니다.
for (int *p = arr; p != arr + n; p++) {
/* ... */
}
마지막 반복에서 p가 arr + n이 되고, 비교가 실패하고, 반복문이 빠져나갑니다. 그 자리를 읽는 일은 한 번도 없습니다.
그럼에도 여전히 미정의인 것이 둘 있습니다. 끝에서 두 칸 뒤나 시작 앞 한 칸을 가리키는 포인터를 만드는 것은, 역참조하지 않더라도 미정의 동작입니다.
int *bad1 = arr + n + 1; // 미정의 - 너무 멀리 지나감
int *bad2 = arr - 1; // 미정의 - 시작 이전
두 번째는 실제로 중요합니다. for (int *p = arr + n - 1; p >= arr; p--)로 쓴 역순 반복문은 마지막 감소에서 arr - 1을 계산합니다. 흔한 컴파일러에서는 다 동작하지만 형식적으로는 여전히 미정의입니다. 깔끔한 역순 반복문은 그것을 피합니다.
void 포인터와 할 수 없는 산술
void *는 타입이 붙지 않은 주소를 담고, 그래서 malloc과 qsort가 범용으로 남을 수 있습니다. 원소 크기가 없으므로 표준은 void *에 대한 포인터 산술을 허용하지 않습니다. 먼저 구체적인 타입으로(또는 바이트 단위 작업이라면 char *로) 캐스팅하세요.
void *v = buffer;
// v + 1; // 표준 C가 아니다 - 비례시킬 크기가 없다
char *b = v;
b + 1; // 괜찮다 - 한 바이트 앞으로
GCC와 Clang은 void * 산술을 char *처럼 다루는 확장으로 받아들이므로, 거기에 기댄 코드는 더 엄격한 곳에서 빌드되는 날까지는 컴파일됩니다. 지적을 받고 싶다면 -std=c17 -pedantic으로 컴파일하세요.
이것이 주는 것
포인터 산술은 가끔 꺼내 쓰는 최적화 요령이 아닙니다. 배열 인덱싱, 문자열 처리, 그리고 표준 라이브러리의 모든 버퍼 순회 밑에 깔린 메커니즘입니다. strlen은 종료 '\0'까지 전진한 포인터에서 시작 주소를 뺀 것이고, memcpy는 나란히 걷는 포인터 둘입니다.
다음으로, 배열을 처음 함수에 넘길 때 모두가 걸리는 sizeof 함정을 포함해 배열과 포인터의 동등성이 실제로 어떻게 동작하는지 보세요. 포인터와 배열입니다.
자주 묻는 질문
C에서 ptr + 1은 무엇을 하나요?
포인터를 1바이트가 아니라 객체 하나만큼 앞으로 옮깁니다. 보통의 기계에서 int *라면 4바이트이고, double *라면 8바이트입니다. 컴파일러가 대신 sizeof(*ptr)을 곱해 주며, 그래서 포인터의 타입이 중요합니다.
C에서 포인터 둘을 뺄 수 있나요?
둘 다 같은 배열 안을 가리킬 때 가능합니다. end - start는 둘 사이의 원소 개수를 주고, 타입은 ptrdiff_t입니다(%td로 출력합니다). 서로 무관한 객체를 가리키는 포인터를 빼는 것은 미정의 동작입니다.
배열의 끝 다음 한 칸을 가리켜도 되나요?
됩니다. 마지막 원소 다음 한 칸을 가리키는 포인터를 만드는 것은 명시적으로 허용되며, 그래야 for (int *p = arr; p != arr + n; p++) 같은 반복문이 동작합니다. 그 포인터를 계산하고 비교할 수는 있지만, 역참조하는 것은 미정의 동작입니다.
*p++와 (*p)++는 왜 다른가요?
*p++는 포인터를 증가시키고 이전 값을 역참조합니다. 즉 현재 원소를 읽은 뒤 전진합니다. (*p)++는 포인터를 그대로 두고 그것이 가리키는 값을 증가시킵니다. 후위 ++가 *보다 강하게 묶이므로, 의미를 바꾸는 것은 괄호입니다.