[C/C++] Effective C++
1. 좋은 인터페이스가 되도록 항상 연구하자
2. 인터페이스의 올바른 사용을 이끄는 방법
- 인터페이스 사이의 일관성 잡아주기 : 여러 언어에서 size(), length(), count()등은 크기를 의미하는데 이런 의미의 일관성을 말함.
- 기본제공 타입과의 동작 호환성 유지하기 : 사용자 정의 타입(struct, class)을 int형을 이용해 만들었다면 사용자 정의 타입도 int형 처럼 똑같이 쓸 수 있게 하자.
3. 사용자 실수 방지법
- 새로운 타입 만들기 : 사용자 정의 타입을 만들어 잘못된 값이 들어가지 않게 방지
- 타입에 대한 연산을 제한하기 : const를 붙여 함수 인자에 연산을 못하도록 한다.
==> if( a * b = c ) ...
int operator * () const { ... }
- 자원 관리 작업을 자동화 하기 : tr1::shared_ptr을 쓰자.
4. tr1::shared_ptr은 사용자 정의 삭제자 지원
- 교차 DLL문제(cross-DLL problem) 방지 : tr1::shared_ptr은 동일한 DLL에서 delete를 호출할수 있도록 되어있음.
- 뮤텍스 등을 자동 잠금 해제
항목 19 : 클래스 설계는 타입 설계와 똑같이 취급하자
좋은타입 = 문법이 자연스럽다, 의미구조가 직관적이다, 효율적인 구현이 되어있다.
1. 생성자, 소멸자에서 처리되어야 할것들 고려
2. 생성자와 대입연산자의 차이를 적절하게 구현 - 객체 초기화와 객체 대입에 대한 적절한 구현 (항목 4)
3. 객체가 값에 의해 전달되는 경우의 의미 정의 - 복사 생성자, 연산자 오버로딩
4. 값의 범위 고려
5. 상속 구조 고려 - 상속한다면 가상 함수 고려 (항목 7)
6. 타입 변환 고려 - (CType)AA, CType BB(AA), AA.c_str() - 명시적으로 함수이름을 정의했다면 타입변환연산자와 비명시호출 생성자는 만들지 말아야 한다.
7. 적절한 연산자와 메소드 정의 (항목 23, 24, 46)
8. 정의하지 않을시 컴파일러가 만들어내는 표준함수 제한 고려 - 자동으로 처리되는 부분은 가능하면 private로 막는다. (항목 6)
9. private, public, protected, friend 고려
10. 수행 성능, 예외 안전성, 자원 사용에 대한 제약 설정 고려 (항목 29)
11. 템플릿으로 만들지 고려
12. 기존 클래스에 기능몇개 추가라면 새로만들지 말고 비멤버 함수나 템플릿으로 처리 고려
항목 20 : '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다
2. 복사손실이 없다. - 복사손실 문제(slicing problem)
public:
std::string name() const;
virtual void display() const;
class WindowWithScrollBars : public Window {
public:
virtual void display() const;
복사손실 문제 발생
{
w.display();
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
컴파일러 중에는 기본제공 타입과 사용자 정의 타입의 하부 표현구조가 같아도 다르게 취급하는 것들이 있다. 진짜 double은 레지스터에 넣지만 double하나로만 만들어진 객체라도 레지스터에 넣지 않는다. 그러므로 참조에 의한 전달을 쓰는 것이 좋다.(포인터는 레지스터에 확실히 들어간다.)
3. 변화 - 시간이 지나면 사용자 정의 타입은 변화가 생길 가능성이 있다. 이 때 값에 의한 전달이라면 복사에 많은 손실을 본다.
항목 21 : 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자
원하는 결과
Rational b(3, 5); // b = 3 / 5
잘못된 예
{
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;
}
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
static Rational result;
result = ...;
return result;
}
새로운 객체를 반환해야 하는 함수를 작성하는 방법에는 정도가 있다고 책에서 예시
{
return Rational(lhs.n * rhs.n, lhs.d, rhs.d);
}
항목 22 : 데이터 멤버가 선언될 곳은 private 영역임을 명심하자
멤버변수는 무조건 private로 써야하는 이유
1. 문법적 일관성 - 어떤때는 변수 어떤때는 함수 할거 없이 항상 함수로 접근
2. 테이터 멤버의 접근성에 대해 훨씬 정교한 제어 가능 - 접근 권한을 함수에 구현함으로 멤버변수 접근 권한을 제어
3. 캡슐화
항목 23 : 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자
아래와 같은 class가 있을 때
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
멤버 함수
public:
...
void clearEverything(); // clearCache, clearHistory, removeCookies를 호출
...
};
비멤버 함수
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
어느 쪽이 더 괜찮을까요? 멤버 버전인 clearEverything일까요, 아니면 비멤버 버전인 clearBrowser일까요?
비멤버 함수 장점
1. 패키징 유연성(packaging flexibility)이 높아지는 장점 - header를 나눔으로써 각각 기능별로 구분
2. 컴파일 의존도 낮춤 - header를 나눔으로써 컴파일 효율 향상
3. WebBrowser의 확장성 향상 - 패키징 유연성에 의해 편의 함수 확장이 쉬움
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
webbrowserbookmarks.h // 즐겨찾기 관련 편의 함수들
namespace WebBrowserStuff {
...
}
webbrowsercookies.h // 쿠키 관련 편의 함수들
namespace WebBrowserStuff {
...
}
...
항목 24 : 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자
* 어떤 함수에 들어가는 모든 매개변수에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 한다.(this포인터 가리키는 객체도 포함)
클래스에서 암시적 타입 변환을 지원하는 것은 좋지 않다. (숫자타입은 예외)
public:
// 생성자에 일부러 explicit를 붙이지 않았습니다. int에서 Rational로의 암시적 변환을 허용하기 위해
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
const Rational operator*(const Rational& rhs) const;
private:
...
};
에러가 나는 경우
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEighth; // 성공
result = result * oneEighth; // 성공
result = 2 * oneHalf; // 에러
result = 2.operator*(oneHalf); // 에러
result = oneHalf * temp; // oneHalf.operator*(temp);와 같음
result = 2 * oneHalf; // 에러
result = 2 * oneHalf; // 컴파일 안됨
아래와 같이 비멤버 함수로 처리할 수 있다.
...
};
// 비멤버 함수
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
result Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
Rational result;
result = oneFourth * 2; // 성공
result = 2 * oneFourth; // 성공
항목 25 : 예외를 던지지 않는 swap에 대한 지원도 생각해 보자
swap의 기본형
template<typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
}
복사를 하면 손해를 보는 pimpl(point to implementation)
public:
...
private:
int a, b, c;
std::vector<double> v;
...
};
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) // pImpl의 포인터만 복사함으로 빠르다
{
...
*pImpl = *(rhs.pImpl);
...
}
...
private:
WidgetImpl *pImpl;
};
표준 swap함수에 Widget class일때는 다른 함수를 쓰도록 설정
public: // swap을 public으로 설정해야 함
...
void swap(Widget& other)
{
using std::swap;
swap(pImpl, other.pImpl);
}
};
namespace std {
template<> // 컴파일러에게 std::swap의 완전 템플릿 특수화(total template specialization)함수임을 알림
void swap<Widget>(Widget& a, Widget& b)
{
a.swap(b);
}
}
Widget class를 템플릿으로 만든다면 표준 swap과 같이 쓸수 없다. (C++는 클래스 템플식에 대해서는 부분 특수화(partial specialization)를 허용하지만 함수 템플릿에 대해서는 허용하지 않는다.)
class WidgetImpl { ... };
template<typename T>
class Widget { ... };
namespace std {
template<typename T>
void swap<Widget<T>>(Widget<T>& a, Widget<T>& b) // 에러
{
a.swap(b);
}
}
일반적으로 함수 템플릿의 오버로딩은 해도 문제 없지만, std는 특별한 네임스페이스이기 때문에 허용되지 않는다.
(std 내의 템플릿에 대한 완전 특수화는 OK, std에 새로운 템플릿을 추가하는것은 않된다. 클래스, 함수 모두 않됨)
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{ a.swap(p); }
}
위와 같이 std 네임스페이스에는 않되지만 다른 네임스페이스를 정의하면 된다.
template<typename T>
void swap(Widget<T>& a, Widget<T>& b)
{ a.swap(p); }
}
std에 있는 swap, std에 특수화한 swap, T타입의 swap이 있을 때 어떤 swap을 호출할지 설정
void doSomething(T& obj1, T& obj2)
{
using std::swap; // std에 특후화한 swap가 있으면 이것이 먼저 불려짐, 이 code가 없다면 T타입이 우선 불려짐
...
swap(obj1, obj2);
...
}
한정자를 잘못 붙이지 말라
강력한 예외 안전성 보장(strong exception-safety guarantee)
어떤 연산이 실행되다가 예외가 발생되면 그 연산이 시작되기 전의 상태로 돌릴 수 있다는 보장
항목 26 : 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자
변수를 정의하면 생기는 비용
1. 프로그램 제어 흐름이 변수의 정의에 닿을 때 생성자가 호출되는 비용
2. 변수가 유효범위를 벗어날 때 소멸자가 호출되는 비용
예외 발생시 이용되지 않는 경우
{
using namespace std;
string encrypted; // 예외 발생시 전혀 이용되지 않는다.
if(password.length() < MinimumPasswordLength) {
thro logic_error("Password is too short");
}
...
return encrypted;
}
예외 발생시 이용되지 않는 경우 변수 정의를 늦추는 예
{
using namespace std;
if(password.length() < MinimumPasswordLength) {
thro logic_error("Password is too short");
}
string encrypted; // 변수 정의 늦춤
...
return encrypted;
}
변수를 쓰기 직전에 생성
{
...
encrypted = password;
encrypt(encrypted);
}
루프에서
for(int i = 0; i < n; ++ i) {
w = i;
...
}
B방법. 루프 안쪽에 정의
Widget w(i);
...
}
A방법 : 생성자 1번 + 소멸자 1번 + 대입 n번
B방법 : 생성자 n번 + 소멸자 n번
①대입이 생성자-소멸자 쌍보다 비용이 덜 들고, ②전체 코드에서 수행 성능에 민감한 부분을 건드리는 중이면 B방법을 써라
항목 27 : 캐스팅은 절약, 또 절약! 잊지 말자
C++ 동작 규칙 : 어떤 일이 있어도 타입 에러가 생기지 않도록 보장
C++에서 위 규칙에 반하는 것이 cast
C스타일의 캐스트
T(표현식)
C++스타일의 캐스트
dynamic_cast<T>(표현식)
reinterpret_cast<T>(표현식)
static_cast<T>(표현식)
const_cast : 객체의 상수성을 없애는 용도
dynamic_cast : 안전한 다운캐스팅(safe downcasting), 객체가 어떤 클래스 상속 계통에 속한 타입인지 확인하는 작업
reinterpret_cast : 포인터를 int로 바꾸는 등의 용도(지원되지 않는 컴파일러 있음)
static_cast : 암시적 변환(비상수 객체 -> 상수 객체, int -> double, ...)을 강제로 수행할 때, 타입변환을 거꾸로 수행하는 용도(void* -> 일반 타입 포인터, 기본 클래스 포인터 -> 파생 클래스 포인터, ...)
C++스타일의 캐스트를 쓰는것이 좋다.
1. 코드를 읽을 때 알아보기 쉽다.(C++의 타입 시스템이 망가 졌는지를 찾기 쉽다.)
2. 캐스트를 사용한 목적을 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러를 진단할 수 있다.
캐스팅은 어떤 타입을 다른 타입으로 처리하는 것 뿐아니다.
타입 변환에 의해서 런타임에 실행되는 코드가 만들어지는 경우가 적지 않다.
...
double d = static_cast<double>(x) / y;
class Derived : public Base { ... };
Derived d;
Base *pb = &d;
C++에서는 다중 상속이 사용되면 이런 현상이 항상 생기지만, 단일 상속에서도 생기는 경우가 있다.
C++에서는 데이터가 어떤 식으로 메모리에 박혀 있을 거라는 섣부른 가정을 피해야 한다.
즉, 어떤 객체의 주소를 char*포인터로 바꿔서 포인터 산술 연산을 적용하는 등의 코드는 미정의 동작을 낳을 수 있다.
객체의 메모리 배치구조를 결정하는 방법과 객체의 주소를 계산하는 방법은 컴파일러마다 천차만별(어떤 플랫폼에서는 되는것이 다른 플랫폼에서 안될수 있다.)
캐스팅을 하면 보기엔 맞는것 같지만 실제로는 틀린 코드를 쓰고도 모르는 경우가 많다.
public:
virtual void onResize() { ... }
...
};
class SpecialWindow : public Window {
public:
virtual void onResize() {
static_cast<Window>(*this).onResize();
...
}
...
};
이렇게 하면 문제 없다.
public:
virtual void onResize() {
Window::onResize();
...
}
...
};
dynamic_cast (p.192)
- 클래스 이름에 대한 문자열 비교 연산에 기반을 두어 만들어 졌다.(Metrowerks CodeWarrior의 C++컴파일러가 strcmp()를 쓰는것으로 알려져 있다.)
- 깊이가 4인 단일 상속에 dynamic_cast를 쓰면 strcmp가 최대 4번 불린다.
항목 28 : 내부에서 사용하는 객체에 대한 '핸들'을 반환하는 코드는 되도록 피하자
내부요소에 대한 핸들을 반환하는 것은 캡슐화 정도를 떨어뜨린다.
핸들을 반환한다면 const를 써서 상수성을 유지시킨다.
무효참조 핸들(dangling handle) - 핸들이 있기는 하지만 그 핸들을 따라갔을 때 실제 객체의 데이터가 없는 것
항목 29 : 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!
2. 자료구조가 더럽혀지지 않게 한다.(예외 발생시 원본 데이터 유지)
예외 안전성을 갖춘 함수
2. 강력한 보장(strong guarantee) : 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장(호출성공은 완벽한 성공, 호출 실패는 함수 호출이 없었던 것과 같은 상태)
3. 예외불가 보장(nothrow guarantee) : 예외를 절대로 던지지 않겠다는 보장(끝까지 완료하는 함수 - 기본제공 타임인 int, pointer, ... 는 예외를 던지지 않는다.)
강력한 보장을 제공하는 방법 (p.205)
2. 잘짜라
3. 사본을 처리하고 성공적인 결과를 얻으면 그때 사본과 원본을 swap을 이용해서 바꿔라.(복사에 걸리는 시간 감수)
항목 30 : 인라인 함수는 미주알고주알 따져서 이해해 두자
단점
2. 메모리가 제한된 컴퓨터에서 잘못 쓰면 문제가 될 수 있다.
3. 가상 메모리를 쓰는 환경이라도 성능에 걸림돌이 된다.(페이징 횟수가 늘어나고, 명령어 캐시 적중률이 떨어질 가능성이 높다.)
장점
컴파일러가 인라인 함수의 본문에 대해 코드를 만드는 경우가 있다.
void (*pf)() = f;
...
f(); // 인라인 된다.
pf(); // 인라인 되지 않아 함수 본문을 만들게 된다.
생성자와 소멸자는 인라인하기에 좋지 않은 함수
항목 31 : 파일 사이의 컴파일 의존성을 최대로 줄이자
파일간 의존성이 있으면 코드가 변경될 때 의존성이 있는 모든 곳이 컴파일 된다. (p.218)
컴파일 시간을 단축하려면
- 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만든다.
- 선언부와 정의부에 대해 별도의 헤더 파일을 제공합니다.
- 핸들 클래스 활용( pimpl 관용구(pointer to implementation) )
- 인터페이스 클래스(Interface class) 활용
핸들 클래스 단점
2. 멤버 함수를 호출하면 구현부 객체의 데이터까지 가기 위해 포인터를 타야 한다. 즉 접근할 때 마다 연산이 있다.
3. 객체 하나씩을 저장하는데 필요한 메모리 크기에 구현부 포인터의 크기가 더해진다.
4. 구현부 포인터가 동적 할당된 구현부 객체를 가리키도록 구현부 포인터의 초기화가 일어나야 한다.(동적 메모리 할당에 따른 연산 오버해드, bad_alloc 예외 가능성)
인터페이스 클래스 단점
2. 함수 호출이 일어날 때마다 가상 테이블 점프에 따르는 비용
개발중에는 핸들 클래스 혹은 인터페이스 클래스를 사용 하라.(구현부가 바뀌었을 때 사용자에게 미칠 파급 효과를 최소화)
실행 속도, 파일 크기 등은 제품을 출시할 때 고민 해라.