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

안녕하세요! 두두코딩 입니다 ✋
오늘은 컴파일러가 암시적으로 생성해주는 생성자, 소멸자, 복사생성자, 복사 대입 연산자에 대해 알아보겠습니다.

Effective C++ 책을 보고 내용을 정리하고자 합니다👣

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

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

시작하기 전

2장에서 다루는 생성자, 소멸자, 대입연산자는 C++ 클래스에 한 개 이상 꼭 들어가 있는 것들이다. 생성자 같은 경우 객체를 메모리에 만드는 데 필요한 과정을 제어하고, 초기화를 맡는 함수이다. 소멸자 같은 경우 객체를 없앰과 동시에 그 객체가 메모리에서 적절히 사라질 수 있도록 도와주는 함수이며, 마지막으로 대입 연산자 같은 경우 기존의 객체에 다른 객체의 값을 넣어 줄 때 도와주는 함수이다.

이들은 아주 기본적인 것으로, 만약 개발자가 이들을 잘못 사용할 경우 기본적으로 틀린 프로그램이 작성된다. 따라서, 잘 만들어진 클래스를 만들기 위해 생성자, 소멸자, 대입연사자는 필히 알아야 하는 것들이다.

이제부터 같이 알아보자 💨

컴파일러가 만들어 주는 것들

우리가 흔히 비어있는 클래스이다 라고 말할때는 언제인가? 보통 C++ 컴파일러가 컴파일을 한 후 비어있는 클래스를 빈 클래스라고 말한다. 왜 그럴까?

C++ 컴파일러는 컴파일 과정에서 클래스 내 필요한 멤버함수가 없으면 암시적으로 넣어준다. 바로 생성자, 소멸자, 복사 생성자, 복사 대입 연산자등이 컴파일러가 암시적으로 만들어주는 것들이다.

class Empty();

라고 썼다면, 근본적으로 아래와 같이 컴파일러가 넣어주는데.. 🤔

class Empty()
{
public:
  Empty() { ... }   // 기본 생성자
  Empty(const Empty& rhs) { ... }   // 복사 생성자
  ~Empty() { ... }    // 소멸자

  Empty& operator=(const Empty& rhs) { ... }    // 복사 대입 연산자
}

위와 같이 암시적으로 몇 가지 멤버함수를 넣어준다. 이들은 모든 클래스에 다 넣어주는 것이아니라 필요할 때 넣어주는데, 보통 조건이 몇가지 있다.

  1. 기본생성자, 소멸자 생성 - Empty e1;
  2. 복사 생성자 - Empty e2(e1);
  3. 복사 대입 연산자 - e2 = e2;

위와 같이, class Empty를 사용함에 있어 클래스 내 멤버 함수가 존재하지 않는다면 해당 조건에 맞게 멤버함수들을 암시적으로 생성해준다.

기본생성자와 소멸자

컴파일러가 만든 기본 생성자와 소멸자가 하는 일은 “배후의 코드”를 깔 수 있는 메모리 공간을 할당받거나 제거하는 일을 한다. “배후의 코드”란 멤버변수들 중 필요할 경우 생성자를 호출하거나 복사 생성자를 호출하는 등의 요청을 할 수 있는 것을 말한다. (즉, 필요한 것들을 모두 할당 받기 위한 공간이라 생각하면 편함.)

소멸자에서는 가상클래스 관련해 할 이야기가 많은데, 이 부분은 항목 7에서 다루도록 하자.

복사 생성자와 복사 대입연산자

컴파일러가 만든 복사 생성자와 복사 대입 연산자는 하는 일이 아주 단순하다. 원본 객체의 비정적 데이터를 사본 객체 쪽에 옮기는 일을 한다.

template<typename T>
class NamedObject {
public:
  NamedObject(const char* name, const T& value);
  NamedObject(const std::string& name, const T& value);
  ...

private:
    std::string nameValue;
    T objectValue;
};

위의 예시를 통해 알아보자.

우선, NamedObject 내에는 인자를 가진 생성자가 선언되어져있다. 사용자가 생성자를 직접 생성할 경우 (인자 유무와 관계없이) 컴파일러는 기본생성자를 만들지 않는다.

위 예시에서는 복사 생성자와 복사 대입 연산자를 따로 만들지 않았기 때문에, 컴파일러가 기본적으로 만들어 넣어준다.

int main() {
  NamedObject<int> no1 ("prime number", 1);
  NamedObject<int> no2 (no1);   // 복사 생성자 호출
}

위와 같이 main에서 no2를 활용해 복사 생성자를 호출할 경우 기본 복사 생성자가 호출된다. no1.nameValueno1.objectValue는 각각 no2 멤버변수에 복사가 이뤄진다. nameValue는 string 타입으로 string 객체의 복사 생성자를 호출한다. (string은 복사 생성자를 갖고 있다.) objectValue는 int 타입으로 비트 복사가 일어난다.

말 그대로 복사하는 동작이 발생하며, 복사 대입 연산자도 동일한 방법으로 동작한다. 복사 대입 연산자 같은 경우 최종 결과 코드가 legal (적법)한지 혹은 resonable (합리적)인지 확인하고 조건이 만족되면 operator= (복사 대입 연산자)를 만들 수가 있다.

복사 대입 연산자 주의점

복사 대입 연산자 같은 경우 적법합리적을 따져 조건에 부합하지 않으면 컴파일러는 기본적으로 생성하지 않는다.

그렇다면, 어떤 기준으로 조건의 적법유무를 판단하는 것일까?

template<typename T>
class NamedObject {
public:
  NamedObject(const std::string& name, const T& value);
  ...

private:
    std::string& nameValue;
    const T objectValue;
};

int main() {
  std::string newDog("nd");
  std::string oldDog("od");

  NamedObject<int> p (nd, 2);
  NamedObject<int> s (od, 22);

  p = s;
}

위의 코드가 가능할까? 위의코드는 불가능하다. 생각해보면, 멤버변수가 string& 형으로 가지고 있다. 두 객체는 각각 다른 string 참조형을 가지고 있다. 이럴 경우 대입 연산은 불가능해진다. C++의 참조자는 원래 자신이 참조하고 있는 것과 다른 객체를 참조할 수 없기 때문이다. 즉, 내가 지정한 참조자 외에 다른 걸 참조할 수가 없다.

해당 복사 대입 연산자를 구현하기 위해선 깊은복사를 하든 추가로 변경이 필요하다. 즉, 사용자가 정의해야한다. 이런 애매모호한 상황에서는 컴파일러는 암시적 생성 거부를 선언한다. (적법하지 않다고 판단한 것이다.)

const를 보자. const의 경우에도 상수성인 값을 복사하는 행위자체가 적법하지 않다고 판단해 여기서도 컴파일러가 암시적 생성 거부를 선언한다.

애매한 경우 컴파일러가 암시적 생성을 거부하도록 하는 것이다. (어떤 도표가 있는 건 아닌 것 같다.) 경우의 수를 몇가지 알아놓는게 좋을 듯하다… 🤔

마지막으로 한 가지 더 적법하지 않은 경우를 생각해보자면, private영역에 기반 클래스의 복사 대입 연산자를 생성한 경우이다. 보통 파생클래스에서는 기반 클래스를 참고해 작업을 한다. 즉, 파생클래스의 복사 대입 연산이 일어날 경우 기반 클래스를 참고해야되는 경우가 발생하게 되는데, private영역에 있다면 접근이 불가능해진다.

따라서, 이런 경우도 애매한 경우라 컴파일러가 암시적 생성 거부를 선언한다.😫

Appendix

컴파일러는 모든 경우가 아닌 경우에 따라 “생성자”, “소멸자”, “복사 생성자”, “복사 대입 연산자”를 암시적으로 생성한다는 점을 기억하자.