개발하는 리프터 꽃게맨입니다.

[C++] 오른값 참조, 이동 연산자, 이동 대입 연산자 본문

언어/C, C++

[C++] 오른값 참조, 이동 연산자, 이동 대입 연산자

파워꽃게맨 2024. 1. 16. 14:02

[ 목차 ]

     

    1. 왼값 vs 오른값

    왼값(l-value), 오른값(r-value)에 대해 알고 계신가요?

    아마 처음 들어보실 수도 있는데, 이렇게 생각하시면 편합니다.

     

    왼값: 데이터를 저장할 수 있는 값, 지속되는 값

    오른값: 일시적인 값, 임시적인 값

     

    아직 잘 모르겠죠?

     

     

    식이 있을 때

    좌항을 왼값, 우항을 오른값이라고 이해하시면 기본적인 이해는 가능합니다.

    (왼값에 왼값을 넣을 수도 있어서 맞는 설명은 아닙니다.)

     

    오른값에 특정한 값을 집어넣는 것은 불가능합니다.

     

    10 = num;

    과 같은 코드가 상식적으로 이해되지는 않죠.

     

    일단 오른값에 대한 기본 개념은 이 정도로 이해하시고 넘어가시면 되겠습니다.

    왼값: 주소를 가지는 변수, 값을 저장할 수 있는 변수, 지속되는 값

    오른값: 상수나 일시적인 값들, 임시적인 값들, 대입이 되지 않는 값들, 왼값이 아닌 값들

    ex) 리턴 값, 상수 등..

     

    왼값은 대입 시에 왼쪽, 오른쪽 다 올 수 있지만

    오른값은 대입 시에 오직 오른쪽에만 올 수 있다.

     

    2. 오른값 참조

    '오른값 참조'는 쓸데없는 복사가 일어나는 문제를 해결하기 위해 나온 개념입니다.

    그러면 먼저 쓸데없는 복사에 대해서 말해봐야겠죠?

     

    컴파일러의 최적화 성능이 낮을 경우

    다음 코드를 실행한다고 가정해봅니다.

     

     

    현재 컴파일러에서는 최적화 수준이 높아서 생성자가 2번 호출되는 일은 발생하지 않지만

    최적화 수준이 낮은 컴파일러를 사용할 때 위 코드를 생성하고자 하면

    우항의 생성자와 좌항으로의 복사생성자 총 2개의 생성자가 호출됩니다.

    (최근 visual studio 에서는 복사 생략이라 하여, 쓸데없는 복사를 하지 않습니다.)

     

    복사 비용에 대해서 생각해봅시다.

    저렴한 생성자라면 상관없겠지만

    vector처럼 매우 메모리를 많이 잡아먹는 클래스를 이용한다면, 복사의 비용이 매우 비싸겠죠?

     

    그래서 '이동'이라는 개념이 만들어졌습니다.

     

    이동은 소유권을 넘겨버린다고 생각하면 편한데,

    굳이 이전 메모리에서 새 메모리로 요소를 복사할 필요가 없을 경우, 

    내부 메모리의 소유권을 아예 넘겨버려서 불필요한 복사를 막겠다는 것이 이동의 의의입니다.

     

    이동 연산을 지원하기 위해 탄생한 것이 오른값 참조입니다.

    오른값 참조자는 && 를 사용하며, 무조건 오른값이랑만 결합할 수 있습니다.

     

     

    앞서 말했지만, 오른 값은 '임시적이고 일시적인 값'을 뜻합니다.

    그래서 오른 값 참조 변수는 곧 소멸할 일시적인 값이고, 오른 값의 자원은 자유롭게  '이동'이 가능합니다.

     

    오른값 참조(&&)도 참조(&)입니다.

     

    오른값 참조는 오른값에 대한 참조이고

    그냥 참조는 왼값에 대한 참조라는 것이 주요 포인트입니다.

     

    3. 이동 연산자

    다시 처음으로 돌아와서

    오른값 참조는 '쓸데없는 복사'를 줄인다고 말했습니다.

     

    오른값이란 지속되지 않는 일시적인 값이기에, 오른값 그 자체에 어떤 조작을 해도 상관없습니다.

     

    이동 연산자/이동 대입 연산자는 클래스의 쓸데없는 복사를 줄이기위해서 오른값 참조를 이용해

    이동 연산을 지원하는 기능인데,

     

    "오른값 자체는 곧 소멸할 값이니 오른값 내부의 데이터를 복사하지않고 그냥 다 가져가"

    라는 것이 주요 아이디어입니다.

     

    아마 코드를 직접보면 이해가 더 빠를겁니다.

     

     

    이동 생성자 부분을 보면

    마치 얕은 복사처럼 행동합니다.

     

    아예 오른값 참조의 변수의 값을 모두 생성 개체로 옮기는 것이죠.

    깊은 복사를 하지 않고, 포인터 메모리를 그대로 옮기고 있습니다.

     

    혹자는 털어온다. 훔쳐온다 라고 말하곤 합니다.

     

    그리고 오른값은 훼손해도 상관없는 값이기 때문에

    그대로 나둬도 되지만, 저런 식으로 0과 nullptr 로 깔끔하게 비워주기도 합니다.

     

    이러면 복사를 하는 것이 아니라 소유권만 이전시키는 것이기 때문에

    복사비용이 감소합니다.

     

    완벽한 전달법(perfect forwarding)이라고 말하기도 하구요.

     

    4. 이동 대입 연산자

    이동 대입 연산자 또한 쉽게 구현할 수 있습니다.

     

    예전 포스팅에서는 언급하지 않았지

    이동 연산자와 이동 대입 연산자 또한 컴파일러가 암시적으로 만들어주는 기능 중 하나입니다.

     

    5. std::move

    왼값을 오른값으로 바꿔주는 간단한 std 함수입니다.

     

    실제로 코드를 보면 그저 오른값으로 static_cast를 해서 리턴하는 동작을 하고 있습니다.

     

    만약 왼값을 더 이상 쓸일이 없고, 

    왼값에 있는 데이터를 새로운 클래스에 온전히 다 옮기고 싶다고 하면

    std::move 를 통해 오른값으로 캐스팅하여 이동생성자를 호출할 수 있습니다.

     

    6. 그래서.. 어디에 쓰는데?

    고전적으로 값을 넘겨주는 방식에는 3가지가 있습니다.

    1) 값에 의한 전달 (복사)

    2) 참조에 의한 전달 (참조자 &)

    3) 주소에 의한 전달 (포인터)

     

    복사의 단점은 값이 메모리에 하나 더 생기는 것이기 때문에, 복사에 대한 비용도 들었고 메모리 비용도 든다는 것이 단점입니다.

     

    그래서 보통 인자를 넘겨줄 때 포인터와 참조자 형식으로 많이 넘기곤 했죠?

    그러면 주소값만 넘겨주면 되니까 복사 비용, 메모리 비용 다 줄일 수 있습니다.

     

    굳이 단점을 말하자면 '원본 값을 변경할 위험이 있다.' 라는 것이죠. (const 를 붙여서 해결가능한 문제)

     

    이제 '이동'이라 함은 오른값이라는 훼손가능한 값을 넘겨주는 방식이기 때문에

    복사 비용도 아낄 수 있을 뿐더러, 불필요한 복사도 없애고 원본 데이터를 마음껏 훼손해도

    상관없다라는 힌트도 주는 것이죠.

     

    사실 오른값 참조는 '마음껏 원본을 훼손해도 된다는 힌트!' 를 제외하곤 기존 참조랑 동일하게 동작합니다.

    const 참조와 오른값 참조는 서로 대입도 되구요.

     

    오른값 참조를 무조건 써야한다..! 라기 보다는 의도에 따라 선택지가 늘어난 느낌이죠.

     

    이동을 적극적으로 사용한 예시가 STL vector입니다.

    만약 재할당이 일어난다라고 했을 때, C 스타일 동적 배열을 만들 당시에는

     

    - 새로운 넉넉한 공간의 포인터를 만들고

    - 기존 데이터를 모두 복사 붙여넣기 한 다음에

    - 기존 포인터를 delete 한 후, 포인터 변수가 새로운 메모리를 가리키게 하는 방식

     

    으로 만들었습니다.

     

    C++ STL vector 에서는

    기존 포인터의 데이터의 경우에는 더 이상 사용하지 않을 것이기 때문에

    오른값 참조와 이동 연산자를 이용해서 빠르게 통채로 가져오는 연산을 수행하죠.

    이러면 속도면에서 굉장히 빠릅니다.

     

    예전에는 이동의 등장으로 '대입'이라는 연산을 모두 '이동'으로 바꾸려는 움직임이 있었지만, 이동 이라는 것은 항상 복사보다 저렴한 것은 아닙니다. 컴파일러 단에서 최적화 해주는 경우가 많기 때문이죠.

     

    그렇기 때문에 오른값 참조는 매우 유용한 기능임에도 개발과정에서 사용할 일은 거의 없습니다.

     

    그러나 딱 한 가지 매우 유용하게 쓰이는 곳이 있는데, 바로 유니크 포인터입니다.

    나중에 '스마트 포인터'에 대한 포스팅은 따로 또 할 것이지만, 미리 설명드리자면

     

    유니크 포인터특정 메모리에 대한 단 하나의 참조만 허용하는 포인터입니다.

     

    그래서 유니크 포인터와 유니크 포인터끼리 이동 연산없이는 대입이 조금 번거롭습니다.

     

    이 때 이동 생성자를 사용하면 편리하게 포인터를 옮기면 오류가 나지 않고 간편하게 옮길 수 있습니다.

     

     

    Modern Effective C++ 라는 책을 읽어보면,

    "이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라"

    라고 적혀있습니다.

     

    컴파일러가 적절한 최적화를 해주기 때문에, 어떤 상황에서 이동 연산을 사용하면 더 빠른 프로그램을 개발할 수 있는지

    상당히 모호하는 것이지요.

     

    그래서 오른값 참조의 개념은 알고있되

    개발과정에서는 필요한 부분 (대부분 유니크 포인터)에만 사용하도록 합시다.