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를 써서 동적 할당 배열을 쓰지 않는 것도 좋다.
- 항목 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, 포인터 등)에 대한 모든 연산은 예외를 던지지 않게 되어 있다. (모던 C++인 noexcept를 적용해서 표시하자)
- 강력한 보장을 만들기 위한 방법 : 스마트 포인터로 메모리 관리, 복사 후 맞바꾸기 (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++은 아니다. 인터페이스 클래스를 쓰고, 팩토리 함수 혹은 가상 생성자를 쓰면 된다.
- 인터페이스 클래스나 핸들 클래스가 추가 실행, 메모리 비용이 들긴 하지만, 일단 쓰고 제품 출시 때 실행 속력이나 파일 크기를 줄이기 위해 최적화를 할 때 없애는 것을 고려하자.
Leave a comment