26. 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자

변수 타입이 생성자나 소멸자를 호출하게 되면 비용이 발생하게 된다. 정의는 되었으나 사용하지 않은 경우에도 비용이 부과된다.

 

std::string encryptPassword(const std::string& password)
{
    std::string encrypted; // 정의로 인한 생성자 호출
    
    if (password.length() < MinimumPasswordLength) {
        throw logic_error("Password is too short"); // 예외발생
    }
    ...
    return encrypted; // 예외 발생시 사용도 못해본다
}

 

어떤 함수 내에서 지역변수를 정의하고 사용하기 전에 예외가 발생한다면 사용하지 않았음에도 생성과 소멸에 대해 비용을 지불해야한다. 그렇기 때문에 꼭 필요해지기 전까지 정의를 미루는 편이 낫다.

 

 

std::string encryptPassword(const std::string& password)
{
    std::string encrypted; // 기본 생성자 호출
    encrypted = password; // 복사 대입 연산자. 총 2번 호출
}

/* */

std::string encryptPassword(const std::string& password)
{
    std::string encrypted(password); // 복사 생성자 호출
}

 

단순히 정의를 미룰 뿐만 아니라 초기화 인자를 손에 넣기 전까지 정의를 늦출수 있는지도 확인해 봐야 한다.

기본 생성자->대입 보다는 복사 생성자로 정의와 초기화를 동시에 해주는게 성능상 이점이 있기 때문이다.

 

만약 지역변수가 반복문 안에서 사용되는 경우라면 생성자와 소멸자의 비용이 복사 대입 연산자보다 더 저렴하지 않은 이상 반복문 안에서 정의하는게 낫다.

 

◾ 변수 정의는 늦출 수 있을 때까지 늦춥시다. 프로그램이 더 깔끔해지며 효율도 좋아집니다.

 

 

27. 캐스팅은 절약, 또 절약! 잊지 말자

C++은 어떤 일이 있어도 타입 에러가 생기지 않도록 보장한다는 동작 규칙이 존재하지만 캐스트가 사용되면 예외가 되어버린다.

 

◾ const_cast : 객체의 상수성이나 휘발성을 제거하는 용도로 사용된다.

◾ dynamic_cast : 안전한 다운캐스팅을 할 때 사용된다. 대신 런타임 비용이 매우 높다.

◾ reinterpret_cast : 전혀 관련없는 타입간 캐스팅이 가능하다. 사용할 일이 없다.

◾ static_cast : 암시적 변환을 강제로 할 때 사용된다. c스타일의 타입 변환을 대체하는 것과 같다.

(참고 링크 : https://erikanes.tistory.com/262)

 

C 스타일의 구형 캐스트보다는 C++ 스타일의 캐스트를 사용하는 것이 바람직하다. 가독성도 좋아지고 사용 범위를 좁히기 때문에 컴파일러 쪽에서 오류를 확인할 수 있다.

 

 

class Window {
public:
    virtual void onResize() { ... }
};

class SpecialWindow : public Window {
public:
    virtual void onResize() {
        static_cast<Window>(*this).onResize(); // Window의 복사 생성자가 호출된다
    }
};

SpecialWindow sw;
sw.onResize();

 

캐스팅을 통한 형 변환 시, 임시 객체가 생성되어서 *this가 아닌 임시 객체의 onResize를 호출하게 된다.

 

 

static_cast<Window>(*this).onResize(); // X
Window::onResize(); // O

 

기본 클래스의 멤버 함수를 호출하고 싶다면 캐스팅이 아니라 직접 호출하도록 한다.

 

 

dynamic_cast는 다운 캐스팅을 안전하게 할 수 있지만 사용 비용이 높다.

파생 클래스들을 컨테이너에 담아서 접근할때마다 dynamic_cast를 하는것은 바람직하지 않다.

우회 방법으로 파생 클래스의 스마트 포인터만을 담을 수 있는 컨테이너를 만들거나 파생 클래스에 존재하는 함수를 기본 클래스에 가상 함수로 정의하는 것이다.

 

 

// 전자
typedef std::vector<std::shared_ptr<SpecialWindow>> VPSW; // 파생 클래스 스마트 포인터를 담는 vector
VPSW winPtrs;
for (VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    (*iter)->blink(); // 캐스팅이 필요 없다
}

/* */

// 후자
class Window {
public:
    virtual void blink() {}
};

typedef std::vector<std::shared_ptr<Window>> VPW; // 기본 클래스 스마트 포인터를 담는 vector
VPW winPtrs;
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) {
    (*iter)->blink(); // 가상 함수로 구현되어있기 때문에 가능
}

 

전자는 파생 클래스 갯수만큼 컨테이너 여러개를 만들어야 할 것이고 후자는 파생 클래스에서 해당 함수를 사용하지 않더라도 기본 클래스의 함수가 호출이 된다.

 

 

if (dynamic_cast<SpecialWindow1*>(iter->get()) { ... }
else if (dynamic_cast<SpecialWindow2*>(iter->get()) { ... }
else if (dynamic_cast<SpecialWindow3*>(iter->get()) { ... }

 

그렇다고 이딴 식으로(폭포식) 설계하는것은 절대로 해서는 안된다.

속도도 느릴뿐더러 파생 클래스가 추가될 때마다 유지보수가 어려워진다.

 

◾ 다른 방법이 가능하다면 캐스팅은 피하십시오. 특히 수행 성능에 민감한 코드에서 dynamic_cast는 몇 번이고 다시 생각하십시오. 설계 중에 캐스팅이 필요해졌다면, 캐스팅을 쓰지 않는 다른 방법을 시도해 보십시오.

◾ 캐스팅이 어쩔 수 없이 필요하다면, 함수 안에 숨길 수 있도록 해 보십시오. 이렇게 하면 최소한 사용자는 자신의 코드에 캐스팅을 넣지 않고 이 함수를 호출할 수 있게 됩니다.

◾ 구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호하십시오. 발견하기도 쉽고, 설계자가 어떤 역할을 의도했는지가 더 자세하게 드러납니다.

 

 

28. 내부에서 사용하는 객체에 대한 '핸들'을 반환하는 코드는 되도록 피하자

class Point {
public:
    void setX(int newVal);
    void setY(int newVal);
};

struct RectData {
    Point ulhc;
    Point lrhc;
};

class Rectangle {
public:
    Point& upperLeft() const { return pData->ulhc; }
    Point& lowerRight() const { return pData->lrhc; }
private:
    std::shared_ptr<RectData> pData;
};

 

upperLeft와 lowerRight는 상수 멤버 함수이고 사각형의 꼭짓점 정보를 알아낼 수 있는 방법만 제공하는 것이 목적이다.

하지만 참조자를 반환함으로써 외부에서 private 멤버 데이터를 수정할 수 있게 된다.

 

 

Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); // 상수 객체

rec.upperLeft().setX(50); // 분명 상수 객체인데 값 수정이 된다

 

상수 객체인데 값이 수정될뿐만 아니라 private 영역에 접근까지 해버린다.

클래스 데이터 멤버는 아무리 숨겨봐야 해당 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 정해진다.

포인터라고 하더라도 참조자, 포인터 둘 다 핸들이고, 객체의 내부요소에 대한 핸들을 반환하게 되면 해당 객체의 캡슐화는 언제든지 무너질 위험성이 있다.

위의 경우는 상수 참조자를 반환시켜주면 된다. 

 

또한 외부 공개가 차단된 멤버 함수의 포인터를 반환하는 멤버 함수도 존재하면 안된다.

 

 

상수 참조자가 반환된다 하더라도 무효참조 핸들에 대한 문제는 여전히 남아있다.

 

 

class GUIObject { ... };

const Rectangle boudingBox(const GUIObject& obj);

GUIObject *pgo;
const Point *pUpperLeft = &(boundingBox(*pgo).upperLeft()); // 임시 객체의 주소값 대입

 

pUpperLeft는 사라진 임시 객체의 주소를 가리키기 때문에 댕글링 포인터가 되어버린다.

일단 핸들이 반환되면 그 핸들이 참조하는 객체보다 더 오래 살 위험이 있기 때문에 주의해야한다.

 

◾ 어떤 객체의 내부요소에 대한 핸들(참조자, 포인터, 반복자)을 반환하는 것은 되도록 피하세요. 캡슐화 정도를 높이고, 상수 멤버 함수가 객체의 상수성을 유지한 채로 동작할 수 있도록 하며, 무효참조 핸들이 생기는 경우를 최소화할 수 있습니다.

 

 

29. 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!

예외 안전성을 확보하려면 두 가지 요구사항을 맞추어야 한다.

 

◾ 자원이 새도록 만들지 않는다

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    lock(&mutex);
    delete bgImage;
    ++imageChanges;
    bgImage = new Image(imgSrc);
    unlock(&mutex);
}

 

뮤텍스의 lock-unlock 사이에서 예외가 발생하면 unlock이 이루어지지 않아서 뮤텍스 반환이 되지 않는다.

 

◾ 자료구조가 더렵혀지는 것을 허용하지 않는다

만약 new Image에서 예외가 발생되면 앞서 삭제 및 값 증가가 모두 이뤄지지만 이미지가 새로 할당되지 않는다.

 

 

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    Lock ml(&mutex);
    ...
}

 

뮤텍스와 같은 자원은 앞서 말했던대로 객체로 관리하는 것이 좋다. 따로 신경쓰지 않아도 함수를 벗어날 때 알아서 해제해주기 때문이다. 자원이 새지 않기 때문에 첫 번째 요구사항을 만족하게 된다.

 

두 번째 요구사항인 자료구조 오염 방지는 우선 예외 안전성을 갖춘 함수가 필요하다.

예외 안전성을 갖춘 함수는 아래의 세 가지 보장중 한 가지를 제공한다.

 

◾ 기본적인 보장

예외 발생 시, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장. 하지만 프로그램의 상태를 정확히 예측하기 어렵다.

 

◾ 강력한 보장

예외 발생 시, 프로그램 상태를 절대로 변경하지 않겠다는 보장. (원자적 동작)

호출이 성공하면 의도한 대로 마무리까지 완벽하게 진행되고 예외 발생 시, 함수 호출 이전의 상태로 되돌아간다.

사용 편의성 측면에서 기본 보장을 제공하는 함수보다 예측 가능한 상태가 두 개밖에 안되기 때문에 더 쉽다.

 

◾ 예외불가 보장

무슨 일이 있어도 예외를 절대로 던지지 않겠다는 보장. 약속한 동작은 언제나 끝까지 완수한다는 의미이다.

 

아무 보장도 제공하지 않으면 예외에 안전한 함수가 아니다. 일반적으로 기본적인 보장과 강력한 보장 중 한가지를 선택한다.

 

class PrettyMenu {
    ...
    std::shared_ptr<Image> bgImage;
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    Lock ml(&mutex);
    bgImage.reset(new Image(imgSrc)); // 내부 포인터 교체
    ++imageChanges;
}

 

위와 같이 자원 관리 객체들을 이용해서 구현하는 경우, 다 좋지만 딱 하나의 옥의 티가 있다. reset이 호출되려면 Image가 생성되어야 하는데, Image의 생성자가 실행 도중 예외를 일으키면 입력 스트림의 읽기 표시자가 이동한 채로 남아 있을 가능성이 충분히 있고, 이것이 전체 프로그램에 영향을 미칠 가능성도 존재하게 된다. 

 

복사 후 맞바꾸기를 통해 강력한 예외 안전성 보장을 좀 더 완성시킬 수 있다. pimpl 관용구를 사용한다.

 

 

struct PMImpl {
    std::shared_ptr<Image> bgImage;
    int imageChanges;
};

class PrettyMenu {
private:
    Mutex mutex;
    std::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc) {
    using std::swap;
    Lock ml(&mutex);
    std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl)); // 객체 데이터 복사
    
    pNew->bgImage.reset(new Image(imgSrc)); // 사본 수정
    ++pNew->imageChanges;
    
    swap(pImpl, pNew); // 복사 후 맞바꾸기
}

 

그렇지만 이것도 함수 전체가 강력한 예외 안전성을 갖도록 보장할 수 없다.

함수 내부에서 또 다른 함수를 호출할 때, 그 함수가 강력한 예외 안전성을 보장하지 못하면 덩달아서 같이 강력한 예외 안전성을 보장하기 어려워진다. (side effect)

 

또한 효율적인 측면에서도 마냥 좋지는 않다. 복사 후 맞바꾸기는 수정할 객체를 복사해 둘 공간과 거기에 걸리는 시간을 감수해야 하기 때문이다.

 

실용성이 확보될 때만 강력한 보장을 제공하는데에 힘 쓰고, 기본적인 보장을 우선적으로 삼으면 된다.

 

언제나 예외에 안전한 코드를 만들지를 진지하게 고민하는 버릇을 들여야 한다.

정말 어쩔 수 없이 재래식 코드를 호출해서 예외 안전성의 보장이 무너질 경우, 반드시 문서로 남겨야 한다.

 

◾ 예외 안전성을갖춘 함수는 실행 중 예외가 발생되더라도 자우너을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않습니다. 이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있습니다.

◾ 강력한 예외 안전성 보장은 '복사-후-맞바꾸기' 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아닙니다.

◾ 어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않습니다.

 

 

30. 인라인 함수는 미주알고주알 따져서 이해해 두자

인라인 함수는 함수처럼 보이고 함수처럼 동작하지만 매크로보다 훨씬 안전하고 쓰기 좋다.

하지만 함수 본문을 바꿔치기 해주는 것 뿐이라서 인라인 함수를 남발하는 것은 목적 코드의 크기가 매우 커지기 때문에 메모리 사용에 있어서 그다지 좋지 않다.

가상 메모리를 쓰는 환경이라면 페이징 횟수도 늘어나고 캐시 적중률이 떨어질 가능성도 높아지기 때문에 성능저하가 일어날 수 있다.

 

인라인 함수는 대체적으로 헤더 파일에 들어 있어야 하는 게 맞다.

또한 인라인 함수는 컴파일러에게 요청하는 것이지 명령이 아니다. 복잡하거나 가상 함수인 경우 인라인 함수라고 명시적으로 선언되어 있어도 컴파일러가 묵살할 수 있다. 인라인 함수의 주소를 취하는 코드가 있는 경우에도 묵살된다.

 

 

inline void f() {...}

void (*pf)() = f;
f(); // 인라인 된다
pf(); // 인라인 되지 않는다
// f는 인라인, 아웃라인 함수 둘 다 존재하게 된다

 

덧붙여서 생성자와 소멸자는 인라인하기 좋지 않은 함수이다.

 

 

사실 인라인 함수는 이러저러한 이유보다 디버깅이 용이하지 않을 수 있다는 점 한가지가 크게 와닿는다.

어떤 함수가 인라인화 된다면 중단점도 안걸리고 호출 스택에 잡히지 않기 때문에 디버깅에 애로사항이 꽃필수 있다.

 

◾ 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는 것으로 묶어둡시다. 이렇게 하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 있는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있는 여지가 최고로 많아집니다.

◾ 함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline으로 선언하면 안 됩니다.

 

 

31. 파일 사이의 컴파일 의존성을 최대로 줄이자

헤더파일 안에서 헤더파일을 include 하는 경우 헤더파일 사이에 컴파일 의존성이 생기게 된다.

 

// A.h
#include <iostream>
class A {};

// B.h
#include "A.h"

// C.h
#include "B.h"

 

위와 같은 구조로 컴파일 의존성이 생기게 되면 A 헤더 파일의 아주 간단한 사항만 변경해도 B와 C 헤더 파일 역시 컴파일이 이루어져야 한다.

 

전방선언을 통해 인터페이스와 구현을 둘로 나누면 컴파일 의존성을 낮출 수 있다. 단, 표준 라이브러리는 전방선언 할 필요가 없다.

 

◾ 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다

어떤 타입의 포인터나 참조자를 정의할 때는 해당 타입의 선언부만 있으면 된다. 하지만 객체를 정의할 때는 정의가 준비되어 있어야 한다.

 

◾ 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만든다

class Date; // 전방 선언
Data today();
void clearAppointmets(Date d);

 

◾ 선언부와 정의부에 대해 별도의 헤더 파일을 제공한다

 

 

class Date;
class Adress;
class PersonImpl;

class Person { // PersomImpl의 인터페이스
public:
    Person() {}
    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::shared_ptr<PersonImpl> pImpl; // pimpl idiom

 

Person같이 pimpl 관용구를 사용하는 클래스를 핸들 클래스라고 한다. 

구현(PersonImpl)과 핸들(Person)을 두개로 쪼개서 사용한다. 둘의 멤버 함수는 일대일로 대응되기 때문에 인터페이스는 동일하다.

 

 

Person을 핸들 클래스가 아닌 인터페이스 클래스로 만드는 방법도 있다. 추상 클래스로 만드는 것이다.

 

class Person { // 인터페이스 클래스
public:
    virtual Person();
    virtual ~Person();
    virtual std::string name() const = 0;
    ...
    static std::shared_ptr<Person> create(...); // 객체 생성 함수
};

std::shared_ptr<Person> pp(Person::create(...));

 

인터페이스 클래스를 사용하기 위해서는 객체 생성 수단이 최소한 하나는 있어야 하고 주로 정적 멤버 함수로 만들어서 사용한다.(팩토리 함수/가상 생성자)

 

◾ 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 '정의' 대신에 '선언'에 의존하게 만들자는 것입니다. 이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스입니다.

◾ 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 합니다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용합시다.

'도서 > Effective C++' 카테고리의 다른 글

7. 템플릿과 일반화 프로그래밍  (0) 2022.12.05
6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30

+ Recent posts