Item26: 가능한 한 변수를 늦게 정의하라.
제어 흐름이 변수 정의에 도달하면 생성 비용이 발생하고, 변수가 범위를 벗어날 때 소멸 비용이 발생한다.
변수가 사용되지 않으면 낭비가 발생되기 때문에 피하는 것이 좋다.
예를 들어 변수를 생성하고 나서 exception이 발생하면 사용하지 않게 되기 때문에 변수를 늦게 정의했다면 발생하지 않을 비용이 발생한다.
// 이 함수는 encrypted의 정의를 필요할 때까지 미루지만,
// 여전히 불필요하게 비효율적이다.
std::string encryptPassword(const std::string& password)
{
...
// std를 가져오고 길이를 체크하는 기존 코드
string encrypted; // 기본 생성자로 encrypted 객체 생성
encrypted = password; // password를 encrypted에 대입
encrypt(encrypted);
return encrypted;
}
위의 방식보다
// 가장 좋은 방식: encrypted를 정의와 동시에 초기화
std::string encryptPassword(const std::string& password)
{
...
// std를 가져오고 길이를 체크하는 기존 코드
string encrypted(password); // 복사 생성자를 사용하여 초기화
encrypt(encrypted);
return encrypted;
}
이 방식이 효율적이다.
Item27: 캐스팅을 최소화하라.
캐스트는 타입 시스템을 우회하는 시도 이다.
쉽게 인식할 수 있는 문제도 있지만 미묘한 문제도 있다.
C++은 다른 언어에 비해 더욱 신중하게 캐스팅을 다루어야 한다.
const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)
C++에서는 C스타일의 기본 캐스팅이 아닌 위의 새로운 캐스트 형식들을 제공한다.
- const_cast: 객체의 const 속성을 제거하는 데 사용된다. const_cast는 C++ 스타일 캐스트 중 유일하게 const 속성을 제거할 수 있다.
- dynamic_cast: "안전한 다운캐스팅(safe downcasting)"을 수행하는 데 사용된다. 즉, 상속 계층에서 특정 객체가 주어진 타입의 인스턴스인지 확인하는 용도로 쓰인다. 런타임 비용이 발생할 수 있는 유일한 캐스트이다.
- reinterpret_cast: 하위 수준(low-level) 캐스트를 수행하며, 구현에 따라 결과가 달라질 수 있다(예: 포인터를 int로 변환). 이는 매우 드물게 사용해야 하며, 특정 저수준 시스템 코드에서만 사용해야 한다.
- static_cast: 암시적 변환이 허용되는 타입 변환을 수행한다(예: int에서 double로 변환). 또한, 상속 관계에서의 변환(예: void*에서 특정 포인터 타입으로 변환)에도 사용된다. 다만, static_cast는 const 속성을 제거할 수 없으며, const_cast가 이를 담당한다.
코드 내에서 쉽게 식별이 가능하고, 목적을 명확하게 구분할 수 있고, 컴파일러가 더 강력한 타입 검사를 수행할 수 있기 때문에 C++에서는 C++스타일 캐스팅을 권장한다.
class Base {
public:
int baseValue;
};
class Unrelated {
public:
int otherValue;
};
class Derived : public Unrelated, public Base {
public:
int derivedValue;
};
Derived d;
Base* pb = &d; // Derived* → Base* 암시적 변환
예를 들어 이러한 경우 암시적 변환일 때 가리키는 주소가 다를 수 있다.
&d (Derived*) → [ Unrelated ][ Base ][ Derived 전용 멤버들 ]
^
|
Base* pb
따라서 캐스팅에 주의가 필요하다.
dynamic_cast를 사용하면 가능하다고 한다.
다만 dynamic_cast는 성능적으로 매우 느리고, 코드 설계가 잘못되었을 가능성이 높다.
추가로 기본 클래스의 함수를 호출할 때는 캐스트 대신 직접 호출하는 것이 좋다.
Item28: 객체 내부 데이터의 핸들 반환을 피하라
struct RectData { // Rectangle의 점 데이터
Point ulhc; // ulhc = "upper left-hand corner"
Point lrhc; // lrhc = "lower right-hand corner"
};
class Rectangle {
...
public:
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
private:
std::tr1::shared_ptr<RectData> pData; // tr1::shared_ptr 정보는 항목 13 참고
};
인 경우에
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); // rec는 const Rectangle (0,0) → (100,100)
// rec가 const인데도 내부 데이터가 변경될 수 있다!
rec.upperLeft().setX(50); // rec의 upperLeft를 (50, 0)으로 변경
// rec는 원래 (0,0) → (100,100)이었지만,
// (50,0) → (100,100)으로 변경됨!
다음과 같이 사용할 수 있다.
rec은 const로 선언되었지만 내부 핸들을 통해서 데이터를 변경할 수 있게 되었다.
이러한 실수를 방지하기 위해서는, 객체 내부 데이터에 대한 참조를 직접 반환하지 않도록 해야 한다.
참조, 포인터, 이터레이터는 모두 핸들로 반환 시 객체의 캡슐화가 깨질 위험이 있다.
해결법
따라서 이를 해결하기 위해 반환을 하게 된다면, const 형식으로 반환한다.
이 경우 읽을 수는 있지만, 수정할 수 없게 된다.
결론적으로 객체 내부 데이터에 대한 참조 반환 시에는 const를 추가해 수정되지 않도록 보호해야 한다.
다만 dangling handle(죽은 핸들) 문제가 발생할 수 있기 때문에 이를 유의하여 사용하자.
Item29: 예외 안전 코드를 지향하라.
예외 발생 시 예외 안전 함수는
1. 리소스가 누수되지 않아야 함
2. 데이터 구조가 손상되지 않아야 함
을 보장해야한다.
이를 해결하기 위해 리소스 관리 클래스를 활용하자.
RAII기법을 사용한다.
예외 안전성을 제공하는 함수는 세 가지 보증 중 하나를 제공한다.
🔹 기본 보증 (basic guarantee)
- 예외가 발생해도 프로그램의 모든 객체와 데이터 구조가 유효한 상태를 유지함을 보장한다.
- 즉, 클래스의 불변 조건(class invariants)이 유지되며, 프로그램이 일관된 내부 상태를 유지한다.
- 다만, 프로그램의 정확한 상태는 예측할 수 없다.
- 예를 들어, changeBackground()를 작성할 때, 예외가 발생하면 PrettyMenu 객체가
이전 배경 이미지를 그대로 유지하거나, 기본 배경 이미지로 변경될 수 있다. - 그러나 클라이언트는 어떤 상태인지 예측할 수 없으며,
이를 알기 위해서는 추가적인 멤버 함수를 호출해야 한다.
- 예를 들어, changeBackground()를 작성할 때, 예외가 발생하면 PrettyMenu 객체가
🔹 강력 보증 (strong guarantee)
- 예외가 발생하면, 프로그램의 상태가 전혀 변경되지 않음을 보장한다.
- 이러한 함수는 원자적(atomic) 인데, 즉 성공하면 완전히 성공하고, 실패하면 아무 일도 일어나지 않은 것과 같다.
- changeBackground()가 강력 보증을 제공한다면,
- 함수 호출이 성공하면 배경 이미지가 변경되고,
- 함수 호출이 실패하면 프로그램의 상태가 전혀 변경되지 않은 것과 같음.
강력 보증을 제공하는 함수는 사용하기 쉽다.
- 강력 보증을 제공하는 함수는 함수 호출 후 프로그램의 두 가지 상태만 가능하다.
- 함수가 정상적으로 실행된 후 기대한 상태
- 예외 발생 시, 함수가 호출되기 전 상태로 유지됨
- 반면, 기본 보증만 제공하는 함수는
- 예외가 발생하면 프로그램이 "어떤 유효한 상태(any valid state)"에 있을지 예측하기 어려움.
🔹 nothrow 보증 (nothrow guarantee)
- 절대 예외를 던지지 않는 함수를 의미한다.
- 이러한 함수는 항상 자신이 약속한 동작을 수행하며, 예외를 절대 발생시키지 않는다.
- 예를 들어, 내장 타입(int, pointer 등)에서의 연산은 기본적으로 nothrow 보증을 제공한다.
- 즉, int a = b + c; 와 같은 연산은 예외를 던지지 않는다.
- 예외 안전 코드의 핵심 구성 요소로 사용된다.
nothrow의 경우 C++에서는 noexcept를 사용한다.
noexcept는 해당 범위 코드가 절대 예외를 발생시키지 않는다고 보장하며(사용하는 사용자에게) 만약 오류가 발생하면 중대한 오류가 발생한 것이기 때문에 프로그램을 terminate시킨다.
예외 안전성은 전파된다.
만약 어떤 함수가 여러 개의 하위 함수들을 호출한다면, 모든 하위 함수가 강력한 예외 안전성을 가져야만 상위 함수도 강력한 예외 안전성을 보장 가능하다.
결론
스마트 포인터 등으로 리소스를 관리하자.
예외 안전성 보장 중 하나를 선택해서 만들자.
Item30: 인라이닝의 장단점을 이해하라.
인라이닝의 장점
- 컴파일러 최적화는 일반적으로 함수 호출이 없는 코드 영역을 대상으로 수행됨.
- 인라인 함수를 사용하면 컴파일러가 해당 함수의 본문을 분석하여 추가적인 최적화를 수행할 가능성이 높아짐. (+ 함수 호출 오버헤드를 제거)
- 반면, 일반적인(outlined) 함수 호출에 대해서는 이러한 최적화를 수행하지 않는 경우가 많음.
인라이닝의 단점
- 인라인 함수는 각 호출 지점에서 해당 함수의 본문을 직접 삽입하는 방식으로 동작한다.
- 즉, 함수 호출을 함수 본문으로 대체하는 것이다.
- 이를 통해 오버헤드를 줄일 수 있지만, 코드 크기가 증가할 가능성이 높다.
- 코드 크기가 커지면:
- 메모리가 제한된 환경에서는 문제가 될 수 있음.
- 가상 메모리를 사용하더라도 코드 부하로 인해 페이징(paging)이 증가할 수 있음.
- 명령어 캐시 히트율(cache hit rate)이 낮아져 성능 저하 가능성이 있음.
- 코드 크기가 커지면:
반면, 인라인 함수의 본문이 매우 짧은 경우에는 코드 크기가 오히려 줄어들 수도 있음.
- 함수 호출을 위한 별도의 어셈블리 코드보다 함수의 본문이 작은 경우에는
- 인라이닝이 코드 크기를 줄이고
- 명령어 캐시 효율을 높일 수도 있음.
인라이닝은 요청이다.
인라인 함수는 컴파일러에게 인라인으로 해달라고 요청하는거지, 강제하는 것은 아니다.
클래스 정의 내부에서 함수를 선언하면 암시적(implicit)으로 인라인 요청이 된다.
inline 키워드를 사용하면 명시적(explicit)으로 인라인 요청이된다.
요청이므로 항상 인라이닝을 수행한다는 보장은 없다.
인라이닝 함수의 위치?
보통 헤더 파일에 정의된다.
대부분의 빌드 환경에서 인라이닝은 컴파일 시점에 이루어지기 때문이다.
함수 호출 -> 함수 본문으로 대체하는 것이 인라이닝인데 컴파일러가 함수의 전체 정의를 알아야 하기 때문에 컴파일 시점에 이루어짐
예외적인 경우에는 런타임에서 인라이닝을 수행하는 경우도 있다고 함.
(.NET CLI기반의 관리 코드)
결론: 인라이닝은 필요할 때만 쓰자, 짧고 자주 호출되는 함수에만 사용하자
Item31: 파일 간의 컴파일 종속성을 최소화하라.
프로그램에서 사소한 변경을 가했는데 모든 것이 다시 컴파일되고 재링크 될 수 있다.
이 문제가 생기는 것은 C++이 인터페이스와 구현을 분리하는 것을 잘하지 못하기 때문이다.
클래스 정의는 클래스 인터페이스 + 상당한 양의 구현 세부 정보를 포함한다.
만약 어떤 클래스가 다른 헤더 파일들 사이에 종속성을 가진다면, 헤더 파일이 변경되었을 때 헤더 파일을 포함하는 클래스를 사용하는 모든 파일들도 같이 컴파일해야 한다.
연쇄적인 컴파일 종속성(cascading compilation dependencies)은 대규모 프로젝트에서 큰 문제를 일으킬 수 있다.
pImpl 패턴
이 기법을 통해 객체의 세부 구현을 숨길 수 있다.
Person 클래스는 내부적으로 포인터를 사용해 구현 클래스를 참조한다.
#include <memory> // 표준 라이브러리 - 직접 선언하지 말 것
class PersonImpl; // Person 구현 클래스의 전방 선언
class Date; // Date 클래스의 전방 선언
class Address; // Address 클래스의 전방 선언
class Person {
public:
Person(const std::string& name, const Date& birthday,
const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
private:
std::tr1::shared_ptr<PersonImpl> pImpl; // pImpl을 사용하여 구현 분리
};
pImpl 패턴의 장점
Person의 클라이언트 코드들은 Date, Address의 구현 세부 사항을 알 필요가 없다.
이로 인해, Date와 Address 코드가 수정되어도 Person을 다시 컴파일할 필요가 없다.
이러한 분리는 정의(definitions)에 대한 의존성을 선언(declarations)에 대한 의존성으로 대체하는 것이다.
컴파일 의존성을 최소화 하는 방법이다.
객체를 사용하기보다는 객체 참조(reference)와 포인터(pointer)를 사용하라.
클래스에 대한 선언만으로 팜조와 포인터를 정의할 수 있다.
그러나 객체를 정의하려면 해당 타입의 정의가 필요하다.
따라서 직접적인 객체 선언 대신 객체 참조 또는 포인터만 선언하도록 해야 한다.
class Date; // 클래스 선언
Date today(); // OK - Date의 정의가 필요 없음
void clearAppointments(Date d); // Date의 정의가 필요함 (값 전달이므로)
#include "datefwd.h" // 선언 전용 헤더 파일 (정의가 아님)
Date today(); // 기존과 동일
void clearAppointments(Date d); // 기존과 동일
pass-by-value의 경우 불필요한 컴파일 의존성이 생기므로, reference, pointer를 사용하면 된다.
함수를 호출하려면, 해당 함수가 선언된 것을 컴파일러가 인식해야 한다.
선언과 정의를 별도의 헤더 파일로 분리하면 두 파일 일관성을 유지할 수 있다.
pImpl 패턴을 사용하는 Handle 클래스
#include "Person.h" // Person 클래스 정의를 포함해야 함
// Person 클래스를 구현하는 코드
#include "PersonImpl.h" // PersonImpl의 정의도 포함해야 한다
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr)) // pImpl 객체 생성
{}
std::string Person::name() const {
return pImpl->name(); // pImpl 객체를 통해 name() 호출
}
인터페이스 클래스
class Person {
public:
virtual ~Person(); // 소멸자는 반드시 virtual이어야 함
virtual std::string name() const = 0; // 순수 가상 함수
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
};
핸들 클래스 vs 인터페이스 클래스
| 특징 | 핸들 클래스 (Handle Class, pImpl) | 인터페이스 클래스 (Interface Class) |
| 데이터 저장 방식 | 내부적으로 pImpl 객체를 생성하여 데이터 저장 | 데이터 없음 (순수 가상 함수만 존재) |
| 컴파일 의존성 | 내부 구현을 감출 수 있어 컴파일 속도 향상 | 파생 클래스가 필요하므로 유연성 증가 |
| 객체 관리 | 직접 객체를 생성하고 내부에서 관리 | 팩토리 함수 또는 스마트 포인터 사용 |
| 장점 | - 구현을 숨길 수 있음 - 변경이 발생해도 클라이언트 코드 영향 적음 |
- 상속을 통해 확장 가능 - 순수 가상 함수만 포함하여 설계가 깔끔 |
| 단점 | - 별도의 pImpl 클래스 필요 | - 직접 객체 생성 불가능 - 항상 스마트 포인터 또는 팩토리 함수 필요 |
이 아이템은 정확하게 이해하지 못해 다시 볼 필요가 있겠다 ㅠㅠ

'C, C++ > Effective C++' 카테고리의 다른 글
| 7장 템플릿과 제네릭 프로그래밍 (0) | 2025.04.02 |
|---|---|
| 6장 상속과 객체 지향 디자인 (0) | 2025.03.28 |
| 4장 설계와 선언 (0) | 2025.03.21 |
| 3장 리소스 관리 (0) | 2025.03.21 |
| 2장 생성자, 소멸자 그리고 할당 연산자 (2) (0) | 2025.03.17 |