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
트릭을 피함으로써 더 단순하고 명확한 설계를 유지하세요.