C++에서 시맨틱 이동

Abdul Mateen 2023년10월12일
  1. C++ 객체 생성 예비
  2. C++에서 시맨틱 이동
C++에서 시맨틱 이동

이 자습서에서는 C++의 이동 의미 체계에 대해 설명합니다.

  • 딥카피 & 얕은카피 관련 개념에 대해 알아보겠습니다.
  • lvalue와 rvalue의 개념에 대해 빠르게 논의할 것입니다.
  • 우리는 예제를 통해 이동 의미를 이해하려고 노력할 것입니다.

참고: 얕은 복사 및 깊은 복사 개념에 자신이 있는 경우 이동 의미 체계 섹션으로 바로 이동할 수 있습니다.

C++ 객체 생성 예비

다음 간단한 예제를 사용하여 개체 복사본 생성 메커니즘을 빠르게 이해해 봅시다.

class T {
  int x;

 public:
  T(int x = 1) { this->x = x; }
  int getX() const { return x; }
  void setX(int x) { this->x = x; }
};
int main() {
  T o1;
  T o2(o1);
  cout << o1.getX() << '\t' << o2.getX() << '\n';
  o2.setX(5);
  cout << o1.getX() << '\t' << o2.getX() << '\n';
  return 0;
}

이 간단한 예에서 클래스 T에는 단 하나의 데이터 멤버(x)만 있습니다. x에 대한 기본값과 두 개의 팩토리 메서드가 있는 하나의 매개 변수화된 생성자를 작성했습니다.

main()에서 사용 가능한 생성자를 사용하여 객체 o1을 생성했습니다. 두 번째 줄에서는 o1의 사본인 o2라는 또 다른 개체를 만들었습니다.

이 객체는 모든 클래스에 기본적으로 존재하는 복사 생성자를 통해 생성됩니다. 이 기본 생성자는 데이터 멤버의 멤버별 복사를 수행합니다.

11행에서는 두 개체의 데이터 멤버를 인쇄하고 4행에서는 o2의 데이터 멤버 x 값을 변경합니다. 마지막으로 두 개체에 대한 수정 효과를 확인하기 위해 데이터 멤버를 다시 표시합니다.

다음은 이 코드의 출력입니다.

1	1
1	5

첫 번째 출력 라인에서 두 데이터 멤버 모두 값이 1입니다. 첫 번째 개체는 매개 변수화된 생성자를 사용하여 기본값 1을 사용하여 생성되기 때문입니다. 두 번째 개체는 o1의 복사본이므로 두 값이 동일합니다.

그러나 나중에 o2는 데이터 멤버의 값을 수정하기 위해 setter 함수를 호출합니다. 출력의 두 번째 라인은 o2의 데이터 멤버 값을 변경해도 o1의 데이터 멤버 값에 영향을 미치지 않음을 보여줍니다.

얕은 카피

안타깝게도 위의 복사 메커니즘은 데이터 멤버가 동적 메모리(힙에서 생성됨)를 가리키는 포인터인 경우에는 작동하지 않습니다. 이 시나리오에서 복사기 개체는 이전 개체에서 만든 동일한 동적 메모리 위치를 가리킵니다. 따라서 복사기 개체는 다른 개체의 얕은 복사본을 가지고 있다고 합니다.

개체 또는 데이터 멤버가 읽기 전용인 경우 솔루션이 제대로 작동합니다. 그렇지 않으면 심각한 문제가 발생할 수 있습니다. 먼저 다음 코드 예제를 통해 얕은 복사 개념을 이해해 보겠습니다.

class T {
  int *x, size;

 public:
  T(int s = 5) {
    size = s;
    x = new int[size];
    x[0] = x[1] = x[2] = x[3] = x[4] = 1;
  }
  void set(int index, int val) { this->x[index] = val; }
  void show() const {
    for (int i = 0; i < size; i++) cout << x[i] << ' ';
    cout << '\n';
  }
};
int main() {
  T o1;
  T o2(o1);
  o1.show();
  o2.show();
  o2.set(2, 5);
  o1.show();
  o2.show();
  return 0;
}

클래스 T에는 이 예제에서 포인터 x크기라는 두 개의 데이터 멤버가 있습니다. 다시 말하지만 기본값이 있는 매개변수화된 생성자가 있습니다.

생성자 본문 내부의 데이터 멤버 size기본 매개변수 값을 할당했습니다.

생성자의 7행과 8행은 크기 길이의 동적 배열을 선언하고 모든 배열 요소에 1을 할당합니다. set 메서드는 val 값을 동적 배열의 index 위치에 배치합니다.

main()에서 o1o2를 생성했습니다. 여기서 o2는 복사 객체입니다. 다음으로 두 개체를 모두 인쇄합니다.

다시 o2에 있는 배열의 세 번째 요소 값을 수정하고 개체를 인쇄합니다.

다음은 이 코드의 출력입니다.

1 1 1 1 1
1 1 1 1 1
1 1 5 1 1
1 1 5 1 1

이제 출력이 예상과 다를 수 있습니다. 처음 두 줄은 정확한 사본을 보여줍니다. 그러나 세 번째와 네 번째 줄에는 두 개체에 동일한 요소가 있지만 하나만 수정했습니다.

이는 기본 복사 생성자가 첫 번째 개체의 포인터 값(동적으로 할당된 배열의 주소)을 두 번째 개체의 포인터에 할당하여 얕은 복사본을 만들었기 때문입니다.

이 얕은 복사본은 읽기 전용 값이 있는 경우에도 괜찮습니다. 그렇지 않으면 출력에 업데이트 위험이 명확하게 표시됩니다.

딥 카피

얕은 복사와 달리 깊은 복사에서는 각 개체에 대해 별도의 동적 메모리를 할당합니다. 그런 다음 힙 내부의 각 요소에 대해 멤버별 복사를 수행합니다.

이는 기본적으로 복사 생성자와 대입 연산자를 오버로드하여 수행됩니다. 복사 생성자를 오버로드하는 코딩 예제를 참조하십시오.

T(const T &t) {
  size = t.size;
  x = new int[size];
  for (int i = 0; i < size; i++) x[i] = t.x[i];
}

두 번째 줄에서 새 동적 할당의 주소는 x에 할당됩니다. 3행과 4행에서는 모든 동적 배열 요소를 복사하여 딥 카피를 생성했습니다.

참고: 위의 예는 복사 생성자 오버로드에 대한 코드만 제공합니다. 나머지 코드는 이전 예제와 동일합니다.

다음은 복사 생성자를 추가한 후 이전 예제에서 작성한 기본 코드의 출력입니다.

1 1 1 1 1
1 1 1 1 1
1 1 1 1 1
1 1 5 1 1

3행과 4행은 첫 번째 객체의 두 번째 객체에 수정 효과가 없음을 보여줍니다. 이는 이제 2o1의 전체 복사본이기 때문에 변경 사항이 두 번째 개체에만 내재되어 있음을 의미합니다.

이제 lvalue & rvalue의 개념에 대해 빠르게 논의하겠습니다.

LValue 및 RValue

lvalue 또는 왼쪽 값은 값을 받는 할당 연산자의 왼쪽에 있는 피연산자를 참조합니다. 반대로 rvalue는 값을 제공하는 할당 연산자의 오른쪽에 있는 피연산자를 의미합니다.

따라서 단순 변수는 lvalue와 rvalue로 모두 사용할 수 있습니다. 상수 또는 변수 상수는 rvalue로만 사용할 수 있습니다.

표현식은 rvalue입니다. 예를 들어 a = b + c라고 쓸 수 있지만 b + c = a 또는 2 = a라고 쓸 수 없습니다.

C++에서 시맨틱 이동

마지막으로 이동 의미 체계에 대한 논의를 시작하겠습니다. 여기에서는 복사 생성자에 관한 이 개념에 대해 설명합니다.

먼저 클래스 T에 대한 복사 생성자에 대한 다음 호출을 참조하십시오.

T o2(o1);
T o3(o1 - o2);

위의 코드를 심층적으로 분석하면 첫 번째 줄에만 심층 복사가 필요하며 나중에 o1을 검사할 수 있습니다. 또한 o1 내용을 수정하기 위해 복사 생성자에 명령문을 포함할 수 있습니다(예: t.set(2,5)와 같은 명령문).

따라서 o1은 lvalue라고 합니다.

그러나 o1-o2라는 표현은 lvalue가 아닙니다. 대신 만장일치(이름 없음) 개체이므로 rvalue이며 o1-o2에 다시 액세스할 필요가 없습니다. 따라서 rvalue는 고려 중인 명령문을 실행한 후 곧 소멸되는 임시 객체를 나타낸다고 말할 수 있습니다.

C++0x는 rvalue 유형 참조를 위해 RValue Reference라는 새로운 접근 방식을 도입했습니다. 이 새로운 접근 방식을 통해 함수 오버로딩에서 rvalue 인수를 일치시킬 수 있습니다.

이를 위해 rvalue 참조 매개변수를 사용하여 다른 생성자를 작성해야 합니다.

T(T&& t) {
  x = t.data;
  t.data = nullptr;
}

먼저 rvalue에 대한 참조인 & 기호를 하나 더 추가한 새 생성자(복사 생성자 대신 이동 생성자라고 함)에 주목하십시오.

2행에서 동적 배열의 전체 복사를 수행하는 대신 기존 개체의 주소를 새 개체에 할당했습니다. 추가 보안을 위해 기존 개체의 포인터에 nullptr을 할당했습니다.

근거는 우리가 임시 소스 개체에 액세스할 필요가 없다는 것입니다. 따라서 이동 생성자에서 리소스(즉, 동적 배열)를 기존(임시) 개체에서 새 호출 개체로 이동합니다.

더 잘 이해하기 위해 할당 연산자를 오버로드하는 다른 코드를 살펴보겠습니다.

T& operator=(T t) {
  size = t.size;
  for (int i = 0; i < size; i++) x[i] = t.x[i];
  return *this;
}

여기서는 rvalue 참조를 사용하지 않았습니다. C++0x에서 컴파일러는 매개변수가 rvalue인지 lvalue인지 확인하고 그에 따라 이동 또는 복사 생성자가 호출됩니다.

따라서 할당 연산자 o1=o2를 호출하면 복사 생성자가 t를 초기화합니다. 그러나 할당 연산자 o3 = o2-o1를 호출하면 이동 생성자가 t를 초기화합니다.

그 이유는 o2-o1이 lvalue가 아닌 rvalue이기 때문입니다.

마지막으로 복사 생성자를 사용하여 추가 액세스를 위해 소스 개체를 안전하게 유지하는 옵션을 저장하기 위해 전체 복사본을 만드는 데 사용할 수 있다는 결론을 내립니다. 이동 생성자는 나중에 rvalue 개체에 액세스할 필요가 없기 때문에 기존 개체의 동적 메모리를 호출 개체의 포인터에 할당합니다.