Menu

C 언어 포인터 산술: 더하기, 빼기, 메모리 훑기

포인터에 1을 더하는 것은 1바이트를 더하는 것이 아니라 그 타입의 다음 객체로 이동하는 것입니다. ptr+1, 증감, 포인터 차이, 비교, 포인터로 배열 훑기, 그리고 끝 다음 한 칸 규칙을 다룹니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

포인터는 주소를 담고, 주소는 숫자입니다. 그러니 거기에 산술이 통하리라 기대하는 것은 합리적입니다. 통하기는 하는데, 평범한 정수 산술과 꼭 같지는 않습니다. 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 < qp가 더 앞의 원소를 가리킬 때 참입니다.

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++) {
    /* ... */
}

마지막 반복에서 parr + n이 되고, 비교가 실패하고, 반복문이 빠져나갑니다. 그 자리를 읽는 일은 한 번도 없습니다.

그럼에도 여전히 미정의인 것이 둘 있습니다. 끝에서 칸 뒤나 시작 한 칸을 가리키는 포인터를 만드는 것은, 역참조하지 않더라도 미정의 동작입니다.

int *bad1 = arr + n + 1;   // 미정의 - 너무 멀리 지나감
int *bad2 = arr - 1;       // 미정의 - 시작 이전

두 번째는 실제로 중요합니다. for (int *p = arr + n - 1; p >= arr; p--)로 쓴 역순 반복문은 마지막 감소에서 arr - 1을 계산합니다. 흔한 컴파일러에서는 다 동작하지만 형식적으로는 여전히 미정의입니다. 깔끔한 역순 반복문은 그것을 피합니다.

void 포인터와 할 수 없는 산술

void *는 타입이 붙지 않은 주소를 담고, 그래서 mallocqsort가 범용으로 남을 수 있습니다. 원소 크기가 없으므로 표준은 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)++는 포인터를 그대로 두고 그것이 가리키는 값을 증가시킵니다. 후위 ++*보다 강하게 묶이므로, 의미를 바꾸는 것은 괄호입니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기