C++ 고급 문법 (8) — const 멤버 함수(const Member Function)

함수 인자를 const T&로 받도록 바꿨더니 그 안에서 부르던 getter가 전부 컴파일 에러를 낸다. getter에 const를 붙이지 않았기 때문이다. 멤버 함수 뒤의 const는 “이 함수는 객체 상태를 바꾸지 않는다”는 약속이다. 컴파일러는 이 약속을 근거로 const 객체에서 호출할 수 있는 함수를 가른다. 다만 약속이 미치는 범위는 생각보다 얕고, mutable로 예외를 만들 수도 있다. 이 글에서는 g++ 13.3으로 const 멤버 함수가 막아주는 것과 막지 못하는 것, mutable을 쓸 때의 스레드 안전성 문제를 확인한다.

이 시리즈의 다른 글

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 함수는 동시 호출을 고려해 동기화한다.

참고

답글 남기기