함수 인자를 const T&로 받도록 바꿨더니 그 안에서 부르던 getter가 전부 컴파일 에러를 낸다. getter에 const를 붙이지 않았기 때문이다. 멤버 함수 뒤의 const는 “이 함수는 객체 상태를 바꾸지 않는다”는 약속이다. 컴파일러는 이 약속을 근거로 const 객체에서 호출할 수 있는 함수를 가른다. 다만 약속이 미치는 범위는 생각보다 얕고, mutable로 예외를 만들 수도 있다. 이 글에서는 g++ 13.3으로 const 멤버 함수가 막아주는 것과 막지 못하는 것, mutable을 쓸 때의 스레드 안전성 문제를 확인한다.
이 시리즈의 다른 글
- C++ 고급 문법 (6) — 명시적 캐스팅(Explicit Casting)
- C++ 고급 문법 (7) — 생성자(Constructor)
- C++ 고급 문법 (9) — 객체 복사(Object Copy)
- C++ 고급 문법 (10) — 함수 포인터(Function Pointer)
const 객체는 const 멤버 함수만 호출한다
#include <cstdio>
#include <string>
class Account {
std::string owner_;
long balance_ = 0;
public:
explicit Account(std::string o) : owner_(std::move(o)) {}
long balance() const { return balance_; } // const: 상태를 바꾸지 않는다고 약속
const std::string& owner() { return owner_; } // const를 빠뜨림
void deposit(long v) { balance_ += v; }
};
void report(const Account& a) // 읽기 전용으로 받는 함수
{
std::printf("%ld\n", a.balance());
std::printf("%s\n", a.owner().c_str());
}$ g++ -std=c++20 -Wall -c basic.cpp
basic.cpp: In function ‘void report(const Account&)’:
basic.cpp:17:32: error: passing ‘const Account’ as ‘this’ argument discards qualifiers [-fpermissive]
17 | std::printf("%s\n", a.owner().c_str());
| ~~~~~~~^~
basic.cpp:10:24: note: in call to ‘const std::string& Account::owner()’
10 | const std::string& owner() { return owner_; } // const를 빠뜨림
| ^~~~~
owner()는 실제로 아무것도 바꾸지 않지만 const가 없어서 const Account&로는 부를 수 없다. 상태를 바꾸지 않는 멤버 함수에는 처음부터 const를 붙여야 한다.
const의 정체: this의 타입
#include <cstdio>
#include <type_traits>
struct Widget {
int v = 0;
void f() { static_assert(std::is_same_v<decltype(this), Widget*>); std::puts("f(): this is Widget*"); }
void g() const { static_assert(std::is_same_v<decltype(this), const Widget*>); std::puts("g() const: this is const Widget*"); }
};
int main()
{
Widget w;
const Widget cw;
w.f(); w.g();
cw.g();
}$ g++ -std=c++20 -Wall -o tt thistype.cpp
$ ./tt
f(): this is Widget*
g() const: this is const Widget*
g() const: this is const Widget*
const 멤버 함수 안에서 this는 const Widget*이므로 모든 멤버가 읽기 전용이 된다.
struct Counter {
int hits = 0;
int peek() const { return ++hits; } // const 함수 안에서 멤버 수정
};$ g++ -std=c++20 -Wall -c modify.cpp
modify.cpp: In member function ‘int Counter::peek() const’:
modify.cpp:3:33: error: increment of member ‘Counter::hits’ in read-only object
3 | int peek() const { return ++hits; } // const 함수 안에서 멤버 수정
| ^~~~
포인터 멤버는 얕은 const
const가 막는 것은 멤버 자체의 비트다. 포인터 멤버는 포인터 값만 고정되고, 가리키는 대상은 여전히 수정할 수 있다.
#include <cstdio>
class Matrix {
int* data_; // 소유한 버퍼를 가리키는 포인터
int n_;
public:
explicit Matrix(int n) : data_(new int[n]{}), n_(n) {}
~Matrix() { delete[] data_; }
Matrix(const Matrix&) = delete;
Matrix& operator=(const Matrix&) = delete;
int at(int i) const { return data_[i]; }
void sneaky_reset() const // const인데 컴파일된다
{
for (int i = 0; i < n_; ++i) data_[i] = -1; // 포인터는 const, 가리키는 대상은 아님
}
};
int main()
{
const Matrix m(3);
std::printf("before: %d\n", m.at(0));
m.sneaky_reset();
std::printf("after : %d\n", m.at(0));
}$ g++ -std=c++20 -Wall -o sh shallow.cpp
$ ./sh
before: 0
after : -1
pimpl처럼 포인터로 내부 상태를 들고 있는 클래스라면 std::experimental::propagate_const(Library Fundamentals TS v2, libstdc++ 제공)로 const를 대상까지 전파할 수 있다.
#include <experimental/propagate_const>
#include <memory>
struct Impl { int state = 0; };
class Widget { // pimpl 패턴
std::unique_ptr<Impl> plain_ = std::make_unique<Impl>();
std::experimental::propagate_const<std::unique_ptr<Impl>> guarded_ = std::make_unique<Impl>();
public:
void touch() const
{
plain_->state = 1; // 통과: unique_ptr 자체만 const
guarded_->state = 1; // 에러: const가 Impl까지 전파
}
};$ g++ -std=c++20 -Wall -c propagate.cpp 2>&1 | head -4
propagate.cpp: In member function ‘void Widget::touch() const’:
propagate.cpp:13:25: error: assignment of member ‘Impl::state’ in read-only object
13 | guarded_->state = 1; // 에러: const가 Impl까지 전파
| ~~~~~~~~~~~~~~~~^~~
mutable과 스레드 안전성
캐시처럼 외부에서 관찰되는 상태와 무관한 멤버는 mutable로 선언해 const 함수에서도 수정할 수 있다(논리적 const).
#include <cmath>
#include <cstdio>
#include <thread>
#include <vector>
class Polygon {
std::vector<double> xs_, ys_;
mutable double cached_area_ = -1; // mutable: const 함수에서도 수정 가능
public:
Polygon(std::vector<double> x, std::vector<double> y) : xs_(std::move(x)), ys_(std::move(y)) {}
double area() const // 논리적으로는 읽기 전용
{
if (cached_area_ < 0) {
double s = 0;
for (std::size_t i = 0; i < xs_.size(); ++i) {
std::size_t j = (i + 1) % xs_.size();
s += xs_[i] * ys_[j] - xs_[j] * ys_[i];
}
cached_area_ = std::fabs(s) / 2;
}
return cached_area_;
}
};
int main()
{
const Polygon p({0, 4, 4, 0}, {0, 0, 3, 3});
std::thread t1([&] { p.area(); }); // const 객체를 두 스레드에서 읽기만 한다
std::thread t2([&] { p.area(); });
t1.join(); t2.join();
std::printf("area=%.1f\n", p.area());
}$ g++ -std=c++20 -Wall -g -fsanitize=thread -o cr cache_race.cpp
$ setarch -R ./cr # ASLR 끄고 실행(WSL2 TSan 요구), 경고와 스택 첫 줄만 발췌
WARNING: ThreadSanitizer: data race (pid=7089)
#0 Polygon::area() const cache_race.cpp:13
#0 Polygon::area() const cache_race.cpp:19
area=12.0
표준 라이브러리는 const 멤버 함수를 여러 스레드가 동시에 불러도 안전하다고 가정한다. 그래서 mutable 멤버를 고치는 const 함수는 직접 동기화해야 한다.
#include <cmath>
#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>
class Polygon {
std::vector<double> xs_, ys_;
mutable std::mutex m_; // 락도 const 함수에서 잠가야 하므로 mutable
mutable double cached_area_ = -1;
public:
Polygon(std::vector<double> x, std::vector<double> y) : xs_(std::move(x)), ys_(std::move(y)) {}
double area() const
{
std::lock_guard lk(m_);
if (cached_area_ < 0) {
double s = 0;
for (std::size_t i = 0; i < xs_.size(); ++i) {
std::size_t j = (i + 1) % xs_.size();
s += xs_[i] * ys_[j] - xs_[j] * ys_[i];
}
cached_area_ = std::fabs(s) / 2;
}
return cached_area_;
}
};
int main()
{
const Polygon p({0, 4, 4, 0}, {0, 0, 3, 3});
std::thread t1([&] { p.area(); });
std::thread t2([&] { p.area(); });
t1.join(); t2.join();
std::printf("area=%.1f\n", p.area());
}$ g++ -std=c++20 -Wall -g -fsanitize=thread -o cr cache_fix.cpp
$ setarch -R ./cr # ASLR 끄고 실행(WSL2 TSan 요구), 경고와 스택 첫 줄만 발췌
area=12.0
const 오버로드의 중복 제거
읽기·쓰기 접근자는 const/비 const 버전을 함께 둔다(함수 오버로딩 편 참고). 본문이 길면 비 const 버전이 const 버전을 호출하게 해서 로직을 한 곳에 둔다.
#include <cstdio>
#include <stdexcept>
#include <utility>
#include <vector>
class Grid {
std::vector<int> cells_;
int w_;
public:
Grid(int w, int h) : cells_(w * h), w_(w) {}
const int& at(int x, int y) const // 실제 로직은 const 버전에만
{
if (x < 0 || x >= w_ || y < 0 || y * w_ >= static_cast<int>(cells_.size()))
throw std::out_of_range("Grid::at");
return cells_[y * w_ + x];
}
int& at(int x, int y) // 비 const 버전은 const 버전을 재사용
{
return const_cast<int&>(std::as_const(*this).at(x, y));
}
};
int main()
{
Grid g(3, 2);
g.at(1, 1) = 7; // 비 const 버전
const Grid& cg = g;
std::printf("%d\n", cg.at(1, 1)); // const 버전
try { cg.at(5, 0); } catch (const std::exception& e) { std::printf("%s\n", e.what()); }
}$ g++ -std=c++20 -Wall -o ov overload.cpp
$ ./ov
7
Grid::at
반대 방향(const 버전이 비 const 버전 호출)은 const 객체를 수정할 수 있는 경로를 만들므로 쓰지 않는다.
static 멤버 함수
struct Registry {
static int count() const; // static 함수에는 this가 없다
};$ g++ -std=c++20 -Wall -c staticconst.cpp
staticconst.cpp:2:24: error: static member function ‘static int Registry::count()’ cannot have cv-qualifier
2 | static int count() const; // static 함수에는 this가 없다
| ^~~~~
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
getter에 const 누락 | const T&로 받는 코드 전체에서 호출 불가 | 상태를 바꾸지 않는 멤버 함수는 기본으로 const |
| 포인터·스마트 포인터 멤버 | const 함수에서 대상 수정 가능 | propagate_const 또는 설계 규칙으로 금지 |
mutable 캐시 | 동시 읽기에서 데이터 레이스 | mutable std::mutex나 std::atomic으로 보호 |
관찰 가능한 상태를 mutable로 선언 | const 약속이 무의미해짐 | mutable은 캐시·락·통계에만 사용 |
const 객체 내부 참조를 반환하는 비 const 경로 | const 우회 | const 버전은 const T&만 반환 |
마무리
const멤버 함수는this를const T*로 만들어,const객체·참조에서 호출 가능한 함수를 가른다.- 보호 범위는 멤버의 비트까지이며, 포인터가 가리키는 대상은 보호하지 않는다.
mutable멤버를 고치는const함수는 동시 호출을 고려해 동기화한다.