[Effective C++] 1. C++에 왔으면 C++의 법을 따릅시다.
1. C++을 언어들의 연합체로 바라보는 안목은 필수
오늘날의 C++은 다중 패러다임 프로그래밍 언어라 불리고 있습니다. 절차적, 객체 지향, 함수식, 일반화, 메타프로그래밍 개념까지 지원하는 이 언어를 이해하려면 단일 언어라는 개념을 넘어 상관 관계가 있는 여러 언어들의 연합체로 보는 것입니다.
여러개의 하위 언어를 지원하는데 하위 언어 4가지는 아래와 같습니다.
- C : C++ 기본적으로 C를 베이스로 하고 있으며 포인터를 비롯한 많은 개념들은 C에서 따왔습니다. 물론 C++에는 많은 유용하면서 월등한 기능을 제공하고 있지만 C의 기능만으로도 구현, 구성이 가능합니다.
- 객제 지향 개념의 C++ : 클래스라는 개념을 기반으로 캡슐화, 상속, 다형성, 가상 함수 등을 얘기합니다.
- 템플릿 C++ : 템플릿 메타 프로그래밍(TMP) 이라는 개념이 등장합니다.
- STL : 템플릿 라이브러리는 컨테이너, 반복자, 알고리즘 및 함수 객체와 같은 개념들이 어떤 규약으로 얽혀져 있습니다. STL을 사용하려면 그 규약을 이용하면 됩니다.
추가로 효과적인 프로그램 개발을 위해 C++이 C보다 더 나은 점이 있다면 언제든지 활용해야 합니다. 예를 들어 값 전달, 참조 전달과 같은 개념이 C++에서는 참조자에 의한 전달(pass-by-reference-to-const) 방식이 더 효율이 좋게 됩니다.
2. #define을 쓰려거든 const, enum, inline을 떠올리자
우리는 예전부터 어떤 상수를 사용할 때 아래와 같이 사용을 많이 했습니다.
#define PI 3.14
위와 같은 방법은 우리는 PI가 3.14라는 것을 인지할 수 있지만 컴파일러에겐 그냥 3.14로 처리가 됩니다. 컴파일러가 쓰는 symbol table에는 PI가 아닌 3.14가 들어가게 됩니다. symbol table에 관한 내용은 아래 링크를 참고 부탁드립니다.
이러한 현상을 미연에 방지하기 위해선 매크로 대신에 상수를 사용하는 방법이 있습니다.
const double PI = 3.14;
위와 같이 #define을 const로 교체하게 되면 컴파일러에게도 PI라는 것을 명시할 수 있게 됩니다. #define을 const로 바꿀 때 주의해야 할 점은 상수 포인터를 정의하는 경우 입니다. 포인터를 const로 해줄 뿐 아니라 포인터가 가르키는 대상까지 const로 선언해야 합니다.
const char* const sName = "Seyong";
클래스 멤버로 상수로 정의하는 경우 상수의 값이 객체가 아닌 클래스에 고정되게 하려면 정적 멤버로 만들어야 합니다.
class Player{
private:
static const int nNumTurn = 5;
int scores[nNumTurn];
};
nNumTurn은 선언되어 있지만 정적 멤버로 정수 타입의 멤버 상수들은 예외입니다. 선언만해도 문제가 없습니다. 간혹 컴파일러에 따라 지원이 되지 않는다면 정의부분만 구현 파일에 두면 됩니다.
class Player {
private:
static const int nNumTurn;
};
const int Player::nNumTurn = 5; // 구현 부분에 정의
만약 위 처럼 상수를 컴파일하는 도중에 필요한 경우 (위 처럼 상수가 배열의 개수가 되는 경우)에는 enum을 사용하여 처리할 수 있습니다.
class Player{
private:
enum { nNumTurn = 5 };
int scores[nNumTurn];
};
enum을 사용하는 것은 const보단 #define에 가까운 동작을 보이게 됩니다. 이 값 혹은 변수는 주소 값을 가지고 처리도 불가능 하며 메모리 할당도 하지 않게 됩니다. 그리고 이 방식을 나열자 둔갑술(enum hack)이라고 불리며 템플릿 메타 프로그래밍의 핵심 기법입니다.
이 #define이 또 많이 쓰이는 부분이 매크로 함수인데 매크로 함수는 인자의 괄호 처리가 매우 중요합니다. 그리고 증감 연산자를 사용하는 경우 원하는 처리가 이루어지지 않을 수 있습니다.
#define SQR(X) X*X
위와 같은 매크로 함수의 경우 선언에 따른 처리가 완전히 달라질 수 있습니다. 아래와 같이 매크로 함수 선언 시 괄호를 제대로 넘기지 않고 처리 시 원하는 결과가 나오지 않기도 합니다.
위와 같이 매크로 함수의 단점을 커버하고 효율성은 그대로 가져오는 방법은 인라인 템플릿 함수 사용입니다.
template<typename T>
inline int SQT(const T& a)
{
return a*a;
}
3. 낌새만 보이면 const를 들이대 보자
const는 변경이 불가능하게 해주는 예약어입니다. const로 상수를 선언하여 사용하거나 정적, 비정적 멤버를 상수로 선언할 수 있습니다. 그리고 포인터에 사용한다면 아래와 같은 용도로도 사용할 수 있습니다.
char str[] = "Hello";
char* p = str; // 비상수 포인터, 비상수 데이터
const char* p = str; // 비상수 포인터, 상수 데이터
char* const p = str; // 상수 포인터, 비상수 데이터
const char* const p = str; // 상수 포인터, 상수 데이터
그리고 매개 변수에 대해 const가 붙은 경우에 아래 두 경우 매개변수 타입은 모두 같습니다. 즉 const 순서를 어떻게 붙이느냐는 아래 두 경우에는 중요하지 않습니다. 하지만 저는 개인적으론 위의 방식으로 선언하는 것을 선호합니다.
void f1(const Test* pw);
void f2(Test const* pw);
const는 STL 반복자의 경우에도 중요하게 다가옵니다. 아래 두가지의 iterator는 const의 위치에 따라 다른 동작을 하게 됩니다.
d아래의 경우에는 포인터를 상수로 선언한 것이기 때문에 위치를 변경하는 것에 에러가 발생했습니다.
std::vector<int> vec;
const std::vector<int>::iterator iter = vec.begin();
*iter = 10;
++iter; // error
하지만 아래의 경우에는 const_iterator를 사용하여 가르키는 대상 자체의 값을 변경하는 것을 막았기 때문에 값 변경에 에러가 발생하게 됩니다.
std::vector<int> vec;
std::vector<int>::const_iterator cIter = vec.begin();
*cIter = 10;
cIter++;
이 const를 가장 잘 사용할 수 있는 방법 중 하나는 함수 반환에 const를 붙이는 것입니다. 이렇게 사용한다면 반환된 값이 변경되는 것을 막아야할 경우 유용합니다.
class Test { ...; };
const Test operator*(const Test& lhs, const Test& rhs);
int main()
{
...;
Test a,b,c;
if ( a * b = c ) { // 원래 비교 연산자를 사용하려 했지만 대입이 되어 버린 상황
...;
}
...;
}
멤버 함수에 const가 붙게 된다면 이 멤버 함수는 상수 객체에 대해서 호출될거야 라고 알려주게 되고 이렇게 된다면 클래스의 인터페이스를 보고 객체를 변경할 수 있는 함수는 무엇인지 변경할 수 없는 함수는 무엇인지 확인이 가능하며, const의 사용을 통해 상수 객체를 사용하여 코드의 효율을 높일 수 있다는 점입니다.
효율을 높인다는 것은 상수 객체에 대한 참조전달을 통해 객체 전달흘 한다는 것인데 이렇게 사용하기 위해서는 상수 멤버 함수가 준비되어 있어야 합니다.
아래는 상수 멤버 함수를 이해하기 위한 예제 코드입니다. 상수 객체로 선언된 객체는 상수 멤버가 호출됩니다.
#include <iostream>
#include <vector>
using namespace std;
class Test {
public:
Test(int n) : nNum(n) {}
int& GetData() {
cout << "Func" << endl;
return nNum;
}
const int& GetData() const {
cout << "const Func" << endl;
return nNum;
}
private:
int nNum;
};
int main()
{
Test t1(10);
const Test t2(20);
cout << t1.GetData() << endl;
cout << t2.GetData() << endl;
}
상수 객체를 생성하는 경우는 상수 객체에 대한 포인터 혹은 상수 객체에 대한 참조자가 객체로 전달될 때 입니다.
#include <iostream>
#include <vector>
using namespace std;
class Test {
public:
Test(int n) : nNum(n) {}
int& GetData() {
cout << "Func" << endl;
return nNum;
}
const int& GetData() const {
cout << "const Func" << endl;
return nNum;
}
private:
int nNum;
};
void print(const Test& p)
{
cout << p.GetData() << endl;
}
int main()
{
Test t1(10);
print(t1);
}
상수 멤버 함수의 경우에는 두 가지 상수성이 존재합니다. 하나는 비트 수준의 상속성 또는 물리적 상속성이며, 다른 하나는 논리적 상수성 입니다.
물리적 상수성은 멤버 함수가 어떠한 데이터 멤버도 건드리지 않는다는 개념입니다. 우리가 멤버 함수 가장 뒤에 const를 붙이는 것이 이 상수성을 따르는 것입니다. 논리적 상수성은 완전히 바꾸지 않는 것이 아니라 바뀐지 모르게 하자는 것입니다. 그런 방식을 사용하기 위해서는 mutable이라는 예약어를 사용합니다. 이 예약어를 사용하게 되면 상수로 선언된 객체, 멤버 함수에서 값을 변경할 수 있습니다.
#include <iostream>
#include <string.h>
using namespace std;
class Test {
public:
Test(char* pData) : p(pData) {}
int GetLength() const {
m_nLen = strlen(p);
return m_nLen;
}
private:
char* p;
mutable int m_nLen;
};
int main()
{
const Test t1("Test");
cout << t1.GetLength();
}
4. 객체를 사용하기 전에 반드시 그 객체를 초기화하자
변수를 제대로 초기화 하지 않으면 어떤 값이 들어가 있을 지 모릅니다. 초기화 할 때 '='를 통해서 초기화 하거나 기타 여러 방법으로 초기화 하게 됩니다. 그리고 우리가 객체를 사용할 때 초기화는 생성자를 통해서 이루어지게 됩니다. 우리는 생성자를 통해 값을 세팅하는 방법을 두가지 알고 있습니다.
class Test {
public:
Test(int n) : nNum(n) {}
private:
int nNum;
};
class Test {
public:
Test(int n) {
nNum = n;
}
private:
int nNum;
};
두 가지 모두 같다고 생각하지만 첫번째 방법은 초기화, 두번째 방법은 대입이라고 생각하셔야 합니다. 우리는 첫번째 방법인 초기화를 통해 객체의 멤버 값을 초기화 해주어야 하며 이 방법을 사용해야 생성자의 본문이 시작되기 전에 세팅이 되기 때문에 진정한 초기화라고 할 수 있습니다.
만약 멤버 변수가 어떤 객체이고 그 객체를 초기화 하면서 기본 생성자로 초기화 해주고 싶은 경우에도 멤버 리스트 초기화를 사용하는 것을 습관으로 들이는게 좋습니다.
class Temp { ... };
class Test {
public:
Test() : t() {}
private:
Temp t;
};
위 방식에 습관을 들이는 것이 좋을 뿐 아니라 특정 상황에서는 의무로 사용해야 합니다. 만약 상수 혹은 참조자 형태의 멤버 변수의 경우 바로 초기화를 해주어야 하기 때문에 멤버 리스트 초기화를 사용해야 합니다.
마지막으로 여러 번역 단위 비지역 정적 객체들의 초기화 순서 문제는 피해야 합니다. 이 문장을 하나씩 해석해 보겠습니다.
번역 단위는 컴파일을 통해 object 파일을 만드는 바탕이 되는 소스코드를 말합니다. 여러 번역 단위라는 것은 별도 컴파일 된 소스 파일이라는 것입니다.
정적 객체는 생성된 시점부터 프로그램이 끝날 때까지 즉 main 함수의 실행이 끝날때 까지 살아 있는 객체를 말하며 정적 객체는 지역 정적 객체와 비지역 정적 객체로 나뉩니다. 지역 정적 객체는 함수 안에 있는 정적 객체를 말하며 비지역 정적 객체는 전역 객체, 네임스페이스 안의 정의된 객체, 클래스 안에서 static으로 선언된 객체, 파일 유효 범위 내에서 static으로 선언된 객체가 있습니다.
초기화 순서 문제라는 것은 만약 각 소스 파일에 비 지역 정적 객체가 있을 경우 사용할 경우 초기화가 되어 있다는 보장을 할 수 없게 됩니다. Directory 생성자에서 FileSystem의 numDisk를 호출하게 되면 FileSystem의 어떤 값을 가져오게 되는데 이 값이 제대로 초기화 됐다는 보장이 없습니다.
class FileSystem {
int numDisk() const;
};
extern FileSystem fts;
///////////////////////////////////////////
class Directory {
Directory() {
int nDisk = fts.numDisk();
}
};
위 문제를 해결하기 위해서는 비지역 정적 객체를 지역 정적 객체로 바꿔주면 됩니다.