copy elision은 말 그대로 “복사(또는 이동)를 생략하는 최적화이다.
copy constructor 또는 move constructor가 호출되어야 하는 지점에서
컴파일러가 임시 객체를 만들지 않고, 최종 목적지에 바로 객체를 생성해서
복사/이동 생성자 호출 자체를 없애 버리는 최적화이다.
함수에서 지역 객체를 값으로 return 할 때
→ RVO(Return Value Optimization), NRVO(Named RVO)
임시 객체(prvalue)를 함수 인자로 넘길 때
C++17 이후: prvalue의 “temporary materialization” 자체를 생략하는 규칙이 들어감
→ 특정 상황에서는 copy/move ctor 호출이 아예 없다고 표준에 명시됨 (guaranteed copy elision)
큰 객체를 복사/이동하면 메모리 할당, 메모리 복사 비용이 큼
copy/move 자체를 없애 버리면 훨씬 더 빠르게 동작
불필요한 함수 호출 제거
constructor 호출 자체도 함수 호출이니까, 이걸 안 부르면 call overhead 감소
표준 차원에서의 보장 (C++17)
“여기서는 copy/move를 부르지 않고 최종 위치에 바로 만들어도 된다”를 넘어서
“반드시 그렇게 해야 한다”로 규칙이 강화됨 (guaranteed copy elision)
#include <iostream>
struct MyString {
MyString() {
std::cout << "default ctor\n";
}
MyString(const MyString&) {
std::cout << "copy ctor\n";
}
MyString(MyString&&) {
std::cout << "move ctor\n";
}
};
MyString make() {
MyString s; // 여기서 default ctor 한 번 호출
return s; // 원래라면 copy 또는 move ctor가 한 번 더 호출될 수 있는 지점
}
int main() {
MyString x = make();
}
C++17 컴파일러에서 위 코드를 빌드하면, 보통 출력은:
copy ctor / move ctor가 전혀 안 나옴 → copy elision
컴파일러 입장에서는:
make() 안에서 s라는 지역 변수를 따로 만들지 않고,
애초에 main()의 x가 놓일 메모리 위치에 직접 생성해버리는 것처럼 최적화함.
“이름이 있고, 주소를 잡을 수 있는 것”에 가까운 개념
메모리 상에 지속적으로 존재하는 객체를 가리키는 표현식
&obj처럼 주소를 얻을 수 있음
대입문의 왼쪽/오른쪽에 모두 올 수 있음
“임시 값, 계산 결과로만 잠깐 존재하는 값”
보통 다시 사용할 이름이 없음
대입문의 오른쪽에만 오는 값
간단한 예:
int x = 10; // x는 lvalue, 10은 rvalue
int y = x; // x는 lvalue, (x의 값) 10은 rvalue
x = x + 1; // x는 lvalue, (x + 1)의 결과는 rvalue
x : 이름이 있고, 주소도 있고, 나중에 또 접근 가능 → lvalue
10, x + 1 : “값 그 자체” → rvalue
C++11 이전엔 lvalue reference만 있었음:
int a = 10;
int& ref = a; // OK, lvalue reference
int& ref2 = 10; // ERROR, rvalue(10)에는 바인딩 불가
C++11에서 rvalue reference (T&&)가 추가됨:
int&& r = 10; // OK, rvalue reference
int&& r2 = a; // ERROR, a는 lvalue → rvalue ref에 못 바인딩
이걸 통해:
“이 값은 더 이상 안 쓸 테니, 자원 뺏어가도 된다”는 의미를 컴파일러에게 전달하고
그걸 기반으로 move semantics가 도입됨.
#include <iostream>
void foo(int& x) { std::cout << "lvalue ref\n"; }
void foo(int&& x) { std::cout << "rvalue ref\n"; }
int main() {
int a = 10;
foo(a); // lvalue → foo(int&) 호출
foo(20); // rvalue → foo(int&&) 호출
foo(a + 1); // rvalue → foo(int&&) 호출
}
lvalue → “복사(copy) 기반”으로 다루고,
rvalue → “이동(move) 기반”으로 다룰 수 있게 됨.
C++11에서 move semantics가 도입된 핵심 이유는:
이미 있는 메모리를 그대로 재사용하고 싶은데,
C++98 스타일의 copy semantics로는 항상 “복사”를 해야 했기 때문이다.
대표 예: std::vector, std::string 같이 내부에 큰 heap 메모리를 들고 있는 타입.
#include <iostream>
#include <vector>
struct Buffer {
std::vector<int> data;
Buffer(size_t n) : data(n) {}
// copy constructor
Buffer(const Buffer& other) : data(other.data) {
std::cout << "copy ctor\n";
}
// move constructor
Buffer(Buffer&& other) noexcept : data(std::move(other.data)) {
std::cout << "move ctor\n";
}
};
int main() {
Buffer a(1000); // 큰 버퍼
Buffer b = a; // copy ctor: data 전체 복사
Buffer c = std::move(a); // move ctor: a.data의 자원을 c.data로 '옮김'
}
Buffer b = a;
a는 lvalue → Buffer(const Buffer&) 호출
a.data 내용을 통째로 복사
Buffer c = std::move(a);
std::move(a)는 a를 rvalue로 캐스팅
Buffer(Buffer&&)가 호출되고
내부적으로 c.data는 a.data의 소유권을 가져오고, a.data는 빈 상태로 만듦
여기서 핵심:
std::move는 진짜 “move”를 하지 않고,
“이 객체를 rvalue처럼 취급해라”라고 캐스팅만 해준다.
진짜 move 작업은 move constructor / move assignment operator 안에서 구현하는 것.
std::vector, std::string 같은 표준 컨테이너는 내부에서 재할당(reallocate)을 할 때 다음과 같다.
요소를 옮길 때 move를 사용해도 예외가 안 난다고 믿을 수 있음
→ 빠르고, 불필요한 copy를 안 해도 됨
move 도중 예외가 나면, 컨테이너의 내부 상태가 중간에 애매하게 깨질 수 있음
strong exception guarantee를 지키기 어렵기 때문에
차라리 copy constructor를 쓰거나,
move를 사용하지 않는 방향을 선택하기도 한다
즉,
“move가 예외를 던지지 않는 타입” 이면 컨테이너가 더 공격적으로 move를 사용해서 성능 최적화를 할 수 있다
move constructor / move assignment operator 에는
→ 웬만하면 noexcept를 붙이는 게 좋다
#include <iostream>
#include <vector>
struct A {
A() = default;
A(const A&) {
std::cout << "A copy ctor\n";
}
A(A&&) noexcept {
std::cout << "A move ctor (noexcept)\n";
}
};
struct B {
B() = default;
B(const B&) {
std::cout << "B copy ctor\n";
}
B(B&&) { // noexcept 없음
std::cout << "B move ctor (can throw)\n";
}
};
int main() {
std::vector<A> va;
va.reserve(1);
va.emplace_back();
va.push_back(A()); // 재할당 상황을 유도해볼 수 있음 (구체적인 capacity에 따라 다름)
std::vector<B> vb;
vb.reserve(1);
vb.emplace_back();
vb.push_back(B());
}
실제 출력은 구현/최적화에 따라 다르지만, 개념적으로:
A의 경우:
A(A&&) noexcept라서
std::vector< A>는 재할당 시 move를 마음껏 사용 가능
B의 경우:
B(B&&)가 noexcept가 아니므로
std::vector는 strong exception guarantee를 지키기 위해
copy를 더 선호하거나, move를 조심스럽게 쓸 수밖에 없다
raw pointer만 이동하는 경우
std::unique_ptr, std::vector, std::string 등,
자체 move가 이미 noexcept인 멤버들만 이동하는 경우
예:
struct MyString {
char* data;
std::size_t len;
MyString(const char* s);
~MyString();
MyString(const MyString& other); // copy ctor
MyString& operator=(const MyString& other); // copy assignment
MyString(MyString&& other) noexcept // move ctor
: data(other.data), len(other.len) {
other.data = nullptr;
other.len = 0;
}
MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
len = other.len;
other.data = nullptr;
other.len = 0;
}
return *this;
}
};
여기서 move ctor / move assignment는:
delete[]는 예외를 던지지 않고,
포인터와 숫자만 옮기는 단순 작업이기 때문에
실제로 예외가 발생할 여지가 거의 없음 → noexcept를 붙인다