[C언어 50강] 28강. 포인터 산술: +1 의미, const 포인터/포인터 const
포인터 산술은 C언어에서 “메모리를 값 단위로 읽고 이동한다”는 감각을 만드는 핵심 주제입니다. 특히 p + 1이 정확히 무엇을 의미하는지, const int *p와 int * const p가 어떻게 다른지 구분하지 못하면 이후 동적 메모리, 문자열 처리, 버퍼 순회에서 실수가 반복됩니다. 오늘은 문법 암기보다 타입이 이동 크기를 결정한다는 원리를 중심으로 개념을 단단히 잡아보겠습니다.
핵심 개념
- 포인터 산술의
+1은 “바이트 1칸”이 아니라 “가리키는 타입의 크기만큼” 이동이다. const int *p는 “값(read-only) 보호”,int * const p는 “주소 재지정 금지”이며, 둘 다 붙은const int * const p도 존재한다.- 유효한 포인터 연산 범위는 같은 배열 내부(또는 one-past-end)로 제한되며, 범위를 벗어난 연산/역참조는 정의되지 않은 동작(UB)이다.
ptrdiff_t와size_t를 적절히 사용해 포인터 차이와 길이를 타입 안정적으로 처리해야 한다.
개념 먼저 이해하기
포인터 산술을 이해할 때 가장 중요한 출발점은 “포인터는 타입 정보를 가진 주소”라는 사실입니다. 예를 들어 int *p에서 p + 1은 주소값에 숫자 1을 단순히 더하는 연산처럼 보이지만, 실제로는 sizeof(int)만큼 증가한 주소를 만듭니다. 같은 메모리 주소 연산이어도 char *라면 1바이트, double *라면 8바이트(환경에 따라 다를 수 있음)씩 이동합니다. 즉, C 컴파일러는 포인터 타입을 보고 이동 스케일을 자동 적용합니다. 이 동작이 있는 이유는 배열 순회에서 “원소 단위”로 이동하기 위함입니다.
여기서 자주 생기는 오해는 “포인터는 결국 정수 주소니까 아무렇게나 더해도 된다”는 생각입니다. 주소 자체를 정수로 캐스팅해 다루는 방식은 시스템 프로그래밍에서 제한적으로 쓰일 수 있지만, 일반 애플리케이션 코드에서 이런 방식은 타입 안정성과 이식성을 망가뜨리기 쉽습니다. C의 포인터 산술은 단순한 숫자놀이가 아니라 타입 시스템과 결합된 메모리 순회 도구입니다. 따라서 포인터를 다룰 때는 “지금 나는 바이트를 걷는가, 원소를 걷는가”를 분명히 해야 합니다.
다음으로 const 조합은 실무에서 읽기 계약(contract)을 표현하는 핵심 문법입니다. const int *p는 “p가 가리키는 정수값을 바꾸지 않겠다”는 뜻입니다. 즉 *p = 10;은 금지되지만, p = other;처럼 다른 주소를 가리키게 재지정하는 것은 가능합니다. 반대로 int * const p는 포인터 변수 p 자체가 상수이므로 한 번 초기화한 뒤 다른 주소를 가리키게 바꿀 수 없습니다. 다만 *p = 10;처럼 값 수정은 가능합니다. 이 둘을 동시에 적용한 const int * const p는 값도 못 바꾸고 주소도 못 바꿉니다.
왜 이 구분이 중요할까요? 함수 인터페이스에서 의도를 명확히 전달하기 때문입니다. 예를 들어 문자열 길이를 세는 함수는 데이터를 읽기만 하므로 const char *s를 받아야 안전합니다. 반대로 버퍼를 채우는 함수는 char *buf를 받아야 하며, 함수 내부에서 입력 포인터를 재할당할 필요가 없다면 지역 변수 설계에서 char * const cursor = buf; 같은 패턴으로 실수를 줄일 수 있습니다. 즉 const는 단순 문법 장식이 아니라 “이 함수가 메모리를 어떻게 다룰지”를 선언하는 계약입니다.
포인터 산술의 경계 규칙도 반드시 기억해야 합니다. C 표준에서 포인터 연산은 같은 배열 객체 내부에서만 유효하며, 마지막 원소 바로 다음(one-past-end)까지는 포인터 값 계산이 허용됩니다. 그러나 one-past-end를 역참조하면 즉시 UB입니다. 또한 서로 다른 배열의 포인터를 뺄셈하는 것도 정의되지 않습니다. 실무에서는 이 규칙을 코드로 강제하기 위해 시작 포인터와 끝 포인터를 함께 관리하거나, 길이(size_t n)를 별도 인자로 전달하는 관례를 씁니다.
마지막으로 포인터 차이 결과 타입을 올바르게 쓰는 습관이 중요합니다. end - begin의 결과는 ptrdiff_t 타입이며, 이는 signed 정수형입니다. 반면 길이는 보통 size_t(unsigned)를 사용합니다. 둘을 섞을 때 경고가 날 수 있고, 부호 변환 버그가 생길 수 있으므로 의도적으로 캐스팅하거나 연산 방향을 정리해야 합니다. 이런 디테일이 결국 “잘 돌아가는 코드”와 “오래 유지되는 코드”를 가릅니다.
기본 사용
예제 1) 포인터 타입에 따라 +1 이동량이 다름
#include <stdio.h>
int main(void) {
int ai[3] = {10, 20, 30};
double ad[3] = {1.1, 2.2, 3.3};
char ac[3] = {'A', 'B', 'C'};
int *pi = ai;
double *pd = ad;
char *pc = ac;
printf("int : pi=%p, pi+1=%p\n", (void*)pi, (void*)(pi + 1));
printf("double: pd=%p, pd+1=%p\n", (void*)pd, (void*)(pd + 1));
printf("char : pc=%p, pc+1=%p\n", (void*)pc, (void*)(pc + 1));
return 0;
}
설명:
- 세 포인터 모두
+1을 했지만 실제 주소 증분은 타입 크기에 따라 달라진다. - 배열 순회는 “원소 단위”라는 감각으로 이해해야 한다.
- 주소 출력 숫자만 보지 말고, 왜 그만큼 이동했는지를
sizeof(T)와 연결해서 해석해야 한다.
예제 2) const int * vs int * const 차이
#include <stdio.h>
int main(void) {
int a = 10, b = 20;
const int *p_readonly = &a; // 가리키는 값 수정 금지
// *p_readonly = 11; // 컴파일 에러
p_readonly = &b; // 주소 재지정 가능
int * const p_fixed = &a; // 포인터 자체 재지정 금지
*p_fixed = 11; // 값 수정 가능
// p_fixed = &b; // 컴파일 에러
const int * const p_both = &b;
// *p_both = 30; // 컴파일 에러
// p_both = &a; // 컴파일 에러
printf("a=%d, b=%d\n", a, b);
return 0;
}
설명:
const가 어디에 붙는지에 따라 보호 대상이 달라진다.- 함수 설계 시 읽기 전용 입력은
const T *로 받아야 호출자 의도를 보존할 수 있다. - 팀 코드리뷰에서
const누락은 버그 잠재요인으로 자주 지적된다.
예제 3) 경계 안전 순회와 포인터 차이 계산
#include <stdio.h>
#include <stddef.h>
int sum_positive(const int *arr, size_t n, int *out_sum) {
if (arr == NULL || out_sum == NULL) return 0;
const int *p = arr;
const int *end = arr + n; // one-past-end
int sum = 0;
while (p < end) {
if (*p > 0) sum += *p;
++p;
}
*out_sum = sum;
return 1;
}
int main(void) {
int nums[] = {-3, 5, 0, 8, -1, 4};
size_t n = sizeof(nums) / sizeof(nums[0]);
int sum = 0;
if (sum_positive(nums, n, &sum)) {
const int *begin = nums;
const int *end = nums + n;
ptrdiff_t diff = end - begin;
printf("sum=%d, count=%td\n", sum, diff);
}
return 0;
}
설명:
end = arr + n은 계산 자체는 합법이지만,*end역참조는 금지다.end - begin은 원소 개수 차이를 주며 타입은ptrdiff_t다.- 길이는
size_t, 차이는ptrdiff_t라는 구분을 지키면 경고와 부호 버그를 크게 줄일 수 있다.
자주 하는 실수
실수 1) p + 1을 1바이트 이동으로 오해
- 원인: 포인터를 단순 정수 주소처럼만 이해함.
- 해결: 항상 “현재 포인터 타입의 원소 크기만큼 이동”이라고 해석한다. 바이트 단위 순회가 필요하면
unsigned char *또는char *를 명시적으로 사용한다.
실수 2) one-past-end 포인터를 역참조
- 원인:
arr + n이 유효한 포인터라는 사실을 “읽을 수 있다”로 잘못 확장함. - 해결:
arr + n은 종료 경계 비교 전용으로만 쓰고, 역참조는p < end조건 안에서만 수행한다.
실수 3) const 위치를 반대로 이해
- 원인: 선언을 왼쪽부터 기계적으로 읽어 의미를 놓침.
- 해결: 식별자 기준으로 읽는다.
const int *p는*p가 const,int * const p는p자체가 const라는 규칙을 반복적으로 적용한다.
실무 패턴
- 읽기 전용 버퍼 인자는 기본적으로
const T *data, size_t len으로 설계한다. - 포인터 순회는
for (const T *p = data, *end = data + len; p < end; ++p)같은 패턴으로 경계를 명시한다. - 포인터 차이를 출력/저장할 때는
%td와ptrdiff_t를 사용한다. - 바이트 스트림 처리(네트워크/파일 헤더)는
uint8_t *또는unsigned char *를 사용해 의도를 분명히 한다. - 코드리뷰 체크리스트에 “const 적절성”과 “배열 경계(one-past-end) 준수” 항목을 포함한다.
오늘의 결론
한 줄 요약: 포인터 산술의 본질은 주소 계산이 아니라 타입 기반 원소 이동이며, const는 메모리 접근 권한을 명시하는 계약이다.
연습문제
find_first(const int *arr, size_t n, int target, const int **out_ptr)함수를 작성하세요. 찾으면 해당 원소 주소를 반환하고, 못 찾으면 NULL을 설정하세요. 인덱스 방식과 포인터 순회 방식 둘 다 구현해 비교해 보세요.clamp_all(int *arr, size_t n, int min_v, int max_v)를 작성하세요. 포인터 산술로 순회하며 범위를 벗어난 값을 경계값으로 고정하세요.const int *와int * const를 각각 인자로 받는 작은 예제 함수를 만들어, 가능한 연산/불가능한 연산을 컴파일 에러 주석과 함께 정리하세요.
이전 강의 정답
지난 27강(포인터와 배열의 관계) 연습문제 예시 정답:
print_reverse(const int *arr, size_t n)
- 인덱스 버전 핵심:
for (size_t i = n; i > 0; --i) printf("%d ", arr[i - 1]);
- 포인터 버전 핵심:
const int *p = arr + n; while (p > arr) { --p; printf("%d ", *p); }
max_in_array(const int *arr, size_t n, int *out_max)
- 정답 포인트:
- 입력 검증:
if (!arr || !out_max || n == 0) return 0; - 초기값:
int m = arr[0]; - 순회 비교 후
*out_max = m; return 1;
- 입력 검증:
- 짝수 원소 2배 함수
- 정답 포인트:
for (size_t i = 0; i < n; ++i) if (arr[i] % 2 == 0) arr[i] *= 2;- 원본 수정 함수 시그니처는
int *arr사용(읽기 전용 const 금지).
실습 환경/재현 정보
- 컴파일러: Apple clang 17.x 또는 GCC 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -pedantic -O2 - 실행 환경: macOS (arm64) / Linux (x86_64)
- 재현 체크:
clang -std=c11 -Wall -Wextra -pedantic -O2 lesson28.c -o lesson28- 주소 출력으로 타입별 이동량 확인
- 주석 처리한 컴파일 에러 라인을 하나씩 풀어
const제약 동작 확인 - 경계 조건(
p < end) 제거 시 어떤 위험이 생기는지 코드리뷰 관점으로 점검