책/이펙티브 C++

이펙티브 C++(111p ~ 121p)

readme.md 2024. 1. 15. 20:54

이펙티브 C++
https://www.yes24.com/Product/Goods/17525589
페이지 111p ~ 121p

요약

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

  • 객체의 안쪽 부분을 캡슐화한 객체 지향 시스템 중 설계가 잘 된 것들을 보면, 객체를 복사하는 함수가 딱 둘만 있는 것을 알 수 있다.
    • 복사 생성자
    • 복사 대입 연산자
  • 이 둘을 통틀어 객체 복사 함수(copying function)라고 부른다.
    • 객체 복사 함수는 컴파일러가 필요에 따라 자동으로 만들기도 한다.
    • 객체 복사 함수의 기본적인 요구 사항은 복사되는 객체가 갖고 있는 데이터를 빠짐없이 복사한다. 이다.
  • 객체 복사 함수를 직집 선언한다는 것은 컴파일러가 만든 객체 복사 함수의 기본 동작에 뭔가 마음에 안 드는 것이 있다는 이야기이다.
void logCall(const std::string& func);

class Customer {
public:
    Customer(const Customer& rhs);
    Customer& operator=(const Customer& rhs);

private:
    std::string name;
}

// 복사 생성자
Customer::Customer(const Customer& rhs): name(rhs.name) {
    logCall("Customer copy constructor");
}

// 복사 대입 연산자
Customer& operator=(const Customer& rhs) {
    logCall("Customer copy assignment operator");
    name = rhs.name;
    return *this;
}
  • Customer 클래스는 고객을 나타내며, 이 클래스의 복사 함수는 개발자가 직접 구현했고, 복사 함수들을 호출할 때마다 로그를 남기도록 되어있다.
  • 현재 코드는 문제가 없어 보이지만, 다음과 같이 클래스의 멤버를 추가하면 문제가 발생하게 된다.
class Date {};

class Customer {
public:

private:
    std::string name;
    Date lastTransaction;
}
  • Date 라는 새로운 멤버 변수가 추가되면, 복사 함수의 동작은 완전 복사가 아니라 부분 복사가 된다.
    • 고객의 name은 복사하지만 lastTransaction은 복사하지 않는다.
    • 컴파일러는 이런 사항에 대해 알려주는 정보가 없다.
    • 따라서 컴파일러가 자동 생성해주는 객체 복사 함수 사용을 포기했다면, 클래스에 새로운 멤버가 추가될 때마다 복사 함수를 다시 작성해주어야 하는 책임을 가져야 한다.
      • 복사 생성자와 복사 대입 연산자에 lastTransaction을 처리해줄 수 있는 코드를 반드시 작성해야 한다.
  • 클래스 상속을 사용하는 경우에는 다음과 같은 문제도 발생한다.
class PriorityCustomer: Customer {
public:
    PriorityCustomer(const PriorityCustomer& rhs);
    PriorityCustomer& operator=(const PriorityCustomer& rhs);

private:
    int priority;
}

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs): priority(rhs.priority) {
    logCall("PriorityCustomer copy constructor");
}

PriorityCustomer& operator(const PriorityCustomer& rhs) {
    logCall("PriorityCustomer copy assignment operator");
    priority = rhs.priority;
    return *this;
}
  • PriorityCustomer 클래스의 복사 함수는 Customer로부터 상속한 데이터 멤버들의 복사는 진행하지 않고 있다.
    • 위 코드에서 Customer 부분은 인자 없는 생성자가 호출되어 초기화가 진행되며, Customer 클래스의 인자 없는 생성자는 namelastTransaction에 대해 기본적인 초기화를 진행하게 된다.
    • 복사 대입 연산자는 기본 클래스의 데이터 멤버를 건드릴 시도도 하지 않기 때문에, 기본 클래스의 데이터 멤버는 변경되지 않고 그대로 있게 된다.
  • 파생 클래스에 대한 객체 복사 함수를 직접 만든다면, 기본 클래스 부분을 복사에서 빠뜨리지 않도록 각별히 주의해야 한다.
    • 기본 클래스의 private 멤버는 직접 건드릴 수 없으니 파생 클래스의 복사 함수 안에서 기본 클래스의 복사 함수를 호출하는 방법을 사용한다.
// 기본 클래스의 복사 생성자 호출
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
    : Customer(rhs),
        priority(rhs.priority)
{
    logCall("PriorityCustomer copy constructor");
}

// 기본 클래스의 복사 대입 연산자 호출
PriorityCustomer& operator=(const PriorityCustomer& rhs) {
    logCall("PriorityCustomer copy assignment operator");
    Customer::operator=(rhs);
    priority = rhs.priority;

    return *this;
}
  • 모든 부분을 복사하자의 의미는 다음과 같다.
    • 해당 클래스의 데이터 멤버를 모두 복사한다.
    • 이 클래스가 상속한 기본 클래스의 복사 함수도 꼬박꼬박 호출한다.
  • 클래스의 양대 복사 함수(복사 생성자, 복사 대입 연산자)는 본문이 비슷하게 나오는 경우가 자주 있어서, 한쪽에서 다른 쪽을 호출하게 만들어서 코드의 중복을 줄이고 싶을 수 있겠지만, 복사 함수의 경우에는 그렇게 할 수 없다.
    • 복사 대입 연산자에서 복사 생성자를 호출하는 경우
      • 이미 만들어진 객체를 다시 생성하는 것이다.
      • 특정 조건에서 데이터가 훼손되는 경우가 발생할 수 있다.
    • 복사 생성자에서 복사 대입 연산자를 호출하는 경우
      • 생성자의 역할은 새로 만들어진 객체를 초기화하는 것이다.
      • 대입 연산자의 역할은 이미 초기화가 끝난 객체에게 값을 주는 것이다.
      • 따라서 생성중인 객체에게 복사 대입 연산자를 사용하는 것은 아직 초기화도 안 된 객체에게 대입 연산을 하는 것과 같다.
    • 중복되는 부분이 있다면, 이를 별도의 private 멤버 함수로 만들어서(init 같은 함수) 이 함수를 호출하는 것이 낫다.

3. 자원 관리

  • 프로그래밍 분야에서 자원(resource)이란, 사용을 일단 마치고 난 후엔 시스템에 돌려주어야 하는 모든 것을 일컫는다.
    • C++에서 가장 흔하게 볼 수 있는 자원은 동적 할당한 메모리가 있으며, 파일 디스크립터, 뮤텍스 락, GUI의 폰트, 브러시 등이 있다.
  • 3장의 목표는 순도 100% 객체 기반 방식의 자원 관리를 보여주는 것으로 시작한다.
    • C++가 지원하는 생성자, 소멸자, 객체 복사 함수를 사용하는 방법이다.

항목 13: 자원 관리에는 객체가 그만!

// 투자를 모델링해 주는 클래스 설계
// 여러 형태의 투자를 모델링한 최상위 클래스
class Investment {};

// 파생 클래스의 객체를 사용자가 얻기 위한 팩토리 함수 사용
// 이 객체의 메모리 할당 해제는 사용자가 해야함
Investment* createInvestment();

void f() {
    Investment* pInv = createInvestment();
    // ...
    delete pInv;
}
  • createInvenstment 함수로부터 얻은 투자 객체의 삭제에 실패할 수 있는 경우가 한두 가지가 아니다.
    • 첫 번째는 //... 부분에서 중간에 return 하는 코드가 있는 경우이다.
    • 두 번째는 createInvestment 호출문과 delete가 하나의 루프 안에 들어 있고, continue 혹은 goto에 의해 갑작스럽게 루프에서 빠져나올 가능성이다.
    • 마지막으로 //... 부분에서 예외가 발생할 수도 있다.
  • 이들의 결과는 메모리가 누출이다.
  • createInvestment 함수로 얻어낸 자원이 항상 해제되도록 만들 방법은, 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 하며, 그 소멸자는 실행 제어가 f를 떠날 때 호출되도록 만드는 것이다.
  • 소프트웨어 개발에 쓰이는 상당수의 자원이 힙에서 동적으로 할당되고, 하나의 블록 혹은 함수 안에서만 쓰이는 경우가 잦기 때문에 그 블록 혹은 함수로부터 실행 제어가 빠져나올 때 자원이 해제되는게 맞다.
    • 표준 라이브러리를 보면 auto_ptr이라는 것이 있는데, 이런 용도에 쓰라고 마련된 클래스이다.
#include <memory>
// auto_ptr 사용
void f() {
    std::auto_ptr<Investment> pInv(createInvestment());
}
  • auto_ptr을 사용하면 auto_ptr의 소멸자를 통해 pInv를 삭제할 수 있게 된다.
  • 자원 관리에 객체를 사용하는 방법의 중요한 특징이 바로 여기에 있다.

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

  • createInvenstment 함수가 만들어 준 자원은 그 자원을 관리할 auto_ptr 객체를 초기화 하는데 쓰이고 있다.
  • 자원 획득 즉 초기화(Resource Acquisition Is Initialization: RAII)라고 불리는 기법이다.
  • 이런 이름이 나온 이유는 자원 획득과 자원 관리 객체의 초기화가 바로 한 문장에서 이루어지는 것이 너무나도 일상적이기 때문이다.

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

  • 소멸자는 어떤 객체가 소멸될 때(유효범위를 벗어나는 경우가 한 가지 예) 자동적으로 호출되기 때문에, 실행 제어가 어떤 경위로 블록을 떠나는가에 상관없이 자원 해제가 제대로 이루어지게 된다.
  • auto_ptr은 자신이 소멸될 때 자신이 가리키고 있는 대상에 대해 자동으로 delete를 호출해주기 때문에 어떤 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 절대 안된다.
    • 이런 경우가 발생하면 자원이 두 번 삭제되고, 미정의 동작의 수렁에 빠지게 된다.
    • 이런 불상사를 막기 위해 auto_ptr은 객체를 복사하면(복사 생성자, 복사 대입 연산자 사용) 원본 객체는 null로 만들어 버린다.(소유권의 개념)
std::auto_ptr<Investment> *pInv1(createInvestement());

std::auto_ptr<Investment> *pInv2(pInv1); // 소유권이 pInv2로 이전됨
// pInv1은 null이 됨

pInv1 = pInv2; // 소유권이 pInv1로 이전됨. pInv2는 null이 됨

메모

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

  • 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 한다.
  • 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 말아야 한다. 그 대신, 공통된 동작을 정의하는 제 3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들자.