개요
이론 정리 - < 소유권과 수명 >에서 이 객체를 치울 책임은 누구에게 있는가를 정리했다. 이번 편은 그 한 층 아래다. 소유권을 이야기하려면 결국 포인터와 레퍼런스가 각각 무엇을 할 수 있는 이름인지, 그리고 객체를 넘길 때 실제로 뭐가 오가는지를 먼저 알아야 한다.
앞 편에서 이미 const T&가 복사를 줄이면서 수정을 막는다는 건 흐릿한 데 없이 나왔다. 그래서 이번엔 가볍게 갈 줄 알았는데, 정작 걸린 데는 예상 밖이었다.
-
방향은 맞는데 표현이 뭉개져 있던 것 —
const Player*를 “읽기만 가능한 주소 변수”라고 말했다. 결론은 비슷하게 나오지만 const가 붙은 대상이 어디인지가 빠져 있다 - 다른 언어 감각을 그대로 들고 온 것 — 레퍼런스에 다른 객체를 대입하면 참조 대상이 바뀐다고 생각했다. 게임 서버를 Node.js로 만들던 습관이 그대로 나왔다
-
아예 반대로 잡고 있던 것 — 함수 안의
static을 “전역변수로 지정되어 수명이 확장된다”고 설명했다. 오늘 제일 아팠던 건 여기다
뒤쪽 복사와 이동은 결과를 추론하는 건 그럭저럭 따라갔는데, move가 무슨 일을 하는 기능인지를 통째로 잘못 알고 있었다. deep copy를 알아서 대신 해주는 편의 기능인 줄 알았다.
이름이 하나 더 생겼을 뿐인가, 자원이 하나 더 생겼는가? 이번 편은 이 질문으로 계속 돌아온다.
( 코드는 개념만 보이게 줄였다. 이번에도 눈으로 읽고 결과를 추론하는 방식으로 진행했고, 직접 컴파일해서 돌려보지는 않았다. 아래 코드는 전부 개념 예시다 )
1. const는 두 축으로 갈린다
내가 말했던 것
네 가지를 이렇게 구분했다.
| 표기 | 내가 한 설명 |
|---|---|
Player* |
객체 주소에 직접 접근 가능한 변수 |
const Player* |
읽기만 가능한 주소 변수 |
Player* const |
주소 변경이 불가능한 변수 |
const Player* const |
읽기만 가능하고 주소 변경도 불가능한 변수 |
세 번째와 네 번째는 맞다. 문제는 두 번째다. “읽기만 가능한 주소 변수”라고 하면 주소도 못 바꾼다는 뜻처럼 들린다. 실제로는 반대다.
헷갈리지 않으려면 질문을 둘로 나눈다
-
*p, 그러니까 가리키는 대상을 수정할 수 있는가? -
p자체가 다른 주소를 가리키도록 바꿀 수 있는가?
| 표기 |
*p 수정 |
p 수정 |
|---|---|---|
Player* |
O | O |
const Player* |
X | O |
Player* const |
O | X |
const Player* const |
X | X |
const Player* p는 p라는 통로로는 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;
}
이걸 “전역변수로 지정되어 수명이 확장된다” 고 설명했다. 결과적으로 오래 산다는 건 맞는데, 전역변수가 된다는 표현이 틀렸다.
두 가지가 섞여 있었다.
-
이름의 scope —
player라는 이름은 여전히 함수 안에서만 보인다. 밖에서는 이 이름으로 못 부른다 - 객체의 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.data와 b.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); // 이동
여기서 b와 c가 같은 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번을 빼먹으면 이렇게 된다.
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, 가상 소멸자, 상속과 다형성 쪽이다. 특히 가상 소멸자는 오늘 정리한 소멸 순서와 자원 정리가 그대로 이어지는 자리라 붙여서 볼 만하다.
참고 자료
- cppreference - const
- cppreference - Reference declaration
- cppreference - Storage duration
- cppreference - Move constructors
- cppreference - std::move
- C++ Core Guidelines - Rule of Zero (C.20)
- 이론 정리 - < 소유권과 수명 > — 이 글의 앞 편
한줄 평
- 오늘은 새로운 지식들을 마구잡이로 배우기보단, 기본적인 내용에 관한 이해도를 높이는 과정이였다.. 이해는 어렵고, 진도는 느리고 약간 힘들었지만 앞으로의 C++ 공부에 많은 도움이 될 것 같아 뿌듯한 시간이 된거 같다.