18. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자
사용자가 인터페이스를 잘못 사용했을 경우 컴파일 되지 않아야 올바른 설계이다.
class Data {
public:
Date(int month, int day, int year);
};
예를 들어 클래스 설계가 위와 같은 경우 매개변수의 순서를 다르게 전달하여 객체를 생성하는 경우가 발생할 가능성이 존재한다.
각 매개변수를 새로운 타입으로 변경한다면 괜찮게 동작할 수 있다.
enum의 경우 타입 안전성은 그리 믿음직 하지 못하다. (C++11부터 enum class 도입으로 인해 이제는 안전하다)
class Month {
public:
static Month Jan() { return Month(1); }
static Month Feb() { return Month(2); }
...
private:
explicit Month(int m); // 생성자가 private 이므로 값이 새로 생성되지 않음
};
Date d(Month::Mar(), Day(30), Year(1995));
또는 타입 클래스의 유효한 집합을 미리 정적 멤버 함수로 정의해 두어도 괜찮다.
if (a * b = c) // 본래 의도는 ==
위와 같은 경우는 operator*의 반환 타입을 const로 한정하면 혹시 모를 실수를 방지할 수 있다.
특별한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만드는게 좋다. 그래야 일관성있는 인터페이스 제공이 가능해진다.
사용자 쪽에서 뭔가를 외워야 제대로 쓸 수 있는 인터페이스는 언제라도 잊어버릴 수 있기 때문에 잘못 쓰기 쉽다.
// 팩토리 함수
Investment* createInvestment(); // 만약 스마트 포인터 사용조차 까먹는다면?
앞선 장에서 다뤘던 구조이다. 팩토리 함수를 통해 객체를 생성해서 포인터를 반환받을 때, 그것을 스마트 포인터에 담는 것조차 잊어버릴 가능성도 존재한다.
std::shared_ptr<Investment> createInvestment();
그래서 아예 스마트 포인터를 반환하게 만들면 스마트 포인터에 담을수밖에 없고 객체 삭제를 잊더라도 문제가 발생하지 않는다.
또한 스마트 포인터의 특징으로 객체 생성과 삭제가 교차 DLL에 의해 다르게 이루어지는 문제가 발생하지 않는다.
new를 실행할 당시의 DLL과 delete를 실행하는 순간의 DLL이 다르더라도 객체가 생성될 때의 DLL에서 삭제자를 사용하도록 만들어져있어서 교차 DLL 문제에서 자유롭다.
◾ 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵습니다. 인터페이스를 만들때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.
◾ 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있습니다.
◾ 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기(클래스), 타입에 대한 연산을 제한하기(const), 객체의 값에 대해 제약 걸기(enum 등), 작업 관리 작업을 사용자 책임으로 놓지 않기(스마트 포인터)가 있습니다.
◾ shared_ptr은 사용자 정의 삭제자를 지원합니다. 이 특징 때문에 shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있습니다.
19. 클래스 설계는 타입 설계와 똑같이 취급하자
새로운 클래스를 정의한다는 것은 새로운 타입을 하나 정의하는 것과 같다.
좋은 타입은 문법이 자연스럽고 의미구조가 직관적이고 효율적인 구현이 한 가지 이상 가능해야 한다.
클래스를 설계할 때 아래의 질문을 항상 떠올리며 신경써서 설계하자.
◾ 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가? -> 이에 따라 생성자 및 소멸자의 설계가 바뀐다.
◾ 객체 초기화는 객체 대입과 어떻게 달라야 하는가? -> 초기화와 대입을 헷갈려선 안된다.
◾ 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가? -> 값에 의한 전달을 구현하는 쪽은 복사 생성자이다.
◾ 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가? -> 클래스의 불변속성은 클래스 차원에서 지켜주어야 한다.
◾ 기존의 클래스 상속 계통망에 맞출 것인가? -> 상속 여부에 따라 멤버 함수의 가상 함수 여부가 결정된다. 특히 소멸자.
◾ 어떤 종류의 타입 변환을 허용할 것인가? -> 타입간 변환 허용 여부에 따라 멤버 함수 또는 생성자의 설계가 이루어져야 한다.
◾ 어떤 연산자와 함수를 두어야 의미가 있을까?
◾ 표준 함수들 중 어떤 것을 허용하지 말 것인가? -> private 선언 함수
◾ 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?
◾ '선언되지 않은 인터페이스'로 무엇을 둘 것인가?
◾ 새로 만드는 타입이 얼마나 일반적인가? -> 새로운 클래스가 아닌 새로운 클래스 템플릿을 정의해야 할 수도 있다.
◾ 정말로 꼭 필요한 타입인가? -> 기능 몇개 추가하자고 파생 클래스를 새로 만들거라면 차라리 간단하게 비멤버 함수나 템플릿을 몇 개 더 정의하는 편이 낫다.
◾ 클래스 설계는 타입 설계입니다. 새로운 타입을 정의하기 전에, 이번 항목에 나온 모든 고려사항을 빠짐없이 점검해 보십시오.
20. '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다
기본적으로 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달' 방식을 사용한다.
클래스의 멤버 변수가 객체이고 또 그 객체의 멤버 변수가 객체라면 연속적으로 존재할수록 생성자가 연속적으로 호출된다.
객체 하나를 복사하기 위해 많은 생성자의 호출이 이루어진다. 소멸자도 마찬가지이다.
bool validateStudent(const Student& s);
상수객체 참조자에 의한 전달은 새로 만들어지는 객체가 없기 때문에 생성자와 소멸자가 전혀 호출되지 않는다.
또한 복사손실 문제(slicing problem)가 없어지는 장점도 있다.
class Window {
public:
...
std::string name() const;
virtual void display() const;
};
class WindowWithScrollBars : public Window {
public:
...
virtual void display() const;
};
void printNameAndDisplay(Window w) // 값에 의한 전달로 복사손실 발생
{
std::cout << w.name();
w.display();
}
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
display 함수가 가상 함수로 선언되었기 때문에 WindowWithScrollBars::display가 호출되어야 하지만, 값에 의한 전달로 인해 파생 클래스의 정보가 모두 잘려서 Window 객체로 생성되어 전달되므로 Window::display만 호출된다.
상수객체 참조자에 의한 전달로 문제를 해결할 수 있다.
타입 크기가 작다고 무조건 값에 의한 전달이 효율적인것은 아니다. 포인터 멤버가 가리키는 대상까지 모두 복사해야 할 수도 있기 때문이다.
객체의 크기와 복사 생성자가 비싸지 않게 만들어졌어도 기본제공 타입과 사용자 정의 타입을 다르게 취급하는 컴파일러들이 있기 때문에 성능이 다를 수 있다.
예를 들어 기본제공 타입인 double은 레지스터에 담기지만 double 하나로만 만들어진 객체는 레지스터에 담지 않는다. 이런 경우는 참조에 의한 전달이 낫다. 포인터는 반드시 레지스터에 담기기 때문이다.
또한 크기가 작더라도 사용자 정의 타입의 크기는 언제나 변할 수 있기 때문에 값에 의한 전달을 무조건 사용할 수 없다.
일반적으로 값에 의한 전달이 저비용이라고 가정해도 되는 것은 기본제공 타입, STL 반복자, 함수 객체 타입 3개 뿐이다.
이 외의 경우에는 상수객체 참조자에 의한 전달을 선택하도록 하자.
◾ '값에 의한 전달'보다는 '상수 객체 참조자에 의한 전달'을 선호합시다. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아줍니다.
◾ 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않습니다. 이들에 대해서는 '값에 의한 전달'이 더 적절합니다.
21. 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자
대체로 상수 객체 참조자에 의한 전달이 좋다고 해도 있지도 않은 객체의 참조자를 넘기는것은 좋지않다.
함수 수준에서 새로운 객체를 만드는 방법은 스택과 힙에 만드는 두 가지 뿐이다.
어떤 연산에 의한 결과를 반환할 때, 그 결과가 스택에 만들어진 경우 참조자로 반환하게 되면 함수 종료시 소멸하기 때문에 존재하지 않는 참조자를 반환하는 결과가 되어버린다.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result; // 결과값을 참조자로 반환하는 것까지는 좋은데 삭제는 누가?
}
설령 그 결과가 동적 할당으로 힙에 만들어졌다고 하더라도 참조자가 올바르게 반환될지언정 포인터에 접근할 방법이 없기 때문에 자원의 누수가 발생한다.
또한 스택 기반이든 힙 기반이든 반환되는 결과는 생성자를 반드시 한 번 이상 호출하게 된다. 그렇다고 정적 객체를 반환하는것은 어불성설이다.
새로운 객체를 반환해야 하는 함수를 작성하는 경우에는 새로운 객체를 반환하도록 하면 된다. 말장난 같겠지만 말 그대로이다.
// case#1
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
// case#2
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
Rational a(1,2);
Rational b(3,5);
Rational c = a*b; // 결과가 다를까?
case#1은 함수 내에서 생성자 한번, 밖에서 복사 생성자 한번 총 2번의 생성자가 호출된다.
하지만 case#2는 최적화 매커니즘에 의해 복사 생성자가 호출되지 않는다. (반환 값 최적화, RVO)
참조자를 반환할지 객체를 반환할지를 결정할 때 무엇을 선택하든 올바른 동작이 이루어지도록 만드는 것에 초점을 두어야 한다.
◾ 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 마세요.
22. 데이터 멤버가 선언될 곳은 private 영역임을 명심하자
데이터 멤버가 public이라면 문법적 일관성과 캡슐화가 깨진다.
예를 들어 데이터의 평균 값을 반환받는 멤버 함수를 만든다고 했을 때, 평균값을 담는 데이터 멤버를 클래스에 추가하거나 호출할때마다 평균 값을 반환하는 두 가지 방법이 존재한다.
성능상 이점에는 서로 차이가 있겠지만 멤버 함수를 호출한다는 공통점이 있다. 추후 수정사항이 발생하더라도 함수를 호출하는것은 변하지 않는것이 좋기 때문에 클래스의 불변속성을 유지하는데에 소홀해질수 없다.
하지만 데이터 멤버가 public이라면 사용자 코드가 깨질 수 있기 때문에 함부로 손을 대기가 더 어려워진다.
protected 데이터 멤버도 사실상 public과 같은 문제점을 안고있다. (의존성)
결과적으로 캡슐화의 관점에서 쓸모 있는 접근 수준은 캡슐화 제공이 가능한 private과 private이 아닌 나머지이다.
◾ 데이터 멤버는 private 멤버로 선언합시다. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐 아니라, 내부 구현의 융통성도 발휘할 수 있습니다.
◾ protected는 public보다 더 많이 '보호'받고 있는 것이 절대로 아닙니다.
23. 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자
캡슐화하는 것이 늘어나면 외부에서 볼 수 있는 것들이 줄어든다. 그만큼 변경할 때 필요한 유연성이 커지는 장점이 있다.
어떤 데이터를 접근하는 함수가 많으면 그 데이터의 캡슐화 정도는 낮아진다. 클래스에서 public으로 제공되는 멤버 함수가 많을수록 캡슐화의 정도가 낮아진다는 것이다.
그렇기 때문에 private 멤버에 접근할 수 있는 멤버 함수보다 비멤버/비프렌드 함수를 사용하는것이 캡슐화의 정도가 더 높아진다.
class WebBrowser {
public:
void clearCache();
void clearHistory();
void removeCookies();
void clearEveryThing(); // 위의 3개를 모두 호출
};
void clearBrowser(WebBrowser& wb) // 비멤버 비프렌드 함수
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
단순히 멤버 함수를 한번에 호출하는 함수라면 비멤버 비프렌드 함수가 캡슐화에 바람직하다.
단, 비멤버 비프렌드 함수 한정이다. 어쨌든 private 멤버의 캡슐화에 영향을 주지 않으면 된다.
위와 같은 코드를 같은 네임스페이스로 묶는것도 하나의 방법이다.
◾ 멤버 함수보다는 비멤버 비프렌드 함수를 자주 쓰도록 합시다. 캡슐화 정도가 높아지고, 패키징 유연성도 커지며, 기능적인 확장성도 늘어납니다.
24. 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자
class Rational {
public:
Rational(int numerator=0, int denominator=1);
const Rational operator*(const Rational& rhs) const;
};
Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // ok
result = 2 * oneHalf; // error
어떤 클래스의 생성자가 암시적 변환을 허용한다면 멤버 함수의 매개변수로 오는 값이 상수여도 암시적 변환을 통해 연산이 가능하다. 하지만 순서가 바뀌면 2라는 상수는 클래스와 연관이 없기 때문에 에러가 발생한다.
암시적 타입 변환에 대해 매개변수가 먹혀들려면 매개변수 리스트에 들어 있어야만 한다.
멤버 연산자 오버로딩을 호출하는 쪽은 왼쪽(*this)이고 반드시 명시적이어야만 한다.
class Rational {
...
};
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.getNumerator() * rhs.getNumerator(), lhs.getDenominator() * rhs.getDenominator());
}
Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // ok
result = 2 * oneHalf; // ok
하지만 비멤버 함수로 만들어준다면 모든 인자에 대해 암시적 타입 변환을 수행하도록 내버려둘수 있다.
멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수이다.
◾ 어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서)에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 합니다.
25. 예외를 던지지 않는 swap에 대한 지원도 생각해 보자
두 객체의 값을 맞바꾸기 한다는 것은 각자의 값을 상대방에게 주는 동작이다.
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)
{
....
*pImpl = *(rhs.pImpl);
}
private:
WidgetImpl *pImpl;
};
pimpl idiom(관용구)(pointer to implementation) 설계를 차용해서 클래스를 설계한 경우 값을 맞바꿀때는 내부의 pImpl 포인터만 서로 교환해주면 된다.
하지만 std::swap은 그런 사실을 모르기때문에 포인터가 가리키는 모든 것을 복사해서 오버헤드가 크게 발생한다.
namespace std {
template<> // 템플릿 특수화
void swap<Widget>(Widget& a, Widget& b)
{
swap(a.pImpl, b.pImpl);
}
}
함수 템플릿을 템플릿 특수화 시키면 Widget 타입에 한해서 위의 함수로 동작하게 된다.
물론 pImpl은 private이기 때문에 별도의 함수를 만들어서 사용해야 한다.
namespace std {
template<typename T>
void swap<Widget<T>>(Widget& a, Widget& b) // 부분 특수화 불가능
{
a.swap(b);
}
}
만약 Widget과 WidgetImpl이 클래스가 아닌 클래스 템플릿으로 만들어져 있어서 WidgetImpl에 저장된 데이터의 타입을 매개변수로 바꿀수 있다면 부분 특수화를 시켜야 하는데, 클래스 템플릿은 부분 특수화를 허용하는 반면 함수 템플릿은 부분 특수화를 허용하지 않는다.
함수 템플릿을 부분 특수화 시키고 싶다면 그냥 오버로딩을 시키면 된다.
단, 일반적으로 함수 템플릿은 오버로딩 해도 별 문제가 없지만 std 함수 템플릿의 오버로딩은 유효하지 않다.
그렇다고 아예 동작하지 않는것은 아니고 컴파일 및 실행은 이루어지지만 실행되는 결과가 미정의 사항이 되어버린다.
먼 길을 돌아왔지만 결론적으로 멤버의 swap 함수를 호출하는 비멤버 함수를 선언하되 std::swap의 특수화 버전이나 오버로딩으로 선언하지만 않으면 된다.
namespace WidgetStuff {
...
template<typename T>
class Widget { ... };
...
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
한가지 더, 사용자가 함수 템플릿 내부에서 swap을 호출할 때, 어떤 swap을 호출해야하는지 확신할 수 없는 경우에는 상황에 따라 swap을 호출할 수 있도록 해야한다.
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; // std::swap을 이 함수 안으로 끌어올 수 있도록 한다
swap(obj1, obj2); // T타입 전용의 swap 호출. 만약 없으면 std::swap이 호출된다
}
이름 탐색 규칙에 따라 전역 유효범위 또는 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지 찾은 뒤에 없으면 std::swap을 선택한다.
◾ std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공합시다. 이 멤버 swap은 예외를 던지지 않도록 만듭시다.
◾ 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공합니다. 클래스(템플릿이 아닌)에 대해서는, std::swap도 특수화해 둡시다.
◾ 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap을 호출합시다.
◾ 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능합니다. 그러나 std에 어떤 것이라도 새로 '추가'하려고 들지는 마십시오.
'도서 > Effective C++' 카테고리의 다른 글
6. 상속, 그리고 객체 지향 설계 (0) | 2022.12.04 |
---|---|
5. 구현 (0) | 2022.12.03 |
3. 자원 관리 (0) | 2022.11.30 |
2. 생성자, 소멸자 및 대입 연산자 (0) | 2022.11.30 |
1. C++에 왔으면 C++의 법을 따릅시다 (0) | 2022.11.27 |