Effective C++ 요약
- 항목 1 : C++에 왔으면 C++의 법을 따릅시다.
- C++은 네 가지의 하위 언어를 제공한다고 생각해라. C, 객체 지향 개념의 C++, 템플릿 C++, STL
- 항목 2 : #define을 쓰려거든 const, enum, inline을 떠올리자
- #define 상수는 (기호식) 디버깅을 어렵게 한다.
- 상수 포인터를 정의할 때는, const 두 개 써서 포인터가 가리키는 대상과 포인터 자체를 모두 상수 선언하자.
- 클래스 멤버를 상수로 정의하는 경우, 그 상수의 사본 개수가 한 개를 넘지 못하게 하고 싶다면 정적(static) 멤버로 만들자. 정수류 타입의 정적 클래스 멤버 상수는 정의 없이 선언만 해도 된다.
- 정수 상수의 주소를 얻는다거나 참조자를 쓰는 것을 막고 싶다면, 나열자 둔갑술(enum hack)을 고려하자. enum은 메모리를 만들지 않기 때문이다. enum은 메모리를 만들지 않는 #define의 장점은 가져오고, 기호자 디버깅 가능하고 캡슐화 가능.
- 함수처럼 쓰이는 매크로를 만들 생각이라면, 인라인 템플릿 함수를 먼저 고려해 봐라.
- 항목 3 : 낌새만 보이면 const를 들이대 보자!
- const 키워드가 *표의 왼쪽에 있으면 포인터가 가리키는 대상이 상수인 반면, const가 *표의 오른쪽에 있는 경우엔 포인터 자체가 상수이다.
- 어떤 반복자를 const ::iterator iter로 선언하는 일은 포인터를 상수로 선언하는 것(T* const 포인터)과 같다. 만약 변경이 불가능한 객체를 가리키는 반복자(const T* 포인터)가 필요하다면 const_iterator를 쓰면 된다.
const std::vector<int>::iterator iter = vec.begin(); *iter = 10; // OK ++iter; // ERROR std::vector<int>::const_iterator cIter = vec.begin(); *cIter = 10; // ERROR ++cIter; // OK- 함수 반환 값을 상수인지 아닌지 정해 주는 것은 오류 예방에도 좋다.
( a * b ) = c; if ( a * b = c)operator*의 반환 타입을 const로 해 줬다면, 이런 의도했을리 없는 쓸데없는 코드를 예방할 수 있다.
- 멤버 함수의 반환 타입 앞에 오는 const는 반환되는 값 자체를 상수화하는 반면, 함수 이름 뒤에 오는 const는 상수 멤버 함수를 나타낸다.
- 상수 멤버 함수에 붙는 const는 “해당 멤버 함수가 상수 객체에 대해 호출될 함수이다”라는 뜻이다. 다시 말해, 이 멤버 함수를 호출한 객체는 상수 객체이니 수정할 수 없다는 것이다.
- 그러나 상수 멤버 함수는 비트수준의 상수성을 지닌다. 이는 상수 멤버 함수가 그 객체의 어떤 데이터 멤버도, 더 정확히는 객체를 구성하는 비트들 중 어떤 것도 바꿀 수 없다는 것이다. 하지만, 데이터 멤버인 포인터가 가리키는 대상을 수정하는 경우에는 비트가 바뀌지 않아서 오류가 발생하지 않는다는 점을 주의해야 한다. (아래 예제에서 string 대신 char*를 쓰는 경우에도 그렇다.)
- 비트수준의 상수성을 지키지 않더라도 논리적 상수성을 지킨 구현이라면 상수 멤버 함수로 괜찮다 할 수 있다. 논리적 상수성은 사용자가 직접 다루지 않는 부분에 대해서는 비트 변경도 상수성을 해치지 않는다는 것이다. 이때 비트수준의 상수성을 지키지 않고도 컴파일러에게 들키지 않는 방법은 mutable이다. mutable 키워드를 붙인 데이터 멤버는 상수 멤버 함수 안에서도 수정할 수 있다.
- 참고로, 상수 객체는 상수 멤버 함수만 호출이 가능하다. 따라서 성능을 위해 상수 객체에 대한 참조자를 사용하기 위해서는, 이 상수 객체를 조작할 수 있는 const 멤버 함수가 준비되어 있어야 한다. 다행히도 const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다.
class TextBlock { const char& operator[] (std::size_t position) const { return text[position]; } char& operator[] (std::size_t position) { return text[position]; } private: std::string text; }; TextBlock tb("Hello"); std::cout << tb[0]; // 비상수 멤버 함수 호출 const TextBlock ctb("World"); std::cout << ctb[0]; // 상수 멤버 함수 호출- 오버로딩된 두 함수에 많은 코드 중복이 있다면, 비상수 함수가 상수 버전을 호출하도록 해서 코드 중복을 없앨 수 있다. static_cast를 통해 상수 함수를 호출하게 하고, const_cast를 통해 반환 타입에서 const를 제거하는 것이다.
const char& operator[] (std::size_t position) const { // ~많은 코드들~ return text[position]; } char& operator[] (std::size_t position) { return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]); }- 이 반대로 상수 버전이 비상수 버전을 호출하게 만드는 것은 틀린 방법이라고 하고 싶다. 비상수 멤버 함수 안에서 상수 멤버 함수를 호출한다고 해서 잘못될 일이 없지만, 그 반대는 다르기 때문이다.
- 항목 4 : 객체를 사용하기 전에 반드시 그 객체를 초기화하자
- 초기화되지 않은 값을 읽도록 내버려 두면 정의되지 않은 동작이 발생한다. C++의 초기화는 언제 초기화가 보장되며 언제 그렇지 않은지 규칙이 명확하지만 외우기에 복잡하다. 따라서 모든 객체를 사용하기 전에 항상 초기화하는 것이 좋은 방법이다.
- 생성자에서 그 객체의 모든 것을 초기화하자. 대입과 초기화를 헷갈리지 말자. 생성자에서 대입을 하면, 사실 대입 전에 이미 초기화는 진행된다(기본제공타입은 꼭 그렇지는 않다). 이게 무슨 소리인가? 생성자에서 대입을 하는 방식은, 멤버 객체에 대해 기본 생성자를 호출해서 이미 초기화를 진행한 상태에 복사 대입 연산자를 추가로 호출한다. 따라서 먼저 호출된 기본 생성자에서 해 놓은 초기화는 헛짓이 된다.
- 그래서 더 좋은 방법은 멤버 초기화 리스트를 사용하는 것이다. 초기화 리스트에 들어가는 인자는 바로 데이터 멤버에 대한 생성자의 인자로 쓰이기 때문이다. 즉, 기본 생성자를 호출하고 복사 생성자를 호출하는 것이 아니라, 오직 복사 생성자만 호출한다.
-
기본 제공 타입은 초기화랑 대입이 다를 게 없지만 그래도 초기화 리스트를 쓰자. 또한, 데이터 멤버를 기본 생성자로 초기화하고 싶을 때도 기본 제공 타입이 아니라면 자동으로 기본 생성자로 초기화되지만, 멤버 초기화 리스트를 사용하자.
class ABEntry { // ~~~ private: std::string theName; std::string theAddress; int numTimesConsulted; }; ABEntry::ABEntry() : theName(), theAddress(), numTimesConsulted(0) - 상수와 참조자는 대입 자체가 불가능하다. 오로지 초기화 리스트로만 가능하다. 이런 저런 거 생각하느니 그냥 다 초기화 리스트를 쓰자.
- 성능과 상관없이 가독성을 위해, 대입으로 초기화가 가능한 데이터 멤버들을 초기화 리스트가 아니라 별도의 private 함수로 빼서 초기화하는 경우도 있다.
- 객체를 구성하는 데이터의 초기화 순서는 컴파일러 상관없이 동일하다. 기본 클래스는 파생 클래스보다 먼저 초기화되고, 초기화 리스트에 넣어진 순서와 상관없이 데이터 멤버가 선언된 순서대로 초기화된다. 멤버 초기화 리스트에 넣는 멤버들의 순서도 클래스에 선언한 순서와 동일하게 맞춰 주자.
- 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다. 이것은 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 정해져 있지 않다는 말이다.
- 함수 안에 있는 정적 객체를 지역 정적 객체라고 하고, 나머지를 비지역 정적 객체라고 한다.
- 정적 객체 : 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체
- 번역 단위 : 컴파일을 통해 하나의 목적 파일을 만드는 바탕이 되는 소스 코드다. 기본적으로 소스 파일 하나가 되는데, 그 파일이 #include하는 파일들까지 합쳐져서 하나의 번역 단위가 된다.
- 따라서 비지역 정적 객체는 순서 문제가 생길 수 있다. 비지역 정적 객체를 함수로 래핑해 지역 정적 객체로 만드는 방법으로 이를 해결할 수 있다. 지역 정적 객체는 함수 호출 중에 최초로 닿았을 때 초기화되기 때문이다. 함수 속에서도 이들은 정적 객체로 선언하고, 그 함수에서는 이들에 대한 참조자를 반환하게 만든다. 그리고 사용할 때는 직접 참조하는 것이 아니라 함수 호출로 가져와 사용한다. 이것은 전형적인 싱글턴 패턴 구현 방법이다.
- 비상수 정적 객체는 다중스레드에서 고려할 점이 많다. 그중에, 다중스레드로 돌입하기 전에 참조자 반환 함수를 직접 호출해 주는 방법으로 다중스레드 프로그래밍에서 초기화에 관계된 경쟁 상태(race condition)을 없앨 수 있다.
- 항목 5 : C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자
- 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자는 클래스 안에 직접 선언해 넣지 않으면 컴파일러가 필요한 경우에 만들어낸다.
- 어떠한 생성자라도 선언이 되어 있으면, 컴파일러는 기본 생성자를 만들어내지 않는다.
- 만들어낸 복사 생성자 안에서 멤버 데이터의 복사는 복사 생성자가 있는 멤버 데이터 객체는 복사 생성자로, 기본제공 타입 객체는 비트를 그대로 복사해 오는 방법으로 진행된다.
- 복사 대입 생성자의 경우엔 상수나 참조자 멤버 데이터가 있으면 대입이 불가능하므로 컴파일러가 만들지 못하고, 컴파일 오류가 뜬다. 따라서 직접 선언해 줘야 한다.
- 복사 대입 연산자를 private로 선언한 기본 클래스로부터 파생된 클래스의 경우, 이 클래스는 암시적 복사 대입 연산자를 가질 수 없다. 기본 클래스의 private 복사 대입 연산자를 호출할 수 없기 때문이다.
- 항목 6 : 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자
- 복사 생성자, 복사 대입 연산자 사용을 막고 싶어서 선언하지 않더라도, 컴파일러가 필요하면 만들어 버려서 정상적으로 컴파일이 되어 버려 막지 못한다.
- 이를 막는 방법은 private 멤버 함수로 선언하는 것이다. 명시적으로 선언했기 때문에 컴파일러가 만들지 않으며, private이기 때문에 (컴파일러가) 외부에서의 호출을 막는다.
- 하지만 멤버 함수 및 프렌드 함수에서 호출이 된다. 이 경우를 막는 방법은 선언만 하고, 정의(구현)하지 않는 것이다. 그러면 함수 호출 시 링크 과정에서 오류가 발생해서 막을 수 있다.
- 에러 탐지는 가능한 빨리 하는 것이 좋다. 위의 링크 오류를 컴파일 오류로 땡기는 방법은 복사 생성자, 복사 대입 연산자의 private 선언을 별도의 기본 클래스에 넣고, 여기서 클래스를 파생해서 사용하는 것이다. 여기서 기본 클래스는 오직 이러한 기능만을 위한 ‘Uncopyable’과 같은 클래스이다.
- 이렇게 하면, Uncopyable을 상속 받은 파생 클래스에서 복사 생성자 및 복사 대입 연산자를 사용했을 때, 컴파일러가 파생 클래스에 복사 생성자 및 복사 대입 연산자를 생성하려고 할 것이고 기본 클래스의 대응 버전을 호출하려고 하겠지만 private라 불가능해 컴파일이 실패할 것입니다.
- 부스트 라이브러리에 Noncopyable이라는 해당 역할을 하는 클래스가 있고, 무척 괜찮다고 한다.
- 항목 7 : 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
- 팩토리 함수 : 새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수.
class TimeKeepr { ... }; class AtomicClock : public TimeKeeper { ... }; class WaterClock : public TimeKeeper { ... }; class WristClock : public TimeKeeper { ... }; TimeKeeper* getTimeKeeper(); // 팩토리 함수- 이렇게 기본 클래스 포인터에 있는 파생 클래스가 소멸될 때, 만약 기본 클래스의 소멸자가 비가상 소멸자라면 파생 클래스의 소멸자는 호출되지 않아 메모리 누수가 발생한다.
- 따라서 이 조건들을 모두 충족한다면 소멸자를 가상 소멸자로 만들자.
- 기본 클래스로 쓰려고 한다.
- 가상 함수를 하나라도 가진다.
- 다형성을 갖도록 설계되었다. (다형성을 갖지 않도록 설계된 경우의 예를 들면 위에 나온 Uncopyable 클래스. 기본 클래스의 인터페이스를 통한 파생 클래스 객체의 조작이 허용되지 않는 클래스이다.)
- 추상 클래스(그 자체로는 인스턴스를 못 만드는 클래스)를 만들고 싶지만, 마땅히 넣을 만한 순수 가상 함수가 없을 때 순수 가상 소멸자를 쓸 수 있다. 이 경우, 정의(구현)부를 꼭 둬야 한다. 파생 클래스의 소멸자 호출이 들어갈 공간이 필요하기 때문이다.
- 항목 8 : 예외가 소멸자를 떠나지 못하도록 붙들어 놓자
- 소멸자에서 예외가 발생하는 것은 특히 좋지 않다. 그 이유는, 예외가 발생해서 함수 스택을 빠져나오는 과정인 스택 언와인딩 중에 함수 지역 변수를 소멸시키는데, 여기서 또 예외가 발생한다면 예외 2개를 동시에 가지고 있을 수 없기 때문에 프로그램이 강제 종료되기 때문이다.
- 따라서 어떤 동작이 예외를 일으킬 가능성이 있고, 그 예외를 처리해야 할 필요가 있다면 그 예외를 소멸자가 아닌 다른 함수에서 발생하게 하자.
- 예제 : DBConnection에 대한 자원 관리 클래스를 만들어서 그 클래스의 소멸자에서 close를 호출하게 만드는 것. 또한 사용자가 직접 close할 수도 있게 구현.
class DBConn { public: void close() { db.close(); closed = true; } ~DBConn() { if (!closed) { try { db.close(); } catch (...) { // 호출 실패 로그 } } } private: DBConnection db; bool closed; }; - 항목 9 : 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자
- 파생 클래스 객체의 생성 중, 기본 클래스의 생성자가 호출될 동안에는 가상 함수가 파생 클래스 쪽으로 내려가지 않는다. 객체 자신이 기본 클래스 타입인 것처럼 동작한다. (아직 초기화되지 않은 파생 클래스의 데이터 멤버를 건드릴 수도 있으니 C++이 막아뒀다.) 소멸자도 마찬가지다. 이미 소멸된 파생 클래스를 건드리면 안 되니깐 기본 클래스의 소멸자 부분에서는 자신이 기본 클래스 타입인 것처럼 동작한다.
- 그러므로, 생성 및 소멸자, 그리고 그 안에서 호출되는 함수 안에서 가상 함수를 호출하지 말자.
- 가상 함수가 필요하다고 느껴지는 내용이라면, 기본 클래스의 비가상 멤버 함수로 선언하고 파생 클래스의 생성자들이 부모 클래스의 생성자에게 파라미터로 필요한 정보를 넘기도록 하자.
class BuyTransaction : public Transaction { public: BuyTransaction( parameters ) : Transaction(createLogString( parameters )) {} private: static std::string createLogString( parameters ); };- 여기서 정적 멤버 함수에 대해 한 가지 적자면, 정적 멤버 함수는 정적 멤버 변수만 쓸 수 있고 정적 멤버 변수는 사용한다면 무조건 정의를 해야 하기 때문에 미초기화된 데이터 멤버를 실수로 건드릴 위험을 없앤다.
- 항목 10 : 대입 연산자는 *this의 참조자를 반환하게 하자
- 대입 연산자는 좌변 인자에 대한 참조자를 반환하도록 구현하는 것이 관례이며, 이를 지켜라.
- 기본 대입 연산자뿐만 아니라, += 등 모든 형태의 대입 연산자에서 지켜져야 한다.
- 항목 11 : operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자
- 자기대입이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다. 아래와 같은 상황에서 중복 참조 등의 이유로 발생할 수 있다.
w = w;a[ i ] = a[ j ];*px = *py;
- 자기대입을 고려하지 않으면, 의도치 않게 자원을 해제해 버릴 수도 있다.
Widget& Widget::operator=(const Widget& rhs) { delete pb; pb = new Bitmap(*rhs.pb); return *this; }- 이 코드에서 이러한 일치성 검사는 자기대입은 해결하지만, new Bitmap() 부분에서 예외가 터지면 pb는 delete되기만 한다. (예외 안전성 문제)
Widget& Widget::operator=(const Widget& rhs) { if (this == &rhs) return *this; delete pb; pb = new Bitmap(*rhs.pb); return *this; }- 이것은 둘 다 해결할 수 있는 코드이다. 일치성 검사하는 방법이 더 효율적인가?에 대해서, 자기대입은 자주 일어나는 일이 아니며, 일치성 검사 코드가 대입 연산자에서 매번 호출 되는 것이 더 비효율적일 수 있다. 한 경우만 생각하지 말자.
Widget& Widget::operator=(const Widget& rhs) { Bitmap *pOrig = pb; pb = new Bitmap(*rhs.pb); delete pOrig; return *this; }- 이런 방법들도 있다. (복사 후 맞바꾸기, copy and swap)
Widget& Widget::operator=(const Widget& rhs) { Widget temp(rhs); swap(temp); // *this의 데이터와 rhs의 데이터를 맞바꾸는 함수. return *this; } Widget& Widget::operator=(Widget rhs) { swap(temp); // *this의 데이터와 rhs의 데이터를 맞바꾸는 함수. return *this; } - 자기대입이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다. 아래와 같은 상황에서 중복 참조 등의 이유로 발생할 수 있다.
- 항목 12 : 객체의 모든 부분을 빠짐없이 복사하자
- 객체 복사 함수(복사 생성자, 복사 대입 연산자)를 작성할 때는 1. 데이터 멤버를 모두 복사하기, 2. 이 클래스가 상속한 기본 클래스의 복사 함수 호출하기
- 코드 중복을 피하기 위해 한쪽에서 다른 쪽을 호출하는 것은 하지 말자. 복사 생성에서 복사 대입이나, 복사 대입에서 복사 생성이나 둘 다. 이런 경우에는, 겹치는 부분을 별도의 멤버 함수로 분리해서 이를 호출하는 방법을 사용하자. (ex : private init 함수)
- 항목 13 : 자원 관리에는 객체가 그만!
- 어떤 함수에서 메모리를 동적 할당하고, delete로 해제하려는 경우에는 그 사이에 예외 발생 or 이후 유지보수 시 실수하는 등 delete 호출이 안 돼서 메모리 누수 발생 가능하다. (예외 발생하면 catch 나올 때까지 지역 변수 소멸자 호출하며 스택 프레임 정리.
- 좋은 방법은, 자원을 획득한 후에, 자원을 자원 관리 객체에 넣고, 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 하는 것이다. 그러면 스코프를 떠날 때 소멸자가 호출되어 자원 해제가 될 것이다. 스마트 포인터를 쓰면 된다. (auto_ptr, tr1::shared_ptr)
- 복사 시에, auto_ptr은 복사되는 객체(원본 객체)를 null로 만든다. 따라서 STL 컨테이너에 사용할 수 없다. 현재는 사용 중지 권고 또는 제거됐다. 대신 unique_ptr이 쓰인다. STL 컨테이너에도 사용할 수 있다.
- 스마트 포인터는 소멸자에서 delete를 사용한다. delete[ ]가 아니므로, 배열 자원에 대해 사용하면 안 된다. boost에 boost::scoped_array, boost::shared_array가 있다. 현재는 unique_ptr<int[ ]> 이런 식으로 선언하면 된다.
- 항목 14 : 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자
- 자원 관리(RAII) 클래스 설계 시, RAII 객체가 복사될 때 어떤 동작이 이루어져야 할까?를 생각해 봐야 한다. RAII 객체가 관리하는 자원을 어떻게 복사하느냐.
- 복사 금지, 참조 카운팅, 자원 깊은 복사, 소유권 이전(auto_ptr) 등의 방법이 있다.
- shared_ptr은 레퍼런스 카운트가 0이 됐을 때 실행할 삭제자(deleter) 지정이 가능하다. delete 대신 함수를 지정할 수 있다는 것이다.
- 항목 15 : 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자
- RAII 클래스가 관리하는 자원을 직접 접근해야 하는 경우도 많기 때문에 관리 자원에 직접 접근하는 방법을 열어 줘야 한다. (예를 들어, 함수 인자로 그냥 포인터를 원하는데, shared_ptr을 가지고 있음)
- 명시적 or 암시적 변환. 안전성만 보면 명시적 변환이 낫지만, 고객 편의성을 놓고 보면 암시적 변환이 낫다. 명시적은 get() 함수.
- 항목 16 : new 및 delete를 사용할 때는 형태를 반드시 맞추자
- new 표현식에 [ ]를 썼으면, 대응되는 delete 표현식에도 [ ]를 써야 한다. 마찬가지로, new 표현식에 [ ]를 안 썼으면 대응되는 delete 표현식에도 쓰지 말아야 한다.
- 배열 타입을 typedef 타입으로 만들면 delete 시에 헷갈릴 수 있으므로, 이를 피하고, 또는 vector를 써서 동적 할당 배열을 쓰지 않는 것도 좋다.
- 항목 17 : new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자
- 참고로, 포인터를 받는 tr1::shared_ptr의 생성자는 explicit으로 선언되어 있다.
processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());이 함수는 크게 세 가지 연산이 진행된다. 근데 각각의 연산이 실행되는 순서는 컴파일러마다 다르다. 만약 밑에 순서로 진행되는 경우에 두 번째에서 예외가 발생하면 new Widget으로 만들어진 포인터로 인해 메모리 누수가 발생할 수 있다.- new Widget 실행
- priority 호출
- tr1::shared_ptr 생성자 호출
- 요점은 new와 스마트 포인터 생성자 사이에 뭔가 호출되면서 문제가 발생할 수 있으니,
std::tr1::shared_ptr<Widget> pw(new Widget);별도의 문장 하나로 만들자는 것이다.
- 항목 18 : 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자.
- 사용자가 저지를 만한 실수를 고려해서 해당 실수를 했을 땐 동작하지 않게 인터페이스를 설계하자.
- 예를 들어, 날짜(년,월,일) 클래스 생성자를 Day, Month, Year 구조체를 사용해서 실수를 막는 것이다. explicit을 사용해서 int로 생성 안 되게 하면 년,월,일 순서를 좀 더 실수하지 않게 만들 수 있고, enum (타입 안전성 생각하면 enum class)나 유효한 값을 반환하는 함수를 제공하는 방법으로 값에 제약을 가할 수 있다. 예를 들면,
static Month Jan() {return Month(1);}이다. - operator*의 반환 타입을 const로 한정함으로써
if (a * b = c)이런 실수를 저지르지 않게 할 수 있다. 일관성 있는 인터페이스를 제공하기 위해서, ‘따로 이유가 없다면, 사용자 정의 타입은 기본제공 타입(int)처럼 동작하게 만들자.’ - 스마트 포인터가 상당수의 실수를 사전에 막을 수 있는 대표적인 방법이다. delete를 깜박하거나 똑같은 포인터에 대해 delete를 두 번 이상 호출(정의되지 않은 동작)하는 등의 문제를 막을 수 있다. nullptr을 delete하는 건 상관 없지만, delete한다고 nullptr 되는 거 아니다.
- tr1::shared_ptr은 포인터별로 삭제자를 가지기 때문에 교차 DLL 문제도 피할 수 있다. 이는 동적 링크 라이브러리(DLL) 사용 시, 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 기본 삭제자가 보장한다는 것이다.
- 부스트의 shared_ptr
- 항목 19 : 클래스 설계는 (기본 제공)타입 설계와 똑같이 취급하자
- 어떤 클래스를 설계하든 간에 점검할 질문들이다.
- 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가?
- 객체 초기화는 개체 대입과 어떻게 달라야 하는가?
- 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가?
- 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가?
- 기존의 클래스 상속 계통망(inheritance graph)에 맞출 것인가?
- 어떤 종류의 타입 변환을 허용할 것인가?
- 어떤 연산자와 함수를 두어야 의미가 있을까?
- 표준 함수들 중 어떤 것을 허용하지 말 것인가?
- 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?
- ‘선언되지 않은 인터페이스’로 무엇을 둘 것인가?
- 새로 만드는 타입이 얼마나 일반적인가?
- 정말로 꼭 필요한 타입인가?
- 어떤 클래스를 설계하든 간에 점검할 질문들이다.
- 항목 20 : ‘값에 의한 전달’보다는 ‘상수객체 참조자에 의한 전달’ 방식을 택하는 편이 대게 낫다.
- 값 전달 시 복사손실문제(slicing problem)가 발생할 수 있다. 이는 파생 클래스 객체가 기본 클래스 객체로서 전달될 때, 값으로 전달되면 기본 클래스의 복사 생성자가 호출되면서 파생 클래스 객체 내용들이 손실되는 것을 말한다.
- 타입 크기만으로 판단하지 마라. 복사 생성자 호출 비용은 이와 별개이다(깊은 복사, 생성자 및 소멸자 호출). 또한, 진짜 double은 레지스터에 넣어 주지만, double 하나로만 만들어진 사용자 정의 타입의 객체는 레지스터에 넣지 않는다.
- ‘값에 의한 전달’이 저비용이라고 가정해도 괜찮은 유일한 타입은 기본제공 타입, STL 반복자, 함수 객체 타입, 이렇게 세 가지뿐이다.
- 항목 21 : 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자
- 참조자는 그냥 이름이다. 존재하는 객체에 붙는 다른 이름이다.
- 함수가 참조자를 반환하도록 만들어졌다면, 이 함수가 반환하는 참조자는 반드시 이미 존재하는 객체의 참조자여야 한다.
- 함수 수준에서 새로운 객체를 만드는 방법은 스택에 만드는 것, 힙에 만드는 것 딱 두 가지뿐이다.
const Rational& operator*(const Rational& lhs, const Rational& rhs) { Rational result(lhs.n * rhs.n, lhs.d * rhs.d); return result; }- 여기서 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 하기 어렵다. 만약 4개의 Rational 객체에 대해 w = x * y * z를 하면 메모리 누수.
- 만약 정적 객체를 둬서 참조자 반환하게 하면, 일단 정적 객체이기 때문에 스레드 안전성을 고려해야 하고, 만약 (a * b) == (c * d) 라는 조건문에서 항상 참이 나온다. (같은 정적 객체가 반환되니깐). 만약 하나의 객체만 쓴다면 상관 없겠지만. (싱글턴 패턴)
inline const Rational operator*(const Rational& lhs, const Rational& rhs) { return Rational(lhs.n * rhs.n, lhs.d * rhs.d);; }- 새로운 객체를 반환하게 하는 것. 이것이 저자의 답이다. 결국 생성, 소멸 비용을 없애지 못 했다. 그러나 중요한 것은 이외의 방법들은 올바르게 동작하지 않았기에, 이것은 올바른 동작에 지불되는 작은 비용인 것이다. 컴파일러 최적화 메커니즘에 의해 operator*의 반환 값에 대한 생성과 소멸 동작이 안전하게 제거될 수도 있다(반환 값 최적화, RVO). 최적화에 메달리다가 올바른 동작을 놓치지 말아라.
- 항목 22 : 데이터 멤버가 선언될 곳은 private 영역임을 명심하자
- 데이터 멤버는 반드시 private 멤버이어야 한다.
- public이나 protected는 왜 안 될까?
- 일관성 : 하고 싶을 때 괄호를 붙여야 하는지 일관적이게 하기 위해
- private + 값을 읽고 쓰는 함수면 접근 불가, 읽기 전용, 심지어 쓰기 전용까지 구현 가능. public이나 protected는 접근 제한 불가.
- 함수를 통해서만 접근 가능하게 하는 것(캡슐화)은 데이터 멤버를 나중에 계산식으로 대체할 수도 있다. 그것 외에도 굉장히 많은 이점이 있다. 데이터 멤버를 읽거나 쓸 때 다른 객체에 알림 메시지를 보낸다든지, 클래스의 불변속성 및 사전조건 사후조건을 검증한다든지, 스레딩 환경에서 동기화를 건다든지.
- 어떤 데이터 멤버를 일단 public 혹은 protected로 선언했고, 사용하기 시작했으면 그 멤버에 대해 무엇을 바꾸기란 무척 힘들어진다.
- 항목 23 : 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자.
- 비멤버 함수로도 만들 수 있는 경우, 멤버 함수 vs 비멤버 함수.
- 객체 지향적인 캡슐화를 위해 데이터와 그 데이터를 기반으로 동작하는 함수는 한 데 묶이는 게 하기 위해 멤버 함수라고 답할 수 있는데, 잘못된 생각이다.
- 캡슐화 → 외부에서 이것을 볼 수 있는 것이 줄어드는 것이 장점. 변경하기 용이(유연)해지기 때문.
- 다른 말로, 어떤 데이터를 접근하는 함수가 많으면 그 데이터의 유연함이 낮다는 이야기.
- 멤버 함수는 private 멤버 데이터에 접근이 가능. private 멤버 함수에도 접근 가능. 즉 멤버 함수가 많아진다면 캡슐화의 장점인 유연함이 낮아진다는 것.
- 프렌드 함수는 private 멤버 접근 가능하므로, 비프렌드 비멤버 함수에만 적용되는 얘기다.
- 함수가 비멤버가 되어야 한다는 것이 그 함수가 다른 클래스의 멤버가 될 수 없다는 얘기가 아니다. 다른 클래스의 멤버도 private 접근 불가능하다. 유틸리티 클래스의 정적 멤버 함수로 만들어도 된다는 것이다.
- 또 다른 좋은 방법은 네임스페이스 안에 두는 것이다. 이러면 여러 개의 소스 파일에 나뉘어 흩어질 수 있다는 장점이 있다. 비멤버 함수화가 가능하다는 것은 웬만해서 편의 함수라는 것이다. 편의 함수를 용도에 따라 다른 헤더 파일에 같은 네임스페이스로 두면 실제로 사용하는 구성요소에 대해서만 컴파일 의존성을 고려할 수 있게 된다는 장점이 있다. 예를 들면 std 네임스페이스이다. 이는 수십 개의 헤더에 흩어져 선언되어 있다. 이렇게 하면 확장에 용이하다. 비멤버 비프렌드 함수를 네임스페이스에 원하는 만큼 추가해 주면 된다. (클래스 멤버 함수라면 이렇게 여러 헤더에 나누는 것이 불가능. 사용하지 않아도 컴파일 (컴파일 의존성))
- 항목 24 : 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자.
- 클래스에서 암시적 타입 변환을 지원하는 것은 일반적으로 못된 생각이다의 예외 중 하나는 숫자 타입을 만들 때이다. 유리수 클래스를 만들 건데, 암시적 타입 변환을 지원할 것이다.
- 유리수 클래스를 만들 때 곱셈 연산을 멤버 함수로 두면 oneHalf * 2 는 되지만, 2 * oneHalf는 안 된다. oneHalf.operator(2)는 2가 암시적 타입 변환이 되지만, 2.operator(oneHalf)는 안 되기 때문이다.
- 그런데 이때, 컴파일러는 operator(2, oneHalf)에 대한 비멤버 버전의 operator도 찾아본다. 따라서 operator*를 비멤버 함수로 만들면 된다.
- 프렌즈 함수로 두어야 하는가? ⇒ 현재 이 클래스에서는 분모, 분자를 가져오는 인터페이스가 있기 때문에 굳이 둘 필요가 없다. 프렌즈 함수는 피할 수 있으면 피하자. ‘멤버 함수면 안 된다’가 ‘프렌즈 함수여야 해’가 아니다.
- 항목 25 : 예외를 던지지 않는 swap에 대한 지원도 생각해 보자
- std::swap은 3번의 복사를 통해 a와 b의 값을 변경하는 함수로, 비효율적인 경우가 있다.
-
이런 경우, 템플릿 특수화를 통해 특정 객체에 대해서 다르게 동작하게 할 수 있다.
namespace std { template<> void swap<Widget>(Widget& a, Widget& b) { // swap(a.pImpl, b.Impl); // 이 경우 private 멤버라서 컴파일 안 된다. a.swap(b); // 프렌즈 선언말고, public 멤버 함수 swap을 만들어쓰자. } } - 근데 만약 Widget이 클래스 템플릿이라면 void swap<Widget
>와 같이 부분 템플릿 특수화를 써야 할 텐데, 안 된다. - 완전 템플릿 특수화 (template<>)은 함수 템플릿, 클래스 템플릿 모두에 적용 가능하다. 부분 템플릿 특수화 (템플릿 매개변수의 일부만 특정 or 포인터 타입 등)은 클래스 템플릿에만 적용 가능하고, 함수 템플릿에는 불가능하다. 함수 템플릿의 부분 템플릿 특수화는 오버로딩으로 대체하면 된다.
- 하지만 std 내의 완전 특수화는 괜찮지만 새로운 템플릿을 추가하는 것은 안 된다. 컴파일도 되고 실행도 되지만, 결과가 미정의 사항이다. 그러므로 std에 절대 아무것도 추가하지 말자.
-
이런 경우엔 멤버 swap을 호출하는 비멤버 swap을 선언해 놓되, std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지 않으면 된다.
namespace WidgetStuff { template<typename T> class Widget { ... }; template<typename T> void swap(Widget<T>& a, Widget<T>& b) { a.swap(b); } }이렇게 두면, 사용자가 std::swap을 쓸지, WidgetStuff::swap을 쓸지 모를 거 같지만.
template<typename T> void doSomething(T& obj1, T& obj2) { using std::swap; swap(obj1, obj2); }이렇게 swap을 호출하면 C++의 이름 탐색 규칙(인자 기반 탐색 or 쾨니그 탐색)에 의해 Widget의 특수화 버전이 있으면 그것부터 사용하고 없다면 std::swap을 호출한다.
- 인자 기반 탐색이 뭐냐면, 일반적으로 함수를 호출할 때는 현재 스코프나 using namespace로 가져온 곳에서 함수를 찾지만, 인자 기반 탐색이 있기 때문에 함수의 인자로 전달된 타입이 정의된 네임스페이스도 함께 탐색하는 것이다.
- using std::swap, swap()을 잊지 말자!
정리 펼치기/접기
첫째, 표준에서 제공하는 swap이 여러분의 클래스 및 클래스 템플릿에 대해 납득할 만한 효율을 보이면 셋째로 가라.
둘째, 표준 swap의 효율이 좋지 않다면
- 여러분의 타입으로 만들어진 두 객체의 값을 빨리 맞바꾸는 함수를 swap이라는 이름으로 만들고, 이것을 public 멤버 함수로 두어라. 이 함수는 절대로 예외를 던져선 안 된다.
- 클래스 혹은 템플릿이 들어 있는 네임스페이스와 같은 네임스페이스에 비멤버 swap을 만들어 넣는다. 그리고 1번에서 만든 swap 멤버 함수를 이 비멤버 함수가 호출하도록 만든다. (private 멤버 변수 접근 위해)
- (클래스 템플릿이 아닌) 새로운 클래스를 만들고 있다면, 그 클래스에 대한 std::swap의 완전 특수화 버전을 준비한다. 이 특수화 버전에서도 swap 멤버 함수를 호출하도록 한다.
셋째, swap을 사용할 때 swap을 호출하는 함수가 std::swap을 볼 수 있도록 using 선언을 하고, 그 다음에 swap을 호출하되 네임스페이스 한정자를 붙이지 말아라.
- 항목 26 : 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자
- 변수 정의는 생성자, 소멸자 비용이 든다. 만약 정의하고 안 쓰는 경우가 생길 수 있으니, 가능한 정의를 미루자. (변수 쓰임새 알기에도 유용)
- 또한, 미루면 대입 연산 대신 초기화를 할 수 있는 가능성이 커진다.
- 변수를 루프 바깥 or 안 : 대입 비용과 생성자-소멸자 쌍 비용 비용에 달렸다. 변수 유효범위도 고려.
- 항목 27 : 캐스팅은 절약, 또 절약! 잊지 말자.
- C 스타일 캐스트는 (type)표현식, type(표현식) 이 두 개로, 동일하게 동작한다.
- C++ 스타일 캐스트는 const, dynamic, reinterpret, static cast이다.
- const_cast는 상수성을 없애는 용도이다. 휘발성을 제거하는 용도로도 쓰인다. (휘발성이란 컴파일러에게 해당 변수 메모리 읽기를 최적화를 하지 말고 항상 메모리 접근해서 값을 읽어오라고 표시하는 것이다. 하드웨어나 프로그램 외부에서 메모리를 수정할 수 있는 경우에 쓴다.)
- dynamic_cast는 다운캐스팅을 안전하게 할 때 쓰인다. 업캐스팅 됐던 애만 정상적으로 다운캐스팅. 이는 런타임 비용이 높은 연산자이기에 주의해서 사용해야 한다.
- reinterpret_cast는 강제 형변환이다. 적용 결과는 구현환경에 의존적이다.(이식성이 없다.) 하부 수준 코드 외에 거의 안 쓴다.
- static_cast는 암시적 변환을 강제로 진행할 때 사용한다. 다운캐스팅 시 주의해야 한다.
- C 스타일 말고, C++ 스타일 캐스트를 쓰자. 목적을 더 정확히 알 수 있다.
- 다른 방법이 가능하다면 캐스팅은 피하자. 정말 잘 작성된 C++ 코드는 캐스팅을 거의 쓰지 않는다고 한다. 가상함수로 대체할 수 있다.
- 캐스팅이 어쩔 수 없이 필요하다면, 함수 안에 숨겨서 최소한 사용자는 자신의 코드에 캐스팅을 넣지 않고 이 함수를 호출할 수 있게 하자.
- 오버라이딩 시에, 기본 클래스의 함수를 이렇게 기본 클래스로 캐스팅해서 사용하는 경우에는 Base 부분을 복사해서 임시 객체를 만들어서 함수를 호출하는 거다! 원래 객체를 건드리는 게 아니다. 주의하자.
class Base { public: virtual void Func() { ... } }; class Derived : public Base { public: virtual void Func() { static_cast<Base>(*this).Func(); // Base::Func() 로 사용하자. } }; - 항목 28 : 내부에서 사용하는 객체에 대한 ‘핸들’을 반환하는 코드는 되도록 피하자.
- 핸들(다른 객체에 손을 댈 수 있게 하는 매개자)을 반환하면 캡슐화 정도가 그 핸들의 접근도에 달리게 된다. 상수 멤버 함수로 만들어도 객체 상태의 변경이 가능할 수 있다(항목 3 내용처럼 비트수준 상수성). 반환 타입 앞에도 const 키워드를 붙이면 비트수준 상수성을 넘어, 아예 상태 변경 못 하게 막을 수 있다. (const Point& Func() const)
- 핸들 반환의 또 다른 문제는 댕글링 핸들이다. 핸들이 있지만, 그 핸들을 따라 갔을 때 실제 객체의 데이터가 없는 것이다. 바깥으로 나온 핸들은 그 핸들이 참조하는 객체보다 더 오래 살 위험이 있기 때문이다.
- 핸들을 반환하는 멤버 함수를 절대로 두지 말라는 이야기는 아니다.
- 항목 29 : 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!
- 예외 안전성을 확보하려면 두 가지의 요구사항을 맞추어야 한다.
- 자원이 새도록 만들지 않는다.
- 자료구조가 더럽혀지는 것을 허용하지 않는다.
- 예외 안전성 보장 종류는 세 가지가 있다. 이 중 아무 보장도 제공하지 않으면 예외에 안전한 함수가 아니다.
- 기본적인 보장 : 예외가 발생했을 때, 프로그램과 관련된 모든 것을 유효한 상태로 유지. 하지만, 프로그램의 상태가 정확히 어떠한지는 예측이 안 될 수도 있다. 멤버 변수 값이 변경될 수는 있다.
- 강력한 보장 : 예외가 발생했을 때, 프로그램의 상태를 절대로 변경하지 않는다. 호출이 실패하면 함수 호출이 없었던 것처럼 프로그램의 상태가 되돌아간다는 것이다.
- 예외불가 보장 : 예외를 절대로 던지지 않겠다는 보장이다. 기본제공 타입(int, 포인터 등)에 대한 모든 연산은 예외를 던지지 않게 되어 있다.
throw()는 함수가 방출할 수 있는 예외를 지정하는 것이다. 괄호 안에 명시한 예외 이외에는 unexpected_handler를 호출한다. C++ 11에서 noexcept가 나왔는데 이는 예외 방출을 안 하겠다는 것이다. 컴파일러는 이를 믿고 스택 풀기 등을 안 해도 된다고 판단하고 최적화를 수행할 수 있다. 대신, 예외가 발생하면 terminate()를 호출한다. C++17부터는 throw()가 deprecate 되었다.
- 강력한 보장을 만들기 위한 방법 : 스마트 포인터로 메모리 관리, 복사 후 맞바꾸기 (copy and swap)와 복사 후 수정을 위한 pimpl 관용구. pimpl 관용구는 ‘진짜’ 객체의 모든 데이터를 별도의 구현 객체에 넣어두고, 그 구현 객체를 가리키는 포인터를 진짜 객체가 물고 있게 하는 방식.
- 복사 후 맞바꾸기는 수정하고 싶은 객체를 복사해 둘 공간과 복사에 걸리는 시간을 감수해야 한다. 즉, 예외 안전성 보장 중에 강력한 보장이 가장 좋으면서도 실용적인 것은 아니다. 실용성도 고려해야 한다.
- 예외 안전성이 없는 함수가 한 개라도 쓰이면 그 시스템은 전부가 예외에 안전하지 않은 시스템이다. 어떻게 하면 예외에 안전한 코드를 만들까를 진지하게 고민하는 버릇을 들이자. 자원 관리가 필요할 때 자원 관리용 객체를 사용하는 것부터가 시작이다.
- 예외 안전성을 문서로 남겨서 함수의 사용자 및 나중의 인수인계자가 파악할 수 있도록 하자.
- 예외 안전성을 확보하려면 두 가지의 요구사항을 맞추어야 한다.
- 항목 30 : 인라인 함수는 미주알고주알 따져서 이해해 두자.
- inline을 쓰면 코드의 크기가 커질 수도 있고, 작아질 수도 있다.
- inline은 컴파일러에 요청하는 것이지 명령이 아니다. (이거 관련 GCC inline 코드 분석한 글 : https://ryutyke.github.io/c-cpp/inline/)
- C++에서 함수 인라인은 컴파일 타임에 진행된다.
- 함수 템플릿이 인라인될 이유가 없다면 인라인 함수로 선언하지 않아도 된다.
- 생성자, 소멸자는 인라인하기에 좋은 함수가 아니다.
- 라이브러리 설계할 때, 인라인 함수를 쓰면 그 함수를 사용한 모든 소스를 재 컴파일 해야 한다는 점을 기억하자. 인라인이 아니라면 얘만 따로 컴파일 하면 된다.
- 우선 아무것도 인라인 하지 말고, 정말 필요한 위치에 인라인 함수를 놓도록 하자.
- 항목 31 : 파일 사이의 컴파일 의존성을 최대로 줄이자
- #include 문은 컴파일 의존성을 만든다. 파일 하나 수정 시, 의존성이 생긴 파일들을 모두 재 컴파일해야 한다는 말이다.
- 의존성을 줄이기 위해 전방 선언을 쓸 수 있는데, 전방 선언은 그 객체의 동작이나 크기를 몰라도 되는 경우에 가능하다. 전방 선언을 위해 포인터화 할 수 있다.
- 선언부와 정의부를 나눠서 클래스 정의 대신 클래스 선언에 의존하게 만드는 것도 방법이다.
- 여기에 더해 pimpl 관용구를 쓰면 컴파일 의존성을 더욱 줄일 수 있다. pimpl 관용구에서 impl 객체를 가지고 있는 클래스를 핸들 클래스라고 한다.
- pimpl 에 들어가는 내용을 원래 객체의 private으로 다 옮기고 impl 클래스를 없애도 의존성은 똑같은 거 아니야? 라고 생각을 했는데. 이건 함수만 있을 때를 생각한 것이었다. impl에 멤버 데이터도 있을 것이고 이것들 중 다른 파일과 컴파일 의존성이 있는 경우가 있을 수 있다. 이 의존성을 떼어줄 수 있다.
- iosfwd 헤더는 C++에서 지원하는 iostream 관련 함수 및 클래스들의 선언부만으로 구성된 헤더이다.
- 템플릿 선언과 템플릿 정의를 분리할 수 있는 export 키워드가 있지만, 이를 제대로 지원하는 컴파일러가 별로 없다고 한다.
- 핸들 클래스 대신에 쓸 수 있는 방법은 인터페이스 클래스이다. 다른 언어의 인터페이스는 데이터 멤버나 함수 구현을 아예 가질 수 없는 제약이 있는 경우도 있지만 C++은 아니다. 인터페이스 클래스를 쓰고, 팩토리 함수 혹은 가상 생성자를 쓰면 된다.
- 인터페이스 클래스나 핸들 클래스가 추가 실행, 메모리 비용이 들긴 하지만, 일단 쓰고 제품 출시 때 실행 속력이나 파일 크기를 줄이기 위해 최적화를 할 때 없애는 것을 고려하자.
- 항목 32 : public 상속 모형은 반드시 “is-a(…는 …의 일종이다)”를 따르도록 만들자
- public 상속은 “is-a(…는 …의 일종이다)”를 기억하자. 클래스 Derived를 클래스 Base로부터 public 상속을 통해 파생시켰다면, Derived is a Base. 하지만 그 반대는 되지 않는다.
- 주의해야 할 것은 public 상속은 기본 클래스 객체가 가진 모든 것들이 파생 클래스 객체에도 그대로 적용된다고 단정하는 상속이다.
- 예를 들어, 정사각형 클래스는 직사각형 클래스로부터 public 상속을 받아야 할까? 정사각형은 직사각형의 일종이고, 그 반대는 아니므로 맞다고 생각할 수 있다. 그러나 직사각형의 성질 중 가로 길이와 세로 길이가 달라도 된다는 성질은 정사각형에 적용할 수 없으므로 이는 틀렸다. (이거에 대해 생각해 보는 방법으로 기본 클래스 포인터에 파생 객체가 들어가 있다고 생각하고 함수를 호출해 보는 상상을 해 보면 좋을 것 같다. 직사각형→길이 변경(3, 6)을 했는데 안 된다? 이상한 거다.)
- 항목 33 : 상속된 이름을 숨기는 일은 피하자
- 이름 가리는 것에 대한 유효범위(scope) 관련 이야기다. 예를 들어, 전역 변수와 지역 변수의 이름이 같을 때 지역 변수가 전역 변수를 가리는 것.
- public 상속 시 기본 클래스에 선언된 것은 파생 클래스가 모두 물려받는다.
- 하지만, 이 상황에서는 다르다. 함수들이 받아들이는 매개변수 타입이 다르거나 말거나, 가상 함수이거나 비가상 함수이거나 상관없이 함수 이름이 가려진다.
class Base { private: int x; public: virtual void mf1() = 0; virtual void mf1(int); void mf3(); void mf3(double); }; class Derived: public Base { public: virtual void mf1(); void mf3(); }; --- Derived d; int x; d.mf1(); // Derived::mf1 호출 d.mf1(x); // 오류. Derived::mf1이 Base::mf1 가림 d.mf3(); // Derived::mf3 호출 d.mf3(x); // 오류. Derived::mf3이 Base::mf3 가림- 이렇게 일부 함수가 가려지면 public 상속에서 is-a 관계 위반이다. 이런 경우에 using을 써서 가려진 함수들을 가져올 수 있다. 다시 말해, public 상속에서 오버로드된 함수가 상위 클래스에 들어 있고 이 함수들 중 몇 개만 오버라이드 하고 싶다면, using 선언을 해 줘야 한다는 것이다.
class Derived: public Base { public: using Base::mf1; using Base::mf3; virtual void mf1(); void mf3(); };- 일부만 가져오고 싶다면 맨 처음 예제처럼 하되, 이는 public 상속이 아니라, 다른 상속을 쓰면 된다.
- 항목 34 : 인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자
- 상속은 두 가지 개념으로 나뉜다. 1) 함수 인터페이스 상속, 2) 함수 구현 상속
- 상속은 세 가지 종류가 있다.
- 순수 가상 함수 : 인터페이스만을 물려준다. 순수 가상 함수가 있는 클래스는 추상 클래스가 되며, 추상 클래스는 인스턴스를 만들 수 없다. (C++에서 순수 가상 함수는 정의(구현)도 제공할 수 있다. 구현이 붙은 순수 가상 함수를 호출하려면 반드시 클래스 이름을 한정자로 붙여 주어야 한다.)
- 단순 가상 함수 : 인터페이스뿐만 아니라 함수의 기본 구현도 물려준다.
- 비가상 함수 : 클래스 파생에 상관없이 변하지 않는 동작을 지정하는 용도이다.
- 단순 가상 함수의 구현을 일부 파생 클래스가 받지 않는 방법을 소개한다. 나는 오버라이드 하되, 부모 클래스의 함수를 호출하지 않는 방법을 썼던 거 같은데, 책에서는 이 방법 포함 두 가지 방법을 소개한다.
- 첫 번째 방법이다. 순수 가상 함수 Func()에 대해 protected 비가상함수 defaultFunc()을 만들어서 원하는 파생 클래스에서만 defaultFunc()을 호출해 주는 것. 이렇게 하면 클래스 내부적으로만 사용하는 default 함수를 protected로 둘 수 있다는 장점이 있다. 또한 defaultFunc()을 비가상 함수로 둬서 재정의해선 안 되는 함수를 표시할 수 있다.
- 두 번째 방법이다. 내가 썼던 방법이다. 한 가지 차이가 있다면 순수 가상 함수로 두고 구현을 구비해 뒀다는 것이다. (이건 설계 차이인 것 같다. ) 이거와 첫 번째 방법의 차이는 defaultFunc()이 있어서 생기는 두 가지 장점이 사라진다는 것이다. (protected, 비가상함수)
- 항목 35 : 가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자.
- 가상 함수 대신에 쓸 수 있는 방법들도 많다.
- 비가상 인터페이스 관용구(NVI 관용구) : 공개되지 않은 가상 함수를 비가상 public 멤버 함수로 감싸서 호출하는, 템플릿 메서드 패턴(C++의 템플릿과는 관계 없음)의 한 형태. 랩퍼(wrapper)를 쓰는 방법으로 사전 동장, 사후 동작을 추가할 수 있다. 참고로, 상속받은 private 가상 함수를 파생 클래스가 재정의할 수 있다.
- 함수 포인터로 가상 함수를 대체. 전략 패턴의 응용. 원래 전략 패턴은 함수를 가진 클래스를 쓰는 것. 함수 포인터도 새로운 함수로, 클래스도 파생 클래스로 런타임 중에 함수를 대체할 수 있다.
- 함수 포인터 대신 tr1::function 데이터 멤버로 쓰는 방법. tr1::function은 시그니처만 맞으면 함수호출성 개체(함수 포인터, 함수 객체, 멤버 함수 포인터)를 어떤 것도 가질 수 있다. 또한, 함수호출성 개체의 매개변수 타입이나 반환 타입이 암시적 변환이 가능하다.
- 이런 방법 사용 중, public 영역에 없는 부분을 써야 하게 될 수 있는데. 그 방법을 사용하는 것의 이점이 캡슐화를 떨어뜨리는 불이익을 채워 줄지 아닐지 생각해 봐야 한다.
- 항목 36 : 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!
- 같은 객체가 부모 포인터에 있든 그 객체의 포인터에 있든 기본 클래스의 멤버 함수를 호출했을 때 결과가 같아야 할 것이다.
- 비가상 함수는 정적 바인딩이기 때문에 만약 파생 클래스에서 재정의했을 경우엔 위의 상황에서 결과가 달라질 수 있다. 이런 경우라면
- 이건 항목 34의 내용을 리마인드해 준 느낌. 비가상 함수는 의도가 변하지 않는 동작을 주는 건데, 이를 바꿨다면 is-a가 깨지는 것. 가상 함수였으면 변경해도 is-a가 안 깨지는 경우를 생각한 의도였을 테니 괜찮겠지만.
- 항목 37 : 어떤 함수에 대해서도 상속받은 기본 매개변수 값은 절대로 재정의하지 말자
- 기본 매개변수 값을 가진 가상 함수를 상속하는 경우, 가상 함수는 동적으로 바인딩되지만, 기본 매개변수 값은 정적으로 바인딩된다. 주의하자.
class Shape { public: enum ShapeColor { Red, Green, Blue }; virtual void draw(ShapeColor color = Red) const = 0; }; class Rectangle : public Shape { public: virtual void draw(ShapeColor color = Green) const; }; Shape* S = new Rectangle(); S->draw(); // Rectangle::draw() 호출되지만 매개변수 기본 값은 Red이다. - 항목 38 : “has-a(…는…를 가짐)” 혹은 “is-implemented-in-terms-of(…는…를 써서 구현됨)”를 모형화할 때는 객체 합성을 사용하자
- 합성이란 어떤 타입의 객체들이 그와 다른 타입의 객체들을 포함하고 있을 경우에 성립하는 그 타입들 사이의 관계를 일컫는다. 다른 용어로, 레이어링, 포함, 통합, 내장 등으로도 쓰인다.
- 객체 합성은 has-a(…는 …를 가짐) 또는 is-implemented-in-terms-of(…는 …를 써서 구현됨)을 의미한다.
- 소프트웨어 개발에서 대하는 영역을 두 가지로 나눌 수 있다. 사물을 본 뜬 것들인 소프트웨어의 응용 영역(사람, 이동수단 등)과 그 외의 것들인 소프트웨어의 구현 영역(버퍼, 탐색 트리 등)이다.
- 객체 합성을 응용 영역의 객체에서 하면 has-a 관계이고, 구현 영역에서 하면 is-implemented-in-terms-of 관계이다.
- public 상속인 is-a와 객체 합성을 잘 구분하자.
- 항목 39 : private 상속은 심사숙고해서 구사하자
- 클래스 사이의 상속 관계가 private이면 컴파일러는 일반적으로 파생 클래스 객체를 기본 클래스 객체로 변환하지 않습니다. (파생 클래스 외부에서)
- 기본 클래스로부터 물려받은 멤버는 파생 클래스에서 모조리 private 멤버가 된다.
- private 상속의 의미는 is-implemented-in-terms-of이다. private 상속을 통해 파생시키는 것은 기본 클래스의 기능을 활용할 목적으로 하는 것이지, 객체 사이에 어떤 개념적 관계가 있어서 하는 것이 아니다. 다른 말로, private 상속은 구현만 물려받고 인터페이스는 물려받지 않는다.
- is-implemented-in-terms-of로 객체 합성과 같다. 할 수 있으면 가능한 객체 합성을 사용하고, 꼭 해야 하면 private 상속을 쓰라고 한다. 꼭 해야 하는 경우는 주로 비공개 멤버를 접근할 때 혹은 가상 함수를 재정의할 경우이다. private 상속을 public 상속에 객체 합성 조합으로 풀어보려고 시도하자. 이는 컴파일 의존성을 줄일 수 있다. (또한 가상 함수 재정의를 막을 수 있다고 쓰여 있는데 이제는 final 키워드를 쓰면 된다.)
- Widget 클래스에서 Tick()을 제공하는 Timer의 Tick() 가상 함수를 재정의해야 하는 상황이라면 재정의하기 위해 상속을 받아야 한다. 그러나 is-a 관계가 아니므로, public 상속은 맞지 않다.
- 공백 기본 클래스 최적화(EBO)에서 private 상속이 쓰인다. C++에는 독립 구조의 객체는 반드시 크기가 0을 넘어야 한다는 금기사항이 있다. 그래서 공백 클래스에 대해 private 상속을 쓰는 공백 기본 클래스 최적화(EBO) 방법이 있다.
class Empty {}; class HoldAnInt { private: int x; Empty e; }; // 이 경우 sizeof(HoldsAnInt) > sizeof(int) 가 나온다. // Empty의 사이즈가 1이기 때문 (바이트패딩 고려x) --- class HoldsAnInt : private Empty { private: int x; }; // 이렇게 하면 sizeof(HoldsAnInt) == sizeof(int)- 공백 클래스는 위 예시처럼 텅 빈 것만이 아니다. typedef 혹은 enum, 정적 데이터 멤버, 비가상 함수까지 갖는 경우도 있다. STL에서 unary_function과 binary_function이 그 예이다. 사용자 정의 함수 객체를 만들 때 상속시킬 기본 클래스로 자주 사용되는 클래스이다.
- 항목 40 : 다중 상속은 심사숙고해서 사용하자
- 다중 상속은 둘 이상의 클래스로부터 상속을 받는 것이다. 둘 이상의 똑같은 이름(함수, typedef 등)을 물려받을 가능성이 생긴다. 이런 경우엔, 호출할 함수의 클래스를 손수 지정해 주어야 한다.
- 다중 상속 시, 죽음의 마름모꼴이라고 알려진 좋지 않은 모양이 나올 수 있다. File을 상속 받은 InputFile, OutputFile 두 개를 다중 상속 받은 IOFile. 이런 경우엔 기본 클래스의 데이터 멤버가 경로 개수만큼 중복 생성되는 것이다. 이 중복 생성을 막기 위해서는 가상 상속을 써야 한다. 가상 상속을 쓴 객체는 크기가 더 크고, 가상 기본 클래스의 데이터 멤버에 접근하는 속도가 더 느린 등 가상 상속은 비싸다. 또한 가상 기본 클래스의 초기화는 복잡하다. 따라서 가능한 가상 기본 클래스 사용을 피하고, 꼭 써야 한다면 데이터 멤버를 넣지 않는 쪽으로 신경 쓰자.
- 다중 상속과 동등한 효과를 내는 단일 상속 설계가 가능하다면 단일 상속이 확실히 좋다. 물론, 가장 명료하고 유지보수성도 좋고 적합한 방법이 다중 상속인 경우도 존재한다. (책의 예제는 public 상속과 Interface를 private 상속을 동시에 하는 다중 상속)
- 항목 41 : 템플릿 프로그래밍의 천릿길도 암시적 인터페이스와 컴파일 타임 다형성부터
- 객체 지향 프로그래밍은 명시적 인터페이스(explicit)와 런타임 다형성.
- 템플릿과 일반화 프로그래밍은 암시적 인터페이스(implicit)과 컴파일 타임 다형성.
- 명시적 인터페이스 : 객체는 클래스 타입이 정해지면 그 타입의 명시적 인터페이스를 지원해야 한다. 명시적 인터페이스는 코드로 명시된 함수들의 시그니처로 이루어져있다.(이름, 매개변수 타입, 반환 타입) (typedef 타입도 포함될 수 있고, 데이터 멤버는 포함되지 않는다고 한다.)
- 암시적 인터페이스 : 템플릿 매개변수이 된 객체는 그 템플릿에서 요구하는 암시적 인터페이스를 지원해야 한다. 암시적 인터페이스는 템플릿 코드를 유효하게 하는 표현식들로 이루어져있다. 표현식들이 코드로 명시되어 있지는 않다. 템플릿 코드 안에 암시적으로 존재할 뿐.
- 런타임 다형성은 런타임에 가상 함수를 통해 나타나고, 컴파일 타임 다형성은 컴파일 중 템플릿 인스턴스화와 함수 오버로딩 모호성 해결을 통해 나타난다. 컴파일 타임에 함수 템플릿이 호출될 때 템플릿의 인스턴스화가 일어나는데, 어떤 템플릿 매개변수가 들어가느냐에 따라 호출되는 함수가 달라지기 때문에 컴파일 타임 다형성이라고 한다.
- 항목 42 : typename의 두 가지 의미를 제대로 파악하자.
- 템플릿 매개변수 선언 시, class와 typename은 동일하게 동작한다. 취향 차이다. 매개변수가 클래스 타입일 필요가 없다는 의미로 typename을 쓰기도 한다고 한다. 일부는, 어떠한 타입도 허용되는 부분에는 typename을 쓰고 사용자 정의 타입만 쓰이는 부분에는 class를 쓴다고 한다.
- typename C에 대해
C::const_iterator는 템플릿 매개변수인 C에 따라 달라지는 타입이다. 이처럼 템플릿 내의 이름 중에 템플릿 매개변수에 종속된 것을 가리켜 의존 이름이라고 한다. 또한, 의존 이름이 어떤 클래스 안에 중첩되어 있는 경우가 있는데 이는 중첩 의존 이름이다. 만약 type으로 쓰려는 거면 중첩 의존 타입 이름. (헷갈릴 수 있는데,C iter;iter는 그저 템플릿 매개변수에 종속된 의존 이름.C::const_iterator;const_iterator는 템플릿 매개변수 C에 들어가는 클래스에도 종속되는 중첩 의존 (타입) 이름. C::const_iterator * x;구문에서 중첩 의존 이름인 const_iterator가 타입인지 아닌지 컴파일러는 알아낼 수 없다. 정적 데이터 멤버 C::const_iterator와 전역 변수 x의 곱셈일 수도 있기 때문이다. C++은 이런 경우를 대비한 규칙이 있다. 중첩 의존 이름의 경우 타입이라고 명시하지 않는 한 타입이 아니라고 가정한다.- 타입이라고 명시하는 방법은 typename 키워드이다. (
typename C::const_iterator). typename 키워드는 중첩 의존 이름을 식별하는 데만 써야 한다. -
한 가지 예외는, 중첩 의존 타입 이름이 기본 클래스 리스트에 쓰이거나, 멤버 초기화 리스트에서 기본 클래스로 쓰이는 경우엔 typename을 쓰면 안 된다.
template<typename T> class Derived : public Base<T>::Nested // 쓰면 안 됨 { public: explicit Derived(int x) : Base<T>::Nested(x) // 쓰면 안됨 {} }; -
현업 코드에서 많이 쓰이는 예제
// 매개변수로 넘어온 반복자가 가리키는 객체의 사본을 temp라는 이름의 지역 변수로 template<typename IterT> void workWithIterator(IterT iter) { // IterT라는 iterator의 특성정보 클래스에서 value type을 사용. typename std::iterator_traits<IterT>::value_type temp(*iter); } --- // 특성정보 클래스에 속한 멤버 이름에 대해 typedef 이름을 만들 땐 그 멤버 이름과 // 똑같이 짓는 것이 관례 template<typename IterT> void workWithIterator(IterT iter) { typedef typename std::iterator_traits<IterT>::value_type value_type; value_type temp(*iter); }
- 항목 43 : 템플릿으로 만들어진 기본 클래스 안의 이름에 접근하는 방법을 알아 두자.
- 템플릿 기본 클래스의 파생 템플릿 클래스에 대한 내용이다.
- C++은 기본 클래스의 멤버에 대한 참조가 유효한지 진단하는 과정이 1. 파생 클래스 템플릿의 정의가 구문분석될 때, 2. 파생 클래스 템플릿이 특정한 템플릿 매개변수를 받아 인스턴스화될 때. 둘 중 전자인 이른 진단을 한다.
- 기본 클래스의 특수화 버전이 존재하여 기존 기본 클래스와 멤버가 다른 경우가 있을 수 있는데, 이것은 템플릿 매개변수를 받아 인스턴스화가 된 이후에야 멤버 정보를 확신할 수 있다. 그러나 이른 진단이기 때문에 컴파일러는 클래스 템플릿의 정의와 마주칠 때 그것의 기본 클래스 멤버 정보를 모른다. 그래서 기본 클래스 멤버를 사용하면 컴파일 오류가 난다. (쉽게 말해, 일반적으로 컴파일러는 템플릿으로 만들어진 기본 클래스를 뒤져서 상속된 멤버 이름을 찾게끔 설계되지 않았다.)
-
그러나 기본 클래스에 있다는 힌트를 명시해 주면, 컴파일 오류가 나지 않는다. 명시하는 방법은 세 가지가 있다. 1. this→, 2. using, 3. 클래스 명시. 세 번째 방법은 가상 함수 바인딩이 무시되기 때문에 추천하지 않는다.
template<typename Company> class LoggingMsgSender : public MsgSender<Company> { public: using MsgSender<Company>::sendClear; // 방법 2 void sendClearMsg(const MsgInfo& info) { sendClear(info); // 컴파일 오류 this->sendClear(info); // 방법 1 MsgSender<Company>::sendClear(info); // 방법 3. 정적바인딩임. } }; -
특수화는 특정 템플릿 매개변수에 대해 따로 정의를 해 주는 것이다. 완전 템플릿 특수화는 템플릿 매개변수들을 하나도 빠짐없이 구체적인 타입으로 정해 정의해 주는 것이다. template<>처럼 괄호 안에 아무것도 없으면 완전 템플릿 특수화인 것이다.
template<> class MsgSender<CompanyZ> { // CompanyZ 라는 타입에 대해 완전 템플릿 특수화 };
- 항목 44 : 매개변수에 독립적인 코드는 템플릿으로부터 분리시키자
-
템플릿 코드에서는 코드 중복이 암시적이기 때문에, 템플릿은 코드 비대화를 초래할 수 있으니 주의해야 한다. 예를 들어, 비타입 매개변수로 사이즈를 받는 정방행렬 클래스를 보자. 이 경우 invert( )의 동작은 사이즈랑 상관없이 다 똑같더라도 size마다 invert( ) 함수가 인스턴스화된다.
template<typename T, std::size_t n> class SquareMatrix { public: void invert(); }; ====================================================== // 해결 template<typename T> class SquareMatrixBase { protected: void invert(std::size_t matrixsize); }; template<typename T, std::size_t n> class SquareMatrix : private SquareMatrixBase<T> { private: using SquareMatrixBase<T>::invert; public: void invert() { this->invert(n) }; // 인라인화 돼서 호출 추가 비용 없음. }; - 첫 번째 방법처럼 행렬 크기가 미리 녹아든 상태로 별도의 invert 버전이 만들어지는 것도 장점이 있다. 행렬 크기가 컴파일 시점에 투입되는 상수이기 때문에 상수 전파 등의 최적화가 가능하다. 생성되는 기계어에 대해 이 크기 값이 즉시 피연산자로 들어가는 것도 이러한 최적화 중 하나이다.
- 반면 두 번째 방법은 코드 크기가 줄어든다. 실행 코드가 작아지면 메모리를 적게 쓰는 것뿐만 아니라 프로그램의 작업 세트(working set, 주 메모리에 올리는 페이지 양) 크기가 줄어들면서 효율적인 캐시 사용으로 참조 지역성에 의해 성능도 좋아진다. (시간적 지역성 : 지금 참조된 메모리는 또 참조될 가능성이 높다. 공간적 지역성 : 지금 참조된 메모리와 가까운 곳에 있는 메모리가 참조될 가능성이 높다.)
- 성능 비교는 두 방법 전부 적용해 보고 결과를 관찰하는 수밖에 없다.
- 그리고 두 번째 방법에 행렬 배열 데이터 등의 멤버 데이터로 인해 생기는 단점들이 좀 더 있다. 그래서 코드 중복을 조금 허용하는 편이 괜찮을 수도 있다고 얘기한다.
- 타입 제약이 엄격한 포인터(T)를 써서 동작하는 멤버 함수를 구현할 때 그 안에서 타입미정 포인터(void)로 동작하는 버전을 호출하는 식으로 코드 비대화를 줄이는 방법도 있다.
-
- 항목 45 : “호환되는 모든 타입”을 받아들이는 데는 멤버 함수 템플릿이 직방!
- (멤버) 함수 템플릿이라는 게 있다는 이야기다. 그 중, 같은 템플릿을 써서 인스턴스화되지만 다른 타입의 객체로부터 객체를 복사하는 생성자를 일반화 복사 생성자라고 한다.
-
기본 제공 포인터는 암시적 변환이 되고, 암시적 다운캐스팅은 안 된다. 일반화 복사 생성자에 기본 제공 포인터를 활용하여 이 이점을 챙길 수 있다.
template<typename T> class SmartPtr { public: template<typename U> SmartPtr(const SmartPtr<U>& other) : heldPtr(other.get()) {} T* get() const { return heldPtr; } ... }; - 복사 대입 연산자도 일반화 가능하다. tr1::shared_ptr은 호환되는 기본 제공 포인터, shared_ptr, auto_ptr, weak_ptr 객체들로부터 생성자 호출이 가능하고, weak_ptr을 제외한 나머지를 대입 연산에 쓸 수 있게 만들어져 있다.
- 멤버 함수 템플릿으로 복사 생성자, 복사 대입 연산자를 일반화시켰더라도, 기본 복사 생성자, 복사 대입 연산자를 선언하지 않았다면 같은 타입 객체가 들어왔을 때 컴파일러가 만들어서 쓴다. 따라서 기본 복사 생성자, 복사 대입 연산자를 직접 선언해 줘야 한다.
- 항목 46 : 타입 변환이 바람직할 경우에는 비멤버 함수를 클래스 템플릿 안에 정의해 두자
- 항목 24에서 나온 ‘모든 매개변수에 대해 암시적 타입 변환이 되도록 만들기 위해서는 비멤버 함수를 써야 한다’는 내용의 템플릿 버전이다.
- 이 예제는 컴파일 되지 않는다. 그 이유는 함수 템플릿의 템플릿 인자 추론 과정에서는 암시적 타입 변환이 고려되지 않기 때문이다.
template<typename T> const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) Rational<int> oneHalf(1, 2); Rational<int> result = oneHalf * 2; // 2의 암시적 타입 변환이 되지 않아 컴파일 오류- 프렌드 함수를 사용해서 해결이 가능하다. oneHalf 객체가 Rational
타입으로 선언되면 Rational 클래스가 인스턴스화 되면서 해당 프렌드 함수도 자동으로 선언된다. 주의할 점은 클래스 밖 비멤버 함수는 인스턴스화 되지 않기 때문에 클래스 내부 프렌드 함수를 정의해 줘야 한다. 그렇지 않으면 링크가 안 된다.
// 링크 오류. 선언은 되지만, 정의는 안 됨. template<typename T> class Rational { public: friend const Rational operator* (const Rational& lhs, const Rational& rhs); }; template<typename T> const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) { return Rational( lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator() ); } ================================================= // 문제 해결 template<typename T> class Rational { public: friend const Rational operator* (const Rational& lhs, const Rational& rhs) { return Rational( lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator() ); } };- 클래스 템플릿 내부에서는 <>를 떼고 쓸 수 있다. 다시 말해, Rational
안에서는 Rational이라고만 써도 Rational 로 처리가 되는 것이다. - 프렌드 함수를 쓰는 이유는 클래스 안에 비멤버 함수를 선언하는 유일한 방법이기 때문이다.
- 클래스 안에 정의된 함수는 암시적으로 인라인된다. 클래스의 바깥에서 정의된 도우미 함수만 호출하는 식으로 구현하면 이러한 암시적 인라인 선언의 영향을 최소화할 수 있다.
- 항목 47 : 타입에 대한 정보가 필요하다면 특성정보 클래스를 사용하자
- 주 내용에 앞서서, 아래 예시에 쓰이는 STL 반복자에 대해서. STL 반복자는 여러 종류가 있다. 반복자가 지원하는 연산에 따라 다섯 개의 범주로 나뉜다.
- 입력 반복자 : 전진만 가능, 한 번에 한 칸씩만 이동, 읽기만 가능, 읽을 수 있는 횟수가 한 번뿐이다. istream_iterator가 대표적인 입력 반복자이다. (한 번뿐이라는 게, stream 한 번 읽으면 데이터 사라지는 것처럼)
- 출력 반복자 : 쓰기만 가능하다는 것 제외하곤 입력 반복자와 동일하다. ostream_iterator.
- 순방향 반복자는 전진만 가능, 한 번에 한 칸씩만 이동, 읽기 쓰기 둘 다 가능. 여러 번 가능. (단일 연결 리스트에서 사용하기 적합)
- 양방향 반복자는 순방향 반복자에 후진 기능을 추가한 것이다. STL의 list, set, multiset, map, multimap 컨테이너에서 사용.
- 임의 접근 반복자는 양방향 반복자에 반복자 산술 연산 수행 기능을 추가한 것으로, 쉽게 말해 반복자를 임의의 거리만큼 앞뒤로 이동시키는 일을 상수 시간 안에 할 수 있다는 것이다. STL의 vector, deque, string 컨테이너에서 사용.
- 특성정보란 컴파일 도중에 어떤 주어진 타입의 정보를 얻을 수 있게 하는 객체이다. 항상 구조체로 구현하는 것으로 굳어져 있으며, 특성정보를 구현하는 데 사용한 구조체를 가리켜 특성정보 클래스라고 부른다.
- 특성정보는 C++에 미리 정의된 문법구조나 키워드가 아니라, 구현 기법이며, 관례를 따른다. 관례 중 하나는 특성정보가 사용자 정의 타입뿐만 아니라 포인터 등의 기본제공 타입에 대해서도 적용할 수 있어야 한다는 것이다. 예를 들어, 어떤 객체의 특성정보를 얻을 수 있을 때 그 객체 포인터에 대해서도 얻을 수 있어야 한다.
- 대략적으로 설명하면, 정보를 나타내는 구조체를 만들고, 특성정보 클래스를 활용해서 어떤 객체의 특성정보에 접근하면 그 구조체 타입을 제공하는 것이다.
// 1. 정보를 나타내는 구조체 struct input_iterator_tag {}; struct output_iterator_tag {}; struct forward_iterator_tag : public input_iterator_tag {}; struct bidirectional_iterator_tag : public forward_iterator_tag {}; struct random_access_iterator_tag : public bidirectional_iterator_tag {}; // 2. 특성정보 접근을 돕는 구조체 (특성정보 클래스) template<typename IterT> struct iterator_traits { typedef typename IterT::iterator_category itertator_category; // ... }; // 2-1. 포인터에 대해서도 적용 가능하게 부분 템플릿 특수화 template<typename IterT> struct iterator_traits<IterT*> { typedef random_access_iterator_tag iterator_category; // ... }; // 3. 특성정보를 가지는 클래스 class deque { public: class iterator { public: typedef random_access_iterator_tag iterator_category; }; };- 이런 구조로 특성정보를 만드는 것의 장점은 컴파일 타임에 특성정보 확인이 가능하다는 것이다. typeid를 사용해서 특성정보를 활용할 수도 있겠지만, 이는 컴파일 타임에 확인이 가능하다는 장점을 살리지 못한다. 컴파일 타임에 활용하는 방법은 오버로딩이다.
// 이는 런타임 확인 template<typename IterT, typename DistT> void advance(IterT& iter, DistT d) { if (typeid(typename std::iterator_iterator_traits<IterT>::iterator_category) == typeid(std::random_access_iterator_tag)) { // ... } } =========================== // 오버로딩을 활용한 컴파일 타임 확인 (임의접근 반복자 예시) template<typename IterT, typename DistT> void doAdvance(IterT& iter, DistT d, std::random_access_iterator_tag) { iter += d; } // ... 다른 반복자들에 대해 오버로딩 ... // 컴파일타임 확인 template<typename IterT, typename DistT> void advance(IterT& iter, DistT d) { doAdvance( iter, d, typename std::iterator_traits<IterT>::iterator_category() ); }- TR1이 도입되면서 타입 관련 정보를 제공하는 특성정보 클래스가 상당수 추가되었다. 예를 들면,
- is_fundamental
: T가 기본제공 타입인지 알려준다. - is_array
: T가 배열 타입인지 알려준다. - is_base_of<T1, T2> : T1이 T2와 같거나 T2의 기본 클래스인지 알려준다.
- is_fundamental
- 주 내용에 앞서서, 아래 예시에 쓰이는 STL 반복자에 대해서. STL 반복자는 여러 종류가 있다. 반복자가 지원하는 연산에 따라 다섯 개의 범주로 나뉜다.
- 항목 48 : 템플릿 메타프로그래밍, 하지 않겠는가?
- 템플릿 메타프로그래밍(TMP)은 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다. 템플릿 메타프로그램은 컴파일러가 실행시키는 프로그램이다. TMP로 나온 결과물(템플릿이 인스턴스화된 코드)가 다시 보통의 컴파일 과정을 거치는 것이다.
- TMP의 강점은 다른 방법으로는 까다롭거나 불가능한 일을 굉장히 쉽게 할 수 있다는 것과 작업을 컴파일 타임에 할 수 있다는 것이다.
- 런타임 오류를 컴파일 타임에 미리 발견할 수 있게 만들 수 있다.
- 컴파일 타임이 길어지지만, 실행 코드가 작아지고 실행 시간도 짧아지며 메모리도 적게 잡아먹는다.
- 활용 분야
- 치수 단위(dimensional unit)의 정확성 확인 : 단위가 똑바로 조합되었는지 컴파일 타임에 확인. 런타임에 오류를 줄일 수 있다는 것의 한 예제. 분수식 지수 표현이 가능(약분 가능).
- 행렬 연산 최적화 : TMP 기술인 표현식 템플릿(expression template)을 통해 큰 임시 객체를 없애고 루프까지 합칠 수 있다. 메모리도 적게 먹으면서 속도도 빠른 소프트웨어 결과물을 만들 수 있다.
- 맞춤식 디자인 패턴 구현 : TMP 기술인 정책 기반 설계(policy-based design)라는 것을 사용하면 따로따로 마련된 설계상의 선택(정책)을 나타내는 템플릿을 만들고, 조합해 다양한 동작을 만들 수 있다. 이것이 생성식 프로그래밍(generative programming)의 기초.
- 항목 49 : new 처리자의 동작 원리를 제대로 이해하자
- 메모리 할당 요청인 operator new가 할당할 메모리가 없을 때는 예외를 던진다.
- 예외 대신 사용자 지정 에러 처리 함수를 호출하게 할 수 있다. 이 에러 처리 함수를 new-handler라고 한다. new-handler를 지정하는 함수는 표준 라이브러리에 있는 set_new_handler()이다.
- set_new_handler()는 new-handler로 지정할 함수 포인터를 인자로 받고, 기존 new-handler 함수 포인터를 반환한다.
- new-handler 함수 안에서 쓰이는 메모리 할당으로 인해 new-handler에서 또 할당할 메모리가 없는 경우도 있다. 중요한 건, operator new에는 while(true)문이 있어서 메모리 할당에 성공하든지 또는 new-handler에서 적절한 동작을 해 줘야 한다. new-handler, 즉 new 처리자 함수는 가용 메모리를 늘려 주든가, 다른 new 처리자를 설치하든가, new 처리자의 설치를 제거하든가, bad-alloc 혹은 bad-alloc에서 파생된 타입의 예외를 던지든가, 아예 함수 복귀를 포기하고 도중 중단을 시켜야 한다.
- 할당된 객체의 클래스 타입에 따라 new-handler를 다른 함수로 하는 방법이 있다. 기능이 제공되는 것은 아니지만 클래스에 set_new_handler 및 operator new를 자체 버전으로 구현하면 된다.
- new_handler 타입 정적 클래스 멤버인 currentHandler 를 가지고, set_new_handler로 이걸 set하고 기존 것을 반환하게 하고, new에서 std::set_new_handler(currentHandler)로 전역 new 처리자를 설정하고 반환된 기존 전역 new 처리자를 자원 관리 클래스 NewHandlerHolder로 보관하고, NewHandlerHolder의 소멸자에서 보관하고 있는 new 처리자를 기존 전역 new 처리자로 설정하는 방법.
- currentHandler와 set_new_handler(), operator new 자체 버전을 다른 클래스에서도 재사용할 수 있게 하는 방법이 있다. 이 기능들만을 물려받아 갈 수 있게 템플릿 기본 클래스를 만드는 것이다. 템플릿으로 만드는 이유는 정적 멤버 데이터인 currentHandler를 파생 클래스마다 따로 가질 수 있게 하기 위해서이다.
- 템플릿 매개변수로 A 클래스를 받아 만들어진 기본 클래스로부터 A가 파생되는 패턴을 신기하게 반복되는 템플릿 패턴(CRTP)이라고 한다.
- new (std::nothrow) 클래스. 이런 방식으로 할당할 메모리가 없을 때 예외가 아니라 널 포인터를 반환하게 하는 옛날 스타일의 동작을 사용할 수 있다. 주의할 점은 new에서 예외가 발생하지 않지만, 클래스 생성자에서 예외가 발생하는 것을 고려해야 한다는 것이다.
- 항목 50 : new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해 두자.
- 언제 operator new와 operator delete를 바꾸는가?
- 잘못된 힙 사용을 탐지하기 위해 : 하나의 예를 들면, 데이터 오버런(할당된 메모리 블록의 끝을 넘어 뒤에 기록하는 것)과 언더런을 대비하여 사용자 정의 operator new로 요구된 크기보다 약간 더 메모리를 할당한 후에 실제 사용할 메모리의 앞과 뒤에 오버런/언더런 탐지용 바이트를 두는 것이 가능하다.
- 효율을 향상시키기 위해 : operator new와 delete의 기본 버전은 일반적인 쓰임새에 맞추어 설계된 것이다. 이런 저런 상황에서 무난하게 동작하게 만들어져 있다는 것이다. 용도에 맞게 직접 만들어 쓰면 우수한 성능을 낼 수 있다. 실행 속도가 빠르고, 메모리도 적게 차지할 수 있다. (ex 단일 스레드로 동작하는 프로그램을 만든다면 스레드 안전성이 없는 할당자를 직접 만들어 씀으로써 속력 이득을 볼 수 있다. , 메모리 군집화를 구현해 페이지 폴트 발생 횟수를 최소화.)
- 동적 할당 메모리의 실제 사용에 관한 통계 정보 수집을 위해
- 기본 할당자의 바이트 정렬 동작을 보장하기 위해 (책에는 기본 제공 new 함수가 double에 대한 동적 할당 시 8바이트 정렬을 보장하지 않는다고 한다.)
- 보안 강화를 위해 : 해제한 메모리 블록에 0을 덮어쓰는 delete를 만드는 등.
- 바꿀 때 주의해야 하는 것 중 하나는 바이트 정렬이다. 컴퓨터는 구조적으로 특정 타입의 데이터가 특정 종류의 메모리 주소(4의 배수 등)를 시작 주소로 하여 저장될 것을 요구한다. 이를 지키지 않으면 하드웨어 예외를 일으키거나 런타임 접근 속도가 느려지는 등 성능이 떨어질 수 있다.
- operator new 함수는 어떤 데이터 타입에도 바이트 정렬을 적절히 만족하는 포인터를 반환해야 한다는 것이 C++의 요구사항이다. 표준 malloc 함수는 이 요구사항에 맞추어 구현되어 있기 때문에 amlloc에서 얻은 포인터를 operator new가 바로 반환하는 것은 안전하다. (예를 들어, 언더런 탐지용 바이트를 넣으면서도 바이트 정렬이 깨질 수 있다.)
- 시중에 나와 있는 컴파일러 중에는 메모리 관리 함수에 디버깅 및 로깅 기능을 넣어 놓고 필요에 따라 전환할 수 있도록 해 둔 것들도 있다. 그러니 쓰고 있는 컴파일러 문서를 훑어보는 시간을 가지자.
- 다른 사람이 만든 (오픈 소스) 메모리 할당자 중에 찾아보는 것도 방법. 부스트의 풀(Pool) 라이브러리에서 제공하는 메모리 할당자는 크기가 작은 객체를 많이 할당할 경우에 도움을 얻을 수 있다. TR1에는 타입에 따른 바이트 정렬 요구사항을 알려 주는 기능을 지원한다.
- 언제 operator new와 operator delete를 바꾸는가?
- 항목 51 : new 및 delete를 작성할 때 따라야 할 기존의 관례를 잘 알아 두자
- new는 0바이트가 요구되었을 때도 적법한 포인터를 반환해야 한다. (0바이트 요청이 들어오면 1바이트 요구로 간주하고 처리하는 것도 방법)
- 전역 new 처리자 함수를 얻어오는 직접적인 방법이 없다. 방법으로는, new 처리자 함수를 NULL로 설정하면서 반환 값으로 함수를 받아오고 다시 new 처리자 함수를 돌려놓는 것이다.
- new에는 whilte(true)문이 있다. 루프를 빠져나오는 방법은 메모리 할당이 성공하든지 아니면 new 처리자 함수쪽에서 해 주든지 둘 중 하나이다. 항목 49에 적혀있는 내용을 참고하자.
-
특정 클래스 전용 new를 구현했을 때, 상속에 의해 더 많은 메모리를 요구하는 파생 클래스가 기본 클래스의 new를 써서 예정된 크기(기본 클래스 크기)보다 더 큰 메모리를 요구하는 경우가 생길 수 있다. 이를 해결하는 방법 중 하나는 인자로 들어온 size를 기본 클래스의 size와 비교해서 다르면 표준 operator new를 호출하는 방법이다.
void* Base::operator new(std::size_t size) throw(std::bad_alloc) { if (size != sizeof(Base)) // 독립 구조 객체 크기는 0이 넘어서 size가 0인 경우도 처리된다. { return ::operator new(size); } } - 클래스 전용 배열 메모리 할당은 operator new[ ]를 구현하면 된다. 해 줄 일은 단순히 원시 메모리의 덩어리를 할당하는 것밖에 없다. 상속 때문에 객체 하나가 얼마나 큰지 확정할 방법이 없다. 또한 동적 할당된 배열에는 배열 원소의 개수를 담기 위한 자투리 공간이 추가로 들어간다.
- delete 구현에 대해서는, C++는 널 포인터에 대한 delete 적용이 항상 안전하도록 보장하므로, 이 보장을 유지해야 한다. 또한, new처럼 잘못된 메모리가 들어오면 표준 operator delete를 호출하는 방식 사용.
- 항목 52 : 위치지정 new를 작성한다면 위치지정 delete도 같이 준비하자
- operator new를 쓰면 new가 호출되고, 클래스의 기본 생성자가 호출된다. 만약, new는 잘 호출되고 생성자에서 예외가 발생하면 new에서 할당한 메모리를 취소해야 할 것이다. 이건 방법이 없다. 그러나 C++ 런타임 시스템이 자동으로 해 준다. 그 이유는 메모리 할당은 성공했지만 생성자에서 예외가 발생했을 경우, 런타임 시스템이 매개변수의 개수 및 타입이 똑같은 버전의 operator delete를 찾고, 있을 경우 그걸 호출해 주기 때문이다. (표준 new는 delete가 짝으로 제공되므로.) 만약 없으면 어떤 operator delete도 호출하지 않으며 이는 메모리 누수로 이어진다.
- new는 기본형과 달리 매개변수를 추가로 받는 형태로도 선언할 수 있다. 예를 들면, 어떤 객체를 생성시킬 메모리 위치를 나타내는 포인터를 매개변수로 추가로 받는 것이다. 사실 이것은 C++ 표준 라이브러리에 있다. 이렇게 매개변수를 추가로 받는 new를 위치지정 new라고 부른다. 앞 예제가 원조여서 이름이 이렇게 붙었다. 이렇게 표준에서 제공하는 경우는 delete도 쌍으로 제공한다.
-
표준이 아닌, 사용자 정의 예제를 하나 들면 객체를 동적 할당할 때 std::cerr(ostream)에 할당 정보를 로그로 기록하는 코드이다. 앞서 말했듯, 이런 경우엔 delete를 쌍으로 정의해 줘야 한다.
class Widget { public: static void* operator new(std::size_t, std::ostream& logStream) throw(std::bad_alloc) static void operator delete(void *pMemory, std::ostream& logStream) throw(); }; - 이렇게 위치지정 new에 쌍으로 맞춰준 delete는 해당 new 호출 때 메모리 할당은 성공했으나 생성자에서 예외가 발생할 때뿐이다. 직접 delete를 호출해 주면, 기본형의 operator delete가 호출된다.
- 위치지정 new를 선언했을 때 다른 new(표준 형태 new, 상속 받은 기본 클래스의 new 등)가 이름 가리기 되는 것을 주의해야 한다.
-
C++가 기본적으로 전역 유효 범위에서 제공하는 operator new 형태는 세 가지다. 의도한 것이 아니라면 이름 가리기 되지 않고 접근할 수 있게 해 줘야 한다. 그대로 사용하려면 클래스 전용 버전이 전역 버전을 호출하도록 구현하는 방법이 있다. 물론, delete도 쌍으로 만들어 줘야 한다.
void* operator new(std::size_t) throw(std::bad_alloc); // 기본형 new void* operator new(std::size_t, void*) throw(); // 위치지정 new void* operator new(std::size_t, const std::nothrow_t&) throw(); // 예외불가 new - 이름 가리기 되지 않게 쉽게 처리하는 방법은 이 세 가지 형태에 대해 전역 버전을 호출하도록 하나의 클래스에 전부 넣고, public 상속 + using을 하는 것이다.
Leave a comment