본문으로 바로가기

c++11) std::move (move semantics)

category C++/Modern 2019. 2. 1. 05:10

lvalue 는 left-value 로, rvalue 는 right-value로 대부분 풀어쓰고 있다.

그래서 = (대입 연산자) 에서 왼쪽에 올 수 있는 값은 lvalue , 오른쪽에 올 수 있는 값은 lvalue 또는 rvalue 라고 정의하고 있다.

하지만 C++11 에서 std::move 와 std::forward 의 도입으로 이제는 다른 식으로 표현해야 할 필요성이 있음.

 

간단하게 표현하기 위해서, lvalue는 주소값을 읽어올 수 있는 객체 또는 이름이 있는 객체로, 

rvalue는 이름이 없거나 주소값을 읽어올 수 없는 객체 라고 보면 된다.

 

1. int x = 3 에서 x 는 상수 3을 가지고 새로운 주소를 할당해 만들었으므로 (이름이 있으므로) lvalue 

하지만 3은 주소가 있던놈이 아니므로 rvalue

 

2. const int y = x 에서, x가 lvalue 였으므로 y도 lvalue

 

3. int z = x + y 에서, x + y  x  y 를 더한값을 임시객체로 내놓고 z에 대입함

그러므로 x + y는 rvalue, z는 lvalue 

 

4. string("abc") 는 들어갈 변수가 없음 또한 임시객체임, 고로 rvalue

 

5. Widget&& abc = returnWidget() 은 팩토리함수에서 임시객체를 생성받아 대입합니다. 임시객체 자체는 rvalue 이지만 abc가 할당받았습니다. (이름이 생김), 고로 abc Widget&& 형이여도 lvalue ( Widget& 형으로 변환됨 )

 

6. Widget&& abc2 = abc 는, abc2  abc를 참조하려고 하나, abc는 lvalue이기 때문에 가능하지 않음.

 

 

x는 lvalue

++x는 lvalue ( 전위 연산자는 자신을 증가시키고, 증가된 자신을 리턴함 )

x++는 rvalue ( 후위 연산자는 자신을 증가시키고, 증가되기 전의 임시객체를 리턴하기 때문에 )

그러므로 int&& 에 값을 넣는다면?

x++은 rvalue 이기때문에 가능하고

++x는 lvalue 이기때문에 불가능

 

lvalue reference와 rvalue reference

C++11 표준에서, rvalue reference 가 등장했다.

lvalue reference는 lvalue만, rvalue reference 는 rvalue만 참조할 수 있습니다.

move semantics가 생겨나며, rvalue 참조자가 등장했습니다.

 

만약 사용자가 T라는 사용자 정의 타입을 만들고 그것을 배열로 선언한 후, 각 각 초기화 시켜야 한다면

for문을 통한 초기화가 가장 편할 것이다.

 

#include <iostream>
using namespace std;

class Widget{
private:
    int* a;
public:
    Widget() : a(nullptr) { }
    Widget(int _data) : a(new int){
        *a = _data;        // int 크기 하나를 할당받고, 값을 _data로 채움
    }
    Widget(const Widget& rhs) : a(new int) {
        *a = *rhs.a;
    }
    Widget& operator=(const Widget& rhs) {
        if (a != nullptr)
            delete a;

        a = new int;
        *a = *rhs.a;
        return *this;
    }
    ~Widget() { delete a; }
};
int main() {
    Widget B[10];
    for (int i = 0; i < 10; ++i)
        B[i] = (Widget(i));
}

1. Widget(i) 를 통해 임시객체를 생성함

2. B[i] = (Widget(i)) 를 통해 Widget 의 대입연산자를 호출함

3. 새로운 메모리를 할당, 값을 대입, 기존 값 해제

4. 자기 자신을 반환

 

이를 통해, 임시 객체가 만들어질때마다 메모리를 할당하고, 그것을 다시 B[i] 에게 대입함.

그럼 대입연산자 내에선 또 자신의 메모리를 할당하고, Widget(i)의 값을 가져온 후, 다시 Widget(i)을 해제함

초기화 한번에 할당이 2번, 해제가 1번 일어나는 셈

이런 불필요한 연산을 없애자는게 Move Semantics 

 

Widget(i)는 rvalue 이므로 ( 임시객체이므로 ) 어차피 임시로만 사용되고 다시 삭제될 것이기 때문에, 소유권을 이전시킨다 라고 한다.

이 소스를 다시 수정하면 

 

#include <iostream>
using namespace std;

class Widget{
private:
    int* a;

public:
    Widget() : a(nullptr) { }
    Widget(int _data) : a(new int){
        *a = _data;        // int 크기 하나를 할당받고, 값을 _data로 채움
    }
    Widget(const Widget& rhs) : a(new int) {
        *a = *rhs.a;
    }
    Widget& operator=(const Widget& rhs) {
        if (a != nullptr)
            delete a;

        a = new int;
        *a = *rhs.a;
        return *this;
    }
    Widget& operator=(Widget&& rhs) {    // 이제 이동 대입 연산자가 있음
        if (a != nullptr)
            delete a;

        a = rhs.a;
        rhs.a = nullptr;
        return *this;
    }
    ~Widget() { delete a; }
};
int main() {
    Widget B[10];
    for (int i = 0; i < 10; ++i)
        B[i] = (Widget(i));
}

 

Widget(i)는 i값에 맞춰 할당을 한번 할거고, Widget(i)가 rvalue 이기때문에

Widget& operator=(Widget&& rhs) 로 진입하게 됨

B[i]의 a는 rhs.a 의 주소값만 가져온 뒤, rhs는 이제 사용하지 않을 객체이기 때문에 nullptr로 바꿔줌

이렇게 할당 2번 + 해제 1번이 할당 1번으로 변경되었음

 

보다싶이, Move semantics는 객체의 리소스 (동적할당 된 변수) 를 다른 객체로 이동시키는 행위

어짜피 사용되지 않을 임시객체이니 리소스 소유권만 이동시키고 불필요한 메모리 할당과 복사를 막자는 것

 

이 처럼 이동 생성자 또한 가능하다.

 

void func(const Widget& rhs) {
    cout << "const Widget&" << endl;
}
void func(Widget&& rhs) {
    cout << "Widget&& " << endl;
}
int main() {
    Widget A;
    func(A);
    func(Widget());
}

다음과 같은 함수가 있다고 해보자 ( Widget는 위에 있는 Widget 그대로 활용 )

func(A);func(Widget()); 는 어떤 함수가 호출될까?

당연히 func(A)는 이름 있는 객체이므로 func(const Widget& rhs) 함수가 호출될 것이고,

func(Widget())는 무명 객체(임시 객체)이므로 func(Widget&& rhs) 함수가 호출될 것이다.

( 사실 func(Widget())func(const Widget& rhs) 버전도 호출 가능하나, 명확한 func(Widget&& rhs)

가 더 높은 우선순위에 오르게 된다. )

 

하지만 std::move 함수를 사용하면 A를 rvalue로 캐스팅 할 수 있게되는데,

이는 static_cast<Widget&&>(A) 와 같다. ( std::move 함수가 이와 같이 작성되어 있다. )

 

void func(const Widget& rhs) {
    cout << "const Widget&" << endl;
}
void func(Widget&& rhs) {
    cout << "Widget&& " << endl;
}
int main() {
    Widget A;
    func(std::move(A));    // 이제 func(Widget&& rhs) 버전이 호출됨
    func(static_cast<Widget&&>(A));    // std::move 와 같음
}

std::move 는 결국 lvalue 가 더 이상 필요 없을때, 소유권을 이전시킬 수 있게 해준다.

 

 

rvalue를 함수내에서 받았을땐, lvalue처럼 취급함.

 

사실 이건 auto 때도 한번 작성했던 내용인데, 한번 더 작성함.

위의 Widget 이동 연산자 내에서의 rhs는 rvalue로 받았지만, lvalue 처럼 작동함.

그 이유는 rvalue reference 를 통해 Widget 의 임시객체를 전달받았지만, rhs 를 사용할 수 있을 때부터, 

그 것은 주소공간에 이미 저장된 (이름이 붙여진) 변수이기때문에 lvalue 와 동일하게 작동함.

이는 Reference Collapsing Rule 과 연결됨

 

<Reference Collapsing Rule>

 

lvalue reference와 rvalue reference, 두 종류의 레퍼런스 종류로 인해

4가지의 레퍼런스의 레퍼런스 조합이 존재할 수 있다.

 

  • &    &    (L + L)
  • &    &&  (L + R)
  • &&  &    (R + L)
  • &&  &&  (R + R)
template <typename T>
void func(T&& _argv) {
    // working...
}
int main() {
    Widget A;
    func(A);        // 1. lvalue 이것은 곧 Widget& &&_argv와 같다.
    func(Widget()); // 2. rvalue 이것은 곧 Widget&& &&_argv와 같다.
}

 

1. 규칙에 의해, A는 lvalue, func에 들어간다면 template 추론으로 의해

void func(Widget& &&_argv) 가 호출이 되고 이것은 func(Widget& _argv)와 같다. ( L + R )

 

2. 규칙에 의해, func(WIdget())는 rvalue, func에 들어간다면 template 추론으로 의해

void func(Widget&& &&_argv) 가 호출이 되고 이것은 func(Widget&& _argv) 와 같다. ( R + R )

 

 

 

[ 만약 이 글만으로도 이해가 되지 않는다면, 더 좋은 예시가 있으니 확인 바람 ]

★링크 : https://m.blog.naver.com/devmachine/176442133

 

[C++11] Rvalue Reference #2 - Move Semantics

Move Semantics Move Semantics란 객체의 리소스(동적으로 할당 된 메모리와 같은)를 또 다른 객체로 ...

blog.naver.com