C++ this 포인터: 메서드 체이닝, 자기 대입 검사, 람다 this 캡처 수명

이 글의 핵심

멤버 함수 안의 this 포인터가 무엇을 가리키고 어떤 타입인지, const 멤버 함수에서 this 타입이 어떻게 바뀌고 ref 한정자는 무엇을 바꾸는지 정리합니다. static 멤버 함수에 this가 없는 이유, 메서드 체이닝, 람다에서 this를 캡처할 때의 수명 문제도 다룹니다.

this 포인터란?

this는 비정적 멤버 함수 안에서 그 함수를 호출한 객체를 가리키는 포인터입니다. 따로 선언하지 않아도 모든 비정적 멤버 함수에서 쓸 수 있고, 멤버 이름을 그냥 쓰면 컴파일러가 this->를 붙인 것으로 해석합니다.

class MyClass {
    int value;

public:
    void setValue(int value) {
        this->value = value;  // 왼쪽은 멤버, 오른쪽은 매개변수
    }
};

this를 명시적으로 써야 하는 대표적인 경우는 네 가지입니다. 매개변수나 지역 변수가 멤버와 같은 이름이라 멤버를 가릴 때, 메서드 체이닝을 위해 *this를 반환할 때, 대입 연산자에서 자기 자신과 비교할 때, 그리고 자신을 다른 함수나 콜백에 넘길 때입니다.

이름이 같은 경우는 모호함이 아니라 가려짐(name hiding)입니다. 아래 첫 번째 코드는 컴파일 에러 없이 매개변수에 매개변수를 대입하므로 멤버는 바뀌지 않습니다. GCC와 Clang은 -Wshadow, Clang은 -Wself-assign으로 이런 코드를 경고해 줍니다.

class MyClass {
    int value;

public:
    // 매개변수 value가 멤버를 가림: 매개변수에 자기 자신을 대입
    void setValueWrong(int value) {
        value = value;
    }
    // this->로 멤버를 지정
    void setValue(int value) {
        this->value = value;
    }
};

this의 동작 원리

컴파일러는 멤버 함수를 객체 주소를 숨은 첫 번째 인자로 받는 함수처럼 다룹니다.

class MyClass {
    int value;

public:
    void setValue(int v) {
        this->value = v;
    }
};

// 개념적으로 다음과 같이 변환됨:
void setValue(MyClass* this, int v) {
    this->value = v;
}

// 호출
MyClass obj;
obj.setValue(10);  // setValue(&obj, 10)

이 “숨은 첫 번째 인자” 모델은 실제 ABI와도 대체로 일치합니다. x86-64 System V(Linux, macOS)에서는 this가 첫 번째 정수 인자 레지스터(rdi)로, Windows x64에서는 rcx로 전달되고, 32비트 MSVC의 __thiscall은 ecx 레지스터를 씁니다. 그래서 C 라이브러리의 콜백 API에 멤버 함수를 바로 넘길 수 없습니다. 멤버 함수 포인터(void (MyClass::*)(int))는 호출할 객체가 따로 필요한 다른 타입이라, C 스타일 void(*)(void*) 콜백에는 정적 함수나 캡처 없는 람다를 넘기고 void* user_data 자리에 this를 실어 보낸 뒤 안에서 static_cast<MyClass*>(user_data)로 되돌리는 것이 관용적인 방법입니다.

생성자에서의 this

생성자 본문에서도 this를 쓸 수 있지만, 멤버를 초기화할 때는 본문에서 대입하기보다 멤버 초기화 리스트를 쓰는 편이 낫습니다. 초기화 리스트에서는 괄호 바깥의 이름은 멤버, 괄호 안의 이름은 매개변수로 해석되므로 이름이 같아도 this->가 필요 없고, 대입이 아니라 초기화라서 const 멤버나 참조 멤버도 다룰 수 있습니다.

class Point {
    int x, y;

public:
    Point(int x, int y) : x(x), y(y) {}   // 바깥 x는 멤버, 괄호 안 x는 매개변수

    void print() const {
        std::cout << "(" << x << ", " << y << ")";
    }
};

실전 예시

메서드 체이닝

멤버 함수가 *this를 참조로 반환하면 호출을 이어 붙일 수 있습니다.

#include <string>
class Builder {
    std::string data;

public:
    Builder& append(const std::string& s) {
        data += s;
        return *this;  // 자신 반환
    }

    Builder& clear() {
        data.clear();
        return *this;
    }

    std::string build() const {
        return data;
    }
};

int main() {
    Builder b;
    auto result = b.append("Hello")
                   .append(" ")
                   .append("World")
                   .build();
}

반환 타입을 Builder&가 아니라 Builder로 쓰면 호출마다 복사본이 만들어져, 두 번째 append부터는 원래 b가 아니라 임시 복사본을 수정하게 됩니다. 컴파일 에러 없이 결과만 틀어지므로 체이닝용 함수의 반환 타입은 참조인지 꼭 확인해야 합니다.

자기 대입 방지

class Array {
    int* data;
    size_t size;

public:
    Array& operator=(const Array& other) {
        if (this != &other) {  // 자기 대입 체크
            delete[] data;
            size = other.size;
            data = new int[size];
            std::copy(other.data, other.data + size, data);
        }
        return *this;
    }
};

a = a;에서 검사가 없으면 delete[] data로 자기 데이터를 지운 뒤 이미 해제된 메모리에서 복사하게 됩니다. 다만 이 구현은 자기 대입은 막아도 예외 안전성이 없습니다. delete[] data를 먼저 한 뒤 new int[size]가 std::bad_alloc을 던지면, data는 이미 해제된 메모리를 가리키고 size는 새 값으로 바뀐 채 객체가 깨진 상태로 남습니다. 소멸자가 이 data를 다시 delete[]하면 이중 해제입니다. 새 메모리를 먼저 할당하고 복사한 뒤 교체하거나, 복사본을 만들어 swap하는 copy-and-swap 관용구(Array tmp(other); swap(tmp); return *this;)를 쓰면 자기 대입 검사 없이도 안전해집니다. 자기 대입 검사는 드물게 일어나는 경우를 위한 분기를 매번 실행한다는 비용도 있어서, copy-and-swap에서는 보통 생략합니다. operator&를 오버로드한 타입까지 고려하면 비교는 this != std::addressof(other)가 안전합니다.

콜백에 자신을 넘기기

class Button {
public:
    void onClick() {
        if (callback) {
            callback(this);  // 자신을 전달
        }
    }

    void setCallback(void (*cb)(Button*)) {
        callback = cb;
    }

private:
    void (*callback)(Button*) = nullptr;
};

콜백이 Button*을 받아 두었다가 나중에 쓰면, 그 사이 Button이 파괴됐을 때 댕글링 포인터가 됩니다. 옵저버 목록에 this를 등록하는 코드도 마찬가지라서, 등록한 객체는 소멸자에서 반드시 등록을 해제해야 합니다.

this의 타입: cv 한정자와 ref 한정자

비정적 멤버 함수 안에서 this는 X* 타입의 prvalue이고, 멤버 함수에 붙은 cv 한정자가 가리키는 대상에 붙습니다.

class MyClass {
public:
    void func()                { /* this: MyClass* */ }
    void constFunc() const     { /* this: const MyClass* */ }
    void volatileFunc() volatile { /* this: volatile MyClass* */ }
};

const 멤버 함수 안에서는 this가 const MyClass*이므로 멤버를 수정할 수 없고, mutable로 표시한 멤버만 예외적으로 수정할 수 있습니다. this는 변수가 아니라 prvalue 표현식이라서 this = &other;처럼 대입할 수 없고 &this로 주소를 얻을 수도 없습니다. 흔히 X* const처럼 동작한다고 설명하는 것도 이 때문입니다.

C++11의 ref 한정자(&, &&)는 cv 한정자와 달리 this의 타입을 바꾸지 않습니다. 대신 호출한 객체가 lvalue인지 rvalue인지에 따라 어느 오버로드가 선택될지를 정합니다. 두 함수 안에서 *this라는 표현식은 모두 lvalue이므로, rvalue 쪽에서 멤버를 이동하려면 std::move를 명시해야 합니다.

struct S {
    std::string data;
    std::string take() &  { return data; }             // lvalue 객체: 복사
    std::string take() && { return std::move(data); }  // 임시 객체: 이동
};

S s;
s.take();            // & 버전
S{}.take();          // && 버전
std::move(s).take(); // && 버전

C++23에서는 명시적 객체 매개변수(deducing this)가 추가되어 this에 해당하는 객체를 매개변수처럼 직접 받을 수 있게 되었습니다.

struct Builder {
    std::string data;
    // self의 타입이 호출 객체의 값 카테고리·const에 따라 추론됨
    template<typename Self>
    auto&& append(this Self&& self, const std::string& s) {
        self.data += s;
        return std::forward<Self>(self);
    }
};

이렇게 쓰면 const/non-const, lvalue/rvalue용으로 네 번 반복하던 오버로드를 하나로 합칠 수 있고, CRTP 없이 파생 클래스 타입을 얻을 수도 있습니다. 명시적 객체 매개변수를 쓰는 함수 안에서는 this 키워드를 쓸 수 없고 모든 멤버를 self.로 접근해야 한다는 점이 기존 멤버 함수와 다릅니다.

static 멤버 함수에는 this가 없다

정적 멤버 함수는 객체 없이 MyClass::increment()처럼 호출되므로 숨은 객체 인자도, this도 없습니다. 정적 멤버 변수에는 접근할 수 있지만, 비정적 멤버에 접근하려면 객체를 인자로 받아야 합니다.

class MyClass {
    static int count;
    int value = 0;

public:
    static void increment() {
        count++;          // OK: 정적 멤버
        // value++;       // 에러: 어떤 객체의 value인지 알 수 없음
    }
    static void reset(MyClass& obj) {
        obj.value = 0;    // 객체를 인자로 받으면 접근 가능
    }
};

생성자와 소멸자 안의 가상 함수 호출

생성자와 소멸자 안에서도 this는 유효하지만, 그 시점의 객체는 아직 완성되지 않았거나 이미 일부가 파괴된 상태입니다. 그래서 기반 클래스 생성자에서 가상 함수를 호출하면 파생 클래스의 오버라이드가 아니라 지금 생성 중인 클래스의 버전이 호출됩니다.

class Base {
public:
    Base() {
        init();   // 항상 Base::init 호출 (Derived 부분은 아직 생성 전)
    }
    virtual void init() { std::cout << "Base::init\n"; }
    virtual ~Base() = default;
};

class Derived : public Base {
    std::string name = "derived";
public:
    void init() override { std::cout << "Derived::init " << name << "\n"; }
};

Derived d;   // 출력: Base::init

C++가 이렇게 정한 이유는 Derived::init이 아직 초기화되지 않은 name을 읽게 되는 상황을 막기 위해서입니다. 소멸자에서도 마찬가지로, 기반 클래스 소멸자가 실행될 때는 파생 클래스 부분이 이미 파괴되어 기반 클래스 버전이 호출됩니다. 생성자 안에서 this를 다른 스레드나 전역 등록부에 넘기는 것도 같은 이유로 위험합니다. 생성이 끝나기 전의 객체를 다른 코드가 사용할 수 있게 되기 때문입니다. 생성 직후 초기화가 필요하면 생성자가 끝난 뒤 호출하는 별도의 init()이나 팩토리 함수를 둡니다.

람다 캡처에서의 this

[this]는 현재 객체의 주소를 복사해 캡처합니다. 람다 안에서는 this->를 생략하고 멤버를 쓸 수 있지만, 객체 자체를 복사한 것이 아니므로 람다가 객체보다 오래 살면 댕글링이 됩니다. 비동기 콜백, 타이머, 스레드 작업 큐에 넘기는 람다에서 특히 문제가 됩니다.

C++17의 [*this]는 객체 전체를 값으로 복사해 캡처합니다. 원본 객체의 수명과 무관해지는 대신, 객체가 크면 복사 비용이 들고 람다 안에서 바꾼 값은 원본에 반영되지 않습니다. 복사본을 수정하려면 람다에 mutable이 필요합니다.

멤버 함수 안의 [=]는 C++17까지 this를 포인터로 암시적으로 캡처했습니다. “모두 값으로 캡처”라는 이름과 달리 멤버는 복사되지 않고 포인터를 통해 접근되어 오해가 잦았기 때문에, C++20부터 이 암시적 캡처는 deprecated되었고 [=, this]처럼 명시하도록 바뀌었습니다. [&]는 여전히 this를 암시적으로 캡처합니다.

void Widget::postToQueue(TaskQueue& q) {
    q.enqueue([this] {
        doWork();  // 큐에 남은 동안 Widget이 파괴되면 미정의 동작
    });
}

객체 수명을 보장하려면 std::enable_shared_from_this로 shared_ptr나 weak_ptr를 캡처하는 방법이 일반적입니다.

class Widget : public std::enable_shared_from_this<Widget> {
public:
    void postToQueue(TaskQueue& q) {
        std::weak_ptr<Widget> weak = weak_from_this();  // C++17
        q.enqueue([weak] {
            if (auto self = weak.lock()) {  // 아직 살아 있을 때만 실행
                self->doWork();
            }
        });
    }
};

weak_ptr로 캡처하면 작업이 실행되는 시점에 객체가 이미 파괴되었는지 확인할 수 있고, shared_ptr로 캡처하면 작업이 끝날 때까지 객체 수명을 연장합니다. 어느 쪽을 고를지는 “객체가 사라졌으면 작업도 의미 없다”(weak)인지 “작업은 반드시 끝나야 한다”(shared)인지에 달려 있습니다. shared_from_this()는 객체가 이미 shared_ptr로 관리되고 있을 때만 호출할 수 있어서, 스택 객체나 생성자 안에서 호출하면 C++17 이후 std::bad_weak_ptr 예외가 납니다(weak_from_this()는 이 경우 빈 weak_ptr를 돌려줍니다). [this] 캡처로 인한 크래시는 객체가 파괴되는 타이밍에 따라 나타났다 사라졌다 하므로 재현이 어렵고, AddressSanitizer의 heap-use-after-free 보고로 처음 드러나는 경우가 많습니다.

템플릿에서 this->가 필요한 경우

보통은 this-> 없이 멤버 이름만 써도 됩니다. 예외는 기반 클래스가 템플릿 매개변수에 의존하는 클래스 템플릿입니다.

template<typename T>
struct Base { T x; };

template<typename T>
struct Derived : Base<T> {
    void f() {
        // x = {};       // 에러: x를 찾지 못함
        this->x = {};    // OK (또는 Base<T>::x)
    }
};

템플릿 정의 시점에는 Base<T>가 어떤 멤버를 가질지 알 수 없으므로(특수화로 완전히 달라질 수 있음), 컴파일러는 이름 조회에서 의존적인 기반 클래스를 보지 않습니다. this->x로 쓰면 x가 this의 타입, 즉 T에 의존하는 이름이 되어 인스턴스화 시점까지 조회가 미뤄집니다. MSVC는 오랫동안 이 규칙을 느슨하게 적용해서 x만 써도 컴파일되었기 때문에, MSVC에서 작성한 코드가 GCC나 Clang에서 'x' was not declared in this scope로 깨지는 일이 흔했습니다. MSVC도 /permissive-(C++20 모드의 기본값)에서는 표준대로 동작합니다.

CRTP에서는 반대로 기반 클래스가 static_cast<Derived*>(this)로 파생 클래스의 구현을 호출합니다. 이 캐스트는 객체가 실제로 Derived일 때만 올바르고, 기반 클래스의 생성자나 소멸자 안에서 호출하면 파생 부분이 아직 없거나 이미 파괴된 상태라 정의되지 않은 동작이 됩니다. 자세한 내용은 CRTP 패턴에서 다룹니다.

실무 패턴

빌더 패턴과 임시 객체

class HttpRequest {
    std::string url_;
    std::string method_ = "GET";
    std::map<std::string, std::string> headers_;

public:
    HttpRequest& setUrl(const std::string& url) {
        url_ = url;
        return *this;
    }

    HttpRequest& setMethod(const std::string& method) {
        method_ = method;
        return *this;
    }

    HttpRequest& addHeader(const std::string& key, const std::string& value) {
        headers_[key] = value;
        return *this;
    }

    void send() {
        std::cout << method_ << " " << url_ << '\n';
        for (const auto& [key, value] : headers_) {
            std::cout << key << ": " << value << '\n';
        }
    }
};

// 사용
HttpRequest()
    .setUrl("https://api.example.com")
    .setMethod("POST")
    .addHeader("Content-Type", "application/json")
    .addHeader("Authorization", "Bearer token")
    .send();

임시 객체 HttpRequest()에서 바로 체이닝하는 것은 안전합니다. 임시 객체는 이 문장(full-expression)이 끝날 때까지 살아 있고, 각 메서드가 돌려주는 HttpRequest&는 그 임시 객체를 가리키기 때문입니다. 문제는 결과를 붙잡아 둘 때입니다. auto& req = HttpRequest().setUrl("...");라고 쓰면 세미콜론에서 임시 객체가 파괴되어 req는 즉시 댕글링 참조가 되고, 참조를 반환하는 함수를 거쳤기 때문에 임시 객체의 수명 연장 규칙도 적용되지 않습니다. 이를 막으려면 앞에서 본 ref 한정자로 오버로드해, rvalue 객체에서 호출됐을 때는 HttpRequest setUrl(...) && { url_ = ...; return std::move(*this); }처럼 값으로 반환하게 하는 방법이 있습니다. 그러면 auto req = HttpRequest().setUrl("...");가 임시 객체의 내용을 req로 옮겨 받으므로 댕글링이 생기지 않습니다.

이동 생성자와 자기 이동 대입

class ScopedLock {
    std::mutex& mutex_;
    bool owns_ = true;

public:
    explicit ScopedLock(std::mutex& m) : mutex_(m) {
        mutex_.lock();
    }

    ~ScopedLock() {
        if (owns_) mutex_.unlock();
    }

    // 이동 생성자
    ScopedLock(ScopedLock&& other) noexcept : mutex_(other.mutex_) {
        other.owns_ = false;  // other는 더 이상 소유하지 않음 (이게 없으면 이중 unlock)
    }

    // 복사 금지
    ScopedLock(const ScopedLock&) = delete;
    ScopedLock& operator=(const ScopedLock&) = delete;
};

이동 생성자에서 other.owns_ = false를 빠뜨리면, 이동된 원본과 새 객체의 소멸자가 둘 다 unlock()을 호출합니다. 소유하지 않은 뮤텍스를 unlock하는 것은 미정의 동작이라 운이 좋으면 디버그 런타임이 abort하고, 운이 나쁘면 다른 스레드가 잡고 있던 락이 풀려 조용히 데이터 레이스가 생깁니다. 이동된 객체가 무엇을 하지 말아야 하는지는 이렇게 상태로 표시해야 합니다. 표준 라이브러리의 std::unique_lock이 정확히 이 방식(owns_lock())으로 구현되어 있으니, 직접 만들기보다 그것을 쓰는 편이 낫습니다.

이동 대입 연산자에서는 자기 이동도 고려해야 합니다. a = std::move(a);에서 먼저 자기 자원을 해제한 뒤 other(=자기 자신)에서 훔쳐 오는 구현은 자원을 잃습니다. 표준 라이브러리는 자기 이동 후 객체가 “유효하지만 지정되지 않은 상태”이기만 하면 된다고 보지만, 최소한 크래시나 누수는 없어야 하므로 if (this != &other) 검사를 두거나 swap 기반으로 구현합니다.

참고 자료


같이 보면 좋은 글