본문 바로가기

멤버 함수와 멤버 변수

객체마다 독립적인 비정적 데이터 멤버와 이를 다루는 멤버 함수의 접근·const·static 규칙을 익힙니다.

클래스의 멤버 변수는 객체의 상태를, 멤버 함수는 그 상태를 읽거나 바꾸는 동작을 나타냅니다. 지금까지는 멤버 함수가 어떻게 자기 객체의 멤버를 찾는지, 상태를 바꾸지 않는 함수를 어떻게 표시하는지, 모든 객체가 함께 쓰는 값은 어디에 두는지를 자세히 보지 않았습니다.

이 글은 객체마다 따로 있는 멤버 변수와 멤버 함수의 관계, 멤버 함수 안의 this 포인터, 상태를 바꾸지 않는 const 멤버 함수, 객체가 아니라 클래스에 속하는 static 멤버를 다룹니다.

호출자는 public 멤버 함수를 통해서만 private 상태를 바꾸고, public 함수는 반지름이 음수가 아니어야 한다는 불변식을 검증한다. c1과 c2는 각각 독립적인 비정적 데이터 멤버를 가지며, const 조회 함수는 호출 대상으로 선택한 객체의 상태만 읽는다.

C++ · encapsulation · object boundary

public 동작이 private 객체 상태의 불변식을 지킨다

객체마다 따로 존재하는 것은 비정적 데이터 멤버입니다. 외부 호출은 public 동작을 통과하고, 본문의 setRadius는 입력을 검증한 뒤 c1만 바꾸고, calculateArea는 선택한 c2를 조회합니다.

서로 다른 Circle의 변경과 조회 경로원desktop4노드2간선이다.c1publicsetRadius7→검증뒤c1radius7빨강쓰기실선.별도c2calculateAreaconst→c2radius10파랑조회점선.각호출의대상객체를구분하며c1변경이c2로전파되지않는다.
c1.setRadius(7)public · r>=0 검증
연결 관계c1.setRadius(7) · public · r>=0 검증 → c1 private 상태 · radius7 · 빨강
c1 private 상태radius7 · 빨강
c2.calculateArea()const 조회 · 변경 없음
연결 관계c2.calculateArea() · const 조회 · 변경 없음 → c2 private 상태 · radius10 · 파랑
c2 private 상태radius10 · 파랑

실선: 검증 뒤 쓰기점선: 선택한 객체 조회두 객체 사이 변경 간선 없음

Circle 객체별 public 동작과 private 상태c1의 setRadius가 검증 뒤 c1의 radius만 7로 갱신한다. c2의 const 조회는 radius 10과 파랑 상태를 읽고 변경하지 않는다. 실선은 검증 후 쓰기, 점선은 조회를 나타낸다.c1 · 변경할 객체c2 · 조회할 객체c1.setRadius(7)public · r ≥ 0 검사 뒤 갱신calculateArea() constpublic · 선택한 c2만 조회c1 · private 상태radius = 7 · color = "빨강"c2 · private 상태radius = 10 · color = "파랑"본문의 검증이 radius ≥ 0을 유지c1 변경과 무관 · 일반 멤버 읽기

01 · public gate

외부 호출은 public 동작을 통과한다

c1.setRadius(7)은 private 상태에 직접 쓰지 않습니다. setRadius가 입력을 검사하고 반지름이 0 이상이라는 불변식을 지킨 뒤 갱신합니다.

02 · c1 private state

c1의 비정적 데이터 멤버만 바뀐다

radius는 7, color는 빨강입니다. 이 상태는 c1 객체 경계 안에 있으며 public 멤버 함수가 읽거나 바꿉니다.

03 · c2 private state

c2는 자신의 상태를 독립적으로 유지한다

c2의 radius는 10, color는 파랑으로 남습니다. c1의 비정적 데이터 멤버를 바꿔도 c2에는 영향이 없습니다.

04 · const read

const 조회는 선택한 객체의 상태만 읽는다

c2.calculateArea() const는 c2의 일반 비정적 데이터 멤버를 변경하지 않고 읽습니다.

객체별 비정적 데이터 멤버의 시작값은 생성할 때 정하며, 보통 생성자 멤버 초기화 리스트를 사용합니다. 멤버 함수의 코드는 객체마다 복사되지 않습니다. thread_local이 아닌 정적 데이터 멤버는 모든 객체가 하나의 엔터티를 공유합니다.

객체마다 따로 있는 멤버 변수

static이 붙지 않은 멤버 변수를 비정적 데이터 멤버(non-static data member)라고 하며, 객체마다 하나씩 따로 있습니다. 한 객체의 값을 바꿔도 다른 객체에는 영향이 없고, 멤버 함수의 코드는 객체마다 복사되지 않고 하나뿐입니다.

circle.cpp
#include <iostream>
#include <numbers>
#include <stdexcept>
#include <string>

class Circle {
private:
    double radius;
    std::string color;

public:
    Circle(double r, std::string c) : radius(r), color(c) {
        if (!(r >= 0.0)) {
            throw std::invalid_argument("radius must be >= 0");
        }
    }

    void setRadius(double r) {
        if (r >= 0.0) {
            radius = r;
        }
    }

    double calculateArea() const {
        return std::numbers::pi * radius * radius;
    }
};

int main() {
    Circle c1(5.0, "red");
    Circle c2(10.0, "blue");
    std::cout << c1.calculateArea() << ' ' << c2.calculateArea() << '\n';

    c1.setRadius(7.0);
    std::cout << c1.calculateArea() << ' ' << c2.calculateArea() << '\n';
}
78.5398 314.159
153.938 314.159

위 그림처럼 c1.setRadius(7.0)은 public 함수에서 음수인지 검사한 뒤 c1의 반지름만 바꾸고, c2.calculateArea()는 c2의 상태를 읽기만 합니다. 그림의 빨강과 파랑은 예제의 red, blue입니다. std::numbers::pi는 C++20의 <numbers>에 있는 원주율 상수이며, 넓이는 기본 출력 형식에 따라 유효 숫자 여섯 자리로 출력됩니다.

생성자의 !(r >= 0.0)은 음수뿐 아니라 숫자가 아닌 값인 NaN도 거부합니다. 관계 연산자와 논리 연산자에서 본 것처럼 NaN은 <, >, <=, >= 비교에서 언제나 거짓이므로 r < 0.0으로 검사하면 통과해 버리기 때문입니다. 생성자에서 반지름이 0 이상이라는 조건을 세우고, 반지름을 바꾸는 setRadius도 같은 조건을 지키므로 Circle 객체의 반지름은 언제나 0 이상입니다.

멤버 함수와 this

멤버 함수를 c1.setRadius(7.0)처럼 호출하면, 함수는 점 앞의 객체를 대상으로 실행됩니다. 함수 안에서 radius라고만 쓰면 그 대상 객체의 radius를 뜻하며, 대상 객체를 가리키는 포인터는 this라는 이름으로 쓸 수 있습니다. radius는 사실 this->radius를 줄여 쓴 것입니다.

counter_this.cpp
#include <iostream>

class Counter {
private:
    int count = 0;

public:
    Counter& add(int count) {
        this->count += count;
        return *this;
    }

    int value() const { return count; }
};

int main() {
    Counter c;
    c.add(2).add(3);
    std::cout << c.value() << '\n';
}
5

add의 매개변수 이름이 멤버와 같은 count여서, 함수 안에서 count는 매개변수를 가리킵니다. 그래서 멤버는 this->count로 구별해 적었습니다. return *this;는 대상 객체 자체의 참조를 돌려주므로, c.add(2).add(3)처럼 호출을 이어 붙여도 둘 다 c를 바꿉니다. this는 다른 객체를 가리키도록 바꿀 수 없으며, C++23에는 대상 객체를 this 대신 이름 있는 첫 매개변수로 받는 명시적 객체 매개변수(explicit object parameter)라는 형식도 있습니다.

계좌 예제

멤버 함수는 같은 클래스의 private 멤버를 직접 쓸 수 있습니다. 다음 예는 잔액을 감추고 입금과 출금으로만 바꾸게 합니다.

account.cpp
#include <iostream>
#include <string>

class Account {
private:
    std::string owner;
    double balance;

public:
    Account(std::string name, double initial) : owner(name), balance(initial) {}

    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    bool withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
            return true;
        }
        return false;
    }

    double getBalance() const { return balance; }
    const std::string& getOwner() const { return owner; }
};

int main() {
    Account acc1("Mina", 50000.0);
    acc1.deposit(10000.0);
    acc1.withdraw(5000.0);
    std::cout << acc1.getOwner() << ' ' << acc1.getBalance() << '\n';

    Account acc2("Jihun", 10000.0);
    bool ok = acc2.withdraw(12000.0);
    std::cout << acc2.getOwner() << ' ' << acc2.getBalance() << ' '
              << std::boolalpha << ok << '\n';
}
Mina 55000
Jihun 10000 false

acc1은 50000에서 10000을 입금하고 5000을 출금해 55000이 되고, acc2의 12000 출금은 잔액보다 커서 거부되어 false를 돌려줍니다. 출금이 성공했는지를 화면에 출력하는 대신 bool로 돌려주면, 호출한 쪽이 결과에 따라 무엇을 할지 정할 수 있습니다. getOwner는 문자열을 복사하지 않도록 const std::string&를 돌려줍니다.

const 멤버 함수

멤버 함수의 매개변수 목록 뒤에 const를 붙이면, 그 함수는 대상 객체를 바꾸지 않는다는 약속이 됩니다. const 멤버 함수 안에서 this는 const X* 타입이 되어, 멤버 변수에 대입하면 컴파일 오류가 납니다. getBalance나 calculateArea처럼 상태를 읽기만 하는 함수에는 const를 붙입니다.

이 표시가 중요한 이유는 const 객체와 const 참조로는 const 멤버 함수만 부를 수 있기 때문입니다. 참조자에서 본 것처럼 큰 객체는 const T&로 받는 경우가 많은데, 읽기만 하는 함수에 const를 빠뜨리면 그런 매개변수로는 그 함수를 부를 수 없습니다.

const_member.cpp
#include <iostream>

class Point {
private:
    int x;
    int y;
    mutable int displayCount{0};

public:
    Point(int x0, int y0) : x(x0), y(y0) {}

    int& xValue() { return x; }
    const int& xValue() const { return x; }

    void setX(int newX) { x = newX; }

    void display() const {
        ++displayCount;
        std::cout << '(' << x << ", " << y << ")\n";
    }

    int getDisplayCount() const { return displayCount; }
};

void printPoint(const Point& p) {
    p.display();
    // p.setX(10); // 컴파일 오류: const 참조로는 const가 아닌 함수를 부를 수 없음
}

int main() {
    Point p1(10, 20);
    p1.display();
    p1.xValue() = 15;
    p1.display();

    const Point p2(30, 40);
    p2.display();
    std::cout << "p2 x " << p2.xValue() << '\n';

    printPoint(p1);
    printPoint(p2);
    std::cout << p1.getDisplayCount() << ' ' << p2.getDisplayCount() << '\n';
}
(10, 20)
(15, 20)
(30, 40)
p2 x 30
(15, 20)
(30, 40)
3 2

xValue는 const가 붙은 것과 붙지 않은 것을 함께 오버로딩했습니다. 바꿀 수 있는 p1에서는 int&를 돌려주는 쪽이 골라져 p1.xValue() = 15로 값을 바꿀 수 있고, const 객체 p2에서는 const int&를 돌려주는 쪽이 골라져 읽기만 할 수 있습니다. p2.setX(35)는 p2가 const이므로 컴파일되지 않습니다.

mutable을 붙인 멤버는 const 멤버 함수 안에서도 바꿀 수 있습니다. displayCount처럼 출력 횟수를 세는 진단용 값이나 계산 결과를 저장해 두는 캐시처럼, 객체의 겉으로 보이는 상태가 아닌 것에만 씁니다. 좌표 같은 실제 상태를 mutable로 만들면 const의 약속이 의미를 잃습니다.

static 멤버

멤버 앞에 static을 붙이면 그 멤버는 특정 객체가 아니라 클래스에 속합니다. static 데이터 멤버는 모든 객체가 하나를 함께 쓰고, static 멤버 함수는 대상 객체 없이 클래스이름::함수()로 호출합니다. static 멤버 함수에는 대상 객체가 없으므로 this도 없고, 비정적 멤버를 쓰려면 객체를 매개변수로 받아야 합니다.

meter.cpp
#include <iostream>

class Meter {
private:
    int value;
    inline static int scale{1};

public:
    explicit Meter(int v) : value(v) {}

    int scaledValue() const { return value * scale; }

    static void setScale(int nextScale) {
        if (nextScale > 0) {
            scale = nextScale;
        }
    }

    static int readRawValue(const Meter& meter) {
        return meter.value;
    }
};

int main() {
    Meter first(3);
    Meter second(5);

    Meter::setScale(2);
    std::cout << first.scaledValue() << ' ' << second.scaledValue() << '\n';
    std::cout << Meter::readRawValue(first) << '\n';
}
6 10
3

scale은 두 객체가 함께 쓰는 값이어서, Meter::setScale(2) 한 번으로 두 객체의 scaledValue가 모두 바뀝니다. readRawValue는 static 함수이지만 클래스의 멤버이므로, 매개변수로 받은 객체의 private 멤버 value를 읽을 수 있습니다. C++17부터는 inline static으로 클래스 안에서 정의까지 할 수 있으며, 그 전에는 클래스 안에 선언만 하고 소스 파일에 int Meter::scale = 1;처럼 따로 정의해야 했습니다.

일반 비정적 멤버 함수, const 비정적 멤버 함수, 정적 멤버를 객체 요구, this 표현식, 대상 상태와 저장, 호출 가능성, 변경 범위, 정의 규칙으로 비교한다. 이 표에서 다루는 implicit-object 비정적 함수에는 암시적 객체 매개변수가 있고 정적 함수에는 없으며, 정적 함수도 명시적으로 전달받은 객체는 다룰 수 있다.

C++ · member semantics · comparison

비정적, const, static은 객체 요구와 상태 범위가 다르다

비정적 함수에는 호출 객체가 결합되지만 정적 함수에는 그렇지 않습니다. const는 별도 저장 종류가 아니라 선택한 객체를 읽기 전용으로 다루는 비정적 함수의 한정자입니다.

객체 요구, 접근 경로, 저장과 정의 규칙 비교
기준 일반 비정적 const 비정적 static
객체 필요 필요. 변경 가능한 호출 객체가 결합됩니다. 필요. 변경 가능 객체와 const 객체 모두 결합될 수 있습니다. 불필요. X::f()를 권장하며 객체를 통한 표기도 가능합니다.
this 암시적 객체 매개변수가 있고 this는 선택한 객체를 가리킵니다. 암시적 객체 매개변수가 const로 한정되고 this는 const X* 타입의 접근 경로가 됩니다. 암시적 객체 매개변수와 this가 없습니다.
대상 상태 선택한 객체의 비정적 데이터 멤버를 다룹니다. 저장은 객체마다 독립적입니다. 같은 객체별 저장을 읽기 전용 경로로 다룹니다. thread_local이 아닌 정적 데이터 멤버는 객체 하위 객체가 아니며 하나의 엔터티를 공유합니다.
호출 가능 const 객체에서는 호출할 수 없습니다. 두 오버로드가 있으면 변경 가능 객체가 이쪽을 선택합니다. 변경 가능 객체와 const 객체에서 호출할 수 있습니다. const 객체는 이쪽만 후보입니다. 객체의 const 여부와 무관합니다. 객체 없이 호출할 수 있습니다.
변경 범위 선택한 객체의 일반 비정적 데이터 멤버를 바꿀 수 있습니다. 일반 멤버는 바꿀 수 없습니다. mutable 구현 세부만 예외입니다. 정적 상태를 바꿀 수 있고, 명시적으로 받은 객체도 접근 권한 안에서 다룰 수 있습니다.
정의 규칙 클래스 안에서 정의하거나 밖에서 X::로 정의합니다. 같은 방식이며 선언과 정의의 뒤쪽 const가 일치해야 합니다. 함수의 클래스 밖 정의에는 static을 반복하지 않습니다. 비인라인 정적 데이터는 odr-use 시 밖의 정의가 필요하고, C++17의 인라인 정적 또는 constexpr 정적 데이터는 클래스 안에서 정의됩니다.

01 · non-static

일반 비정적 멤버 함수

객체 요구: 변경 가능한 호출 객체가 필요합니다.

this: 암시적 객체 매개변수가 있고 선택한 객체를 가리킵니다.

대상 상태: 객체마다 독립적인 비정적 데이터 멤버를 다룹니다.

호출: const 객체에서는 호출할 수 없습니다. const 오버로드와 함께 있으면 변경 가능 객체가 이쪽을 선택합니다.

변경: 선택한 객체의 일반 비정적 데이터 멤버를 바꿀 수 있습니다.

정의: 클래스 안에서 정의하거나 밖에서 X::로 정의합니다.

02 · const non-static

const 비정적 멤버 함수

객체 요구: 변경 가능 객체와 const 객체 모두 사용할 수 있습니다.

this: 암시적 객체 매개변수가 const로 한정되고 const X* 타입의 접근 경로가 됩니다.

대상 상태: 같은 객체별 저장을 읽기 전용 경로로 다룹니다.

호출: const 객체는 이 오버로드만 후보로 삼습니다.

변경: 일반 멤버는 바꿀 수 없고 mutable 구현 세부만 예외입니다.

정의: 클래스 밖 정의에서도 뒤쪽 const를 빠뜨리면 안 됩니다.

03 · static

정적 멤버

객체 요구: 객체 없이 X::f()로 호출할 수 있습니다. 객체를 통한 표기도 가능하지만 클래스 한정이 선명합니다.

this: 암시적 객체 매개변수와 this가 없습니다.

대상 상태: thread_local이 아닌 정적 데이터 멤버는 하나의 엔터티를 공유합니다.

호출: 객체의 const 여부와 무관합니다.

변경: 정적 상태를 바꿀 수 있고, 명시적으로 받은 객체도 접근 권한 안에서 다룰 수 있습니다.

정의: 함수의 밖 정의에는 static을 반복하지 않습니다. 비인라인 정적 데이터는 odr-use 시 밖에서 정의하며, C++17의 인라인 정적 또는 constexpr 정적 데이터는 클래스 안 선언이 정의입니다.

이 비교는 implicit-object 비정적 멤버 함수를 기준으로 합니다. C++23 explicit-object 멤버 함수는 객체 매개변수를 명시적으로 선언하며 본문에 this 표현식이 없습니다. 멤버 함수 코드는 객체마다 복사되지 않습니다. const는 객체 접근을 한정하고, static은 객체 없이 사용할 클래스 수준 경계를 만듭니다. mutable은 논리적으로 보이지 않는 구현 세부에만 좁게 사용해야 합니다.

위 그림은 세 종류의 멤버를 비교한 것입니다. 일반 멤버 함수는 바꿀 수 있는 대상 객체가 필요하고, const 멤버 함수는 대상 객체를 읽기만 하며, static 멤버는 대상 객체가 필요 없습니다. 멤버를 설계할 때는 값이 객체마다 다른지 모든 객체에 공통인지를 먼저 정하고, 읽기만 하는 함수에는 const를, 객체가 필요 없는 동작에만 static을 붙입니다.

더 깊이: 정적 객체가 초기화되는 순서

int Meter::scale = 1;처럼 상수로 초기화하는 정적 데이터 멤버는 프로그램이 실행을 시작하기 전에 값이 정해집니다. 하지만 함수 호출의 결과처럼 실행해야 알 수 있는 값으로 초기화하는 전역 변수와 정적 데이터 멤버는 실행 중에, 대개 main이 시작되기 전에 초기화됩니다. 같은 번역 단위 안에서는 위에서 아래로 초기화되지만 서로 다른 .cpp 파일에 있는 것끼리는 순서가 정해져 있지 않아, 명령줄에 적은 파일의 순서에 따라 바뀔 수도 있습니다. 그래서 한 파일의 정적 객체가 초기화하면서 다른 파일의 정적 객체를 쓰면 아직 초기화되지 않은 객체를 읽을 수 있으며, 이를 정적 초기화 순서 문제(static initialization order fiasco)라고 부릅니다.

흔한 해결책은 그런 객체를 함수 안의 static 지역 변수로 두고 그 참조를 돌려주는 것입니다. static 지역 변수는 실행이 처음 그 선언을 지날 때 초기화되므로, 처음 쓰는 순간에는 반드시 만들어져 있습니다. C++11부터는 여러 스레드가 동시에 처음 부르더라도 초기화가 정확히 한 번만 일어나도록 정해졌고, cppreference의 저장 기간 설명에 따르면 흔한 구현은 이미 초기화된 뒤의 비용을 불리언 비교 한 번 정도로 줄입니다. 값이 상수로 정해질 수 있는 변수라면 C++20의 constinit으로 상수 초기화를 요구해, 실행 중의 초기화 순서에 휘말리지 않게 할 수도 있습니다.

정리

  • 비정적 데이터 멤버는 객체마다 따로 있고, 멤버 함수의 코드는 하나이며 호출할 때의 대상 객체에 작용합니다.
  • 멤버 함수 안의 this는 대상 객체를 가리키며, 멤버 이름은 this->를 줄여 쓴 것이고 return *this;로 호출을 이어 붙일 수 있습니다.
  • const 멤버 함수는 대상 객체를 바꾸지 않으며, const 객체와 const 참조로는 const 멤버 함수만 부를 수 있습니다.
  • const와 비const 버전을 오버로딩하면 객체가 const인지에 따라 알맞은 쪽이 선택됩니다.
  • mutable 멤버는 const 함수에서도 바꿀 수 있으며, 캐시나 진단 값처럼 겉으로 보이는 상태가 아닌 것에만 씁니다.
  • static 멤버는 클래스에 속해 모든 객체가 함께 쓰며, static 멤버 함수에는 this가 없습니다.

연습 문제

1. const 참조로 받은 객체

const_member.cpp의 main 끝에 const Point& view = p1; view.xValue() = 99;를 추가하면 컴파일되나요? p1은 const 객체가 아닙니다.

해설 보기

컴파일되지 않습니다. view는 const 참조이므로 xValue()의 const 버전이 선택되고, 그 버전은 const int&를 돌려주어 대입할 수 없습니다. p1 자체를 바꿀 수 있어도 const 참조라는 경로로는 바꿀 수 없으며, mutable인 displayCount를 바꿀 수 있다고 좌표까지 바꿀 수 있는 것도 아닙니다.

2. 공유하는 배율

meter.cpp의 끝에 Meter::setScale(3);과 Meter::setScale(0);을 차례로 추가하고 두 객체의 scaledValue()와 readRawValue()를 출력하면 무엇이 나오나요?

해설 보기

setScale(3)으로 공유하는 scale이 3이 되고, setScale(0)은 0보다 크지 않아 무시되므로 scale은 3을 유지합니다. 그래서 scaledValue()는 9와 15이고, 각 객체의 value는 바뀐 적이 없으므로 readRawValue()는 3과 5입니다.

3. 값으로 돌려준 객체

counter_this.cpp의 add가 Counter& 대신 Counter를 돌려주도록 바꾸면 c.value()는 얼마가 되나요?

해설 보기

c.add(2)는 c를 2로 만들고 c의 복사본을 돌려주므로, 이어지는 .add(3)은 그 임시 복사본에 3을 더하고 사라집니다. 그래서 c.value()는 2입니다. 호출을 이어 붙여 같은 객체를 계속 바꾸려면 *this를 참조로 돌려주어야 합니다.

다음 글 연산자 오버로딩 기초에서는 +, ==, << 같은 연산자를 클래스에 맞게 정의해, 직접 만든 타입도 기본 타입처럼 쓰는 방법을 다룹니다.