한글 번역
Tip of the Week #99: 비멤버 인터페이스 에티켓
2015-06-24에 totw/99로 최초 게시
2017-10-10 수정됨
C++ 클래스의 인터페이스는 클래스 멤버나 정의로만 제한되지 않습니다. API를 평가할 때, 클래스 본문 외부에서 정의된 내용도 클래스의 public 멤버만큼이나 인터페이스의 일부가 될 수 있습니다.
이러한 외부 인터페이스 포인트에는 템플릿 특수화(예: 해시 함수나 traits), 비멤버 연산자 오버로드(예: 로깅, 관계 연산자), 그리고 ADL(Argument-Dependent Lookup)을 활용하기 위해 설계된 기타 비멤버 함수들(특히 swap()
)이 포함됩니다.
예를 들어, space::Key
라는 클래스에서 이러한 사례를 다음과 같이 볼 수 있습니다:
namespace space {
class Key { ... };
bool operator==(const Key& a, const Key& b);
bool operator<(const Key& a, const Key& b);
void swap(Key& a, Key& b);
// 표준 스트리밍
std::ostream& operator<<(std::ostream& os, const Key& x);
// gTest 출력 지원
void PrintTo(const Key& x, std::ostream* os);
// 새로운 플래그 확장
bool ParseFlag(const string& text, Key* dst, string* err);
string UnparseFlag(const Key& v);
} // namespace space
HASH_NAMESPACE_BEGIN
template <>
struct hash<space::Key> {
size_t operator()(const space::Key& x) const;
};
HASH_NAMESPACE_END
이러한 확장을 잘못 작성하면 중요한 위험이 발생할 수 있으므로, 이 글에서는 몇 가지 지침을 제시하고자 합니다.
적절한 네임스페이스
함수로 설계된 인터페이스 포인트는 대개 ADL로 검색되도록 설계됩니다. 연산자와 몇몇 연산자 유사 함수(특히 swap()
)는 ADL에 의해 검색되도록 설계되었습니다. 이 프로토콜은 함수가 커스터마이즈하려는 타입과 연관된 네임스페이스에 정의될 때만 제대로 작동합니다. 연관된 네임스페이스에는 타입의 베이스 클래스와 클래스 템플릿 매개변수가 포함됩니다.
자주 저지르는 실수는 이러한 함수를 전역 네임스페이스에 두는 것입니다. 아래 예시는 동일한 구문으로 호출된 good(x)
와 bad(x)
함수가 어떻게 다른 동작을 하는지 보여줍니다.
namespace library {
struct Letter {};
void good(Letter);
} // namespace library
// bad는 전역 네임스페이스에 잘못 배치됨
void bad(library::Letter);
namespace client {
void good();
void bad();
void test(const library::Letter& x) {
good(x); // 정상: ADL로 'library::good'이 검색됨.
bad(x); // 오류: '::bad'는 'client::bad'에 의해 숨겨짐.
}
} // namespace client
library::good()
은 어떤 이름 충돌 없이 검색되는 반면, ::bad()
는 client::bad()
에 의해 숨겨져 호출할 수 없습니다. 이는 함수를 데이터와 동일한 네임스페이스에 정의하면 피할 수 있는 문제입니다.
클래스 내 friend 정의에 대한 간단한 참고사항
클래스 정의 내부에서 비멤버 함수를 추가하는 방법이 있습니다. 바로 friend 함수입니다.
namespace library {
class Key {
public:
explicit Key(string s) : s_(std::move(s)) {}
friend bool operator<(const Key& a, const Key& b) { return a.s_ < b.s_; }
friend bool operator==(const Key& a, const Key& b) { return a.s_ == b.s_; }
friend void swap(Key& a, Key& b) {
swap(a.s_, b.s_);
}
private:
std::string s_;
};
} // namespace library
이러한 friend 함수는 ADL을 통해서만 검색될 수 있는 특별한 속성이 있습니다. 이러한 함수는 클래스 정의 외부에서 검색되지 않습니다. friend 정의는 인라인 형태로 작성해야 이 특성이 작동합니다.
이 방식은 간편하고 클래스 내부에 접근할 수 있다는 장점이 있지만, 암시적 변환으로 인해 클래스가 인수를 받을 수 있는 경우 검색되지 않을 수 있다는 단점이 있습니다.
적절한 소스 위치
ODR(One Definition Rule)을 위반하지 않으려면, 타입 인터페이스의 모든 커스터마이징은 한 번만 정의되도록 해야 합니다. 일반적으로 이는 해당 타입과 동일한 헤더 파일에 패키징되어야 한다는 의미입니다.
연산자 오버로드를 포함한 함수 오버로드는 해당 인수 중 하나를 정의한 헤더 파일에 선언되어야 합니다.
언제 커스터마이징을 피해야 하는가
-
테스트 코드에서 커스터마이징을 하지 마세요. 테스트 파일에서 정의된 연산자는 유지 보수를 어렵게 하고 ODR 위반의 위험을 초래할 수 있습니다.
-
Protobuf로 생성된 타입을 확장하지 마세요. Protobuf로 생성된 C++ API는 여러분이 소유한 것이 아니므로, 확장을 추가하면 위험할 수 있습니다.
-
포인터에 커스터마이징을 하지 마세요. 포인터의 기본 동작과 충돌할 수 있으므로 피해야 합니다.
해결 방법
아래는 자주 발생하는 문제와 이를 해결하기 위한 대안입니다.
EXPECT_EQ
사용 시
- 문제:
EXPECT_EQ
는operator==
와operator<<
이 필요합니다. - 대안: Google Test의 MATCHER_P를 사용해 가벼운 gMock 매처를 작성하세요.
컨테이너 키로 T
를 사용 시
- 문제: 기본 비교 연산자가
operator<
나hash<T>
를 요구합니다. - 대안: 사용자 정의 비교자나 해시를 사용하세요.
결론
타입의 동작은 클래스 정의에만 의존하지 않습니다. 비멤버 정의와 특수화 역시 중요한 역할을 합니다. 이러한 커스터마이징은 신중하게 추가되어야 하며, 부적절한 정의는 나중에 큰 문제를 초래할 수 있습니다.