2장. 생성자, 소멸자 및 대입연산자 🎺

안녕하세요! 두두코딩 입니다 ✋
오늘은 항목 12 객체의 복사에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

복사함수 (Copying function)

우리가 흔히 객체를 설계할 때, 복사관련된 함수를 작성한다. 이때, 잘 설계된 클래스를 보면 객체를 복사하는 함수는 복사생성자복사 대입 연산자 딱 둘만 있는 것을 볼 수 있는데 이를 “복사함수 (Copying function)”이라 부른다.

우리가 이전 포스팅을 통해 복사생성자와 복사 대입 연산자를 만들지 않을 경우 컴파일러가 암묵적으로 만들어 주는 것을 알 수 있었다. 컴파일러가 만들어준 기본 복사생성자 혹은 대입연산자는 저절로 만들어 졋찌만 동작은 아주 “기본적인 요구”에 충실하게 즉, 객체가 갖고 있는 데이터를 빠짐없이 복사하도록 한다.

만약, 복사생성자 혹은 복사 대입 연산자를 사용자가 직접만들 경우, 컴파일러가 제공해주는 기본적인 기능말고 추가적인 기능 혹은 검사를 하겠다고 선언하는 것으로 컴파일러는 암묵적으로 생성해주는 것들을 만들어 주지 않는다. 이때 컴파일러는 “이 함수는 내가 만든것이 아니니, 확실하게 검사는 할 수 없어.. 왜냐면 사용자 너가 만들었으니 너가 시키는 데로 할거야.. 🙄” 라고 하면서 확실히 틀렸을 경우에도 경고를 출력하지 않는다🤐

컴파일러가 에러를 출력해주지 않으면 어떤 경우에 문제가 되는지 한번 알아보자.

사용자 정의 복사함수

예를들어, 고객을 나타내는 클래스가 있다고 가정해보자. 해당 클래스 내 복사 생성자는 개발자가 직접 구현했고, 복사 함수를 호출 할때마다 로그를 남기도록 하는 작업을 수행하는 프로그램이다.

void logCall(const std::string& funcName);  // 로그 기록내용을 만들어줌

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::Customer& operator=(const Customer& rhs)
{
  logCall("Customer copy assignment operator");

  name = rhs.name;

  return *this;
}

위의 예시코드를 보자. 위의코드를 보면, 복사생성자복사 대입 연산자를 재정의 해 로그를 기록하도록 만들었으며, 문제가 하나도 없어보인다.

만약 우리가 시스템을 사용하다가 “고객등록 날짜”가 필요해 등록 날짜 클래스를 멤버데이터로 추가했다고 가정해보자.

class Date { ... };

class Customer {
public:
  ...

private:
  std::string name;
  Date signUpDate;
}

위와 같이, Date 클래스를 하나 만들고, 멤버변수에 SignUpDate라는 것이 추가되었다고 생각해보자. 이렇게 될 경우 기존 복사함수는 완전 복사가 아니라 “부분 복사 (partial copy)”가 된다. 즉, 기존 복사는 name의 멤버 변수만 했는데 이제는 signUpDate도 같이 해줘야한다. 하지만, 우리가 코드를 수정하지 않는이상 name멤버 변수만 복사하도록 되어져있을 것이다.

이런 상황에서 컴파일러가 “야 너 부분복사중이야!! 참고해” 라고 경고만 던져주더라도 프로그래머는 밤을 세야할 일이 줄어들 것이다.. 😭 하지만 컴파일러는 사용자가 정의한 기본 함수들에 대해서는 경고를 주지 않기 때문에 전적으로 개발자가 만들어야한다.

이를 해결할 방법은 다른게 있는게 아니다. 클래스에 데이터 멤버를 추가할 경우 추가한 데이터 멤버를 처리하도록 복사 함수를 다시 구현해야한다. 즉, 복사생성자복사 대입 연산자 내 새로 추가된 멤버변수를 처리하도록 수정해야한다.

(컴파일러에게 전적으로 맞겨두지 않은 벌이라 생각하고.. 열심히 수정하자 😭)

이런 사용자 정의 복사 함수 문제가 가장 골치아프게 만드는 부분은 클래스 상속할 때이다.

클래스 상속시 복사함수 처리

class PriorityCustomer : public 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&
PriorityCustomer::PriorityCustomer& operator=(const PriorityCustomer& rhs)
{
  logCall("PriorityCustomer copy assignment operator");

  priority = rhs.priority;

  return *this;
}

위의 코드를 보면, PriorityCustomer클래스의 복사함수는 언뜻 보기에 PriorityCustomer의 모든 것을 복사하고 있는것과 같이 보인다. 하지만, 우리는 Customer 클래스를 상속받아 사용하고 있기 때문에 Customer로 상속한 데이터 멤버들의 사본도 엄연히 PriorityCustomer 클래스에 들어있고, 이들은 복사되지 않고 있기 때문에 문제가 된다.

지금과 같이 구현할 경우, 복사생성자에서, Customer에 대한 인자없이 넘어가기 때문에 Customer의 기본생성자를 부르고 name과 같은 멤버변수는 기본 값으로 초기화 되게 된다. 즉, 객체 복사를 위해 복사생성자를 불렀는데, 일부의 값이 기본 값으로 초기화 되는 문제가 발생하는 것이다.

복사 대입 연산자는 복사생성자와 달리, 기본적인 생성자도 호출하지 않기 때문에 기본클래스의 데이터 멤버 값을 그대로 가지고 있게 된다.

위와 같은 문제를 해결하는 방법은 딱히 없다. 우리가 주의를 하고 고치는 수밖에.. 😭

“파생 클래스”에 대한 복사 함수들을 개발자 본인이 만들겠다고 한다면 클래스 부분의 복사에서 일부분을 빠뜨리지 않도록 주의해서 작성해야한다.

// 기반 클래스의 복사생성자를 꼭 호출해준다!
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
:Customer(rhs),
 priority(rhs.priority)
{
  logCall("PriorityCustomer copy constructor");
}

// 기반 클래스의 복사 대입연산자를 이용한다!
PriorityCustomer&
PriorityCustomer::PriorityCustomer& operator=(const PriorityCustomer& rhs)
{
  logCall("PriorityCustomer copy assignment operator");

  Customer::operator=(rhs);
  priority = rhs.priority;

  return *this;
}

위의 코드와 같이, 파생클래스에서 복사함수들을 재정의하기로 마음먹었다면, 꼭 “기반 클래스”의 복사함수들을 이용해 완전한 복사가 일어날 수 있도록 하자.

복사함수 중복

사실 위의 코드들을 보면 하는일이 비슷해 한쪽에서 호출해 처리하면 되지 않을까 의구심이 드는 사람들이 있을 수도 있다.

구체적으로, 양쪽이 판박이 같으니 한쪽에만 몰빵해서 구현해두고, 다른쪽에서는 호출하는 방식으로 문제를 해결하고자 할 수 있다. 코드중복을 위해서는 아주 기특한 생각이긴 하지만, 굉장히 위험한 발상이 될 수 있다.

복사 대입 연산자 에서 복사 생성자를 호출한다는 것 자체가 말이 되지 않는다. 이미 만들어져 존재하는 객체를 새롭게 “생성”하는 것이니 말이 되지 않는 것이다.

또한, 복사 생성자 에서 복사 대입 연산자를 호출하는 것도 말이 되지 않는다. 생성자의 역할은 새로 만들어진 객체를 초기화하는 것이지만, 대입 연산자는 ‘이미’초기화가 끝난 객체에게 값을 주는 것이다.

위의 말이 햇갈릴 수 있다. 너무 마음에 담아두지 말고, 이렇게 사용하지 말자라는 말을 기억하자.

그래도 중복된 부분을 제거하고 싶다면, 비슷한 부분의 코드를 묶어 함수로 구현한 후, 생성자대입연산자 내에서 함수를 호출하도록 하자.

To Sum Up

객체의 복사함수를 작성할 때 확인해야할 2가지

  1. 해당 클래스의 데이터 멤버를 모두 복사해야함
  2. 이 클래스가 상속한 기본 클래스의 복사함수도 꼬박꼬박 호출해주자

👉 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기반 클래스의 내용을 빠뜨리지 말고 복사해야함.

👉 클래스 복사함수 두개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대절대 하지 말것. 공통된 동작을 함수로 만들어 호출하는 방식으로 사용하는건 괜찮은 접근임.