Abseil Tip 11 반환 정책

주간 팁 #11: 반환 정책

원래 게시일: 2012년 8월 16일

작성자: Paul S. R. Chisholm (p.s.r.chisholm@google.com)

Frodo: 돌아오는 여정에는 남은 것이 없을 거야.
Sam: 돌아오는 여정은 없을 거예요, 미스터 프로도.
반지의 제왕: 왕의 귀환 (J.R.R. 톨킨 원작, 프란 월시, 필리파 보이엔스, 피터 잭슨 각본)

참고: 이 팁은 여전히 유효하지만, C++11에서 도입된 이동 의미론을 포함하지 않습니다. 따라서 이 팁을 읽을 때 TotW #77의 조언도 함께 고려해 주세요.

많은 오래된 C++ 코드베이스는 객체를 복사하는 것에 대해 다소 두려워하는 패턴을 보입니다. 다행히도, “복사 없이 복사하기”라는 기능 덕분에 객체를 복사하지 않고도 복사할 수 있습니다. 이 기능은 바로 “반환 값 최적화” (RVO)입니다.

RVO는 거의 모든 C++ 컴파일러에서 오랜 기간 지원되는 기능입니다. 다음은 C++98 코드 예시입니다. 이 코드에는 복사 생성자와 대입 연산자가 있습니다. 이 함수들은 비용이 많이 들기 때문에, 각 함수가 호출될 때마다 메시지를 출력하도록 했습니다:

class SomeBigObject {
 public:
  SomeBigObject() { ... }
  SomeBigObject(const SomeBigObject& s) {
    printf("비용이 많이 드는 복사 …\n", );
    
  }
  SomeBigObject& operator=(const SomeBigObject& s) {
    printf("비용이 많이 드는 대입 …\n", );
    
    return *this;
  }
  ~SomeBigObject() { ... }
  
};

(여기서는 이동 연산자에 대한 논의를 의도적으로 피하고 있습니다. TotW #77을 참고하세요.)

이 클래스에 다음과 같은 팩토리 메서드가 있다고 가정해 봅시다:

static SomeBigObject SomeBigObjectFactory(...) {
  SomeBigObject local;
  ...
  return local;
}

이것은 비효율적으로 보이지 않나요? 그렇다면 다음 코드를 실행했을 때 어떤 일이 일어날까요?

SomeBigObject obj = SomeBigObject::SomeBigObjectFactory(...);

간단한 답변: 적어도 두 개의 객체가 생성될 것이라고 예상할 것입니다. 호출된 함수에서 반환된 객체와 호출 함수에서의 객체입니다. 두 객체가 복사되므로 두 개의 “비용이 많이 드는 연산” 메시지가 출력될 것이라고 생각할 수 있습니다. 실제로 출력되는 메시지는 없습니다. 왜냐하면 복사 생성자와 대입 연산자가 전혀 호출되지 않았기 때문입니다!

이것이 어떻게 가능한 걸까요? 많은 C++ 프로그래머들이 “효율적인 코드”를 작성할 때 객체를 생성하고 그 객체의 주소를 함수에 전달하여 그 함수가 원래 객체를 조작하도록 합니다. 그런데 컴파일러는 이러한 “비효율적인 복사”를 “효율적인 코드”로 변환할 수 있습니다!

컴파일러는 호출 함수에 있는 변수(이 반환 값을 바탕으로 생성될 객체)와 호출된 함수에서 반환될 변수(여기서는 반환되는 지역 변수)를 확인합니다. 이를 통해 컴파일러는 두 변수가 모두 필요 없다는 것을 알게 됩니다. 그래서 호출 함수의 변수 주소를 호출된 함수로 전달합니다.

C++98 표준을 인용하자면, “임시 클래스 객체가 복사 생성자를 사용하여 복사될 때 … 구현은 원본 객체와 복사본 객체를 동일한 객체를 참조하는 두 가지 방법으로 취급하여 복사를 수행하지 않을 수 있습니다. 함수의 반환 값이 지역 객체의 이름인 경우 … 구현은 반환 값을 저장할 임시 객체를 만들지 않을 수 있습니다 …” (C++98 표준, 12.8 섹션 [class.copy], 15단락). C++11 표준에도 비슷한 내용이 있지만, 더 복잡합니다.

“허용된다”는 표현이 강한 약속이 아닌 것 같아 걱정되시나요? 다행히도, 모든 현대 C++ 컴파일러는 기본적으로 RVO를 수행합니다. 심지어 디버그 빌드에서도, 비인라인 함수에서도 수행됩니다.

컴파일러가 RVO를 수행하도록 어떻게 보장할 수 있을까요?

호출된 함수는 반환 값을 위해 단일 변수를 정의해야 합니다:

SomeBigObject SomeBigObject::SomeBigObjectFactory(...) {
  SomeBigObject local;
  ...
  return local;
}

호출 함수는 반환된 값을 새 변수에 할당해야 합니다:

// 비용이 많이 드는 연산에 대한 메시지 없이:
SomeBigObject obj = SomeBigObject::SomeBigObjectFactory(...);

이게 전부입니다!

호출 함수가 기존 변수를 재사용하여 반환 값을 저장하는 경우(이 경우 이동 의미론이 적용될 수 있음) 컴파일러는 RVO를 수행할 수 없습니다:

// 여기서는 RVO가 일어나지 않음; "비용이 많이 드는 대입 ..." 메시지가 출력됨:
obj = SomeBigObject::SomeBigObjectFactory(s2);

호출된 함수가 반환 값을 위해 여러 개의 변수를 사용하는 경우에도 RVO는 수행되지 않습니다:

// 여기서는 RVO가 일어나지 않음:
static SomeBigObject NonRvoFactory(...) {
  SomeBigObject object1, object2;
  object1.DoSomethingWith(...);
  object2.DoSomethingWith(...);
  if (flag) {
    return object1;
  } else {
    return object2;
  }
}

그러나 호출된 함수가 하나의 변수를 사용하고 여러 곳에서 반환하는 경우에는 RVO가 수행됩니다:

// 여기서는 RVO가 일어남:
SomeBigObject local;
if (...) {
  local.DoSomethingWith(...);
  return local;
} else {
  local.DoSomethingWith(...);
  return local;
}

이 정도면 RVO에 대해 알아야 할 모든 것일 것입니다.

한 가지 더: 임시 객체

RVO는 이름이 붙은 변수만 적용되는 것이 아닙니다. 임시 객체에도 RVO를 적용할 수 있습니다. 호출된 함수가 임시 객체를 반환할 때도 RVO가 수행됩니다:

// 여기서 RVO가 일어남:
SomeBigObject SomeBigObject::ReturnsTempFactory(...) {
  return SomeBigObject::SomeBigObjectFactory(...);
}

또한 호출 함수가 반환된 값을 즉시 사용하는 경우에도 RVO를 적용할 수 있습니다(이 값은 임시 객체에 저장됩니다):

// 비용이 많이 드는 연산에 대한 메시지 없이:
EXPECT_EQ(SomeBigObject::SomeBigObjectFactory(...).Name(), s);

마지막으로 한 가지 주의할 점: 코드에서 복사를 해야 할 경우에는 복사를 하세요. 복사가 최적화될 수 있다고 해서 효율성만을 추구해서는 안 됩니다. 항상 정확성을 우선시해야 합니다.