c++ - type - cppreference assignment operator
복사 및 스왑 이디엄이란 무엇입니까? (4)
개요
복사 및 스왑 이디엄이 필요한 이유는 무엇입니까?
리소스 (스마트 포인터와 같은 래퍼) 를 관리하는 모든 클래스는 The Big Three 를 구현해야합니다. 복사 생성자와 소멸자의 목표와 구현은 간단하지만 복사 할당 연산자는 가장 미묘하고 어려울 것입니다. 어떻게해야합니까? 어떤 함정을 피해야합니까?
카피 - 스왑 (copy-and-swap) 관용구 는 솔루션이며, 할당 연산자가 코드 중복을 피하고 강력한 예외 보장을 제공한다는 두 가지를 달성하는 데 우아하게 도움을줍니다.
어떻게 작동합니까?
Conceptually 복사 생성자의 기능을 사용하여 데이터의 로컬 복사본을 만든 다음 복사 된 데이터를 swap
함수로 가져와 이전 데이터를 새 데이터로 바꿉니다. 그런 다음 임시 복사본이 삭제되고 이전 데이터가 삭제됩니다. 새로운 데이터의 복사본이 남아 있습니다.
copy-and-swap 관용구를 사용하려면 작업 복사본 생성자, 작업 소멸자 (둘 다 래퍼의 기초이므로 어쨌든 완료해야 함)와 swap
함수의 세 가지가 필요합니다.
스왑 함수는 클래스의 두 객체 (멤버의 멤버)를 서로 바꿔주는 비 던진 (non-throwing) 함수입니다. 우리 자신을 제공하는 대신 std::swap
을 사용하도록 유혹 될 수도 있지만 이는 불가능할 수 있습니다. std::swap
은 구현 내에서 복사 생성자와 복사 할당 연산자를 사용하며 궁극적으로 할당 연산자를 자체적으로 정의하려고합니다.
(그뿐만 아니라 스 swap
부적합한 호출은 std::swap
이 필요로하는 클래스의 불필요한 생성 및 파기를 건너 뛰고 커스텀 스왑 연산자를 사용할 것입니다.)
심층 설명
목표
구체적인 경우를 생각해 봅시다. 우리는 무의미한 클래스에서 동적 배열을 관리하려고합니다. 우리는 작업 생성자, 복사 생성자 및 소멸자로 시작합니다.
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
class dumb_array
{
public:
// (default) constructor
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}
// copy-constructor
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr),
{
// note that this is non-throwing, because of the data
// types being used; more attention to detail with regards
// to exceptions must be given in a more general case, however
std::copy(other.mArray, other.mArray + mSize, mArray);
}
// destructor
~dumb_array()
{
delete [] mArray;
}
private:
std::size_t mSize;
int* mArray;
};
이 클래스는 배열을 거의 성공적으로 관리하지만 올바르게 작동하려면 operator=
가 필요합니다.
실패한 솔루션
다음은 순진한 구현 방식을 보여줍니다.
// the hard part
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get rid of the old data...
delete [] mArray; // (2)
mArray = nullptr; // (2) *(see footnote for rationale)
// ...and put in the new
mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}
return *this;
}
그리고 우리는 우리가 끝났다고 말한다. 이제는 누출없이 어레이를 관리합니다. 그러나 코드에서 (n)
으로 순차적으로 표시되는 세 가지 문제가 있습니다.
첫 번째는 자체 할당 테스트입니다. 이 검사는 두 가지 목적을 수행합니다. 즉, 자체 할당시 불필요한 코드를 실행하지 못하도록하는 쉬운 방법이며 미묘한 버그 (예 : 배열을 삭제하고 복사하는 등)로부터 우리를 보호합니다. 그러나 다른 모든 경우에는 프로그램을 느리게 실행하고 코드에서 소음으로 작용합니다. 자체 할당이 거의 발생하지 않으므로 대부분이 시간을 낭비합니다. 운영자가 없이도 제대로 작동한다면 더 좋을 것입니다.
두 번째는 기본 예외 보증 만 제공한다는 것입니다.
new int[mSize]
가 실패하면*this
수정됩니다. (즉, 크기가 잘못되어 데이터가 사라졌습니다!) 강력한 예외 보증을 위해서는 다음과 같은 것이 필요합니다.dumb_array& operator=(const dumb_array& other) { if (this != &other) // (1) { // get the new data ready before we replace the old std::size_t newSize = other.mSize; int* newArray = newSize ? new int[newSize]() : nullptr; // (3) std::copy(other.mArray, other.mArray + newSize, newArray); // (3) // replace the old data (all are non-throwing) delete [] mArray; mSize = newSize; mArray = newArray; } return *this; }
코드가 확장되었습니다! 따라서 세 번째 문제가 생깁니다 : 코드 중복. 할당 연산자는 이미 다른 곳에서 작성한 모든 코드를 효과적으로 복제합니다. 이것은 끔찍한 일입니다.
우리의 경우 핵심은 두 줄 (할당 및 사본)이지만 더 복잡한 리소스를 사용하면이 코드가 부 풀리는 것은 상당히 번거로운 작업이 될 수 있습니다. 우리는 결코 반복하지 않기 위해 노력해야합니다.
(하나의 리소스를 올바르게 관리하기 위해 많은 코드가 필요하다면, 내 클래스가 하나 이상의 리소스를 관리한다면 어떨까요? 이것은 중요한 문제인 것처럼 보일 수 있지만 실제로는 try
/ catch
절을 필요로하지 않습니다. 클래스는 하나의 리소스 만 관리해야하기 때문입니다!)
성공적인 솔루션
앞서 언급했듯이 복사 및 스왑 이디엄은 이러한 모든 문제를 해결합니다. 하지만 지금은 swap
기능을 제외한 모든 요구 사항이 있습니다. The Rule of Three는 성공적으로 복사 생성자, 할당 연산자 및 소멸자의 존재를 수반하지만 실제로는 "Big Three and A Half"라고해야합니다 : 클래스가 리소스를 관리 할 때마다 swap
을 제공하는 것이 좋습니다 기능.
클래스에 스왑 기능을 추가해야합니다. † 다음과 같이합니다.
class dumb_array
{
public:
// ...
friend void swap(dumb_array& first, dumb_array& second) // nothrow
{
// enable ADL (not necessary in our case, but good practice)
using std::swap;
// by swapping the members of two objects,
// the two objects are effectively swapped
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}
// ...
};
(왜 public friend swap
하는지에 대한 설명이 있습니다.) 이제 우리는 우리의 dumb_array
을 바꿀 수있을뿐만 아니라, 일반적으로 스왑이 더 효율적일 수 있습니다. 전체 배열을 할당하고 복사하는 것이 아니라 단순히 포인터와 크기를 서로 바꿉니다. 기능과 효율성면에서이 보너스를 제외하고 이제 복사 및 스왑 이디엄을 구현할 준비가되었습니다.
추측없이, 우리의 대입 연산자는 다음과 같습니다.
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
그리고 그게 다야! 하나가 급습하자 세 가지 문제가 모두 우아하게 처리되었습니다.
왜 작동합니까?
우리는 우선 중요한 선택을 주목합니다 : 매개 변수 인수는 값에 의해 취해진 것입니다. 그럼에도 불구하고 다음과 같은 작업을 쉽게 수행 할 수 있습니다 (실제로 많은 숙련 된 구현 방식이 가능합니다).
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
우리는 중요한 최적화 기회를 놓치게됩니다. 뿐만 아니라이 선택 사항은 나중에 설명 할 C ++ 11에서 중요합니다. (일반적으로 매우 유용한 지침은 다음과 같습니다 : 함수에서 무언가의 복사본을 만들려면 컴파일러가 매개 변수 목록에서이를 수행하도록하십시오.) ‡)
어느 쪽이든, 우리의 자원을 얻는이 방법은 코드 중복을 제거하는 열쇠입니다. 복사 생성자의 코드를 사용하여 복사본을 만들고, 그 코드를 반복 할 필요가 없습니다. 사본이 만들어지기 때문에, 우리는 교환 할 준비가되었습니다.
새로운 데이터가 모두 이미 할당되고 복사되고 사용될 준비가되었다는 것을 함수에 입력하면 관찰하십시오. 이것이 우리에게 무료로 강력한 예외 보증을 제공하는 것입니다. 복사의 구성이 실패하면 함수를 입력하지 않기 때문에 *this
상태를 변경할 수 없습니다. (우리가 강력하게 예외를 보장하기 위해 수동으로했던 것은 컴파일러가 지금 우리를 위해하고있는 것입니다.)
swap
은 비 던지기 때문에이 시점에서 우리는 집이 없습니다. 현재 데이터를 복사 된 데이터와 교환하여 상태를 안전하게 변경하고 오래된 데이터를 임시로 가져옵니다. 그런 다음 함수가 반환 될 때 이전 데이터가 해제됩니다. (여기서 매개 변수의 범위가 끝나고 소멸자가 호출됩니다.)
관용구는 코드를 반복하지 않기 때문에 연산자 내에 버그를 도입 할 수 없습니다. 이는 우리가자가 할당 검사의 필요성을 없앰으로써 operator=
단일하게 구현할 수 있음을 의미합니다. (또한 비 자체 할당에 대한 성능 저하는 더 이상 발생하지 않습니다.)
이것이 복사 및 스왑 이디엄입니다.
C ++ 11은 어떻습니까?
C ++의 차기 버전 인 C ++ 11은 우리가 자원을 관리하는 방법에있어 매우 중요한 변화 중 하나입니다. 규칙 3은 이제 4의 규칙입니다 . 왜? 자원을 복사 할 수 있어야 할 뿐만 아니라 이동도해야합니다 .
다행스럽게도 우리에게는 쉽습니다.
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other)
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
무슨 일 이니? 이동 건설의 목표 : 클래스의 다른 인스턴스에서 리소스를 가져 와서 할당 가능하고 파괴 가능하도록 보장 된 상태로 둡니다.
그래서 우리가 한 일은 간단합니다. 기본 생성자 (C ++ 11 기능)를 통해 초기화 한 다음 other
교체합니다. 클래스의 기본 생성 된 인스턴스가 안전하게 할당되고 소멸 될 수 있다는 것을 알기 때문에 스와핑 후에도 other
클래스가 동일하게 수행 할 수 있습니다.
일부 컴파일러는 생성자 위임을 지원하지 않으므로이 경우 수동으로 클래스를 생성해야합니다. 이는 불행하지만 다행히도 사소한 작업입니다.
왜 그게 효과가 있니?
그것은 우리가 수업에해야 할 유일한 변화입니다. 그렇다면 왜 효과가 있습니까? 매개 변수를 참조가 아닌 값으로 만들기 위해 우리가 결정한 중요한 결정을 기억하십시오.
dumb_array& operator=(dumb_array other); // (1)
이제, 만약 other
값이 rvalue로 초기화된다면, 그것은 움직일 것 입니다. 완전한. 같은 방식으로 C ++ 03은 인수에 의한 인수를 취하여 복사 생성자 기능을 다시 사용하게하고 C ++ 11은 적절할 때 자동으로 이동 생성자를 선택합니다. (물론 이전에 링크 된 기사에서 언급했듯이 값의 복사 / 이동은 모두 생략 할 수 있습니다.)
복사 및 스왑 이디엄을 결론지었습니다.
각주
* 왜 mArray
를 null로 설정합니까? 연산자의 더 이상의 코드가 throw되면 dumb_array
의 소멸자가 dumb_array
될 수 있습니다. null로 설정하지 않고 이런 일이 발생하면 이미 삭제 된 메모리를 삭제하려고 시도합니다! null을 삭제하는 것은 아무런 작업이 아니므로 null로 설정하여이를 방지합니다.
† 우리 유형에 대해 std::swap
을 전문화해야하고, 클래스 내 swap
과 함께 자유 기능 swap
등을 제공해야한다는 다른 주장이 있습니다. 그러나 이것은 모두 불필요합니다 : swap
적절한 사용은 자격이없는 것입니다 전화를 걸면 우리의 기능은 ADL 통해 발견 될 것입니다. 한 가지 기능 만 수행합니다.
‡ 이유는 간단합니다. 일단 리소스를 소유하고 나면 언제 어디서나 스왑하거나 이동할 수 있습니다 (C ++ 11). 매개 변수 목록에서 사본을 작성하면 최적화가 최대화됩니다.
이 관용구는 무엇이며 언제 사용해야합니까? 어떤 문제가 해결됩니까? C ++ 11을 사용하면 관용어가 변경됩니까?
비록 그것이 여러 곳에서 언급되었지만, 우리는 단 하나의 "what is it"이라는 질문과 대답을 가지고 있지 않았습니다, 그래서 여기 있습니다. 이전에 언급 한 장소의 일부 목록은 다음과 같습니다.
C ++ 11 스타일 할당 자 인식 컨테이너를 다룰 때 경고 메시지를 추가하고 싶습니다. 스와핑과 할당에는 미묘하게 다른 의미가 있습니다.
구체성을 위해 컨테이너 std::vector<T, A>
생각해 보자. 여기서 A
는 상태 저장 유형이고 다음 함수를 비교할 것이다.
void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{
a.swap(b);
b.clear(); // not important what you do with b
}
void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
a = std::move(b);
}
함수 fs
와 fm
의 목적은 b
가 초기에 가지는 상태를 제공 a
것입니다. 그러나 숨겨진 질문이 있습니다. a.get_allocator() != b.get_allocator()
? 답은 다음과 같습니다. AT = std::allocator_traits<A>
작성해 보겠습니다.
AT::propagate_on_container_move_assignment
가std::true_type
이면fm
은a
의 할당자를b.get_allocator()
의 값으로 다시 할당합니다. 그렇지 않으면 false로 설정하고a
는 원래 할당자를 계속 사용합니다. 이 경우a
와b
의 저장 영역이 호환되지 않기 때문에 데이터 요소를 개별적으로 스왑해야합니다.AT::propagate_on_container_swap
이std::true_type
이면fs
는 데이터와 할당자를 모두 예상 한 방식으로std::true_type
합니다.AT::propagate_on_container_swap
이std::false_type
이면 동적 검사가 필요합니다.-
a.get_allocator() == b.get_allocator()
의 경우, 2 개의 컨테이너는 호환성이있는 스토리지를 사용해, 통상의 방법으로 스왑이 진행됩니다. - 그러나
a.get_allocator() != b.get_allocator()
인 경우 프로그램에 정의되지 않은 동작이 발생합니다 (cf. [container.requirements.general / 8] 참조).
-
결론은 컨테이너가 상태 저장 할당자를 지원하기 시작하자마자 C + + 11에서 스와핑이 중요하지 않다는 것입니다. 이는 다소 "고급 사용 사례"이지만 클래스가 리소스를 관리하고 메모리가 가장 많이 사용되는 리소스 중 하나 인 경우 이동 최적화가 일반적으로 흥미로워지기 때문에 완전히 불가능하지는 않습니다.
이 대답은 위의 답변에 대한 추가 및 약간의 수정과 같습니다.
Visual Studio의 일부 버전 (및 기타 컴파일러)에는 실제로 성가신이며 의미가없는 버그가 있습니다. 따라서 다음과 같이 swap
함수를 선언하거나 정의하면 :
friend void swap(A& first, A& second) {
std::swap(first.size, second.size);
std::swap(first.arr, second.arr);
}
... swap
함수를 호출하면 컴파일러에서 소리를 지르겠습니다.
friend
함수가 호출되고이 객체가 매개 변수로 전달되는 것과 관련이 있습니다.
이 문제를 해결하는 방법은 friend
키워드를 사용하지 않고 swap
함수를 재정의하는 것입니다.
void swap(A& other) {
std::swap(size, other.size);
std::swap(arr, other.arr);
}
이번에는 swap
호출하고 other
넘겨 컴파일러를 행복하게 만들 수 있습니다.
결국, 당신은 2 개체를 교환하는 friend
기능을 사용할 필요 가 없습니다. 하나의 other
객체를 매개 변수로 가지는 멤버 함수를 swap
으로 만드는 것은 많은 의미가 있습니다.
this
객체에 이미 액세스 할 수 있으므로 매개 변수로 전달하는 것은 기술적으로 중복됩니다.
이미 좋은 해답이 있습니다. 나는 그들이 부족하다고 생각하는 것에 주로 초점을 맞출 것이다. 복사 - 스왑 (copy-and-swap) 관용구에 대한 "단점"에 대한 설명 ....
복사 및 스왑 이디엄이란 무엇입니까?
할당 연산자를 스왑 함수로 구현하는 방법은 다음과 같습니다.
X& operator=(X rhs)
{
swap(rhs);
return *this;
}
기본적인 아이디어는 다음과 같습니다.
객체에 할당하는 가장 오류가 발생하기 쉬운 부분은 새로운 상태가 필요로하는 모든 자원 (예 : 메모리, 설명자)을 확보하는 것입니다.
새로운 값의 사본이 만들어지면 객체의 현재 상태 (즉,
*this
) 를 수정 하기 전에 획득을 시도 할 수 있습니다. 이것이rhs
가 참조가 아닌 값 (즉 복사 됨)으로 받아 들여지는 이유입니다로컬 복사본의 상태를 바꾸는 것이 가능합니다.
*this
로컬 복사본은 나중에 특정 상태가 필요하지 않으므로 잠재적으로 실패 / 예외없이 비교적 쉽게 수행 할 수 있습니다. (소멸자가 실행될 상태가 필요합니다. > = C ++에서 옮겨 지는 객체 11)
언제 사용해야합니까? (어떤 문제가 해결 되나요?)
할당 된 객체가 예외를 throw하는 할당에 영향을받지 않도록하려면 강력한 예외 보장이있는
swap
이 있거나 이상적으로는 스레딩 /throw
할 수없는 스 ..당신은 깨끗하고 이해하기 쉽고 (단순한) 복사 생성자,
swap
및 소멸자 함수의 관점에서 할당 연산자를 정의하는 강력한 방법을 원할 때.- 복사 및 스왑으로 수행되는 자체 할당은 간과 할 수없는 최 소의 사례를 피합니다. ‡
- 할당 중에 추가 임시 오브젝트를 작성하여 작성된 성능 저하 또는 순간적으로 높은 자원 사용이 어플리케이션에 중요하지 않은 경우. ⁂
† swap
스레딩 : 객체가 포인터로 추적하는 데이터 멤버를 신뢰할 수있게 교환 할 수 있지만 스로우 프리 스왑이 없거나 스왑을 X tmp = lhs; lhs = rhs; rhs = tmp;
로 구현해야하는 포인터가 아닌 데이터 멤버를 X tmp = lhs; lhs = rhs; rhs = tmp;
X tmp = lhs; lhs = rhs; rhs = tmp;
복사 - 구성 또는 할당이 던져 질 수도 있고, 스왑 된 일부 데이터 멤버와 그렇지 않은 데이터 멤버를 남겨 둘 가능성도 여전히있다. 이 잠재력은 C ++ 03 std::string
의 다른 답변에 대한 James의 주석에도 적용됩니다.
@wilhelmtell : C ++ 03에는 std :: string :: swap (std :: swap에 의해 호출 됨)에 의해 잠재적으로 throw되는 예외에 대한 언급이 없습니다. C ++ 0x에서 std :: string :: swap은 noexcept이며 예외를 throw해서는 안됩니다. - James McNellis Dec 22 '10 at 15:24
• 개별 객체에서 할당 할 때 쉽게 문제가 발생할 수있는 할당 연산자 구현은 자체 할당에 실패 할 수 있습니다. 클라이언트 코드가 자체 할당을 시도하는 것조차도 상상할 수없는 것처럼 보일 수도 있지만 x = f(x);
컨테이너에 대한 algo 연산 중에 상대적으로 쉽게 발생할 수 있습니다 x = f(x);
code f
는 (아마도 #ifdef
브랜치에 대해서만) 매크로 ala #define f(x) x
또는 #define f(x) x
대한 참조를 반환하는 함수 또는 심지어 x = c1 ? x * 2 : c2 ? x / 2 : x;
과 같은 (비효율적이지만 간결한) 코드 x = c1 ? x * 2 : c2 ? x / 2 : x;
x = c1 ? x * 2 : c2 ? x / 2 : x;
). 예 :
struct X
{
T* p_;
size_t size_;
X& operator=(const X& rhs)
{
delete[] p_; // OUCH!
p_ = new T[size_ = rhs.size_];
std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);
}
...
};
자체 할당시, 위의 코드는 x.p_;
의 x.p_;
, 새로 할당 된 힙 영역에서 p_
를 p_
다음 그 안에있는 초기화되지 않은 데이터를 읽으려고 시도합니다 (정의되지 않은 동작). 너무 이상한 일이 없다면 copy
는 모든 파괴 된 'T'에 자체 할당을 시도합니다!
복사 및 스왑 (copy-and-swap) 관용구는 여분의 임시 사용 (운영자 매개 변수가 복사 생성 된 경우)으로 인해 비 효율성 또는 제한을 초래할 수 있습니다.
struct Client
{
IP_Address ip_address_;
int socket_;
X(const X& rhs)
: ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_))
{ }
};
여기서 손으로 작성한 Client::operator=
는 rhs
와 동일한 서버에 *this
연결되어 있는지 확인할 수 있습니다 (유용한 경우 "재설정"코드를 전송하는 경우). 반면 카피 앤드 스왑 방식은 카피 앤드 스왑 방식을 호출합니다. 고유 한 소켓 연결을 연 다음 원래의 연결을 닫으려는 작성자가 될 생성자. 이것은 단순한 프로세스 내 변수 복사본 대신에 원격 네트워크 상호 작용을 의미 할뿐만 아니라 소켓 리소스 또는 연결에 대한 클라이언트 또는 서버 제한에 위배 될 수 있습니다. (물론이 클래스는 꽤 무시 무시한 인터페이스를 가지고 있지만, 또 다른 문제입니다 .- P).