◆ 무한한 가능성/& Visual C/C++

[요약] Effective C++ 1장 (항목01~04)

치로로 2009. 11. 4. 18:11
[요약] Effective C++ 1장 (항목01~04)



1장 C++에 왔으면 C++의 법을 따릅시다.



항목 01: C++를 언어들의 연합체로 바라보는 안목은 필수

 1> C++
 : C, Object Oriented C++, Template C++, STL(Standard Template Library)

 - C++은 한 가지 프로그래밍 규칙 아래 똘돌 뭉친
   통합 언어(unified language)가 아니라, 네가지 하위 언어들의 연합체

 - 값을 넘겨줄때...  아래의 방법을 추천
   C: call by value
   C++: call by reference
   STL: call by value(pointer)


 *이것만은 잊지 말자!

  ** C++를 사용한 효과적인 프로그래밍 규칙은 경우에 따라 달라집니다.
     그 경우란, 바로 C++의 어떤 부분을 사용하느냐입니다.






항목 02: #define을 쓰려거든 const, enum, inline을 떠올리자

 1> #define 과 const

1: #define ASPECT_RATIO 1.653
2: const double AspectRatio = 1.653;

 - #define을 써서, 매크로로 코드에 ASPECT_RATIO가 등장하기만 하면 선행 처리자에 의해
1.653으로 모두 바뀌면서 결국 목적 코드 안에 1.653의 사본이 등장 횟수만큼 들어가게 되지만,
상수 타입의 AspectRatio는 아무리 여러 번 쓰이더라도 사본은 딱 한 개만 생기기 때문에
효율적(in memory?)

첫째, 상수 포인터(constant pointer)를 정의하는 경우...

1: const char* const authorName = "Scott Meyers";

 - #define을 상수로 교체하려면 두 가지 경우를 조심해야 함
  상수 정의는 대게 헤더 파일에 넣는 것이 상례이므로 포인터(pointer)는
 꼭 const로 선언해 주어야 하고 이와 아울러 포인터 지칭 대상까지 const로 하는게 보통

1:   const std::string authorName("Scott Meyers");

  - 위에서... 문자열 상수를 쓸 때 char* 기반의 구닥다리 문자열 보다는 string 객체가
대체적으로 사용이 쉬움

둘째, 클래스 멤버로 상수를 정의하는 경우... (즉, 클래스 상수를 정의하는 경우...)

1: class GamePlayer {
2: private:
3:         static const int NumTurns = 5;     // 상수 선언 (정의 아님!)
4:         int scores[Numturns];              // 상수를 사용하는 부분
5:         ...
6: };


  - 어떤 상수의 유효범위를 클래스 내부만 쓰는 private 로 할 경우,그 상수를 멤버로 만든다.
또한 상수의 사본이 한개를 넘지 못하게 하고 싶으면 static 멤버로 만든다.
  const int GamePlayer::Numturns;     // NumTurns의 정의. 값이 주어지지 않는 이유는...
  - 위의 소스문장은 헤더가 아닌 구현 파일에 둔다. 그리고 초기값은 이미 클래스 멤버 상수로
선언할 때에 초기값을 넣었다.


 2> enum: const인데 배열?

1: class GamePlayer {
2: private:
3:         enum { NumTurns = 5 };     // "나열자 둔갑술": NumTurns를 5에 대한 기호식 이름으로 만듬
4:         int scores[NumTurns];
5:         ...
6: }

  - 이는 '1.'의 둘째의 클래스 멤버로 상수를 정의하여 사용할 경우... GamePlayer::scores 등의
 배열 멤버를 선언할 때에 쿠식 컴파일에서 이 배열의 크기를 알 수 없어서 에러를 내뱉을 경우를
해결하기 위한 것이다.
  - 나열자 둔갑술(enum hack)을 사용하면...
 첫째, 동작방식이 const 보다는 #define에 더 가깝다. 즉, const의 주소를 잡아내는 것은
 합당한 것이지만, enum의 주소를 취하는 것은 불법이며, #define도 주소를 얻는 것 역시 맞지 않다.
 enum은 #define처럼 어떤 형태의 쓸데없는 메모리 할당도 절대 저지르지 않는다.
 둘째, 상당히 많은 코드에서 이 기법을 쓰고 있고, 템플릿 메타 프로그래밍의 핵심 기법이기도 하다.


 3> inline: #define 매크로 함수를 쓰지 마라.

1: // a와 b 중에 큰 것을 f에 넘겨 호출.
2: #define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

 - 위에서의 불편함은 많다. 각 인자마다 ()로 묶어주어야하고, 연산오류를 겪기도 한다.

1: int a = 5, b = 0;
2: CALL_WITH_MAX(++a, b);       // a가 두 번 증가. ㅡ_ㅡ;
3: CALL_WITH_MAX(++a, b+10);    // a가 한 번 증가.

 - 이는 f가 호출되기 전에 a가 증가하는 횟수가 달라지는 기현상을 발생시킨다.
 - 이때문에 기존 매크로의 효율을 그대로 유지하고 정규 함수의 모든 동작방식 및 타입 안전성까지 취하는
   inline 함수의 쓰임이 필요하다.

1:   template<typename T>                               // T가 정확히 무엇인지
2:   inline void callWithMax(const T& a, const T& b)    // 모르기 때문에, 매개변수로
3:   {                                                  // 상수 객체에 대한 참조자를 씀
4:           f(a>b ? a : b);
5:   }



 *이것만은 잊지 말자!

  ** 단순한 상수를 쓸 때는, #define보다 const 객체 혹은 enum을 우선 생각합시다.

  ** 함수처럼 쓰이는 매크로를 만들려면, #define 매크로보다 inline 함수를 우선 생각합시다.






항목 03: 낌새만 보이면 const를 들이대 보자!


1: char greeting[] = "Hello";
2: 
3: char *p = greeting;                  // 비상수 포인터, 비상수 데이터
4: 
5: const char *p = greeting;            // 비상수 포인터, 상수 데이터
6: 
7: char * const p = greeting;           // 상수 포인터, 비상수 데이터
8: 
9: const char * const p = greeting;     // 상수 포인터, 상수 데이터

 - const 키워드가 * 표의 왼쪽에 있으면, 포인터가 가리키는 대상이 상수
 - const 키워드가 * 표의 오른쪽에 있으면, 포인터 자체가 상수
 - const 키워드가 * 표의 양쪽에 있으면, 둘 다 상수

1: void f1(const Widget *pw);         // f1은 상수 Widget 객체에 대한 포인터를 매개변수로 취함
2: void f2(Widget const *pw);         // f2도 같음

 - 위의 함수들이 받아들이는 매개변수 타입은 모두 똑같다.

01: std:: vector<int> vec;
02: ...
03: 
04: const std::vector<int>::iterator iter = vec.begin();
05: // iter는 T* const 처럼 동작
06: *iter = 10;             // 정상~, iter가 가리키는 대상을 변경
07: ++iter;                 // 에러!, iter는 상수
08: 
09: std::vector<int>::const_iterator cIter = vec.begin();
10: // cIter는 const T* 처럼 동작
11: *cIter = 10;            // 에러!, *cIter가 상수이기 때문에 안됨
12: ++cIter;                // 정상~ cIter를 변경

 - (헷갈린다 -_-; 그냥 외워야지)
 - const std::vector<int>::iterator은 * 변경 가능
 - std::vector<int>::const_iterator은 * 변경 불가


* 상수 멤버 함수
 : 멤버 함수에 붙는 const 키워드의 역할은
  "해당 멤버 함수가 상수 객체에 대해 호출될 함수" 라는 사실을 알려줌
 : C++ 프로그램의 실행 성능을 높이는 핵심 기법 중 하나가 객체 전달을
  '상수 객체에 대한 참조자(reference-to-const)'로 진행하는 것이기 때문에...
  상수 상태로 전달된 객체를 조작할 수 있는 const 멤버 함수,
  즉 상수 멤버 함수가 준비되어 있어야 한다.
 : const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능

1: class TextBlock {
2: public:
3:         ...
4:         const char& operator[] (std::size_t position) const     // 상수 객체에 대한
5:         {   return TEXT[position];   }                          // operator[]
6:         char& operator[] (std::size_t position)                 // 비상수 객체에 대한
7:         {   return TEXT[position];   }                          // operator[]
8: private:
9:         std::string text;
10: };

1: TextBlock tb("Hello");
2: std::cout << tb[0];                     // TextBlock::operator[]의
3:                                         // 비상수 멤버를 호출
4: const TextBlock ctb("World");
5: std::cout << ctb[0];                    // TextBlock::operator[]의
6:                                         // 상수 멤버를 호출


위의 두 구문은 이해를 돕기 위한 용도의 성격이 짙고,
아래의 예가 실제와 가깝다.

1: void printf(const TextBlock& ctb)    // 이 함수에서 ctb는 상수 객체로 쓰임
2: {
3:         std::cout << ctb[0];             // TextBlock:operator[]의 상수
4:         ...
5: }

다음으로...
어떤 멤버 함수가 상수 멤버(const)라는 것이 대체 어떤 의미일까?
 : 비트수준 상수성[bitwise constness] == 물리적 상수석(physical constness) 과
   논리적 상수성(logical constness)


 1. 비트수준 상수성[bitwise constness] == 물리적 상수석(physical constness)
  : C++에서 정의하고 있는 상수성이 비트수준 상수성.
  : 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(정적멤버는 제외)
   그 멤버 함수가 'const'임을 인정하는 개념이다. 즉, 그 객체를 구성하는
   비트들 중 어떤 것도 바꾸면 안된다는 것

 >> 취약성은... '제대로 const'가 동작하지 않는데도 비트수준 상수성검사를 통과하는 것이다.
  예를 들어보자.

1: class CTextBlock {
2: public:
3:         ...
4:         char& operator[](std::size_t position) const     // 부적절한(그러나 비트수준
5:         { return pText[position]; }                      // 상수성이 있어서 허용되는)
6: private:                                             // operator[]의 선언
7:         char *pText;
8: };

 - 위의 코드에서 보듯이, operator[] 함수가 상수 멤버 함수로 선언되어 있는데 이것은 틀린것이다.
이를 이용하여...

1: const CTextBlock cctb("Hello");      // 상수객체를 선언
2: char *pc = &cctb[0];                 // 상수 버전의 operator[]를 호출하여 cctb의
3:                                      // 내부 데이터에 대한 포인터를 얻는다.
4: *pc = 'J';                           // cctb는 이제 "Jello"라는 값을 갖는다.

 - 위의 코드에서의 결과는... 'Jello'가 cctb에 들어가 있는 것을 확인할 수 있고, 이는 잘못된 연산이다.
  이를 보완하기 위해 논리적 상수성인 Logical constness 가 나온 것이다.


 2. 논리적 상수성(logical constness)
  : 비트수준 상수성에서의 취약점을 보완하는 대체 개념으로... 상수 멤버 함수라고 해서
   객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을
   사용자측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 것이다.

01: class CTextBlock {
02: public:
03:         ...
04:         std::size_t length() const;
05: private:
06:         char *pText;
07:         std::size_t textLength;                 // 바로 직전에 계산한 텍스트 길이
08:         bool lengthIsValid;             // 이 길이가 현재 유효한가?
09: };
10: 
11: std::size_t CTextBlock::length() const
12: {
13:         if (!lengthIsValid)
14:         {
15:                 textLength = std::strlen(pText);        // 에러! 상수 멤버 함수 안에서는
16:                 lengthIsValid = true;                   // textLength 및 lengthIsValid에
17:         }                                           // 대입할 수 없습니다.
18: 
19:         return textLength;
20: }

 - 위의 length()의 구현은 '비트수준 상수성'과 멀리 떨어진 코드이다.
하지만, CTextBlock의 상수 객체는 아무 문제가 없는 코드이다. 컴파일러는 비트 수준의 상수성이
지켜져야 되서 에러를 낼 것이지만,이를 해결하기위해~
C++에서는 mutable을 제공한다.

01: class CTextBlock {
02: public:
03:         ...
04:         std::size_t length() const;
05: private:
06:         char *pText;
07:         mutable std::size_t textLength;                 // 이 데이터 멤버들은 어떤 순간에도 수정가능
08:         mutable bool lengthIsValid;             // 심지어 상수 멤버 함수 안에서도 수정가능
09: };
10: 
11: std::size_t CTextBlock::length() const
12: {
13:         if (!lengthIsValid)
14:         {
15:                 textLength = std::strlen(pText);        // No Problem!
16:                 lengthIsValid = true;                   // No Problem!
17:         }
18: 
19:         return textLength;
20: }

 - mutable을 이용하여 상수멤버함수 안에서도 연산이 가능하도록 만들 수 있다.
이는 논리적 상수성(logical constness)의 개념을 말하는 것이다.


* 상수 멤버 및 비상수 멤버 함수에서 코드 중복 현상을 피하는 방법

01: class TextBlock {
02: public:
03:         ...
04:         const char& operator[] (std::size_t position) const
05:         {
06:                 ...                         // 경계 검사
07:                 ...                         // 접근 데이터 로깅
08:                 ...                         // 자료 무결성 검증
09:                 return text[position];
10:         }
11:         char& operator[] (std::size_t positioin)
12:         {
13:                 ...                         // 경계 검사
14:                 ...                         // 접근 데이터 로깅
15:                 ...                         // 자료 무결성 검증
16:                 return text[position];
17:         }
18: private:
19:         std::string text;
20: };

 - 위의 경계검사, 접근데이터로깅, 자료무결성검증 에 대한 중복 호출로 인한 비효율성을 볼 수 있다.
  이를 해결하기 위한 방법이 바로 비상수 함수에서 상수 함수롤 호출하여 중복성을 없애는 방법이 있다.
  그것을 캐스팅이라고 부른다.

01: class TextBlock {
02: public:
03:         ...
04:         const char& operator[] (std::size_t position) const    // 이전과 동일
05:         {
06:                 ...
07:                 ...
08:                 ...
09:                 return text[position];
10:         }
11:         char& operator[] (std::size_t positioin)        // 상수버전 op[]를 호출하고 끝
12:         {
13:                 // op[]의 반환 타입에 캐스팅을 적용하여 const를 떼어냄
14:                 return const_cast<char&>(
15:                         // *this의 타입에 const를 붙여 op[]의 상수 버전을 호출
16:                         static_cast<const TextBlock&> (*this)[positioin]
17:                 );
18:         }
19:         ...
20:  };

 - const 를 붙이는 캐스팅은 안전한 타입 변환(비상수→상수)을 강제로 진항하는 것이기 때문에
  static_cast만 써도 딱 맞다. 반면에 const를 제거하는 캐스팅은 const_cast밖에 없다.



 *이것만은 잊지 말자!

  ** const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는데 도움을 줍니다.
     const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및
     반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있습니다.

  ** 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만,
     여러분은 개념적인(논리적인) 상수성을 사용해서 프로그래밍 해야 합니다.

  ** 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는
     코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만드세요.





항목 04: 객체를 사용하기 전에 반드시 그 객체를 초기화 하자.

 - C++의 C부분(항목 1 참조)만을 쓰고 있으며 초기화에 런타임 비용이 소모될 수 있는 상황이라면
값이 초기화된다는 보장이 없습니다. 그렇지만 C가 아닌 부분으로 발을 거맃게 되면 사정이 때때로 달라짐
 - 배열(C++의 C부분)은 각 원소가 확실히 초기화된다는 보장이 없으나
 - Vector(C++의 STL부분)는 그러한 보장을 갖게 되는 이유가 바로 이런 법칙 때문입니다.

 - 가장 좋은 방법은 모든 객체를 사용하기 전에 항상 초기화 하는 것

1: int x = 0;                                  // int의 직접 초기화
2: 
3: const char *text = "A C-style string";      // 포인터의 직접 초기화(항목3참조)
4: 
5: double d;                                   // 입력 스트림에서 읽음으로써
6: std::cin >> d;                              // "초기화" 수행

위에서 보는 것처럼, 초기화 하는 것은 참 쉽지만...
잊어버리지 말아야 할 것이 있다.

그것은, 대입(assignment)을 초기화(initialization)와 헷갈리지 않는 것 참 중요하다.


*대입(assignment)

01: class PhoneNumber { ... };
02: 
03: class ABEntry {
04: public:
05:         ABEntry(const std::string &name, const std::string &address,
06:                 const std::list<PhoneNumber> &phones);
07: private:
08:         std::string theName;
09:         std::string theAddress;
10:         std::list<PhoneNumber> thePhones;
11:         int numTimesConsulted;
12: };
13: 
14: 
15: ABEntry::ABEntry(const std::string &name, const std::string &address,
16:                                  const std::list<PhoneNumber> &phones)
17: {
18:         theName = name;                  // 지금은 모두 '대입'을 하고 있다. 
19:         theAddress = address;            // '초기화'가 아님!!!
20:         thePhones = phones;
21:         numTimesConsulted = 0;
22: }

*초기화(initialization)

1: ABEntry::ABEntry(const std::string &name, const std::string &address,
2:                                  const std::list<PhoneNumber> &phones)
3:                                  : theName(name),
4:                                    theAddress(address),      // 모두 '초기화' 되고 있음
5:                                    thePhones(phones),
6:                                    numTimesConsulted(0)
7: {}        // 생성자 본문엔 아무것도 없음


*C++에서 객체를 구성하는 데이터의 초기화 순서

 ① 기본 클래스는 파생 클래스보다 먼저 초기화되고,
 ② 클래스 데이터 멤버는 그들이 선언된 순서대로 초기화 된다.


*정적 객체(static object)
 : 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 개체

 - 정적 객체의 범주
  ① 전역객체
  ② 네임스페이스 유효범위에서 정의된 객체
  ③ 클래스 안에서 static으로 선언된 객체
  ④ 함수 안에서 static으로 선언된 객체
  ⑤ 파일 유효범위에서 static으로 정의된 객체

 - 지역 정적 객체(local static object)
  : 함수 안에 있는 정적 객체

 - 비지역 정적 객체(non-local static object)
  : 지역 정적 객체의 나머지, == 전역


* 번역 단위(translation unit)
 : 컴파일을 통해 하나의 목적 파일(object file)을 만드는 바탕이 되는 소스 코드.
  여기서의 번역은 소스의 언어를 기계어로 바꾼다는 말


* 참고로...
 : 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 '정해져 있지 않다'.
즉, 함수 내부에 static 로 정의된 객체들의 초기화는 지맘대로~


 비지역 정적 객체가 순서대로 초기화 되지 않는 것에 대한 문제점이 있다.
그것에 대한 문제점이 무엇인지 짚어보고 해결해보자.

1: class FileSystem {                   // 여러분의 라이브러리에 포함된 클래스
2: public:
3:         ...
4:         std::size_t numDisks() const;   // 많고 많은 멤버 함수들 중 하나
5:         ...
6: };
7: 
8: extern FileSystem tfs;               // 사용자가 쓰게 될 객체
9:                                      // "tfs" = "the file system"

01: class Directory {                   // 라이브러리의 사용자가 만든 클래스
02: public:
03:         Directory(params);
04:         ...
05: };
06: 
07: Directory::Directory(params)
08: {
09:         ...
10:         std::size_t disks = tfs.numDisks();   // tfs 객체를 여기서 사용함
11:         ...
12: }


1: Directory tempDir( params );            // 임시 파일을 담는 디렉토리

위의 두 클래스와 아래의 tempDir을 보면,
tempDir 보다 tfs 가 먼저 초기화 되지 않으면 안된다는 것을 알 수 있다.

하지만 우리는 앞에서 본 것과 같이
위의 비지역 정적 객체에서의 초기화 순서는 컴파일러 맘대로 라는 것이라는 것을 기억한다.

그렇다면, 이를 어떻게 든 고쳐서 tfs 를 먼저 초기화 시켜야 한다.
그것은 무엇일까?

바로! '비지역 정적 객체'를 '지역 정적 객체'로 바꾸면 되는 것이다.

한번 바꿔보자.


01: class FileSystem { ... };                   // 이전과 다를 것 없는 클래스
02: 
03: FileSystem &tfs()                           // tfs 객체를 이 함수로 대신함
04: {                                           // 이는 클래스 안에서 정적 멤버로 들어가도 됨
05:         static FileSystem fs;                   // 지역 정적 객체를 정의하고 초기화
06:         return fs;                              // 이 객체에 대한 참조자를 반환
07: }
08: 
09: class Directory{ ... };                     // 역시 이전과 다를 것 없는 클래스
10: 
11: Directory::Directory(params)                // 이전과 동일, tfs의 참조자였던 것이
12: {                                           // 지금은 tfs()로 바뀌었다는 것만 다름
13:         ...
14:         std::size_t disks = tfs().numDisk();
15:         ...
16: }
17: 
18: Directory &tempDir()                        // tempDir 객체를 이 함수로 대신함
19: {                                           // 이는 Directory 클래스의 정적 멤버로 들어가도 됨
20:         static Directory td;                    // 지역 정적 객체를 정의/초기화
21:         return td;                              // 이 객체에 대한 참조자를 반환
22: }

 참조자 반환 함수를 사용하는 것으로, 초기화 순서 문제를 방지할 수 있다.
 참조자 반환 함수는 내부적으로 정적 객체를 쓰기 때문에,
다중 스레드 시스템에서는 동작에 장애가 생길 수도 있다.

혹시나 다중스레드가 돌아가는 프로그램이라면, 비상수 정적 객체(지역 객체이든 비지역이든)는
온갖 골칫거리의 시한폭단이라고 보면 된다.
골칫거리를 다루는 한가지 방법으로, 프로그램이 다중스레드로 돌입하기 전의 시동 단계에서
참조자 반환 함수를 전부 손으로 호출해 줄 수 있다. 이렇게 하면 초기화에 관계된
경쟁 상태(race condition)이 없어진다.

그리고 중요한 것이 참조자 반환 함수에서의 초기화 순서이다.
이 순서를 틀려서 오류가 나는 것은 만든이의 실수로 인한 오류라서 욕먹어도 싸지만,
순서만 잘 지키면 더할나위없이 좋은 것이다.


정리를 하자면...
첫째, 멤버가 아닌 기본제공 타입 객체는 여러분 손으로 직접 초기화 할 것
둘째, 객체의 모든 부분에 대한 초기화에는 멤버 초기화 리스트를 사용
셋째, 별개의 번역 단위에 정의된 비지역 정적 객체에 영향을 끼치는 불확실한
       초기화 순서를 염두에 두고 이러한 불확실성을 피해서 프로그램을 설계





 *이것만은 잊지 말자!

  ** 기본제공 타입의 객체는 직접 손으로 초기화합니다.
     경우에 따라 저절로 되기도 하고 안되기도 하기 때문입니다.

  ** 생성자에서는, 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로
     멤버를 초기화하지 말고 멤버 초기화 리스트를 즐겨 사용합시다. 그리고 초기화 리스트에
     데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열합시다.

  ** 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 합니다.
     비지역 정적 객체를 지역 정적 객체로 바꾸면 됩니다.





※ Effective C++ 의 책을 개인적으로 요약한 것입니다.
책의 저작권 등등 각종 권한은 출판사와 지은이/옮긴이에 있음
 - 출판: Addison Wesley
 - 지음: 스콧 마이어스
 - 옮김: 곽용재