C++의 move 연산은 어떤 것인가?
C++ 11에 그간의 C++ 연산 속도를 획기적으로 끌어올릴 수 있는 개념이 추가되었다.
이 포스트에서 서술하게 될 Move Semantics이다.
번역하자면 '이동 의미론' 정도 될 것이다.
C++ 11 이전에는 move라는 개념 자체가 존재하지 않았다. 따라서, 어떤 값을 어딘가로 이동시키기 위해서는 복사를 활용해야만 했다.
int temp = a;
a = b;
b = temp;
너무나도 유명한 방식이다. 하지만 우리는 여기서 조금만 더 확장해서 임의의 template를 활용한 swap 함수를 만들어 보자.
template<typename T>
void swap(T &a, T &b)
{
T temp = a;
a = b;
b = temp;
}
이 방식이 C++ 11 이전에 한 값을 다른 값으로 옮기는 (서로 바꾸는) 방식이라고 볼 수 있다.
당연하게도 문제점이 있었기에 이 부분이 개선된 것일텐데, 그 부분이 무엇인지 한번 알아보자.
복사하는 단위 자체가 크지 않은 경우에는 지금과 연산 속도가 별반 다르지 않다.
long long 타입의 변수 a와 b를 swap할 때와 move를 활용해서 값을 교환할 때의 시간 차이는 거의 없습니다. 왜냐하면 std::swap과 move를 이용한 값 교환은 둘 다 대체로 값 복사와 이동 작업을 수행하며, long long은 단순한 POD (Plain Old Data) 타입이기 때문입니다. 구체적으로 비교하면 다음과 같습니다:
std::swap은 다음과 같이 세 번의 복사 연산을 수행합니다:
template<typename T>
void swap(T& a, T& b) {
T temp = std::move(a); // 1. a를 temp로 이동
a = std::move(b); // 2. b를 a로 이동
b = std::move(temp); // 3. temp를 b로 이동
}
std::move는 이동 연산자를 통해 값을 "이전"하는 역할을 하며, 실제로 데이터가 이동되는 것은 아닙니다. long long은 크기가 8바이트(64비트)이므로, 이동 연산이나 복사 연산은 CPU의 레지스터에서 처리됩니다. long long 크기의 데이터를 이동/복사하는 데 걸리는 시간은 매우 작습니다.std::move를 활용해 직접 값을 교환하는 코드:
a = std::move(b); // b의 값을 a로 이동
b = std::move(a); // a의 이전 값을 b로 이동
이 경우에도 실질적으로 메모리 복사 수준에서 동일한 작업을 하게 됩니다. 단, std::swap은 내부적으로 임시 변수를 활용하여 값을 안전하게 교환하지만, 직접 move를 사용하면 이 임시 변수를 생략할 수 있습니다.
long long 타입에서는 실행 시간이 거의 동일하며, 일반적으로 수십 나노초(ns) 수준입니다.다음은 1억 번 실행했을 때 대략적인 시간 차이 (컴파일러 최적화 적용 시):
| 방식 | 시간 (1억 번 반복) |
|---|---|
std::swap | 약 10~15ms |
직접 std::move | 약 9~13ms |
long long 같은 기본형에서는 std::swap과 직접 move를 사용한 교환의 시간 차이는 1~2% 이하로 미미합니다.std::swap을 사용하는 것이 권장됩니다.아하. GPT가 long long조차도 시간차가 얼마 안난다고 한다.
그럼 어떨 때 시간차가 크게 날까?
바로 우리가 잘 사용하는 string을 써먹을 때이다.
#include <iostream>
#include <string>
#include <chrono>
using namespace std;
int main() {
// 매우 긴 string 생성
size_t length = 1'000'000'000; // 1GB
string a(length, 'A');
string b(length, 'B');
for(int i = 0; i < 10; i++)
{
cout << "Testcase " << i + 1 << ':' << '\n';
// swap 연산 시간 측정
auto start = chrono::high_resolution_clock::now();
swap(a, b);
auto end = chrono::high_resolution_clock::now();
cout << "std::swap elapsed time: "
<< chrono::duration_cast<chrono::nanoseconds>(end - start).count()
<< " nanoseconds\n";
// move 연산 시간 측정
start = chrono::high_resolution_clock::now();
string temp = std::move(a);
a = std::move(b);
b = std::move(temp);
end = chrono::high_resolution_clock::now();
cout << "move elapsed time: "
<< chrono::duration_cast<chrono::nanoseconds>(end - start).count()
<< " nanoseconds\n";
cout << '\n';
}
return 0;
}
이 코드를 돌려볼거다. string의 길이는 10억정도로 잡고, 결과는 나노초 단위에서 뽑아낼 것이다.
Testcase 1:
std::swap elapsed time: 83 nanoseconds
move elapsed time: 333 nanoseconds
Testcase 2:
std::swap elapsed time: 42 nanoseconds
move elapsed time: 125 nanoseconds
Testcase 3:
std::swap elapsed time: 42 nanoseconds
move elapsed time: 83 nanoseconds
Testcase 4:
std::swap elapsed time: 0 nanoseconds
move elapsed time: 84 nanoseconds
Testcase 5:
std::swap elapsed time: 41 nanoseconds
move elapsed time: 83 nanoseconds
Testcase 6:
std::swap elapsed time: 42 nanoseconds
move elapsed time: 83 nanoseconds
Testcase 7:
std::swap elapsed time: 42 nanoseconds
move elapsed time: 84 nanoseconds
Testcase 8:
std::swap elapsed time: 0 nanoseconds
move elapsed time: 84 nanoseconds
Testcase 9:
std::swap elapsed time: 0 nanoseconds
move elapsed time: 41 nanoseconds
Testcase 10:
std::swap elapsed time: 41 nanoseconds
move elapsed time: 83 nanoseconds
이제야 유의미한 결과가 도출됐다. 결국, 하나하나의 사이즈가 큰 값을 수정할 때 2~3배의 차이가 발생함을 볼 수 있다.
그러면, move와 swap의 구조를 한 번 살펴보자.
<swap의 구조>
<move의 구조>
template<typename T>
void swap(T &a, T &b)
{
T temp = a;
a = b;
b = temp;
}
우리는 이 함수에서 move 함수의 구조를 도출해야 한다. 하지만, 걸리는 문제가 몇 가지 있다. 같이 한 번 알아보자.
=> 이게 무슨소린고 하니.. 우리 move 함수를 쓸 때는 다음과 같이 썼던 기억이 있다.
a = std::move(b);
결국 a는 임의의 자료형이라는 것이고, 그것이 Generic 함수를 만들어야 하는 이유가 되는 것이다.
제일 치명적인 문제이다. 함수에 값으로 들어가는 b가 lValue이라는 점이다.
이를 rValue로 처리할 수 있는 방안을 구상해야 할 것이다.
** lValue가 뭔데..?
=> https://velog.io/@77ehgus/CS-L-Value-vs-R-Value
두 가지 문제점에 유의하면서 한 번 만들어 보자.
시작은 동일하다.
template<typename T>
임의의 type에 대해서 작동해야 하기 때문에 template를 기본적으로 깔고 들어간다.
이제 반환형을 임의의 type에 대하여 적용시켜야 한다. 그러면 이렇게 하면 될 것이다
template<typename T>
T move(T& b)
{
...
}
그런데 이렇게 놓고 다음 함수를 실행하면..? 오류가 날 것이다.
=> 우선, 받는 값이 lValue이다. 'b'라는 입력값이 결국 실체가 있는 값이라는건데, 만약 이런 식으로 함수를 사용한다면 오류가 난다는 것이다.
string a = move("Hello World!"); // error!
왜냐? 실체가 없는 값이기 때문이다.
따라서 "rValue Reference"를 사용해야 한다.
보통의 reference라 하면 lValue만 받을 수 있는 것이 원칙이나, 이 move semantics를 위해서 업데이트된 것이 rValue reference이다.
기호 : &&
template<typename T>
T move(T&& b)
{
...
}
이런식으로 쓰면 rValue를 입력으로 받을 수 있다.