5. 구현
26) 변수 정의는 최대한 늦추자
객체를 선언해 기본 생성한 후 대입하는 방법은 비효율적이다.
기본 생성자를 건너뛸 수 있다면 건너 뛰고 초기화 인자를 얻을 때까지 정의를 미루자.
루프에 대해서는 다음 두가지 선택지가 있다.
// A 방법
Widget w;
for(int i = 0; i < n; ++i)
{
w = Widget(i); // i에 따라 달라지는 값
}
// B 방법
for(int i = 0; i < n; ++i)
{
Widget w(i); // i에 따라 달라지는 값
}
A 방법은 생성자 1번 + 소멸자 1번 + 대입 n번
B 방법은 생성자 n번 + 소멸자 n번이 불린다.
만약 대입 비용이 생성자+소멸자 쌍보다 적게 드는 경우엔 A가 효율이 좋다.
하지만 그렇지 않을 때엔 B가 효율적일 수 있다.
단, A방법을 사용하면 w의 유효범위가 늘어나게 되므로 프로그램의 유지보수성이 떨어지게 될 수 있다.
따라서 대입이 비용이 적고, 수행 성능에 민감한 부분을 건드리는 중이 아니라면 B방법을 사용하자.
27) 캐스팅은 절약, 또 절약 !
C++ 스타일 캐스팅에는 네가지가 있다.
- const_cast<T>() : 객체의 상수성을 없애는 용도로 사용한다.
- dynamic_cast<T>() : 안전한 다운캐스팅을 할 때 사용한다. 주어진 객체가 어떤 클래스 상속 계통에 속했는지 아닌지를 검사할 때 사용할 수 있다. 또한 런타임 비용이 높다.
- reinterpret_cast<T>() : 포인터를 int로 바꾸는 등의 저수준 캐스팅을 위한 연산자로, 구현 환경에 결과가 의존적이므로 이식성이 없다. 최대한 지양해야 한다.
- static_cast<T>() : 암시적 변환을 강제로 진행할 때 사용한다. 상수 객체를 비상수 객체로 만들때는 사용할 수 없다.
C스타일의 구형 캐스팅은 최대한 쓰지 않는 것이 좋다.
또한 int를 double로 캐스팅하게 되면 둘의 표현구조가 완전히 다르기 때문에 내부적으로 추가적인 코드가 만들어진다.
또 C++의 상속에서는 다음 두 포인터가 다른 값을 가지게 되는 경우도 많다.
class Derived : public Base { ... };
Derived d;
Base *pb = &d;
객체 하나가 하나 이상의 주소를 가지게 될 수 있는 것이다.
따라서 주소를 *char 포인터로 변환해 포인터 산술 연산을 적용하는 등의 코드는 거의 항상 미정의 동작을 불러오게 된다. 한 플랫폼에서 올바르게 동작했다 하더라도 다른 플랫폼에서는 또 다르게 동작하는 경우도 많으니 절대절대 사용하지 말자.
또한 캐스팅이 포함된 다음과 같은 코드는 올바르게 동작하지 않는다.
class Window{
public:
virtual void onResize() { ... }
...
};
class DerivedWindow : public Window {
public:
virtual void onResize() {
static_cast<Window>(*this).onResize();
}
...
}
*this를 Window로 캐스팅하는 과정에서 기본 클래스 부분에 대한 사본이 임시로 만들어지기 때문에 사본에 대해 onResize를 호출하게 되는 셈이다.
따라서 다음과 같이 그냥 *this의 onResize를 호출하면 된다.
Window::onResize();
dynamic_cast 또한 피해야 할 연산자 중 하나다. 우선 굉장히 느리다.
일부 환경에서는 클래스 이름에 대한 비교로 만들어져 있기 때문에, strcmp가 불리게 된다.
만약 파생 클래스의 함수를 호출하고 싶은데 기본 클래스로의 포인터밖에 없는 상황에는 두가지 해결방법이 있다.
- 원하는 파생 클래스 포인터를 저장하는 컨테이너를 사용한다.
- 기본 클래스에 아무것도 하지 않는 파생 클래스의 가상함수 기본 구현을 제공한다.
28) 내부에서 사용하는 객체에 대한 핸들을 반환하는 코드는 되도록 피하자
두가지를 알아둘 필요가 있다.
- 클래스 데이터 멤버를 숨기더라도 그 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 결정된다.
- 어떤 객체에서 호출한 상수 멤버 함수의 참조자 반환 값의 실제 데이터가 그 객체의 바깥에 저장되어 있다면, 이 함수의 호출부에서 그 데이터의 수정이 가능하다.
- 이는 참조자 뿐만 아니라 포인터나 반복자에도 해당된다.
클래스의 캡슐화 정도, 내부 데이터의 접근 수준을 위해 핸들을 반환하는 경우는 절대로 없어야 하겠다.
단, Rectangle 클래스의 Point 객체와 같이 복사 비용은 줄이고 싶으나 접근 수준은 바꾸고 싶지 않을때, const 객체에 대한 참조자를 반환하도록 함으로써 문제를 쉽게 해결할 수 있다.
const Point& upperLeft() const { return pData->ulhc; }
그러나 이렇게 하더라도 핸들이 객체보다 오래 살아남는다면 댕글링 포인터 문제를 발생시킨다.
따라서 string이나 vector에서 []연산자와 같은 어지간한 경우가 아니라면 절대로 핸들을 반환하지 말자.
29) 예외 안정성을 중요시하자
예외 안정성을 보장하는 함수는 다음 세가지 보장 중 하나를 제공한다.
- 기본적인 보장 : 함수 동작 중에 예외가 발생하면, 실행중인 프로그램의 모든 것들을 유효한 상태로 유지한다. 메모리의 어떤 객체나 자료구조도 더럽혀지지 않고 일관성을 유지한다. 하지만 결과는 예측 불가능할 수도 있다.
- 강력한 보장 : 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않는다. 결과는 예측 가능하다.
- 예외 불가 보장 : 예외를 절대로 던지지 않는다. 약속한 동작을 언제나 끝까지 완수한다. 단, throw로 예외 지정이 된 함수라고 해서 꼭 예외불가 보장을 하지는 않는다.
예외 보장을 위해 다음과 같은 코드를 두가지 규칙을 가지고 바꾸어 보자.
class PrettyMenu{
public:
void changeBackground(std::istream& imgSrc);
private:
Mutex mutex;
Image *bgImage;
int imageChanges;
}
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex);
}
첫째, bgImage 데이터 멤버의 타입을 자원관리 전담 포인터 shared_ptr로 변경한다.
둘째, changeBackground 함수 내의 문장을 재배치해 배경 그림이 진짜로 바뀌기 전에는 imageChanges를 증가시키지 않도록 만든다.
class PrettyMenu{
std::shared_ptr<Image> bgImage;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock m1(&mutex);
bgImage.reset(new Image(imageSrc));
++imageChanges;
}
그러나 만약 Image 클래스의 생성자가 실행되다가 예외를 일으킨다면, 입력 스트림의 읽기 표시자가 이동한 채로 남아있을 가능성이 있다.
따라서 이 함수는 기본적인 보장만을 제공한다.
이 문제를 pimpl 관용구를 통해 근본적으로 해결해보자.
이 전략은 copy-and-swap으로 잘 알려져 있는데, 객체를 수정하고 싶으면 그 객체의 사본을 만들어 사본을 수정하는 것이다. 이렇게 하면 수정 중 예외가 발생하더라도 원본은 보존된다.
struct PMImpl{
std::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu{
private:
Mutex mutex;
std::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap;
Lock m1(&mutex);
// 객체의 데이터 부분 복사
std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
// 사본 수정
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
// swap
swap(pImpl, pNew);
}
그러나 이렇게 하더라도 사본을 수정하는 과정에서 보장하는 예외 안정성이 강력하지 못하면 또다른 문제가 발생한다.
어떻게 하더라도 side effect가 발생한다는 사실을 염두에 두고있는 것이 좋다.
또한 copy-and-swap 방식은 비효율적이므로, 실용적이지 못한 경우도 있다.
중요한 건, 언제나 예외에 안전한 코드를 짜도록 고민하는 버릇이다.
자원 관리 객체를 사용하는 것부터 시작하자.
30) 인라인 함수에 대해 완벽하게 이해해두자
인라인 함수는 함수처럼 보이고 함수처럼 동작하고 매크로보다 안전하고, 함수 호출 오버헤드도 없고, 컴파일러가 문맥별 최적화를 걸기도 용이해진다.
그러나 부작용도 있다. 실제로 코드가 함수의 본문으로 바꿔치기 되므로, 목적 코드의 크기가 커진다.
프로그램의 크기가 커지고, 페이징 횟수가 늘어나고, 캐시 적중률이 감소할 것이다.
만약 본문 길이가 아주 짧은 인라인 함수라면 코드 크기가 감소하여 목적 코드도 작아지고 캐시 히트율도 높아질 것이다.
inline 키워드를 붙인다고 해서 모든 함수가 인라인화 되는 것은 아니다. 이는 요청일 뿐 명령이 될 수 없다.
루프가 포함되어 있거나 재귀 함수, 가상 함수인 경우 절대 인라인화 될 수 없다.
생성자와 소멸자를 inline화 시키는 것은 좋지않은 생각이다.
inline을 붙이지 않아도 컴파일러가 스스로 인라인화 하는 경우도 있다.
또한 완벽한 인라인 조건을 갖추었더라도, 어떤 인라인 함수의 주소를 취하는 코드가 있으면 이 코드를 위해 컴파일러는 함수를 만들 수 밖에 없다.
인라인 요청에는 두가지 방법이 있는데, inline 키워드를 붙이는 것이 명시적 요청이고, 클래스 정의 안에 함수를 바로 정의하면 이 또한 암시적 인라인 요청이다.
인라인 함수와 템플릿의 선언이 헤깔리는 사람은 다음을 참고
- 인라인 함수는 헤더 파일에 들어있어야 한다. 왜냐하면 인라인화는 컴파일 도중에 수행되는데, 컴파일러가 함수의 형태를 알고 있어야 하기 때문이다.
- 템플릿은 대체로 헤더 파일에 포함되어 있다. 템플릿이 사용되는 부분에서 해당 템플릿을 인스턴스화 하려면 그것이 어떻게 생겼는가를 컴파일러가 알아야 하기 때문이다.
- 하지만 템플릿과 inline은 아무런 관계가 없다. 그냥 템플릿의 함수가 인라인이었으면 좋겠을 때 inline을 붙여줄 뿐이다.
마지막으로, 인라인 함수는 디버깅에 썩 친화적이지 못하다.
따라서, 좋은 전략은
- 아무것도 인라인하지 않는다.
- 혹은 매우 단순한 함수에 한해서만 인라인하라.
'C++ > Effective C++' 카테고리의 다른 글
Effective C++ (36) ~ (40) (0) | 2021.08.25 |
---|---|
Effective C++ (31) ~ (35) (0) | 2021.08.24 |
Effective C++ (21) ~ (25) (0) | 2021.08.16 |
Effective C++ (16) ~ (20) (0) | 2021.08.14 |
Effective C++ (11) ~ (15) (0) | 2021.08.12 |
댓글