Skip to main content

[C++ Primer Plus] Chapter 18. (1) Move Semantics와 rvalue참조, 새로운 클래스 형태

C++ 기초 플러스 책을 읽고 공부한 노트입니다.




Move Semantics의 필요성 #

  • Move Semantics는 객체의 리소스(동적 할당된 메모리 같은 것)를 또 다른 객체로 이동하는 것을 의미한다.
    • 만약 1,000자의 문자 string을 20,000개 가지고 있는 vector가 있다고 하자.
    • 이것을 복사하면?
      • vector 복사 생성자가 new를 이용해서 20,000개의 string 객체를 넣을 메모리를 만들어서 대입하고,
      • string 복사 생성자가 또 new를 이용해서 1,000개의 문자를 넣을 메모리를 만들어서 대입한다.
      • 그러면 총 20,000,000개의 문자가 복사된다.
    • 하지만 굳이 이런 복사를 하지 않아도 되는 경우가 있다.
      • 아래 예시 (2)의 경우가 그렇다.
      • AllCaps()에서 임시 객체 temp를 생성하고 그리고 함수 종료 시 임시 객체를 삭제한다.
      • 20,000,000개의 문자가 새로운 곳(copy2)에 저장되고 이전 저장소(temp)가 삭제되는 대신 문자가 남아 있는 자리에 이름만 copy2로 바꿔주면 어떨까?
      • 이런 접근법을 Move Semantics라고 한다.
    • Move Semantics을 위해서는…
      • rvalue 참조를 사용하는 이동 생성자를 만들어야 한다.
vector<string> AllCaps(const vector<string>& vs)
{
    vector<string> temp;
    // 매개변수로 전달된 vs를 모두 대문자로 만들어서 temp에 저장한다.
    return temp;
}

int main()
{
    vector<string> strs;
    // strs가 1,000자의 문자 string을 20,000개 가지고 있다고 가정하자.
    
    vector<string> copy1(strs);           // (1)
    vector<string> copy2(AllCaps(strs));  // (2)
}



이동 생성자 및 이동 대입 연산자 예시 #

  • 이동 생성자 Useless::Useless(Useless && f)
    • 매개변수로 전달된 f의 주소를 가로채고, nullptr로 설정해준다. 이것은 소멸자가 같은 주소에 대해 두 번 delete []하는 것을 막는다. 이것을 pilfering(필퍼링, 좀도둑질)이라고 한다.
#include <iostream>
using namespace std;


class Useless
{
private:
    int n;          // 매개변수 수
    char* pc;      // 데이터를 가리키는 포인터
    static int ct;  // 전체 객체 수 카운트
    void ShowObject() const;

public:
    Useless();
    explicit Useless(int k);
    Useless(int k, char ch);

    Useless(const Useless& f);  // 일반적인 복사 생성자
    Useless(Useless&& f);       // 이동 생성자

    ~Useless();

    Useless operator+(const Useless& f)const;

    Useless& operator=(const Useless& f); // 일반적인 복사 대입 연산자
    Useless& operator=(Useless&& f); // 이동 대입 연산자

    void ShowData() const;
};


int Useless::ct = 0;

Useless::Useless()
{
    ++ct;
    n = 0;
    pc = nullptr;
    cout << "기본 생성자 호출됨; 총 객체 수: " << ct << endl;
    ShowObject();
}

Useless::Useless(int k) : n(k)
{
    ++ct;
    cout << "int 생성자 호출됨; 총 객체 수: " << ct << endl;
    pc = new char[n];
    ShowObject();
}

Useless::Useless(int k, char ch) : n(k)
{
    ++ct;
    cout << "int, char 생성자 호출됨; 총 객체 수: " << ct << endl;
    pc = new char[n];
    for (int i = 0; i < n; i++)
        pc[i] = ch;
    ShowObject();
}

Useless::Useless(const Useless& f) : n(f.n) // 일반적인 복사 생성자
{
    ++ct;
    cout << "복사 생성자 호출됨; 총 객체 수: " << ct << endl;
    pc = new char[n];
    for (int i = 0; i < n; i++)
        pc[i] = f.pc[i];
    ShowObject();
}

Useless::Useless(Useless&& f) : n(f.n) // 이동 생성자
{
    ++ct;
    cout << "이동 생성자 호출됨; 총 객체 수: " << ct << endl;
    pc = f.pc;       // 주소 가로채기
    f.pc = nullptr;  // 이전 객체가 아무것도 반환하지 않도록 함
    f.n = 0;
    ShowObject();
}

Useless::~Useless()
{
    cout << "소멸자 호출됨; 남은 객체 수: " << --ct << endl;
    cout << "삭제된 객체 정보: ";
    ShowObject();
    delete[] pc;
}

Useless Useless::operator+(const Useless& f)const
{
    cout << "연산자 +() 진입\n";
    Useless temp = Useless(n + f.n);
    for (int i = 0; i < n; i++)
        temp.pc[i] = pc[i];
    for (int i = n; i < temp.n; i++)
        temp.pc[i] = f.pc[i - n];
    cout << "연산자 +() 나옴\n";
    return temp;
}

Useless& Useless::operator=(const Useless& f) // 일반적인 복사 대입 연산자
{
    if (this == &f) return *this;
    delete[] pc;
    n = f.n;
    pc = new char[n];
    for (int i = 0; i < n; i++)
        pc[i] = f.pc[i];
    return *this;
}

Useless& Useless::operator=(Useless&& f) // 이동 대입 연산자
{
    if (this == &f) return *this;
    delete[] pc;
    n = f.n;
    pc = f.pc;  // 주소 가로채기
    f.pc = nullptr; // 이전 객체가 아무것도 반환하지 않도록 함
    f.n = 0;
    return *this;    
}

void Useless::ShowObject() const
{
    cout << "매개변수 수: " << n;
    cout << " 데이터 주소: " << (void*)pc << endl;
}

void Useless::ShowData() const
{
    if (n == 0)
        cout << "(객체 없음)";
    else
        for (int i = 0; i < n; i++)
            cout << pc[i];
    cout << endl;
}


int main()
{
    {
        Useless one(10, 'x');
        cout << endl;

        Useless two = one;          // 복사 생성자 호출
        cout << endl;

        Useless three(20, 'o');
        cout << endl;

        Useless four(one + three);  // +()연산자와 이동 생성자 호출
        cout << endl;

        cout << "모든 객체 정보 표시해보기 \n";
        cout << "one: ";
        one.ShowData();

        cout << "two: ";
        two.ShowData();

        cout << "three: ";
        three.ShowData();

        cout << "four: ";
        four.ShowData();
        cout << endl;
    }
}
int, char 생성자 호출됨; 총 객체 수: 1
매개변수 수: 10 데이터 주소: 01526730

복사 생성자 호출됨; 총 객체 수: 2
매개변수 수: 10 데이터 주소: 01526180

int, char 생성자 호출됨; 총 객체 수: 3
매개변수 수: 20 데이터 주소: 01529F50

연산자 +() 진입
int 생성자 호출됨; 총 객체 수: 4
매개변수 수: 30 데이터 주소: 01529F90
연산자 +() 나옴
이동 생성자 호출됨; 총 객체 수: 5
매개변수 수: 30 데이터 주소: 01529F90
소멸자 호출됨; 남은 객체 수: 4
삭제된 객체 정보: 매개변수 수: 0 데이터 주소: 00000000

모든 객체 정보 표시해보기
one: xxxxxxxxxx
two: xxxxxxxxxx
three: oooooooooooooooooooo
four: xxxxxxxxxxoooooooooooooooooooo

소멸자 호출됨; 남은 객체 수: 3
삭제된 객체 정보: 매개변수 수: 30 데이터 주소: 01529F90
소멸자 호출됨; 남은 객체 수: 2
삭제된 객체 정보: 매개변수 수: 20 데이터 주소: 01529F50
소멸자 호출됨; 남은 객체 수: 1
삭제된 객체 정보: 매개변수 수: 10 데이터 주소: 01526180
소멸자 호출됨; 남은 객체 수: 0
삭제된 객체 정보: 매개변수 수: 10 데이터 주소: 01526730



강제 이동 #

  • 이동 생성자와 이동 대입 연산자는 rvalue와 함께 동작한다.
    • 하지만 lvalue와 함께 사용하고 싶을 때가 있다.
Useless choices[10];
Useless best;
int pick;

// 한 객체를 선택하고 pick 번째를 색인 하기 위한 설정...

best = choices[pick]; // 이렇게 하나를 선택하고 이전 배열을 버린다면...
// choices[pick]은 lvalue이지만 rvalue로 사용해서 이동 대입 연산자를 사용할 수 있다면 편리할 것이다. 

  • static_cast<>연산자를 이용해서 객체를 Useless&&타입으로 변환하면 가능하다.
    • C++11에서는 이와 비슷한 방법을 제공한다.
    • utility 헤더파일에 선언된 move()함수를 이용할 수 있다.
Useless one(10, 'x');
cout << endl;

Useless two;
cout << endl;


two = one;   // 복사 대입
cout << endl;

cout << "모든 객체 정보 표시해보기 \n";
cout << "one: ";
one.ShowData();

cout << "two: ";
two.ShowData();
cout << endl;


two = move(one);  // 강제 이동 대입 
cout << endl;

cout << "모든 객체 정보 표시해보기 \n";
cout << "one: ";
one.ShowData();

cout << "two: ";
two.ShowData();
cout << endl;
//...

복사 대입 연산자 호출

모든 객체 정보 표시해보기
one: xxxxxxxxxx
two: xxxxxxxxxx

이동 대입 연산자 호출

모든 객체 정보 표시해보기
one: (객체 없음)
two: xxxxxxxxxx

//...

  • move()는 이동 대입 연산자를 호출한다. 만약 이동 대입 연산자를 정의하지 않았다면,
    • 복사 대입 연산자를 사용한다. 그것도 정의하지 않았다면, 대입은 절대 허용되지 않는다.



새로운 클래스 형태 #

  • 복사 생성자, 복사 대입 연산자를 제공한다면,

    • 컴파일러는 이동 연산자나 이동 대입 연산자를 자동으로 제공하지 않는다.
  • 반대로, 이동 연산자나 이동 대입 연산자를 제공한다면,

    • 컴파일러는 복사 생성자나 복사 대입 연산자를 제공하지 않는다.

  • 기본 함수, 삭제 함수
    • default 키워드
      • 컴파일러가 생성하는 기본 함수를 사용하고자하는 경우
    • delete 키워드
      • 컴파일러가 특정 함수를 사용하는 것을 방지하고자 하는 경우
class Someclass
{
public:
    // 컴파일러가 생성한 기본 생성자를 사용한다. 
    Someclass() = default;

    // 복사 생성자와 복사 대입 연산자를 사용할 수 없다. 
    Someclass(const Someclass&) = delete;
    Someclass& operator=(const Someclass&) = delete;

    // 컴파일러가 생성한 이동 생성자와 이동 대입 연산자를 사용한다. 
    Someclass(Someclass&&) = default;
    Someclass& operator=(Someclass&&) = default;

    Someclass& operator+(const Someclass&) const;
};


int main()
{
    Someclass one;
    Someclass two;
    Someclass three(one);       // (X) lvalue는 허용하지 않는다. 
    Someclass four(one + two);  // (O) rvalue는 허용한다. 
}
class Someclass
{
public:
    // delete키워드로 특정 변환을 막을 수 있다. 
    void DoSomething(int) = delete;
    void DoSomething(double);
};


int main()
{
    Someclass one;
    one.DoSomething(1);    // (X) 컴파일 에러 
    one.DoSomething(1.1);  // (O) 
}

  • 위임 생성자
    • 다른 생성자의 정의의 일부를 생성자로 사용할 수 있도록 한 것이다.
    • 한 생성자가 임시로 다른 생성자가 생성한 객체에 책임을 전가하기 때문에 위임(delegation)이라고 한다.
class Notes
{
private:
    int i;
    double d;
    string st;

public:
    Notes();
    Notes(int);
    Notes(int, double);
    Notes(int, double, string);
};

Notes::Notes(int ii, double dd, string stt) : i(ii), d(dd), st(stt) { }

Notes::Notes()                  : Notes(0, 0.0, "") { }
Notes::Notes(int ii)            : Notes(ii, 0.0, "") { }
Notes::Notes(int ii, double dd) : Notes(ii, dd, "") { }

  • 상속 생성자
    • 파생 클래스에서 기본 클래스의 모든 생성자를 사용할 수 있다.
    • 상속된 기본 클래스 생성자는 오직 기본 클래스 멤버만을 초기화한다는 것을 명심하라.
class Base
{
private:
    int i;
    double d;

public:
    Base() : i(0), d(0.0) {}
    Base(int ii) : i(ii), d(0.0) {}
    Base(double dd) : i(0), d(dd) {}
    Base(int ii, double dd) : i(ii), d(dd) {}
};

class Derived : public Base
{
private:
    short s;

public:
    using Base::Base;  // Base 클래스의 생성자를 사용할 수 있다. 

    Derived() : s(0) {}
    Derived(double dd) : Base(dd), s(0) {}
    Derived(int ii) : Base(ii), s(0) {}
};


int main()
{
    Derived derived(10, 1.8);
    // Derived에 (int, double) 생성자가 없으므로
    // Base(int, double)을 사용한다.
    // 당연히 Derived의 멤버는 초기화되지 않는다. 
}

  • 가상 함수 관리
    • 가상 지정자 override를 사용함으로써 가상 함수를 오버라이드 하겠다는 것을 명시할 수 있다.
      • 선언이 기본 함수와 일치하지 않았을 때,
      • override를 사용하지 않으면 단순히 기본 함수를 숨기기만 한다.
      • 하지만 override를 사용하면 컴파일 에러가 난다.
    • 지정자 final은 파생 클래스에서 재정의할 수 없는 가상 함수를 지정한다.
class Base
{
public:
    virtual void Function(int ch) const  // int형 매개변수
    {
        cout << "Base" << endl;
    }
};

class Derived : public Base
{
public:
    virtual void Function(int* ch) const  // int*형 매개변수 (다르다)
    {
        cout << "Derived" << endl;
    }
};


int main()
{
    int num = 1;

    Derived d;
    d.Function(&num); // (O) Derived의 Function이 Base의 Function를 숨긴다. 
}
class Base
{
public:
    virtual void Function(int ch) const  // int형 매개변수
    {
        cout << "Base" << endl;
    }
};

class Derived : public Base
{
public:
    // (X) override 지정자를 사용했다. 매개변수가 기본 함수와 달라서 컴파일 에러가 난다.
    virtual void Function(int* ch) const override  
    {
        cout << "Derived" << endl;
    }
};
class Base
{
public:
    virtual void Function(int ch) const final  // final 지정자
    {
        cout << "Base" << endl;
    }
};

class Derived : public Base
{
public:
    virtual void Function(int ch) const   // (X) final인 함수를 오버라이드할 수 없다. 
    {
        cout << "Derived" << endl;
    }
};