Abseil Tip 77 임시 객체, 이동, 복사

title: “이번 주의 팁 #77: 임시 객체, 이동, 복사” layout: tips sidenav: side-nav-tips.html published: true permalink: tips/77 type: markdown order: “077” —

원래 totw/77로 2014-07-09에 게시됨

작성자: Titus Winters (titus@google.com)

2017-10-20 업데이트

빠른 링크: abseil.io/tips/77

C++11이 어떻게 바뀌었는지 비전문가들에게 설명하려는 시도의 일환으로, “언제 복사가 발생하는가?”라는 주제를 다시 한 번 다뤄봅니다. 이는 C++에서 복사를 둘러싼 복잡한 규칙을 단순화하고, 더 간단한 규칙으로 대체하려는 일반적인 시도의 일부입니다.

2까지 셀 수 있나요?

셀 수 있다고요? 훌륭합니다. “이름 규칙(name rule)”이란 특정 리소스에 할당할 수 있는 고유한 이름이 데이터의 복사본 수에 영향을 미친다는 의미입니다. (TotW 55에서 이름 세기에 대한 복습을 참고하세요.)

이름 세기, 간단하게 설명

복사본이 생성되는지 걱정하고 있다면, 아마도 특정 코드 줄에 대해 걱정하고 있을 것입니다. 그 시점을 보세요. 복사된다고 생각하는 데이터에 대해 얼마나 많은 이름이 존재하나요? 여기서는 3가지 경우만 고려하면 됩니다.

두 개의 이름: 복사입니다

이 경우는 쉽습니다. 동일한 데이터에 두 번째 이름을 부여하고 있다면, 이는 복사입니다.

std::vector<int> foo;
FillAVectorOfIntsByOutputParameterSoNobodyThinksAboutCopies(&foo);
std::vector<int> bar = foo;     // 네, 이건 복사입니다.

std::map<int, string> my_map;
string forty_two = "42";
my_map[5] = forty_two;          // 이것도 복사입니다. my_map[5]는 이름으로 간주됩니다.

하나의 이름: 이동입니다

이것은 다소 놀라운 부분입니다. C++11은 더 이상 이름으로 참조할 수 없는 경우 해당 데이터를 더 이상 신경 쓰지 않는다고 인식합니다. 언어는 소멸자(예: absl::MutexLock)에 의존하는 경우를 깨뜨리지 않기 위해 주의했으므로, return이 가장 쉽게 식별할 수 있는 사례입니다.

std::vector<int> GetSomeInts() {
  std::vector<int> ret = {1, 2, 3, 4};
  return ret;
}

// 단순 이동입니다: "ret" 또는 "foo" 중 하나만 데이터에 접근합니다.
std::vector<int> foo = GetSomeInts();

std::move()를 호출하는 것도 이름을 제거하는 방법입니다 (TotW 55 참고).

std::vector<int> foo = GetSomeInts();
// 복사는 아닙니다. move는 컴파일러가 foo를 임시 객체로 처리하도록 하여
// std::vector<int>의 이동 생성자를 호출합니다.
std::vector<int> bar = std::move(foo);

이름이 없음: 임시 객체입니다

임시 객체도 특별합니다. 복사를 피하고 싶다면 변수에 이름을 부여하지 마세요.

void OperatesOnVector(const std::vector<int>& v);

// 복사 없음: GetSomeInts()에서 반환된 벡터의 값이 임시 객체로 이동(O(1))되어
// 참조로 OperatesOnVector()에 전달됩니다.
OperatesOnVector(GetSomeInts());

주의: 좀비 객체

위의 설명(특히 std::move() 제외)은 직관적이기를 바랍니다. 다만 C++11 이전의 복사 개념을 이미 가지고 있다면 익숙하지 않을 수 있습니다. 가비지 컬렉션이 없는 언어에서 이러한 방식의 리소스 관리가 성능과 가독성 면에서 탁월합니다. 그러나 이것이 완벽한 것은 아니며, 주요 문제는 다음과 같습니다: 이동한 후의 값은 무엇이 남을까요?

T bar = std::move(foo);
CHECK(foo.empty()); // 이게 유효한가요? 어쩌면 그럴 수도 있지만, 확신하지 마세요.

이것이 주요 어려움 중 하나입니다: 이동된 이후의 값에 대해 무엇을 말할 수 있을까요? 대부분의 표준 라이브러리 타입의 경우, 이동 후 해당 값은 “유효하지만 지정되지 않은 상태”로 남습니다. 비표준 타입도 대개 동일한 규칙을 따릅니다. 안전한 접근법은 이러한 객체를 멀리하는 것입니다: 다시 할당하거나 범위에서 벗어나게 할 수는 있지만, 그 외에는 상태를 가정하지 마세요.

Clang-tidy는 misc-use-after-move 검사 기능을 통해 이동 후 사용을 일부 정적 분석으로 잡아냅니다. 하지만 정적 분석만으로는 모든 경우를 잡아낼 수 없으니 주의하세요. 코드 리뷰 시 이 문제를 지적하고, 자신의 코드에서도 피하세요. 좀비를 멀리하세요.

잠깐, std::move는 실제로 이동하지 않나요?

네, 주의할 점이 하나 더 있습니다. std::move() 호출은 실제로 이동을 수행하는 것이 아닙니다. 단지 rvalue-참조로의 캐스트일 뿐입니다. 이동 생성자나 이동 할당이 이 참조를 사용할 때 실제로 이동이 발생합니다.

std::vector<int> foo = GetSomeInts();
std::move(foo); // 아무 일도 일어나지 않습니다.
// std::vector<int>의 이동 생성자를 호출합니다.
std::vector<int> bar = std::move(foo);

이런 상황은 거의 발생하지 않으며, 이에 대해 많은 신경을 쓸 필요는 없습니다. std::move()와 이동 생성자 간의 연결이 혼란스러웠다면 참고만 하세요.

아, 너무 복잡해! 왜 이래야 하는 거야?

우선: 사실 그렇게 나쁘지 않습니다. 대부분의 값 타입(프로토콜 버프 포함)에 대해 이동 연산이 가능해지면서, 이제 “이게 복사인가? 효율적인가?”라는 고민을 할 필요가 없습니다. 이름 세기만 신경 쓰면 됩니다: 이름이 두 개면 복사, 그보다 적으면 복사 아님.

복사 문제를 무시한다면, 값 타입을 사용하는 것이 더 명확하고 이해하기 쉽습니다. 다음 두 가지 예시를 보세요:

void Foo(std::vector<string>* paths) {
  ExpandGlob(GenerateGlob(), paths);
}

std::vector<string> Bar() {
  std::vector<string> paths;
  ExpandGlob(GenerateGlob(), &paths);
  return paths;
}

이 두 함수는 동일한가요? *paths에 기존 데이터가 있는 경우는 어떤가요? 어떻게 알 수 있나요? 값 타입을 사용하는 것이 독자가 이해하기 더 쉽습니다.

값 타입을 잘 관리하면 할당기 호출을 최소화할 수 있습니다(저렴하지만 무료는 아님). 이동 의미론을 이해하고 복사를 줄이면, 컴파일러의 최적화가 객체 타입, 수명, 가상 함수 호출 등의 문제를 더 잘 분석하여 더 효율적인 기계 코드를 생성할 수 있습니다.

대부분의 유틸리티 코드가 이제 이동 인식(move-aware)이므로, 복사와 포인터 의미론에 대해 걱정하지 말고 간단하고 따라가기 쉬운 코드를 작성하는 데 집중하세요.

reference

https://github.com/abseil/abseil.github.io/blob/master/_posts/2017-10-20-totw-77.md