Chapter 5. Implementations
26. Postpone variable definitions as long as possible
생성자와 소멸자를 끌고 다니는 타입으로 변수를 정의하면 프로그램 제어 흐름이 변수의 정의에 닿을 때 생성자가 호출되고 유효범위를 벗어날 때 소멸자가 호출됩니다. 따라서 변수 선언을 신중하게 해야합니다. 변수를 너무 빠르게 선언하면은 쓰이지 않는 경우에도 생성자, 소멸자를 모두 호출시키기에 비용이 비싸집니다. 따라서 변수가 진짜로 필요해질 떄까지 선언을 늦추는 것이 좋습니다. 또한 새로운 변수를 한번에 복사 생성하지 않고 기본 생성자 호출 후 대입 할당을 해주는 경우도 존재합니다. 이는 함수를 두번 호출 하기에 바꿔줄 필요가 있습니다.
std::string password = "1234";
std::string str1; // default constructor
str1 = password; // copy assignment
std::string str2(password) // copy constructor
만약 for 문에 대해서 for 문 밖에서 변수를 선언하는 경우는 생성자 1번 + 소멸 1번 + 대입 n번으로 끝나지만, 안에서 선언하는 경우는 생성자 n번 + 소멸자 n번으로 비용이 커집니다. 이는 생성 비용이 클 때 더욱 심한데, 하지만 비용이 크지 않은 경우는 프로그램 이해도와 유지 보수성을 위해서 안에 선언하는 것도 좋습니다.
이것만은 잊지 말자!
- 변수 정의는 늦출 수 있을 때까지 늦춥시다. 프로그램이 더 깔끔해지며 효율도 좋아집니다.
27. Minimize casting
먼저 casting에는 4가지 방법이 존재합니다.
[const_cast]
객체의 상수성을 없애는 용도로 사용됩니다.
[dynamic_cast]
안전한 다운캐스팅을 할 때 사용하는 연산자입니다. 즉, 주어진 객체가 상속 계통에 속한 특정 타입인지 아닌지를 결정할 수 있기도 합니다.
[reinterpret_cast]
포인터를 int로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자로서, 구현 결과는 구현 환경에 의존적입니다. 이 코드는 사실상 잘 안쓰입니다.
[static_cast]
암시적 변환을 강제로 진행할 때 사용합니다.
구형 스타일의 캐스트는 적법하게 사용될 수 있지만, C++ 스타일의 캐스트를 쓰는 것이 코드를 읽을 때 알아보기 쉬우며, 캐스트를 사용한 목적을 좀 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러 진단하기가 좋습니다.
캐스팅을 그냥 어떤 타입을 다른 타입으로 처리하는 것으로만 알고 있으면 이것을 옳지 않습니다. 잘못된 캐스팅에 의해 런타임에러가 발생할 수도 있습니다.
class Base {...};
class Derived : public Base {...};
Derived d;
Base *pb = &d;
다음과 같이 d의 포인터를 pb로 다운 캐스팅했을 때, 포인터의 값은 같지 않게 될 수도 있습니다. 이는 객체 하나가 가질 수 있는 주소가 하나가 아닌 여러 개(Base *에 대한 주소, Derived *에 대한 주소)가 될 수 있기에 이는 위험합니다. 다중 상속이 되면 이러한 일은 계속 생기게 됩니다. 따라서 어떤 객체의 주소를 포인터로 바꿔서 포인터 산술 연산을 적을 경우에는 밎어의 동작을 낳을 수도 있습니다. 또한 이러한 캐스팅은 잘못된 판단을 낳을 수도 있습니다.
class Window {
public:
virtual void onResize() {....}
....
};
class SpecialWindow : public Window {
public :
virtual void onResize()[ {
static_cast<Window>(*this).onResize(); --- (1)
Window::onResize(); --- (2)
....
}
};
(1)과 같이 현재 객체를 캐스팅의 값은 this의 Window 값이 아닌 새로운 복사된 객체의 onResize를 불러오게 됩니다. 따라서 이 코드는 예상대로는 동작하지 않게 됩니다. (2)와 같이 Window namespace에서의 onResize를 호출하는 것이 바람직한 코드입니다.
dynamic_cast는 연산이 상당히 느리기 때문에 최대한 불러오지 않는 것이 좋습니다. 클래스 이름을 비교하기 위해 strcmp를 호출한다던가 클래스 확인을 위한 연산이 상당히 들어갑니다. 하지만 파생 클래스 객체임이 확실한 경우에 대해서 기본 클래스에서 파생 클래스로 바꿔야 하는 경우가 존재합니다. 이번에는 이를 피해가는 법에 얘기하고자 합니다.
- 파생 클래스에만 함수가 존재하는 경우 : dynamic_cast가 아닌 부모 클래스에도 해당 함수를 가상으로 추가하고 이를 부모 클래스 포인터에서 부르면 가상 함수 테이블을 거쳐 파생 클래스 함수가 실행됨
- 폭포식(cascading) dygnamic_cast를 통한 부모클래스 포인터에서 파생된 자식 클래스를 확인하는 경우 : 상당히 느리고 에러가 발생하기 쉽기 때문에 가능하다면 다른 코드로 바꾸거나 템플릿 특수화를 통해서 해결 가능.
따라서 이와 같은 캐스팅에는 문제가 존재하기 때문에 캐스팅을 최대한 사용하지 않고 필요한 부분에 대해서만 사용하도록 합니다. 또한 캐스팅을 최대한 격리 시키기 위해서 함수 속에 물아 놓고, 이 함수의 동작 방식에 대해서 외부에서 알 수 없도록 인터페이스를 막아두는 것이 좋습니다.
이것만은 잊지 말자!
- 다른 방법이 가능하다면 캐스팅을 피하십시오.
- 캐스팅이 어쩔 수 없다면 함수 안에 숨길 수 있도록 해 보십시오.
- 구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호하십시오. 발견하기도 쉽고, 설계자의 의도가 더 잘 드러납니다.
28. Avoid returning “handles” to object internals
class Point {
public:
Point(int x, int y);
void setX(int newVal);
void setY(int newVal);
,,,
};
struct RectData {
Point ulhc;
Point lrhc;
};
class Rectangle {
private:
std::shared_ptr<RectData> pData;
public:
Point& upperLeft() const { return pData->ulhc; } --- (1)
Point& lowerRight() const { return pData->lrhc; } --- (1)
const Point& upperLeft() const { return pData->ulhc; } --- (2)
const Point& lowerRight() const { return pData->lrhc; } --- (2)
};
다음과 같이 Point로 점을 나타내고 RectData로 사각형의 점 데이터를 보관하며 이를 shared_ptr로 보관하고 있는 클래스 Rectangle을 상상해봅시다 만약 이에 대해 (1) 처럼 점들을 반환한다고 생각해봅시다. 이는 Point의 복사 생성을 하지 않기에 더 효율적으로 보입니다. Rectangle 객체를 수정시키지 않기 위해 상수멤버로 선언하였지만 참조값을 내보내기에 이를 변경하게 되면 객체의 private 데이터 값도 변하게 됩니다. 따라서 캡슐화 정도가 낮아지게 됩니다.
이를 통해 클래스 데이터 멤버는 아무리 숨겨도 그 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 정해진다는 점을 알 수 있습니다. 즉 이번처럼 private 멤버를 참조자를 리턴함으로서 캡슐화 정도가 낮아졌다는 것입니다. 또한 어떤 객체에서 호출한 상수 멤버 함수의 참조자 변환 값의 실제 데이터가 그 객체의 바깥에 저장되어 있다면, 이 함수의 호출부에서 그 데이터의 수정이 가능하다는 점입니다. 이와 같이 참조자, 포인터와 같은 핸들을 반환하게 되면 객체의 캡슐화를 무너뜨리게 됩니다.
그래서 먼저 반환 타입에 const 키워드를 붙이는 것으로 해결이 가능해보입니다. 적어도 위와 같은 문제는 사라집니다. 하지만 또 다른 문제가 하나 대두됩니다.
class GUIObject { ... };
const Rectangle boundingBox(const GUIObject& obj);
GUIObject *pgo;
...
const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft());
위와 같은 코드에서 GUIObject 값을 받고 이에 대한 복사본을 통해 upperLeft를 호출하여 Point 값을 얻어냅니다. 하지만 이는 임시 객체의 upperLeft Point 값을 가져오는 것이고, 이는 곧 사라질 것입니다. 따라서 pUpperLeft가 실질적으로 가지고 있는 것은 무효참조 핸들이 되기에 위험하게 됩니다. 따라서 핸들을 반환하는 함수는 어떻게든 위험할 수 밖에 없습니다. 하지만 이를 무조건적으로 하지 말라는 것이 아닌 피하라는 것이고, 이를 적절하게 활용해야만 합니다.
이것만은 잊지 말자!
- 어떤 객체의 내부요소에 대한 핸들(참조자, 포인터, 반복자)을 반환하는 것은 되도록 피하세요. 캡슐화 정도를 높이고, 객체의 상수성을 유지한 채로 동작할 수있도록 하며, 무효참조 핸들이 생기는 경우를 최소화할 수 있습니다.
29. Strive for exception-safe code
예외 안정성을 가진 함수라면 자원이 새면 안되고, 자료구조가 더럽혀지면 안됩니다. 따라서 예외 안정성을 갖춘 함수는 아래의 세 가지 보장 중 하나를 제공합니다.
[기본적인 보장(basic guarantee)]
함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것을 유효한 상태로 유지하겠다는 보장. 어떤 객체나 자료구조도 더렵혀지지 않으며 일관성을 유지해야하지만 프로그램의 상태가 정확히 어떤지 예측하기 어렵습니다.
[강력한 보장(strong guarantee)]
함수 동작 중에 예외가 ㅂ라생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장으로 원자적인 동작입니다. 호출이 성공하면 마무리까지 완벽하게 돌아가고, 아닌 경우는 전처럼 프로그램의 상태가 돌아가게 합니다.
[예외불가 보장(notrhrw guarantee)]
예외를 절대 던지지 않겠다는 보장입니다. 약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻입니다. 기본제공 타입에 대한 모든 연산은 예외를 던지지 않게 되어 있습니다.
이 3가지 중 하나라도 만족하지 못한다면 예외에 안전한 함수가 아닙니다. 위의 세가지 보장 중에서는 실용성으로 보면 강력한 보장이 좋아보입니다. 예외불가 보장의 경우는 실제로 만들기가 어렵습니다. 따라서 현실적으로는 기본적인 보장이나 강력한 보장 중 하나를 고르게 됩니다.
이것만은 잊지 말자!
- 예외 안정성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않습니다. 이러한 함수들이 제공할 수 있는 예외 안정성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있습니다.
- 강력한 예외 안정성 보장은 '복사-후-맞바꾸기' 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아닙니다.
- 어떤 함수가 제공하는 예외 안정성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않습니다.
30. Understand the ins and outs of inlining
인라인 함수는 함수처럼 보이고 함수처럼 동작하면서 매크로보다 훨씬 안전하며 쓰기 좋습니다. 또한 함수 호출 시 발생하는 오버헤드도 걱정할 필요가 없습니다. 거기다가 인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별 최적화를 걸기가 용이해집니다. 반면에 대부분 컴파일러는 아웃라인 함수 호출에 대해 이런 최적화를 적용하지 않습니다.
하지만 인라인 함수의 아이디어는 함수 호출문을 그 함수의 본문으로 바꿔치기 하는 것이라서, 메모리가 제한된 컴퓨터에서는 문제가 발생할 수도 있습니다. 반대로 본문의 길이가 굉장히 짧은 인라인 함수를 사용하면, 함수 본문에 대해 만들어지는 코드의 크기가 함수 호출문에 대해 만들어지는 코드보다 작아질 수도 있기에 목적 코드의 크기도 작아지며 명령어 캐시 적중률도 올라가게 됩니다.
inline 키워드를 붙여 inline으로 명시하는 것은, 실질적으로는 컴파일러가 이를 보고 눈치껏 판단하여 inline으로 처리할 수도, 처리하지 않을 수도 있습니다. 게다가 코드가 짧은 함수에 대해서는 암시적으로 Inline으로 변환하는 경우가 있어, inline 키워드의 여부와 관계 없이도 작동된다고 생각될 수도 있습니다.
흔히들 쓰는 함수 템플릿 inline 코드로서 std::max가 존재를 하는데 이 때문에 모든 함수 템플릿은 반드시 inline 함수이어야 하고 대개 헤더 파일 안에 정의한다라고 알고 있습니다. 대체적으로 이는 맞는데 이는 대부분의 빌드 환경에서 인라인을 컴파일 도중에 수행하기 때문입니다. 따라서 인라인 함수 호출을 그 함수의 본문으로 바꿔치기하려면, 일단 컴파일러는 그 함수가 어떤 형태인지 알고 있어야 합니다. 템플릿 역시 같은 이유로 헤더 파일에 들어 있어야 합니다. 하지만 함수 템플릿을 inline으로 선언하는 것은 하등의 관련이 없고 그저 inline으로 선언하고 싶은 경우에만 선언하면 됩니다.
더욱이 inline은 요청이기 때문에 "어떤 함수를 호출할지 결정하는 작업을 실행 중에 한다"라는 뜻의 virtual과 "함수 호출 위치에 호출된 함수를 끼워 넣는 작업을 프로그램 실행 전에 한다"라는 뜻의 inline은 상충되기 떄문에 서로 쓸 수 없고, 긴 코드인 경우는 inline해주지 않습니다.
또한 분명히 inline이 되는 함수라도 그냥 부르지 않고, 또 다른 함수 포인터에서 정의하여 부르게 된다면 inline 되자 않습니다. 즉 어떻게 호출하느냐도 중요한 포인트가 됩니다.
그 외로 생성자와 소멸자는 inline하기에는 그리 좋지 않습니다. 왜냐하면 C++은 객체가 생성 소멸될 떄 일어나는 일에 대해 여러 가지 보장을 준비해 놓았습니다. 특히 생성자와 소멸자는 예외 없는 환경을 만들기 위해서 컴파일러에서 더 많은 코드를 부여하게 됩니다. 즉 우리가 적은 코드가 적을지라도 실제로 컴파일러에서 구현하는 코드는 엄청나게 길어지게 되며, 이를 inline으로 선언하게 되면 상당히 곤란한 상황에 처하게 됩니다.
따라서 inline을 선언할 때에는 그 영향에 대해서 많은 고민을 해야합니다. 만약 inline 함수를 나중에 바꾸게 된다면 그에 맞는 오브젝트 파일을 전부 다시 컴파일 해야할 것입니다.
이것만은 잊지 말자!
- 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는 것으로 묶어둡시다. 이렇게 하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 있는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있느 여지가 최고로 많아집니다.
- 함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline으로 선언하면 안됩니다.
31. Minimize compilation dependencies between files
C++이 인터페이스와 구현을 깔끔하게 분리하는 일에 별로 일가견이 없다는 데에 있습니다. C++의 클래스 정의는 클래스 인터페이스만 지정하는 것이 아니라 구현 세부사항까지 상당히 많이 지정하고 있습니다. 만약 class를 구현하는 데에 private 멤버 변수로서 std::string과 같은 여러 클래스를 가지고 있고, 이를 #include 지시어를 통해 위의 클래스에 대한 정보를 가져와야 합니다. 즉 변수 코드들에 대해서 컴파일 의존성을 가지게 도비니다. 만약 해당 클래스를 바꾸게 된다면, 해당 클래스의 파일 뿐만 아니라 이에 해당되는 모든 파일들에 대해서 모두 다시 컴파일 해야만합니다.
이는 전방선언으로도 큰 성과를 이뤄내기 힘듭니다. std::string 같은 경우는 typedef로 정의된 타입 동의어 이므로 전방 선언이 불가능하고 템플릿을 들고와야만 합니다. 그래서 일부를 끌고 오기위해서는 #include 부분을 적절하게 고쳐야만 하기에 일이 복잡해집니다. 또 다른 이유로는 컴파일 도중에 객체들의 크기를 전부 알아야 하기 때문에 결국 객체가 정의된 클래스의 세부사항을 알아야만 합니다. 하지만 이것은 포인터를 활용한다면 해결이 가능합니다.
따라서 다음과 같은 방식으로 클래스의 구현과 인터페이스를 각각 제공하도록 합니다.
#include <string>
#include <memory>
class PersonImpl;
class Date;
class Address;
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::shared_ptr<PersonImpl> pImpl;
};
이와 같은 패턴을 pimpl idiom이라고 합니다. Person의 인터페이스에서는 자질구레한 세부사항과 완전히 갈라서면서도 인터페이스를 제공하고 있고 실제적인 구현은 PersonImpl에서 수행하도록 합니다. 즉, '정의부에 대한 의존성'을 '선언부에 대한 의존성'으로 컴파일 의존성을 최소화하도록 만듭니다. 이러한 전략을 가져가는 방법은 다음과 같습니다.
- 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않습니다. - 타입에 대한 참조자 및 포인터를 정의할 때는 선언부만 필요합니다.
- 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만듭니다. - 클래스 객체를 값으로 전달하거나 반환하여 클래스 정의가 필요 없도록 합니다. 이 방법은 실제 함수를 호출이 일어나는 사용자의 소스 파일 쪽에 헤더 파일 쪽 부담을 전가하는 방법으로, 사용자 입장에서도 실제로 쓰지도 않을 타입 정의에 대해 사용자가 의존성을 끌어오는 거추장스러움을 막을 수 있습니다.
- 선언부와 정의부에 대해 별도의 헤더 파일을 제공합니다. - 클래스를 선언부와 정의부로 나누어 구성합니다. 앞서 보여준 pImpl과 같은 것이 핸들 클래스로 구현을 맡고, 다른 파트가 인터페이스 및 선언을 맡습니다. 이는 인터페이스 함수는 그에 해당하는 구현부 함수를 불러오게 합니다. 다른 방법으로는 인터페이스 클래스를 만들어 이의 파생 클래스를 만들도록 합니다.인터페이스 클래스는 대부분의 함수를 순수 가상함수로 구현하는데, 객체 생성 수단이 최소한 하나는 있어야하기 때문에 가상 생성자를 만들어 생성자 역할을 대신합니다. 인터페이스 클래스의 구현은 크게 2가지가 사용됩니다.
- 인터 페이스 클래스로부터 인터페이스 명세를 물려받게 만든 후에, 그 함수들을 구현하는 방법
- 다중 상속을 사용하여 구현하는 방법
핸들 클래스의 경우는 한번 접근할 때마다 요구되는 간접화 연산이 한 단계 더 늘어나게 됩니다. 또한 객체르 하나씩 저장하는데 필요한 메모리 크기에 구현부 포인터의 크기도 더해져서 커지며, 구현부 포인터가 동적할당된 구현부 객체를 가르키도록 초기화가 일어나야합니다.
또한 두가지 모두의 약점으로 인라인 함수의 도움을 제대로 끌어내기가 어렵습니다.
이것만은 잊지 말자!
- 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 정의 대신에 선언에 의존하게 만들자는 것입니다. 이 아이디어에 기반한 두가지 접근 방법은 핸들 클래스와 인터페이스 클래스입니다.
- 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 합니다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용합니다.