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

이 글의 핵심

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

this 포인터란?

this 는 현재 객체를 가리키는 포인터입니다. 멤버 함수 내부에서 자동으로 제공되며, 객체 자신을 참조할 때 사용합니다. this는 암시적으로 전달되며, 명시적으로 사용할 수 있습니다.

// 타입 정의
class MyClass {
    int value;
    
public:
    void setValue(int value) {
        this->value = value;  // 멤버 변수
    }
};

왜 필요한가?:

  • 이름 충돌 해결: 멤버 변수와 매개변수 이름이 같을 때
  • 메서드 체이닝: 자신을 반환하여 연속 호출
  • 자기 대입 방지: 대입 연산자에서 자신과의 비교
  • 콜백 등록: 자신을 다른 함수에 전달
// ❌ 이름 충돌: 모호함
// 타입 정의
class MyClass {
    int value;
    
public:
    void setValue(int value) {
        value = value;  // 매개변수 = 매개변수
    }
};

// ✅ this 사용: 명확함
class MyClass {
    int value;
    
public:
    void setValue(int value) {
        this->value = value;  // 멤버 = 매개변수
    }
};

this의 타입:

this의 타입은 멤버 함수의 cv-한정자(const, volatile)에 따라 달라집니다.

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

this의 동작 원리:

멤버 함수는 내부적으로 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)로 전달되고, 32비트 MSVC의 __thiscall은 ecx 레지스터를 씁니다. 그래서 C 라이브러리의 콜백 API에 멤버 함수를 바로 넘길 수 없습니다. 멤버 함수 포인터(void (MyClass::*)(int))는 호출할 객체가 따로 필요한 다른 타입이라, C 스타일 void(*)(void*) 콜백에는 정적 함수나 캡처 없는 람다를 넘기고 void* user_data 자리에 this를 실어 보낸 뒤 안에서 static_cast<MyClass*>(user_data)로 되돌리는 것이 관용적인 방법입니다.

기본 사용

class Point {
    int x, y;
    
public:
    Point(int x, int y) {
        this->x = x;  // this-> 명시
        this->y = y;
    }
    
    void print() {
        std::cout << "(" << this->x << ", " << this->y << ")";
    }
};

실전 예시

예시 1: 메서드 체이닝

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();
}

예시 2: 자기 대입 방지

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;
    }
};

이 구현은 자기 대입은 막지만 예외 안전성이 없습니다. 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에서는 보통 생략합니다.

예시 3: 콜백 등록

class Button {
public:
    void onClick() {
        if (callback) {
            callback(this);  // 자신을 전달
        }
    }
    
    void setCallback(void (*cb)(Button*)) {
        callback = cb;
    }
    
private:
    void (*callback)(Button*) = nullptr;
};

예시 4: const 멤버 함수

class Data {
    int value;
    
public:
    void setValue(int v) {
        this->value = v;  // OK
    }
    
    int getValue() const {
        // this는 const Data*
        // this->value = 10;  // 에러
        return this->value;  // OK
    }
};

this 타입

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

자주 발생하는 문제

문제 1: 이름 충돌

class MyClass {
    int value;
    
public:
    // ❌ 모호함
    void setValue(int value) {
        value = value;  // 매개변수 = 매개변수
    }
    
    // ✅ this 사용
    void setValue(int value) {
        this->value = value;
    }
};

문제 2: 정적 멤버

class MyClass {
    static int count;
    
public:
    static void increment() {
        // this->count++;  // 에러: this 없음
        count++;  // OK
    }
};

문제 3: 람다 캡처

class MyClass {
    int value = 10;
    
public:
    void func() {
        auto lambda = [this]() {
            std::cout << this->value;  // OK
        };
        lambda();
    }
};

문제 4: 생성자에서 this

class MyClass {
public:
    MyClass() {
        // this 사용 가능하지만 주의
        // 가상 함수 호출은 다형성 안 됨
    }
};

this 활용

// 1. 체이닝
return *this;

// 2. 자기 대입 방지
if (this != &other) {}

// 3. 콜백
callback(this);

// 4. 이름 충돌 해결
this->member = parameter;

실무 패턴

패턴 1: 빌더 패턴

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); }처럼 rvalue를 돌려주고, 받는 쪽에서 값으로 이동받게 하는 방법이 있습니다.

패턴 2: 싱글톤

class Logger {
private:
    Logger() = default;
    
public:
    static Logger& getInstance() {
        static Logger instance;
        return instance;
    }
    
    Logger& log(const std::string& message) {
        std::cout << "[LOG] " << message << '\n';
        return *this;
    }
    
    Logger& error(const std::string& message) {
        std::cerr << "[ERROR] " << message << '\n';
        return *this;
    }
};

// 사용
Logger::getInstance()
    .log("Application started")
    .log("Processing data")
    .error("An error occurred");

패턴 3: RAII 래퍼

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())으로 구현되어 있으니, 직접 만들기보다 그것을 쓰는 편이 낫습니다.

this의 타입과 const·ref 한정 심화

멤버 함수에 붙는 cv 한정자(const, volatile)와 ref 한정자(&, &&)는 this의 타입을 바꿉니다. 비-정적 멤버 함수 안에서 this의 타입은 대략 다음 규칙을 따릅니다.

  • 일반 멤버 함수: this는 X* (또는 클래스에 따라 X const* 등으로 조정된 포인터).
  • const 멤버 함수: this는 const X* — 멤버를 통한 수정이 금지됩니다.
  • volatile 멤버 함수: this는 volatile X*.
  • C++11 이후 ref 한정 멤버 함수: void foo() &에서는 this가 lvalue 객체를, void foo() &&에서는 rvalue(std::move로 넘긴 임시 등) 객체를 가리키는 맥락에서 오버로드 분리에 사용됩니다.
// 타입 정의
struct S {
    void work() &  { /* *this는 lvalue */ }
    void work() && { /* *this는 rvalue: 이동 최적화·체이닝에 활용 */ }
};

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

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.로 접근해야 한다는 점이 기존 멤버 함수와 다릅니다.

람다 캡처에서의 this

  • [this]: 현재 객체의 주소를 복사해 둡니다. 람다가 객체 수명보다 오래 살아가면 댕글링이 됩니다(비동기 콜백, 타이머, 스레드 큐에 특히 주의).
  • [*this] (C++17): 객체를 값으로 복사해 캡처합니다. 짧은 수명 객체를 넘길 때는 복사 비용과 예외 안전성을 함께 고려하세요.
  • [=]: 클래스 멤버 함수 안에서 [=]는 암시적으로 this를 캡처하는 것과 유사하게 동작할 수 있어(구현·경고에 따라 다름) 명시적으로 [this] 또는 [*this]를 쓰는 편이 의도를 드러내기 좋습니다.
void Widget::postToQueue(TaskQueue& q) {
    q.enqueue([this] {
        doWork();  // 큐에 남은 동안 Widget이 파괴되면 미정의 동작
    });
}

수명을 보장하려면 shared_from_this 패턴, 작업 취소 토큰, 또는 [*this]로 값 복사 후 필요 시 약한 참조 등을 검토하세요.

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 예외가 납니다. 비동기 콜백이 많은 네트워크 코드에서 [this] 캡처로 인한 크래시는 재현이 어려워서, 저는 큐나 타이머에 넘기는 람다에서는 [this]를 코드 리뷰 체크 항목으로 둡니다.

명시적 this와 암묵적 멤버 접근

같은 스코프에서 멤버 이름을 쓰면 컴파일러는 this-> 없이도 멤버를 찾습니다. 즉 value는 (로컬에 가리는 이름이 없으면) this->value와 같습니다.

  • 암묵적: 짧고 단순한 멤버 접근에 읽기 쉽습니다.
  • 명시적 this->: 템플릿 기반 클래스에서 의존 이름(dependent name) 해석, 매개변수와의 이름 충돌, 코드 리뷰 시 “멤버다”라는 점을 드러낼 때 유리합니다.
template<typename T>
struct Base { T x; };

template<typename T>
struct Derived : Base<T> {
    void f() {
        // this->x 또는 Base<T>::x — 템플릿에서는 this->가 종종 필요
        this->x = {};
    }
};

추가 실전 패턴

  • CRTP: 기반 클래스 템플릿에서 static_cast<Derived*>(this)로 파생 쪽 구현을 호출할 때 this를 넘깁니다.
  • 비교 연산자: return *this < rhs처럼 일관된 표현을 유지할 때 this를 직접 쓰기보다는 멤버 비교로 끝내는 경우가 많지만, 식별자 비교가 필요하면 this와 &rhs를 사용합니다.
  • 컨테이너에 객체 포인터 저장: std::vector<Observer*>에 this를 등록할 때, 소멸자에서 반드시 해제·역등록해 이중 등록이나 댕글링을 막습니다.

흔한 실수

  1. 비동기·콜백 후 수명 종료: [this] 람다가 큐에만 남고 객체는 이미 파괴된 경우.
  2. 자기 이동 대입 처리 누락: a = std::move(a);처럼 자기 자신을 이동 대입하면, 먼저 자기 자원을 해제한 뒤 other(=자기 자신)에서 훔쳐 오는 구현은 자원을 잃습니다. 표준 라이브러리는 자기 이동 후 객체가 “유효하지만 지정되지 않은 상태”이기만 하면 된다고 보지만, 최소한 크래시나 누수는 없어야 합니다. operator&를 오버로드한 타입까지 고려하면 비교는 this != std::addressof(other)가 안전합니다.
  3. 정적 멤버에서 this 사용 시도: 정적 함수에는 this가 없습니다.
  4. 생성자/소멸자에서의 가상 호출: this로 가상 함수를 호출해도 생성/소멸 중에는 규칙이 제한됩니다(Effective C++ 등에서 다루는 주제).
  5. nullptr인 것처럼 가정: 정상적인 멤버 호출에서 this는 null이 아니라고 가정하는 것이 일반적이며, 비정상 호출은 미정의 동작입니다.

FAQ

Q1: this는 무엇인가요?

A: this는 현재 객체를 가리키는 포인터입니다. 멤버 함수 내부에서 자동으로 제공되며, 객체 자신을 참조할 때 사용합니다.

class MyClass {
    int value;
    
public:
    void setValue(int value) {
        this->value = value;  // this는 현재 객체
    }
};

Q2: this는 언제 사용해야 하나요?

A:

  • 이름 충돌: 멤버 변수와 매개변수 이름이 같을 때
  • 메서드 체이닝: 자신을 반환하여 연속 호출
  • 자기 대입 방지: 대입 연산자에서 자신과의 비교
  • 콜백 등록: 자신을 다른 함수에 전달
// 이름 충돌
void setValue(int value) {
    this->value = value;
}

// 메서드 체이닝
Builder& append(const std::string& s) {
    data += s;
    return *this;
}

// 자기 대입 방지
Array& operator=(const Array& other) {
    if (this != &other) {
        // 복사
    }
    return *this;
}

Q3: 정적 멤버 함수에서 this를 사용할 수 있나요?

A: 아니요. 정적 멤버 함수는 객체 없이 호출되므로 this 포인터가 없습니다.

class MyClass {
    static int count;
    
public:
    static void increment() {
        // this->count++;  // 에러: this 없음
        count++;  // OK
    }
};

Q4: const 멤버 함수에서 this는 어떻게 되나요?

A: const 멤버 함수에서 this는 const 포인터(const MyClass*)가 됩니다. 따라서 멤버 변수를 수정할 수 없습니다.

class Data {
    int value;
    
public:
    void setValue(int v) {
        this->value = v;  // OK: this는 Data*
    }
    
    int getValue() const {
        // this는 const Data*
        // this->value = 10;  // 에러: const 객체 수정 불가
        return this->value;  // OK: 읽기만
    }
};

Q5: 람다에서 this를 캡처하려면?

A: [this] 또는 [=]로 캡처합니다. C++17부터는 [*this]로 객체를 복사 캡처할 수 있습니다.

class MyClass {
    int value = 10;
    
public:
    void func() {
        // [this]: this 포인터 캡처
        auto lambda1 = [this]() {
            std::cout << this->value << '\n';
        };
        
        // [*this]: 객체 복사 캡처 (C++17)
        auto lambda2 = [*this]() mutable {
            value++;  // 복사본 수정
        };
        
        lambda1();
        lambda2();
    }
};

Q6: 생성자나 소멸자에서 this를 사용할 수 있나요?

A: 가능하지만 주의해야 합니다. 생성자에서 가상 함수를 호출하면 다형성이 작동하지 않으며, 소멸자에서는 이미 파생 클래스 부분이 소멸된 상태입니다.

class Base {
public:
    Base() {
        // this 사용 가능
        // 하지만 가상 함수 호출은 Base 버전만 호출됨
    }
    
    virtual void init() {
        std::cout << "Base::init\n";
    }
};

class Derived : public Base {
public:
    Derived() {
        // Base 생성자에서 init() 호출 시 Base::init만 호출됨
    }
    
    void init() override {
        std::cout << "Derived::init\n";
    }
};

Q7: this 학습 리소스는?

A:

관련 글: Lambda Capture, Method Chaining.

this는 현재 객체를 가리키는 포인터로, 멤버 함수 내부에서 객체 자신을 참조할 때 사용합니다.


같이 보면 좋은 글