Abseil Tip 149 Object Lifetimes vs = delete

title: “이번 주의 팁 #149: 객체 수명 vs. = delete” layout: tips sidenav: side-nav-tips.html published: true permalink: tips/149 type: markdown order: “149” —

원래 TotW #149로 2018년 5월 3일 게시됨

작성: Titus Winters

업데이트: 2020-04-06


“다시 파란 속으로, 돈이 사라진 후에
인생에 한 번, 지하로 흐르는 물처럼” — 데이비드 번


=delete로 수명 관리

다음과 같은 API를 상상해 보세요. 이 API는 소유권을 가지지 않지만, 어떤 장기간 존재하는 객체에 대한 참조를 요구합니다.

class Request {
  ...

  // 제공된 Context는 현재 Request의 수명 동안 존재해야 합니다.
  void SetContext(const Context& context);
};

이때, 이런 생각이 들 수 있습니다: “누군가 임시 객체를 전달하면 어떻게 되지? 문제가 발생하겠군. 하지만 이건 모던 C++이잖아. 내가 막을 수 있어!” 그리고 다음과 같이 API를 수정합니다.

class Request {
  ...

  // 제공된 Context는 현재 Request의 수명 동안 존재해야 합니다.
  void SetContext(const Context& context);
  void SetContext(Context&& context) = delete;
};

수정을 마치고 나면 이런 생각이 들 수 있습니다: “이제 API 자체가 모든 것을 설명하니, 주석은 필요 없겠군.”

class Request {
  ...

  void SetContext(const Context& context);
  void SetContext(Context&& context) = delete;
};

이 방식이 좋은 아이디어일까요? 왜 그럴까요, 혹은 왜 아닐까요?


단독으로 설계하지 말 것

이 방식은 겉보기에는 좋은 아이디어처럼 보일 수 있습니다. 그러나 많은 API 설계 사례에서 API 정의만 보는 것은 유혹적이지만, API 사용 방식을 살펴보는 것이 훨씬 더 유용합니다. 이 시나리오를 다시 살펴보면서 실제 사용 사례를 고려해 보겠습니다.

기존 SetContext()를 사용하는 사용자가 적절한 Context 객체를 어디서 찾아야 할지 몰라 간단한 호출을 시도한다고 가정해 보세요.

request.SetContext(Context());

=delete 수정 없이 이 코드는 빌드가 성공하지만, 런타임에 실패합니다(아마 알기 어려운 방식으로). 이 경우 SetContext API에 명시된 수명 요구 사항을 확인한 후 사용자가 코드를 수정합니다.

request.SetContext(request2.context());

반면, =delete를 추가하고 주석이 없는 “개선된” SetContext()를 사용하려는 사용자는 빌드 오류를 먼저 경험합니다:

error: call to deleted member function 'SetContext'

  request.SetContext(Context());
  ~~~~~~~~^~~~~~~~~~

<source>:4:8: note: candidate function has been explicitly deleted
  void SetContext(Context&& context) = delete;

이 사용자는 “임시 객체를 전달할 수 없구나”라고 생각한 뒤, 요구사항에 대한 정보가 없는 상태에서 다음과 같이 코드를 수정할 가능성이 높습니다.

Context context;
request.SetContext(context);

이제 중요한 문제는 다음과 같습니다: 새로운 context 자동 변수의 범위가 이 호출에 적합할 확률은 얼마나 될까요?
그 확률이 100% 미만이라면, 수명 요구사항 주석은 여전히 필요합니다.

class Request {
  ...

  // 제공된 Context는 현재 Request의 수명 동안 존재해야 합니다.
  void SetContext(const Context& context);
  void SetContext(Context&& context) = delete;
};

이와 같이 오버로드 집합의 멤버를 삭제하는 것은 최선의 경우 반쪽짜리 해결책입니다. 특정 클래스의 버그를 피할 수는 있지만 API를 복잡하게 만듭니다. C++ 타입 시스템은 매개변수의 수명 요구 사항에 필요한 세부 정보를 인코딩할 수 없습니다.

따라서 이런 방식에 의존하지 말고, 단순하게 유지하세요. 이 패턴으로 임시 객체를 차단하려고 시도하지 마세요. 충분히 효과적이지 못합니다.


최적화를 위한 =delete

다른 상황을 고려해 봅시다. 임시 객체를 방지하려는 것이 아니라 복사를 방지하려는 경우입니다.

future<bool> DnaScan(Config c, const std::string& sequence) = delete;
future<bool> DnaScan(Config c, std::string&& sequence);

이 API를 호출하는 사용자가 값을 유지하지 않을 것이라고 확신할 수 있을까요?
API의 사용 방식을 정확히 알 수 없다면, 이는 사용자를 짜증 나게 하는 설계로 이어질 가능성이 큽니다. 다음은 일반적인(삭제되지 않은) 설계를 통한 호출 예제입니다:

Config c1 = GetConfig();
Config c2 = GetConfig();
std::string s = GetDna();

// 두 설정에 대해 스캔을 시작합니다.
auto scan1 = DnaScan(c1, s);
auto scan2 = DnaScan(c2, std::move(s));

여기서 s가 마지막으로 사용된다는 점을 알 수 있으므로 std::move를 호출해 값 소비 호출을 처리할 수 있습니다. 그러나 “최적화된” 삭제된 버전에서는 코드가 더 지저분해집니다.

Config c1 = GetConfig();
Config c2 = GetConfig();
std::string s = GetDna();
std::string s2 = s;

// 두 설정에 대해 스캔을 시작합니다.
auto scan1 = DnaScan(c1, std::move(s));
auto scan2 = DnaScan(c2, std::move(s2));

요약

rvalue 참조나 참조 수식자와 =delete를 조합해 더 “사용자 친화적”인 API를 제공하려는 시도는 매력적으로 보일 수 있습니다. 그러나 실질적으로는 이런 시도가 대부분 나쁜 선택입니다. C++ 타입 시스템은 수명 요구 사항을 충분히 표현할 수 없으며, API 제공자는 자신의 API 사용 방식에 대해 모든 미래의 유효한 시나리오를 예측할 수 없습니다.
이러한 =delete 트릭을 피함으로써 더 단순하고 명확한 설계를 유지하세요.