한 줄 요약: C++의 const는 붙는 위치에 따라 값, 포인터, 포인터가 가리키는 대상, 멤버 함수의 객체 상태 중 서로 다른 부분을 수정하지 않겠다는 계약을 표현합니다.
왜 중요한가
- C++ 코드를 읽다 보면 `const int*`, `int* const`, `const int* const`, `Title() const`처럼 const가 여러 위치에 등장합니다. 문법을 통째로 외우면 잠시 후 다시 헷갈리지만, const가 바로 왼쪽 타입을 제한하고 왼쪽에 대상이 없으면 오른쪽을 제한한다고 읽으면 무엇이 고정되는지 차근차근 확인할 수 있습니다.
- const는 단순히 컴파일 오류를 늘리는 장치가 아닙니다. 함수가 입력을 읽기만 하는지, 포인터를 다른 대상으로 바꿀 수 있는지, const 객체에서도 멤버 함수를 호출할 수 있는지를 인터페이스에 드러냅니다.
핵심 개념
변수와 참조의 const는 읽기 전용 경로를 만든다
`const int count = 10;`에서 const는 count를 통해 저장된 값을 바꾸지 않겠다는 뜻입니다.
선언과 동시에 초기화할 수 있지만 이후 `count = 20;`처럼 대입하면 컴파일 오류가 납니다.
컴파일러가 막는 대상은 count라는 표현식을 통한 수정이며, 이 제한 덕분에 뒤쪽 코드에서 값이 의도치 않게 바뀌지 않는다는 가정을 세울 수 있습니다.
참조에서는 `const std::string& name`처럼 참조가 가리키는 객체를 이 경로로 수정하지 않겠다는 계약을 표현합니다.
const 참조는 비 const 객체에도 바인딩할 수 있고 const 객체에도 바인딩할 수 있어서, 복사 없이 읽기만 하는 함수 매개변수에 잘 맞습니다.
원본이 비 const라면 다른 경로를 통해 수정될 수 있으므로 const 참조가 객체 전체를 영구적으로 고정한다고 이해하면 안 됩니다.
const int*와 int const*는 같은 의미다
`const int* ptr`에서 ptr은 int를 가리키는 포인터이고, const는 가리키는 int에 적용됩니다. 따라서 `ptr = &other;`처럼 포인터를 다른 int 주소로 바꿀 수 있지만 `*ptr = 3;`처럼 그 경로를 통해 값을 수정할 수는 없습니다. 읽기 전용으로 관찰할 대상을 선택하거나 순회할 때 자주 쓰이는 형태입니다.
`int const* ptr`도 같은 타입입니다. const를 기준으로 왼쪽을 읽으면 const int를 가리키는 포인터라는 사실이 보이고, 왼쪽에 타입이 이미 있으므로 그 타입을 제한합니다.
팀 스타일에 따라 `const int*` 또는 `int const*`를 선택할 수 있지만, 한 코드 안에서는 일관된 표기가 읽는 속도를 높여 줍니다.
여기서 읽기 전용이라는 표현은 ptr을 통한 접근에 한정됩니다.
원본 int가 비 const 객체라면 다른 비 const 포인터나 변수 이름을 통해 값이 바뀔 수 있습니다.
함수가 `const T*`를 받는다는 것은 적어도 그 함수가 해당 포인터 경로로 T를 수정하지 않겠다는 의미이지, 프로그램 전체에서 T가 절대 바뀌지 않는다는 보장은 아닙니다.
int* const는 포인터 자체를 고정한다
`int* const ptr = &value;`에서는 const 바로 왼쪽에 포인터를 뜻하는 `*`가 있으므로 ptr 자체가 const입니다. 선언할 때 반드시 가리킬 주소를 정해야 하고 이후 다른 주소를 대입할 수 없습니다. 대신 가리키는 int는 const가 아니므로 `*ptr = 30;`처럼 값을 수정할 수 있습니다.
가리키는 값과 포인터 모두를 고정하려면 `const int* const ptr`를 사용합니다. 오른쪽 const는 포인터 자체의 재지정을 막고, 왼쪽 const는 가리키는 int를 해당 경로로 수정하지 못하게 합니다.
선언을 오른쪽 변수 이름에서 왼쪽으로 읽으면 ‘ptr은 const 포인터이며 const int를 가리킨다’고 정리할 수 있습니다.
멤버 함수 뒤의 const는 this가 가리키는 객체를 제한한다
`const std::string& Title() const`에서 앞쪽 const는 반환되는 문자열을 이 참조로 수정하지 못하게 하고, 괄호 뒤 const는 멤버 함수가 객체의 논리적 상태를 바꾸지 않겠다는 뜻입니다. 위치가 다른 두 const가 서로 다른 대상을 제한하므로 반환 타입과 함수 한정자를 나누어 읽어야 합니다.
const 객체에서는 기본적으로 const 멤버 함수만 호출할 수 있습니다. 조회 함수에 뒤쪽 const를 빠뜨리면 구현이 실제로 멤버를 바꾸지 않더라도 const 객체에서 호출할 수 없습니다. getter와 관찰 함수에 const를 정확히 붙이면 비 const 객체와 const 객체 모두에서 같은 읽기 인터페이스를 사용할 수 있습니다.
const 멤버 함수 안에서는 일반 데이터 멤버를 수정할 수 없지만 `mutable`로 선언한 멤버는 예외입니다. 캐시나 잠금처럼 객체의 외부에서 관찰되는 논리적 값은 유지하면서 내부 관리 상태만 바꿀 때 사용할 수 있습니다. mutable을 const를 우회하는 도구로 넓게 사용하면 계약이 흐려지므로, 논리적 const와 스레드 안전성까지 함께 검토해야 합니다.
코드 예제
포인터에서 const가 붙는 위치 비교하기
가장 자주 헷갈리는 세 선언을 한 프로그램에 놓고, 포인터 재지정과 값 수정 중 어느 연산이 허용되는지 비교해 보겠습니다. 컴파일되지 않는 줄은 주석으로 남겨 각 타입의 제한을 바로 확인할 수 있게 했습니다.
#include <iostream>
int main()
{
int first = 10;
int second = 20;
const int* pointerToConst = &first;
pointerToConst = &second; // 포인터가 가리키는 대상은 변경 가능
// *pointerToConst = 30; // 컴파일 오류: 이 경로로 값 수정 불가
int* const constPointer = &first;
*constPointer = 30; // 가리키는 값은 수정 가능
// constPointer = &second; // 컴파일 오류: 포인터 자체 재지정 불가
const int* const fixedReadOnly = &second;
std::cout << *pointerToConst << ' '
<< *constPointer << ' '
<< *fixedReadOnly << '\n';
}
`pointerToConst`는 다른 주소를 선택할 수 있지만 값을 쓸 수 없고, `constPointer`는 주소를 바꿀 수 없지만 값을 쓸 수 있습니다. `fixedReadOnly`는 두 동작을 모두 막습니다.
const가 선언 전체를 막는다고 외우기보다 어떤 표현식의 수정을 막는지 나누어 보면 위치별 의미가 선명해집니다.
const 참조로 수정 의도 없는 입력 받기
함수가 문자열을 출력하기만 한다면 비 const 참조보다 const 참조가 더 넓고 정확한 입력 계약을 제공합니다. 다음 예제에서는 수정 가능한 문자열과 const 문자열을 같은 함수에 전달합니다.
#include <iostream>
#include <string>
void PrintName(const std::string& name)
{
std::cout << name << '\n';
}
int main()
{
std::string mutableName = "AutoBlog";
const std::string fixedName = "C++17";
PrintName(mutableName);
PrintName(fixedName);
}
`PrintName`은 두 객체 모두 복사하지 않고 읽습니다. 함수 안에서 name을 수정할 수 없다는 사실이 시그니처에 드러나므로 호출자는 입력이 변경되지 않을 것이라고 예상할 수 있습니다.
참조를 함수 밖에 저장한다면 원본 수명을 별도로 확인해야 한다는 점은 const 여부와 별개입니다.
const 객체에서 호출할 수 있는 멤버 함수 만들기
클래스의 조회 함수와 변경 함수를 구분하면 const 객체가 사용할 수 있는 인터페이스가 자연스럽게 정해집니다. 아래 Article 클래스는 제목 조회에는 뒤쪽 const를, 제목 변경에는 비 const 멤버 함수를 사용합니다.
#include <iostream>
#include <string>
class Article
{
public:
explicit Article(std::string title) : title_(std::move(title)) {}
const std::string& Title() const
{
return title_;
}
void Rename(std::string title)
{
title_ = std::move(title);
}
private:
std::string title_;
};
int main()
{
Article draft("const 위치별 의미");
draft.Rename("C++ const 위치별 의미 정리");
const Article published("Published article");
std::cout << draft.Title() << '\n';
std::cout << published.Title() << '\n';
}
`published`는 const 객체이므로 `Title()`은 호출할 수 있지만 `Rename()`은 호출할 수 없습니다. 반환 타입의 `const std::string&`는 내부 문자열을 외부에서 수정하는 경로도 막습니다.
실제 클래스에서 참조를 반환할 때는 반환된 참조가 객체보다 오래 사용되지 않도록 수명도 함께 문서화해야 합니다.
자주 하는 실수
실수 1. const int*를 값 수정용 포인터로 사용하기
함수 매개변수를 `const int*`로 선언하면 함수는 그 포인터를 통해 int를 읽을 수만 있습니다. 값을 초기화하거나 재설정하는 함수에 이 타입을 사용하면 구현하려는 동작과 시그니처가 충돌합니다.
문제 코드 — 잘못된 예시:
void Reset(const int* value)
{
// 잘못된 예시: const int*는 값 수정을 허용하지 않습니다.
*value = 0;
}
개선 코드:
void Reset(int* value)
{
if (value != nullptr)
{
*value = 0;
}
}
void Observe(const int* value)
{
// 읽기 전용 접근만 수행합니다.
}
수정이 목적이라면 null 가능성을 검토한 뒤 `int*` 또는 필수 입력인 `int&`를 선택해야 합니다. 관찰만 하는 함수는 `const int*`를 유지해 수정하지 않는다는 의도를 드러냅니다.
const를 제거하는 것이 해결이 아니라 함수의 책임에 맞는 타입을 고르는 것이 핵심입니다.
실수 2. 조회 함수에서 뒤쪽 const를 빠뜨리기
getter가 멤버를 바꾸지 않더라도 함수 선언 뒤에 const가 없으면 컴파일러는 비 const 멤버 함수로 취급합니다. 그러면 const 객체나 const 참조를 통해 해당 getter를 호출할 수 없습니다.
문제 코드 — 잘못된 예시:
#include <string>
class Article
{
public:
// 잘못된 예시: 뒤쪽 const가 없어 const 객체에서 호출할 수 없습니다.
const std::string& Title()
{
return title_;
}
private:
std::string title_ = "const";
};
개선 코드:
#include <string>
class Article
{
public:
const std::string& Title() const
{
return title_;
}
private:
std::string title_ = "const";
};
개선 코드의 `Title() const`는 this가 가리키는 Article을 수정하지 않는다는 계약을 표현합니다.
반환 타입의 const와 함수 뒤쪽 const는 역할이 다르므로 둘 중 하나만 붙여서는 같은 효과를 얻을 수 없습니다.
조회 함수가 내부 캐시를 갱신해야 한다면 논리적 const가 유지되는지 먼저 검토합니다.
실수 3. const_cast로 실제 const 객체를 수정하기
레거시 API와 연결할 때 const를 제거하고 싶은 유혹이 생기지만, `const_cast`는 원래 객체의 수정 가능성을 바꾸지 않습니다. 실제로 const로 정의된 객체를 캐스팅한 뒤 쓰면 안전한 우회가 되지 않습니다.
문제 코드 — 잘못된 예시:
void ForceChange(const int& value)
{
// 잘못된 예시: 원본이 실제 const 객체면 수정 시 정의되지 않은 동작입니다.
int& writable = const_cast<int&>(value);
writable = 42;
}
int main()
{
const int fixed = 10;
ForceChange(fixed);
}
개선 코드:
void SetValue(int& value)
{
value = 42;
}
int main()
{
int mutableValue = 10;
SetValue(mutableValue);
}
문제 코드에서 fixed는 처음부터 const 객체이므로 캐스팅한 참조로 값을 쓰면 정의되지 않은 동작입니다.
수정이 필요한 작업은 처음부터 비 const 참조나 포인터를 받도록 계약을 설계해야 합니다.
const_cast가 필요한 상호 운용 코드에서는 원본이 실제로 수정 가능한 객체인지와 외부 API가 값을 쓰지 않는지까지 좁은 범위에서 검증해야 합니다.
정리
const를 읽을 때는 선언 전체를 한 번에 외우지 말고 어떤 대상의 수정을 제한하는지 찾는 것이 좋습니다.
`const int*`는 가리키는 값을, `int* const`는 포인터 자체를, `const int* const`는 둘 모두를 제한합니다.
참조와 함수 매개변수의 const는 읽기 전용 접근 의도를 드러내고, 멤버 함수 뒤의 const는 const 객체에서도 호출 가능한 관찰 인터페이스를 만듭니다. 같은 단어가 여러 위치에 나타나도 제한 대상은 서로 다릅니다.
참고 자료
- cppreference: cv type qualifiers — const와 volatile 한정자의 기본 규칙, const 객체 수정과 정의되지 않은 동작, mutable 멤버 규칙을 확인할 때 참고합니다.
- cppreference: Pointer declaration — 포인터 선언 문법과 포인터 자체 또는 가리키는 타입에 적용되는 cv 한정자의 위치를 비교할 때 유용합니다.
- Microsoft C++ documentation: const — MSVC 환경에서 const 변수, 포인터, 멤버 함수 예제를 대조하고 컴파일러 관점의 설명을 확인할 때 사용합니다.