본문으로 바로가기

OOP의 이해 - ⑤ Rule of three(or five)

category Computer Science/C++ 2021. 10. 13. 15:57

OOP의 이해 - ⑤ Rule of three(or five)

클래스를 생성하게 되면 아래의 6가지 함수는 컴파일러가 알아서 만들어 준다.

  • Constructor
  • Destroctor
  • Copy / Move Constructor
  • Copy / Move Assigment

Rule of three(or five)

보통의 경우 개발자가 Constructor는 필요에 따라 여러 가지 만들어서 사용할 수 있지만, 나머지 5개의 함수들은 기본으로 제공되는 함수를 사용한다. 하지만 멤버 변수로 동적 할당한 객체의 포인터를 가지게 된다면 나머지 5개의 함수를 직접 구현해야 한다. 이를 rule of three(혹은 five)라고 한다.

Constructor

개발자가 어떠한 생성자도 만들지 않았다면 컴파일러는 알아서 객체를 생성만 하는 디폴트 생성자를 만들어 준다.

class cat{
public:
    //Default Constructor
    // cat() {}
    void speak()
    {
        std::cout << mName << " " << mAge << ": mew~" << std::endl;
    }
private:
    std::string mName{};
    int mAge{};
};
int main()
{
    cat kitty;
    kitty.speak();
    return 0;
}

출력 결과

 0: mew~

만약 개발자가 매개변수를 가지는 생성자를 만든다면 컴파일러는 디폴트 생성자를 만들지 않는다. 그래서 매개변수를 가지는 생성자와 디폴트 생성자를 모두 사용하고 싶다면 다음과 같이 default 키워드로 디폴트 생성자를 가질 수 있다.

class cat{
public:
    //Default Constructor
    cat() = default;
    cat(std::string name, int age) : mName{std::move(name)}, mAge{age} {}
    void speak()
    {
        std::cout << mName << " " << mAge << ": mew~" << std::endl;
    }
private:
    std::string mName{};
    int mAge{};
};
int main()
{
    cat kitty;
    kitty.speak();
    cat nabi("nabi", 3);
    nabi.speak();
    return 0;
}

출력 결과

 0: mew~
nabi 3: mew~

Destructor

소멸자(Destructor) 또한 기본 소멸자를 컴파일러가 알아서 만들어 준다. 하지만 멤버 변수로 포인터 변수를 가지고, 동적 할당하여 사용 중이라면 소멸자에서 해당 포인터의 자원을 해제해야 한다.

class cat{
public:
    // Constructor 생략..

    // Destructor
    ~cat()
    {
        std::cout << mName << " destructor" << std::endl;
        //delete mPtr;
    }
    void speak()
    {
        std::cout << mName << " " << mAge << ": mew~" << std::endl;
    }
private:
    std::string mName{};
    int mAge{};
    //char* mPtr;
};
int main()
{
    cat nabi("nabi", 3);
    nabi.speak();
    return 0;
}

출력 결과

nabi constructor
nabi 3: mew~
nabi destructor

Copy Constructor

복사 생성자(Copy Constructor)는 객체를 생성할 때 다른 객체의 값을 복사해서 생성하는 역할을 한다. 복사 생성자의 매개변수는 클래스명(const 클래스명 & 매개변수명) 이다.

class cat{
public:
    // Constructor 생략..
    // Destructor 생략..

    // Copy Constructor
    cat(const cat& other) : mName{other.mName}, mAge(other.mAge)
    {
        std::cout << mName << " copy constructor" << std::endl;
        // mPtr = new char();
        // *mPtr = *other.mPtr;
    }

private:
    std::string mName{};
    int mAge{};
    //char* mPtr;
};
int main()
{
    cat kitty("kitty", 3);
    cat kitty2(kitty);  // 복사 생성자
    cat kitty3 = kitty; // 복사 생성자. (대입 연산자 아님 주의)
    return 0;
}

출력 결과

kitty constructor
kitty copy constructor
kitty copy constructor
kitty destructor
kitty destructor
kitty destructor

main함수의 3번째 줄에서 = 연산자가 있어서 대입 연산자가 호출되는 것처럼 보이지만 생성하는 동시에 값을 대입하는 것이라 실제로는 복사 생성자가 호출된다는 것에 주의하자.

Move Constructor

이동 생성자(Move Constructor)는 다른 오브젝트가 가지고 있는 데이터의 소유권을 이전받을 때 호출된다. 이동 생성자의 매개변수는 클래스명(클래스명 && 매개변수명) 이다.

class cat{
public:
    // Constructor 생략..
    // Destructor 생략..
    // Copy Constructor 생략..

    // Move Constructor
    cat(cat&& other) : mName{std::move(other.mName)}, mAge{other.mAge}
    {
        std::cout << mName << " move constructor" << std::endl;
        // mPtr = other.mPtr;
        // other.mPtr = nullptr;
    }
private:
    std::string mName{};
    int mAge{};
    //char* mPtr;
};
int main()
{
    cat kitty("kitty", 3);
    cat kitty2(std::move(kitty));  // 이동 생성자
    return 0;
}

출력 결과

kitty constructor
kitty move constructor
kitty destructor
 destructor

이동 생성자에서는 other 객체로부터 데이터 소유권을 모두 이전받아야 한다. 만약 출력 결과를 살펴보자. kitty 의 이름 데이터를 kitty2로 이전했기 때문에 kitty가 소멸될 때 mName은 아무것도 출력되지 않는다.

Copy Assignment

현재 객체에 다른 객체가 대입될 때 호출된다. 객체 AB를 대입할 경우 B의 값은 유지된 채로 AB의 값이 복사 되어야 한다. 때문에 Copy assignment라고 부른다.

자기 자신을 대입하는 경우도 있을 수 있다. 만약 동적 할당한 리소스를 관리하는 경우 메모리 누수가 발생할 수 있기 때문에 자기 자신에 대한 대입에 대한 예외처리도 필요하다.

class cat{
public:
    // Constructor 생략..
    // Destructor 생략..
    // Copy Constructor 생략..
    // Move Constructor 생략..

    // Copy Assignment
    cat& operator= (const cat& other)
    {
        if (this == &other)
        {
            return *this;
        }
        mName = other.mName;
        mAge = other.mAge;
        //*mPtr = *other.mPtr
        std::cout << mName << " Copy Assignment" << std::endl;

        return *this;
    }
    void speak()
    {
        std::cout << mName << " " << mAge << ": mew~" << std::endl;
    }
private:
    std::string mName{};
    int mAge{};
    //char* mPtr;
};
int main()
{
    cat kitty("kitty", 3);
    cat nabi("nabi", 5);
    nabi = kitty; // Copy Assignment
    nabi.speak();
    kitty.speak();
    return 0;
}

출력 결과

kitty constructor
nabi constructor
kitty Copy Assignment
kitty 3: mew~
kitty 3: mew~
kitty destructor
kitty destructor

Move Assignment

이동 연산이 필요할 때 호출된다. 우측 값의 데이터 소유권을 이전받는 역할을 수행한다. 이동 연산에서도 마찬가지로 자기 자신에 대한 이동 연산이 진행될 경우 예외처리를 해야 한다.

class cat{
public:
    // Constructor 생략..
    // Destructor 생략..
    // Copy Constructor 생략..
    // Move Constructor 생략..
    // Copy Assignment 생략..

    // Move Assignment
    cat& operator= (cat&& other)
    {
        if (this == &other)
        {
            return *this;
        }
        mName = std::move(other.mName);
        mAge = other.mAge;
        other.mAge = 0;
        // mPtr = other.mPtr;
        // other.mPtr = nullptr;
        //std::cout << other.mName << std::endl;
        std::cout << mName << " Move Assignment" << std::endl;
        return *this;
    }

    void speak()
    {
        std::cout << mName << " " << mAge << ": mew~" << std::endl;
    }
private:
    std::string mName{};
    int mAge{};
    //char* mPtr;
};
int main()
{
    cat kitty("kitty", 3);
    cat nabi("nabi", 5);
    nabi = std::move(kitty); // Move assignment
    nabi.speak();
    kitty.speak();

    return 0;
}

noexcept 키워드

앞서 구현한 함수들 중 Destructor, Move Constructor, Move Assignment에서는 리소스 할당이 일어날 이유가 없다. 그렇기 때문에 예외가 발생할 일도 없다. 그래서 noexcept를 붙여주어서 컴파일러가 더 명확하기 이해할 수 있도록 할 수 있다. 더 자세한 사항은 예외처리 파트에서 더 다룬다.

class cat {
public:
    //Default Constructor
    cat() = default;
    cat(std::string name, int age) : mName{ std::move(name) }, mAge{ age }
    {
        std::cout << mName << " constructor" << std::endl;
        //mPtr = new char(); 
    }
    // Destructor
    ~cat() noexcept
    {
        std::cout << mName << " destructor" << std::endl;
        //delete mPtr;
    }
    // Copy Constructor
    cat(const cat& other) : mName{ other.mName }, mAge(other.mAge)
    {
        std::cout << mName << " copy constructor" << std::endl;
        // mPtr = new char();
        // *mPtr = *other.mPtr;
    }
    // Move Constructor
    cat(cat&& other) noexcept : mName{ std::move(other.mName) }, mAge{ other.mAge }
    {
        std::cout << mName << " move constructor" << std::endl;
        // mPtr = other.mPtr;
        // other.mPtr = nullptr;
    }
    // Copy Assignment
    cat& operator= (const cat& other)
    {
        if (this == &other)
        {
            return *this;
        }
        mName = other.mName;
        mAge = other.mAge;
        //*mPtr = *other.mPtr
        std::cout << mName << " Copy Assignment" << std::endl;

        return *this;
    }
    // Move Assignment
    cat& operator= (cat&& other) noexcept
    {
        if (this == &other)
        {
            return *this;
        }
        mName = std::move(other.mName);
        mAge = other.mAge;
        other.mAge = 0;
        // mPtr = other.mPtr;
        // other.mPtr = nullptr;
        //std::cout << other.mName << std::endl;
        std::cout << mName << " Move Assignment" << std::endl;
        return *this;
    }
    void speak()
    {
        std::cout << mName << " " << mAge << ": mew~" << std::endl;
    }
private:
    std::string mName{};
    int mAge{};
    //char* mPtr;
};

정리

지금까지 아래의 6개 멤버 함수를 구현해보았다.

  • Constructor
  • Destructor
  • Copy / Move Constructor
  • Copy / Move Assignment

클래스에서 직접 포인터 변수로 리소스를 관리하는 것이 아니라면 Constructor를 제외한 나머지 5개 함수는 굳이 구현할 필요가 없다. 만약 직접 포인터 변수로 리소스를 관리한다면 rule of three(or five) 규칙에 따라 나머지 5개의 멤버 함수들을 모두 구현해야만 한다. 그래서 생산성이 높은 개발을 하고 싶다면 멤버 변수로 포인터 변수를 활용한 리소스 관리는 최대한 피하는 것이 좋다.

 

반대로 필요에 따라 delete 키워드를 활용하여 컴파일러가 자동으로 멤버 함수를 생성하는 것을 막을 수도 있다. 예를 들어 static 함수만 가지고 있는 클래스의 경우 생성자에 delete 키워드를 붙여 객체 생성을 막을 수 있다. 그리고 디자인 패턴에서 나오는 싱글톤 같은 경우 오직 하나의 객체만 존재함이 보장되어야 하기 때문에 Copy Constructor나 Move Constructor가 존재해서는 안된다. 이런 경우 delete 키워드로 해당 함수가 자동으로 생성되는 경우를 막아줄 수 있다.


출처: https://www.youtube.com/channel/UCHcG02L6TSS-StkSbqVy6Fg