[Effective C++ 정리 #3] const — 타입 안정성과 효율성의 시작점
이 글은 『Effective C++』를 읽고 개인적으로 공부한 내용을 정리한 기록입니다.
저는 컴퓨터공학을 전공하지 않았으며, 프로그래밍을 공부하는 과정에서의 이해와 생각을 정리하기 위해 글을 작성하고 있습니다.
따라서 내용 중 일부에 오류나 부정확한 설명이 있을 수 있으며, 피드백은 언제든지 환영합니다. 확인 후 수정하도록 하겠습니다.
전문적인 해설이 아닌 개인적 시선에서의 정리임을 참고하고 읽어주시면 감사하겠습니다.
Item 3: const를 붙일 수 있으면 반드시 붙여라
C++을 공부하다 보면 const
가 유난히 자주 등장합니다. 처음엔 그저 상수를 뜻하는 키워드라고 생각하기 쉽지만, const
는 단순한 “상수” 개념을 넘어 프로그램의 안정성과 효율성을 높이는 핵심 개념입니다. 이 글에서는 Effective C++의 Item 3에서 말하는 const
의 올바른 사용법과 원리를 정리 하겠습니다.
const의 핵심 목적
const
는 “이 객체는 수정되어선 안 된다”는 의미적 제약(semantic constraint)을 컴파일러에게 알려주는 장치입니다. 이 제약은 단지 문서화의 의미가 아니라, 컴파일러가 해당 객체의 변경을 강제로 막아줍니다. 그 결과 코드의 버그 가능성을 줄이고, 함수 사용자의 의도를 명확히 표현할 수 있습니다.
다양한 const 사용 예시
char greeting[] = "Hello";
// (1) 포인터도, 데이터도 변경 가능
char* p = greeting;
// (2) 포인터는 변경 가능, 데이터는 변경 불가
const char* p = greeting;
// (3) 포인터는 변경 불가, 데이터는 변경 가능
char* const p = greeting;
// (4) 둘 다 변경 불가
const char* const p = greeting;
const의 위치에 따라 의미가 달라지기 때문에 주의해야 합니다. 다음 두 함수는 의미가 완전히 동일합니다:
void f1(const Widget* pw); // 포인터가 가리키는 Widget은 const
void f2(Widget const* pw); // 위와 동일
STL Iterator에서의 const
C++의 반복자(iterator)는 포인터처럼 동작합니다. const_iterator
와 iterator
는 const의 대상이 다릅니다:
std::vector<int> vec;
// const iterator (iterator 자체만 고정)
const std::vector<int>::iterator iter = vec.begin();
*iter = 10; // OK
++iter; // 오류: iter는 const
// iterator to const (데이터만 고정)
std::vector<int>::const_iterator cIter = vec.begin();
*cIter = 10; // 오류: *cIter는 const
++cIter; // OK
함수 선언에서의 const
const
는 함수 선언에서도 유용하게 사용됩니다. 다음은 operator*
에 const
를 붙인 예시입니다:
class Rational { ... };
const Rational operator*(const Rational& lhs, const Rational& rhs);
이렇게 하면 실수로 다음과 같이 잘못된 대입을 시도하는 경우를 막을 수 있습니다:
(a * b) = c; // 컴파일 오류 발생 (operator* 결과는 const)
멤버 함수에서의 const
const
멤버 함수는 해당 함수가 객체의 상태를 변경하지 않음을 의미합니다. 이로 인해 const 객체에도 호출할 수 있으며, 다음과 같이 오버로딩도 가능합니다:
참고로 아래의 operator[]가 참조를 반환하는 이유는 str[1] = 'a'
와 같이 lvalue처럼 사용하기 위함입니다. 참조가 아닌 char를 반환했다면 rvalue이기 때문에 대입으로 사용할 수 없습니다.
class TextBlock {
public:
const char& operator[](std::size_t pos) const { return text[pos]; }
char& operator[](std::size_t pos) { return text[pos]; }
private:
std::string text;
};
이로 인해 const 여부에 따라 다음처럼 동작이 달라집니다:
TextBlock tb("Hello");
tb[0] = 'x'; // OK
const TextBlock ctb("World");
ctb[0] = 'x'; // 오류 발생!
bitwise vs logical const
C++ 컴파일러는 객체의 멤버 함수가 const인지 아닌지를 판단할 때, 그 함수가 객체의 비트를 실제로 수정했는지만을 기준으로 판단합니다. 이것을 bitwise const(비트 단위 불변성)이라고 합니다.
예를 들어, 다음 멤버 함수가 있다고 해봅시다:
int getValue() const {
return cachedValue++; // <- const 함수인데 비트를 수정함!
}
이 함수는 const
로 선언되어 있지만 내부에서 멤버 값을 수정하고 있기 때문에 컴파일 오류가 발생합니다. 이는 **객체의 내부 비트가 실제로 바뀌기 때문**입니다.
그러나 때때로 우리는 “논리적으로는 const지만, 내부 캐시만 갱신”하고 싶은 상황이 있습니다. 예를 들어 문자열 길이를 미리 계산해두는 캐시 같은 경우죠. 이럴 때 사용하는 것이 바로 mutable 키워드입니다.
mutable이란?
mutable
은 **const 멤버 함수 내부에서도 변경할 수 있는 예외적인 멤버**를 선언할 때 사용합니다. 다시 말해, const 함수임에도 불구하고 특정 멤버 변수만은 수정 가능하게 만드는 키워드입니다.
class CTextBlock {
public:
std::size_t length() const;
private:
char* pText;
mutable std::size_t textLength; // const 함수에서도 수정 가능
mutable bool lengthIsValid; // 캐시 유효성
};
std::size_t CTextBlock::length() const {
if (!lengthIsValid) {
textLength = std::strlen(pText); // OK
lengthIsValid = true; // OK
}
return textLength;
}
이처럼 mutable
을 붙인 멤버 변수는 const
함수 내에서도 수정할 수 있기 때문에, 객체 외부의 상태에는 영향을 주지 않지만 내부적으로 최적화를 수행할 수 있습니다. 이런 관점을 논리적 불변성(logical constness)이라고 부릅니다.
즉, bitwise const는 “비트가 진짜로 안 바뀌는 것”에 초점을 두고, logical const는 “외부 관점에서 보기에 상태가 바뀌지 않는 것”에 초점을 둡니다.
중복 제거를 위한 const 호출 재사용
const
멤버 함수와 non-const
멤버 함수가 동일한 구현을 갖는 경우, non-const
함수가 const 버전을 호출하면 중복 코드를 줄일 수 있습니다.
char& TextBlock::operator[](std::size_t position) {
return const_cast<char&>(
static_cast<const TextBlock&>(*this)[position]
);
}
여기서는 static_cast
로 const
객체로 만든 후, 다시 const_cast
로 char&
를 돌려받습니다. 이 방식은 “non-const → const” 호출은 안전하지만, 그 반대는 위험하므로 **절대 금지**입니다.
핵심 요약
- 함수, 멤버, 포인터, 반복자 등 가능한 모든 곳에 적극적으로 const를 사용하길 권장합니다.
- 컴파일러는 비트 수준의 const를 강제하지만, 의미적인 불변성(logical constness)을 사용을 권장합니다.
- 중복된 const/non-const 함수는 const 버전을 재사용하는 구조로 구현하세요.
감사합니다.