Effective C++ 3판 요약정리 DarkKaiser, 2007년 7월 17일2023년 8월 30일 C++에 왔으면 C++의 법을 따릅시다 항목 1. C++를 언어들의 연합체로 바라보는 안목은 필수 C++를 사용한 효과적인 프로그래밍 규칙은 경우에 따라 달라집니다. 그 경우란, 바로 C++의 어떤 부분을 사용하느냐입니다. 항목 2. #define을 쓰려거든 const, enum, inline을 떠올리자 단순한 상수를 쓸 때는, #define 보다 const 객체 혹은 enum을 우선 생각합시다. 함수처럼 쓰이는 매크로를 만들려면, #define 매크로보다 인라인 함수를 우선 생각합시다. 항목 3. 낌새만 보이면 const를 들이대 보자 const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 줍니다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있습니다. 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 여러분은 개념적인 상수성을 사용해서 프로그래밍해야 합니다. 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만드세요. 항목 4. 객체를 사용하기 전에 반드시 그 객체를 초기화하자 기본제공 타입의 객체는 직접 손으로 초기화합니다. 경우에 따라 저절로 되기도 하고 안되기도 하기 때문입니다. 생성자에서는, 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고 멤버 초기화 리스트를 즐겨 사용합시다. 그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열합시다. 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 합니다. 비지역 정적 객체를 지역 정적 객체로 바꾸면 됩니다. 생성자, 소멸자 및 대입 연산자 항목 5. C++가 은글슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자 컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있습니다. 항목 6. 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자 컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현을 하지 않은 채로 두십시오. Uncopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법입니다. 항목 7. 다형성을 가진 기본 클래스에서는 솜려자를 반드시 가상 소멸자로 선언하자 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다. 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 합니다. 항목 8. 예외가 소멸자를 떠나지 못하도록 붙들어 놓자 소멸자에서는 예외가 빠져나가면 안 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 합니다. 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 합니다. 항목 9. 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자 생성자 혹은 소멸자 안에서 가상 ㅎ마수를 호출하지 마세요. 가상 함수라고 해도, 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않으니까요. 항목 10. 대입 연산자는 *this의 참조자를 반환하게 하자 대입 연산자는 *this의 참조자를 반환하도록 만드세요 항목 11. operator=에서는 자기 대입에 대한 처리가 빠지지 않도록 하자 operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만듭시다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다. 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보세요 항목 12. 객체의 모든 부분을 빠짐없이 복사하자 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다. 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분래해 놓고 양쪽에서 이것ㅇ르 호출하게 만들어서 해결합시다. 자원 관리 항목 13. 자원 관리에는 객체가 그만! 자원 누츨을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII(Resource Acquisition Is Initialization: 자원 획득 즉 초기화) 객체를 사용합시다. 일반적으로 널리 쓰이는 RAII 클래스는 std::tr1::shared_ptr 그리고 std::auto_ptr 입니다. 이 둘 가운데 std::tr1::shared_tr이 복사 시의 동작이 직관적이기 때문에 대개 더 좋습니다. 반면, auto_ptr은 복사되는 객체를 null로 만들어 버립니다. 항목 14. 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자 RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의 복사 동작이 결정됩니다. RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해 주는 선으로 마무리하는 것입니다. 하지만 이 외의 방법들도 가능하니 참고해 둡시다. 항목 15. 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자 실제 자원을 직접 접근해야 하는 기존 API들도 많기 때문에, RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을 열어 주어야 합니다. 자원 접근은 명시적 변환(get() 등의 함수를 이용한 자원 획득) 혹은 암시적 변환(자동 형변환을 통한 자원 획득, operator HANDLE() const {} )을 통해 가능합니다. 안정성만 따지면 명시적 변환이 대체적으로 더 낫지만, 고객 편의성을 놓고 보면 암시적 변환이 괜찮습니다. 항목 16. new 및 delete를 사용할 때는 형태를 반드시 맞추자 new 표현식에 []를 썼으면, 대응되는 delete 표현식에도 []를 써야 합니다. 마찬가지로 new 표현식에 []를 안 썼으면, 대응되는 delete 표현식에도 []를 쓰지 말아야 합니다. 항목 17. new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자 new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만듭시다. 이것이 안 되어 있으면, 예외가 발생될 때 디버깅하기 힘든 자원 누출이 초래될 수 있습니다. processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority()); processWidget 함수 호출이 이루어지기 전에 컴파일러는 다음의 세 가지 연산을 위한 코드를 만듭니다. prioirty()를 호출합니다. “new Widget”을 실행합니다. tr1::shared_ptr 생성자를 호출합니다. 그런데, 여기서 각각의 연산이 실행되는 순서는 컴파일러 제작사마다 다르다는 게 문제입니다. C++ 컴파일러의 경우엔 이들의 순서를 정하는 데 있어서 상당한 자유도를 갖고 있습니다. “new Widget” 표현식은 tr1::shared_ptr 생성자가 실행될 수 있기 전에 호출되어야 하겠지요. 그러나 priority의 호출은 처음 호출될 수도 있고, 두 번째나 세 번쩨에 호출될 수도 있습니다. 만일 어떤 컴파일러에서 두 번째라고 정했다면 연산 순서는 다음과 같이 결정됩니다. “new Widget”을 실행합니다. prioirty()를 호출합니다. tr1::shared_ptr 생성자를 호출합니다. 하지만 priority 호출 부분에서 예외가 발생했다면 어떻게 될지 생각해 보세요. “new Widget”으로 만들어졌던 포인터가 유실될 수 있겠죠? 그리니까 processWidget 호출 중에 자원이 노출될 가능성이 있는 이유는, 자원이 생성되는 시점과 그 자원이 자원 관리 객체로 넘어가는 시점 사이에 예외가 끼어들 수 있기 때문입니다. 이런 문제를 피해가는 방법은 간단합니다. Widget을 생성해서 스마트 포인터에 저장한 코드를 별도의 문장 하나로 만들고, 그 스마트 포인터를 processWidget에 넘기는 겁니다. std::tr1::shared_ptr<Widget> pw(new Widget); processWidget(pw, prioirty()); 설계 및 선언 항목 18. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자 좋은 인터페이스는 제대로 쓰기엔 쉬우며 엉터리로 쓰기엔 어렵습니다. 인터페이스를 만들때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다. 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본 제공 타입과의 동작 호환성 유지하기가 있습니다. 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있습니다. tr1::shared_ptr은 사용자 정의 삭제자를 지원합니다. 이 특징 때문에 tr1::shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있습니다. 항목 19. 클래스 설계는 타입 설계와 똑같이 취급하자 클래스 설계는 타입 설계입니다. 새로운 타입을 정의하기 전에, 아래의 모든 고려사항을 빠짐없이 점검해 보십시오. 고려사항 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가? 객체 초기화는 객체 대입과 어떻게 달라야 하는가? 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가? 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가? 기존의 클래스 상속 계통망에 맞출 것인가? 어떤 종류의 타입 변환을 허용할 것인가? 어떤 연산자와 함수를 두어야 의미가 있을까? 표준 함수들 중 어떤 것을 허용하지 말 것인가? 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가? ‘선언되지 않은 인터페이스’로 무엇을 둘 것인가? 새로 만드는 타입이 얼마나 일반적인가? 정말로 꼭 필요한 타입인가? 항목 20. ‘값에 의한 전달’보다는 ‘상수객체 참조자에 의한 전달’ 방식을 택하는 편이 대개 낫다 ‘값에 의한 전달’보다는 ‘상수 객체 참조자에 의한 전달’을 선호합시다. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아 줍니다. 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않습니다. 이들에 대해서는 ‘값에 의한 전달’이 더 적절합니다. 항목 21. 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 마세요. 항목 22. 데이터 멤버가 선언될 곳은 private 영역임을 명심하자 데이터 멤버는 private 멤버로 선언합시다. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐 아니라, 내부 구현의 융통성도 발휘할 수 있습니다. protected는 public보다 더 많이 ‘보호’받고 있는 것이 절대로 아닙니다. 항목 23. 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자 멤버 함수보다는 비멤버 비프렌드 함수를 자주 쓰도록 합시다. 캡슐화 정도가 높아지고, 패키징 유연성도 커지며, 기능적인 확장성도 늘어납니다. 항목 24. 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자 어떤 함수에 들어가는 모든 매개변수에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 합니다. 항목 25. 예외를 던지지 않는 swap에 대한 지원도 생각해 보자 std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공합시다. 이 멤버 swap은 예외를 던지지 않도록 만듭시다. 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공합니다. 클래스(템플릿이 아닌)에 대해서는, std::swap도 특수화해 둡시다. 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap을 호출합시다. 사용자 정의 타입에 대한 std 텝플릿을 완전 특수화하는 것은 가능합니다. 그러나 std에 어떤 것이라도 새로 ‘추가’하려고 들지는 마십시오. 구현 항목 26. 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자 변수 정의는 늦출 수 있을 때까지 늦춥시다. 프로그램이 더 깔끔해지며 효율도 좋아집니다. 항목 27. 캐스팅은 절약, 또 절약! 잊지 말자 다른 방법이 가능하다면 캐스팅은 피하십시오. 특히 수행 성능에 민감한 코드에서 dynamic_cast는 몇 번이고 다시 생각하십시오. 설계 중에 캐스팅이 필요해졌다면, 캐스팅을 쓰지 않는 다른 방법을 시도해 보십시오. 캐스팅이 어쩔 수 없이 필요하다면, 함수 안에 숨길 수 있도록 해 보십시오. 이렇게 하면 최소한 사용자는 자신의 코드에 캐스팅을 넣지 않고 이 함수를 호출할 수 있게 됩니다. 구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호하십시오. 발견하기도 쉽고, 설계자가 어떤 역할을 의도했는지가 더 자세하게 드러납니다. const_cast 객체의 상수성을 업애는 용도로 사용됩니다. dynamic_cast ‘안전한 다운캐스팅’을 할 때 사용하는 연산자입니다. 즉, 주어진 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정하는 작업에 쓰입니다. 하지만 신경 씅리 정도로 런타임 비용이 높은 캐스트 연산자입니다. reinterpret_cast 포인터를 int로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자로서, 이것의 적용 결과는 구현환경에 의존적입니다(이식성이 없다는 뜻이죠). 이런 캐스트는 하부 수준 코드 외에는 거의 없어야 합니다. static_cast 암시적 변환을 강제로 진행할 때 사용합니다. 흔히들 이루어지는 타입 변환을 거꾸로 수앵하는 용도(void*를 일반 타입의 포인터로 바꾸거나, 기본 클래스의 포인터를 파생 클래스의 포인터로 바꾸는 등)로도 쓰입니다. 항목 28. 내부에서 사용하는 객체에 대한 ‘핸들’을 반환하는 코드는 되도록 피하자 어떤 객체의 내부요소에 대한 핸들(참조자, 포인터, 반복자)을 반환하는 것은 되도록 피하세요. 캡슐화 정도를 높이고, 상수 멤버 함수가 객체의 상수성을 유지한 채로 동작할 수 있도록 하며, 무효참조 핸들이 생기는 경우를 최소화할 수 있습니다. 항목 29. 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자! 예외 안전성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않습니다. 이런 함수들이 제공할 수 있는 예외 안정성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있습니다. 강력한 예외 안정성 보장은 ‘복사-후-맞바꾸기’ 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아닙니다. 어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않습니다. 기본적인 보장 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장입니다. 어떤 객체나 자료구조도 더럽혀지지 않으며, 모든 객체의 상태는 내부적으로 일관성을 유지하고 있습니다. 하지만 프로그램의 상태가 정확히 어떠한지는 예측이 안 될 수도 있습니다. 강력한 보장 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장입니다. 이런 함수를 호출하는 것은 원자적인 동작이라고 할 수 있습니다. 호출이 성공하면 마무리까지 완벽하게 성공하고, 호출이 실패하면 함수 호출이 없었던 것처럼 프로그램의 상태가 되돌아간다는 면에서 말이죠. ‘쓰기 편한가’의 측면에서 보면 강력한 보장을 제공하는 함수가 기본 보장을 제공하는 함수보다 더 쉽습니다. 예측할 수 있는 프로그램의 상태가 두 개밖에 안 되기 때문입니다. 그러니까 ㅎ마수가 성공적으로 실행을 마친 후의 상태, 아니면 함수가 호출될 때의 상태만이 존재하는 거죠. 이와 대조적으로 함수가 기본 보장을 제공할 경우에는, 예외 발생 시에 프로그램이 있을 수 있는 상태가 그냥 유효하기만 하면 어떤 상태도 될 수 있습니다. 예외 불가 보장 예외를 절대로 던지지 않겠다는 보장입니다. 항목 30. 인라인 함수는 미주알고주알 따져서 이해해 두자 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는 것으로 묶어둡시다. 이렇게하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 잇는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있는 여지가 최고로 많아집니다. 함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline으로 선언하면 안 됩니다. 항목 31. 파일 사이의 첨파일 의존성을 최대로 줄이자 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 ‘정의’ 대신에 ‘선언’에 의존하게 만들자는 것입니다. 이 아이이더에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스입니다. 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 합니다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용합시다. 템플릿과 일반화 프로그래밍 Book Effective C++