[Effective C++] Chapter 4. 설계 및 선언
Table of Contents
이펙티브 C++ 책을 읽고 공부한 노트입니다.
Item 18: 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자 #
- 사용자가 실수할 만한 것들을 생각해보고 인터페이스를 설계하자
class Date
{
public:
Date(int month, int day, int year);
};
int main()
{
Date d(1, 30, 2023); // 저런, month와 day를 잘못 넣었다!
}
- Wrapper 타입을 만들어본다.
struct Day
{
int val
explicit Day(int v) : val(v)
};
// ... Month, Year ...
class Date
{
public:
Date(const Month& m, const Day& d, const Year& y);
};
int main()
{
Date d(1, 30, 2023); // 컴파일 에러
Date d(Month(1), Day(30), Year(2023); // 컴파일 에러
Date d(Day(30), Month(1), Year(2023); // Okay
Date d(Day(1), Month(30), Year(2023); // 악 안돼!
}
- 적절한 값들을 미리 준비해놓고 그것만 쓰도록 한다.
enum
은 타입 안정성이 좋지 못하므로 (int
로도 쓰일 수 있다) 유효한Month
의 집합을 미리 정의해 두면 좋겠다.
class Month
{
public:
// 객체가 아닌, 함수를 사용하였다.
// 객체가 초기화 되기 전에 사용되면 위험하기 때문이다 (Item 4)
static Month Jan() { return Month(1); }
static Month Feb() { return Month(1); }
//...
private:
// Month 값이 새로 생성되지 않도록
// 명시호출 생성자가 private 멤버이다.
explicit Month(int m);
};
int main()
{
Date d(Month::Jan(), Day(30), Year(2023)); // Good
}
- 사용자가 사용법을 제대로 외워야 사용할 수 있는 인터페이스는 잘못 사용하기 쉽다.
Investment * createInvestment();
// 사용자가 자원을 만들어 놓고 삭제하는 걸 까먹으면 어떻하지?
// Investment를 스마트 포인터로 관리해야겠다.
// 스마트 포인터 사용을 까먹으면 어떻하지?
// 차라리 스마트 포인터를 리턴하는 것이 좋겠다.
std::shared_ptr<Investment> createInvestment();
// 이렇게
- 만약에 스마트 포인터의 삭제자가 따로 있어서 미리 지정해 주어야 한다면?
- 그리고 스마트 포인터를 생성하는 시점에 실제 데이터를 알 수 없어서, 일단 널 포인터를 가리키는 스마트 포인터를 생성하고 싶다면?
// 삭제자
void getRidOfInvestment(Investment * inv);
std::shared_ptr<Investment> createInvestment()
{
// 삭제가가 묶인 스마트 포인터를 리턴하므로
// 이제 삭제 걱정도 안 해도 된다.
std::shared_ptr<Investment> retVal(0, getRidOfInvestment);
// (X) 이것은 널을 가리키는 스마트 포인터가 아니다.
// 0은 int이지 Investment* 타입이 아니다.
std::shared_ptr<Investment> retVal(static_cast<Investment *>(0), getRidOfInvestment);
// 캐스트를 활용해서(Item 27 참고) 널 포인트를 가진 스마트 포인터를 생성한다.
// 그리고 나중에 실제 데이터를 가리키도록 한다.
// retVal = ...
// 만약에 스마트 포인터를 생성하는 시점에 실제 데이터를 알 수 있다면, 생성자에 바로 넘겨주는게 좋겠다.
return retVal;
}
- 교차 DLL 문제(cross-DLL problem)
- 객체 생성할 때 사용한 동적 링크 라이브러리(dynamically linked library: DLL)의
new
와, 그 객체를 삭제할 때 사용한delete
의 DLL이 다른 경우에 발생한다.
- 객체 생성할 때 사용한 동적 링크 라이브러리(dynamically linked library: DLL)의
shared_ptr
는 가 교차 DLL 문제가 발생하지 않아서 좋다.
class Investment {};
class Stock : public Investment {};
std::shared_ptr<Investment> createInvestment()
{
return std::shared_ptr<Investment>(new Stock());
// Investment형 shared_ptr이어도,
// Stock으로 생성한 걸 잊지 않고 그 DLL의 delete를 호출해준다.
}
Item 19: 클래스 설계는 타입 설계와 똑같이 취급하자 #
- 클래스를 설계한다는 것은, 타입을 설계하는 것과 같다.
- 따라서 마치 언어 설계자가 그 언어의 기본제공 타입을 설계하면서 쏟는 정성과 보살핌이 필요하다.
- 생성과 소멸은 어떻게 이루어져야 하는가?
- 초기화와 대입이 어떻게 달라야 하는가?
- 값에 의해 객체가 전달되는 경우에 어떤 의미를 줄 것인가?
- 새로운 타입이 가질 수 있는 값을 어떻게 제한할 것인가?
- 기존의 클래스 상속 계통망(inheritance graph) 에 맞출 것인가?
- 어떤 종류의 타입 변환을 허용할 것인가? (암시적, 명시적)
- 어떤 연산자와 함수를 두어야 할까?
- 표준 함수들 중 어떤 것을 허용하지 말 것인가?
- 멤버에 대한 접근권한을 어느쪽에 줄 것인가?
- ‘선언되지 않은 인터페이스‘로 무엇을 둘 것인가?
- 새로 만드는 타입이 얼마나 일반적인가? (템플릿)
- 정말로 꼭 필요한 타입인가?
Item 20: ‘값에 의한 전달’보다는 ‘상수객체 참조자에 의한 전달’ 방식을 택하는 편이 대개 낫다 #
- 값에 의한 전달(pass-by-value)은 복사 생성자에 의해 사본을 만든다.
- 그래서 고비용 연산이 되기도 한다.
class Person {};
class Student : public Person {};
bool validateStudent(Student s);
int main()
{
Student plato;
validateStudent(plato);
// Student의 복사 생성자가 호출된다.
// Person 객체가 먼저 만들어진다.
// 만약에 멤버로 string 객체가 있다면?
// 그에 따른 생성자가 또 불릴 것이다.
// 삭제할 때는 생성한 만큼 더 불린다.
// 값에 의한 전달은 어마어마한 비용이 들게 되었다.
}
- 따라서 참조자로 전달하면 좋겠다.
bool validateStudent(const Student & s);
// const를 사용하므로써 변경을 방지한다.
- 참조자는 복사손실 문제(slicing problem) 를 막을 수도 있다.
class Window
{
public:
virtual void display() const;
};
class WindowWithScrollBars : public Window
{
public:
virtual void display() const;
};
void displayWindow(Window w)
// WindowWithScrollBars 객체가 Window 객체로 복사되면서
// WindowWithScrollBars의 부분이 싹둑 잘려나간다.
{
w.display();
// 이것은 Window::display()만 부를 것이다.
// 해결하려면 참조로 전달하면 된다.
}
int main()
{
WindowWithScrollBars wwsb;
displayWindow(wwsb);
}
Item 21: 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자 #
- 그렇다고 모든 코드를 참조에 의한 전달로 하는 것은 옳지 않다.
- 함수에서 객체를 반환해야 할 때를 살펴보자.
class Rational
{
private:
int n, d;
// 두 유리수를 곱한다.
friend const Rational operator*(const Rational& lhs, const Rational& rhs);
// 값으로 반환하게 되어 있다. 그럼 객체의 생성과 소멸에 대한 비용이 들겠다.
// 이것을 참조로 반환하면 비용이 덜 들지 않을까?
public:
Rational(int numerator = 0, int denominator = 1);
};
- 객체에 대한 참조자를 반환할 수 있으려면, 그 전에 객체를 생성해야 하는 건 마찬가지이다.
- 객체는 스택이나 혹은 힙에 만들 수 있다.
- 그럼 스택에 만들어보자.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
// 이것은 지역객체이다.
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
} // 블록이 끝나면 사라져버린다.
// 따라서 반환된 참조는 필요없는 메모리 뭉치가 된다.
- 그럼 힙에 만들어보자.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational * result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
// 근데 이 객체는 누가 delete 해주지?
}
int main()
{
Rational w, x, y, z;
w = x * y * z;
// operator* 호출이 두 번 일어나므로 new에 맞춰서 delete도 두 번 해줘야 한다.
// 하지만 반환되는 참조자에 대한 포인터를 알 수 있는 방법이 없다.
}
- 정적 객체로 만들어 놓고 그것의 참조자를 반환하면 어떨까?
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
static Rational result; // 정적 객체를 반환한다.
// result = ...;
return result;
}
bool operator==(const Rational& lhs, const Rational& rhs);
int main()
{
Rational a, b, c, d;
if ((a * b) == (c * d)) // 이것은 늘 true가 되버린다.
// ...
}
- 따라서 함수에서 객체를 반환할 때는, 새로운 객체를 만들어서 값으로 반환해야하겠다.
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
Item 22: 데이터 멤버가 선언될 곳은 private
영역임을 명심하자 #
- 데이터 멤버는
private
로 하자. 그 이유는…- 일관성 있는 인터페이스를 제공할 수 있다.
- 필요에 따라 접근 제어가 세밀하게 가능하다.
- 캡슐화(encapsulation) 를 함으로써, 클래스의 불변속성을 강화할 수 있고, 내부 구현을 융통성 있게 바꿀 수 있다.
protected
는 어떨까?- 어떤 데이터 멤버를
protected
로 했다가 제거한다고 치자.. - 그러면 그 멤버를 사용하던 파생 클래스에 해당하는 코드를 전부 고쳐야 할 것이다.
protected
는public
보다 더 많이 보호 받고 있다는 것이 절대 아니다.
- 어떤 데이터 멤버를
Item 23: 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자 #
class WebBrowser
{
public:
void clearCache();
void clearHistory();
void removeCookies();
// 위의 세가지를 한 번에 하는 함수를 만들고 싶다.
// 이것을 멤버 함수로 제공할 것인가?
void clearEverything();
};
// 아니면 비멤버 비프렌드 함수로 제공할 것인가?
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
- 멤버 함수 말고 비멤버 비프렌드 함수를 쓰도록 하자.
- 비멤버 비프렌드 함수는
private
멤버에 접근할 수 없다. 따라서 캡슐화 정도가 더 높다고 볼 수 있다.
- 비멤버 비프렌드 함수는
- 컴파일 의존성을 줄일 수 있다.
- 멤버 함수는 반드시 하나의 클래스가 통으로 정의되어야 한다.
- 하지만 비멤버 비프렌드 함수들은 같은 네임스페이스 안에 둔 다음, 쓰임새에 따라 각기 다른 헤더파일에 몰어서 선언할 수 있다.
- 그러면 사용자는 실제로 사용하는 구성요소에 대해서만 컴파일 의존성을 고려할 수 있게 된다.
- 또한 확장도 쉬워진다.
- 새로운 함수를 만들 때는 헤더 파일을 하나 더 만들어서 정의를 추가하면 된다.
// "webbrowser.h"
namespace WebBrowserStuff // 같은 네임스페이스 안에 둔다.
{
// 핵심 관련 기능들...
class WebBrowser {};
void clearBrowser(WebBrowser& wb);
}
// "webbrowserbookmarks.h"
namespace WebBrowserStuff
{
// 즐겨 찾기 관련 편의 함수들...
}
// "webbroswercookies.h"
namespace WebBrowserStuff
{
// 쿠기 관련 평의 함수들...
}
Item 24: 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자 #
- 유리수 클래스를 만들었는데,
int
형 등과 함께 혼합형(mixed-mode) 수치 연산이 가능하게 하고 싶다.
class Rational
{
public:
// 생성자를 explicit(명시 호출)로 선언하지 않았다.
// 따라서 암시적 타입 변환이 가능해진다.
Rational(int numerator = 0, int denominator = 1);
int numerator();
int denominator();
// operator*를 멤버 함수로 두었다.
const Rational operator*(const Rational& rhs) const;
};
int main()
{
Rational oneEighth(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth;
// 암시적 타입 변환으로 2가 Rational 객체가 되었기에 가능하다
result = oneHalf * 2; // oneHalf.operator*(2)
// 컴파일 에러!
result = 2 * oneHalf; // 2.operator*(oneHalf)
}
- 컴파일러는 암시적 타입 변환(implicit type conversion)을 했다.
Rational
생성자에int
를 넘겨서int
를Rational
클래스로 둔갑시켰다.- 이것은 생성자에
explicit
선언이 되지 않았기에 가능하다. - 컴파일 에러 부분을 해결하기 위해
operator*
를 비멤버 함수로 만들면 어떨까?
class Rational {};
// 비멤버 함수로 두었다.
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
int main()
{
Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // OK
result = 2 * oneHalf; // OK!
}
- 멤버 함수의 반대는 비멤버 함수이다.
friend
함수가 아니다.- 위
operator*
는Rational
의public
인터페이스만 써서 구현할 수 있으므로,friend
일 필요가 없다.
- 위
Item 25: 예외를 던지지 않는 swap
에 대한 지원도 생각해 보자 #
- 표준에서 제공하는
swap()
은 복사만 제대로 지원하는(복사 생성자, 복사 대입 연산자를 통해) 타입이기만 하면 어떤 타입의 객체이든 맞바꾸기 동작을 수행해 준다.
namespace std
{
template<typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
}
클래스를 위한 std::swap()
의 특수화 #
- 포인터가 주성분인 객체는 굳이 데이터까지 복사하지 않고 포인터만 맞바꾸는게 비용이 적게 들 것이다.
- 이 때,
std::swap
을 특수화(specialize) 하면 되겠다.
- 이 때,
class WidgetImpl
{
private:
int a, b, c;
std::vector<double> v; // 복사 비용이 높겠다.
};
class Widget
{
private:
// 포인터가 주성분이다.
WidgetImpl * pImpl;
public:
// 복사 생성자와 복사 대입 연산자를 제공한다.
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)
{
*pImpl = *(rhs.pImpl);
// ...
}
void swap(Widget& other)
{
using std::swap;
// 객제가 아니라 포인터를 맞바꾼다.
swap(pImpl, other.pImpl);
}
};
namespace std
{
// 명시적 특수화 :
// 다른 swap 템플릿을 사용하지 말고, 주어진 형에 맞게 특별히 명시적으로 정의된 이 함수 정의를 사용해라.
template<>
void swap<Widget>(Widget& a, Widget& b)
{
// Widget에서 swap()을 제공한다.
// 굳이 freind로 해서 pImpl에 직접 접근할 필요가 없다.
a.swap(b);
}
}
클래스 템플릿을 위한 비멤버 swap()
#
- 만약
Widget
과WidgetImpl
이 클래스 템플릿으로 되어있다면?std::swap
을 부분적인 특수화(partial specialization) 해야 하겠다.
- 하지만 C++은 클래스 템플릿에 대해서는 부분적 특수화를 허용하지만, 함수 템플릿(
std::swap
)에 대해서는 허용하지 않는다.
// 클래스 템플릿 -> std::swap의 부분 특수화가 필요해졌다.
template<typename T>
class WidgetImpl {};
template<typename T>
class Widget {};
namespace std
{
// (X) C++은 함수 템플릿의 부분 특수화는 허용하지 않는다.
template<typename T>
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b)
{
a.swap(b);
}
}
- 함수 템플릿을 부분 특수화하고 싶을 때는 보통 오버로드 버전을 하나 추가한다.
- 하지만
std
네임스페이스의 경우 조금 특별해서, 이 안에서 완전 특수화는 OK지만, 새로운 템플릿을 추가하는 건 NOT OK이다.
- 하지만
- 따라서 비멤버
swap
을 선언하되,std::swap
의 특수화 버전이나 오버로딩 버전으로 선언하지 않으면 된다.
namespace WidgetStuff // std가 아니므로 오버로딩 버전이 아니다.
{
template<typename T>
class Widget {};
// 비멤버 swap
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
// swap 뒤에 <...>가 없으므로 특수화 버전이 아니다.
{
a.swap(b);
}
}
사용자 입장에서 swap
사용하는 법 #
- 위에서 본 것처럼
swap
을 만들 었다면, 사용자는 어떻게 사용해야 좋을까?
template<typename T>
void doSomething(T& obj1, T& obj2)
{
swap(obj1, obj2);
// 어떤 버전의 swap을 호출해야 할까?
// (1) T 타입 전용 버전
// (2) std::swap을 특수화한 버전
// (3) std::swap
}
- 순서대로 살펴보기를 원한다면 함수 내
using
선언을 포함시켜서 해당 함수가std::swap
을 볼 수 있게 한다.
template<typename T>
void doSomething(T& obj1, T& obj2)
{
using std::swap; // std::swap을 이 함수 안으로 끌어온다.
swap(obj1, obj2);
}
- 그러면 C++의 이름 탐색 규칙(인자 기반 탐색: argument-dependent lookup 혹은 쾨니그 탐색: Koenig lookup) 에 따라서,
- (1) 전역 유효범위 or 타입
T
와 동일함 네임스페이스 안에,T
전용의swap
이 있는지 찾는다. - (2) 없다면
std::swap
의 특수화 버전을 찾는다. - (3) 없다면
std::swap
을 사용한다.
- (1) 전역 유효범위 or 타입