Modern C++ 강의 정리 – Classes & Objects (2)

학습 내용들

– 복사 생성자 (Copy Constructor)
– 위임 생성자 (Delegating Constructor)
– Default, Delete
– L-value, R-value
– Move


복사 생성자 (Copy Constructor)

이미 만들어진 객체를 통해 객체를 초기화하는 생성자. 컴파일러에 의해 자동 생성되고, 단순히 값을 복사하는 형식으로 구현된다. 만약 클래스의 멤버 변수 중 포인터 멤버가 존재할 경우에는 단순히 그 주소가 복사될 뿐이기 때문에, 사용자 정의 복사 생성자가 필요한 경우가 있다.

#include <iostream>

class Integer {
    public:
        int *m_pInt;
        Integer(int value) {
            m_pInt = new int;
            *m_pInt = value;
        }
};

int main()
{
    Integer i1(3);
    Integer i2 = i1;
}

다음과 같은 경우 i2은 i1을 인자로 하는 복사 생성자에 의해 초기화된다. 이 때 포인터 멤버 변수인 m_pInt는 단순히 i1의 m_pInt가 가리키는 주소를 가져온 것이 된다. 따라서 두 객체는 동일한 주소를 m_pInt에 각각 저장하게 된다.

위 코드는 오류가 발생하지 않지만, 저 영역을 수정하는 함수를 구현하거나 소멸자에서 저 공간을 release 하게 한다면 즉시 에러가 발생한다. 두 객체에 대해 각각 소멸자가 호출되는데, 두 번째로 소멸자가 호출될 때는 이미 delete된 메모리를 또 delete 하려고 하기 때문이다.

Integer(Integer & obj) {
    m_pInt = new int(*obj.m_pInt);
}

다음과 같이 사용자 정의 복사 생성자를 구현할 수 있다. m_pInt는 새로운 메모리를 할당한 후 인자로 들어온 obj 레퍼런스의 m_pInt의 값을 할당받은 공간에 저장한다. 두 객체의 m_pInt는 서로 다른 공간을 가리킨다.

이러한 이유로 소멸자 / 복사 생성자 / 복사 대입 연산자는 반드시 셋 다 구현하거나 모두 컴파일러가 자동 생성하게 두어야한다. 만약 그렇지 않을 경우 소멸자가 메모리를 해제하거나 복사 생성자가 복사를 수행하는 과정에서 shallow copy가 발생하거나 memory leak이 발생할 수 있다.


위임 생성자 (Delegating Constructors)

생성자에서 또 다른 생성자를 호출 할 수 있다.

class Student {
    public:
    int grade;
    int classroom;
    Student(): Student(0, 0) {
        
    }
    Student(int grade): Student(grade, 0) {

    }
    Student(int grade, int classroom) {
        this->grade = grade;
        this->classroom = classroom;
    }
};

생성자를 호출하는 여러가지 상황에 따라 코드가 중복될 수 있는 소지가 있다. 위와 같이 기본 생성자나 매개변수가 1개인 생성자에서 매개변수가 2개인 생성자를 호출할 수 있도록 만들면, 한 번의 구현으로 여러 형태의 호출에 대응할 수 있다.

여기서 위임된 생성자의 본문은 위임한 생성자의 본문보다 앞서 실행된다. 위 예시에서 Student()나 Student(int grade)의 본문은 Student(int grade, int classroom)이 끝나고 난 후에 실행된다.


Default & Deleted Functions

클래스에서 매개 변수가 있는 생성자가 하나라도 있는 경우 컴파일러는 자동으로 기본 생성자를 만들지 않는다. 이 때 default 키워드를 통해 컴파일러가 기본 생성자를 자동생성하게 강제할 수 있다.

class Student {
public:
    int grade;
    Student() = default;
    Student(int grade) {
        this->grade = grade;
    }
};

다음과 같이 default 키워드를 통해 기본 생성자를 강제할 수 있다. 다만 멤버 변수는 초기화되지 않는다.

delete 키워드는 특정 형태의 생성자를 호출 할 수 없도록 한다. 예를 들어 어떤 클래스가 복사 생성자를 통해 호출되는 것을 막고 싶다고 가정하자. 이 때 다음과 같이 delete 키워드를 통해 복사 생성자 호출을 강제로 막을 수 있다.

class Student {
public:
    int grade;
    Student() = default;
    Student(const Student &) = delete;
};

위의 경우 컴파일러는 복사 생성자가 호출되는 구문에서 컴파일 오류를 뱉는다.

매개변수 Type을 자동으로 바꾸지 못하게 하는 방식에도 delete 키워드를 쓸 수 있다.

class Student {
public:
    int grade;
    Student() = default;
    void setGrade(int val) {
        this->grade = val;
    }
    void setGrade(float val) = delete;
};

위 클래스의 setGrade는 int를 매개변수로 하지만 float을 입력으로 넣는다고 해도 잘 동작한다. 그 이유는 float에 대해 setGrade(float val)이 호출되기 때문인데, delete 키워드를 통해 이런 암시적인 변환을 완전히 막아 예기치 못한 입력과 동작을 컴파일 단계에서 방지할 수 있다.


L-value와 R-value
  • L-value는 이름을 가지고, R-value는 이름을 가지지 않는다.
  • 모든 변수들은 L-value이다. R-value는 임시 값이다.
  • L-value에는 값을 대입할 수 있지만 R-value에는 값을 대입할 수 없다.
  • 어떤 표현식은 L-value를 리턴하고, 어떤 표현식은 R-value를 리턴한다.
  • L-value는 표현식 이후에도 유효하지만 (++x) R-value는 표현식 이후에는 존재하지 않는다. (x + y)
  • L-value는 레퍼런스를 리턴하는 함수에서 리턴된다. R-value는 값을 리턴하는 함수에서 리턴된다.
  • L-value reference와 R-value reference는 각각 L-value와 R-value만을 참조할 수 있다.
// l-value를 리턴
int Add(int x, int y) {
    return x + y;
}

// r-value를 리턴
int & inc(int &x) {
    return ++x;
}

int main()
{
    int x = 5; // x는 l-value, 5는 r-value
    int y = x * 5; // y는 l-value, x * 5는 r-value
    ++x = 7; // ++x는 l-value, 7은 r-value
}

R-value references

R-value references는 && 연산자를 통해 가능하며 오직 temproray만을 참조할 수 있고 l-value 참조는 불가능하다.

    int &&r1 = 10; // r-value reference
    int &&r2 = Add(1, 2); // r-value reference
    int x = 1; // l-value
    int &&r3 = x // 오류
    int &ref = x // l-value reference
    const int &ref2 = 3 // r-value reference

여기에서 특이한 점은 r-value reference가 && 연산자와 const & 두 가지 형태로 모두 사용가능하다는 점이다. 그렇다면 아래와 같은 경우가 발생할 수 있다.

int print(int &x) {
    std::cout << "print (int &x)";
}

int print(const int &x) {
    std::cout << "print (const int &x)" << std::endl;
}

int print(int &&x) {
    std::cout << "print (int &&x)" << std::endl;
}

int main()
{
    const int& x = 3;
    print(x);
    print(3);
}

x는 r-value에 대한 reference이다. 하지만 위 코드를 실행했을 때는 서로 다른 print 함수가 실행된다.

여기서 print(int &&x)를 주석처리하면 print(const int &x)만 두 번 호출된다. print(3)에서 3이 const int&로 암시적으로 변환된 것으로 보인다. 그 반대로 print(const int& x)를 주석으로 처리했을 때는 컴파일 오류가 발생한다. const int&를 int &&로 변환은 할 수 없는 것으로 보인다.

즉 int&& -> const int&로의 변환은 가능하지만 그 역은 성립하지 않는다.


Movie Semantics

복사의 경우 원본 객체의 모든 값들을 복사하여 대상 객체에 새롭게 저장한다. 만약 복사 이후에 원본 객체가 필요없어지는 경우라면 이 과정은 불필요한 복사 연산을 한 셈이다. 예를 들어 어떤 객체를 리턴하는 함수 A가 있다고 하자.

Object B = A()의 구문에서 A()는 임시 객체를 리턴하고 이 객체를 복사 생성자를 통해 B로 복사하게 된다. 그 이후 이 임시 객체는 더 이상 필요가 없다. 만약 복사 대신 값을 바로 이동시킨다면 수행시간이 단축될 수 있다. 이것이 복사의 원리다.

대상 객체는 원본 객체가 가리키는 영역을 그대로 가리키게 되고, 원본 객체는 nullptr을 가리키게 된다. 이 방식을 통해 Move를 수행한다.

class Integer {
public:
    int *val;
    Integer() = default;
    Integer(int val) {
        this->val = new int(val);
    }
    ~Integer() {
        delete val;
    }
};

Integer Add(const Integer& a, const Integer &b) {
    Integer tmp;
    tmp.val = new int(a.val + b.val);
    return tmp;
}

int main()
{
    Integer i1(1), i2(3);
    i1.val = Add(i1, i2).val;
    std::cout << i1.val;
}

이 구문에서 생성자는 총 4번 호출된다. i1과 i2에 대해 매개변수 생성자가 각 1번. tmp에 대해 기본 생성자가 1번. Add가 Integer 객체를 리턴할 때 tmp를 원본으로 한 복사 생성자가 한 번 호출된다. (tmp 객체의 life time은 Add가 끝날 때까지 뿐이다.)

Integer(Integer && obj) {
    val = obj.val;
    obj.val = nullptr;
}

다음과 같은 코드가 추가된 이후에는 Add가 Integer 객체를 리턴할 때 복사 생성자가 아니라 위의 생성자가 실행되게 된다. 이 때 새로운 객체는 원본의 val이 가리키는 주소를 그대로 가리키며, 대신 원본의 val은 nullptr를 가리키게 된다. 실제 데이터는 이동하지 않고 포인터만 달라졌기 때문에 복사에 비해 훨씬 빠르다.

이전 정리노트에서 소멸자 / 복사 생성자/ 복사 대입 연산자는 모두 사용자 정의이거나 존재하지 않아야 한다고 했는데, 이동 생성자에 대해서도 마찬가지다. 즉, 소멸자 / 복사 생성자 / 복사 대입 연산자 / 이동 생성자 / 이동 대입 연산자는 5가지 모두 구현되거나 모두 구현되지 않아야한다.

std::move는 어떤 객체를 실제로 이동하지는 않는다. 다만 대상 객체를 우측값 레퍼런스로 캐스팅해주는 역할만 한다. 해당 객체에 대해 이동 생성자나 이동 대입 생성자가 정의되어 있지 않으면 의미가 없다.

class Integer {
public:
    int* val;
    Integer() = default;
    Integer(int val) {
        this->val = new int(val);
    }
    Integer(Integer && obj) {
        this->val = obj.val;
        obj.val = nullptr;
    }
};

int main()
{
    Integer i1(1);
    auto i2 = std::move(i1);
    std::cout << *i1.val;
}

다음의 코드는 에러를 뱉는다. i2에 대해 i1를 이동 대입하고 난 후 i1의 포인터는 nullptr가 되었는데 이 상태에서 그 포인터 주소를 참조했기 때문이다.