Effective C++ : 4
Updated:
Chapter4. 설계 및 선언
항목 18: 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자.
C++에서는 발에 치이고 손에 잡히는 것이 인터페이스이다. (함수/클래스/템플릿) ‘제대로 쓰기에 쉽고 엉터리로 쓰기에 어려운’ 인터페이스를 개발하려면 우선 사용자가 저지를 만한 실수의 종류를 머리에 넣어두고 있어야 한다.
새로운 타입을 들여와 인터페이스를 강화하면 상당수의 사용자 실수를 막을 수 있다. 어떤 타입에 제약을 부여해 그 타입을 통해 할 수 있는 일들을 묶어 버리는 방법도 있다. 제약 부여 방법으로 아주 흔히 쓰이는 예가 ‘const 붙이기’이다.
기본제공 타입과 쓸데없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서이다.
createInvestment 함수에서 getRidOfInvestment를 삭제자로 갖는 tr1::shared_ptr을 반환하도록 구현하는 방법은 다음과 비슷할 것이다.
std::tr1::shared_ptr<Investment> createInvestment()
{
std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestment); // getRidOfInvestment << 삭제자
retVal = . . .; // retVal는 실제 객체를 가리키도록 만든다.
return retVal;
}
tr1::shared_ptr에는 엄청 좋은 특징이 하나 있다. 바로 포인터별(per-pointer) 삭제자를 자동으로 씀으로써 사용자가 저지를 수 있는 또 하나의 잘못을 미연에 없애 준다는 점이다. 여기서 또 하나의 잘못이란 바로 ‘교차 DLL 문제(cross-DLL problem)’이다.
이 문제는 객체 생성 시에 어떤 동적 링크 라이브러리(dynamically linked library: DLL)의 new를 썼는데 그 객체를 삭제할 때는 이전의 DLL과 다른 DLL에 있는 delete를 썼을 경우이다. tr1::shared_ptr은 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 만들어져 있어서 걱정할 필요가 없다.
- 이것만은 잊지 말자!
- 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵다. 인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하고 또 고민하자.
- 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있다.
- 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있다.
- tr1::shared_ptr은 사용자 정의 삭제자를 지원한다. 이 특징 때문에 tr1::shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있다.
항목 19: 클래스 설계는 타입 설계와 똑같이 취급하자
- 효과적인 클래스를 설계할 때 필요한 질문들이다.
- 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가?
- 객체 초기화는 객체 대입과 어떻게 달라야 하는가?
- 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가?
- 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가?
- 전부는 아니지만, 클래스 데이터 멤버의 몇 가지 조합 값만은 반드시 유효해야 한다. 이런 조합을 가리켜 클래스의 불변속성(invariant)라고 하며, 클래스 차원에서 지켜주어야 하는 부분이다.
- 기존의 클래스 상속 계통망(inheritance graph)에 맞출 것인가?
- 어떤 종류의 타입 변환을 허용할 것인가?
- 여러분이 만든 타입은 결국 기존의 수많은 타입들과 어울려야 한다. 여러분의 타입과 다른 타입 사이에 변환 수단이 있어야 할까?
- T1 타입의 객체를 T2 타입의 객체로 암시적으로(implicitly) 변환되도록 만들고 싶다면, T1 클래스에 타입 변환 함수를 하나 넣어두든가(operator T2) 아니면 인자 한 개로 호출될 수 있는 비명시호출(non-explicit) 생성자를 T2 클래스에 넣어두어야 할 것이다.
- 명시적(explicit) 타입 변환만 허용하고 싶은 경우에는, 해당 변환을 맡는 별도 이름의 함수를 만들되 타입 변환 연산자 혹은 (인자 하나로 호출될 수 있는) 비명시호출 생성자는 만들지 말아야 할 것이다.
- 어떤 연산자와 함수를 두어야 의미가 있을까?
- 표준 함수들 중 어떤 것을 허용하지 말 것인가?
- 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?
- ‘선언되지 않은 인터페이스’로 무엇을 둘 것인가?
- 여러분이 만들 타입이 제공할 보장이 어떤 종류일까에 대한 질문으로서, 보장할 수 있는 부분은 수행 성능 및 예외 안전성, 그리고 자원 사용(잠금 및 동적 메모리 등)이다.
- 이들에 대해 여러분이 보장하겠다고 결정한 결과는 클래스 구현에 있어서 제약으로 작용한다.
- 새로 만드는 타입이 얼마나 일반적인가?
- 정말로 꼭 필요한 타입인가?
- 이것만은 잊지 말자!
- 클래스 설계는 타입 설계이다. 새로운 타입을 정의하기 전에, 이번 항목에 나온 모든 고려사항을 빠짐없이 점검해보자.
항목 20: ‘값에 의한 전달’보다는 ‘상수객체 참조자에 의한 전달’방식을 택하는 편이 대개 낫다.
참조에 의한 전달 방식으로 매개변수를 넘기면 복사손실 문제(slicing problem)가 없어진다. 일반적으로, ‘값에 의한 전달’이 저비용이라고 가정해도 괜찮은 유일한 타입은 기본제공 타입, STL 반복자, 함수 객체 타입, 이렇게 세 가지이다.
- 이것만은 잊지 말자!
- ‘값에 의한 전달’보다는 ‘상수 객체 참조자에 의한 전달’을 선호하자. 대체적으로 효율적일뿐만 아니라 복사손실 문제까지 막아준다.
- 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않다. 이들에 대해서는 ‘값에 의한 전달’이 더 적절하다.
항목 21: 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자
- 이것만은 잊지 말자!
- 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 말아라.
항목 22: 데이터 멤버가 선언될 곳은 private 영역임을 명심하자
이번 항목에서는 데이터 멤버가 public이면 안 되는 이유를 알아보고, public 데이터 멤버에 대한 모든 이야기가 protected 데이터 멤버에도 똑같이 적용되는 모습을 확인하겠다.
- 문법적 일관성
- 데이터 멤버가 public이 아니라면, 사용자 쪽에서 어떤 객체를 접근할 수 있는 유일한 수단은 멤버 함수일 것이다. 어떤 클래스의 공개 인터페이스에 있는 것들이 전부 함수뿐이라면, 그 클래스의 멤버에 접근하고 싶을 때 알아보기 쉽다. 또한, 데이터 멤버의 값을 읽고 쓰는 함수를 사용하면 접근 불가, 읽기 전용, 읽기 쓰기 접근 등을 여러분이 직접 구현할 수 있다.
- 그리고 캡슐화(encapsulation)이다. 함수를 통해서만 데이터 멤버에 접근할 수 있도록 구현해 두면, 데이터 멤버를 나중에 계산식으로 대체할 수도 있을 것이고, 사용자는 절대로 이 클래스를 넘보지 못하게 된다.
- 이것만은 잊지 말자!
- 데이터 멤버는 private 멤버로 선언하자. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐 아니라, 내부 구현의 융통성도 발휘할 수 있다.
- protected는 public보다 더 많이 ‘보호’받고 있는 것이 절대로 아니다.
항목 23: 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자
- 이것만은 잊지 말자!
- 멤버 함수보다는 비멤버 비프렌드 함수를 자주 쓰도록 하자. 캡슐화 정도가 높아지고, 패키징 유연성도 커지며, 기능적인 확장성도 늘어난다.
항목 24: 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자
- 이것만은 잊지 말자!
- 어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서)에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 한다.
항목 25: 예외를 던지지 않는 swap에 대한 지원도 생각해 보자
표준에서 기본적으로 제공하는 swap은 구현 코드를 보면 알겠지만 복사만 제대로 지원하는(복사 생성자 및 복사 대입 연산자를 통해) 타입이기만 하면 어떤 타입의 객체이든 맞바꾸기 동작을 수행해준다.
이 때 복사하면 손해를 보는 타입들 중 으뜸을 꼽는다면 아마도 다른 타입의 실제 데이터를 가리키는 포인터가 주성분(!)인 타입일 것이다. 이렇게 포인터가 주성분인 객체를 직접 맞바꾼다면, 포인터만 살짝 바꾸는 것 말고는 실제로 할 일이 없다. 하지만 이런 사정을 표준 swap 알고리즘이 알 턱이 없다.
C++로는 이러한 의도를 그대로 할 수 있는 방법이 있다. std::swap을 특정한 상황에 대해 특수화(specialize)하는 것인데, 일단 기본 아이디어만 간단히 코드로 보여주겠다. (컴파일 안됨)
namespace std {
template<> // 이 코드는 T가 Widget일 경우에
void swap<Widget> (Widget& a, Widge& b) // 대해 std::swap을 특수화한 것이다.
{
swap(a.pointer, b.pointer); // Widget을 'swap'하기 위해, 포인터만 맞바꾼다.
}
}
우선 함수 시작부분에 있는 ‘template<>’을 보자. 이 함수가 std::swap의 완전 템플릿 특수화(total template specialization) 함수라는 것을 컴파일러에게 알려 주는 부분이다. 그리고 함수 이름 뒤에 있는 ‘
하지만 위 함수는 일반적으로 좋지 않다. Widget 클래스 내부 멤버 포인터는 일반적으로 private이기 때문이다. 그래서 Widget 안에 swap이라는 public 멤버 함수를 선언하고 그 함수가 실제 맞바꾸기를 수행하도록 하는 것이 좋다.
class Widget {
public:
...
void swap(Widget& other)
{
using std::Swap; // 이 선언문이 필요한 이유는 이후의 설명에서 알 수 있다.
swap(pointer, other.pointer);
}
...
};
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b);
}
}
컴파일될 뿐만 아니라, 기존의 STL 컨테이너와 일관성도 유지되는 착한 코드가 되었다.
여기서 가정을 하나 더 해보자. Widget과 WidgetImpl이 클래스가 아니라 클래스 템플릿으로 만들어져 있어서, WidgetImpl에 저장된 데이터의 타입을 매개변수로 바꿀 수 있다면 어떻게 될까?
template<typename T>
class WidgetImpl { ... };
template<typename T>
class Widget { ... };
swap 멤버 함수를 Widget에 넣는 정도는 먼젓번 경우처럼 별로 어렵지 않지만, std::swap을 특수화하는 데서 좌절모드로 들어가게 된다. 사실 우리가 작성하려고 했던 코드는 이런 것이었다.
namespace std {
template<typename T>
void swap<Widget<T> >(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
퍼펙트하게 보일 수도 있지만, C++의 기준에는 적법하지 않다. 우리는 지금 함수 템플릿(std::swap)을 부분적으로 특수화해 달라고 컴파일러에게 요청한 것인데, C++은 클래스 템플릿에 대해서는 부분 특수화(partial specialization)를 허용하지만 함수 템플릿에 대해서는 허용하지 않도록 정해져 있다.
함수 템플릿을 ‘부분적으로 특수화’하고 싶을 때 흔히 취하는 방법은 그냥 오버로드 버전을 하나 추가하는 것이다.
namespace std {
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
일반적으로 함수 템플릿의 오버로딩은 해도 별 문제가 없지만, std는 조금 특별한 네임스페이스이기 때문에 이 네임스페이스에 대한 규칙도 다소 특별하다. 요컨대, std 내의 템플릿에 대한 완전 특수화는 OK지만, std에 새로운 템플릿을 추가하는 것은 안 된다.
여기에서 주의해야 할 사항은 std의 영역을 침범하더라도 일단 컴파일까지는 거의 다 되고 실행도 된다. 그런데 실행되는 결과가 미정의 사항이라는 것이다.
해결 방법은 다음과 같다. 멤버 swap을 호출하는 비멤버 swap을 선언해 놓되, 이 비멤버 함수를 std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지만 않으면 된다. 예를 들어, Widget 관련 기능이 전부 WidgetStuff 네임스페이스에 들어 있다고 가정하면 다음과 같이 만들라는 이야기이다.
namespace WidgetStuff {
... // 템플릿으로 만들어진 WidgetImpl 및 기타 등등
template<typename T> // 이전과 마찬가지로 swap이란 이름의 멤버 함수가 들어있다.
class Widget { ... } ;
...
template<typename T> // 비멤버 swap 함수
void swap(Widget<T>& a, Widget<T>& b) // 이번에는 std 네임스페이스의 일부가 아니다.
{
a.swap(b);
}
}
이제는 어떤 코드가 두 Widget 객체에 대해 swap을 호출하더라도, 컴파일러는 C++의 이름 탐색 규칙에 의해 WidgetStuff 네임스페이스 안에서 Widget 특수화 버전을 찾아낸다.
지금까지 살펴본 내용은 전부 swap을 구현하는 사람들 쪽에 무게가 있었지만, 이제는 고객의 눈으로 어떤 상황 하나를 놓고 이야기해보자. 여러분이 어떤 함수를 만들고 있는데, 이 함수 템플릿은 실행 중에 swap을 써서 두 객체의 값을 맞바꾸어야 한다고 가정하자.
template<typename T>
void doSomething(T& obj1, T& obj2)
{
...
swap(obj1, obj2);
...
}
이 부분에서 과연 어떤 swap을 호출해야 할까? 가능성은 세 가지이다.
- std에 있는 일반형 버전: 이것은 확실히 있다.
- std의 일반형을 특수화한 버전: 있을 수도, 없을 수도 있다.
- T 타입 전용의 버전: 있거나 없거나 할 수 있고, 어떤 네임스페이스 안에 있거나 없거나 할 수도 있다. 우리는 타입 T 전용 버전이 있으면 그것이 호출되도록 하고, T 타입 전용 버전이 없으면 std의 일반형 버전이 호출되도록 만들고 싶다. 어떻게 이렇게 이끌어낼 수 있을까? 아래의 코드가 정답이다.
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; // std::swap을 이 함수 안으로 끌어올 수 있도록 만드는 문장
...
swap(obj1, obj2); // T 타입 전용의 swap을 호출한다.
...
}
컴파일러가 위의 swap 호출문을 만났을 때 하는 일은 현재의 상황에 딱 맞는 swap을 찾는 것이다. C++의 이름 탐색 규칙을 따라, 우선 전역 유효범위 혹은 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지를 찾는다[예를 들어 T가 WidgetStuff 네임스페이스 내의 Widget이라면, 컴파일러는 인자 의존 규칙을 적용해 Widget-Stuff의 swap을 찾아낼 것이다].
원하는 swap이 호출되도록 만드는 작업은 별로 어렵지 않다. 이거 딱 하나만 조심하면 된다. 호출문에 한정자를 잘못 붙이거나 하지 말아라. 한정자가 붙게 되면 C++이 호출된 함수를 결정하는 메커니즘에 바로 영향이 가기 때문이다. 예를 들어, 위의 swap 호출문을 아래와 같이 써 버리면,
std::swap(obj1, obj2);
std의 swap(그 어떤 템플릿 특수화 버전들도 포함해서) 외의 다른 것은 거들떠보지도 말라고 컴파일러를 구속하게 된다. 더 딱 맞을 수 있는 T 전용 버전이 다른 곳에 있을 가능성을 완전히 무시하는 것이다.
이번 항목에서는 표준 swap, 멤버 swap, 비멤버 swap, 특수화한 std::swap 그리고 swap 호출 시의 상황들에 대해 집중적으로 조명해 보았다. 이제 정리해보자.
-
첫째, 표준에서 제공하는 swap이 여러분의 클래스 및 클래스 템플릿에 대해 납득할 만한 효율을 보이면, 그냥 아무것도 하지 말고 지내라. 여러분이 만든 타입으로 만든 객체에 대해 ‘swap’을 시도하는 사용자 코드는 표준 swap을 호출하게 될 것이다. 그리고 아무 문제도 없을 것이다.
- 둘째, 그런데 표준 swap의 효율이 기대한 만큼 충분하지 않다면, 다음과 같이 해라.
- 여러분의 타입으로 만들어진 두 객체의 값을 빛나게 빨리 맞바꾸는 함수를 swap이라는 이름으로 만들고, 이것을 public 멤버 함수로 두어라. 단, 이 함수는 절대로 예외를 던져선 안 된다.
- 여러분의 클래스 혹은 템플릿이 들어 있는 네임스페이스와 같은 네임스페이스에 비멤버 swap을 만들어 넣는다. 그리고 1번에서 만든 swap 멤버 함수를 이 비멤버 함수가 호출하도록 만든다.
- 새로운 클래스(클래스 템플릿이 아니라)를 만들고 있다면, 그 클래스에 대한 std::swap의 특수화 버전을 준비해 둔다. 그리고 이 특수화 버전에서도 swap 멤버 함수를 호출하도록 만든다.
- 셋째, 사용자 입장에서 swap을 호출할 때, swap을 호출하는 함수가 std::swap을 볼 수 있도록 using 선언을 반드시 포함시킨다. 그 다음에 swap을 호출하되, 네임스페이스 한정자를 붙이지 않도록 해라.
-
이것만은 잊지 말자!
- std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공하자. 이 멤버 swap은 예외를 던지지 않도록 만들자.
- 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공한다. 클래스에 대해서는, std::swap도 특수화해 두자.
- 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap을 호출하자.
- 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능하다. 그러나 std에 어떤 것이라도 새로 ‘추가’하려고 들지는 말아라.
Leave a comment