본문 바로가기
프로그래밍 언어/C,C++

C++ copy and move constructor, assignment

by pagehit 2021. 8. 18.
반응형

이 글에서는 C++의 copy semantic과 move sematic에 대해서 알아봅니다.

 

객체(object)를 복사(copy)하는 방법에는 copy constructor, copy assignment 두 가지 방법이 있다. 먼저 '복사'의 의미를 알아보자.

객체 x를 객체 y로 '복사'한다는 것은 두 객체가 동등(equivalent)하지만 독립적(independent)이라는 뜻이다. 즉, 복사 후에 x == y이지만, 객체 x에 대한 수정은 객체 y에 영향을 주지 않는다.

예를 들어, 객체를 함수의 인자에 값으로 넘겨줄 때(pass by value) 복사가 일어난다.

 

#include <cstdio>

int add_one_to(int x) {
  x++;
  return x;
}

int main() {
  auto original = 1;
  auto result = add_one_to(original);
  printf("Original: %d; Result: %d", original, result);
}

// Original: 1; Result: 2

 

만약 객체를 함수의 인자로 넘겨주면, 각 객체의 멤버마다 copy가 일어난다.

 

struct Point {
  int x, y;
};

Point make_transpose(Point p) {
  int tmp = p.x;
  p.x = p.y;
  p.y = tmp;
  return p;
}

 

객체의 멤버로 포인터를 가지고 있으면, 같은 주소값이 복사되어 복사된 독립적인 객체가 같은 주소를 가리키는 문제가 발생한다. 이러한 문제가 발생한 것을 모르는 상태에서 해당 포인터를 해제하면, double free 문제가 발생할 수 있다. copy constructor나 copy assignment를 쓰면 이 문제를 해결할 수 있다.

 

copy constructor

copy constructor는 객체를 복사한 뒤 새로운 객체에 할당한다. copy constructor는 아래와 같이 생겼다. 다른 생성자를 사용하는 것처럼 쓰면 된다.

 

struct SimpleString {
  --snip--
  SimpleString(const SimpleString& other);
};

SimpleString a;
SimpleString a_copy{ a }; // copy constructor 호출

 

struct SimpleString {
    SimpleString(size_t max_size)
    : max_size{ max_size },
    length{} {
        if (max_size == 0 ) {
            throw std::runtime_error{ "Max size must be at least 1." };
        }
        buffer = new char[max_size];
        buffer[0] = 0;
    }
    ~SimpleString() {
        delete[] buffer;
    }

    void print(const char* tag) const {
        printf("%s: %s", tag, buffer);
    }

    bool append_line(const char* x) {
        const auto x_len = strlen(x);
        if (x_len + length + 2 > max_size) return false;
        std::strncpy(buffer+length, x, max_size-length);
        length += x_len;
        buffer[length++] = '\n';
        buffer[length] = 0;
        return true;
    }

private:
    size_t max_size;
    char* buffer;
    size_t length;
};

 

copy constructor를 이용해 deep copy를 구현할 수 있다. 객체가 멤버로 포인터나 참조를 가지고 있는 경우, 해당 포인터가 가리키는 변수들 까지 복사하는 것을 deep copy라 한다. copy constructor를 단순히 호출하면 포인터 변수 자체를 복사하지만 생성자 안에서 포인터가 가리키는 변수를 복사하도록 구현하면 deep copy가 된다.

 

SimpleString(const SimpleString& other)
  : max_size{ other.max_size },
    buffer{ new char[other.max_size] },
    length{ other.length } {
    std::strncpy(buffer, other.buffer, max_size);
}

 

copy constructor는 객체를 함수에 값으로 전달할 때 호출된다.

 

void foo(SimpleString x) {
  x.append_line("This change is lost.");
}

int main() {
  SimpleString a { 20 };
  foo(a); // Invokes copy constructor
  a.print("Still empty");
}

/*
Still empty:
*/

 

하지만 객체가 아주 큰 데이터를 가지고 있을 때 이를 복사하는 것은 성능상 매우 비효율적이다. 매번 새로운 메모리 공간을 할당하고 변수를 복사하는 일에는 많은 비용이 든다. 이를 피하기 위해서는 const reference를 사용해야 한다.

 

copy assignment

이미 존재하는 객체에 복사하려는 객체를 할당하여 copy assignment를 할 수 있다.

 

void dont_do_this() {
  SimpleString a{ 50 };
  a.append_line("We apologize for the");
  SimpleString b{ 50 };
  b.append_line("Last message");
  b = a; // copy assignment, 객체 a를 b에 복사한다.
}

 

copy constructor와 copy assignment의 차이점은 copy assignment에서는 기존 객체가 이미 값을 가지고 있을 수 있다는 점이다. 이러한 경우 기존 객체의 자원을 관리해 줘야 한다.(비워 주어야 한다)

 

default copy assignment는 객체의 멤버를 source object에서 destination object로 각각 복사한다. 크게 두 가지 문제가 발생할 수 있다. detination object에서 동적으로 할당된 배열이 해제(free)되지 않은 상태로 덮어 씌어질 수 있다. 두 번째, 두 개의 객체에서 같은 포인터를 소유하여, dangling pointer나 double free 문제가 발생할 수 있다. 따라서 copy assignment operator에서 clean up을 구현해야 한다.

 

struct SimpleString {
  --snip--
  SimpleString& operator=(const SimpleString& other) { // copy assignment
    if (this == &other) return *this;
    --snip--
    return *this;
  }
}

 

copy assignment는 결과 *this에 대한 참조를 반환한다. other가 this를 참조하는지 확인하는 것은 거의 idiom이다.

 

  SimpleString& operator=(const SimpleString& other) {
    if (this == &other) return *this;
    const auto new_buffer = new char[other.max_size];
    delete[] buffer;
    buffer = new_buffer;
    length = other.length;
    max_size = other.max_size;
    std::strncpy(buffer, other.buffer, max_size);
    return *this;
  }

 

default copy

컴파일러가 default copy constructor나 default copy assignment를 호출하도록 만들 수 있다.

default copy constructor와 assignment는 잘못 동작하는 경우가 많으므로 아래처럼 명시적으로 default를 사용하겠다고 적어줄 수 있다.

 

struct Replicant {
  Replicant(const Replicant&) = default;
  Replicant& operator=(const Replicant&) = default;
  --snip--
};

 

컴파일러가 copy constructor와 assignment를 만들지 못하도록 아래처럼 적어줄 수도 있다. 해당 클래스가 copy를 복하도록 delete를 명시해 줄 수 있다. 예를 들어, 클래스가 파일을 관리하거나 동시성 프로그램에서 mutual exclusion lock을 위해 사용한다.

 

struct Highlander {
  Highlander(const Highlander&) = delete;
  Highlander& operator=(const Highlander&) = delete;
  --snip--
};

 

copy semantic 정리

네트워크 연결이나 파일, 프린터 등과 같은 자원을 소유하는 객체에서는 copy constructor나 copy assignment를 구현하는 것이 좋다. 이러한 경우가 아니라면 default와 delete를 사용해 명시적으로 적어주는 것이 좋다.

 

move sematic

많은 양의 데이터가 관여할 경우 copy는 런타임에 시간이 많이 걸리기 때문에 비효율적이다. copy 대신에 move를 통해 한 객체에서 다른 객체로 자원의 소유권을 전달해 줄 수 있다.

 

객체 y를 객체 x로 move하면, x는 이전의 y 값과 동일한 값이 된다. move 이후에 y는 moved-from 상태가 된다. moved-from 객체에는 두 가지 연산 중 하나를 할 수 있다. 재할당하거나 destruct할 수 있다. 참고로 객체 y를 객체 x로 move하는 것은 단순히 rename하는 것이 아니다. 각각의 객체는 서로 다른 저장공간을 가지고 있으며 각자의 lifetime이 존재한다.

 

move constructor와 move assignment operator를 통해서 객체가 어떻게 move할지 명시할 수 있다.

 

c++ crash course
c++ crash course

 

단순히 copy를 하면 객체를 더 이상 사용하지 않아 낭비된다. 객체 a를 move하여 소유권을 넘겨 주어 객체 b는 이전의 a의 상태와 같아지고, 객체 a는 destruct될 수 있다.

 

moved-from object를 사용할 경우 위험하며 클래스의 invariant가 깨지는 일이 발생한다. 이를 방지하기 위해 lvalue와 rvalue가 있다.

 

모든 expression은 두 개의 특성 type과 value category가 있다. value category는 어떤 연산을 수행할 수 있는지 알려준다. value category는 복잡하며 glvalue(generalized lvalue), prvalue(pure rvalue), xvalue(expiring value), lvalue(xvalue가 아닌 glvalue), rvalue(prvalue or xvalue)가 있다.

 

일반적으로 아주 간단히 lvalue는 이름이 있는 value, rvalue는 lvalue가 아닌 value로 생각할 수 있다.

 

SimpleString a{ 50 };
SimpleStringOwner b{ a };                   // a is an lvalue
SimpleStringOwner c{ SimpleString{ 50 } };  // SimpleString{ 50 } is an rvalue

 

lvalue reference, rvalue reference

lvalue reference와 rvalue reference를 이용해 함수가 lvalue를 받을지, rvalue를 받을지 정할 수 있다. rvalue reference는 &&를 사용한다.

 

#include <cstdio>
#include <utility> // std::move

void ref_type(int &x) { // lvalue reference
  printf("lvalue reference %d\n", x);
}
void ref_type(int &&x) { // rvalue reference
  printf("rvalue reference %d\n", x);
}

int main() {
  auto x = 1;
  ref_type(x);
  ref_type(2);
  ref_type(x + 2);
  ref_type(std::move(x));
}
/*
lvalue reference 1
rvalue reference 2
rvalue reference 3
rvalue reference 1 // !!
*/

 

std::move는 lvalue를 rvalue로 바꿔준다. 즉 lvalue를 rvalue로 cast해준다. std::move는 실제로 move를 수행하지 않으며, 단지 cast만 해준다.

 

move construction

rvalue reference를 이용해 move construction을 구현할 수 있다. 

 

SimpleString(SimpleString&& other) noexcept
  : max_size{ other.max_size },
  buffer(other.buffer),
  length(other.length) {
  other.length = 0;
  other.buffer = nullptr;
  other.max_size = 0;
}

 

move constructor가 copy constructor와 다른 점은 rvalue reference를 사용한다는 점이다. other의 모든 field를 복사한 다음, other의 모든 field를 0으로 만들어 준다. other의 field를 모두 0으로 만들어 주기 때문에 other는 moved-from 상태가 되며, other가 destruction 되었을 때 메모리 공간은 안전하게 유지된다. rvalue reference를 사용하므로 해당 메모리 공간을 직접 접근하지 못한다.

buffer의 주소값만 넘겨주기 때문에 copy constructor보다 효율적이다.

move constructor는 예외를 발생하지 않도록 설계되어 있으므로 noexcept를 명시해 준다.

 

move assignment

move assignment는 const lvalue reference가 아닌 rvalue reference를 인자로 받는다.

 

SimpleString& operator=(SimpleString&& other) noexcept {
  if (this == &other) return *this;
  delete[] buffer;
  buffer = other.buffer;
  length = other.length;
  max_size = other.max_size;
  other.buffer = nullptr;
  other.length = 0;
  other.max_size = 0;
  return *this;
}

 

buffer를 비워 준 다음, other의 field를 복사해 주고 other는 0으로 만들어 준다. self-reference를 체크해 주는 조건문과 buffer를 clean-up해 주는 부분을 제외하고는 move constructor와 유사하다.

 

--snip--
int main() {
  SimpleString a{ 50 };
  a.append_line("We apologize for the");
  SimpleString b{ 50 };
  b.append_line("Last message");
  a.print("a");
  b.print("b");
  b = std::move(a);
  // a is "moved-from"
  b.print("b");
}
--------------------------------------------------------------------------
a: We apologize for the
b: Last message
b: We apologize for the

 

아래 코드에서 std::move를 이해하기 어렵다. 책에서는 lvalue/rvalue와 lvalue reference/rvalue reference는 다르다고 말한다. 즉, x는 rvalue reference이고, std::move(x)를 통해 rvalue로 만들어 주어, 더이상 x를 사용하지 못하게 한다.

 

SimpleStringOwner(SimpleString&& x) : string{ std::move(x) } { }

 

아래는 전체 소스코드이다.

 

#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <utility>

struct SimpleString {
  SimpleString(size_t max_size)
    : max_size{ max_size },
    length{} {
    if (max_size == 0) {
      throw std::runtime_error{ "Max size must be at least 1." };
    }
    buffer = new char[max_size];
    buffer[0] = 0;
  }
  ~SimpleString() {
    delete[] buffer;
  }
  SimpleString(const SimpleString& other)
    : max_size{ other.max_size },
    buffer{ new char[other.max_size] },
    length{ other.length } {
    std::strncpy(buffer, other.buffer, max_size);
  }
  SimpleString(SimpleString&& other) noexcept
    : max_size(other.max_size),
    buffer(other.buffer),
    length(other.length) {
    other.length = 0;
    other.buffer = nullptr;
    other.max_size = 0;
  }
  SimpleString& operator=(const SimpleString& other) {
    if (this == &other) return *this;
    const auto new_buffer = new char[other.max_size];
    delete[] buffer;
    buffer = new_buffer;
    length = other.length;
    max_size = other.max_size;
    std::strncpy(buffer, other.buffer, max_size);
    return *this;
  }
  SimpleString& operator=(SimpleString&& other) noexcept {
    if (this == &other) return *this;
    delete[] buffer;
    buffer = other.buffer;
    length = other.length;
    max_size = other.max_size;
    other.buffer = nullptr;
    other.length = 0;
    other.max_size = 0;
    return *this;
  }
  void print(const char* tag) const {
    printf("%s: %s", tag, buffer);
  }
  bool append_line(const char* x) {
    const auto x_len = strlen(x);
    if (x_len + length + 2 > max_size) return false;
    std::strncpy(buffer + length, x, max_size - length);
    length += x_len;
    buffer[length++] = '\n';
    buffer[length] = 0;
    return true;
  }
private:
  size_t max_size;
  char* buffer;
  size_t length;
};

 

copy constructor, assignment, move constructor, assignment를 default나 delete로 명시해 주는 것이 좋다.

반응형

댓글