const 포인터와 포인터 상수
const가 가리키는 값과 포인터 자체 중 무엇을 제한하는지 세 선언으로 구분하고 함수 매개변수에 적용합니다.
포인터에는 바꿀 수 있는 것이 두 가지 있습니다. 포인터를 통해 가리키는 값을 바꿀 수 있고, 포인터 자체에 다른 주소를 넣어 가리키는 대상을 바꿀 수 있습니다. const를 어디에 붙이느냐에 따라 이 둘 가운데 무엇을 막는지가 달라지며, const int*, int* const, const int* const는 처음 보면 헷갈리기 쉽습니다.
이 글은 세 선언이 각각 무엇을 막는지, 선언을 읽는 요령, 함수 매개변수에 const를 붙이는 기준, 포인터와 참조 가운데 무엇을 고를지를 다룹니다. 아래 그림은 세 선언을 대상 수정과 포인터 재지정의 두 축으로 비교한 것입니다.
POINTEE ACCESS · POINTER OBJECT · TOP-LEVEL CONST
const는 위치에 따라 대상 접근과 포인터 재지정을 제한한다
선언은 대상 수정과 p 재지정을 따로 묻습니다. 대상 타입의 const는 이 경로를 제한합니다. 다른 비-const 별칭의 변경은 막지 않습니다. 객체 전체 불변도 아닙니다.
세 선언은 독립 비교 사례대상 수명·소유권은 별도 조건객체 전체의 불변을 뜻하지 않음
| 선언 | 대상 수정? | p 재지정? | 정확한 의미 |
|---|---|---|---|
const T* p | 아니요 | 예 | p가 제공하는 경로는 읽기 전용이고, p는 다른 T를 가리킬 수 있습니다. |
T* const p | 예 | 아니요 | 대상은 수정할 수 있지만, 이 포인터 객체는 초기화 뒤 다른 주소로 재지정할 수 없습니다. |
const T* const p | 아니요 | 아니요 | 이 경로로는 대상을 수정할 수 없고, 포인터 객체도 재지정할 수 없습니다. |
const T* p
대상 수정: 아니요 · 재지정: 예. p 경로는 읽기 전용이며, 다른 비-const 별칭의 변경은 막지 않습니다.
T* const p
대상 수정: 예 · 재지정: 아니요. 대상 수정만 가능합니다.
const T* const p
대상 수정: 아니요 · 재지정: 아니요. 두 제한이 독립적으로 함께 적용됩니다.
void inspect(T* const p)
최상위 const는 내부 복사본 p만 고정합니다. 함수 타입에는 이 한정이 남지 않습니다. 호출자 포인터와 대상 접근은 바뀌지 않습니다. 읽기 전용에는 const T*가 필요합니다.
오른쪽→왼쪽 읽기는 문법 규칙이 아닌 출발점이다
변수 이름부터 찾습니다. const가 포인터 쪽인지 대상 타입 쪽인지 봅니다. 함수 포인터 등은 괄호와 선언자 구조를 봅니다.
대상 수정 제한과 포인터 재지정 제한을 따로 읽습니다. 세 선언 모두 대상의 수명이나 소유권을 자동으로 관리하지 않습니다.
const int*: 가리키는 값을 바꿀 수 없음
const int* ptr은 ptr을 통해서는 가리키는 int를 바꿀 수 없다는 뜻이며, int const* ptr로 적어도 같습니다. 포인터 자체는 const가 아니므로 다른 주소를 넣을 수 있습니다.
#include <iostream>
int main() {
int a = 10;
int b = 20;
const int* ptr = &a;
// *ptr = 30; // 컴파일 오류: ptr로는 값을 바꿀 수 없음
ptr = &b; // 가능: 다른 대상을 가리킴
std::cout << *ptr << '\n';
}20여기서 막히는 것은 ptr이라는 접근 경로뿐이고, 대상 객체 자체가 바뀌지 않는다는 뜻은 아닙니다. b는 const가 아닌 변수이므로 b = 30;처럼 직접 바꿀 수 있고, 그 뒤 *ptr로 읽으면 30이 나옵니다. 그래서 이런 포인터를 흔히 읽기 전용 포인터라고 부릅니다.
int* const: 포인터를 바꿀 수 없음
int* const ptr은 포인터 자체가 상수라는 뜻입니다. 처음에 가리킨 대상을 끝까지 가리키며, 그 대상의 값은 바꿀 수 있습니다. 포인터 자체가 상수이므로 선언할 때 반드시 초기화해야 합니다.
#include <iostream>
int main() {
int a = 10;
int b = 20;
int* const ptr = &a;
*ptr = 30; // 가능: 대상의 값을 바꿈
// ptr = &b; // 컴파일 오류: 포인터 자체를 바꿀 수 없음
std::cout << a << ' ' << b << '\n';
}30 20*ptr = 30은 a를 30으로 바꾸고, ptr에 b의 주소를 넣으려는 문장은 컴파일되지 않습니다. 대상을 바꿀 수 없다는 점에서 참조자와 비슷하지만, int* const는 nullptr로 초기화할 수 있고 쓸 때 *가 필요하다는 점이 다릅니다.
const int* const: 둘 다 바꿀 수 없음
두 자리에 모두 const를 붙이면, 포인터를 통해 값을 바꿀 수도 없고 포인터에 다른 주소를 넣을 수도 없습니다. 앞의 두 형태를 합친 것이며, 아래 코드에서 주석으로 막아 둔 두 줄은 앞의 두 절에서 각각 컴파일 오류가 났던 것과 같은 종류의 문장이며, 여기서는 둘 다 오류가 됩니다.
#include <iostream>
int main() {
int value = 42;
const int* const ptr = &value;
// *ptr = 100; // 컴파일 오류
// ptr = nullptr; // 컴파일 오류
std::cout << *ptr << '\n';
}42두 길이 모두 막혔으므로 ptr로 할 수 있는 일은 *ptr로 값을 읽는 것뿐입니다. 이 경우에도 막힌 것은 ptr이라는 접근 경로이고, value는 const가 아니므로 value = 100;처럼 이름으로 직접 바꾸는 것은 여전히 됩니다. 그 뒤 *ptr로 읽으면 바뀐 100이 나옵니다.
선언을 읽는 요령
간단한 포인터 선언은 변수 이름에서 시작해 오른쪽에서 왼쪽으로 읽으면 뜻이 드러납니다. * 바로 오른쪽의 const는 포인터 자체에, * 왼쪽의 const는 가리키는 타입에 붙습니다.
| 선언 | 읽는 법 | 대상 수정 | 포인터 재지정 |
|---|---|---|---|
const int* p | p는 const int를 가리키는 포인터 | 불가 | 가능 |
int* const p | p는 int를 가리키는 const 포인터 | 가능 | 불가 |
const int* const p | p는 const int를 가리키는 const 포인터 | 불가 | 불가 |
const 객체의 주소는 const가 아닌 포인터에 담을 수 없습니다. const int limit = 5;이면 int* p = &limit;은 limit을 바꿀 길을 여는 것이어서 컴파일 오류이고, const int* p = &limit;만 허용됩니다. 반대로 const가 아닌 객체의 주소를 const int*에 담는 것은 할 일을 줄이는 것이므로 언제나 허용됩니다.
더 깊이: 포인터의 포인터에 const가 저절로 붙지 않는 이유
int*는 const int*로 저절로 바뀌지만, 한 단계 더 들어간 포인터의 포인터는 그렇지 않습니다. int*를 가리키는 포인터는 const int*를 가리키는 포인터로 바뀌지 않는데, 이 변환을 허용하면 캐스트 없이 const 객체를 바꾸는 길이 생기기 때문입니다. const int limit = 5; int* p;에서 p의 주소를 const int*를 가리키는 포인터 q에 담을 수 있다면, *q = &limit;으로 p가 limit을 가리키게 한 뒤 *p = 10;으로 const 객체를 바꿀 수 있습니다. 뒤의 두 대입은 각각 타입이 맞는 올바른 대입이므로, 막을 수 있는 곳은 처음의 변환뿐입니다.
그래서 int*를 가리키는 포인터는 const int* const*처럼 중간 단계에도 const를 붙인 타입으로만 저절로 바뀝니다. 포인터의 포인터를 받아 읽기만 하는 함수라면 매개변수를 const int* const*로 선언하면 되고, 그러면 *q = &limit; 같은 대입이 컴파일되지 않아 같은 문제가 생기지 않습니다. isocpp.org의 const 정확성 FAQ도 같은 예로 이 규칙을 설명합니다.
포인터 매개변수와 const
함수가 포인터로 받은 데이터를 읽기만 한다면, 매개변수를 const T*로 선언해 바꾸지 않는다는 것을 호출하는 사람에게 알립니다. 바꿔야 하는 경우에만 const 없는 T*를 씁니다.
#include <cstddef>
#include <iostream>
int sum(const int* data, std::size_t count) {
int total = 0;
for (std::size_t i = 0; i < count; ++i) {
total += data[i];
}
return total;
}
void fill(int* data, std::size_t count, int value) {
for (std::size_t i = 0; i < count; ++i) {
data[i] = value;
}
}
int main() {
int values[3]{1, 2, 3};
std::cout << sum(values, 3) << ' ';
fill(values, 3, 7);
std::cout << sum(values, 3) << '\n';
}6 21sum의 const int*는 함수가 배열을 읽기만 한다는 약속이며, 실수로 data[i] = 0;을 적으면 컴파일러가 막아 줍니다. fill은 원소를 바꾸므로 int*로 받습니다. const int* 매개변수에는 const 배열도 넘길 수 있지만, int* 매개변수에는 넘길 수 없다는 점도 const를 붙이는 이유입니다.
포인터 매개변수 자체도 값으로 전달되는 사본입니다. 함수 안에서 매개변수에 다른 주소를 넣어도, 호출한 쪽의 포인터는 바뀌지 않습니다.
#include <iostream>
void redirect(int* p, int& destination) {
p = &destination;
*p = 99;
}
int main() {
int first = 10;
int second = 20;
int* caller = &first;
redirect(caller, second);
std::cout << first << ' ' << second << ' '
<< std::boolalpha << (caller == &first) << '\n';
}10 99 true함수의 p는 caller의 사본이므로, p = &destination으로 second를 가리키게 해도 caller는 계속 first를 가리킵니다. 그 뒤 *p = 99는 p가 지금 가리키는 second를 바꿉니다. 매개변수를 int* const p로 선언하면 함수 안에서 p를 바꾸지 못하게 할 수 있지만, 이 const는 함수 안의 사본에만 붙는 것이어서 함수 오버로딩에서 본 대로 함수의 타입은 int*를 받는 함수와 같습니다.
포인터와 참조 가운데 무엇을 쓸까
매개변수의 형태는 대상이 반드시 있는지와, 함수가 대상을 바꾸는지의 두 질문으로 고릅니다.
PRESENCE · MUTABILITY · LIFETIME · OWNERSHIP
존재·수정 우선, 수명·소유권 별도
필수 대상에는 참조를, nullptr가 의미 있으면 포인터를 먼저 검토합니다. 이어 이 경로에서 원본을 수정할지에 따라 const를 고릅니다.
존재 계약
필수 대상을 표현할지, 없음 상태를 허용할지 결정합니다.
접근 권한
이 함수 경로에서 원본을 수정할지 읽기만 할지 결정합니다.
수명과 소유권
호출 중 유효성, 소유자, 해제 시점은 별도 계약입니다.
| 존재 계약 | 대상 수정? | 형태 | 호출자에게 보이는 의미 |
|---|---|---|---|
| 반드시 존재 | 예 | T& | 유효한 대상을 받아 원본을 수정합니다. 참조 자체에는 정상적인 null 상태가 없습니다. |
| 반드시 존재 | 아니요 | const T& | 유효한 대상을 받아 이 참조 경로로는 읽기만 합니다. |
| 없음 허용 | 예 | T* | nullptr를 검사한 뒤 대상이 있으면 수정합니다. 포인터라는 사실만으로 비-null을 보장하지 않습니다. |
| 없음 허용 | 아니요 | const T* | nullptr를 검사한 뒤 대상이 있으면 이 경로로 읽기만 합니다. |
T&
대상은 반드시 존재하고, 함수는 이 경로로 원본을 수정합니다.
const T&
대상은 반드시 존재하고, 함수는 이 경로로 읽기만 합니다.
T*
nullptr를 허용하며, 대상이 있으면 수정할 수 있습니다.
const T*
nullptr를 허용하며, 대상이 있으면 이 경로로 읽습니다.
참조의 비-null 성질과 수명 유효성은 다른 조건이다
정상적으로 형성된 참조에는 null 상태가 없지만, 참조 대상이 먼저 파괴되면 참조는 댕글링할 수 있습니다. 포인터도 비-null인 것만으로 유효한 객체를 가리킨다고 보장되지 않습니다.
원시 포인터와 참조 자체는 소유권 이전을 표현하지 않는다
빌린 접근은 수명 규칙을 문서화합니다. 소유권 이전·공유에는 값이나 스마트 포인터를 씁니다. 연속 범위는 길이를 담는 std::span 같은 뷰를 검토합니다.
이 표는 빌린 대상의 존재·수정 의도를 정하는 설계 기준입니다. 포인터 타입이 널을 허용한다고 모든 API가 널을 허용하는 것은 아니므로 실제 함수 계약을 함께 확인합니다.
| 대상 | 바꾸는가 | 형태 |
|---|---|---|
| 반드시 있음 | 예 | T& |
| 반드시 있음 | 아니요 | const T& |
| 없을 수 있음 | 예 | T*, 쓰기 전에 nullptr 검사 |
| 없을 수 있음 | 아니요 | const T*, 읽기 전에 nullptr 검사 |
대상이 반드시 있다면 참조가 더 단순하고, nullptr로 "없음"을 나타내야 한다면 포인터가 알맞습니다. 위 그림의 마지막 칸처럼, 포인터와 참조는 둘 다 대상을 빌려 쓸 뿐 소유하지 않습니다. 대상이 호출 동안 살아 있는지, 누가 해제하는지는 동적 메모리 할당에서 본 std::unique_ptr 같은 소유 타입이나 코드의 구조로 따로 정해야 합니다.
정리
const int* p는p를 통해 값을 바꿀 수 없지만p에 다른 주소를 넣을 수 있습니다.int* const p는p에 다른 주소를 넣을 수 없지만 값은 바꿀 수 있고, 선언할 때 초기화해야 합니다.const int* const p는 둘 다 막으며, 선언은 변수 이름에서 오른쪽에서 왼쪽으로 읽습니다.- 대상 쪽의
const는 그 포인터 경로만 막으며, 다른 경로로 대상이 바뀌는 것까지 막지는 않습니다. - 읽기만 하는 포인터 매개변수는
const T*로 받고, 포인터 매개변수 자체는 사본이어서 함수 안에서 바꿔도 호출한 쪽에 영향이 없습니다. - 대상이 반드시 있으면 참조, 없을 수 있으면 포인터를 쓰고, 수명과 소유는 따로 정합니다.
연습 문제
1. 매개변수 선언 바꾸기
redirect.cpp에서 첫 매개변수만 int* const p, const int* p, const int* const p로 각각 바꾸면, 본문의 두 대입 가운데 어느 것이 컴파일 오류가 되나요?
해설 보기
int* const p는 포인터를 바꿀 수 없으므로 p = &destination;에서 오류가 납니다. const int* p는 재지정은 되지만 *p = 99;에서 오류가 납니다. const int* const p는 두 대입이 모두 오류입니다.
2. 읽기 전용 경로와 원본
int x = 1; const int* r = &x; x = 2; 다음에 *r을 읽으면 무엇이 나오나요? const int c = 5;일 때 int* p = &c;는 컴파일되나요?
해설 보기
r이 막는 것은 r을 통한 수정뿐이고, x는 const가 아니므로 직접 2로 바꿀 수 있습니다. *r은 같은 객체를 읽으므로 2입니다. 반면 int* p = &c;는 const 객체를 바꿀 수 있는 경로를 만들려는 것이어서 컴파일 오류이며, const int* p = &c;로 선언해야 합니다.
3. 알맞은 매개변수 형태
int 배열의 최댓값을 찾는 함수 maxOf의 매개변수를 int* data로 선언했다면, 어떤 문제가 생길 수 있나요?
해설 보기
최댓값을 찾는 함수는 원소를 읽기만 하는데 int*로 받으면, 실수로 원소를 바꾸는 코드를 써도 컴파일러가 막지 못합니다. 또 const int arr[]처럼 const 배열은 int*에 넘길 수 없어 함수를 쓸 수 없습니다. const int* data와 원소 개수를 받거나, std::span<const int>를 받도록 고칩니다.
이것으로 7장의 포인터와 참조를 마칩니다. 다음 장의 스트림과 파일 입출력 개요에서는 지금까지 써 온 std::cin과 std::cout의 바탕인 스트림을 정리하고, 같은 방식으로 파일을 읽고 쓰는 방법으로 넘어갑니다.