[Effective C++(3판)]에 대한 내용을 공부하면서 정리한 내용이다.
이번에 요약하는 내용은 01~04 항목으로 아래와 같다.
- 01. C++를 언어들의 연합체로 바라보는 안목은 필수
- 02. #define을 쓰려거든 const, enum, inline을 떠올리자
- 03. 낌새만 보이면 const를 들이대 보자!
- 04. 객체를 사용하기 전에 반드시 그 객체를 초기화하자
요약
01. C++를 언어들의 연합체로 바라보는 안목은 필수
기존의 C++은 C 언어에 OOP 몇 가지를 결합한 형태에 불과했다. 그에 따라 처음의 이름 역시 "C with Classes"였다. 하지만, 시간이 지남에 따라 C++은 점차 과감하게 변화했다. Exception, Template, STL이 그러한 변화의 산출물이다. 이제 C++은 다중 패러다임 프로그래밍 언어(multiparadigm programming language)로 부른다.
(개인적으로 모던 C++이 되면서 TR1의 다양한 기능을 합치면서 훨씬 완성된 것 같다.)
다중 패러다임 프로그래밍 언어라 부르는 이유는, 절차적(procedural), 객체 지향적(object-oriented), 함수식(functional), 일반화(generic) 프로그래밍을 지원할 뿐만 아니라 메타 프로그래밍(metapo-programming)까지 지원되기 때문이다.
일반화 프로그래밍을 대표하는 것은 Template이다. Template 개념이 등장함으로써 TMP(Template MetaProgramming)라는 완전히 새로운 프로그래밍 패러다임까지 파생되기도 했다.
02. #define
을 쓰려거든 const, enum, inline을 떠올리자
전처리자(#define
) 보다는 컴파일러를 가까이하는 습관을 들여야 한다.
#define
이 안좋은 이유 중 하나를 예로 들어보자. 소스코드에서는 defined symbolic name을 쓰지만, 에러라도 발생하게 된다면 defined value가 들어갈 것이다. 그럴 경우, 오류에 대한 분석이 더 어려워질 것이다.
이를 방지하기 위해 가장 괜찮은 방법이 상수(const)를 쓰는 것이다. 다만, 상수를 쓸 때 주의할 점이 있다.
- 상수 포인터(constant pointer)
- 포인터와 포인터의 대상 모두 상수가 되어야하는지 확인해야 한다.
- ex. const char * const name = "Name";
- 위의 예는 std::string을 쓰면 보다 효과적으로 나타낼 수 있다.
- ex. const std::string name = "Name";
- 포인터와 포인터의 대상 모두 상수가 되어야하는지 확인해야 한다.
- 클래스의 상수 멤버 정의 (클래스 상수)
- 상수를 사용할 때 유효 범위에 주의해야 한다.
class A { private: static const int num = 5; // 상수 선언 int arr[num]; // 상수 사용 }
#define
은 유효범위가 별도 지정 불가능하다. 즉,#undef
하지 않는 이상 유효하고, 따라서 캡슐화가 불가능하다.- 구식 컴파일러는 정수 타입이라도 클래스 내부의 선언과 동시에 정의를 지원하지 않을 수 있다. 이 경우에는 위 코드에서
int arr[num]
에서 문제가 발생할 수 있다. 이때 enum hack 기법으로 문제를 피할 수 있다.
- 상수를 사용할 때 유효 범위에 주의해야 한다.
위에서 언급한 enum hack은 enumerator 타입으로 const int를 대체하는 것이다. 그러나, enum hack 자체는 const 보다는 #define와 더 비슷하다. 하지만, 아래의 경우는 enum hack이 const 보다 효과적인 경우다.
#define
과 같이 값의 주소를 제공하지 않는다. 값의 주소를 받지 못하게 하기 위해서는 효과적이다.- 구식 컴파일러는 const에 대한 메모리를 확보할 수도 있다. 이때는 필요에 따라 enum이 효과적이다.
- TMP의 핵심 기법이다.
#define
을 썼을 때 발생 가능한 다른 문제는, macro 함수를 정의했을 때다. 아래와 같은 경우에 macro 함수는 예상한 것과 달리 동작할 것이다.
#define MAX(a,b) f(((a) > (b)) ? (a) : (b))
...
int a = 5, b = 0;
MAX(++a, b); // a가 한 번 증가됨
MAX(++a, b + 10); // a가 두 번 증가됨
이와 같은 경우에 인라인 함수의 템플릿을 구현하게 되면, 기존 macro의 효율성과 동시에 안전성도 확보할 수 있다. 인라인 함수의 템플릿을 사용하면, 템플릿을 사용했기 때문에 동일계열 함수군(family of functions)을 만든다. 보다 코드가 깔끔하면서 안전하게 동작한다. 또한, 결국에 함수이기 때문에 유효 범위 및 접근 규칙에 대해 적용받는다. 이를 이용해임의의 클래스 안에서만 유효하게 할 수 있다.
template<typename T>
inline void MAX(const T &a, const T &b) {
f((a > b) ? a : b);
}
03. 낌새만 보이면 const를 들이대 보자!
컴파일러 덕분에 소스코드 수준에서 상수를 보장하게 된다. 불변해야 할 값에 대해 적용했을 때, 개발자의 의도를 확실히 할 수 있다.
아래와 같이 객체 선언에서의 const 위치에 따라 상수화하려는 대상이 달라진다.
char szText[] = "hello";
char *p = szText; // 비상수 포인터, 비상수 데이터
const char *p = szText; // 비상수 포인터, 상수 데이터
char * const p = szText; // 상수 포인터, 비상수 데이터
const char * const p = szText; // 상수 포인터, 상수 데이터
요약하자면, 포인터(*) 왼쪽에 const가 붙을 경우, 대상이 상수를 의미한다. 반대로, 포인터(*) 오른쪽에 const가 붙을 경우, 대상의 포인터가 상수를 의미한다.
하지만, 실제 현업에서는 의미적으로 같게 사용하기는 한다.
이번에는 iterator로 예제 코드를 살펴보자. iterator는 포인터를 이용한 것이라고 알려진만큼 아래와 같이 차이가 존재한다.
std::vector<int> vec;
const std::vector<int>::iterator iter = vec.begin();
*iter = 10; // OK. iter가 가리키는 대상은 비상수라 변경 가능함
++iter; // Error. iter는 상수이기 때문에 변경 불가능함
std::vector<int>::const_iterator cIter = vec.begin();
*cIter = 10; // Error. cIter가 가리키는 대상은 상수라 변경 불가능함
++cIter; // OK. cIter는 변경 가능함
const를 가장 강력하게 사용/활용할 수 있는 부분은 함수의 선언부다. 함수의 선언부에서는 const를 인자값에 설정할 수도 있고, 반환값에 설정할 수도 있고, 멤버 함수 자체에 대해 설정할 수도 있다.
아래 코드는 반환값에 대한 상수 선언인데, 이를 가볍게 생각하는 사람들이 많다. 하지만, 반환값의 상수화는 아래 코드의 마지막 줄과 같은 당연히 안되는 코드를 작성하는 실수를 방지할 수 있는 효과적인 대응을 제공한다.
const Rational operator *(const Rational &lhs, const Rational &rhs);
(a * b) = c; // Error.
상수 멤버 함수는 함수 선언부 마지막에 const를 붙인 것을 말한다. 상수 멤버 함수를 사용하는 목적은 아래 두 가지로 볼 수 있다.
- 클래스의 인터페이스를 이해하기 쉽다.
- 상수 객체를 사용할 수 있다.
- C++의 성능을 높이는 방법 중 '상수 객체에 대한 참조자(reference-to-const)'를 이용해 객체를 전달하는 방법이 있다.
- 이를 이용하기 위해서는 상수 멤버 함수가 준비되어 있어야 한다.
상수 멤버 함수의 선언(const 유무)은 오버로딩이 가능하다. 상수 객체에서는 상수 멤버 함수를, 일반 객체에서는 일반 멤버 함수를 호출하게 된다. 이때 상수 객체에서 상수 멤버 함수를 호출한 경우에는 write을 할 경우 문제가 발생한다. (read는 가능)
// 상수 멤버 함수 예
const char &operator [] (std::size_t position) const {
return text[position];
}
// 일반 멤버 함수 예 (오버로딩 허용)
char &operator [] (std::size_t position) {
return text[position];
}
// 상수 멤버 함수 사용 예
// 상수 객체를 인자로 받아, 상수 멤버 함수를 호출
void print(const CTextBlock &ctb) {
std::cout << ctb[0]; // OK. 읽기 가능
ctb[0] = 'x'; // Error. 쓰기 불가능
}
const의 종류는 아래의 두 가지로 나눌 수 있다.
- 비트 수준 상수성(bitwise constness) 또는 물리적 상수성(physical constness)
- 어떤 멤버 함수가 그 객체의 어떤 것도(정적 멤버 제외) 건드리지 않아야 한다.
- 멤버에 대한 대입 연산이 불가함을 의미한다.
- 그러나, 멤버 변수가 포인터일 때, 포인터가 가리키는 대상에 대한 수정은 허용되는 경우가 있다.
- 이에 대한 보완으로 논리적 상수성 개념이 등장했다.
class CTextBook { public: std::size_t length() const { if ( false == isLengthValid ) { // 상수 멤버 함수에서 비상수 객체를 변경 siTextLen = std::strlen(pText); // Error isLengthValid = true; // Error } return siTextLen; } private: char *pText; std::size_t siTextLen; // 텍스트 길이 isLengthValid; // 텍스트 길이 유효성 }
- 논리적 상수성(logical constness)
- 비트 수준의 const에 맞서기 위해 mutable 변수가 등장했다.
- 비정적 멤버 데이터를 비트 수준 상속성의 족쇄에서 벗어나게 해주었다.
class CTextBook { public: std::size_t length() const { // 상수 멤버 함수에서 mutable 객체를 변경 if ( false == isLengthValid ) { siTextLen = std::strlen(pText); // OK isLengthValid = true; // OK } return siTextLen; } private: char *pText; mutable std::size_t siTextLen; // 텍스트 길이 (mutable) mutable isLengthValid; // 텍스트 길이 유효성 (mutable) }
앞서, const는 오버로딩 대상이라고 말했다. 개발자는 상수 멤버 함수, 일반 멤버 함수 나눠서 작성할텐데, 이때 코드의 중복을 해결하기 위한 방법이 있다. casting을 두 번 함으로써 해결할 수 있다. 아래의 예제를 보자.
class CTextBook {
public:
const char &opeartor [](std::size_t position) const {
...
return text[position];
}
char &operator [](std::size_t position) {
// 상수 타입에서 상수를 제거
return const_cast<char &>(
// 상수로 형 변환 이후, 상수 멤버 함수 호출
static_cast<const CTextBook &> (*this)[position];
);
}
}
const를 붙이는 과정은 안전한 형변환이기 때문에 static_cast로 처리가 가능하다. 다만, const 제거는 const_cast만 지원하기 때문에 이를 사용해야 한다.
이때 역으로 비상수 함수에서 상수 함수를 호출하는 방식으로는 논리적으로 옳지 못하다. 비상수 함수에서 상수 멤버가 포함되거나 변경이 발생하면 안 된다.
04. 객체를 사용하기 전에 반드시 그 객체를 초기화하자
객체를 초기화하지 않으면 정의되지 않은 동작이 발생할 수 있다.
일반적으로 C 영역은 초기화를 보장하지 않는다. 하지만, C++ 영역은 초기화를 보장한다. 예를 들어, C의 배열은 초기화를 보장하지 않지만, C++의 벡터는 초기화를 보장한다. C++ 영역이 초기화를 보장하더라도, 개발자는 모든 객체를 초기화하려고 해야한다.
일반적으로 대입(assignment)과 초기화(initialization)는 구분되어야 한다. 생성자에서 처리한다고 하더라도 이 둘은 엄연히 다르다.
class CPhoneNumber {
...
};
class CABEntry {
public:
CABEntry(const std::String &sName, const std::string &sAddress, const std::list<CPhoneNumber> &lstPhones);
private:
std::string m_name;
std::string m_addrses;
std::list<CPhoneNumber> m_phones;
int iTimesConsulted;
};
// 1. 대입 방식
CABEntry::CABEntry(const std::String &sName, const std::string &sAddress, const std::list<CPhoneNumber> &lstPhones) {
m_name = sName;
m_address = sAddress;
m_phones = lstPhones;
iTimesConsulted = 0;
}
// 2. 초기화 방식
CABEntry::CABEntry(const std::String &sName, const std::string &sAddress, const std::list<CPhoneNumber> &lstPhones)
: m_name(sName), m_address(sAddress), m_phones(lstPhones), iTimesConsulted(0)
{
}
대입 방식은 생성자 바디 부분에 "="를 통해 "대입"하는 것이다. 초기화 방식은 바디가 아닌 ":" 뒤에 "()"를 통해 "초기화"하는 것이다.
대입 방식은 초기화가 아니다. 이미 초기화된 객체에 한 번 더 대입하는 것으로, 처음 초기화 과정이 낭비된다. 반면, 초기화 방식은 초기화 단계에서 바로 복사 생성자를 이용해 초기화하는 것이다.
이때, 초기화 방식은 vector, list, string 같은 객체에 대해서는 진행하지 않아도 C++ 영역이기 때문에 초기화가 된다. 하지만, 어쩌다가 어떤 멤버를 빼먹어 초기화되지 않았을 수도 있다는 부담이 없어지기 때문에 C++ 영역이라도 모두 초기화를 하도록 하자.
다만, 여러 생성자들을 둘 경우 초기화 코드로 인해 지저분해질 수 있다. 이때는 대입 가능한 것들은 따로 대입 함수를 만들어 여러 생성자에서 공통적으로 호출할 수 있도록 하면 정리가 가능하다.
C++의 객체 데이터가 초기화되는 순서는 다음과 같다.
- 기본 클래스가 파생 클래스 보다 먼저 초기화된다.
- 멤버 데이터는 선언한 순서대로 초기화된다.
C++ 초기화에 대해서 중요한 마지막 내용은 "비지역 정적 객체는 개별 번역 단위에서 초기화 순서가 독립적으로 결정된다."는 것이다.
말이 어렵기 때문에 하나씩 살펴보도록 하자.
- 정적 객체
- 전역 객체
- 네임 스페이스 유효 범위 내 정의 객체
- 클래스 내부 static 선언 객체
- 함수 내부 static 선언 객체
- 파일 유효 범위 내의 static 정의 객체
- 지역 정적 객체(local static object)
- 함수 내부에 있는 정적 객체(4번)
- 비지역 정적 객체(non-local static object)
- 지역 정적 객체가 아닌 모든 정적 객체
- 번역 단위(translation unit)
- 컴파일 시 하나의 목적파일(.o, object file)을 만드는 바탕이 되는 소스코드
- 기본적으로는 소스코드 1개이지만, #include를 통해 추가한 파일까지 포함됨
즉, 위의 내용을 다시 정리하자면, 별도 컴파일된 소스파일 두 개 이상에서, extern을 통해 서로 참조하고 있는 비지역 정적 객체가 있다고 해보자. 이때, 하나의 번역 단위에서는 해당 비지역 정적 객체가 초기화가 되어 있더라도 다른 하나의 번역 단위에서는 초기화되어 있지 않을 수 있다는 것이다.
이러한 문제를 막는 방법 중 하나는 다음과 같다. 비지역 정적 객체를 하나씩 맡은 함수를 준비하고, 이 안에서 객체를 static으로 선언하는 것이다. 즉, 비지역 정적 객체를 함수 안에서 호출하는 지역 정적 객체로 변경하는 것이다. 그리고, 이 함수는 해당 정적 객체 참조를 반환하도록 한다.
위의 내용을 다시 정리하자면, 싱글톤 패턴의 원시적인 모습이다.
이때 주의해야할 점은, 참조를 반환하는 함수는 내부적으로 정적 객체를 쓰고 있어 다중 스레드 시스템에서 장애가 발생할 수 있다. 특히 비상수 정적 객체는 지역/비지역 상관없이 모두 시한 폭탄이라고 보면 된다. 다중 스레드 시스템에서의 문제를 보완하는 방법은 다중 스레드를 만들기 전에(즉, 단일 스레드인 상태에서) 해당 참조 반환 함수를 호출하는 것이다. 이럴 경우 초기화 단계에서의 경쟁 상태(race condition)을 방지할 수 있다.
'study > C++' 카테고리의 다른 글
[C++][Effective C++] 26~31. 구현 (0) | 2022.03.18 |
---|---|
[C++][Effective C++] 18~25. 설계 및 선언 (0) | 2022.03.16 |
[C++][Effective C++] 13~17. 자원 관리 (0) | 2022.03.14 |
[C++][Effective C++] 05~12. 생성자, 소멸자 및 대입 연산자 (0) | 2022.03.13 |
[C++][Effective C++] 00. Effective C++ 들어가면서 (0) | 2022.03.09 |