프로그래밍에 있어서 최적화는 정말 중요한 문제이다.
프로그래밍에서 최적화를 해치는 요소는 여러가지가 있지만, 잦은 복사는 최적화를 해치는 것에 있어서 널리 알려진 것이다. 크기가 큰 데이터에 대한 깊은 복사를 계속 하게 되면 프로그램에 상당한 부하가 발생할 것이다.
하지만, 복사가 반드시 필요한 상황도 분명 존재할 수 있다. 그렇다면, 성능과 기능 중 한가지를 포기해야만 할까?
이러한 딜레마를 해결해주고자 하는 것이 Move Semantic이다.
C++ 11 부터는 깊은 복사로 인한 성능 저하를 해결하기 위해 이동 문법을 제공해주고 있다.
이동 문법을 Move Semantic이라고 하며, 이번 게시글에선 이에 대해 알아볼 것이다.
얕은 복사와 깊은 복사의 문제점
얉은 복사는 값을 그대로 복사하기 때문에, 포인터 변수를 복사할 경우 동일한 주소값을 가리키게 된다.
한쪽에서 값을 변경하거나 메모리를 해제할 경우 다른 쪽에서는 예측할 수 없는 문제가 발생할 수 있으므로 얕은 복사는 안정성이 매우 떨어진다고 할 수 있다.
이러한 문제를 해결하기 위해 복사 생성자, 복사 대입자를 오버로딩하여 깊은 복사를 하게 된다.
깊은 복사는 별도의 메모리 공간을 할당받은 뒤, 값만 동일하게 저장하는 것이다.
그렇기 때문에 어느 한 쪽에서 메모리를 해제하는 등의 작업을 하더라도 다른 쪽에는 전혀 영향을 주지 않게 된다.
하지만, 깊은 복사는 안정성을 얻는 대신 성능적인 부분에서 손해를 많이 보게 된다.
배열의 경우를 예로 든다면, 10만개의 원소를 보유한 배열을 복사한다면 10만번의 반복문을 돌며 모든 원소를 복사해야 한다.
Move Semantic
우리가 깊은 복사를 하는 근본적인 이유를 생각해보자.
서로 다른 포인터 변수가 같은 메모리를 가리킬 때, 어느 한 쪽에서 수행한 작업이 다른 쪽에 영향을 미칠 수 있기 때문이다. 거기서 발생하는 위험을 막기 위해 깊은 복사를 하는 것이고, 그로 인한 성능 저하가 발생하는 것이다.
그렇다면, 다른 쪽에서 해당 메모리 영역을 절대 건드리지 않는다는 확신이 있다면 얕은 복사를 해도 되지 않을까?
예를 들어, A와 B가 있다고 해보자. B가 가지고 있는 데이터를 모두 A로 복사하고자 할 때, 만약 B가 더이상 사용되지 않을 것이라고 확신할 수 있다면 얕은 복사를 해서 성능상 이점을 챙기는 것이 더 좋지 않을까? 어차피 위험이 발생할 가능성이 없으니까 말이다.
이러한 아이디어를 실현하는 것이 무브 시맨틱이다. 복사되는 쪽이 더 이상 사용되지 않을 것이라고 확신할 수 있다면, 그냥 얕은 복사를 해버리자는 것이다.
무브 시맨틱은 엄밀히 말하면 복사라는 개념과는 살짝 거리가 있다. 왜냐하면, 복사한다는 것은 동일한 테이터를 다른 곳에 또 만든다는 개념이지만, 무브 시맨틱은 어느 한쪽은 더이상 사용할 수 없게 만들어 버리기 때문이다. 얕은 복사를 하면서 발생할 수 있는 위험성을 차단하기 위해 복사되는 쪽의 데이터를 지워버리게 된다.
이러한 특징 때문에, 복사가 아니라 이동이라는 용어를 사용하게 된다.
그림으로 한 번 이해해보자.
얕은 복사는 위처럼, 두 변수가 모두 같은 주소를 가리키게 하는 것이다.
반면, 이동의 경우 위의 그림처럼 한 쪽은 소유권을 포기하고, 다른 쪽이 새로 소유권을 가지도록 하는 것이다.
데이터를 다른 쪽으로 이동한 것이다.
우측값 참조
Move Semantic은 위에서도 말했듯이, 더이상 사용되지 않을 것이라고 확신하는 대상의 정보를 다른 곳으로 옮기는 것이라고 하였다.
그런데 더 이상 사용되지 않을 것이라고 확신되는 것이 뭐가 있을까?
가장 쉽게 생각할 수 있는 것이 바로 임시 객체들, 즉 R-Value 이다. R-Value는 식이 끝난 뒤에 사라지는 친구들이다.
그러므로 우리가 R-Value인 객체의 값을 이후에 사용하고 싶어도 사용할 수가 없다. R-Value의 값을 복사하는 경우에는 깊은 복사를 하는 것의 이점이 전혀 없으므로, 이동을 통해 해결하는 것이다.
class A
{
public:
//기본 생성자
A()
{
Arr = new int[50];
for(int i = 0; i < 50; i++)
{
Arr[i] = i;
}
}
~A()
{
if(Arr != nullptr)
{
delete[] Arr;
Arr = nullptr;
}
}
int* Arr = nullptr;
};
A CreateA()
{
A ReturnA;
return ReturnA;
}
int main()
{
A NewA = CreateA();
return 0;
}
위의 코드를 보자. CreateA로 반환되는 값은 R-Value이다. 값을 NewA로 복사한 뒤에, 더 이상은 사용할 수 없는 값이다.
그렇다면, CreateA()에 의해 반환되는 값은 NewA로 얕은 복사를 해도 상관이 없을 것 같다.
하지만, 아니다. 왜냐하면, 소멸자에서 Arr의 메모리를 해제하고 있기 때문이다.
CreateA()에 의해 반환되는 값은 우리가 직접 그 값을 조작할 수는 없지만, 객체가 파괴되기 전에 소멸자를 호출하게 된다.
즉, Arr의 값을 NewA에 복사한 뒤에 메모리를 해제하여 NewA의 Arr이 댕글링 포인터가 되어버린다.
그러므로, 얕은 복사의 문제점이 여전히 발생하는 셈이다.
그렇다고 깊은 복사를 하게 된다면? 성능의 저하가 발생하게 된다. 그렇다면, 한 가지 방법이 있다.
Arr이 가지고 있는 주소값을 NewA의 Arr에 복사한 뒤에, 임시 객체의 Arr을 nullptr로 만들어버리는 것이다.
임시 객체의 Arr을 nullptr로 만들면, Arr이 소멸될 때, 예외 처리에 의해 delete가 호출되지 않는다.
그렇다면, 복사 생성자와 복사 대입자를 아래와 같이 정의할 수 있을 것이다.
A(A& _Other)
{
Arr = _Other.Arr;
_Other.Arr = nullptr;
}
A& operator=(A& _Other)
{
if (Arr != nullptr)
{
delete[] Arr;
}
Arr = _Other.Arr;
_Other.Arr = nullptr;
}
이렇게 복사 생성자와 복사 대입자를 오버로딩하면, 문제가 해결될 것만 같다.
하지만, 문제가 한 가지 추가로 발생하게 된다.
이렇게 하면 깊은 복사를 사용할 수가 없다는 것이다.
위에서 말했듯이, 이동 연산은 더 이상 사용되지 않을 것이라고 확신하는 대상에 대해 사용하는 것이다.
반대로 말하자면, 이후에도 사용될 것으로 예상되는 대상에 대해서는 깊은 복사를 여전히 해야한다는 것이다.
그런데, 위처럼 함부로 복사 생성자를 이동 연산으로 정의해버리면, 깊은 복사를 사용할 수가 없게 된다.
그러므로, 이동 연산은 복사 생성자, 복사 대입자를 오버로딩하는 것이 아니라, 우측값을 참조하는 이동 생성자, 이동 대입자를 오버로딩 해야 한다.
//복사 생성자
A(const A& _Other)
{
Arr = new int[50];
for(int i = 0; i < 50; i++)
{
Arr[i] = _Other.Arr[i];
}
}
//복사 대입자
A& operator=(const A& _Other)
{
for(int i = 0; i < 50; i++)
{
Arr[i] = _Other.Arr[i];
}
}
//이동 생성자
A(A&& _Other) noexcept
{
Arr = _Other.Arr;
_Other.Arr = nullptr;
}
//이동 대입자
A& operator=(A&& _Other) noexcept
{
if (Arr != nullptr)
{
delete[] Arr;
}
Arr = _Other.Arr;
_Other.Arr = nullptr;
return *this;
}
복사의 경우 A& 타입으로 인자를 받고 있지만, 이동은 A&& 타입을 인자로 받고 있다.
&&타입으로 인자를 받는 생성자와 대입자를 정의하게 되면 이동 연산이 가능하게 된다.
(이동은 default가 없으므로 반드시 정의해주어야 한다.)
여기서 중요한 점은 이동 생성자와 이동 대입자는 반드시 noexcept 키워드를 붙여야 한다는 것이다.
예외가 발생할 수 있는 상황이라면, 이동이 아닌 복사가 발생하도록 컴파일러가 구성되어 있기 때문에, 예외가 없음을 보장해주어야 한다.
시간 테스트를 한 번 해보자.
for (int i = 0; i < 1000000; i++)
{
NewA = CreateA();
}
위의 코드를 총 5번 실행할 것이다.
위의 결과는 복사 생성자, 복사 대입자만 정의했을 때의 시간이다.
위의 결과는 이동 생성자와 이동 대입자도 정의했을 때의 시간이다.
훨씬 빨라진 것을 볼 수 있다.
만약, 테스트를 위해 만든 클래스 내부의 배열이 50개의 원소만 가지고 있었지만 500개나 5000개 등 크기를 더 늘리게 되 차이는 더욱 커질 것이다.
std::move
위에서는 이동 연산을 임시 객체를 기준으로 설명하였다.
하지만, 임시 객체가 아닌 대상에도 이동 연산을 적용할 수 있다.
바로, std::move 함수의 도움을 받는 것이다.
std::move는 L-Value를 R-Value로 캐스팅하여, 복사 생성자, 복사 대입자가 아니라 이동 생성자, 이동 대입자가 호출될 수 있도록 도와주는 역할을 한다.
void Func(A& _A)
{
A NewA;
_A = NewA;
}
위와 같은 함수를 보자.
위의 함수에서 NewA 는 임시 객체가 아니라 지역 변수이다.
즉 L-Value인 것이다. 하지만, 함수가 끝나면 사라져서 더 이상 사용되지 않을 객체이기도 하다.
이런 경우에도 깊은 복사가 불필요하지만, NewA가 L-Value이기 때문에 이동 연산이 실행되지 않는다.
void Func(A& _A)
{
A NewA;
_A = std::move(NewA);
}
위의 코드처럼 std::move를 사용하여 대입 연산을 실행하게 되면 std::move는 NewA를 R-Value로 캐스팅한 값을 반환하므로 이동 대입자가 호출된다. 이처럼, L-Value이지만 더 이상 사용이 되지 않을 대상이나 소유권을 완전히 이전하고 싶은 대상에 대해서는 std::move 함수를 사용하면 이동 연산을 통해 효율적으로 데이터를 옮길 수 있게 된다.
std::forward
std::Move는 복사할 대상을 확실하게 알고 있을 때 사용 가능하다. 하지만, 특정 상황에선 변수의 상태를 정확히 예측할 수 없다.
예를 들어, 템플릿 함수를 사용한다고 해보자. 템플릿에는 다양한 자료형이 들어올 수 있고, 어떤 변수가 들어올 지 예상할 수가 없다. 인자로 L-Value가 들어올 수도 있고 R-Value가 들어올 수도 있다. 일반적인 상황에선 안정성을 위해, 인자로 들어오는 모든 값이 L-Value라고 가정하고 깊은 복사를 실행하는 것이 좋다.
하지만, std::forward를 사용하면, L-Value는 깊은 복사, R-Value는 이동 연산을 실행하도록 별도로 처리하여 추가적인 최적화를 노려볼 수 있다.
class A
{
};
void Func(A& _A)
{
std::cout << "L-Value 참조 " << std::endl;
}
void Func(A&& _A)
{
std::cout << "R-Value 참조 " << std::endl;
}
template <typename T>
void FuncTemplate(T _T)
{
Func(_T);
}
int main()
{
A NewA;
FuncTemplate(NewA);
FuncTemplate(A());
}
위의 코드는 std::forward를 사용하지 않은 상황이다.
L-Value와 R-Value를 인자로 받는 함수를 오버로딩하였다.
기대하는 결과는, FuncTemplate(NewA); 에선 L-Value를 인자로 받는 Func가 호출되고
FuncTemplate(A()); 에서는 R-Value를 인자로 받는 Func가 호출되는 것이다.
하지만 실행해보면 위처럼, 둘 다 L-Value를 인자로 받는 함수를 호출하게 된다.
템플릿 때문에 컴파일러가 인자를 정확하게 추론하지 못하고 있는 상황인 것이다.
template <typename T>
void FuncTemplate(T&& _T)
{
Func(std::forward<T>(_T));
}
하지만, FuincTemplate를 위처럼 코드를 바꿔본다면?
이렇게, L-Value와 R-Value를 컴파일러가 정확히 구분할 수 있게 된다.
사용하기 위해선, 위처럼 템플릿 함수의 인자를 &&타입으로 바꾼 뒤, std::forward<T>(_T)의 형식을 사용하면 된다.
'C++ > C++' 카테고리의 다른 글
C++ - 스마트 포인터 (shared_ptr, weak_ptr, unique_ptr) (1) | 2024.06.13 |
---|---|
C++ - Callable 과 std::function (함수 객체, 람다 함수) (1) | 2024.06.11 |
C++ - L-Value vs R-Value (1) | 2024.06.09 |
C++ - 얕은 복사 vs 깊은 복사 (1) | 2024.04.18 |
C++ - 가변 인자 템플릿 (0) | 2024.04.18 |