본문 바로가기
프로그래밍/C++

c++의 move, rvalue, lvalue에 대한 설명....(복잡해요~)

by Hwan2 2019. 12. 17.
반응형

 

 

 

 

무브...... move........

 

사전에 알아야할 것들이 있습니다.

 

1. 복사생성자(깊은복사).

2. static_cast

3. lvalue, rvalue

 

move에 대해 이해하려면 위 3가지는 반드시 짚고 넘어가야 합니다.

 

1. 복사생성자에 관해선 이미 글을 올린것이 있으니 참고하시기 바랍니다.

· 얕은복사와 깊은복사 : https://hwan-shell.tistory.com/42

· 복사생성자의 호출 과정 : https://hwan-shell.tistory.com/43

 

2. static_cast에 관해서는 아래 링크를 클릭해 주세요.

https://hwan-shell.tistory.com/211

 

3. lvalue, rvalue는 따로 정의할까 하다가같이 설명하는게 좋을것 같아 move와 같이 설명하겠습니다.

 

그리고 여기에 들어오신 분들은 move()에 대해 찾거나 rvalue를 이해하기 위해 검색을 통해 들어왔을 것입니다.

 

즉, 이 글을 읽고 계신 분들은 대충" move()가 뭔지 rvalue가 뭔지는 알지만 정확한 이해를 하지 못한상태 "라고 생각합니다.

 

결론부터 말씀드리자면 저희는 move() 생성자만 잘 만들어 준다면 됩니다.

그리고 사용자가 move()를 만들어서 사용한다 해도 컴파일러에 의해 복사생성자를 사용할 수도 있습니다.

또한 rvalue로 바뀌는 순간 데이터의 원본은 없어지는 것이기 때문에 예외가 발생했을 시 데이터의 백업이 안됩니다.

(이것도 사용자가 어떻게 정의 하냐에 따라 달라짐니다. 보통 백업이 되는 경우는 move()를 실행 했지만

move를 정의하지 않았거나 컴파일러의 판단으로 인한 lvalue로 인한 복사생성자의 호출이겠죠.)

따라서 예외처리에 취약하며 어느정도 데이터가 보장된 경우에 컴파일러의 판단에 의해 실행됩니다.

마지막으로 vector, string 등 modern c++에서 제공하는 컨테이너들(자료구조)은 move()가 잘 설계되어 있기 때문에(내부적으로)

저희는 신경을 따로 안써줘도 됩니다.

 

 

 

 

 

우선!! 이 move()라는 녀석이 왜 생겨났는지 알아야 합니다.

 

이유를 알아야 써먹을 수 있으니 말입니다.

 

이 녀석이 생겨난 이유는 바로 복사생성자 때문입니다.

(솔직히 복사생성자가 생겨난 이유도 c++이 객체지향 언어 성격을 띄면서 자연스럽게 생겨난 거기도 합니다...)

 

그럼 복사생성자를 왜 사용할까???

 

복사생성자가 필요한 이유는 다음과 같습니다.

 

1. class의 복사를 원할 때(깊은 복사)

2. 컨테이너(STL lib)를 활용할 때(값을 넣을 때)

 

이럴때 필요합니다.

 

스택에 할당된 변수는 상관이 없지만 동적으로 할당된 변수들은 기본적으로

포인터 성격을 띄기 때문입니다.

(만약 위 말이 이해가 안가신다면 복사생성자와 깊은 복사를 보고 오시길 바랍니다.)

 

 

복사생성자를 보면 알겠지만 빈번한 임시객체를 생성합니다. 그리고 빈번한 생성은 속도를 느리게 하죠.

물론 stack에 선언된 변수같은 것들은 이 횟수가 적은 편입니다.

문제는 heap에 할당된 변수들입니다.

메모리 할당과 소멸이 빈번하게 일어나기 때문입니다.

그 만큼 데이터 값을 유지하기 위한 복사생성자도 많이 일어나게 됩니다.

 

이 문제를 해결하고자 나온것이 move()입니다.

 

사실 이 move()는 실제로 값을 이동시켜주지는 않습니다.

 

단지 rvalue로 바꿔주는 역할을 할 뿐이죠.

 

그리고 이 rvalue로 바뀐 후 데이터에 값이 입력되는 순간.....

사용자가 정의한 move 생성자에 의해 데이터는 복사가 아닌 이동을 하게 됩니다.

 

 

 

move()를 보기 앞서서, 저희는 rvalue에 대해서 알아야 합니다.

간단한 예제를 통해 이해해보도록 합시다.

int i = 1;        //rvalue
int t = i;        //lvalue
int z = i + t;    //rvalue

int& a1 = 1;    //error!
int& a2 = i;    //ok!!
int& a3 = i + t;//error!
 

rvalue와 lvalue를 나눠봤습니다.

rvalue는 실체가 없는 값이라고 생각하시면 됩니다.

즉, 참조자 &ref로 받을 수 없는 모든 데이터들 이라고 생각하시면 됩니다.

 

int i = 1 에서 1에 대한 주소 값을 받을 수 있을까요? 불가능 합니다.

int z = i + t에서 i + t에 대한 주소 값을 받을 수 있을까요? 불가능 합니다.

i+t같은 경우에는 이 두개의 변수의 합을 임시객체에 저장 후 값을 반환하는 형태이기 때문에 rvalue라고 구분짓습니다.

 

반면 int t = i 에서 i는 lvalue입니다. 즉, i에 대해 주소 값을 사용자는 받을 수 있죠.

 

정리해보자면

주소 값을 받을 수 있는 모든 것들은 lvalue라고 부르고 rvalue는 그 외의 것들입니다.

다시말해서 lvalue는 참조 변수로 참조할 수 있지만 rvalue는 참조할 수 없습니다.

 

 

 

rvalue와 lvalue에 대한 설명이 부족한것 같지만 저희는 이것만 알고 넘어가면 됩니다.

rvalue는 주소 값 반환이 안되는 것, lvalue는 주소 값 반환이 되는 것!!

rvalue는 참조가 안되지만 lvalue는 참조가 된다는 것!!

 

여기까지만 읽고나면 이런 생각이 드실겁니다.

 

move()랑 rvalue랑 무슨 상관인데? rvalue로만 값을 만들어 주면 무조건 복사생성자 호출이 안되는 건가??

무슨 상관관계지??

 

이렇게 생각하신 분들이 계신다면..... 축하합니다.!!!!!🙏🙏🙏🙏🙏

저와 같은 분들이십니다.^^

 

이 궁금증은 아래에서 설명합니다.

 

 

 

그럼 이제 move()가 어떻게 생겼는지 알아보겠습니다.

그전에 저희는 2가지를 봐야합니다.

 

1. 해더파일 <utility>에 선언된 move() 함수.

2. 사용자가 직접 클래스에 정의할 move constructor.

 

 

설명에 앞서 rvalue를 받는 방법을 말씀드리겠습니다.

바로 '&&' 입니다.

즉, 참조자는 int&

rvalue는 int&& 로 받습니다.

 

move() 함수를 사용하는 방법은 간단합니다.

string s_1 = "Hwan";
string s_2 = move(s_1);

int i = 5;
int j = move(i);
 

간단하죠??

 

 

 

그럼 <utility>에 선언된 move() 함수 내부를 보겠습니다.

template<class _Ty>
    _NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
    {    // forward an lvalue as either an lvalue or an rvalue
    return (static_cast<_Ty&&>(_Arg));
    }

template<class _Ty>
    _NODISCARD constexpr _Ty&& forward(remove_reference_t<_Ty>&& _Arg) noexcept
    {    // forward an rvalue as an rvalue
    static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
    return (static_cast<_Ty&&>(_Arg));
    }

 

어허..... 보기만해도 머리가 아프고 뭔 말인지 전혀~ 모르겠는 코드 입니다.

 

여기서 저희가 주목해서 봐야할 부분은 다음과 같습니다.

 

forward(remove_reference_t<_Ty>& _Arg)

return (static_cast<_Ty&&>(_Arg));

 

forward(remove_reference_t<_Ty>&& _Arg)

return (static_cast<_Ty&&>(_Arg));

 

 

여기서 forward는 if와 비슷한 역할을 하는데, 들어오는 값이 lvalue일 경우와 rvalue일 경우를 두고 만들어진 코드 입니다.

풀어서 설명하자면,

 

move를 통해 들어온 변수 타입이 lvalue이면 1번에 있는 함수를 실행하고,

move를 통해 들어온 변수 타입이 rvalue이면 2번에 있는 함수를 실행한다. 입니다.

즉, 

제가 설명하기 앞서 맨 처음에 이렇게 말씀드렸습니다.

 

" 사실 이 move()는 실제로 값을 이동시켜주지는 않습니다.

 

단지 rvalue로 바꿔주는 역할을 할 뿐이죠.

 

그리고 이 rvalue로 바뀐 후 데이터에 값이 입력되는 순간.....

사용자가 정의한 move 생성자에 의해 데이터는 복사가 아닌 이동을 하게 됩니다. "

 

이것을 증명하는 코드가 바로 위 코드 입니다.

 

lvalue는 참조가 가능하여 타입은 참조타입으로 받습니다. (2번 행)

<_Ty>& _Arg 이렇게 말이죠..... 보면 &가 하나있습니다.

 

rvalue로 받는 녀석들은 && 두개로 (8번 행)

<_Ty>&& _Arg 이렇게 말이죠.....

 

 

그리고 타입별로 함수가 실행되며(오버로딩) 반환을 다음과 같이 해주고 있습니다.

return (static_cast<_Ty&&>(_Arg));

 

rvalue로 케스팅(바꿔주는)해주고 있습니다.

그리고 캐스팅된 값을 반환해 주고 있고요.

 

 

 

여기까지 왔으면 다음과 같은 생각이 드실 겁니다.

"move() 동작은 이해했는데, rvalue로 값을 반환 해주는거랑 복사 생성자 호출이랑 뭔상관이지?"

 

이에 대한 해답을 아래에 설명하겠습니다.

 

 

 

이번엔 move constructor를 만들어 보겠습니다.

#include <iostream>
#include <vector>
#include <string>

using namespace std;

class A {
private:
    string s;
    char* data;

public:
    A() {}

    A(string s) : s(s), data(new char[10000]) { cout << " constructor" << endl; }

    A(const A &ref) {        //복사 생성자
        this->s = ref.s;
        this->data = new char[10000];

        cout << s << " copy constructor" << endl;
    }

    A(A &&ref) {            //이동 생성자
        this->s = ref.s;
        this->data = ref.data;
        ref.data = nullptr;
        cout << s << " move constructor" << endl;
    }

    ~A() {
        delete[]data;
        cout << " ~constructor" << endl;
    }

};
 

 

클래스 A에 대한 코드입니다.

 

생성자, 복사 생성자, 이동 생성자가 선언된 모습을 확인할 수 있습니다.

 

복사생성자는 A(const A &ref)형태로 받고 있고,

이동생성자는 A(A &&ref)형태로 받고 있습니다.

 

간단한 클래스 A의 설명을 드리자면, 

복사생성자가 호출이 되면 새로운 배열을 할당받고,

이동생성자가 호출이 되면 기존의 배열 주소 값을 전달 받습니다.

 

 

사용자가 기본적으로 클래스만 생성하게되면

이동생성자에 대한 디폴트는 없습니다.

반면 복사생성자의 디폴트는 있죠.

 

하지만 사용자에 의해 복사생성자가 정의 된다면 컴파일 과정에서

디폴트 이동생성자가 생깁니다.

참고 : https://en.cppreference.com/w/cpp/language/move_constructor

 

 

그리고 제가 정의한 클래스 A처럼 복사와 이동생성자가 있다면

대입과정에서 보통 복사생성자가 호출이 됩니다.

 

또한 c++ library에 정의된 container 함수들인 vector, string 등에 

복사와 이동생성자가 정의되어 있습니다.

따라서 저희가 신경써야 할 것들은 클래스의 이동생성자 생성입니다.

 

 

그럼 다시 본론으로 돌아와서 저 이동생성자를 호출할 방법에 대해 알아보도록 하겠습니다.

#include <iostream>
#include <utility>

using namespace std;

class A {
private:
    string s;
    char * data;

public:
    A() {}
    A(string s) : s(s), data(new char[10000]) 
    { cout << " constructor" << endl; }

    A(const A& ref) {
        this->s = ref.s;
        this->data = new char[10000];

        cout << s << " copy constructor" << endl;
    }
    
    A(A &&ref)  {
        this->s = ref.s;
        this->data = ref.data;
        ref.data = nullptr;
        cout << s << " move constructor" << endl;
    }

    ~A() {
        delete[]data;
        cout << " ~constructor" << endl;
    }

    void print() {
        printf("data가 가르키고 있는 주소 값 : %x\n", data);
    }
};


int main(void) {

    A a("a");
    A b = a;

    a.print();
    b.print();

    return 0;
}
 

 

41행부터 50행까지가 추가 되었습니다.

 

일반적인 대입연산을 하고 있으며, print()라는 정의한 함수를 통해

data가 가르키고 있는 주소 값을 보여주고 있습니다.

 

output :

 

 

a.data와 b.data는 서로 다른 주소를 가르키고 있습니다.

 

여기서 알 수 있는 사실이 있습니다.

 

" 이동생성자를 정의해도 일반적인 대입연산으로 인한 호출은 복사생성자가 호출 되는구나!! "

 

 

그럼 저희는 이동생성자에게 '&&' 타입의 변수를 전달해 줘야 합니다.

일반적인 전달은 복사생성자 변수인 참조변수 &ref가 다 가져가 버리기 때문입니다.

 

이걸 위한 방법이 move()의 호출입니다.

 

int main(void) {

    A a("a");
    A b = move(a);

    a.print();
    b.print();

    return 0;
}

 

4행을 보시면 move(a);로 코드가 바뀌었습니다.

 

 

output : 

 

 

 

이동생성자가 호출되고 data가 가르키는 주소값이 잘 전달된 걸 알 수 있습니다.

 

 

이 뒤에부터 나오는 설명은 제가 정의한 클래스 A가 백터 안에서 어떻게 작동되는지,

string에서의 move는 어떻게 되는지 알아보도록 하겠습니다.

 

 

제가 앞전에서 string이나 vector같은 것들은 "이들 함수들은 내부에 복사와 이동생성자에 대해서 정의가 되어 있어"

라고 말했습니다. 이를 증명해보도록 하겠습니다.

 

 

 

우선 stirng부터....

#include <iostream>
#include <string>

int main(void) {
    string s_1 = "hwan";
    string s_2 = s_1;

    cout << s_1 << endl;
    cout << s_2 << endl;

    cout << "////////////////" << endl;
    
    string s_3 = move(s_1);
    
    cout << s_1 << endl;
    cout << s_2 << endl;

    return 0;
}

output : 

 

 

 

간단해서 설명을 드릴건 없고....

move를 통해 s_1이 전달되면

s_1값은 ""로 되는걸 보여주는 예시였습니다.

 

이를 통해 string 내부에는 이동생성자에 대한 정의가 있는걸 알 수 있습니다.

(백터도 똑같은 방식으로 백터끼리 이동시켜주면 저렇게 됩니다.)

 

 

다음은 흔히 사용되는 vector에 제가 정의한 클래스 A를 넣어보는 실험을 해보겠습니다.

 

그 전에 백터에 대해 좀 알아야 합니다.

링크 : https://hwan-shell.tistory.com/119

 

※이 예제는 백터의 재할당 과정에서의 생성자 호출을 보기 위함입니다.

호출과정을 이해하실려면 백터의 push_back()에 따른 capacity()용량 증가를 아셔야 합니다.

이것은 위 링크에 설명되어 있으니 참고하시기 바랍니다.

 

#include <iostream>
#include <vector>
#include <utility>

using namespace std;

class A {
private:
    string s;
    char * data;

public:
    A() {}
    A(string s) : s(s), data(new char[10000]) 
    { cout << " constructor" << endl; }

    A(const A& ref) {
        this->s = ref.s;
        this->data = new char[10000];

        cout << s << " copy constructor" << endl;
    }
    
    A(A &&ref)  {
        this->s = ref.s;
        this->data = ref.data;
        ref.data = nullptr;
        cout << s << " move constructor" << endl;
    }

    ~A() {
        delete[]data;
        cout << " ~constructor" << endl;
    }

    void print() {
        printf("data가 가르키고 있는 주소 값 : %x\n", data);
    }
};


int main(void) {
    vector<A> v;

    A a("a");
    A b("b");

    v.push_back(a);    //백터에 값(a) 입력...    
    v[0].print();        //v[0]에 있는 data가 가르키는 주소 값 출력...

    cout << "백터가 가르키고 있는 주소 값 : " << &v[0] << ", v.capacity() : " << v.capacity() << endl;

    v.push_back(b);    //백터에 값(b) 입력...
    v[0].print();        //v[0]에 있는 data가 가르키는 주소 값 출력...

    cout << "백터가 가르키고 있는 주소 값 : " << &v[0] << ", v.capacity() : " << v.capacity() << endl;
    cout << "///////////cut///////////" << endl;
    return 0;
}

 

output : 

 

 

 

이를통해 알 수 있는 사실 2가지가 있습니다.

 

1. 이동 생성자를 정의해도 값을 move()로 전달하지 않으면 백터 내부에선 값이 복사된다.

2. 복사로 인해 같은 녀석이 가르키는 메모리도 달라진다.

 

 

output을 보시면 "data가 가르키는 주소 값" 이랑 " 벡터가 가르키고 있는 주소 값"이 

push_back(a)를 했을 때와 push_back(b)를 했을 때와 다른걸 알 수 있습니다.

 

 

그림 이동생성자를 사용해보겠습니다.

#include <iostream>
#include <vector>
#include <utility>
using namespace std;

class A {
private:
    string s;
    char * data;

public:
    A() {}
    A(string s) : s(s), data(new char[10000]) 
    { cout << " constructor" << endl; }

    A(const A& ref) {
        this->s = ref.s;
        this->data = new char[10000];

        cout << s << " copy constructor" << endl;
    }
    
    A(A &&ref)  noexcept{        //noexcept 추가
        this->s = ref.s;
        this->data = ref.data;
        ref.data = nullptr;
        cout << s << " move constructor" << endl;
    }

    ~A() {
        delete[]data;
        cout << " ~constructor" << endl;
    }

    void print() {
        printf("data가 가르키고 있는 주소 값 : %x\n", data);
    }
};


int main(void) {
    vector<A> v;

    A a("a");
    A b("b");

    v.push_back(move(a));    //백터에 값(a) 입력...    
    v[0].print();        //v[0]에 있는 data가 가르키는 주소 값 출력...

    cout << "백터가 가르키고 있는 주소 값 : " << &v[0] << ", v.capacity() : " << v.capacity() << endl;

    v.push_back(move(b));    //백터에 값(b) 입력...
    v[0].print();        //v[0]에 있는 data가 가르키는 주소 값 출력...

    cout << "백터가 가르키고 있는 주소 값 : " << &v[0] << ", v.capacity() : " << v.capacity() << endl;
    cout << "///////////cut///////////" << endl;
    return 0;
}
 

 

 

output : 

 

 

 

이동생성자 호출 후 data가 가르키는 값을 보면 동일한걸 알 수 있습니다.

 

바뀐 행은 23, 47, 52행입니다.

23행에선 noexcept 이 추가 되었으며

47과 52행은 move()가 추가 되었습니다.

 

그림으로 표현하면 다음과 같습니다.

 

23행에서 noexcept의 역할은 예외가 없다는 보장을 컴파일러에게 알려주는 것입니다.

이 것이 없다면 move생성자는 제대로 실행되지 않습니다.

그 이유는 간단합니다. 컴파일러는 저 코드에서 예외가 발생할 수 있으니 최대한 안전한 복사생성자를

호출하는 것입니다.

 

 

 

앞서 말씀드렸다 시피 move()데이터의 백업을 보장하지 않습니다. 때문에 예기치 못한 버그에 취약하죠.

또한 요즘 컴퓨터 성능이 좋아져서 프로그래머 입장에선 move()에 대해 신경써가면서 코딩할 필요는 없을 것 같습니다.

하지만 알아두면 좋겠죠.

 

 

그리고 백터 같은 경우도 선언될 경우 capacity값은 1로 잡혀있습니다.

따라서 재할당으로 인한 불필요한 오버해드를 막기 좋은 방법은 reserve()함수를 이용해 capacity크기를 정해놓는 것이 좋습니다.

 

 

 

 

이 글을 작성하는데 이틀이 걸렸내요.....

혹시 끝까지 읽으신 분이 계신다면 하트 하나만 눌러주세요 ㅋㅋㅋㅋ

몇분이 끝까지 봤는지 개인적으로 궁금하네요 ㅎㅎ

 

 

 

 

 

반응형

댓글


스킨편집 -> html 편집에서