![[CPP] std::move와 const제약](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBvwcS%2FbtsOiokPdhS%2FQO2x6kaHcFbS60vM9yrrG1%2Fimg.png)
std::move와 const 제약에 다룬 글이다. 이동생성자와 perfect fowarding과 밀접한 연관이 있는데 perfect forwarding은 다음 글에서 다루도록 하겠다.
우선 이동생성자(및 이동대입 연산자)가 무엇인지 부터 알아보자.
MSDN을 보면 '이동 생성자를 사용하면 rvalue개체가 소유한 리소스를 복사하지 않고 lvalue로 이동할 수 있습니다.' 라고 되어 있다.
게임에서 예를 들어보면 level_a에 player가 있고 이제 이 player가 level_b로 이동했다고 해보자.
그럼 level_b에서 player에 대한 정보를 싹다 복사해서 level_b에 동일한 데이터로 player를 생성할 수 있다.
근데 player가 10MB의 용량을 가진다면 level_b로 이동하는데 엄청난 복사비용이 들것이다.
그래서 이때 복사를 하지 않고 단순히 객체는 생성하되 내부 데이터들을 옮기기만 하면 어떨까? 에서 시작한 아이디어가 '이동'에 대한 것이고 C++11부터 이를 지원하기 시작했다.
이동에 대한 자세한 문법과 예시는 https://modoocode.com/228
씹어먹는 C++ - <12 - 2. Move 문법 (std::move semantics) 과 완벽한 전달 (perfect forwarding)>
모두의 코드 씹어먹는 C++ - <12 - 2. Move 문법 (std::move semantics) 과 완벽한 전달 (perfect forwarding)> 작성일 : 2018-03-27 이 글은 50499 번 읽혔습니다. 등에 대해 다룹니다. 안녕하세요 여러분! 지난번의 우
modoocode.com
위 글을 참고하자. (꼭 보자...)
#include <iostream>
class A {
public:
A() { std::cout << "ctor\n"; }
A(const A& a) { std::cout << "copy ctor\n"; }
A(A&& a) { std::cout << "move ctor\n"; }
};
class B {
public:
B(const A& a) : a_(std::move(a)) {}
A a_;
};
int main() {
A a;
std::cout << "create B-- \n";
B b(a);
}
위 코드에서 A는 player, B 클래스는 level이라고 보면 편할거같다. B클래스의 멤버 변수인 a객체를 초기화 하는것이 목적인 코드인데 복사생성자를 호출하지 않고 이동생성자를 호출하는게 목적이다.
근데 위 코드를 컴파일해서 실행하면 copy ctor이 출력이 된다. 즉 복사생성자가 호출되었다는 것이다.
std::move(a)를 통해서 a를 rvalue로 캐스팅해서 넘겨주었기 때문에 이동생성자가 호출되어야 할 거같지만 그렇게 동작하지 않은 것이다.
여기에 대한 이유를 살펴보자.
우선 B b(a)에서 B(const A& a) {} 가 호출이 되고 a의 타입은 const A&가 된다.
그리고 const A&를 우측값으로 캐스팅해주고 있다. std::move(a)
std::move는 a라는 객체가 이동 될 수 있음을 알려줄 뿐이다. 실제로 이동은 수행하지 않는다. 즉 std::move의 역할은 객체를 rvalue 참조로 캐스팅(변환)의 역할을 수행한다.
rvalue로 캐스팅된 객체는 '이제 더 이상 사용되지 않을 것이니, 이 객체의 자원을 다른 곳으로 훔쳐가도 좋다'라는 힌트를 컴파일러에게 알려주고 이런 힌트를 기반으로 이동생성자나 이동할당연산자가 있다면 해당 함수가 호출되오 효율적으로 '이동'시키게 된다.
이때 만약 이동생성자/이동할당연산자가 없다면 복사 생성자/복사대입연산자가 호출된다.
즉 const A&타입인 a는 std::move에 의해 const A&&로 캐스팅이 되고 이 타입의 객체 a를 전달한다.
그런데 A클래스의 오버로딩된 생성자 목록들을 보면
A(const A& a), A(A&& a) 두개가 존재한다.
컴파일러는 const A&& 타입의 인자에 가장 잘 매칭되는 생성자를 찾으려한다.
이동생성자는 A(A&& a)는 non-const-ravlue 참조를 받고, A(const A& a)는 const A&(const lvalue참조)를 받는다.
const 참조는 lvalue, rvalue모두를 받을 수 있고 const라는 제약이 이동의 의미를 상쇄시키기 때문에 복사 생성자가 더 적합하다고 판단하여 복사 생성자를 호출하는 것이다.
const A&& 타입의 임시객체는 A의 A(const A& a)와 더 잘 매칭된다. 이동 생성자는 const 객체의 자원을 이동시킬 수 없기 때문에 선택되지 않는다.
방금 위에서 밑줄 친 부분에 대한 설명을 하면 상수성과 연관되어있는 부분이다.
'const'키워드는 '변하지 않는'이라는 의미를 강력하게 가진다.
const A&는 'A객체를 받아서 그 내용을 변경하지 않고 읽기만 하겠다는 뜻'을 가지고
std::move : '이 객체는 더 이상 사용되지 않을 것이니, 마음대로 자원을 가져가도 돼'라는 뜻을 가진다.
만약 B의 생성자가 const A&를 받음에도 불구하고 std::move를 통해 이동 생성자가 호출된다면, 이는 const의 의미를 깨는 행위가 된다.
B의 생성자에게 a를 const로 전달한 의도는 B가 a의 내용을 변경하지 않도록 하는 것인데, 이동 생성자는 a의 자원을 훔쳐(가져가)감으로써 a를 '변경된' 상태로 만들 수 있기 때문이다.
컴파일러는 이런 const의 제약을 존중하여, const가 붙은 rvalue참조는 복사 생성자와 바인딩 되도록 설계되어 있다.
즉, 'const 객체는 이동할 수 없다' 라는 원칙을 지키는 것이다.
해결
우선 아래 코드로 수정한다고 해도 여전히 copy ctor이 호출된다.
#include <iostream>
class A {
public:
A() { std::cout << "ctor\n"; }
A(const A& a) { std::cout << "copy ctor\n"; }
A(A&& a) { std::coaut << "move ctor\n"; }
};
class B {
public:
B(A&& a) : a_(a) {}
A a_;
};
int main() {
A a;
std::cout << "create B-- \n";
B b(std::move(a));
}
분명히 B(A&& a) 받아서 우측값이라 생각이 들 수 있지만, B생성자의 a는 우측값 참조는 맞지만 a 그자체는 왼값이기 때문이다. (가르킬 수 있고 주소를 알 수 있기 때문에)
그래서 a를 우측값으로 받은 다음에 다시 한번 std::move로 캐스팅 해주거나 forward키워드를 통해 완벽전달을 해주면 해결이 가능하다.
#include <iostream>
class A {
public:
A() { std::cout << "ctor\n"; }
A(const A& a) { std::cout << "copy ctor\n"; }
A(A&& a) { std::cout << "move ctor\n"; }
};
class B {
public:
B(A&& a) : a_(std::move(a)) {} // a(std::forward(a) {}
A a_;
};
int main() {
A a;
std::cout << "create B-- \n";
B b(std::move(a));
}
정리
1. A a : A의 기본생성자 A() 호출되어 "ctor"호출
2. B b(a)
- main함수의 a(lvalue A객체)를 인자로 사용하여 B객체 b를 생성
- B의 생성자 B(const A& a_param)이 호출, 이때 main함수의 a(A타입의 lvalue)가 B의 생성자 매개 변수 a_param(const A& 타입)으로 전달
- const A&는 lvaue, rvalue모두를 참조할 수 있으므로 main의 a가 a_param에 성공적으로 바인딩 된다.
3. B생성자 내부 초기화 리스트 (a(std::move(a_param))
- std::move가 사용됨, 여기서 a_param은 B 생성자의 매개 변수로 const A&이다.
- std::move는 인자를 rvalue참조로 캐스팅한다. const A& a_param을 static_cast<const A&&>(a_param)과 같이 캐스팅 수행
- 따라서 a를 초기화하기 위해 const A&&타입의 임시객체 생성
- 이제 컴파일러는 const A&& 타입의 인자를 받을 A클래스의 생성자를 찾는다.
- const A&&는 rvalue 참조입니다. 일반적으로 rvalue 참조는 A(A&& a)와 같은 이동 생성자를 호출해야 할 것 같습니다.
- 하지만 여기서 const 한정자가 핵심 역할을 합니다. const A&&는 "상수 rvalue 참조"를 의미합니다. 즉, 이 참조를 통해 가리키는 객체를 변경할 수 없습니다.
- 왜 A(A&& a)가 호출되지 않을까요? A(A&& a) 이동 생성자는 인자로 받은 A&&의 내부 자원(예: 힙 메모리)을 "훔쳐서"(이동시켜서) 자신의 것으로 만듭니다. 이 과정은 원본 객체(A&&로 받은 객체)를 변경하는 행위입니다.
- 하지만 우리가 전달한 것은 const A&&입니다. const는 변경을 허용하지 않습니다. 따라서 const A&&는 A&&에 바인딩될 수 없습니다. (const가 아닌 참조는 const를 바인딩할 수 없지만, const 참조는 const가 아닌 것을 바인딩할 수 있다는 규칙과 반대). const가 있는 rvalue 참조는 const가 없는 rvalue 참조에 바인딩 될 수 없습니다. const 한정자를 제거해야 바인딩이 가능합니다.
- 왜 A(const A& a)가 호출될까요? A(const A& a)는 const A&를 받습니다. 앞서 언급했듯이, const T&는 lvalue와 rvalue 모두를 받을 수 있습니다. 즉, const A&& (상수 rvalue 참조)는 const A& (상수 lvalue 참조)에 성공적으로 바인딩될 수 있습니다. const 참조는 const가 있는 rvalue를 받을 수 있으며, 이때 const 제약 때문에 자원을 훔치지 못하고 복사가 이루어집니다.
- 따라서 A의 복사생성자 A(const A& a)가 가장 적합한 오버로드로 선택 -> copy ctor 출력
이동생성자를 호출하기 위해서는 A&& a로 받은 a를 다시 한번 std::move로 캐스팅하거나 forward를 통해 완벽전달을 해준다.
'CPP' 카테고리의 다른 글
[CPP] Universal Reference & forward (모두의 코드 분석) (0) | 2025.05.31 |
---|---|
[C++] std::vector resize와 reserve의 차이점 (0) | 2025.05.06 |
Stomp Allocator (0) | 2025.04.04 |
[C++] 다형성과 가상 소멸자 (0) | 2025.03.25 |
[C++] 스마트 포인터 (0) | 2023.09.14 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!