Abseil Tip 176 출력 매개변수 대신 반환 값을 선호하세요

주간 팁 #176: 출력 매개변수 대신 반환 값을 선호하세요

원래 TotW #176으로 2020년 3월 12일 게시됨
작성자: Etienne Dechamps
2020-04-06 업데이트됨

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


문제

다음과 같은 함수를 고려해봅시다:

// 주어진 doodad에서 foo 사양과 bar 사양을 추출합니다.
// 입력이 유효하지 않으면 false를 반환합니다.
bool ExtractSpecs(Doodad doodad, FooSpec* foo_spec, BarSpec* bar_spec);

이 함수를 올바르게 사용하거나 구현하려면 개발자는 다음과 같은 질문을 해야 합니다:

  • foo_specbar_spec출력 매개변수인지, 아니면 입출력 매개변수인지?
  • foo_specbar_spec의 기존 데이터는 어떻게 처리되나요? 덮어쓰나요? 추가되나요? 함수가 CHECK-fail을 일으키나요? 아니면 false를 반환하나요? 정의되지 않은 동작인가요?
  • foo_specbar_spec이 null일 수 있나요? null일 수 없다면, null 포인터가 전달되었을 때 CHECK-fail을 발생시키나요? 아니면 false를 반환하나요? 정의되지 않은 동작인가요?
  • foo_specbar_spec의 수명 요구사항은 무엇인가요? 즉, 함수 호출보다 오래 살아남아야 하나요?
  • false가 반환되었을 때, foo_specbar_spec은 어떻게 되나요? 변경되지 않도록 보장되나요? 특정 방식으로 “초기화”되나요? 아니면 명시되지 않나요?

이 질문들에 함수 시그니처만으로는 답할 수 없습니다. 또한 C++ 컴파일러가 이러한 계약을 강제하지 않습니다. 함수 주석은 도움이 될 수 있지만, 종종 충분하지 않습니다. 위 함수의 문서화는 대부분의 문제를 다루지 않고 있으며, “입력”이라는 의미조차도 모호합니다. 이는 doodad만을 의미하는지, 아니면 다른 매개변수까지 포함하는지를 명확히 하지 않습니다.

게다가, 이 접근법은 모든 호출 위치에 보일러플레이트 코드를 요구합니다. 호출자는 FooSpecBarSpec 객체를 미리 할당해야만 함수를 호출할 수 있습니다.

이 경우, 보일러플레이트를 제거하고 컴파일러가 계약을 강제할 수 있도록 간단히 해결할 수 있습니다.


해결책

다음과 같이 모든 문제를 해결할 수 있습니다:

struct ExtractSpecsResult {
  FooSpec foo_spec;
  BarSpec bar_spec;
};
// 주어진 doodad에서 foo 사양과 bar 사양을 추출합니다.
// 입력이 유효하지 않으면 nullopt를 반환합니다.
std::optional<ExtractSpecsResult> ExtractSpecs(Doodad doodad);

이 새로운 API는 의미적으로 동일하지만, 오용하기 훨씬 어려워졌습니다:

  • 입력과 출력이 무엇인지 더 명확합니다.
  • foo_specbar_spec의 기존 데이터에 대한 의문이 사라집니다. 함수에서 새로 생성하기 때문입니다.
  • 포인터가 없기 때문에 null 포인터에 대한 의문도 사라집니다.
  • 모든 것이 값으로 전달되고 반환되므로 수명에 대한 의문도 없습니다.
  • 실패 시 foo_specbar_spec이 어떻게 되는지에 대한 의문도 없습니다. nullopt이 반환되면 접근조차 할 수 없기 때문입니다.

이로 인해 버그 가능성이 줄어들고 개발자의 인지 부담이 줄어듭니다.

또 다른 장점은 함수가 더 쉽게 조합 가능하다는 것입니다. 예를 들어, SomeFunction(ExtractSpecs(...))와 같은 방식으로 간단히 사용할 수 있습니다.


주의사항

  • 이 접근법은 입출력 매개변수에는 적합하지 않습니다.
    • 경우에 따라 매개변수를 값으로 받고, 수정한 뒤 값을 반환하는 방식으로 변형할 수 있습니다. 이 방식이 적합한지는 함수 사용 방식과 값이 효율적으로 이동 가능한지 여부에 따라 달라집니다. (팁 #117 참고)
  • 이 접근법은 호출자가 반환된 객체의 생성을 쉽게 사용자 정의할 수 없게 만듭니다.
    • 예를 들어, FooSpecBarSpec이 프로토(protos)라면, 출력 매개변수 접근법에서는 호출자가 특정 아레나에서 해당 프로토를 할당할 수 있습니다. 반면 반환값 접근법에서는 아레나를 추가 매개변수로 지정하거나 호출자가 이를 미리 알고 있어야 합니다.
  • 성능은 선택한 접근법과 상황에 따라 달라질 수 있습니다.
    • 반환값 사용이 비효율적일 수 있는 경우도 있습니다. 예를 들어, 반복문 내에서 반복적으로 할당이 발생할 경우입니다.
    • 그러나 반환값 사용이 예상보다 효율적인 경우도 있습니다. (N)RVO(팁 #11, 팁 #166) 덕분입니다. 심지어 출력 매개변수보다 더 효율적일 수 있습니다. 왜냐하면 옵티마이저가 별칭(aliasing)에 대해 걱정할 필요가 없기 때문입니다.
    • 항상 조기 최적화를 피하세요. 가장 적합한 API를 선택하고, 성능이 실제로 영향을 미친다는 증거가 있을 때만 최적화에 신경 쓰세요.

권장 사항

  1. 출력 매개변수 대신 반환값을 선호하세요. 이는 스타일 가이드와 일치합니다.
  2. 반환값이 없음을 표현하려면 std::optional과 같은 일반 래퍼를 사용하세요. 더 유연한 표현이 필요하다면 std::variant를 고려하세요.
  3. 구조체를 사용하여 함수에서 여러 값을 반환하세요.
    • 필요하다면 해당 함수의 반환값을 표현하기 위해 새로운 구조체를 만들어도 좋습니다.
    • std::pairstd::tuple 사용의 유혹에 저항하세요.