[Effective C++] Cp 3. const 사용의 습관화
1. 왜 const를 사용해야 하는가
const를 쓰는 이유는 간단하다. "바뀌면 안 되는 값을 못 바꾸게 만들기" 위해서다.
의도의 전달
const를 붙이면 "이 값은 안 바뀐다"는 약속을 코드에 직접 적는 셈이다. 그래서 나중에 코드를 읽는 사람(미래의 나 포함)도 의도를 바로 이해할 수 있다.
컴파일러를 통한 실수 방지
const 값은 바꾸려고 하면 컴파일 단계에서 바로 에러가 난다. 프로그램을 실행하기 전에 실수를 잡을 수 있다는 뜻이다.
const int maxSize = 100;
maxSize = 200; // 컴파일 에러 → 실수를 즉시 발견
핵심은 "내가 조심하자"가 아니라 "컴파일러가 막아주게 하자"다. const를 붙일 수 있으면 가능한 붙이는 편이 안전하다.
2. const의 적용 범위
const는 생각보다 여러 곳에 적용할 수 있다.
2-1. 변수
가장 기본적인 사용법이다. 처음 정한 값이 이후에 바뀌면 안 될 때 쓴다.
const int bufferSize = 1024;
const std::string name = "sonji-log/tistory";
2-2. 포인터
포인터에서는 const를 어디에 붙이느냐가 중요하다. "가리키는 값"을 고정할지, "포인터 자체"를 고정할지가 달라진다.
int value = 10;
const int* p1 = &value; // 가리키는 대상이 const (대상 변경 불가)
int* const p2 = &value; // 포인터 자체가 const (다른 곳을 가리킬 수 없음)
const int* const p3 = &value; // 둘 다 const
쉽게 보면 * 기준으로 읽으면 된다. 왼쪽 const는 값 보호, 오른쪽 const는 포인터 보호다.
2-3. 반복자(Iterator)
STL 반복자도 포인터와 비슷하게 생각하면 된다.
const std::vector<int>::iterator iter = vec.begin(); // iter는 다른 곳을 가리킬 수 없다 (T* const와 동일)
*iter = 10; // OK, 가리키는 값은 변경 가능
std::vector<int>::const_iterator cIter = vec.begin(); // 가리키는 값을 변경할 수 없다 (const T*와 동일)
*cIter = 10; // 컴파일 에러
2-4. 함수 매개변수
함수 안에서 인자를 바꾸지 않겠다는 뜻을 명확히 하고 싶을 때 사용한다. 특히 참조(&)로 받을 때 자주 쓴다.
// 원본을 변경하지 않겠다는 보장
void print(const std::string& text)
{
std::cout << text << std::endl;
// text = "modified"; // 컴파일 에러
}
const 참조를 쓰면 복사는 줄이고, 원본은 보호할 수 있다. 그래서 기본 선택으로 많이 쓴다.
2-5. 함수 반환값
반환값에 const를 붙이면, 반환된 임시 값에 잘못 대입하는 실수를 막을 수 있다.
class Rational { /* ... */ };
const Rational operator*(const Rational& lhs, const Rational& rhs);
예를 들어 아래 같은 실수를 컴파일 단계에서 걸러준다.
Rational a, b, c;
(a * b) = c; // const 반환이므로 컴파일 에러
// 의도는 비교(==)였을 가능성이 높다
3. const 멤버 함수
3-1. 왜 멤버 함수에 const를 붙이는가
멤버 함수 뒤에 const를 붙이면 "이 함수는 객체 상태를 바꾸지 않는다"는 약속이 된다. 이 약속 덕분에 실제로 얻는 이점이 두 가지 있다.
- const 객체에서도 호출할 수 있다.
- 함수 선언만 보고도 '상태 변경 여부'를 판단하기 쉽다.
class TextBlock
{
public:
// const 멤버 함수 — const 객체에서도 호출 가능
const char& operator[](std::size_t position) const
{
return text[position];
}
// non-const 멤버 함수 — non-const 객체에서만 호출 가능
char& operator[](std::size_t position)
{
return text[position];
}
private:
std::string text;
};
void print(const TextBlock& ctb)
{
std::cout << ctb[0]; // OK — const 버전 호출
// ctb[0] = 'x'; // 컴파일 에러 — const char& 반환
}
TextBlock tb("Hello");
tb[0] = 'J'; // OK — non-const 버전 호출, char& 반환
이런 const 오버로딩이 없으면, print처럼 const 참조를 받는 코드에서 operator[]를 못 쓰게 된다.
3-2. bitwise constness vs logical constness
const를 보는 기준은 두 가지가 있고, 둘이 항상 같지는 않다.
bitwise constness (물리적 상수성)
컴파일러 기준의 상수성이다. 멤버 변수가 한 비트도 바뀌지 않으면 const라고 본다. 그런데 이 기준만으로는 놓치는 경우가 있다.
class CTextBlock
{
public:
char& operator[](std::size_t position) const
{
return pText[position]; // 포인터 자체는 안 바뀌니 컴파일 통과
}
private:
char* pText;
};
const CTextBlock cctb("Hello");
char* pc = &cctb[0];
*pc = 'J'; // const 객체의 내용이 바뀌어 버린다!
위 코드에서는 포인터 변수 자체는 그대로라서 컴파일러가 통과시킨다. 하지만 실제 문자열 내용은 바뀌었으니, 사람 입장에서는 "변경됨"이다.
logical constness (논리적 상수성)
프로그래머 기준의 상수성이다. "사용자 눈에 보이는 결과가 안 바뀌면 const로 보자"는 생각이다. 예를 들어 캐시 계산처럼 내부 최적화는 외부 동작이 같다면 const 함수 안에서 해도 괜찮다고 본다.
3-3. mutable 키워드
문제는 컴파일러가 bitwise 기준으로 검사한다는 점이다. 이때 mutable이 도움이 된다. mutable 멤버는 const 멤버 함수 안에서도 수정할 수 있다.
class CTextBlock
{
public:
std::size_t length() const
{
if (!lengthIsValid)
{
textLength = std::strlen(pText); // mutable이므로 변경 가능
lengthIsValid = true; // mutable이므로 변경 가능
}
return textLength;
}
private:
char* pText;
mutable std::size_t textLength; // const 멤버 함수에서도 수정 가능
mutable bool lengthIsValid; // const 멤버 함수에서도 수정 가능
};
length()는 바깥에서 볼 때 동작이 변하지 않는다. 내부 캐시만 갱신하는 것이므로 const로 두는 게 자연스럽고, mutable이 그걸 가능하게 한다.
4. const / non-const 중복 코드 제거
4-1. 문제: 코드 중복
const 오버로딩을 하면 같은 코드가 두 번 들어가는 일이 많다. 경계 검사나 로그 같은 코드가 복붙되면 수정할 때 실수하기 쉽다.
class TextBlock
{
public:
const char& operator[](std::size_t position) const
{
// 경계 검사
// 접근 로그 기록
// 자료 무결성 검증
return text[position];
}
char& operator[](std::size_t position)
{
// 경계 검사 ← 중복
// 접근 로그 기록 ← 중복
// 자료 무결성 검증 ← 중복
return text[position];
}
private:
std::string text;
};
4-2. 해결: non-const가 const를 호출한다
해결 방법은 간단하다. non-const 버전에서 const 버전을 호출하고, 마지막에 const만 벗겨서 반환한다.
class TextBlock
{
public:
const char& operator[](std::size_t position) const
{
// 경계 검사, 로깅, 검증 등 모든 로직을 여기에 작성
return text[position];
}
char& operator[](std::size_t position)
{
// 1. *this에 const를 붙여서 const 버전을 호출
// 2. 반환된 const char&에서 const를 제거
return const_cast<char&>(
static_cast<const TextBlock&>(*this)[position]
);
}
private:
std::string text;
};
캐스트가 두 번 보여서 처음엔 낯설 수 있다. 그래도 핵심 로직을 한 곳에만 두게 되어 유지보수가 쉬워진다.
static_cast<const TextBlock&>(*this)— *this에 const를 붙여서 const 버전이 호출되도록 한다.const_cast<char&>(...)— const 버전이 반환한const char&에서 const를 벗겨낸다.
4-3. 반대 방향은 안 되는 이유
반대로 const 버전에서 non-const 버전을 호출하면 위험하다. const 함수는 "안 바꾼다"가 약속인데, non-const 함수는 바꿀 수 있기 때문이다.
반면 non-const에서 const를 호출하는 건 안전하다. 원래 변경 권한이 있는 쪽에서 읽기 전용 버전을 재사용하는 형태이기 때문이다.
4-4. 그럼 const_cast를 직접 쓰면 안 될까?
const_cast 자체가 나쁜 것은 아니다. 다만 잘못 쓰면 const 약속을 깨기 쉬워서 기본 전략으로는 권장되지 않는다.
특히 아래처럼 const 함수 안에서 const_cast로 non-const 함수를 부르는 방식은 위험하다.
class TextBlock
{
public:
const char& operator[](std::size_t position) const
{
return const_cast<TextBlock&>(*this)[position]; // 권장하지 않음
}
char& operator[](std::size_t position)
{
return text[position];
}
private:
std::string text;
};
이 코드는 "const 객체"로 들어온 경우에도 non-const 경로를 탈 수 있어 문제가 생긴다.
즉, const 객체를 실제로 수정하면 정의되지 않은 동작(Undefined Behaviour) 이 될 수 있다.
정리하면 다음처럼 기억하면 된다.
- 중복 제거의 기본 패턴은
non-const -> const호출 const_cast는 그 과정에서 반환 타입 맞출 때 최소한으로만 사용- const 객체를 수정할 가능성이 생기는 방향(
const -> non-const)은 피하기
5. 코멘트
이론으로는 참 많이 봤는데, 실제로 코드에서 잘 쓰느냐? 하면 모르겠다.
실제로 지금 개발 패러다임처럼 대부분 코드를 AI에게 맡기는 형국에선 const가 아예 없어서 QA 레벨에 많은 논리적 버그가 생기거나 const가 너무 많아서 개발자가 알아보기 힘든 문제가 같이 발생하는 것 같다.
개인적으로 AI의 코드를 리뷰할 때 const 를 사용한 방어처리가 많이 비어서 해당 로직을 추가해달라고 자주 요청했다. 결과적으로 내 서비스를 많이 deploy 해본 것은 아니라.. 얼마나 효용성이 있었는지는 검증되지 않았다.