18. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자

사용자가 인터페이스를 잘못 사용했을 경우 컴파일 되지 않아야 올바른 설계이다.

 

class Data {
public:
    Date(int month, int day, int year);
};

 

예를 들어 클래스 설계가 위와 같은 경우 매개변수의 순서를 다르게 전달하여 객체를 생성하는 경우가 발생할 가능성이 존재한다.

각 매개변수를 새로운 타입으로 변경한다면 괜찮게 동작할 수 있다.

enum의 경우 타입 안전성은 그리 믿음직 하지 못하다. (C++11부터 enum class 도입으로 인해 이제는 안전하다)

 

 

class Month {
public:
    static Month Jan() { return Month(1); }
    static Month Feb() { return Month(2); }
    ...
private:
    explicit Month(int m); // 생성자가 private 이므로 값이 새로 생성되지 않음
};

Date d(Month::Mar(), Day(30), Year(1995));

 

또는 타입 클래스의 유효한 집합을 미리 정적 멤버 함수로 정의해 두어도 괜찮다.

 

 

if (a * b = c) // 본래 의도는 ==

 

위와 같은 경우는 operator*의 반환 타입을 const로 한정하면 혹시 모를 실수를 방지할 수 있다.

특별한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만드는게 좋다. 그래야 일관성있는 인터페이스 제공이 가능해진다.

 

사용자 쪽에서 뭔가를 외워야 제대로 쓸 수 있는 인터페이스는 언제라도 잊어버릴 수 있기 때문에 잘못 쓰기 쉽다.

 

 

// 팩토리 함수
Investment* createInvestment(); // 만약 스마트 포인터 사용조차 까먹는다면?

 

앞선 장에서 다뤘던 구조이다. 팩토리 함수를 통해 객체를 생성해서 포인터를 반환받을 때, 그것을 스마트 포인터에 담는 것조차 잊어버릴 가능성도 존재한다.

 

 

std::shared_ptr<Investment> createInvestment();

 

그래서 아예 스마트 포인터를 반환하게 만들면 스마트 포인터에 담을수밖에 없고 객체 삭제를 잊더라도 문제가 발생하지 않는다.

 

 

또한 스마트 포인터의 특징으로 객체 생성과 삭제가 교차 DLL에 의해 다르게 이루어지는 문제가 발생하지 않는다.

new를 실행할 당시의 DLL과 delete를 실행하는 순간의 DLL이 다르더라도 객체가 생성될 때의 DLL에서 삭제자를 사용하도록 만들어져있어서 교차 DLL 문제에서 자유롭다.

 

◾ 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵습니다. 인터페이스를 만들때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.

◾ 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있습니다.

◾ 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기(클래스), 타입에 대한 연산을 제한하기(const), 객체의 값에 대해 제약 걸기(enum 등), 작업 관리 작업을 사용자 책임으로 놓지 않기(스마트 포인터)가 있습니다.

◾ shared_ptr은 사용자 정의 삭제자를 지원합니다. 이 특징 때문에 shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있습니다.

 

 

19. 클래스 설계는 타입 설계와 똑같이 취급하자

새로운 클래스를 정의한다는 것은 새로운 타입을 하나 정의하는 것과 같다.

좋은 타입은 문법이 자연스럽고 의미구조가 직관적이고 효율적인 구현이 한 가지 이상 가능해야 한다.

클래스를 설계할 때 아래의 질문을 항상 떠올리며 신경써서 설계하자.

 

◾ 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가? -> 이에 따라 생성자 및 소멸자의 설계가 바뀐다.

◾ 객체 초기화는 객체 대입과 어떻게 달라야 하는가? -> 초기화와 대입을 헷갈려선 안된다.

◾ 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가? -> 값에 의한 전달을 구현하는 쪽은 복사 생성자이다.

◾ 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가? -> 클래스의 불변속성은 클래스 차원에서 지켜주어야 한다.

◾ 기존의 클래스 상속 계통망에 맞출 것인가? -> 상속 여부에 따라 멤버 함수의 가상 함수 여부가 결정된다. 특히 소멸자.

◾ 어떤 종류의 타입 변환을 허용할 것인가? -> 타입간 변환 허용 여부에 따라 멤버 함수 또는 생성자의 설계가 이루어져야 한다.

◾ 어떤 연산자와 함수를 두어야 의미가 있을까?

◾ 표준 함수들 중 어떤 것을 허용하지 말 것인가? -> private 선언 함수

◾ 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?

◾ '선언되지 않은 인터페이스'로 무엇을 둘 것인가?

◾ 새로 만드는 타입이 얼마나 일반적인가? -> 새로운 클래스가 아닌 새로운 클래스 템플릿을 정의해야 할 수도 있다.

◾ 정말로 꼭 필요한 타입인가? -> 기능 몇개 추가하자고 파생 클래스를 새로 만들거라면 차라리 간단하게 비멤버 함수나 템플릿을 몇 개 더 정의하는 편이 낫다.

 

◾ 클래스 설계는 타입 설계입니다. 새로운 타입을 정의하기 전에, 이번 항목에 나온 모든 고려사항을 빠짐없이 점검해 보십시오.

 

 

20. '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다

기본적으로 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달' 방식을 사용한다.

클래스의 멤버 변수가 객체이고 또 그 객체의 멤버 변수가 객체라면 연속적으로 존재할수록 생성자가 연속적으로 호출된다.

객체 하나를 복사하기 위해 많은 생성자의 호출이 이루어진다. 소멸자도 마찬가지이다.

 

bool validateStudent(const Student& s);

 

상수객체 참조자에 의한 전달은 새로 만들어지는 객체가 없기 때문에 생성자와 소멸자가 전혀 호출되지 않는다.

또한 복사손실 문제(slicing problem)가 없어지는 장점도 있다.

 

 

class Window {
public:
    ...
    std::string name() const;
    virtual void display() const;
};

class WindowWithScrollBars : public Window {
public:
    ...
    virtual void display() const;
};

void printNameAndDisplay(Window w) // 값에 의한 전달로 복사손실 발생
{
    std::cout << w.name();
    w.display();
}

WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);

 

display 함수가 가상 함수로 선언되었기 때문에 WindowWithScrollBars::display가 호출되어야 하지만, 값에 의한 전달로 인해 파생 클래스의 정보가 모두 잘려서 Window 객체로 생성되어 전달되므로 Window::display만 호출된다.

상수객체 참조자에 의한 전달로 문제를 해결할 수 있다.

 

 

타입 크기가 작다고 무조건 값에 의한 전달이 효율적인것은 아니다. 포인터 멤버가 가리키는 대상까지 모두 복사해야 할 수도 있기 때문이다.

객체의 크기와 복사 생성자가 비싸지 않게 만들어졌어도 기본제공 타입과 사용자 정의 타입을 다르게 취급하는 컴파일러들이 있기 때문에 성능이 다를 수 있다.

예를 들어 기본제공 타입인 double은 레지스터에 담기지만 double 하나로만 만들어진 객체는 레지스터에 담지 않는다. 이런 경우는 참조에 의한 전달이 낫다. 포인터는 반드시 레지스터에 담기기 때문이다.

 

또한 크기가 작더라도 사용자 정의 타입의 크기는 언제나 변할 수 있기 때문에 값에 의한 전달을 무조건 사용할 수 없다.

일반적으로 값에 의한 전달이 저비용이라고 가정해도 되는 것은 기본제공 타입, STL 반복자, 함수 객체 타입 3개 뿐이다.

이 외의 경우에는 상수객체 참조자에 의한 전달을 선택하도록 하자.

 

◾ '값에 의한 전달'보다는 '상수 객체 참조자에 의한 전달'을 선호합시다. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아줍니다.

◾ 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않습니다. 이들에 대해서는 '값에 의한 전달'이 더 적절합니다.

 

 

21. 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자

대체로 상수 객체 참조자에 의한 전달이 좋다고 해도 있지도 않은 객체의 참조자를 넘기는것은 좋지않다.

함수 수준에서 새로운 객체를 만드는 방법은 스택과 힙에 만드는 두 가지 뿐이다.

어떤 연산에 의한 결과를 반환할 때, 그 결과가 스택에 만들어진 경우 참조자로 반환하게 되면 함수 종료시 소멸하기 때문에 존재하지 않는 참조자를 반환하는 결과가 되어버린다.

 

const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
    Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
    return *result; // 결과값을 참조자로 반환하는 것까지는 좋은데 삭제는 누가?
}

 

설령 그 결과가 동적 할당으로 힙에 만들어졌다고 하더라도 참조자가 올바르게 반환될지언정 포인터에 접근할 방법이 없기 때문에 자원의 누수가 발생한다.

또한 스택 기반이든 힙 기반이든 반환되는 결과는 생성자를 반드시 한 번 이상 호출하게 된다. 그렇다고 정적 객체를 반환하는것은 어불성설이다.

 

새로운 객체를 반환해야 하는 함수를 작성하는 경우에는 새로운 객체를 반환하도록 하면 된다. 말장난 같겠지만 말 그대로이다.

 

// case#1
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
    return result;
}

// case#2
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

Rational a(1,2);
Rational b(3,5);
Rational c = a*b; // 결과가 다를까?

 

case#1은 함수 내에서 생성자 한번, 밖에서 복사 생성자 한번 총 2번의 생성자가 호출된다.

하지만 case#2는 최적화 매커니즘에 의해 복사 생성자가 호출되지 않는다. (반환 값 최적화, RVO)

 

참조자를 반환할지 객체를 반환할지를 결정할 때 무엇을 선택하든 올바른 동작이 이루어지도록 만드는 것에 초점을 두어야 한다.

 

◾ 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 마세요.

 

 

22. 데이터 멤버가 선언될 곳은 private 영역임을 명심하자

데이터 멤버가 public이라면 문법적 일관성과 캡슐화가 깨진다.

예를 들어 데이터의 평균 값을 반환받는 멤버 함수를 만든다고 했을 때, 평균값을 담는 데이터 멤버를 클래스에 추가하거나 호출할때마다 평균 값을 반환하는 두 가지 방법이 존재한다.

 

성능상 이점에는 서로 차이가 있겠지만 멤버 함수를 호출한다는 공통점이 있다. 추후 수정사항이 발생하더라도 함수를 호출하는것은 변하지 않는것이 좋기 때문에 클래스의 불변속성을 유지하는데에 소홀해질수 없다.

 

하지만 데이터 멤버가 public이라면 사용자 코드가 깨질 수 있기 때문에 함부로 손을 대기가 더 어려워진다.

protected 데이터 멤버도 사실상 public과 같은 문제점을 안고있다. (의존성)

 

결과적으로 캡슐화의 관점에서 쓸모 있는 접근 수준은 캡슐화 제공이 가능한 private과 private이 아닌 나머지이다.

 

◾ 데이터 멤버는 private 멤버로 선언합시다. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐 아니라, 내부 구현의 융통성도 발휘할 수 있습니다.

◾ protected는 public보다 더 많이 '보호'받고 있는 것이 절대로 아닙니다.

 

 

23. 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자

캡슐화하는 것이 늘어나면 외부에서 볼 수 있는 것들이 줄어든다. 그만큼 변경할 때 필요한 유연성이 커지는 장점이 있다.

어떤 데이터를 접근하는 함수가 많으면 그 데이터의 캡슐화 정도는 낮아진다. 클래스에서 public으로 제공되는 멤버 함수가 많을수록 캡슐화의 정도가 낮아진다는 것이다.

 

그렇기 때문에 private 멤버에 접근할 수 있는 멤버 함수보다 비멤버/비프렌드 함수를 사용하는것이 캡슐화의 정도가 더 높아진다.

 

 

class WebBrowser {
public:
    void clearCache();
    void clearHistory();
    void removeCookies();
    void clearEveryThing(); // 위의 3개를 모두 호출
};

void clearBrowser(WebBrowser& wb) // 비멤버 비프렌드 함수
{
    wb.clearCache();
    wb.clearHistory();
    wb.removeCookies();
}

 

단순히 멤버 함수를 한번에 호출하는 함수라면 비멤버 비프렌드 함수가 캡슐화에 바람직하다.

단, 비멤버 비프렌드 함수 한정이다. 어쨌든 private 멤버의 캡슐화에 영향을 주지 않으면 된다.

 

위와 같은 코드를 같은 네임스페이스로 묶는것도 하나의 방법이다.

 

◾ 멤버 함수보다는 비멤버 비프렌드 함수를 자주 쓰도록 합시다. 캡슐화 정도가 높아지고, 패키징 유연성도 커지며, 기능적인 확장성도 늘어납니다.

 

 

24. 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자

class Rational {
public:
    Rational(int numerator=0, int denominator=1);
    const Rational operator*(const Rational& rhs) const;
};

Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // ok
result = 2 * oneHalf; // error

 

어떤 클래스의 생성자가 암시적 변환을 허용한다면 멤버 함수의 매개변수로 오는 값이 상수여도 암시적 변환을 통해 연산이 가능하다. 하지만 순서가 바뀌면 2라는 상수는 클래스와 연관이 없기 때문에 에러가 발생한다.

암시적 타입 변환에 대해 매개변수가 먹혀들려면 매개변수 리스트에 들어 있어야만 한다.

멤버 연산자 오버로딩을 호출하는 쪽은 왼쪽(*this)이고 반드시 명시적이어야만 한다.

 

 

class Rational {
    ...
};
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
	return Rational(lhs.getNumerator() * rhs.getNumerator(), lhs.getDenominator() * rhs.getDenominator());
}

Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // ok
result = 2 * oneHalf; // ok

 

하지만 비멤버 함수로 만들어준다면 모든 인자에 대해 암시적 타입 변환을 수행하도록 내버려둘수 있다.

 

멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수이다.

 

◾ 어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서)에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 합니다.

 

 

25. 예외를 던지지 않는 swap에 대한 지원도 생각해 보자

두 객체의 값을 맞바꾸기 한다는 것은 각자의 값을 상대방에게 주는 동작이다.

 

 

class Widget {
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs)
    {
        ....
        *pImpl = *(rhs.pImpl);
    }
private:
    WidgetImpl *pImpl;
};

 

pimpl idiom(관용구)(pointer to implementation) 설계를 차용해서 클래스를 설계한 경우 값을 맞바꿀때는 내부의 pImpl 포인터만 서로 교환해주면 된다.

하지만 std::swap은 그런 사실을 모르기때문에 포인터가 가리키는 모든 것을 복사해서 오버헤드가 크게 발생한다.

 

 

namespace std {
    template<> // 템플릿 특수화
    void swap<Widget>(Widget& a, Widget& b)
    {
        swap(a.pImpl, b.pImpl);
    }
}

 

함수 템플릿을 템플릿 특수화 시키면 Widget 타입에 한해서 위의 함수로 동작하게 된다.

물론 pImpl은 private이기 때문에 별도의 함수를 만들어서 사용해야 한다.

 

 

namespace std {
    template<typename T>
    void swap<Widget<T>>(Widget& a, Widget& b) // 부분 특수화 불가능
    {
        a.swap(b);
    }
}

 

만약 Widget과 WidgetImpl이 클래스가 아닌 클래스 템플릿으로 만들어져 있어서 WidgetImpl에 저장된 데이터의 타입을 매개변수로 바꿀수 있다면 부분 특수화를 시켜야 하는데, 클래스 템플릿은 부분 특수화를 허용하는 반면 함수 템플릿은 부분 특수화를 허용하지 않는다.

함수 템플릿을 부분 특수화 시키고 싶다면 그냥 오버로딩을 시키면 된다.

 

단, 일반적으로 함수 템플릿은 오버로딩 해도 별 문제가 없지만 std 함수 템플릿의 오버로딩은 유효하지 않다.

그렇다고 아예 동작하지 않는것은 아니고 컴파일 및 실행은 이루어지지만 실행되는 결과가 미정의 사항이 되어버린다.

 

 

먼 길을 돌아왔지만 결론적으로 멤버의 swap 함수를 호출하는 비멤버 함수를 선언하되 std::swap의 특수화 버전이나 오버로딩으로 선언하지만 않으면 된다.

 

namespace WidgetStuff {
    ...
    template<typename T>
    class Widget { ... };
    ...
    template<typename T>
    void swap(Widget<T>& a, Widget<T>& b)
    {
        a.swap(b);
    }
}

 

한가지 더, 사용자가 함수 템플릿 내부에서 swap을 호출할 때, 어떤 swap을 호출해야하는지 확신할 수 없는 경우에는 상황에 따라 swap을 호출할 수 있도록 해야한다.

 

template<typename T>
void doSomething(T& obj1, T& obj2)
{
    using std::swap; // std::swap을 이 함수 안으로 끌어올 수 있도록 한다
    swap(obj1, obj2); // T타입 전용의 swap 호출. 만약 없으면 std::swap이 호출된다
}

 

이름 탐색 규칙에 따라 전역 유효범위 또는 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지 찾은 뒤에 없으면 std::swap을 선택한다.

 

◾ std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공합시다. 이 멤버 swap은 예외를 던지지 않도록 만듭시다.

◾ 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공합니다. 클래스(템플릿이 아닌)에 대해서는, std::swap도 특수화해 둡시다.

◾ 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap을 호출합시다.

◾ 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능합니다. 그러나 std에 어떤 것이라도 새로 '추가'하려고 들지는 마십시오.

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

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
3. 자원 관리  (0) 2022.11.30
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30
1. C++에 왔으면 C++의 법을 따릅시다  (0) 2022.11.27

자원은 사용을 마치고 나면 시스템에 돌려주어야 하는 모든 것이다.

 

 

13. 자원 관리에는 객체가 그만!

어떤 함수 내에서 팩토리 함수 등으로 객체의 생성과 삭제가 이루어진다고 했을 때, 해당 함수가 객체의 delete 문에 항상 도달한다는 보장이 없다.

팩토리 함수로 얻어낸 자원이 항상 해제되도록 하려면 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡으면 된다. 소멸자는 함수를 떠날 때 호출되도록 만들면 언제나 저절로 해제된다.

 

개발에서 사용되는 상당수의 자원은 힙에서 동적으로 할당되고 하나의 블록 또는 함수 안에서만 쓰이는 경우가 잦기 때문에 블록이나 함수를 빠져나올 때 자원이 해제되는 것이 맞다.

이 때, 스마트 포인터를 사용하면 된다.

 

void f()
{
    std::auto_ptr<Investment> pInv(createInvestment()); // 팩토리 함수 호출
    ...
} // auto_ptr의 소멸자를 통해 pInv를 삭제한다

 

자원 관리에 객체를 사용하는 방법의 중요한 두 가지 특징

 

◾ 자원을 획득한 후에 자원 관리 객체에게 넘긴다

자원 획득과 자원 관리 객체의 초기화는 한 문장에서 이루어지는 것이 일상적이다. (RAII)

 

◾ 자원 관리 객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 한다

 

auto_ptr은 자신이 소멸될 때 자기가 가리키고 있는 대상에 대해서 자동으로 delete를 실행하기 때문에 어떤 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 절대로 안된다.

그래서 auto_ptr로 객체를 복사하면 원본 객체는 null로 바뀐다.

 

덧붙여서 STL 컨테이너는 auto_ptr의 원소가 될 수 없다.

 

auto_ptr을 쓸 수 없는 상황이라면 대안으로 shared_ptr가 있다.

auto_ptr과 달리 같은 자원을 여럿이 가리킬 수 있으며 참조 갯수가 0이 되면 해당 자원을 자동으로 삭제한다.

복사 동작이 의도한 대로 이루어지기 때문에 STL 컨테이너도 사용 가능하다.

단, 순환 참조가 발생하는 경우 참조 갯수가 0으로 줄어들지 않아서 자원이 해제되지 않는 문제가 발생할 수 있으므로 유의해야한다.

 

참고사항

Effective C++은 모던 C++이 적용되기 이전에 작성되었기 때문에 현재와 상이한 내용들이 있다.

auto_ptr은 C++11부터 사용 중지 권고, C++17부터 삭제되었다. 대신 unique_ptr이 등장했다.

또한 스마트 포인터의 배열의 삭제도 잘 이루어진다. (C++11/14와 17이 다르긴 하지만)

참고 : https://karupro.tistory.com/65

 

◾ 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII 객체를 사용합시다.

◾ 일반적으로 널리 쓰이는 RAII 클래스는 shared_ptr, 그리고 auto_ptr입니다. 이 둘 가운데 shared_ptr이 복사 시의 동작이 직관적이기 때문에 대개 더 좋습니다. 반면 auto_ptr은 복사되는 객체(원본 객체)를 null로 만들어 버립니다.

 

 

14. 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자

모든 자원이 힙에서 생기지는 않는다.

예를 들어 Mutex가 있다. 뮤텍스 잠금을 관리하는 클래스를 만들고자 할 때 기본적으로 RAII 법칙을 따라서 구성하게 된다.

하지만 만약 이 객체의 복사가 이루어진다면?

 

◾ 복사 금지

◾ 관리 자원 참조 카운팅 수행

◾ 관리 자원을 실제로 복사 (깊은 복사)

◾ 관리하는 자원의 소유권을 옮김 (=unique_ptr)

 

위 4가지 중 하나의 선택지를 고를 수 있다.

 

◾ RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의 복사 동작이 결정됩니다.

◾ RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해 주는 선으로 마무리하는 것입니다. 하지만 이 외의 방법들도 가능하니 참고해 둡시다.

 

 

15. 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자

스마트 포인터는 보통 역참조 연산자도 오버로딩하고 있기 때문에 관리하는 실제 포인터에 대한 암시적 변환도 쉽게 할 수 있다.

하지만 임의의 자원 관리 클래스를 만들어서 사용중이라면 외부에서 자원에 접근하는 방법을 제공해야 한다.

 

참고로 RAII 클래스는 데이터 은닉이 목적이 아니기때문에 자원 접근 함수를 열어두어도 캡슐화에 위배되지 않는다.

 

◾ 실제 자원을 직접 접근해야 하는 기존 API들도 많기 때문에, RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을 열어 주어야 합니다.

◾ 자원 접근은 명시적 변환 혹은 암시적 변환을 통해 가능합니다. 안전성만 따지면 명시적 변환이 대체적으로 더 낫지만, 고객 편의성을 놓고 보면 암시적 변환이 괜찮습니다.

 

 

16. new 및 delete를 사용할 때는 형태를 반드시 맞추자

단일 객체의 메모리 배치구조와 객체 배열에 대한 메모리 배치구조가 다르다. 대개 배열의 경우 최상단에 원소의 개수가 같이 박혀서 들어간다.

delete 호출 시 소멸자를 한번만 호출하고 delete[] 호출 시 앞쪽의 메모리 몇 바이트를 읽어서 배열 크기라고 해석한 뒤에 해당하는 횟수만큼 소멸자를 호출한다.

 

가급적 배열 타입을 typedef 타입으로 만들지 않는 것이 좋다.

 

◾ new 표현식에 []를 썼으면, 대응되는 delete 표현식에도 []를 써야 합니다. 마찬가지로 new 표현식에 []를 안 썼으면, 대응되는 delete 표현식에도 []를 쓰지 말아야 합니다.

 

 

17. new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자

int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);

processWidget(new Widget, priority()); // 컴파일 에러
processWidget(std::shared_ptr<Widget>(new Widget), priority()); // 컴파일 가능!.. 하지만 자원을 흘릴 가능성이 있다

 

스마트 포인터의 생성자는 explicit로 선언되어 있기 때문에 동적 할당된 포인터가 스마트 포인터 타입의 객체로 바뀌는 암시적인 변환이 존재하지 않는다.

명시적 변환으로 인자를 주게 될 경우 컴파일은 되지만 자원을 흘릴 가능성이 생긴다.

priority 호출, new Widget 실행, shared_ptr 생성자 호출 총 3가지 연산을 실행하는데 각각의 연산 순서가 컴파일러마다 다르기 때문이다.

만약 new Widget과 shared_ptr 사이에 priority 호출이 끼어들게 되고 priority 호출에서 예외 발생 시, 포인터가 유실될 수 있다.

 

해결 방법은 객체의 동적 할당과 스마트 포인터에 담는 코드를 하나의 독립적인 문장으로 만들고 스마트 포인터를 인자로 넘겨주면 된다.

 

std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

 

◾ new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만듭시다. 이것이 안 되어 있으면, 예외가 발생될 때 디버깅하기 힘든 자원 누출이 초래될 수 있습니다.

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

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30
1. C++에 왔으면 C++의 법을 따릅시다  (0) 2022.11.27

5. C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자

복사 생성자, 복사 대입 연산자, 소멸자는 직접 선언하지 않으면 컴파일러가 암시적으로 public 인라인 함수로 만들어버린다.

멤버 변수가 레퍼런스이거나 상수라면 복사 대입 연산자는 자동으로 만들어지지 않고 컴파일이 거부된다.

부모 클래스가 복사 대입 연산자를 private으로 선언한 경우에 자식 클래스는 복사 대입 연산자를 가질 수 없게 된다.

 

◾ 컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있습니다.

 

 

6. 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자

복사 생성자, 복사 대입 연산자 등 컴파일러가 암시적으로 만들어 낸 함수가 필요 없으면(또는 절대 사용해서 안된다면) private으로 선언하면 된다. friend를 통한 접근까지 완벽하게 막으려면 정의를 하지 않으면 된다. 접근 시 링커 에러가 발생한다.

링크 시점 에러를 컴파일 시점으로 옮기려면 복사 생성자와 복사 대입 연산자를 선언 및 정의한 부모 클래스를 private으로 상속 받으면 된다.

 

◾ 컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은 채로 두십시오. Uncopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법입니다.

 

 

7. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자

기본 클래스 포인터를 통해 파생 클래스가 삭제될 때, 기본 클래스의 소멸자가 비가상 소멸자라면 프로그램의 동작은 미정의 사항이 되어버린다. 파생 클래스의 소멸자가 호출되지 못하고 메모리의 누수가 발생해버린다.

그렇기 때문에 파생될 여지가 있는 기본 클래스의 소멸자는 반드시 가상 소멸자로 선언해주어야 한다.

 

◾ 가상함수가 하나라도 포함된 클래스는 가상 함수 테이블 포인터가 추가되기 때문에 객체의 크기가 커진다. 가상 함수가 하나라도 포함된 클래스에만 가상 소멸자를 추가해주도록 하자.

 

◾ STL 컨테이너는 모두 비가상 소멸자이므로 상속 받아서 사용하는 것은 자제하도록 한다.

 

◾ 추상 클래스를 만들고 싶은데 순수 가상 함수로 만들 함수가 하나도 없다면 소멸자를 순수 가상 함수로 만들면 된다. 이 때, 순수 가상 소멸자라도 반드시 구현을 해두어야 한다.

 

모던 C++은 순수 가상 함수가 아니더라도 클래스에 abstract 키워드를 붙임으로써 추상 클래스임을 명시할 수 있다. 

 

 

◾ 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다.

◾ 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 합니다.

 

 

8. 예외가 소멸자를 떠나지 못하도록 붙들어 놓자

예외가 발생할 소지가 있는 문제에 대해서는 소멸자에서 모든것을 처리하게 하지 않고 사용자가 대처할 기회를 가질 수 있게 하는 것이 좋다.

 

 

class DBConn {
public:
    void close()
    {
        db.close();
        close = true;
    }
    ~DBConn()
    {
        if (!close)
        {
            try {
                db.close(); // 사용자가 연결을 안 닫았다면 여기서 시도
            }
            catch (...) {
                // close 호출 실패 로그 작성
                // 프로그램 종료 또는 예외 삼키기
            }
        }
    }
private:
    DBConnection db;
    bool closed;
};

 

close 호출의 책임을 소멸자에서 사용자에게로 넘김으로써 예외를 처리할 기회를 준다.

예외가 발생한다면 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야만 한다.

 

◾ 소멸자에서는 예외가 빠져나가면 안 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 합니다.

◾ 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 합니다.

 

 

9. 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자

기본 클래스의 생성자가 호출되는 동안에는 가상 함수가 절대 파생 클래스 쪽으로 내려가지 않는다.

 

class Transaction
{
public:
    Transaction() { logTransaction(); }
    virtual void logTransaction() const = 0;
};

class BuyTransaction: public Transaction
{
public:
    virtual void logTransaction() const { ... }
};

BuyTransaction b;
// Transaction 생성자 -> logTransaction() -> BuyTransaction 생성자
// 하지만 logTransaction은 순수 가상 함수..?

파생 클래스의 객체가 생성될 때 호출 순서는 기본 클래스의 생성자부터이고 기본 클래스의 생성자가 호출되는 동안에는 파생 클래스의 정보를 모르는 상태이다. 이 때 만큼은 객체의 타입이 기본 클래스가 된다. 그래서 BuyTransaction의 logTransaction 함수가 아닌 Transaction의 logTransaction 함수가 호출 된다. 순수 가상 함수이므로 링크 에러가 발생하게 된다.

 

순수 가상 함수가 아니라 그냥 가상 함수이고 구현까지 되어있다면 오류가 전혀 발생하지 않기 때문에 디버깅에 애를 먹을 수 있다.

 

미초기화된 데이터 멤버는 정의되지 않은 상태에 있기 때문에 기본 클래스의 생성과 소멸이 진행되는 동안에는 가상 함수가 파생 클래스 쪽으로 내려가지 않는 것이다.

 

◾ 생성자 혹은 소멸자 안에서 가상 함수를 호출하지 마세요. 가상 함수라고 해도, 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않으니까요.

 

 

10. 대입 연산자는 *this의 참조자를 반환하게 하자

대입 연산은 여러 개가 사슬처럼 엮일 수 있다.

 

int x, y, z;
x = y = z = 15; // x = (y = (z = 15));

 

대입 연산이 연쇄적으로 이루어지려면 반드시 참조자가 반환되어야 한다는 것을 직관적으로 알 수 있다.

 

◾ 대입 연산자는 *this의 참조자를 반환하도록 만드세요.

 

 

11. operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자

 

w = w; // 자기 대입
a[i] = a[j] // 자기 대입 가능성 있음
*px = *py // 자기 대입 가능성 있음

 

Widget& Widget::operator=(const Widget& rhs)
{
    if (this == &rhs) return *this;
    
    delete pb;
    pb = new Bitmap(rhs.pb); // 만약 자기 자신이면 삭제된것으로 생성을 시도함
    
    return *this;
}

 

자기 참조의 위험이 있는 코드는 일치성 검사를 통해 처리해 주어야 한다.

만약 자기 참조가 허용된다면 자기 자신을 삭제 후 다시 할당하는 문제가 발생할 여지가 있다.

위의 경우는 동적 할당에서 예외가 발생하면 문제가 발생할 여지가 여전히 남는다.

또한 일치성 검사가 많이, 자주 일어날수록 비용이 커진다.

 

대부분 operator=를 예외에 안전하게 구현하면 대개 자기대입에도 안전한 코드가 나오게 되어있다.

 

 

Widget& Widget::operator=(const Widget& rhs)
{
    Bitmap *pOrig = pb;
    pb = new Bitmap(*rhs.pb);
    delete pOrig;
    
    return *this;
}

 

일치성 검사 대신 문장 순서를 바꾸는 것만으로도 효율적이지는 않지만 예외에 안전해 질 수 있다.

 

 

class Widget
{
    void swap(Widget& rhs);
};

Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs); // rhs의 사본 생성
    swap(temp); // *this와 사본을 교체
    
    return *rhis;
}

 

또는 복사 후 맞바꾸기(copy and swap)를 통해 효율성과 예외 안전성까지 챙길 수 있다.

객체를 복사하는 코드가 함수 본문에서 생성자로 옮겨졌기 때문에 더 효율적인 코드를 생성할 수 있는 여지가 생기고 객체의 교체가 이루어지기 전에 예외가 발생하면 원본을 그대로 유지할 수 있기 때문에 예외에도 안전해진다.

 

◾ 복사 후 맞바꾸기

어떤 객체를 수정하고 싶으면 그 객체의 사본을 만들어서 사본을 수정한 뒤에 맞바꾸는 것

 

◾ operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만듭시다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다.

◾ 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보세요.

 

 

12. 객체의 모든 부분을 빠짐없이 복사하자

복사 생성자와 복사 대입 연산자를 통틀어서 객체 복사 함수라고 한다.

객체 복사 함수를 명시적으로 구현할 때, 두 가지를 유의해야 한다.

 

  1. 데이터 멤버가 추가될 때 마다 복사 함수에 추가해 주어야 한다.
  2. 파생 클래스를 복사할 때 기본 클래스의 복사 함수도 같이 호출해 주어야 한다.

 

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) // 복사 생성자
: Customer(rhs),
  priority(rhs.priority)
{
    ...
}

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs) // 복사 대입 연산자
{
    Customer::operator=(rhs);
    priority = rhs.priority;
    
    return *this;
}

 

복사 함수의 기능이 서로 비슷하기 때문에 복사 대입 연산자에서 복사 생성자를 호출할 수 있을 것 같지만 그렇지 않다. 초기화와 대입이라는 서로 다른 매커니즘을 가지기 때문이다.

대신 양쪽에서 겹치는 부분은 별도의 멤버 함수로 분리한 후에 호출하는 방법을 이용할 수 있다.

보통 private으로 선언되어있고 init으로 시작하는 경우가 많다.

 

◾ 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다.

◾ 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합시다.

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

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30
1. C++에 왔으면 C++의 법을 따릅시다  (0) 2022.11.27

1. C++를 언어들의 연합체로 바라보는 안목은 필수

C++이 단일 언어가 아닌 여러개의 하위 언어를 제공한다고 생각하자.

 

◾ C

◾ 객체 지향 개념의 C++

◾ 템플릿 C++

◾ STL

 

◾ C++을 사용한 효과적인 프로그래밍 규칙은 경우에 따라 달라집니다. 그 경우란, 바로 C++의 어떤 부분을 사용하느냐입니다.

 

 

2. #define을 쓰려거든 const, enum, inline을 떠올리자

#define을 상수로 교체할 때 상수 포인터를 정의하는 경우와 클래스 멤버를 상수로 정의하는 경우를 유의해야한다.

 

우선 클래스 멤버를 #define으로 정의하는 것 자체가 불가능 하다는 것을 알아야한다.

클래스 멤버를 상수로 정의하는 경우, 상수의 유효 범위를 클래스로 한정짓고자 한다면 해당 상수를 멤버 변수로 만들어야한다. 다만 사본 개수가 한 개를 넘지 못하게 하려면 정적 멤버로 만들어주어야 한다.

 

class GamePlayer
{
private:
    static const int NumTurns = 5; // 구식 컴파일러에서는 오류 발생
    int scores[NumTurns];
};

 

구식 컴파일러에서는 나열자 둔갑술을 대신 사용할 수 있다.

 

class GamePlayer
{
private:
    enum { NumTurns = 5 };
    int scores[NumTurns];
};

 

◾ 단순한 상수를 쓸 때는, #define보다 const 객체 혹은 enum을 우선 생각합시다.

◾ 함수처럼 쓰이는 매크로를 만들려면, #define 매크로보다 인라인 함수를 우선 생각합시다.

 

 

3. 낌새만 보이면 const를 들이대 보자!

const가 * 왼쪽에 있으면 포인터가 가리키는 대상이 상수, 오른쪽에 있으면 포인터 자체가 상수이다

 

void f1(const Widget *pw);
void f2(Widget const *pw); // 둘 다 같은 의미

 

매개변수, 지역 객체를 수정할 수 없게 하는 것이 목적이라면 const를 반드시 붙여주도록 한다. 실수 발생시 컴파일 에러 발생으로 오류를 쉽게 잡을 수 있다.

 

◾ 상수 멤버 함수

객체를 reference-to-const로 전달하면 실행 성능을 높일 수 있다. (상수 멤버 함수 필요)

또한 const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다.

 

const char& operator[](std::size_t position) const // 상수 객체
char& operator[](std::size_t position) // 비상수 객체

 

어떤 멤버 함수가 상수 멤버라는것은 비트수준(물리적) 상수성, 논리적 상수성을 만족한다는 의미이다.

하지만 상수 멤버 함수는 물리적 상수성은 만족하지만 논리적 상수성은 만족하지 못한다.

 

class CTextBlock {
public:
    char& operator[](std::size_t position) const
    { return pText[position]; }
private:
    char *pText;
};
...
const CTextBlock cctb("Hello"); // 상수 객체 선언
char *pc = &cctb[0]; // 상수 버전 연산자 호출
*pc = 'J'; // 값이 바뀐다..?

 

 

컴파일러 입장에서는 비트수준 상수성을 만족하기 때문에 컴파일 오류 없이 정상 동작한다.

우리는 추가적으로 논리적 상수성을 지켜서 프로그래밍 해야 한다.

 

◾ 상수 멤버 및 비상수 멤버 함수에서 코드 중복을 피하는 방법

두 멤버 함수가 상수/비상수 차이만 있을 뿐 로직과 기능이 동일하다면 비상수 버전이 상수 버전을 호출하도록 구현하면 코드 중복을 피할 수 있다.

 

class TextBlock{
public:
    const char& operator[](std::size_t position) const
    {
        ...
        return text[position];
    }
    char& operator[](std::size_t position)
    {
        return const_cast<char&>( // 캐스팅으로 const 제거
            static_cast<const TextBlock&> // *this 타입에 const 추가
            (*this)[position]; // 상수 버전 호출
        );
    }
};

 

 

두 번의 캐스팅 없이 그냥 operator[]를 호출하면 자기 자신(비상수)을 재귀적으로 무한하게 호출한다.

그래서 *this를 const 캐스팅 시켜서 상수 버전을 호출하고 다시 캐스팅을 통해 const를 떼버린다.

 

반대로 상수 버전에서 비상수 버전을 호출하는것은 상수 멤버가 깨지기 때문에 불가능하다.

 

◾ const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 줍니다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있습니다.

◾ 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 여러분은 개념적인(논리적인) 상수성을 사용해서 프로그래밍해야 합니다.

◾ 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만드세요.

 

 

4. 객체를 사용하기 전에 반드시 그 객체를 초기화하자

대입과 초기화를 헷갈려서는 안된다.

대입의 경우 기본 생성자-복사 생성자 순으로 이뤄지지만 초기화는 인자가 직접 생성자의 인자로 쓰이기 때문에 호출 비용이 줄어든다.

데이터 멤버를 기본 생성자로 초기화 하는 경우에도 초기화 리스트를 사용하는 것이 좋다.

 

기본제공 타입의 멤버가 상수나 참조자인 경우에는 반드시 초기화가 이루어져야 한다.

 

C++은 기본 클래스가 파생 클래스보다 먼저 초기화되고 클래스 데이터 멤버는 선언된 순서대로 초기화된다.

 

비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다.

◾ 정적 객체: 함수 내부의 정적 객체를 제외한 나머지

◾ 번역 단위: obj 파일을 만드는 기반 소스 파일

 

서로 다른 번역 단위에 정의된 비지역 정적 객체들의 상대적인 초기화 순서는 정해져 있지 않다.

 

초기화 순서를 정하고싶다면 비지역 정적 객체를 하나씩 맡는 함수를 만들어서 지역 정적 객체로 바꾸면 된다. (≒싱글톤)

 

class FileSystem { ... };
FileSystem& tfs()
{
    static FileSystem fs; // 지역 정적 객체 정의/초기화
    return fs; // 참조자 반환
}

 

 

다중스레드 시스템에서 비상수 정적 객체(지역/비지역)는 문제가 발생할 수 있다.

참조자 반환 함수를 모두 호출해주면 초기화에 관계된 경쟁 상태가 발생하지 않는다.

 

◾ 기본제공 타입의 객체는 직접 손으로 초기화합니다. 경우에 따라 저절로 되기도 하고 안되기도 하기 때문입니다.

◾ 생성자에서는, 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고 멤버 초기화 리스트를 즐겨 사용합시다. 그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열합시다.

◾ 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 합니다. 비지역 정적 객체를 지역 정적 객체로 바꾸면 됩니다.

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

6. 상속, 그리고 객체 지향 설계  (0) 2022.12.04
5. 구현  (0) 2022.12.03
4. 설계 및 선언  (0) 2022.12.01
3. 자원 관리  (0) 2022.11.30
2. 생성자, 소멸자 및 대입 연산자  (0) 2022.11.30

+ Recent posts