물에 사는 벌레
우측값과 좌측값 그리고 이동 생성자 본문
int i = 1;
우측 값과 좌측 값을 쉽게 보자면 = 연산자의 오른쪽에 있는 값, 왼쪽에 있는 값을 부르는 말이다.
그런데 좌측 값은 좌측, 우측 어디에든 있을 수 있으며, 우측 값은 언제나 우측에만 있을 수 있다.
무슨 말이냐면,
int i = 1;
int j = i;
이런 말이다.
좌측값인 i는 두 번째 행에서 우측에 존재한다. 즉 좌측 값은 우측에 위치할 수 있다.
하지만 우측 값이 좌측에 위치하는 것은 불가능하다.
int i = 1;
1 = int j;
1 = i;
2행 3행은 불가능하다.
& 기호를 사용하는 레퍼런스 또한 좌측 값이기 때문에 모든 우측 값을 받을 수 있을 것 같으나 그렇지 않다.
int& i = 1;
해당 구문은 에러 메시지를 출력한다.
"내용은 비 const 참조에 대한 내용은 lvalue여야 합니다."로 lvalue 즉 left value(좌측값)을 대입해야 하거나 타입을 const로 정의하라고 하는 것이다.
int i = 1;
int& ref_i = i;
const int& const_ref = 1;
즉 위와 같은 내용은 에러 메세지를 출력하지 않는다.
함수로 참조값을 반환하는 경우 함수 자체에 값을 대입하는 모양을 나타낼 수도 있다.
int i = 0;
int& f()
{
return i;
}
int& f(int& j)
{
return j;
}
int main()
{
f() = 1;
cout << f() << endl;
int j = 2;
f(j) = 3;
cout << j << endl;
return 0;
}
보통 함수는 우측값이나 이런 경우는 당연하게도 좌측 값이 되어 값을 할당받을 수 있다.
일반적으로 한 개의 앰퍼서드 기호를 사용하여 참조를 나타내지만 두 개의 앰퍼서드 기호를 사용하여 나타내는 특이한 참조도 있다.
이를 우측값 레퍼런스라고 한다.
int&& i = 1;
앰퍼서드가 한 개만 있는 경우에는 위에서 본 것처럼 실체가 있는 좌측 값을 대입하라고 에러 메시지를 띄우지만 이 경우에는 에러 메시지를 띄우지 않는다.
또한 우측값 레퍼런스는 처음부터 우측 값만을 참조하게 만들어져 있다.
즉 좌측값을 참조할 수 없다.
int j = 1;
int&& i = j;
2행에서 오류 메시지가 발생한다.
메시지의 내용은 "rvalue 참조를 lvalue에 바인딩할 수 없습니다." 이다.
즉 좌측 값을 참조할 수 없다는 말이다.
그래서 이걸 어디에 쓸까?
이럴 바에는 그냥 "int i = 1;" 이라고 쓰면 되지 않느냐?
이 특이한 녀석은 이동 생성자라는 것의 인자로 사용된다.
이동 생성자라는 녀석의 위력을 보여주기 위해 극한 상황을 만들어 보았다.
참고로 32비트 모드에서 1000MB로 진행 시 크래쉬가 난다.
큰 차이를 보기 위해서는 64비트 모드에서 MB의 크기를 조정하길 바란다.
#include <iostream>
#include <conio.h>
using namespace std;
#define MB 1000
class A
{
static int g_count;
int count;
public:
char* str[MB];
A(const char* str)
{
count = g_count++;
for (int i = 0; i < MB; i++)
{
int str_len = strlen(str);
this->str[i] = new char[str_len + 1];
strcpy_s(this->str[i], str_len + 1, str);
}
cout << count << " 생성자 실행" << endl;
}
~A()
{
bool deleted = false;
for (int i = 0; i < MB; i++)
{
if (str[i] != nullptr)
{
delete[] str[i];
deleted = true;
}
}
if (deleted)cout << "문자열 제거됨, ";
cout << count << " 소멸자 실행" << endl;
}
A(const A& a)
{
count = g_count++;
for (int i = 0; i < MB; i++)
{
int str_len = strlen(a.str[i]);
this->str[i] = new char[str_len + 1];
strcpy_s(this->str[i], str_len + 1, a.str[i]);
}
cout << count << " 복사 생성자 실행" << endl;
}
A& operator = (const A& a)
{
for (int i = 0; i < MB; i++)
{
int str_len = strlen(a.str[i]);
this->str[i] = new char[str_len + 1];
strcpy_s(this->str[i], str_len + 1, a.str[i]);
}
cout << count << " 복사 연산자 실행" << endl;
return *this;
}
};
int A::g_count = 1;
void Swap(A& a1, A& a2)
{
A temp(a1);
a1 = a2;
a2 = temp;
}
char _1mbStr[1000000];
void Init1MB()
{
for (int i = 0; i < 1000000; i++)_1mbStr[i] = '0';
}
int main()
{
cout << "초기화 중..." << endl;
Init1MB();
A a1(_1mbStr);
A a2(_1mbStr);
cout << "엔터 키를 눌러서 스왑 시작..." << endl;
while (!_kbhit()) {}
Swap(a1, a2);
return 0;
}
각각의 A 클래스는 1GB의 문자열을 가지고 있다.
이 두 개의 A 클래스를 서로 바꾸는 스왑 함수가 있는데 이때 스왑 함수의 처리시간은 어마어마하다.
또한 메모리의 낭비도 어마어마하게 커진다.
스왑 함수를 자세히 들여다보면 순서는 이렇다.
"A temp(a1);" 구문에서 a1의 문자열을 temp에 복사한다.
- a1의 문자열을 모두 복사하는 데에 큰 시간이 걸린다.
- temp에 1GB의 추가 메모리 할당이 일어난다.
"a1 = a2;" 구문에서 a2의 문자열을 a2에 복사한다.
- a2의 문자열을 모두 복사하는 데에 큰 시간이 걸린다.
"a2 = temp;" 구문에서 temp의 문자열을 a2에 복사한다.
- temp의 문자열을 모두 복사하는데에 큰 시간이 걸린다.
총 3GB의 문자열을 처리하고, 1GB의 공간 낭비가 생겼다.
꽤나 극한 상황이다.
그런데 사실 생각해보면 swap 함수의 내부에서 값을 전달할 때, 즉...
a1에서 temp로,
a2에서 a1으로,
temp에서 a2로,
같은 작업들은 잘 생각해 보면 값을 전달한 후로는 굳이 보낸 값을 가지고 있을 필요가 없다. 어차피 덮어씌워진 예정이기 때문이다.
차라리 그냥 보내버리고 원래 값을 지워버려도 문제가 없다는 말이다.
위의 사항을 고려하여 Swap2 함수를 구현하면 이렇다.
void Swap2(A& a1, A& a2)
{
char* tempStr[MB];
for (int i = 0; i < MB; i++)
{
tempStr[i] = a1.str[i];
a1.str[i] = a2.str[i];
a2.str[i] = tempStr[i];
}
}
새로운 객체를 생성하여 값을 저장하는 대신, 내부의 문자열만 스왑 해주고 있는 모습이다.
하지만 이동 생성자를 사용하는 것에 비해서 조금 느리다.
또한 클래스 내부의 멤버 변수가 늘어나는 경우 유지보수가 힘들기도 하다.
위처럼 '전달'만 필요한 경우. 즉 전달 후 그 이후에 더 이상 값을 참조하지 않을 경우에는 이동 생성자를 사용하면 좋다.
이동 생성자는 Swap2 함수와 거의 유사하다.
아래는 이동 생성자와 이동 연산자를 구현한 A 형태이다.
#include <iostream>
#include <conio.h>
using namespace std;
#define MOVE
#define NOMOVE
#define MB 1000
class A
{
static int g_count;
int count;
public:
char* str[MB];
A(const char* str)
{
count = g_count++;
for (int i = 0; i < MB; i++)
{
int str_len = strlen(str);
this->str[i] = new char[str_len + 1];
strcpy_s(this->str[i], str_len + 1, str);
}
cout << count << " 생성자 실행" << endl;
}
~A()
{
bool deleted = false;
for (int i = 0; i < MB; i++)
{
if (str[i] != nullptr)
{
delete[] str[i];
deleted = true;
}
}
if (deleted)cout << "문자열 제거됨, ";
cout << count << " 소멸자 실행" << endl;
}
A(const A& a)
{
count = g_count++;
for (int i = 0; i < MB; i++)
{
int str_len = strlen(a.str[i]);
this->str[i] = new char[str_len + 1];
strcpy_s(this->str[i], str_len + 1, a.str[i]);
}
cout << count << " 복사 생성자 실행" << endl;
}
A(A&& a)
{
count = g_count++;
for (int i = 0; i < MB; i++)
{
this->str[i] = a.str[i];
a.str[i] = nullptr;
}
cout << count << " 이동 생성자 실행" << endl;
}
A& operator = (const A& a)
{
for (int i = 0; i < MB; i++)
{
int str_len = strlen(a.str[i]);
this->str[i] = new char[str_len + 1];
strcpy_s(this->str[i], str_len + 1, a.str[i]);
}
cout << count << " 복사 연산자 실행" << endl;
return *this;
}
A& operator = (A&& a)
{
for (int i = 0; i < MB; i++)
{
this->str[i] = a.str[i];
a.str[i] = nullptr;
}
cout << count << " 이동 연산자 실행" << endl;
return *this;
}
};
int A::g_count = 1;
void Swap(A& a1, A& a2)
{
A temp(a1);
a1 = a2;
a2 = temp;
}
void Swap2(A& a1, A& a2)
{
char* tempStr[MB];
for (int i = 0; i < MB; i++)
{
tempStr[i] = a1.str[i];
a1.str[i] = a2.str[i];
a2.str[i] = tempStr[i];
}
}
void Swap_Move(A& a1, A& a2)
{
A&& temp = move(a1);
a1 = move(a2);
a2 = move(temp);
}
char _1mbStr[1000000];
void Init1MB()
{
for (int i = 0; i < 1000000; i++)_1mbStr[i] = '0';
}
int main()
{
cout << "초기화 중..." << endl;
Init1MB();
A a1(_1mbStr);
A a2(_1mbStr);
cout << "엔터 키를 눌러서 스왑 시작..." << endl;
while (!_kbhit()) {}
#ifdef MOVE
Swap_Move(a1, a2);
#elif defined NOMOVE
Swap(a1, a2);
#endif
return 0;
}
위 코드를 실행하면 Swap 함수에 비하여 엄청 빠른 속도로 스왑이 이루어지는 결과를 얻을 수 있다.
우선 새로 추가한 이동 생성자와 이동 연산자는 구현은 똑같기에 이동 생성자를 기준으로 설명하겠다.
A(A&& a)
{
count = g_count++;
for (int i = 0; i < MB; i++)
{
this->str[i] = a.str[i];
a.str[i] = nullptr;
}
cout << count << " 이동 생성자 실행" << endl;
}
우선 선언의 인자 내용이 기존의 "A(const A& a)"와 다르게 "A&& a" 형태이다.
내부를 보면 문자열을 일일이 복사하는 게 아닌 문자 배열의 주소 자체를 넘겨버리고 있다.
그리고 위에서 대략 이야기가 나온 "나는 전달했으니, 더 이상 가지고 있지 않아요." 라는 말처럼 전달한 후 nullptr 로 변경한다. 물론 소멸자에서 nullptr 인 경우 삭제하지 않는 조건을 추가해 주어야 한다.
그리고 이동 생성자의 호출은 "move( x )" 함수를 통해 이루어진다.
이제 새로 추가한 Swap_Move( a1, a2 ) 함수를 보자.
void Swap_Move(A& a1, A& a2)
{
A&& temp = move(a1);
a1 = move(a2);
a2 = move(temp);
}
"A&& temp = move(a1);" 구문에서 a1의 내용을 temp로 이동시킨다. 단순히 이동. 이동 연산자가 호출될 텐데 출력은 없다.
"a1 = move(a2):" 구문에서 a2의 내용을 a1으로 이동시킨다. 이동 연산자 호출.
"a2 = move(temp);" 구문에서 temp의 내용을 a2로 이동시키고 끝. 이동 연산자 호출.
이로써 스왑 함수의 불필요한 복사와 메모리 공간 낭비를 해결했다.
temp는 소멸자 조차 호출되지 않는다.
이동 생성자는 '이동'이 빈번히 일어나거나, 크기가 큰 클래스의 '이동'시에 사용하면 굉장히 좋을 듯하다.
여기서 이동이란 값을 전달한 후 전달한 이는 더 이상 값을 갖고 있지 않은 상태를 말한다.
class A
{
public:
A() { cout << "A constuctor" << endl; }
~A() { cout << "A destructor" << endl; }
A(const A& a) { cout << "A copy constructor" << endl; }
A(A&& a) { cout << "A move constructor" << endl; }
A& operator =(const A& a) { cout << "A copy operator" << endl; return *this; }
A& operator =(A&& a) { cout << "A move operator" << endl; return *this; }
};
template <typename T>
class SharedPointerClone
{
T* pointer;
public:
SharedPointerClone() {}
};
int main()
{
A a1;
A&& a2 = move(a1);
A&& a3 = move(a2);
A&& a4 = move(a3);
}
확실히 하기 위해 마지막 예제!
이 경우 출력은" A constructor, A destructor" 단 두 개뿐이다.
이상한 건 이동 연산자나 이동 생성자가 호출될 텐데 출력이 없는 것이다.
자세한 내용을 알고 싶다면 && 참조, move, forward, 모던 C++ 키워드로 검색하면 좋다.
'C++' 카테고리의 다른 글
오브젝트를 빅 엔디안 바이트 배열로 변환하기 (0) | 2019.08.30 |
---|---|
unique_ptr, shared_ptr 구현하기 (0) | 2019.08.15 |
delete의 다른 사용처 (0) | 2019.08.11 |
C++에서 C#의 델리게이트 구현하기 (0) | 2019.08.01 |
C++에서 가비지 콜렉터 구현하기 (0) | 2019.07.14 |