본문 바로가기

진리는어디에/C++

[C++] move semantics

들어가며

이번 포스트에서는 C++11에 추가 된 내용 중 매우 중요하게 다루어지는 move에 대해 살펴 보도록 한다. 필자의 경우 move에서 말하는 자원의 '복사' 대신 '이동' 시킨다는 메커니즘이 머리 속에서 그려지지 않아 move를 접한 처음엔 이해하는데 많은 어려움을 겪었다.

하지만 본 포스트를 읽는 여러분에게는 필자와 같은 어려움을 겪지 않도록 최대한 쉽게 move에 대해 접근해 보도록하겠다.

NOTICE : move에 대해 이해하기 위해서는 r-value에 대한 이해가 먼저 필요하다. 만일 r-value에 익숙하지 않은 분이라면 [r-value 레퍼런스(reference) 완벽 가이드]를 먼저 읽고 돌아 오도록 하자.

Move semantics란?

이번 섹션은 move라고 불리는, Move semantics의 개념적인 부분을 다루도록 한다.

C++11 공식 문서에서 말하고 있는 move의 가장 핵심적인 개념은 자원의 '이동'이라고 한다. 하지만 자원의 이동이라는 개념이 아무래도 쉽게 머리에 그려지지 않는다(적어도 나는 그랬다). move를 이해하는데 가장 먼저 필요한 것은 '자원의 이동'이란 무엇인지 정확하게 살펴 보는 것이다.

아래 예제를 통해 move에서 말하는 자원의 '이동'이라는 것이 무엇인지 구체적으로 알아 보도록 하겠다.

#include <iostream>
#include <string>

int main() 
{
    std::string s1 = "Hello World";
    std::string s2 = s1;
    std::string s3 = "Hello World";
    std::string s4 = std::move(s3);
    
    std::cout << "s1:" << s1 << std::endl;
    std::cout << "s2:" << s2 << std::endl;
    std::cout << "s3:" << s3 << std::endl;
    std::cout << "s4:" << s4 << std::endl;
    
    return 0;
}

// OUTPUT
// s1:Hello World
// s2:Hello World
// s3:
// s4:Hello World

위 예제 6~7 라인에서 s1을 s2에 대입하고 있다. C++에서 대입 연산을 사용하게 되면 복사 생성자를 호출하게 되며, 아래의 그림 처럼 s1이 가지고 있는 'Hello World'는 별도의 메모리 공간(0x2000)에 복사 되고, s2는 그 주소를 가리키게 된다.

반면에 move 함수를 사용하고 있는 s3와 s4를 살펴 보자. 대입 연산에 move 함수를 사용하게 되면 C++에서는 메모리의 복사 대신  s4는 s3가 가지고 있던 문자열을 가리키도록 하고, s3의 문자열을 가리키고 있던 포인터는 무효화 시켜 다시는 s3가 해당 문자열에 접근 할 수 없도록 만든다.

설명을 그림으로 나타내면 아래와 같다.

이렇게 자원을 빼앗아 오는 과정은 우리가 이미 알고 있는 객체의 얉은 복사와 매우 유사하다. 다만 차이가 있다면 얉은 복사 수행수, 기존 자원을 가지고 있던 원본 객체의 포인터를 무효화 시켜 뺏긴 자원에 대한 접근을 다시 할 수 없도록 한다는 것이다.

C++11 에서는 이러한 과정을 '자원의 이동' 이라 하며 move 연산이라고 한다.

위 코드를 수행한 결과를 살펴 보면 s1, s2, s4는 'Hello World'가 출력 되지만 s3의 경우 s4에게 자원을 빼앗겼기 때문에 아무것도 출력 되지 않는 것을 확인할 수 있다.

move...
즉, '자원의 이동'은 얉은 복사후 원본의 참조를 제거하는 것을 말한다.

move를 사용하는 이유

앞에서 move의 '이동'이라는 개념을 살펴 보았으니 이번 섹션에서는 move가 필요한 이유에 대해 살펴 보도록 한다. 아래는 복사를 이용한 전통적인 swap 코드다.

template <class T>
void CopySwap(T& lhs, T& rhs)
{
    T tmp = lhs;
    lhs = rhs;
    rhs = tmp;
}

int main()
{
    std::string s1 = "Good Morning";
    std::string s2 = "Good Afternoon";

    CopySwap(s1, s2);

    return 0;
}

위 코드를 실행하게 되면 tmp 변수에 대한 '메모리 생성'과 각 대입 연산에 따른 메모리 '복사' 오버헤드가 발생한다.

그럼 이번에는 move 함수를 이용한 swap을 살펴 보자.

template <class T>
void MoveSwap(T& lhs, T& rhs)
{
    T tmp = std::move(lhs);
    lhs = std::move(rhs);
    rhs = std::move(tmp);
}

이전 코드와 차이는 단순 대입 연산 대신 move 함수를 통해 대입 연산을 진행하고 있다. 

move 함수를 사용하게 앞의 복사를 이용한 swap 예제 처럼 메모리 생성과 복사 대신, tmp에 대한 새로운 메모리 공간을 생성하지 않았을 뿐 아니라 각 값들에 대한 복사 오버헤드도 발생하지 않았다. 단지 세번의 메모리 주소 복사 만이 발생 했을 뿐이다.

  • 임시 변수에 생성에 대한 오버헤드 감소
  • 메모리 복사에 대한 오버헤드 감소

우리가 move를 사용해야 할 이유는 위와 같이 불필요한 오버헤드를 줄여 성능 향상이 가능하기 때문이다.

move는 복사로 인한 오버헤드를 피할 수 있다

move 생성자

우리는 앞에서 단순 복사가 아닌 자원의 이동(move)을 통해 복사 오버헤드를 줄일 수 있으며 이것이 우리가 move를 사용해야 하는 이유라고 배웠다. 이번 섹션에서는 move 생성자를 이용하여 메모리 복사에 대한 오버헤드를 줄일 수 있는 케이스에 대해 좀 더 깊이 샆펴 보도록 한다.

먼저, 전통적인 복사 생성자가 필요한 케이스를 살펴 보자. Customer라는 고객의 정보를 다루는 클래스가 있다고 가정 한다(이름을 다루는데 std::string 클래스를 사용하면 되지만 본문에서는 설명을 위해 의도적으로 char 포인터를 사용하고 있음을 주목하자)

class Customer
{
public:
    char* name;

    Customer(const char* s)
    {
        name = new char[strlen(s) + 1];
        strcpy_s(name, strlen(s) + 1, s);
    }

    ~Customer()
    {
        delete[] name;
    }
};

int main()
{
    Customer c1("Jason");
    Customer c2 = c1;
    return 0;
}

위 클래스는 20 라인과 같이 객체 하나의 생성과 사용에는 문제가 없지만, 21 라인 처럼 다른 객체로 복사 생성시 복사 생성자를 호출하게 된다. 기본적인 복사 생성은  name에 대한 포인터만을 복사하는 얉은 복사(swallow copy)를 진행하게 되어 p2의 name을 변경하면 p1객체의 값이 같이 변경 되거나, 두 객체가 소멸될 때 같은 포인터에 대한 delete를 중복으로 호출하게 된다는 문제점이 있다.

위 문제에 대한 전통적인 C++의 해결 방법은 사용자가 직접 복사 생성자를 작성하여 깊은 복사(deep copy), 즉, 각 객체가 각자의 name 데이터를 가지도록 만드는 것이었다.

class Customer
{
public:
    char* name;

    Customer(const char* s)
    {
        name = new char[strlen(s) + 1];
        strcpy_s(name, strlen(s) + 1, s);
    }

    // 복사 생성자
    Customer(const Customer& c)
    {
        name = new char[strlen(c.name) + 1]; // 별도의 메모리 생성
        strcpy_s(name, strlen(c.name) + 1, c.name);
    }

    ~Customer()
    {
        delete[] name;
    }
};

이렇게 깊은 복사를 하는 복사 생성자를 제공하는 방식은 기능적인 측면에선 아무런 문제가 없지만 성능적인 측면에서는 문제가 있을 수 있다.

예를 들어 아래 예제 9 라인 처럼 함수에서 객체를 반환하는 경우, 리턴 값에 대한 임시 객체가 생성되고 이 임시객체가 c2에 복사 되게 된다. 즉, 삭제될 임시객체의 문자열 생성과 메모리 복제가 불필요하게 발생하는 복사 오버헤드가 발생한다.

Person CreateCustomer(const char* name)
{
    return Customer(name); // 곧 파괴 될 임시 객체
}

int main()
{
    Customer c1 = Customer("Robert");
    Customer c2 = CreateCustomer("Jason"); // 임시 객체의 데이터를 '깊은 복사'
    return 0;
}

그렇다면 위 처럼 즉시 파괴될 임시 객체의 경우 깊은 복사를 하지말고, 주소 만을 복사하는 얉은 복사를 하게하고 임시 객체가 가지는 참조는 무효화 시킬수 있다면 훨씬 효율적이지 않을까?

결론은 임시 객체를 위한 별도의 복사 생성자가 필요하다는 것이고 이를 위해 C++11 부터 move 생성자라는 임시 객체, 즉 r-value를 대상으로 하는 특별한 생성자를 제공한다.

class Customer
{
public:
    char* name;

    Customer(const char* s)
    {
        name = new char[strlen(s) + 1];
        strcpy_s(name, strlen(s) + 1, s);
    }

    // 복사 생성자
    Customer(const Customer& c)
    {
        name = new char[strlen(c.name) + 1]; // 별도의 메모리 생성
        strcpy_s(name, strlen(c.name) + 1, c.name);
    }
    
    // move 생성자
    Customer(Customer&& c) : name(c.name) // r-value로 초기화 할시 호출
    {
        c.name = nullptr;
    }

    ~Customer()
    {
        delete[] name;
    }
};

위 예제의 20 라인에는 기존의 복사 생성자와는 다른 형태의 '&&' 타입의 인자를 받는 생성자 코드가 추가 되었다. 이는 move 생성자라고 불리는 임시 객체(정확하게는 rvalue) 전용 생성자다.

복사 생성자가 name의 메모리를 전체 복사하는 대신, move 생성자에서는 단지 name의 주소를 가지고 있는 포인터 만을 복사할 뿐이다. 그리고 원본 객체가 가지고 있는 포인터를 그대로 놔두게 되면 원본 객체가 소멸할 때 name 도 같이 삭제하게 되므로 원본의 포인터는 무효화 시켜 준다.

int main()
{
    Customer c1 = Customer("Robert");
    Customer c2 = CreateCustomer("Jason"); // 임시 객체의 데이터를 '깊은 복사'
    return 0;
}

그래서 "Robert"의 경우에는 임시 객체가 아닌 일반 변수로 초기화 되기 때문에 복사 생성자를 호출할 것이며, "Jason"의 경우에는 함수에서 리턴 되는 임시 객체를 사용하므로 move 생성자가 호출 되도록 변경 된다.

임시 객체를 위한 별도의 복사 생성자 -> 'move 생성자'
REMARK : 위와 같이 함수의 임시 객체를 통한 move 생성자 호출의 경우, 컴파일러 최적화 과정을 거치며 함수 호출이 생략 되고 객체 생성으로 코드가 대체되어, move 생성자 호출이 없어 지는 경우가 있을 수 있다.

std::move 함수

앞에서 복사 생성자와 move 생성자에 대해 알아 보았다. 이번 섹션에서는 move 생성자를 호출하게 해주는 std::move 함수에 대해 살펴 보도록 하자.

class Object
{
public:
    Object() = default;
    Object(const Object& obj) { std::cout << "copy constructor" << std::endl; }
    Object(Object&& obj) noexcept { std::cout << "move constructor" << std::endl; }
};

Object func()
{
    Object obj;
    return obj;
}

int main()
{
    Object obj1;
    Object obj2 = obj1; // copy
    Object obj3 = func(); // move
    Object obj4 = obj1; // copy

    return 0;
}

위 예제는 복사 생성자와 move 생성자를 구현한 Object 클래스와 main함수에서 복사 생성자와 move 생성자를 호출할 수 있는 조건을 구현해 놓았다. 여기서 우리가 주의 깊게 볼 부분은 20라인 이다. 20라인에서는 obj4에 일반 객체 obj1을 대입하고 있으므로 복사 생성자가 호출된다.

하지만 20라인 뒤로는 obj1을 참조하는 코드가 없으므로 복사 생성 대신 move 생성자를 이용하여 오버헤드를 줄이고 싶다. 이럴 경우 rvalue 타입 캐스팅을 통해 강제적으로 move 생성자를 호출 하는 것이 가능하다.

// Object obj4 = obj1;
Object obj4 = static_cast<Object&&>(obj1); // move 생성자 호출

하지만 매번 위 처럼 긴 코드를 작성하려니 번거롭다. 그래서 C++11에서는 위 캐스팅 연산을 함수로 만들어 제공한다. 이것이 우리가 앞서 보았던 std::move 함수의 정체다.

std::move 함수는 :

  • 복사 생성자(복사 대입 연산자)가 아닌 "move 생성자(move 대입연산자)를 호출하기 위해 사용"
  • 단순히 전달 받은 인자를 rvalue로 캐스팅해서 리턴

흔히 std::move에 대해 오해하는 부분은 std::move 함수를 호출하기만 하면 C++에서 내부적으로 원본 객체의 자원을 빼앗아 대상 객체로 이동 시켜주는 것이라 오해하는데, 실제 move에 관련된 연산은 move 생성자나 move 대입연산자에서 구현 해주어야 한다. 실제 자원을 옮기는 작업은 클래스를 설계하는 작성자가 직접 구현해주어야 한다.

// Object obj4 = static_cast<Object&&>(obj1); // move 생성자 호출
Object obj4 = std:move(obj1); // 위의 static_cast와 같다

그런데 만일 사용자가 std::move를 객체에 대해 호출 했는데 클래스 설계자가 move 생성자나 대입연산자를 제공하지 않은 경우는 어떻게 될까? 이런 경우 앞에서 살펴 본대로 연산자 우선 순위에 의해 move 생성자가 없는 경우에는 일반 복사 생성자가 대신 호출 된다.

std::move 사용 시 :

  • move 생성자가 있다면 : move 생성자 호출
  • move 생성자가 없다면 : 에러를 발생 시키지 않고 복사 생성자를 대신 호출

마치며

이상 C++11 에서 추가 된 move sematics의 개념과 필요성에 대해 살펴 보았다. 위 예제에서는 move 생성자에 대해서만 예제를 다루고 있지만 대입 연산자에 대해서 move 연산을 작성하는 것이 어렵지 않을거라 생각한다.

move를 이해하기 위해서는 rvalue에 대한 이해가 꼭 필요하므로 아직까지 rvalue에 대해 모르는 분이 계신다면 아래 부록과 부록에 연결된 링크를 통해 객념을 꼭 익히도록 하자.

부록 1. r-value

C++에서는 앞으로 참조 되지 않을 변수들을 r-value 변수라고 하며 아래와 같이 정의한다. r-value에 대한 자세한 사항은 [여기]에서 자세히 다루고 있으므로 본 포스트에서는 r-value에 대한 간략한 정의만 살펴 보고 넘어 가도록 한다.

l-value r-value
등호(=)의 왼쪽에 올 수 있다 등호(=)의 왼쪽에 올 수 없다
이름이 있고 단일식을 벗어나 사용 가능 이름이 없고, 단일식에서만 사용 가능
주소 연산자로 주소를 구할 수 있다 주소 연산자로 주소를 구할 수 없다
참조를 반환하는 함수
문자열 리터럴
값을 반환하는 함수
실수/정수 리터럴
임시 객체(함수 리턴 값)

부록 2. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!