Item2: #define 대신 const, enum, inline을 선호하자
개요
이는 선행 처리자(preprocessor)보다 컴파일러를 가까이 하자는 것과 비슷하다.
#define
#define은 치환이기 때문에 디버그 시에 symbolic이름으로 확인할 수 없다.
따라서 const 전역 상수를 사용하는 것이 낫다.
#define 매크로 함수는 문법적으로 신경쓸 것이 많고 실수할 확률이 높다.
따라서 inline 함수를 사용하는 것이 낫다.
Item3: const를 가능하면 최대한 사용하기
개요
const를 사용하면 많은 장점을 가지고 프로그래밍할 수 있다.
const 키워드는 유연하여 전역 변수나 네임스페이스 범위의 상수로 사용할 수도 있고, 블록 범위에서 선언된 static 상수에도 사용 가능하다.
클래스 내부에서는 static, non-static 멤버에 적용할 수 있다.
const의 장점
1. 불변성을 보장한다: 컴파일러가 자동으로 수정되지 않도록 보장하고, 다른 개발자들도 명확하게 의도를 알 수 있다.
2. 컴파일러 최적화 가능: const를 사용하면 컴파일러가 최적화 기회를 가진다.
3. 코드의 안정성과 가독성 향상
포인터 사용 시 const의 위치에 따라 의미가 달라진다.
char greeting[] = "Hello";
char *p = greeting; // 변경 가능: p와 p가 가리키는 값 모두 변경 가능
const char *p = greeting; // p가 가리키는 데이터는 변경 불가, p는 변경 가능
char *const p = greeting; // p는 변경 불가, 하지만 가리키는 데이터는 변경 가능
const char *const p = greeting; // p와 가리키는 데이터 모두 변경 불가
이해하기 쉽게 하기 위해서는 자료형을 빼고 생각하면 된다.
const의 위치?
void f1(const Widget *pw); // f1: const Widget을 가리키는 포인터
void f2(Widget const *pw); // f2: 위와 동일
실제 코드에서 두 가지 스타일 모두 보게될 수 있기 때문에 둘 다 익숙해지는 것이 중요하다.
STL의 iterator에서의 const
iterator에 const를 붙이는 경우
std::vector<int> vec;
const std::vector<int>::iterator iter = vec.begin();
++iter와 같은 연산이 불가하다. (포인터 자체를 변경 불가)
iterator가 가리키는 값 자체가 const인 경우
std::vector<int>::const_iterator clter = vec.begin(); // vec.cbegin();
const T*의 포인터처럼 동작해 iterator가 가리키는 값은 변경불가하지만, iterator가 다른 곳을 가리킬 수는 있다.
| 선언 방식 | 의미 | 값 변경 가능 여부 | 이동 가능 여부 |
| std::vector<int>::iterator iter | 일반 반복자 | ✅ 가능 | ✅ 가능 |
| const std::vector<int>::iterator iter | 반복자 자체가 const | ✅ 가능 | ❌ 불가능 |
| std::vector<int>::const_iterator clter | 가리키는 값이 const | ❌ 불가능 | ✅ 가능 |
operator*의 반환 값은 const?
불필요한 코드를 막기 위해 operator*와 같은 연산자 오버로딩에 반환값을 const로 선언한다.
class Rational { ... };
const Rational operator*(const Rational& lhs, const Rational& rhs);
const로 연산자 오버로딩을 하는 경우 아래와 같은 잘못된 코드 사용을 컴파일러단에서 막을 수 있다.
Rational a, b, c;
(a * b) = c; // 컴파일 에러! (a * b)가 const이므로 대입 불가
따라서 const 사용은 불필요한 코드, 타입 변환 실수를 막고 일관성을 유지하는 측면에서 좋다.
const 멤버 함수의 목적
1. class의 인터페이스를 명확하게 정의 가능: 어떤 멤버 함수가 객체를 수정할 수 있는지 여부를 명확하게 구분 가능
2. const객체가 사용가능한 함수: const객체는 값이 불변해야하므로, 비 const 멤버 함수는 호출할 수 없다.
(reference to const 사용은 C++ 코드 최적화를 위한 중요한 개념이라고 언급함)
C++에서 멤버 함수가 오직 const 여부만 다르게 오버로딩될 수 있다.
// 책의 예시
class TextBlock {
public:
...
const char& operator[](std::size_t position) const // `const` 객체용
{ return text[position]; }
char& operator[](std::size_t position) // 비-`const` 객체용
{ return text[position]; }
private:
std::string text;
};
TextBlock tb("Hello");
std::cout << tb[0]; // 비-`const` 버전 호출 (읽기)
tb[0] = 'X'; // 비-`const` 버전 호출 (쓰기)
const TextBlock ctb("World");
std::cout << ctb[0]; // `const` 버전 호출 (읽기)
ctb[0] = 'X'; // 컴파일 에러! `const` 객체는 수정 불가능
| 호출 객체 | 호출되는 operator[] 버전 | 설명 |
| tb[0] | char& operator[](size_t) | 비-const 객체이므로 비-const 버전 호출 (읽기/쓰기 가능) |
| tb[0] = 'X' | char& operator[](size_t) | 비-const 버전이므로 tb[0]에 값 할당 가능 |
| ctb[0] | const char& operator[](size_t) const | const 객체이므로 const 버전 호출 (읽기만 가능) |
| ctb[0] = 'X' | 컴파일 에러 | const 객체는 수정 불가능하므로 에러 발생 |
C++ const 멤버 함수의 두 가지 개념
| 개념 | 설명 |
| Bitwise constness (물리적 상수성, Physical Constness) |
- 객체의 비트 단위 데이터(멤버 변수)를 변경하지 않는 것을 의미한다. - 즉, const 멤버 함수가 호출되면 객체의 어떠한 데이터도 변경하지 않는다는 것을 보장해야한다. |
| Logical constness (논리적 상수성, Logical Constness) |
- 멤버 변수의 데이터를 직접 수정하지 않지만, 캐싱(cache) 등의 목적으로 내부적으로 변경이 가능한 개념이다. - mutable 키워드를 사용하여 const 함수 내부에서도 특정 멤버 변수 값을 변경할 수 있다. |
Bitwise Constness
C++ 표준에서 const 멤버 함수는 Bitwise Constness이다.
class Example {
public:
void foo() const {
x = 10; // 오류 `const` 멤버 함수에서는 멤버 변수를 변경할 수 없음
}
private:
int x;
};
const 멤버 함수 내에서 데이터 멤버를 변경한다면 오류를 발생시킨다.
Bitwise constness의 허점
class CTextBlock {
public:
char& operator[](std::size_t position) const {
return pText[position];
}
private:
char *pText; // 문자 데이터를 가리키는 포인터
};
위의 함수에서는 const 멤버 함수 내에서는 객체의 멤버 변수 값을 변경하지 않기 때문에 오류가 나지 않지만, 실제 데이터를 변경할 수 있는 참조 값을 반환한다.
C++ 컴파일러는 const 멤버 함수 내부에서 포인터의 값을 변경하는지 여부만 검사한다.
그니까 const 변수 자체는 수정을 허용하지 않지만 이를 가리키는 포인터를 만들어 간접적으로 변경을 시도하면 const는 메모리 수준에서 변경을 막는 것이 아니기 때문에 수정이 가능한 것이다.
const CTextBlock cctb("Hello"); // `const` 객체 선언
char *pc = &cctb[0]; // `const` operator[]가 포인터를 반환
*pc = 'J'; // `const` 객체의 데이터가 수정됨!
Logical constness
이 개념을 사용하는 개발자는 멤버 함수가 객체의 일부 비트를 변경하더라도, 변경이 클라이언트가 모르는 변경이라면 const로 선언해도 된다고 한다.
class CTextBlock {
public:
...
std::size_t length() const; // 길이 반환
private:
char *pText;
mutable std::size_t textLength; // 마지막으로 계산된 길이 (mutable)
mutable bool lengthIsValid; // 길이가 유효한지 여부 (mutable)
};
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText);
lengthIsValid = true;
}
return textLength;
}
여기서 멤버 변수를 mutable로 선언하지 않는다면, bitwise constness에 따라 오류가 발생한다.
(mutable을 사용하면 const함수 내에서도 변경 가능한 변수가 된다.)
const, non-const 멤버 함수에서 중복 피하기
class TextBlock {
public:
...
const char& operator[](std::size_t position) const
{
... // 범위 검사 수행
... // 접근 데이터 로깅
... // 데이터 무결성 검사
return text[position];
}
char& operator[](std::size_t position)
{
... // 범위 검사 수행
... // 접근 데이터 로깅
... // 데이터 무결성 검사
return text[position];
}
private:
std::string text;
};
위와 같은 상황에서 여러 중복 코드가 발생할 수 있다.
이를 해결하기 위해 casting away constness 기법을 사용한다.
class TextBlock {
public:
...
const char& operator[](std::size_t position) const // 기존 const 버전
{
...
return text[position];
}
char& operator[](std::size_t position) // 이제 const 버전만 호출
{
return
const_cast<char&>( // op[]의 반환 타입에서 const 제거
static_cast<const TextBlock&>(*this) // *this의 타입을 const로 변환
[position] // const 버전 op[] 호출
);
}
};
검증 로직의 경우 실제 데이터를 변환하는 것이 아니므로 위와 같이 하면 const와 non-const의 코드 중복을 제거할 수 있을 것이다.
(const_cast는 단지 const상수성을 제거하는 것)
다만 일반적으로 캐스팅은 좋지 않은 아이디어임을 생각하자.
역방향(const -> non-const)는 non-const가 const보다 큰 범주이기 때문에 이러한 호출은 하지 않는다.(할 수 없다.)
결론
const는 포인터, iterator, 객체, 참조, 파라미터, return type, 지역 변수, 멤버 함수
에서 강력한 아군이 되기 때문에 가능한 const를 적극적으로 사용해보자.
bitwise, logical 개념은 나중에 또 봐야겠다 뭔지 잘 모르겠음
Item4: 객체가 사용되기 전에 초기화되었는지 확인하라.
C++은 객체의 값을 초기화하는 방식에 대해 변덕스러워 보일 수 있다.
int x;
이러한 코드는 초기화(0으로)가 되는 것을 보장하지만
class Point {
int x, y;
};
...
Point p;
이러한 문맥에서는 초기화가 될 수도 아닐 수도 있다.(sometimes they're not이라는 표현을 씀)
초기화가 되지 않은 값을 읽으면 Undefined Behavior(UB)가 생길 수 있다.
이는 프로그램에 큰 오류를 불러올 수 있기 때문에 초기화 여부 확인이 중요하다.
C++은 C도 포함하기 때문에 어떤 것이 초기화가 보장되고 않는지 판단하는 것은 너무 복잡하다.
따라서 그냥 항상 객체를 초기화하자.
int x = 0; // 수동 초기화 (정수형 변수)
const char * text = "A C-style string"; // 수동 초기화 (포인터)
double d;
std::cin >> d; // 입력을 통해 "초기화"
이렇게 직접 초기화하는 것이 안전하다.
생성자
모든 생성자는 반드시 객체의 모든 멤버 변수를 초기화해야 한다.
C++에서는 초기화와 대입을 혼동하지 않는 것이 중요하다.
class PhoneNumber { ... };
class ABEntry { // ABEntry = "Address Book Entry"
public:
ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones);
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
};
// 생성자 구현
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
{
theName = name;
theAddress = address;
thePhones = phones;
numTimesConsulted = 0;
}
위의 경우 초기화가 아닌 대입으로 생성자를 구현한 것이다.
이 방식은 성능이 떨어질 가능성이 있고, 불필요한 추가 연산이 생긴다.
따라서 초기화 리스트 방식을 사용한다.
(자바는 이렇게 하지 않나 생각했는데, 내부적으로 알아서 초기화 리스트와 유사하게 처리된다고 한다.)
초기화 리스트 (initialization list)
ABEntry::ABEntry(const std::string& name, const std::string& address,
const std::list<PhoneNumber>& phones)
: theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{}
초기화 리스트는 위처럼 사용하고, 이렇게 하면 생성자 본문에서 별도의 대입 연산을 수행하지 않아도 된다.
대입 기반 방식에서는 디폴트 생성자가 호출되고, 본문에서 대입하여 디폴트 값을 덮어씌운다.
초기화 리스트를 사용하면, 디폴트 생성자(불필요한 호출) 없이, 값이 대입되어 복사 비용이 줄어든다.
기본 타입 같은 비용차이가 크지 않은 멤버라도 일관성을 위해 모든 멤버를 초기화 리스트에서 초기화하는 것이 좋다.
ABEntry::ABEntry()
: theName(), // theName의 기본 생성자 호출
theAddress(), // theAddress의 기본 생성자 호출
thePhones(), // thePhones의 기본 생성자 호출
numTimesConsulted(0) // 명시적으로 0으로 초기화
{}
위와 같이 구현 가능하다.
초기화 리스트를 반드시 사용해야 하는 경우도 있다.
const 멤버, 참조형&멤버는 반드시 초기화 리스트에서 초기화해야 한다.(default 초기화 후 대입하기 때문으로 추정해본다 Item5에서 설명해준다고 함)
생성자가 여러 개일 경우 초기화 관리
클래스에는 여러 개의 생성자가 존재할 수 있다.
각 생성자는 자체적인 멤버 초기화 리스트를 가진다.
이 때 여러 초기화 리스트가 중복되면서 코드가 불필요하게 길어질 수 있다.
이 때 모든 생성자가 호출하는 단일 초기화 함수(private)를 만드는 방식이 효과적일 수 있다.
C++에서 객체 초기화 순서
C++에서 객체의 초기화 순서는 언제나 일정하며 변경할 수 없다.
1. Base Class 먼저 초기화
2. 멤버 변수는 클래스 정의에서 선언된 순서대로 초기화됨
이 때 2번에 따라 만약에 초기화 리스트에서 순서를 다르게 쓰더라도 클래스에 선언된 순서대로 초기화되기 때문에, 코드 가독성을 위해 초기화 리스트 순서도 동일하게 맞추는 것이 좋다.
static object
정적(static) 객체는 프로그램이 실행되는 동안 존재하는 객체이다.
스택과 힙에 할당되는 객체는 static 객체가 아니다.
- global 객체
- namespace scope에서 정의된 객체
- 클래스 내부에서 static으로 선언된 객체
- 함수 내부에서 static으로 선언된 객체
- 파일 범위에서 static으로 선언된 객체
함수 내부에서 static으로 선언된 객체는 local static object,
그 외의 static 객체들은 non-local static obejct라고 한다.
모든 static 객체는 프로그램 종료 시 destroy된다. 소멸자가 호출된다.
비지역 정적 객체의 초기화 순서문제
translation unit: 하나의 오브젝트 파일을 생성하는 소스 코드 단위이다. 단일 소스 파일 + 포함된 모든 #include 파일을 포함한 하나의 컴파일 단위이다.
만약에 서로 다른 translation unit에 있는 non-local static object가 있다면 참조를 할 때 문제가 생길 수 있다.
class FileSystem {
public:
...
std::size_t numDisks() const;
...
};
// FileSystem의 전역 인스턴스를 선언
extern FileSystem tfs; // ("tfs" = "the file system")
// 정의는 라이브러리의 .cpp 파일 내부에 존재
class Directory { // 라이브러리 클라이언트가 만든 클래스
public:
Directory( params );
...
};
Directory::Directory( params )
{
...
std::size_t disks = tfs.numDisks(); // `tfs` 객체 사용
...
}
Directory tempDir(params);
이 때 tfs가 tempDir 보다 먼저 초기화되지 않는다면
tempDir의 생성자는 초기화되지 않은 객체를 사용하려고 하기 때문에 오류가 발생한다.
또한 서로 다른 translation unit에 있는 non-local static object의 경우 어떤 것이 먼저 초기화될 지 보장할 수 없기 때문에 예측이 불가능하다.
위의 문제 해결법
작은 설계 변경을 통해서 이를 해결한다.
non-local static 객체를 반환하는 static function을 만드는 것이다.
이러한 함수들은 자신이 포함하는 객체의 참조를 반환한다.
이를 사용하던 클라이언트는 객체 자체를 직접 참조하는 대신 해당 함수를 호출하게 된다.
이를 통해 non-local static객체가 local static객체로 대체된다.
local static객체는 해당 객체가 포함된 함수가 처음 호출될 때 초기화 되기 때문에 이를 사용하면 항상 초기화된 객체를 가리키는 것이 보장된다.
class FileSystem { ... }; // 기존과 동일
FileSystem& tfs() // 기존의 `tfs` 객체를 함수로 변환
{
static FileSystem fs; // 지역(static) 정적 객체 선언 및 초기화
return fs; // 참조 반환
}
class Directory { ... }; // 기존과 동일
Directory::Directory( params ) // 기존과 동일하나 `tfs` 사용 방식 변경
{
...
std::size_t disks = tfs().numDisks(); // `tfs` 객체가 아닌 함수 호출
...
}
Directory& tempDir() // 기존의 `tempDir` 객체를 함수로 변환
{
static Directory td( params ); // 지역(static) 정적 객체 선언 및 초기화
return td; // 참조 반환
}
다만 멀티스레드 환경의 경우 race condition 을 초래할 수 있으니 주의
결론적으로 해야할 것
1. 내장 타입의 비멤버 객체를 수동으로 초기화
2. 멤버 초기화 리스트를 사용해 모든 객체의 구성 요소 초기화
3. 여러 translation unit에서 정의된 non local static object로 인한 초기화 순서 문제 회피
'C, C++ > Effective C++' 카테고리의 다른 글
| 4장 설계와 선언 (0) | 2025.03.21 |
|---|---|
| 3장 리소스 관리 (0) | 2025.03.21 |
| 2장 생성자, 소멸자 그리고 할당 연산자 (2) (0) | 2025.03.17 |
| 2장 생성자, 소멸자 그리고 할당 연산자 (1) (0) | 2025.03.16 |
| 1장 C++과 친해지기 (1) (0) | 2025.03.14 |