[CS] Move Semantics

Dorae·2024년 11월 27일

CS

목록 보기
3/3
post-thumbnail

문제 인식

C++의 move 연산은 어떤 것인가?

C++ 11에 그간의 C++ 연산 속도를 획기적으로 끌어올릴 수 있는 개념이 추가되었다.
이 포스트에서 서술하게 될 Move Semantics이다.
번역하자면 '이동 의미론' 정도 될 것이다.

C++ 11 이전의 연산

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 type 변수를 swap할 때와 move할 때 발생하는 시간 차이 - by GPT

long long 타입의 변수 abswap할 때와 move를 활용해서 값을 교환할 때의 시간 차이는 거의 없습니다. 왜냐하면 std::swap과 move를 이용한 값 교환은 둘 다 대체로 값 복사와 이동 작업을 수행하며, long long은 단순한 POD (Plain Old Data) 타입이기 때문입니다. 구체적으로 비교하면 다음과 같습니다:


1. std::swap을 사용할 경우

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의 레지스터에서 처리됩니다.
    • 현대 CPU에서 단순한 long long 크기의 데이터를 이동/복사하는 데 걸리는 시간은 매우 작습니다.

2. move를 직접 사용할 경우

std::move를 활용해 직접 값을 교환하는 코드:

a = std::move(b); // b의 값을 a로 이동
b = std::move(a); // a의 이전 값을 b로 이동

이 경우에도 실질적으로 메모리 복사 수준에서 동일한 작업을 하게 됩니다. 단, std::swap은 내부적으로 임시 변수를 활용하여 값을 안전하게 교환하지만, 직접 move를 사용하면 이 임시 변수를 생략할 수 있습니다.


시간 차이

  • 두 방식 모두 long long 타입에서는 실행 시간이 거의 동일하며, 일반적으로 수십 나노초(ns) 수준입니다.
  • 직접 move를 사용할 경우 임시 변수를 생략할 수 있어 메모리 접근이 약간 줄어들지만, 차이는 CPU의 파이프라인 및 레지스터에서 처리되므로 무시할 만한 수준입니다.

실험적인 결과 (대략적인 값)

다음은 1억 번 실행했을 때 대략적인 시간 차이 (컴파일러 최적화 적용 시):

방식시간 (1억 번 반복)
std::swap약 10~15ms
직접 std::move약 9~13ms

결론

  • long long 같은 기본형에서는 std::swap과 직접 move를 사용한 교환의 시간 차이는 1~2% 이하로 미미합니다.
  • 두 방식 모두 충분히 빠르며, 가독성과 유지보수를 위해 std::swap을 사용하는 것이 권장됩니다.

아하. GPT가 long long조차도 시간차가 얼마 안난다고 한다.
그럼 어떨 때 시간차가 크게 날까?

바로 우리가 잘 사용하는 string을 써먹을 때이다.

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
  • 0ns가 나온 3번의 TC를 제외하고 시간 효율 : 2.6배 빠름.
  • 0ns가 나온 3번의 TC를 포함하고 시간 효율 : 3.3배 빠름.

이제야 유의미한 결과가 도출됐다. 결국, 하나하나의 사이즈가 큰 값을 수정할 때 2~3배의 차이가 발생함을 볼 수 있다.

구조

그러면, move와 swap의 구조를 한 번 살펴보자.

<swap의 구조>

<move의 구조>

Move 함수를 만들기 위해서..

template<typename T>
void swap(T &a, T &b)
{
	T temp = a;
    a = b;
    b = temp;
}

우리는 이 함수에서 move 함수의 구조를 도출해야 한다. 하지만, 걸리는 문제가 몇 가지 있다. 같이 한 번 알아보자.

1. swap 함수와 달리 move 함수는 Generic 함수이다.

=> 이게 무슨소린고 하니.. 우리 move 함수를 쓸 때는 다음과 같이 썼던 기억이 있다.

a = std::move(b);

결국 a는 임의의 자료형이라는 것이고, 그것이 Generic 함수를 만들어야 하는 이유가 되는 것이다.

2. 근본적인 문제

제일 치명적인 문제이다. 함수에 값으로 들어가는 b가 lValue이라는 점이다.
이를 rValue로 처리할 수 있는 방안을 구상해야 할 것이다.
** lValue가 뭔데..?
=> https://velog.io/@77ehgus/CS-L-Value-vs-R-Value

두 가지 문제점에 유의하면서 한 번 만들어 보자.

Generic 함수

시작은 동일하다.

template<typename T>

임의의 type에 대해서 작동해야 하기 때문에 template를 기본적으로 깔고 들어간다.

이제 반환형을 임의의 type에 대하여 적용시켜야 한다. 그러면 이렇게 하면 될 것이다

template<typename T>
T move(T& b)
{
	...
}

그런데 이렇게 놓고 다음 함수를 실행하면..? 오류가 날 것이다.
=> 우선, 받는 값이 lValue이다. 'b'라는 입력값이 결국 실체가 있는 값이라는건데, 만약 이런 식으로 함수를 사용한다면 오류가 난다는 것이다.

string a = move("Hello World!"); // error!

왜냐? 실체가 없는 값이기 때문이다.
따라서 "rValue Reference"를 사용해야 한다.

rValue Reference

  • rValue를 받기 위한 reference
  • C++11에서 업데이트

보통의 reference라 하면 lValue만 받을 수 있는 것이 원칙이나, 이 move semantics를 위해서 업데이트된 것이 rValue reference이다.

기호 : &&

적용

template<typename T>
T move(T&& b)
{
	...
}

이런식으로 쓰면 rValue를 입력으로 받을 수 있다.

profile
게임 개발자 지망생

0개의 댓글