std::move가 최적화를 막는 경우와, noexcept가 이동 선택에 미치는 영향을 알아봅니다.
돌아가기
목차
이 글은 이동 의미론(move semantics)을 다루는 시리즈의 네 번째 글입니다. 3편에서는 std::move와 std::forward가 둘 다 static_cast 한 줄을 감싼 **비용 없는 추상화(zero-cost abstraction)**임을 확인했습니다. 그렇다면 비용이 0이니, 이동 가능한 자리마다 std::move를 붙이는 게 손해 볼 게 없는 습관이 아닐까요?
답은 정반대입니다. std::move를 잘못 붙이면 오히려 손해를 보는 경우가 있고, 이동 생성자가 있는데도 컴파일러가 일부러 그것을 쓰지 않는 경우도 있습니다.
이 글에서 다루는 범위는 다음과 같습니다.
return std::move(local)이 왜 그 생략을 막고 오히려 이동 생성자 호출을 하나 추가시키는지std::vector 같은 컨테이너가 재할당할 때 이동 생성자가 있어도 복사를 선택하는 이유, 그리고 그 선택을 결정하는 noexcept와 std::move_if_noexcept언리얼 엔진의 MoveTemp·Forward·TArray 구현은 다음 글에서 다룹니다.
함수가 지역 변수를 값으로 반환하면, 상식적으로는 그 지역 변수를 만들고 → 호출자에게 옮기고 → 지역 변수를 없애는 세 단계가 필요할 것 같습니다. 그런데 실제로는 이 과정이 통째로 생략되는 경우가 있습니다.
Buffer처럼 레지스터에 담기 어려운 클래스 타입을 값으로 반환하는 함수는, 시그니처 뒤에 숨은 매개변수가 하나 더 붙습니다. 결과를 써 넣을 메모리의 주소입니다. 이 주소는 호출하는 쪽(caller)이 미리 마련해서 호출받는 함수(callee)에게 넘겨주며, 이 자리를 반환 슬롯이라 부릅니다. 대개 호출자의 스택 프레임이 그 실체입니다.
컴파일러는 반환할 지역 변수를 함수 안 별도 공간에 만들었다가 나중에 그 반환 슬롯으로 옮기는 대신, 처음부터 반환 슬롯의 주소에 직접 생성할 수 있습니다. 이것이 **NRVO(Named Return Value Optimization)**입니다. 단, 아무 변수나 되는 건 아닙니다. cppreference 기준으로, 반환문에 적힌 이름이 다음 조건을 모두 만족해야 합니다.
static이나 new로 만든 객체가 아닌, 함수가 끝나면 사라지는 객체)volatile이 붙지 않았을 것Buffer makeBuffer()
{
Buffer local(1024);
return local; // local의 이름을 그대로 반환
}
NRVO가 적용되면 local은 함수 안에 따로 만들어지지 않고, 처음부터 makeBuffer()를 호출한 쪽의 반환 슬롯에 생성됩니다. 즉 local과 호출자가 받을 객체가 애초부터 같은 메모리입니다. 이때 실제로 호출되는 생성자는 Buffer(1024) 하나뿐입니다. 복사도 이동도 일어나지 않습니다. 이 생략은 복사·이동 생성자 안에 출력 같은 부수 효과가 있어도 허용됩니다. 눈에 보이는 동작을 바꿀 수 있는데도 표준이 따로 허락한, 몇 안 되는 최적화입니다.
<aside>
짚고 가기 — NRVO는 표준이 보장하는 최적화가 아니라 허용하는 최적화입니다. C++17이 필수로 만든 것은 return Buffer(1024);처럼 이름 없는 임시 객체(prvalue)를 반환할 때의 생략(RVO)이고, 이름 있는 지역 변수를 반환하는 NRVO는 지금도 컴파일러의 선택 사항입니다. 매개변수를 그대로 반환하거나 서로 다른 지역 변수를 여러 분기에서 반환하는 경우처럼, 컴파일러가 반환 슬롯에 지어질 객체를 하나로 확정할 수 없으면 NRVO는 적용되지 않습니다.
</aside>
NRVO가 이렇게 생성 자체를 생략해준다면, 여기에 std::move를 덧붙이는 건 어떨까요. "이동을 확실히 하겠다"는 의도로 return std::move(local);처럼 쓰는 경우를 흔히 볼 수 있습니다. 그런데 이건 최적화가 아니라 최적화를 막는 행동입니다.
NRVO가 적용되려면 반환문의 피연산자가 지역 변수의 이름 그 자체(id-expression)여야 합니다. return local;에서 local은 정확히 이 조건을 만족합니다.
return std::move(local);에서 반환되는 표현식은 더 이상 local이라는 이름이 아니라, std::move(local)이라는 함수 호출의 결과, 즉 Buffer&& 타입의 표현식입니다. 복사 생략은 반환할 객체와 결과 객체를 하나로 합쳐도 된다는 허가인데, 표준은 이 허가를 반환문이 객체를 이름으로 직접 가리킬 때만 줍니다. 그 참조가 결국 local을 가리킨다는 건 사람 눈에는 뻔하지만, 규칙은 표현식의 형태만 봅니다. 함수 호출식은 이름이 아니므로 자격에서 탈락합니다. 가리키는 대상이 같으면 허용하도록 규칙을 넓힐 수도 있지만, 그런 표현식은 모두 local로 바꿔 쓰면 되니 얻는 건 없고 컴파일러의 판단만 복잡해집니다.
자격을 잃으면 두 단계로 처리됩니다. 먼저 local이 함수 안 자신의 자리에 정상적으로 생성되고(Buffer(1024) 호출 1번), 그다음 그 값이 반환 슬롯으로 이동 생성자를 통해 옮겨집니다(이동 생성자 호출 1번). 즉 아무것도 안 붙였을 때는 생성자 호출이 1번인데, std::move를 붙이면 2번이 됩니다. 비용이 0인 캐스팅 하나를 붙였을 뿐인데, 그 결과로 실제 이동 생성자 호출이 하나 더 남는 역설이 생기는 겁니다.
<aside>
짚고 가기 — NRVO는 std::move가 없어도 실패할 수 있습니다. 매개변수를 그대로 반환하는 경우(매개변수의 저장 공간은 호출자가 관리하므로 NRVO 대상에서 제외됩니다), 그리고 여러 분기에서 서로 다른 지역 변수를 반환하는 경우가 대표적입니다. 반면 같은 변수를 여러 분기에서 반환하는 경우는 컴파일러마다 다르게 처리해, 표준이 선택 사항으로 남겨 둔 영역이라는 것을 보여줍니다.
</aside>
그렇다면 NRVO가 실패하는 모든 경우에 복사로 떨어질까요. 아닙니다. C++는 NRVO와는 별개로 암시적 이동(implicit move)이라는 두 번째 안전망을 갖고 있습니다.
지역 변수를 이름으로 반환하는 문맥에서, 컴파일러는 그 지역 변수를 생성자에 넘기기 위한 오버로드 해소를 두 단계로 수행합니다. 먼저 그 변수를 rvalue로 취급해 오버로드 해소를 시도합니다. 이 시도가 성공하고 선택된 생성자의 첫 매개변수가 그 타입에 대한 우측값 참조라면(즉 이동 생성자라면) 그것으로 확정됩니다. 이 시도가 실패하거나 조건을 만족하지 않으면, 그 변수를 lvalue로 취급해 오버로드 해소를 다시 수행합니다. 이때는 복사 생성자가 선택됩니다.
결과적으로 타입에 이동 생성자가 있으면 자동으로 이동되고, 이동 생성자가 없고 복사 생성자만 있으면 자동으로 복사됩니다. NRVO가 실패해서 생성 자체는 생략되지 못하더라도, 그 생성에 쓰이는 연산이 복사가 아니라 이동으로 자동 승격되는 것입니다. 매개변수를 그대로 반환하는 경우도 이 규칙의 적용을 받습니다.
<aside>
짚고 가기 — C++17까지의 암시적 이동에는 예외가 있습니다. 반환하는 지역 변수의 실제 타입이 함수의 반환형과 정확히 일치하지 않고 파생 클래스인 경우입니다. 예를 들어 U를 반환하는 함수 안에서 U의 파생 클래스인 T 타입의 지역 변수를 반환하면, rvalue 취급 시도에서 선택되는 생성자가 반환형 U에 대한 참조를 받지 않으므로 조건을 만족하지 못해 실패하고, lvalue 취급으로 넘어가 복사가 선택됩니다. 이 경우는 return std::move(local);이 실제로 복사가 아닌 이동을 끌어내는 최적화 행동입니다. C++20부터는 이 조건이 사라져, 파생 클래스를 반환해도 자동으로 이동됩니다.
</aside>
지금까지는 함수가 값을 반환하는 상황이었습니다. 이번엔 다른 상황입니다. Buffer에 이동 생성자가 멀쩡히 있는데도, std::vector<Buffer>가 재할당할 때 컴파일러가 일부러 그 이동 생성자를 안 쓰고 복사 생성자를 쓰는 경우가 있습니다.
std::vector의 push_back이나 resize가 용량을 초과해 재할당해야 하면, 새 메모리를 할당하고 기존 원소들을 전부 새 메모리로 옮긴 뒤 이전 메모리를 해제합니다. 이때 표준 라이브러리는 **강한 예외 보장(strong exception guarantee)**을 지킵니다. 재할당 도중 예외가 발생해도 벡터는 호출 전과 완전히 동일한 상태로 남아야 한다는 약속입니다.
이 재할당을 복사로 수행하면 이 약속을 지키기 쉽습니다. 원본 원소는 전혀 건드리지 않으므로, 새 메모리에 복사하다가 예외가 나도 이전 메모리는 여전히 온전하고, 새 메모리만 정리하고 이전 상태로 돌아가면 됩니다.
반면 이동으로 수행하면 문제가 생깁니다. 6개 원소 중 4개를 새 메모리로 이동한 시점에 5번째 원소의 이동 생성자가 예외를 던졌다고 가정해 보겠습니다. 이전 메모리의 앞 4개 원소는 이미 자원을 넘겨주고 비워진 상태이고, 새 메모리의 앞 4개는 유효하지만 재할당 자체는 완료되지 못했습니다. 되돌릴 방법이 없습니다. 비워진 원본은 복구할 수 없기 때문입니다.
그래서 표준 라이브러리는 이동이 안전하다고 보장될 때만 이동을 쓰고, 그렇지 않으면 안전하게 복사로 물러섭니다. 이 판단을 코드로 구현한 것이 std::move_if_noexcept이며, 인자 타입의 상태에 따라 세 갈래로 나눠 판단합니다.
noexcept인 타입 — 복사 생성자가 있든 없든 우측값 참조를 반환해 이동을 선택합니다.noexcept가 아니지만 복사 생성자가 있는 타입 — const 좌측값 참조를 반환해 복사로 물러섭니다.noexcept가 아니고 복사 생성자도 없는 타입(move-only) — 선택지가 없으므로 그래도 우측값 참조를 반환해 이동합니다. 이 경우엔 강한 예외 보장이 지켜지지 않을 수 있습니다.Buffer처럼 복사 생성자와 noexcept 이동 생성자를 둘 다 가진 타입은 첫 번째 경우에 해당해, std::vector<Buffer>가 안전하게 이동을 선택할 수 있습니다.
그리고 "예외를 던지지 않는다고 보장된다"는 것은 그 생성자에 noexcept가 붙어 있다는 뜻입니다. noexcept는 컴파일러와 표준 라이브러리에 "이 함수는 예외를 던지지 않는다"고 약속하는 지정자이며, std::vector는 바로 이 약속을 보고 이동을 써도 안전한지 판단합니다. 2편에서 Buffer의 이동 생성자에 noexcept를 붙인 이유가 바로 여기 있습니다. 다만, 이 약속은 컴파일러가 검증해주지 않습니다. noexcept 함수의 본문이나 그 안에서 호출하는 함수들을 따라가며 정말 예외를 던지지 않는지 확인하는 절차가 없습니다. 그래서 약속과 달리 noexcept 함수 안에서 발생한 예외를 catch로 잡지 않아 함수 밖으로 전파되면, 컴파일 에러가 아니라 런타임에 std::terminate가 호출되어 프로그램이 그 자리에서 종료됩니다.
// 이동 생성자에 noexcept가 있으면
Buffer(Buffer&& other) noexcept { ... }
// std::vector<Buffer> 재할당 시 이동 생성자가 선택됩니다.
// noexcept가 없으면 (설령 실제로는 절대 던지지 않더라도)
Buffer(Buffer&& other) { ... }
// std::vector<Buffer> 재할당 시 컴파일러는 안전을 확신할 수 없어
// 복사 생성자를 대신 선택합니다.
<aside>
짚고 가기 — 이동 생성자를 직접 작성하면서 noexcept를 빠뜨리는 것은 조용한 성능 함정입니다. 컴파일이 실패하지도 않고 동작도 정상이라, 실제로 벡터가 커질 때마다 이동 대신 복사가 일어나고 있다는 사실을 알아채기 어렵습니다. 참고로 컴파일러가 자동 생성하는 이동 생성자는 그 안에서 호출하는 모든 멤버의 이동 연산이 noexcept일 때 자동으로 noexcept가 되지만, 자원을 직접 관리해 이동 생성자를 손으로 작성해야 하는 Buffer 같은 타입은 이 자동 전파의 대상이 아니므로 직접 명시해야 합니다.
</aside>
std::move의 비용이 0이라고 해서 많이 붙일수록 이득인 것은 아닙니다. 이번 글에서 확인한 내용은 다음과 같습니다.
return std::move(local);은 반환 표현식을 이름이 아닌 참조로 바꿔 NRVO 자격을 깹니다. 생성자 1번에 이동 생성자 1번이 추가되어 2번이 됩니다.std::vector 같은 컨테이너는 재할당의 강한 예외 보장을 지키기 위해, 이동 생성자가 noexcept일 때만 이동을 쓰고 그렇지 않으면 복사로 물러섭니다. 이 판단은 std::move_if_noexcept가 수행합니다.다음 글에서는 언리얼 엔진이 이 모든 것을 MoveTemp, Forward, TArray로 어떻게 구현하는지 다룹니다.
E-mail : [email protected] Phone number : (+82) 010 3902 8624