개요

이론 정리 - < 소유권과 수명 >에서 이 객체를 치울 책임은 누구에게 있는가를 정리했다. 이번 편은 그 한 층 아래다. 소유권을 이야기하려면 결국 포인터와 레퍼런스가 각각 무엇을 할 수 있는 이름인지, 그리고 객체를 넘길 때 실제로 뭐가 오가는지를 먼저 알아야 한다.

앞 편에서 이미 const T&가 복사를 줄이면서 수정을 막는다는 건 흐릿한 데 없이 나왔다. 그래서 이번엔 가볍게 갈 줄 알았는데, 정작 걸린 데는 예상 밖이었다.

  • 방향은 맞는데 표현이 뭉개져 있던 것const Player*를 “읽기만 가능한 주소 변수”라고 말했다. 결론은 비슷하게 나오지만 const가 붙은 대상이 어디인지가 빠져 있다
  • 다른 언어 감각을 그대로 들고 온 것 — 레퍼런스에 다른 객체를 대입하면 참조 대상이 바뀐다고 생각했다. 게임 서버를 Node.js로 만들던 습관이 그대로 나왔다
  • 아예 반대로 잡고 있던 것 — 함수 안의 static을 “전역변수로 지정되어 수명이 확장된다”고 설명했다. 오늘 제일 아팠던 건 여기다

뒤쪽 복사와 이동은 결과를 추론하는 건 그럭저럭 따라갔는데, move가 무슨 일을 하는 기능인지를 통째로 잘못 알고 있었다. deep copy를 알아서 대신 해주는 편의 기능인 줄 알았다.

이름이 하나 더 생겼을 뿐인가, 자원이 하나 더 생겼는가? 이번 편은 이 질문으로 계속 돌아온다.

( 코드는 개념만 보이게 줄였다. 이번에도 눈으로 읽고 결과를 추론하는 방식으로 진행했고, 직접 컴파일해서 돌려보지는 않았다. 아래 코드는 전부 개념 예시다 )


1. const는 두 축으로 갈린다

내가 말했던 것

네 가지를 이렇게 구분했다.

표기 내가 한 설명
Player* 객체 주소에 직접 접근 가능한 변수
const Player* 읽기만 가능한 주소 변수
Player* const 주소 변경이 불가능한 변수
const Player* const 읽기만 가능하고 주소 변경도 불가능한 변수

세 번째와 네 번째는 맞다. 문제는 두 번째다. “읽기만 가능한 주소 변수”라고 하면 주소도 못 바꾼다는 뜻처럼 들린다. 실제로는 반대다.

헷갈리지 않으려면 질문을 둘로 나눈다

  1. *p, 그러니까 가리키는 대상을 수정할 수 있는가?
  2. p 자체가 다른 주소를 가리키도록 바꿀 수 있는가?
표기 *p 수정 p 수정
Player* O O
const Player* X O
Player* const O X
const Player* const X X

const Player* pp라는 통로로는 Player를 못 건드린다는 뜻이다. 그 Player가 세상에서 영원히 불변이 된다는 뜻이 아니다. 같은 객체를 가리키는 다른 Player*가 있으면 그쪽으로는 얼마든지 바꾼다.

const는 객체에 거는 자물쇠가 아니라 이 접근 경로에 거는 자물쇠다.


2. 레퍼런스는 다시 붙지 않는다

여기서 Node.js 습관이 그대로 나왔다.

Player player;
Player& ref = player;

Player another;
another.hp = 500;

ref = another;

마지막 줄을 보고 이제 ref가 another를 참조한다고 읽었다. JS에서 객체를 담은 변수에 다른 객체를 대입하면 그 변수가 보는 대상이 바뀌니까, 같은 그림으로 읽은 것이다.

C++ 레퍼런스는 그렇지 않다. 한 번 바인딩되면 다른 객체로 갈아타지 않는다. ref는 처음부터 끝까지 player의 다른 이름이다. 그러니 저 마지막 줄은 참조 관계를 바꾸는 문장이 아니라,

player = another;   // 사실상 이 뜻

player의 내용을 another로 덮어쓰는 대입이다. another.hp = 500이었으니 player.hp가 500이 된다. 내가 생각했던 그림에서는 player가 안 건드려지고 남아 있어야 하는데, 실제로는 player가 바뀐다. 결과가 반대편이다.

  포인터 레퍼런스
다른 대상으로 갈아타기 된다 일반적으로 안 된다
아무것도 안 가리키는 상태 nullptr로 표현 표현할 수단이 없다
대입의 의미 가리키는 주소가 바뀐다 가리키던 객체의 값이 바뀐다

레퍼런스에 대한 대입은 이름표를 옮기는 게 아니라 그 이름이 붙은 물건을 바꾸는 것이다.


3. nullptr이 아니어도 유효하지 않을 수 있다

앞 편에서 이미 한 번 짚은 이야기라 짧게만 잇는다.

Player* FindPlayer()
{
    Player player;
    return &player;
}

함수가 끝나면 지역 player의 수명이 끝난다. 그런데 끝나는 건 객체지 반환된 주소값이 아니다. 받은 쪽에서 nullptr 검사를 통과해도 그 주소에 멀쩡한 Player가 있다는 보장은 없다.

non-null값이 들어 있다는 뜻이지 대상이 살아 있다는 뜻이 아니다.

그리고 Player*라는 타입만 봐서는 이것도 알 수 없다.

  • 누가 소유자인가
  • 받은 쪽이 delete해야 하는가
  • 대상이 언제까지 살아 있는가
  • null이 올 수 있는가

raw pointer가 나쁜 게 아니다. 소유하지 않는 접근에는 지금도 적절하다. 다만 소유권과 수명 약속은 타입 바깥에서 따로 정해야 한다. 앞 편의 여섯 개 질문이 그대로 여기에 걸린다.


4. scope와 lifetime은 다른 축이다

오늘 제일 아팠던 지점이다.

void func()
{
    static Player player;
}

이걸 “전역변수로 지정되어 수명이 확장된다” 고 설명했다. 결과적으로 오래 산다는 건 맞는데, 전역변수가 된다는 표현이 틀렸다.

두 가지가 섞여 있었다.

  • 이름의 scopeplayer라는 이름은 여전히 함수 안에서만 보인다. 밖에서는 이 이름으로 못 부른다
  • 객체의 lifetime — 객체 자체는 정적 저장 기간을 가진다. 함수를 나가도 파괴되지 않고 다음 호출에서도 같은 객체다

이름이 보이는 범위객체가 살아 있는 기간은 따로 논다. 전역변수는 이 둘이 다 넓은 경우일 뿐이고, 함수 안 static수명만 넓고 이름은 좁은 경우다.

돌이켜보면 앞 편에서 dangling pointer를 이해할 때도 이미 이 구분을 썼다. 지역 객체는 수명이 끝나는데 주소값은 남는다 — 그것도 scope와 lifetime이 어긋나는 이야기였다. 같은 축인 걸 그때는 몰랐다..

scope != lifetime. 오늘 문장 하나 얻어간 게 있다면 이거다.


5. 생성자인가 대입인가 — = 하나가 두 갈래다

Player a;
Player b = a;
  • a : 기본 생성자
  • b : 복사 생성자. b는 이 줄에서 처음 만들어진다
  • 스코프가 끝나면 생성의 역순으로 파괴된다. b 소멸자 → a 소멸자
Player b;
b = a;
  • b이미 존재한다. 그러니 여기는 복사 대입 연산자

같은 = 기호인데 왼쪽 객체가 그 시점에 존재하느냐로 갈린다. 없으면 생성자, 있으면 대입이다. 이동도 똑같이 갈린다. 새로 만드는 자리면 이동 생성자, 기존 객체에 넣는 자리면 이동 대입이다.

대입 연산자가 보통 T&를 돌려주는 이유도 여기 붙는다. a = b = c;처럼 이어 쓸 수 있고, 값으로 돌려줄 때 생기는 불필요한 복사도 없앤다.


6. 얕은 복사가 소멸자 두 번에서 터진다

자원을 직접 들고 있는 클래스에서 컴파일러가 만들어주는 복사를 그냥 쓰면 이렇게 된다.

class Buffer
{
    int* data;   // heap 할당을 직접 들고 있다
    int  size;
};

Buffer a(100);
Buffer b = a;    // data 주소값이 그대로 복사된다

복사되는 건 주소값이다. 그래서 a.datab.data같은 할당을 가리킨다. 앞 편의 Player* q = p;와 정확히 같은 모양인데, 이번엔 소멸자가 붙어 있다는 게 다르다.

스코프가 끝나면 b 소멸자가 그 메모리를 해제하고, a 소멸자가 이미 해제된 같은 메모리를 또 해제하려 든다. double free다.

진짜로 독립된 복사본이 필요하면 별도로 할당을 새로 잡고 내용을 복제하는 deep copy를 직접 써야 한다. 그리고 여기서 비용 이야기가 나온다. 자원 크기가 N이면 deep copy는 칸을 다 베껴야 하니 O(N)이다.


7. 이동은 옮기는 게 아니라 넘겨주는 것

내가 알고 있던 건 이동 생성자가 deep copy나 메모리 해제를 알아서 도와주는 기능이라는 것이었다. 복사를 편하게 해주는 도우미쯤으로 본 셈이다.

정반대다. 이동의 핵심은 복사를 안 하는 것이다.

Buffer(Buffer&& other) noexcept
    : size(other.size),
      data(other.data)     // 주소만 받아온다
{
    other.size = 0;
    other.data = nullptr;  // 원본은 빈 상태로
}

heap에 있는 100칸을 물리적으로 어디로 나르는 게 아니다. 그 100칸을 가리키는 주소와 소유권을 새 객체가 넘겨받고, 원본은 손을 뗀다. 원본을 nullptr로 비우는 마지막 두 줄이 핵심이다. 이걸 안 하면 원본 소멸자가 이미 넘겨준 메모리를 해제해서 6번의 double free로 돌아간다.

그래서 비용이 O(1) 모양이 된다. 주소와 크기 몇 개만 옮기고 자원 자체는 그대로 둔다.

같은 크기와 같은 자원은 다르다

Buffer a(100);
Buffer b = a;              // 복사
Buffer c = std::move(a);   // 이동

여기서 bc같은 100칸을 가진다고 말했다. 크기와 내용만 보면 틀린 말이 아닌데, 같은 할당을 공유하는 것으로 읽히면 틀린다.

복사와 이동의 최종 상태

  • b : deep copy로 새로 잡은 100칸
  • c : 원래 a가 쓰던 그 100칸
  • a : 자원 없음

숫자는 같은데 소유하는 물건이 다르다. 소멸자가 각자 자기 것만 해제하니 문제가 없는 것이고, 만약 정말 같은 할당을 나눠 가진 것이었다면 그건 6번의 double free 상황이다.


8. std::move는 아무것도 옮기지 않는다

이름이 제일 헷갈리는 지점이다. 나는 std::move(a)대입 전에 객체를 이동 가능한 상태로 바꿔놓는다고 설명했다. 뭔가를 하긴 하는 함수로 본 것이다.

std::move자원을 안 옮긴다. 하는 일은 표현식의 취급을 바꾸는 것뿐이다. a라는 표현식을 xvalue로 만들어서, 이동 버전 오버로드가 선택될 수 있게 해준다.

실제 자원 이전은 그 다음에 선택된 이동 생성자나 이동 대입 연산자가 한다. std::move는 그쪽 문으로 보내는 표지판이고, 일은 문 안에서 일어난다.

이동당한 객체는 죽은 게 아니다

std::move(a) 다음의 a를 파괴된 객체로 오해하기 쉬운데, 아니다. a는 여전히 자기 수명 안에 살아 있고 다시 대입 대상이 될 수도 있다.

객체 자체는 존재하고, 소유한 자원이 비어 있을 뿐이다.

다만 표준 라이브러리 타입에서는 이동 후 상태를 보통 유효하지만 구체적인 값은 정해져 있지 않은 상태로 다룬다. 그래서 “이동 후엔 무조건 비어 있다”고 일반화하면 안 되고, 그 타입의 계약을 확인해야 한다. 이건 앞 편에서 unique_ptr을 두고 이미 한 번 걸렀던 이야기와 같다.


9. 이동 대입 — 지금 들고 있는 것부터 치운다

새로 만드는 자리가 아니라 이미 자원을 들고 있는 객체가 남의 것을 넘겨받는 경우다. 순서가 중요하다.

  1. 내가 지금 소유한 자원을 먼저 정리한다
  2. 상대의 주소와 크기를 받아온다
  3. 상대를 자원 없는 안전한 상태로 만든다

1번을 빼먹으면 이렇게 된다.

Buffer& operator=(Buffer&& other)
{
    data = other.data;     // 기존 data 주소를 여기서 잃는다
    size = other.size;
    other.data = nullptr;
    other.size = 0;
    return *this;
}

this->data가 이미 할당을 들고 있었다면, 그 주소를 덮어쓰는 순간 그 메모리에 다시 접근할 방법이 사라진다. 해제할 수도 없다. Memory Leak이다.

6번의 double free와 짝이 되는 실수다. 한쪽은 같은 걸 두 번 놓는 것이고, 이쪽은 놓아야 할 걸 손에서 놓쳐버리는 것이다.

자기 자신에게 이동하면

a = std::move(a);

이 줄에서는 this == &other가 된다. 위 순서를 그대로 따르면 1번에서 자기 자원을 해제해놓고 2번에서 그 해제된 자원을 자기한테 다시 받아오는 꼴이 된다. 결국 빈 상태가 되거나 이미 놓은 메모리를 붙잡는다.

직접 구현할 때는 자기 대입 조건을 어떻게 처리할지 정해두어야 한다.


10. Rule of Three / Five / Zero

자원을 직접 관리하는 클래스에서 같이 따라오는 특수 멤버 함수들이다.

  포함
Rule of Three 소멸자, 복사 생성자, 복사 대입
Rule of Five 여기에 이동 생성자, 이동 대입
Rule of Zero 하나도 직접 안 쓰는 것

앞의 6번과 9번이 정확히 왜 이 규칙이 있는지를 보여준다. 소멸자만 정의하고 복사를 그냥 두면 double free, 이동 대입을 어설프게 쓰면 Memory Leak이다. 하나를 직접 쓰기 시작하면 나머지도 같이 생각해야 한다.

그래서 현대 C++이 권하는 쪽은 Rule of Zero다. std::vector, std::string, unique_ptr처럼 이미 제대로 만들어둔 RAII 타입에 자원 관리를 맡기고, 내 클래스는 특수 멤버 함수를 아예 안 쓰는 것이다. 앞 편에서 정리한 RAII가 여기로 이어진다.

제일 안전한 이동 대입 연산자는 내가 안 쓴 것이다.


정리

기억이 확실했던 것

  • 포인터는 주소를 담고, 레퍼런스는 같은 객체의 다른 이름이다. 큰 틀은 그대로 나왔다
  • const T&는 복사를 줄이면서 수정을 막는다. 앞 편에 이어 여기서도 흔들리지 않았다
  • unique_ptr은 복사가 금지되고 이동으로 소유권을 넘긴다

뭉개져 있거나 반대로 잡고 있던 것

  • const Player*는 주소를 못 바꾸는 게 아니다. const가 걸린 건 그 통로로 하는 대상 수정이다
  • 레퍼런스에 대입해도 참조 대상은 안 바뀐다. JS 감각을 그대로 들고 왔었다. 참조하던 객체의 값이 덮어쓰인다
  • 함수 안 static은 전역변수가 되는 게 아니다. 이름은 함수 안에 남고 수명만 늘어난다
  • 이동 생성자는 deep copy를 대신 해주는 기능이 아니다. 복사를 안 하려고 있는 것이다
  • std::move는 자원을 옮기지 않는다. 이동 오버로드가 골라지게 표현식을 바꿔줄 뿐이다

이번에 새로 얹은 것

  • const는 두 축으로 나눠서 본다. 대상을 바꿀 수 있는가, 포인터를 갈아탈 수 있는가
  • scope != lifetime. 이름이 보이는 범위와 객체가 사는 기간은 별개다
  • 왼쪽 객체가 존재하는지가 생성자와 대입을 가른다. T b = a;는 생성, b = a;는 대입
  • 얕은 복사 → 두 소멸자 → double free. 자원을 들고 있는 클래스에서 복사를 방치하면 여기로 온다
  • 이동 대입은 기존 자원을 먼저 정리한다. 안 하면 Memory Leak, 자기 대입이면 그것도 따로 봐야 한다
  • Rule of Zero — 특수 멤버 함수를 안 쓰는 게 제일 낫다

다음은 virtual, override, 가상 소멸자, 상속과 다형성 쪽이다. 특히 가상 소멸자는 오늘 정리한 소멸 순서와 자원 정리가 그대로 이어지는 자리라 붙여서 볼 만하다.

참고 자료

한줄 평

  • 오늘은 새로운 지식들을 마구잡이로 배우기보단, 기본적인 내용에 관한 이해도를 높이는 과정이였다.. 이해는 어렵고, 진도는 느리고 약간 힘들었지만 앞으로의 C++ 공부에 많은 도움이 될 것 같아 뿌듯한 시간이 된거 같다.