Tổng quat
Tại sao chúng ta cần thành ngữ sao chép và trao đổi?
Bất kỳ lớp nào quản lý tài nguyên ( trình bao bọc , như con trỏ thông minh) đều cần thực hiện The Big Three . Trong khi các mục tiêu và việc thực hiện của trình tạo bản sao và hàm hủy là đơn giản, thì toán tử gán-sao chép được cho là sắc thái và khó nhất. Nên làm thế nào? Những cạm bẫy cần phải tránh?
Thành ngữ copy-and-exchange là giải pháp và hỗ trợ một cách tao nhã cho toán tử gán trong việc đạt được hai điều: tránh sao chép mã và cung cấp một đảm bảo ngoại lệ mạnh .
Làm thế nào nó hoạt động?
Về mặt khái niệm , nó hoạt động bằng cách sử dụng chức năng của nhà xây dựng bản sao để tạo một bản sao dữ liệu cục bộ, sau đó lấy dữ liệu được sao chép bằng một swap
hàm, hoán đổi dữ liệu cũ với dữ liệu mới. Các bản sao tạm thời sau đó phá hủy, lấy dữ liệu cũ với nó. Chúng tôi còn lại một bản sao của dữ liệu mới.
Để sử dụng thành ngữ copy-and-exchange, chúng ta cần ba điều: một hàm tạo sao chép hoạt động, một hàm hủy hoạt động (cả hai đều là cơ sở của bất kỳ trình bao bọc nào, vì vậy dù sao cũng phải hoàn thành) và một swap
hàm.
Hàm hoán đổi là một không ném , hoán đổi hai đối tượng của một lớp, thành viên cho thành viên. Chúng tôi có thể bị cám dỗ để sử dụng std::swap
thay vì cung cấp của chúng tôi, nhưng điều này là không thể; std::swap
sử dụng trình xây dựng sao chép và toán tử gán sao chép trong quá trình triển khai và cuối cùng chúng tôi sẽ cố gắng xác định toán tử gán theo thuật ngữ của chính nó!
(Không chỉ vậy, nhưng các cuộc gọi không đủ tiêu chuẩn để swap
sẽ sử dụng toán tử hoán đổi tùy chỉnh của chúng tôi, bỏ qua việc xây dựng và phá hủy không cần thiết của lớp chúng tôi std::swap
sẽ đòi hỏi.)
Một lời giải thích sâu sắc
Mục đích
Hãy xem xét một trường hợp cụ thể. Chúng tôi muốn quản lý, trong một lớp vô dụng, một mảng động. Chúng tôi bắt đầu với một constructor hoạt động, copy-constructor và hàm hủy:
#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;
};
Lớp này gần như quản lý mảng thành công, nhưng nó cần operator=
hoạt động chính xác.
Một giải pháp thất bại
Đây là cách thực hiện ngây thơ có thể trông:
// 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;
}
Và chúng tôi nói rằng chúng tôi đã hoàn thành; bây giờ quản lý một mảng, không có rò rỉ. Tuy nhiên, nó bị ba vấn đề, được đánh dấu tuần tự trong mã là (n)
.
Đầu tiên là bài kiểm tra tự giao. Kiểm tra này phục vụ hai mục đích: đó là một cách dễ dàng để ngăn chúng tôi chạy mã không cần thiết khi tự gán và nó bảo vệ chúng tôi khỏi các lỗi tinh vi (chẳng hạn như chỉ xóa mảng để thử và sao chép nó). Nhưng trong tất cả các trường hợp khác, nó chỉ đơn thuần phục vụ để làm chậm chương trình và hoạt động như tiếng ồn trong mã; tự giao hiếm khi xảy ra, vì vậy hầu hết thời gian kiểm tra này là một sự lãng phí. Sẽ tốt hơn nếu người vận hành có thể làm việc đúng mà không cần nó.
Thứ hai là nó chỉ cung cấp một đảm bảo ngoại lệ cơ bản. Nếu new int[mSize]
thất bại, *this
sẽ được sửa đổi. (Cụ thể, kích thước là sai và dữ liệu đã biến mất!) Để đảm bảo ngoại lệ mạnh mẽ, nó sẽ cần phải giống với:
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;
}
Mã đã được mở rộng! Điều này dẫn chúng ta đến vấn đề thứ ba: sao chép mã. Toán tử gán của chúng tôi sao chép một cách hiệu quả tất cả các mã chúng tôi đã viết ở nơi khác và đó là một điều tồi tệ.
Trong trường hợp của chúng tôi, cốt lõi của nó chỉ có hai dòng (phân bổ và sao chép), nhưng với các tài nguyên phức tạp hơn, sự phình to mã này có thể khá rắc rối. Chúng ta nên cố gắng không bao giờ lặp lại chính mình.
(Người ta có thể tự hỏi: nếu cần nhiều mã này để quản lý một tài nguyên một cách chính xác, thì nếu lớp của tôi quản lý nhiều tài nguyên thì sao? Trong khi điều này có vẻ là một mối quan tâm hợp lệ và thực sự nó đòi hỏi không phải là tầm thường try
/ catch
mệnh đề, đây không phải là một mệnh đề phát hành. Đó là bởi vì một lớp chỉ nên quản lý một tài nguyên !)
Một giải pháp thành công
Như đã đề cập, thành ngữ sao chép và trao đổi sẽ khắc phục tất cả các vấn đề này. Nhưng ngay bây giờ, chúng ta có tất cả các yêu cầu ngoại trừ một: một swap
chức năng. Mặc dù Quy tắc ba đòi hỏi thành công sự tồn tại của trình tạo bản sao, toán tử gán và hàm hủy của chúng ta, nhưng nó thực sự nên được gọi là "Ba lớn và một nửa": bất cứ khi nào lớp của bạn quản lý tài nguyên, nó cũng có ý nghĩa để cung cấp một swap
hàm .
Chúng ta cần thêm chức năng trao đổi cho lớp của mình và chúng ta thực hiện điều đó như sau †:
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);
}
// ...
};
( Đây là lời giải thích tại sao public friend swap
.) Bây giờ không chỉ chúng ta có thể trao đổi của chúng ta dumb_array
, mà nói chung các giao dịch hoán đổi có thể hiệu quả hơn; nó chỉ hoán đổi con trỏ và kích thước, thay vì phân bổ và sao chép toàn bộ mảng. Ngoài phần thưởng này về chức năng và hiệu quả, giờ đây chúng tôi đã sẵn sàng để thực hiện thành ngữ sao chép và trao đổi.
Nếu không có thêm rắc rối, toán tử gán của chúng tôi là:
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
Và đó là nó! Với một cú trượt ngã, cả ba vấn đề được giải quyết một cách tao nhã cùng một lúc.
Tại sao nó hoạt động?
Trước tiên chúng tôi nhận thấy một lựa chọn quan trọng: đối số tham số được lấy theo giá trị . Trong khi người ta có thể dễ dàng làm như sau (và thực tế, nhiều triển khai ngây thơ của thành ngữ này):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
Chúng tôi mất một cơ hội tối ưu hóa quan trọng . Không chỉ vậy, nhưng lựa chọn này rất quan trọng trong C ++ 11, sẽ được thảo luận sau. (Trên một lưu ý chung, một hướng dẫn hữu ích đáng chú ý như sau: nếu bạn định tạo một bản sao của một thứ gì đó trong hàm, hãy để trình biên dịch thực hiện nó trong danh sách tham số. ‡)
Dù bằng cách nào, phương pháp lấy tài nguyên này của chúng tôi là chìa khóa để loại bỏ sao chép mã: chúng tôi có thể sử dụng mã từ trình tạo bản sao để tạo bản sao và không bao giờ phải lặp lại bất kỳ bit nào của nó. Bây giờ bản sao được thực hiện, chúng tôi đã sẵn sàng để trao đổi.
Quan sát rằng khi nhập chức năng, tất cả dữ liệu mới đã được phân bổ, sao chép và sẵn sàng để sử dụng. Đây là điều mang lại cho chúng tôi một bảo đảm ngoại lệ mạnh mẽ miễn phí: chúng tôi thậm chí sẽ không nhập chức năng nếu việc xây dựng bản sao thất bại và do đó không thể thay đổi trạng thái của*this
. (Những gì chúng tôi đã làm thủ công trước đây để đảm bảo ngoại lệ mạnh mẽ, trình biên dịch đang làm cho chúng tôi bây giờ; như thế nào.)
Tại thời điểm này, chúng tôi không có nhà, vì swap
không ném. Chúng tôi trao đổi dữ liệu hiện tại của chúng tôi với dữ liệu được sao chép, thay đổi trạng thái của chúng tôi một cách an toàn và dữ liệu cũ sẽ được đưa vào tạm thời. Dữ liệu cũ sau đó được giải phóng khi hàm trả về. (Khi phạm vi của tham số kết thúc và hàm hủy của nó được gọi.)
Vì thành ngữ lặp lại không có mã, chúng tôi không thể đưa ra các lỗi trong toán tử. Lưu ý rằng điều này có nghĩa là chúng tôi không cần kiểm tra tự gán, cho phép thực hiện thống nhất một lần duy nhấtoperator=
. (Ngoài ra, chúng tôi không còn có hình phạt về hiệu suất đối với việc không tự gán.)
Và đó là thành ngữ copy-and-exchange.
C ++ 11 thì sao?
Phiên bản tiếp theo của C ++, C ++ 11, tạo ra một thay đổi rất quan trọng đối với cách chúng ta quản lý tài nguyên: Quy tắc ba bây giờ là Quy tắc bốn (và một nửa). Tại sao? Bởi vì chúng ta không chỉ cần có khả năng sao chép-xây dựng tài nguyên của mình, mà chúng ta còn cần phải di chuyển-xây dựng nó .
May mắn cho chúng tôi, điều này là dễ dàng:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
Những gì đang xảy ra ở đây? Nhắc lại mục tiêu của xây dựng di chuyển: để lấy tài nguyên từ một thể hiện khác của lớp, để nó ở trạng thái được đảm bảo có thể gán và phá hủy được.
Vì vậy, những gì chúng tôi đã làm rất đơn giản: khởi tạo thông qua hàm tạo mặc định (tính năng C ++ 11), sau đó trao đổi với other
; chúng ta biết một thể hiện được xây dựng mặc định của lớp chúng ta có thể được gán và hủy một cách an toàn, vì vậy chúng ta biết other
sẽ có thể làm tương tự, sau khi hoán đổi.
(Lưu ý rằng một số trình biên dịch không hỗ trợ ủy quyền của hàm tạo; trong trường hợp này, chúng ta phải mặc định xây dựng lớp theo cách thủ công. Đây là một nhiệm vụ không may nhưng rất may mắn.)
Tại sao nó hoạt động?
Đó là thay đổi duy nhất chúng ta cần thực hiện cho lớp của mình, vậy tại sao nó hoạt động? Hãy nhớ quyết định cực kỳ quan trọng mà chúng tôi đưa ra để biến tham số thành giá trị và không phải là tham chiếu:
dumb_array& operator=(dumb_array other); // (1)
Bây giờ, nếu other
đang được khởi tạo với một giá trị, nó sẽ được xây dựng di chuyển . Hoàn hảo. Theo cách tương tự C ++ 03, chúng ta hãy sử dụng lại chức năng xây dựng bản sao bằng cách lấy tham số theo giá trị, C ++ 11 sẽ tự động chọn công cụ xây dựng di chuyển khi thích hợp. (Và, tất nhiên, như đã đề cập trong bài viết được liên kết trước đây, việc sao chép / di chuyển giá trị có thể chỉ đơn giản là bị loại bỏ hoàn toàn.)
Và như vậy kết luận thành ngữ sao chép và trao đổi.
Chú thích
* Tại sao chúng ta đặt mArray
thành null? Bởi vì nếu có thêm mã nào trong toán tử ném, hàm hủy của dumb_array
có thể được gọi; và nếu điều đó xảy ra mà không đặt nó thành null, chúng tôi sẽ cố gắng xóa bộ nhớ đã bị xóa! Chúng tôi tránh điều này bằng cách đặt nó thành null, vì xóa null là không hoạt động.
Có những tuyên bố khác mà chúng tôi nên chuyên môn hóa std::swap
cho loại của mình, cung cấp một swap
chức năng miễn phí bên cạnh lớp swap
, v.v. Nhưng điều này là không cần thiết: mọi hoạt động sử dụng hợp lý swap
sẽ thông qua một cuộc gọi không đủ tiêu chuẩn và chức năng của chúng tôi sẽ tìm thấy thông qua ADL . Một chức năng sẽ làm.
Lý do rất đơn giản: một khi bạn có tài nguyên cho chính mình, bạn có thể trao đổi và / hoặc di chuyển nó (C ++ 11) bất cứ nơi nào nó cần. Và bằng cách tạo bản sao trong danh sách tham số, bạn tối đa hóa tối ưu hóa.
Trình xây dựng di chuyển thường là noexcept
, nếu không, một số mã (ví dụ std::vector
thay đổi kích thước logic) sẽ sử dụng hàm tạo sao chép ngay cả khi di chuyển sẽ có ý nghĩa. Tất nhiên, chỉ đánh dấu nó không có ngoại lệ nếu mã bên trong không ném ngoại lệ.