Abseil Tip 101 반환 값, 참조 및 수명

주간 팁 #101: 반환 값, 참조 및 수명

Originally posted as totw/101 on 2015-07-29
By Titus Winters (titus@google.com)

다음의 코드 스니펫을 보겠습니다:

const string& name = obj.GetName();
std::unique_ptr<Consumer> consumer(new Consumer(name));

특히 여기서 &에 주목해주세요. 이것이 적절할까요? 우리는 무엇을 확인해야 할까요? 어떤 문제가 발생할 수 있을까요? 많은 C++ 프로그래머들이 참조에 대해 완전히 이해하지 못한 상태에서 “복사를 피한다”고만 알고 있는 경우가 많습니다. 하지만 C++의 대부분의 문제처럼, 이 문제도 더 복잡합니다.

사례별 분석: 반환되는 값과 저장되는 방식

여기서 중요한 질문은 두세 가지가 있습니다:

  1. 반환되는 타입은 무엇인가요? (이 예제에서는 GetName()에 의해 반환되는 타입)
  2. 우리가 저장하거나 초기화하는 타입은 무엇인가요? (이 예제에서는 name의 타입)
  3. 만약 참조를 반환하는 경우, 반환된 참조가 가지는 수명에 제한이 있는가요?

여기서는 string 타입을 예로 들지만, 이 논의는 대부분의 비트리비얼(non-trivial) 값 타입에 일반화될 수 있습니다.

  1. string 반환, string 초기화: 이는 보통 RVO(Return Value Optimization)이 적용되며, 현대적인 타입에서는 최악의 경우 이동(move)만 발생합니다 (TotW 77 참고).
  2. string& 또는 const string& 반환, string 초기화: 이 경우 복사가 발생합니다 (참조를 반환하는 경우, 해당 데이터를 가리키는 이름이 두 개 생기므로 복사가 필요합니다. TotW 77 참고). 하지만 함수가 제공하는 수명 보장보다 더 오래 string이 필요할 경우 유용할 수 있습니다.
  3. string 반환, string& 초기화: 이는 컴파일되지 않습니다. 임시 객체에 참조를 바인딩할 수 없기 때문입니다.
  4. const string& 반환, string& 초기화: 이것도 컴파일되지 않습니다. const를 부적절하게 제거했기 때문입니다.
  5. const string& 반환, const string& 초기화: 비용이 들지 않습니다 (사실상 포인터를 반환하는 것과 유사). 하지만 기존 수명 제한을 상속받게 됩니다. 대부분의 접근자 메서드가 참조를 반환할 때 멤버를 반환하므로, 반환된 참조는 포함된 객체의 수명 동안에만 유효합니다.
  6. string& 반환, string& 초기화: #5와 동일하지만, 추가적인 경고 사항이 있습니다. 반환된 참조가 const가 아니므로, 참조를 통해 변경하면 원본에 반영됩니다.
  7. string& 반환, const string& 초기화: #5와 동일합니다.
  8. string 반환, const string& 초기화: #3을 생각해보면 이게 작동하지 않을 것 같지만, 언어 차원에서 예외 처리가 있습니다. const T&를 임시 T로 초기화하면 해당 T(여기서는 string)는 참조가 스코프를 벗어날 때까지 파괴되지 않습니다 (일반적인 경우 자동 변수나 정적 변수).

시나리오 #8은 대부분의 참조 사용을 반사적으로 허용합니다 (즉, “복사를 피하고 싶으니 참조에 할당하겠다”라고 별다른 생각 없이 사용하는 경우). 하지만 #1에서 본 것처럼, 실제로는 큰 이점이 없습니다. 애초에 복사가 발생하지 않았을 가능성이 높기 때문입니다. 게다가, 코드 독자는 const string& 타입의 지역 변수가 실제로 스코프를 벗어나거나 변경되었는지 여부를 걱정해야 합니다.

다시 말해, 원래의 코드 스니펫을 코드 리뷰할 때 다음 사항을 걱정해야 합니다:

  • GetName()이 값으로 반환하는가, 참조로 반환하는가?
  • Consumer의 생성자는 string, const string& 또는 string_view를 받는가?
  • 생성자가 해당 매개변수에 대해 수명 제한을 요구하는가? (만약 string이 아닌 경우)

반면, 처음부터 namestring으로 선언하면 RVO와 이동 의미론 덕분에 성능 저하가 없으며, 객체 수명 측면에서도 안전할 가능성이 높습니다.

추가로, 객체 수명 문제가 있는 경우, 참조의 수명 보장과 SetName()의 수명 요구 사항 간의 상호 작용을 살피기보다, 자신의 string을 사용하는 것이 로컬 코드만 살피면 되므로 문제를 더 쉽게 찾을 수 있습니다.

즉, 복사를 피하는 것은 좋지만, 불필요한 복잡성을 추가하지 않는 것이 중요합니다. 애초에 복사가 없었을 경우 복잡성을 추가하는 것은 좋은 선택이 아닙니다.