Di chuyển ngữ nghĩa là gì?


1702

Tôi vừa nghe xong cuộc phỏng vấn podcast radio kỹ thuật phần mềm với Scott Meyers về C ++ 0x . Hầu hết các tính năng mới có ý nghĩa với tôi và bây giờ tôi thực sự hào hứng với C ++ 0x, ngoại trừ một tính năng. Tôi vẫn không nhận được ngữ nghĩa di chuyển ... Chính xác thì nó là gì?


20
Tôi tìm thấy [bài viết trên blog của Eli Bendersky] ( eli.thegreenplace.net/2011/12/15/iêu ) về giá trị và giá trị trong C và C ++ khá nhiều thông tin. Ông cũng đề cập đến các tham chiếu rvalue trong C ++ 11 và giới thiệu chúng với các ví dụ nhỏ.
Nils


19
Mỗi năm tôi lại tự hỏi ngữ nghĩa di chuyển "mới" trong C ++ là gì, tôi google nó và vào trang này. Tôi đọc phản hồi, não tôi tắt. Tôi trở lại C, và quên mọi thứ! Tôi bế tắc.
bầu trời

7
@sky Hãy xem xét std :: vector <> ... Ở đâu đó trong đó có một con trỏ tới một mảng trên heap. Nếu bạn sao chép đối tượng này, một bộ đệm mới phải được phân bổ và dữ liệu từ bộ đệm cần được sao chép vào bộ đệm mới. Có bất kỳ tình huống nào sẽ ổn khi chỉ cần đánh cắp con trỏ? Câu trả lời là CÓ, khi trình biên dịch biết đối tượng là tạm thời. Di chuyển ngữ nghĩa cho phép bạn xác định làm thế nào các lớp của bạn có thể được di chuyển ra và thả vào một đối tượng khác khi trình biên dịch biết đối tượng bạn đang di chuyển sắp biến mất.
dicroce

Tài liệu tham khảo duy nhất tôi có thể hiểu: learncpp.com/cpp-tutorial/ , tức là lý do ban đầu của ngữ nghĩa di chuyển là từ con trỏ thông minh.
jw_

Câu trả lời:


2481

Tôi thấy dễ hiểu nhất về ngữ nghĩa di chuyển với mã ví dụ. Hãy bắt đầu với một lớp chuỗi rất đơn giản chỉ chứa một con trỏ tới một khối bộ nhớ được phân bổ heap:

#include <cstring>
#include <algorithm>

class string
{
    char* data;

public:

    string(const char* p)
    {
        size_t size = std::strlen(p) + 1;
        data = new char[size];
        std::memcpy(data, p, size);
    }

Vì chúng tôi đã chọn tự quản lý bộ nhớ, chúng tôi cần tuân theo quy tắc ba . Bây giờ tôi sẽ trì hoãn việc viết toán tử gán và chỉ thực hiện hàm hủy và hàm tạo sao chép:

    ~string()
    {
        delete[] data;
    }

    string(const string& that)
    {
        size_t size = std::strlen(that.data) + 1;
        data = new char[size];
        std::memcpy(data, that.data, size);
    }

Trình xây dựng sao chép định nghĩa ý nghĩa của việc sao chép các đối tượng chuỗi. Tham số const string& thatliên kết với tất cả các biểu thức của chuỗi loại cho phép bạn tạo các bản sao trong các ví dụ sau:

string a(x);                                    // Line 1
string b(x + y);                                // Line 2
string c(some_function_returning_a_string());   // Line 3

Bây giờ đến cái nhìn sâu sắc quan trọng về ngữ nghĩa di chuyển. Lưu ý rằng chỉ trong dòng đầu tiên mà chúng tôi sao chép xthì bản sao sâu này thực sự cần thiết, bởi vì chúng tôi có thể muốn kiểm tra xsau và sẽ rất ngạc nhiên nếu xđã thay đổi bằng cách nào đó. Bạn có để ý cách tôi chỉ nói xba lần (bốn lần nếu bạn bao gồm câu này) và có nghĩa là cùng một đối tượng mỗi lần không? Chúng tôi gọi các biểu thức như x"giá trị".

Các đối số trong dòng 2 và 3 không phải là giá trị, mà là giá trị, bởi vì các đối tượng chuỗi bên dưới không có tên, vì vậy máy khách không có cách nào để kiểm tra lại chúng vào thời điểm muộn hơn. các giá trị biểu thị các đối tượng tạm thời bị phá hủy ở dấu chấm phẩy tiếp theo (nói chính xác hơn: ở phần cuối của biểu thức đầy đủ có chứa giá trị từ vựng). Điều này rất quan trọng vì trong quá trình khởi tạo bc, chúng tôi có thể làm bất cứ điều gì chúng tôi muốn với chuỗi nguồn và khách hàng không thể nói sự khác biệt !

C ++ 0x giới thiệu một cơ chế mới gọi là "tham chiếu rvalue", trong số những thứ khác, cho phép chúng tôi phát hiện các đối số giá trị thông qua nạp chồng hàm. Tất cả chúng ta phải làm là viết một hàm tạo với tham số tham chiếu rvalue. Bên trong hàm tạo đó, chúng ta có thể làm bất cứ điều gì chúng ta muốn với nguồn, miễn là chúng ta để nó ở một số trạng thái hợp lệ:

    string(string&& that)   // string&& is an rvalue reference to a string
    {
        data = that.data;
        that.data = nullptr;
    }

Chúng ta đã làm gì ở đây? Thay vì sao chép sâu dữ liệu heap, chúng tôi chỉ sao chép con trỏ và sau đó đặt con trỏ ban đầu thành null (để ngăn 'xóa []' khỏi hàm hủy của đối tượng nguồn phát hành 'dữ liệu vừa bị đánh cắp'). Trên thực tế, chúng tôi đã "đánh cắp" dữ liệu ban đầu thuộc về chuỗi nguồn. Một lần nữa, cái nhìn sâu sắc quan trọng là trong mọi trường hợp khách hàng có thể phát hiện ra rằng nguồn đã được sửa đổi. Vì chúng tôi không thực sự làm một bản sao ở đây, chúng tôi gọi hàm tạo này là "hàm tạo di chuyển". Công việc của nó là di chuyển tài nguyên từ đối tượng này sang đối tượng khác thay vì sao chép chúng.

Xin chúc mừng, bây giờ bạn đã hiểu những điều cơ bản của ngữ nghĩa di chuyển! Hãy tiếp tục bằng cách thực hiện toán tử gán. Nếu bạn không quen thuộc với thành ngữ sao chép và trao đổi , hãy tìm hiểu nó và quay lại, bởi vì đó là một thành ngữ C ++ tuyệt vời liên quan đến an toàn ngoại lệ.

    string& operator=(string that)
    {
        std::swap(data, that.data);
        return *this;
    }
};

Huh, phải không? "Tài liệu tham khảo giá trị ở đâu?" bạn có thể hỏi "Chúng tôi không cần nó ở đây!" là câu trả lời của tôi :)

Lưu ý rằng chúng ta truyền tham số that theo giá trị , do đó thatphải được khởi tạo giống như bất kỳ đối tượng chuỗi nào khác. Chính xác thì thatsẽ được khởi tạo như thế nào? Vào thời xa xưa của C ++ 98 , câu trả lời sẽ là "bởi người xây dựng bản sao". Trong C ++ 0x, trình biên dịch chọn giữa hàm tạo sao chép và hàm tạo di chuyển dựa trên việc đối số cho toán tử gán là giá trị hay giá trị.

Vì vậy, nếu bạn nói a = b, hàm tạo sao chép sẽ khởi tạo that(vì biểu thức blà một giá trị) và toán tử gán gán hoán đổi nội dung với một bản sao sâu, mới được tạo. Đó là định nghĩa của bản sao và thành ngữ hoán đổi - tạo một bản sao, hoán đổi nội dung với bản sao và sau đó loại bỏ bản sao bằng cách rời khỏi phạm vi. Không có gì mới ở đây.

Nhưng nếu bạn nói a = x + y, hàm tạo di chuyển sẽ khởi tạo that(vì biểu thức x + ylà một giá trị), do đó không có bản sao sâu liên quan, chỉ có một động thái hiệu quả. thatvẫn là một đối tượng độc lập khỏi đối số, nhưng cấu trúc của nó là không đáng kể, vì dữ liệu heap không phải sao chép, chỉ cần di chuyển. Không cần thiết phải sao chép nó bởi vì đó x + ylà một giá trị, và một lần nữa, di chuyển từ các đối tượng chuỗi được biểu thị bằng các giá trị.

Để tóm tắt, hàm tạo sao chép tạo một bản sao sâu, bởi vì nguồn phải được giữ nguyên. Mặt khác, hàm tạo di chuyển chỉ có thể sao chép con trỏ và sau đó đặt con trỏ trong nguồn thành null. Bạn có thể "vô hiệu hóa" đối tượng nguồn theo cách này, bởi vì máy khách không có cách nào kiểm tra lại đối tượng.

Tôi hy vọng ví dụ này có ý chính. Có nhiều hơn nữa để đánh giá các tài liệu tham khảo và di chuyển ngữ nghĩa mà tôi cố tình bỏ qua để giữ cho nó đơn giản. Nếu bạn muốn biết thêm chi tiết xin vui lòng xem câu trả lời bổ sung của tôi .


40
@ Nhưng nếu ctor của tôi nhận được một giá trị, không bao giờ có thể được sử dụng sau này, tại sao tôi thậm chí cần phải bận tâm để nó ở trạng thái nhất quán / an toàn? Thay vì đặt that.data = 0, tại sao không để nó ở đó?
einpoklum

70
@einpoklum Bởi vì không có that.data = 0, các nhân vật sẽ bị hủy diệt quá sớm (khi tạm thời chết), và cũng hai lần. Bạn muốn đánh cắp dữ liệu, không chia sẻ nó!
dòng chảy

19
@einpoklum Trình hủy diệt được lên lịch thường xuyên vẫn được chạy, do đó bạn phải đảm bảo rằng trạng thái sau di chuyển của đối tượng nguồn không gây ra sự cố. Tốt hơn, bạn nên chắc chắn rằng đối tượng nguồn cũng có thể là người nhận bài tập hoặc ghi khác.
CTMacUser

12
@pranitkothari Có, tất cả các đối tượng phải bị phá hủy, thậm chí di chuyển từ các đối tượng. Và vì chúng tôi không muốn mảng char bị xóa khi điều đó xảy ra, chúng tôi phải đặt con trỏ thành null.
dòng chảy

7
@ Virus721 delete[]trên nullptr được xác định theo tiêu chuẩn C ++ là không có op.
dòng chảy

1057

Câu trả lời đầu tiên của tôi là một giới thiệu cực kỳ đơn giản để di chuyển ngữ nghĩa, và nhiều chi tiết bị bỏ qua nhằm mục đích đơn giản. Tuy nhiên, có nhiều hơn nữa để di chuyển ngữ nghĩa, và tôi nghĩ rằng đã đến lúc câu trả lời thứ hai để lấp đầy khoảng trống. Câu trả lời đầu tiên đã khá cũ và cảm thấy không đúng khi chỉ cần thay thế nó bằng một văn bản hoàn toàn khác. Tôi nghĩ rằng nó vẫn phục vụ tốt như là một giới thiệu đầu tiên. Nhưng nếu bạn muốn đào sâu hơn, hãy đọc tiếp :)

Stephan T. Lavavej đã dành thời gian để cung cấp thông tin phản hồi có giá trị. Cảm ơn bạn rất nhiều, Stephan!

Giới thiệu

Di chuyển ngữ nghĩa cho phép một đối tượng, trong những điều kiện nhất định, có quyền sở hữu một số tài nguyên bên ngoài của đối tượng khác. Điều này rất quan trọng theo hai cách:

  1. Biến các bản sao đắt tiền thành động thái rẻ tiền. Xem câu trả lời đầu tiên của tôi cho một ví dụ. Lưu ý rằng nếu một đối tượng không quản lý ít nhất một tài nguyên bên ngoài (trực tiếp hoặc gián tiếp thông qua các đối tượng thành viên của nó), thì ngữ nghĩa di chuyển sẽ không cung cấp bất kỳ lợi thế nào so với ngữ nghĩa sao chép. Trong trường hợp đó, sao chép một đối tượng và di chuyển một đối tượng có nghĩa là điều tương tự chính xác:

    class cannot_benefit_from_move_semantics
    {
        int a;        // moving an int means copying an int
        float b;      // moving a float means copying a float
        double c;     // moving a double means copying a double
        char d[64];   // moving a char array means copying a char array
    
        // ...
    };
  2. Thực hiện các loại "chỉ di chuyển" an toàn; đó là, các kiểu sao chép không có ý nghĩa, nhưng di chuyển thì có. Ví dụ bao gồm khóa, xử lý tệp và con trỏ thông minh với ngữ nghĩa sở hữu duy nhất. Lưu ý: Câu trả lời này thảo luận std::auto_ptr, một mẫu thư viện chuẩn C ++ 98 không dùng nữa, đã được thay thế bằng std::unique_ptrtrong C ++ 11. Các lập trình viên C ++ trung cấp có lẽ ít nhất là hơi quen thuộc std::auto_ptrvà vì "ngữ nghĩa di chuyển" mà nó hiển thị, có vẻ như là một điểm khởi đầu tốt để thảo luận về ngữ nghĩa di chuyển trong C ++ 11. YMMV.

Di chuyển là gì?

Thư viện chuẩn C ++ 98 cung cấp một con trỏ thông minh với ngữ nghĩa sở hữu duy nhất được gọi std::auto_ptr<T>. Trong trường hợp bạn không quen thuộc auto_ptr, mục đích của nó là đảm bảo rằng một đối tượng được phân bổ động luôn được giải phóng, ngay cả khi đối mặt với các ngoại lệ:

{
    std::auto_ptr<Shape> a(new Triangle);
    // ...
    // arbitrary code, could throw exceptions
    // ...
}   // <--- when a goes out of scope, the triangle is deleted automatically

Điều khác thường auto_ptrlà hành vi "sao chép" của nó:

auto_ptr<Shape> a(new Triangle);

      +---------------+
      | triangle data |
      +---------------+
        ^
        |
        |
        |
  +-----|---+
  |   +-|-+ |
a | p | | | |
  |   +---+ |
  +---------+

auto_ptr<Shape> b(a);

      +---------------+
      | triangle data |
      +---------------+
        ^
        |
        +----------------------+
                               |
  +---------+            +-----|---+
  |   +---+ |            |   +-|-+ |
a | p |   | |          b | p | | | |
  |   +---+ |            |   +---+ |
  +---------+            +---------+

Lưu ý cách thức khởi tạo của bvới akhông không sao chép các hình tam giác, nhưng thay vì chuyển giao quyền sở hữu của tam giác từ ađến b. Chúng tôi cũng nói " ađược chuyển thành b " hoặc "tam giác được chuyển từ a tới b ". Điều này nghe có vẻ khó hiểu vì bản thân tam giác luôn ở cùng một vị trí trong bộ nhớ.

Để di chuyển một đối tượng có nghĩa là chuyển quyền sở hữu một số tài nguyên mà nó quản lý sang một đối tượng khác.

Hàm tạo sao chép auto_ptrcó thể trông giống như thế này (hơi đơn giản):

auto_ptr(auto_ptr& source)   // note the missing const
{
    p = source.p;
    source.p = 0;   // now the source no longer owns the object
}

Di chuyển nguy hiểm và vô hại

Điều nguy hiểm auto_ptrlà những gì về mặt cú pháp trông giống như một bản sao thực sự là một động thái. Cố gắng gọi một hàm thành viên trên một chuyển từ auto_ptrsẽ gọi hành vi không xác định, vì vậy bạn phải rất cẩn thận không sử dụng một auto_ptrsau khi nó đã được di chuyển từ:

auto_ptr<Shape> a(new Triangle);   // create triangle
auto_ptr<Shape> b(a);              // move a into b
double area = a->area();           // undefined behavior

Nhưng auto_ptrkhông phải lúc nào cũng nguy hiểm. Các chức năng của nhà máy là một trường hợp sử dụng hoàn toàn tốt cho auto_ptr:

auto_ptr<Shape> make_triangle()
{
    return auto_ptr<Shape>(new Triangle);
}

auto_ptr<Shape> c(make_triangle());      // move temporary into c
double area = make_triangle()->area();   // perfectly safe

Lưu ý cách cả hai ví dụ theo cùng một mẫu cú pháp:

auto_ptr<Shape> variable(expression);
double area = expression->area();

Tuy nhiên, một trong số họ viện dẫn hành vi không xác định, trong khi người còn lại thì không. Vậy sự khác biệt giữa các biểu thức avà là make_triangle()gì? Cả hai cùng loại phải không? Thật vậy, họ có, nhưng họ có các loại giá trị khác nhau .

Các loại giá trị

Rõ ràng, phải có một số khác biệt sâu sắc giữa biểu thức biểu athị một auto_ptrbiến và biểu thức biểu make_triangle()thị lệnh gọi của hàm trả về một auto_ptrgiá trị, do đó tạo ra một auto_ptrđối tượng tạm thời mới mỗi khi nó được gọi. alà một ví dụ về một giá trị trái , trong khi đó make_triangle()là một ví dụ về một rvalue .

Di chuyển từ các giá trị như alà nguy hiểm, vì sau này chúng ta có thể cố gắng gọi một hàm thành viên thông qua a, gọi hành vi không xác định. Mặt khác, di chuyển từ các giá trị như make_triangle()là hoàn toàn an toàn, bởi vì sau khi trình xây dựng sao chép đã thực hiện công việc của mình, chúng ta không thể sử dụng lại tạm thời. Không có biểu hiện nào cho thấy tạm thời nói; nếu chúng ta chỉ đơn giản viết make_triangle()lại, chúng ta sẽ có một tạm thời khác . Trong thực tế, tạm thời chuyển từ đã đi trên dòng tiếp theo:

auto_ptr<Shape> c(make_triangle());
                                  ^ the moved-from temporary dies right here

Lưu ý rằng các chữ cái lrcó nguồn gốc lịch sử ở phía bên trái và bên phải của một bài tập. Điều này không còn đúng trong C ++, bởi vì có các giá trị không thể xuất hiện ở phía bên trái của một phép gán (như mảng hoặc các kiểu do người dùng định nghĩa mà không có toán tử gán) và có các giá trị có thể (tất cả các giá trị của các loại lớp với một toán tử gán).

Một giá trị của loại lớp là một biểu thức có đánh giá tạo ra một đối tượng tạm thời. Trong trường hợp bình thường, không có biểu thức nào khác trong cùng phạm vi biểu thị cùng một đối tượng tạm thời.

Tài liệu tham khảo Rvalue

Bây giờ chúng tôi hiểu rằng di chuyển từ giá trị có thể nguy hiểm, nhưng di chuyển từ giá trị là vô hại. Nếu C ++ có hỗ trợ ngôn ngữ để phân biệt đối số giá trị với đối số giá trị, chúng ta hoàn toàn có thể cấm di chuyển khỏi giá trị hoặc ít nhất là thực hiện chuyển từ giá trị rõ ràng tại trang web cuộc gọi, để chúng ta không còn di chuyển do tai nạn.

Câu trả lời của C ++ 11 cho vấn đề này là tài liệu tham khảo giá trị . Tham chiếu rvalue là một loại tham chiếu mới chỉ liên kết với các giá trị và cú pháp là X&&. Các tài liệu tham khảo cũ tốt X&hiện được gọi là một tài liệu tham khảo lvalue . (Lưu ý rằng X&&không một tham chiếu đến một tài liệu tham khảo, không có điều như vậy trong C ++.)

Nếu chúng ta constkết hợp, chúng ta đã có bốn loại tài liệu tham khảo khác nhau. Những loại biểu thức Xmà họ có thể liên kết với?

            lvalue   const lvalue   rvalue   const rvalue
---------------------------------------------------------              
X&          yes
const X&    yes      yes            yes      yes
X&&                                 yes
const X&&                           yes      yes

Trong thực tế, bạn có thể quên đi const X&&. Bị hạn chế đọc từ giá trị không phải là rất hữu ích.

Một tham chiếu rvalue X&&là một loại tham chiếu mới chỉ liên kết với các giá trị.

Chuyển đổi ngầm định

Tài liệu tham khảo Rvalue đã trải qua một số phiên bản. Kể từ phiên bản 2.1, một tham chiếu X&&giá trị cũng liên kết với tất cả các loại giá trị thuộc loại khác Y, miễn là có một chuyển đổi ngầm định từ Ysang X. Trong trường hợp đó, một loại tạm thời Xđược tạo và tham chiếu giá trị bị ràng buộc với tạm thời đó:

void some_function(std::string&& r);

some_function("hello world");

Trong ví dụ trên, "hello world"là một giá trị của loại const char[12]. Vì có một chuyển đổi ngầm định từ const char[12]thông qua const char*sang std::string, một loại tạm thời std::stringđược tạo và rbị ràng buộc với tạm thời đó. Đây là một trong những trường hợp mà sự phân biệt giữa giá trị (biểu thức) và tạm thời (đối tượng) hơi mờ.

Di chuyển các nhà xây dựng

Một ví dụ hữu ích của hàm với X&&tham số là hàm tạo di chuyển X::X(X&& source) . Mục đích của nó là chuyển quyền sở hữu tài nguyên được quản lý từ nguồn vào đối tượng hiện tại.

Trong C ++ 11, std::auto_ptr<T>đã được thay thế bằng cách std::unique_ptr<T>tận dụng các tham chiếu rvalue. Tôi sẽ phát triển và thảo luận về một phiên bản đơn giản hóa unique_ptr. Đầu tiên, chúng tôi gói gọn một con trỏ thô và làm quá tải các toán tử ->*vì vậy lớp của chúng tôi có cảm giác như một con trỏ:

template<typename T>
class unique_ptr
{
    T* ptr;

public:

    T* operator->() const
    {
        return ptr;
    }

    T& operator*() const
    {
        return *ptr;
    }

Hàm tạo có quyền sở hữu đối tượng và hàm hủy sẽ xóa nó:

    explicit unique_ptr(T* p = nullptr)
    {
        ptr = p;
    }

    ~unique_ptr()
    {
        delete ptr;
    }

Bây giờ đến phần thú vị, hàm tạo di chuyển:

    unique_ptr(unique_ptr&& source)   // note the rvalue reference
    {
        ptr = source.ptr;
        source.ptr = nullptr;
    }

Hàm tạo di chuyển này thực hiện chính xác những gì hàm tạo auto_ptrsao chép đã làm, nhưng nó chỉ có thể được cung cấp với các giá trị:

unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a);                 // error
unique_ptr<Shape> c(make_triangle());   // okay

Dòng thứ hai không biên dịch được, vì alà một giá trị, nhưng tham số unique_ptr&& sourcechỉ có thể được liên kết với các giá trị. Đây chính xác là những gì chúng tôi muốn; di chuyển nguy hiểm không bao giờ nên được ngầm. Dòng thứ ba biên dịch tốt, bởi vì make_triangle()là một giá trị. Các constructor di chuyển sẽ chuyển quyền sở hữu từ tạm thời sang c. Một lần nữa, đây chính xác là những gì chúng tôi muốn.

Hàm tạo di chuyển chuyển quyền sở hữu tài nguyên được quản lý vào đối tượng hiện tại.

Di chuyển toán tử gán

Phần còn thiếu cuối cùng là toán tử gán di chuyển. Nhiệm vụ của nó là giải phóng tài nguyên cũ và thu nhận tài nguyên mới từ đối số của nó:

    unique_ptr& operator=(unique_ptr&& source)   // note the rvalue reference
    {
        if (this != &source)    // beware of self-assignment
        {
            delete ptr;         // release the old resource

            ptr = source.ptr;   // acquire the new resource
            source.ptr = nullptr;
        }
        return *this;
    }
};

Lưu ý cách triển khai toán tử gán chuyển động này sao chép logic của cả hàm hủy và hàm tạo di chuyển. Bạn có quen thuộc với thành ngữ copy-and-exchange? Nó cũng có thể được áp dụng để di chuyển ngữ nghĩa như là thành ngữ di chuyển và trao đổi:

    unique_ptr& operator=(unique_ptr source)   // note the missing reference
    {
        std::swap(ptr, source.ptr);
        return *this;
    }
};

Bây giờ sourcelà một biến kiểu unique_ptr, nó sẽ được khởi tạo bởi hàm tạo di chuyển; đó là, đối số sẽ được chuyển vào tham số. Đối số vẫn được yêu cầu là một giá trị, bởi vì chính hàm tạo di chuyển có một tham số tham chiếu giá trị. Khi luồng điều khiển đạt đến mức đóng của operator=, sourceđi ra khỏi phạm vi, tự động giải phóng tài nguyên cũ.

Toán tử gán chuyển di chuyển quyền sở hữu tài nguyên được quản lý vào đối tượng hiện tại, giải phóng tài nguyên cũ. Thành ngữ di chuyển và trao đổi đơn giản hóa việc thực hiện.

Di chuyển từ giá trị

Đôi khi, chúng tôi muốn chuyển từ giá trị. Đó là, đôi khi chúng tôi muốn trình biên dịch xử lý một giá trị như thể nó là một giá trị, vì vậy nó có thể gọi hàm tạo di chuyển, mặc dù nó có thể không an toàn. Với mục đích này, C ++ 11 cung cấp một mẫu hàm thư viện tiêu chuẩn được gọi std::movebên trong tiêu đề <utility>. Tên này là một chút không may, bởi vì std::movechỉ đơn giản là đưa ra một giá trị cho một giá trị; nó không tự di chuyển bất cứ thứ gì Nó chỉ cho phép di chuyển. Có lẽ nó nên được đặt tên std::cast_to_rvaluehoặc std::enable_move, nhưng bây giờ chúng ta bị mắc kẹt với cái tên này.

Đây là cách bạn rõ ràng di chuyển từ một giá trị:

unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a);              // still an error
unique_ptr<Shape> c(std::move(a));   // okay

Lưu ý rằng sau dòng thứ ba, akhông còn sở hữu một hình tam giác. Điều đó không sao, bởi vì bằng cách viết rõ ràngstd::move(a) , chúng tôi đã nói rõ ý định của mình: "Kính gửi nhà xây dựng, làm bất cứ điều gì bạn muốn ađể khởi tạo c; Tôi không quan tâm đến anữa. Hãy thoải mái theo cách của bạn a."

std::move(some_lvalue) đưa ra một giá trị cho một giá trị, do đó cho phép di chuyển tiếp theo.

Giá trị

Lưu ý rằng mặc dù std::move(a)là một giá trị, đánh giá của nó không tạo ra một đối tượng tạm thời. Câu hỏi hóc búa này buộc ủy ban phải giới thiệu một loại giá trị thứ ba. Một cái gì đó có thể được liên kết với một tham chiếu giá trị, mặc dù nó không phải là một giá trị theo nghĩa truyền thống, được gọi là xvalue (giá trị eXpires). Các giá trị truyền thống được đổi tên thành giá trị (giá trị thuần túy).

Cả giá trị và giá trị x đều là giá trị. Xvalues ​​và lvalues ​​đều là glvalues ( Generalvalval ). Các mối quan hệ dễ nắm bắt hơn với sơ đồ:

        expressions
          /     \
         /       \
        /         \
    glvalues   rvalues
      /  \       /  \
     /    \     /    \
    /      \   /      \
lvalues   xvalues   prvalues

Lưu ý rằng chỉ xvalues ​​là thực sự mới; phần còn lại chỉ là do đổi tên và nhóm.

Giá trị C ++ 98 được gọi là giá trị trong C ++ 11. Thay thế tinh thần tất cả các lần xuất hiện của "giá trị" trong các đoạn trước bằng "prvalue".

Di chuyển ra khỏi chức năng

Cho đến nay, chúng ta đã thấy sự di chuyển vào các biến cục bộ và vào các tham số hàm. Nhưng di chuyển cũng có thể theo hướng ngược lại. Nếu một hàm trả về theo giá trị, một số đối tượng tại trang gọi (có thể là biến cục bộ hoặc tạm thời, nhưng có thể là bất kỳ loại đối tượng nào) được khởi tạo với biểu thức sau returncâu lệnh làm đối số cho hàm tạo di chuyển:

unique_ptr<Shape> make_triangle()
{
    return unique_ptr<Shape>(new Triangle);
}          \-----------------------------/
                  |
                  | temporary is moved into c
                  |
                  v
unique_ptr<Shape> c(make_triangle());

Có lẽ đáng ngạc nhiên, các đối tượng tự động (biến cục bộ không được khai báo là static) cũng có thể được chuyển hoàn toàn ra khỏi các hàm:

unique_ptr<Shape> make_square()
{
    unique_ptr<Shape> result(new Square);
    return result;   // note the missing std::move
}

Làm thế nào mà các nhà xây dựng di chuyển chấp nhận giá trị resultnhư một đối số? Phạm vi của resultnó sắp kết thúc, và nó sẽ bị phá hủy trong quá trình giải nén stack. Không ai có thể phàn nàn sau đó resultđã thay đổi bằng cách nào đó; khi luồng điều khiển quay lại người gọi, resultkhông tồn tại nữa! Vì lý do đó, C ++ 11 có một quy tắc đặc biệt cho phép trả về các đối tượng tự động từ các chức năng mà không phải viết std::move. Trong thực tế, bạn không bao giờ nên sử dụng std::moveđể di chuyển các đối tượng tự động ra khỏi các chức năng, vì điều này ngăn cản "tối ưu hóa giá trị trả về có tên" (NRVO).

Không bao giờ sử dụng std::moveđể di chuyển các đối tượng tự động ra khỏi chức năng.

Lưu ý rằng trong cả hai chức năng của nhà máy, loại trả về là một giá trị, không phải là tham chiếu giá trị. Tham chiếu Rvalue vẫn là tài liệu tham khảo và như mọi khi, bạn không bao giờ nên trả lại tham chiếu cho đối tượng tự động; người gọi sẽ kết thúc với một tham chiếu lơ lửng nếu bạn lừa trình biên dịch chấp nhận mã của bạn, như thế này:

unique_ptr<Shape>&& flawed_attempt()   // DO NOT DO THIS!
{
    unique_ptr<Shape> very_bad_idea(new Square);
    return std::move(very_bad_idea);   // WRONG!
}

Không bao giờ trả lại các đối tượng tự động bằng cách tham chiếu giá trị. Di chuyển được thực hiện độc quyền bởi các nhà xây dựng di chuyển, không phải bởi std::move, và không chỉ bằng cách ràng buộc một giá trị với một tham chiếu giá trị.

Di chuyển thành viên

Sớm hay muộn, bạn sẽ viết mã như thế này:

class Foo
{
    unique_ptr<Shape> member;

public:

    Foo(unique_ptr<Shape>&& parameter)
    : member(parameter)   // error
    {}
};

Về cơ bản, trình biên dịch sẽ phàn nàn rằng đó parameterlà một giá trị. Nếu bạn nhìn vào loại của nó, bạn sẽ thấy một tham chiếu rvalue, nhưng một tham chiếu rvalue chỉ đơn giản có nghĩa là "một tham chiếu được ràng buộc với một giá trị"; điều đó không có nghĩa là bản thân tài liệu tham khảo là một giá trị! Thật vậy, parameterchỉ là một biến số thông thường với một tên. Bạn có thể sử dụng bao nhiêu lần parametertùy thích bên trong phần thân của hàm tạo và nó luôn biểu thị cùng một đối tượng. Hoàn toàn di chuyển từ nó sẽ nguy hiểm, do đó ngôn ngữ cấm nó.

Một tham chiếu rvalue có tên là một giá trị, giống như bất kỳ biến nào khác.

Giải pháp là tự kích hoạt di chuyển:

class Foo
{
    unique_ptr<Shape> member;

public:

    Foo(unique_ptr<Shape>&& parameter)
    : member(std::move(parameter))   // note the std::move
    {}
};

Bạn có thể lập luận rằng parameternó không được sử dụng nữa sau khi khởi tạo member. Tại sao không có quy tắc đặc biệt nào để âm thầm chèn std::movegiống như với các giá trị trả về? Có lẽ bởi vì nó sẽ là quá nhiều gánh nặng cho những người triển khai trình biên dịch. Ví dụ, nếu cơ quan xây dựng ở một đơn vị dịch thuật khác thì sao? Ngược lại, quy tắc giá trị trả về chỉ đơn giản là phải kiểm tra các bảng ký hiệu để xác định xem có phải là định danh hay không sau khi returntừ khóa biểu thị một đối tượng tự động.

Bạn cũng có thể vượt qua parametertheo giá trị. Đối với các loại chỉ di chuyển như unique_ptr, có vẻ như chưa có thành ngữ nào được thiết lập. Cá nhân, tôi thích vượt qua bởi giá trị, vì nó gây ra sự lộn xộn trong giao diện.

Chức năng thành viên đặc biệt

C ++ 98 ngầm định khai báo ba hàm thành viên đặc biệt theo yêu cầu, nghĩa là khi chúng cần ở đâu đó: hàm tạo sao chép, toán tử gán sao chép và hàm hủy.

X::X(const X&);              // copy constructor
X& X::operator=(const X&);   // copy assignment operator
X::~X();                     // destructor

Tài liệu tham khảo Rvalue đã trải qua một số phiên bản. Kể từ phiên bản 3.0, C ++ 11 tuyên bố hai hàm thành viên đặc biệt bổ sung theo yêu cầu: hàm tạo di chuyển và toán tử gán chuyển động. Lưu ý rằng cả VC10 và VC11 đều không phù hợp với phiên bản 3.0, vì vậy bạn sẽ phải tự thực hiện chúng.

X::X(X&&);                   // move constructor
X& X::operator=(X&&);        // move assignment operator

Hai hàm thành viên đặc biệt mới này chỉ được khai báo ngầm nếu không có hàm thành viên đặc biệt nào được khai báo thủ công. Ngoài ra, nếu bạn khai báo hàm tạo di chuyển của riêng bạn hoặc toán tử chuyển nhượng di chuyển, thì hàm tạo sao chép cũng như toán tử gán gán sao chép sẽ không được khai báo ngầm.

Những quy tắc này có ý nghĩa gì trong thực tế?

Nếu bạn viết một lớp mà không có tài nguyên không được quản lý, bạn không cần phải tự khai báo bất kỳ chức năng nào trong năm chức năng thành viên đặc biệt và bạn sẽ nhận được ngữ nghĩa sao chép chính xác và di chuyển ngữ nghĩa miễn phí. Nếu không, bạn sẽ phải tự thực hiện các chức năng thành viên đặc biệt. Tất nhiên, nếu lớp của bạn không được hưởng lợi từ ngữ nghĩa di chuyển, thì không cần phải thực hiện các hoạt động di chuyển đặc biệt.

Lưu ý rằng toán tử gán gán sao chép và toán tử gán di chuyển có thể được hợp nhất thành một toán tử gán đơn thống nhất, lấy giá trị của nó theo giá trị:

X& X::operator=(X source)    // unified assignment operator
{
    swap(source);            // see my first answer for an explanation
    return *this;
}

Bằng cách này, số lượng chức năng thành viên đặc biệt để thực hiện giảm từ năm xuống còn bốn. Có một sự đánh đổi giữa an toàn ngoại lệ và hiệu quả ở đây, nhưng tôi không phải là chuyên gia về vấn đề này.

Chuyển tiếp tài liệu tham khảo ( trước đây gọi là tài liệu tham khảo phổ quát )

Hãy xem xét mẫu hàm sau:

template<typename T>
void foo(T&&);

Bạn có thể mong đợi T&&chỉ liên kết với các giá trị, bởi vì thoạt nhìn, nó trông giống như một tham chiếu giá trị. Khi nó bật ra, T&&cũng liên kết với giá trị:

foo(make_triangle());   // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&
unique_ptr<Shape> a(new Triangle);
foo(a);                 // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

Nếu đối số là một giá trị của loại X, Tđược suy ra là X, do đó T&&có nghĩa là X&&. Đây là những gì bất cứ ai mong đợi. Nhưng nếu đối số là một giá trị loại X, do một quy tắc đặc biệt, Tđược suy luận là X&, do đó T&&sẽ có nghĩa như thế X& &&. Nhưng vì C ++ vẫn không có khái niệm về các tham chiếu đến các tham chiếu, nên kiểu X& &&này được thu gọn vào X&. Điều này thoạt nghe có vẻ khó hiểu và vô dụng, nhưng thu gọn tham chiếu là điều cần thiết để chuyển tiếp hoàn hảo (điều này sẽ không được thảo luận ở đây).

T && không phải là tham chiếu giá trị, mà là tham chiếu chuyển tiếp. Nó cũng liên kết với giá trị, trong trường hợp này TT&&cả hai tham chiếu giá trị.

Nếu bạn muốn giới hạn một mẫu hàm theo giá trị, bạn có thể kết hợp SFINAE với các đặc điểm loại:

#include <type_traits>

template<typename T>
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
foo(T&&);

Thực hiện di chuyển

Bây giờ bạn đã hiểu sự sụp đổ tham chiếu, đây là cách std::movethực hiện:

template<typename T>
typename std::remove_reference<T>::type&&
move(T&& t)
{
    return static_cast<typename std::remove_reference<T>::type&&>(t);
}

Như bạn có thể thấy, movechấp nhận bất kỳ loại tham số nào nhờ tham chiếu chuyển tiếp T&&và nó trả về tham chiếu giá trị. Cuộc std::remove_reference<T>::typegọi hàm meta là cần thiết bởi vì nếu không, đối với các giá trị của kiểu X, kiểu trả về sẽ là X& &&, sẽ sụp đổ vào X&. Vì tluôn luôn là một giá trị (hãy nhớ rằng một tham chiếu rvalue có tên là một giá trị), nhưng chúng tôi muốn liên kết tvới một tham chiếu giá trị, chúng tôi phải chuyển rõ ràng tsang loại trả về chính xác. Cuộc gọi của hàm trả về tham chiếu rvalue chính là xvalue. Bây giờ bạn biết xvalues ​​đến từ đâu;)

Cuộc gọi của hàm trả về tham chiếu giá trị, chẳng hạn như std::move, là một giá trị x.

Lưu ý rằng việc trả về bằng tham chiếu rvalue là tốt trong ví dụ này, vì tkhông biểu thị một đối tượng tự động, mà thay vào đó là một đối tượng được người gọi chuyển qua.



24
Có một lý do thứ ba di chuyển ngữ nghĩa là quan trọng: an toàn ngoại lệ. Thông thường, nơi một hoạt động sao chép có thể ném (vì nó cần phân bổ tài nguyên và phân bổ có thể thất bại) một hoạt động di chuyển có thể không được ném (vì nó có thể chuyển quyền sở hữu tài nguyên hiện tại thay vì phân bổ tài nguyên mới). Có các hoạt động không thể thất bại luôn luôn tốt và nó có thể rất quan trọng khi viết mã cung cấp các đảm bảo ngoại lệ.
Brangdon

8
Tôi đã ở bên bạn đúng với 'Tài liệu tham khảo phổ quát', nhưng sau đó tất cả đều quá trừu tượng để theo dõi. Tham chiếu sụp đổ? Chuyển tiếp hoàn hảo? Bạn đang nói rằng một tham chiếu rvalue trở thành một tham chiếu phổ quát nếu loại được tạo khuôn mẫu? Tôi ước có một cách để giải thích điều này để tôi biết nếu tôi cần hiểu nó hay không! :)
Kylotan

8
Hãy viết một cuốn sách ngay bây giờ ... câu trả lời này đã cho tôi lý do để tin rằng nếu bạn bao quát các góc khác của C ++ theo cách sáng suốt như thế này, hàng ngàn người sẽ hiểu nó hơn.
halivingston

12
@halivingston Cảm ơn bạn rất nhiều vì phản hồi của bạn, tôi thực sự đánh giá cao nó. Vấn đề với việc viết một cuốn sách là: đó là công việc nhiều hơn bạn có thể tưởng tượng. Nếu bạn muốn tìm hiểu sâu về C ++ 11 và hơn thế nữa, tôi khuyên bạn nên mua "Hiệu quả hiện đại C ++" của Scott Meyers.
dòng chảy

77

Di chuyển ngữ nghĩa được dựa trên tài liệu tham khảo giá trị .
Một giá trị là một đối tượng tạm thời, sẽ bị phá hủy ở cuối biểu thức. Trong C ++ hiện tại, các giá trị chỉ liên kết với các consttham chiếu. C ++ 1x sẽ cho phép các consttham chiếu không có giá trị, đánh vần T&&, là các tham chiếu đến một đối tượng giá trị.
Vì một giá trị sẽ chết ở cuối biểu thức, bạn có thể đánh cắp dữ liệu của nó . Thay vì sao chép nó vào một đối tượng khác, bạn di chuyển dữ liệu của nó vào nó.

class X {
public: 
  X(X&& rhs) // ctor taking an rvalue reference, so-called move-ctor
    : data_()
  {
     // since 'x' is an rvalue object, we can steal its data
     this->swap(std::move(rhs));
     // this will leave rhs with the empty data
  }
  void swap(X&& rhs);
  // ... 
};

// ...

X f();

X x = f(); // f() returns result as rvalue, so this calls move-ctor

Trong đoạn mã trên, với các trình biên dịch cũ, kết quả f()được sao chép vào xbằng cách sử dụng hàm tạo Xsao chép. Nếu trình biên dịch của bạn hỗ trợ di chuyển ngữ nghĩa và Xcó hàm tạo di chuyển, thì nó được gọi thay thế. Từ trước đến nay rhsđối số là một rvalue , chúng tôi biết điều đó không cần thiết nữa và chúng tôi có thể ăn cắp giá trị của nó.
Vì vậy, giá trị là chuyển từ tạm giấu tên trở về từf()đếnx(trong khi dữ liệux, khởi tạo một sản phẩm nàoX, được chuyển vào tạm thời, mà sẽ bị phá hủy sau khi chuyển nhượng).


1
lưu ý rằng nó phải là this->swap(std::move(rhs));bởi vì các tham chiếu rvalue được đặt tên là giá trị
wmamrak

Điều này là sai, theo nhận xét của mỗi @ Tacyt: rhslà một giá trị trong bối cảnh X::X(X&& rhs). Bạn cần phải gọi std::move(rhs)để có được một giá trị, nhưng điều này làm cho câu trả lời phải di chuyển.
Asherah

Điều gì làm di chuyển ngữ nghĩa cho các loại mà không có con trỏ? Di chuyển ngữ nghĩa hoạt động sao chép?
Gusev Slava

@Gusev: Tôi không biết bạn đang hỏi gì.
sbi

60

Giả sử bạn có một hàm trả về một đối tượng quan trọng:

Matrix multiply(const Matrix &a, const Matrix &b);

Khi bạn viết mã như thế này:

Matrix r = multiply(a, b);

sau đó một trình biên dịch C ++ thông thường sẽ tạo một đối tượng tạm thời cho kết quả của multiply(), gọi hàm tạo sao chép để khởi tạo rvà sau đó hủy giá trị trả về tạm thời. Di chuyển ngữ nghĩa trong C ++ 0x cho phép "hàm tạo di chuyển" được gọi để khởi tạor bằng cách sao chép nội dung của nó, sau đó loại bỏ giá trị tạm thời mà không phải phá hủy nó.

Điều này đặc biệt quan trọng nếu (có lẽ Matrixnhư ví dụ ở trên), đối tượng được sao chép phân bổ thêm bộ nhớ trên heap để lưu trữ biểu diễn bên trong của nó. Một nhà xây dựng bản sao sẽ phải tạo một bản sao đầy đủ của biểu diễn bên trong hoặc sử dụng các phép đếm tham chiếu và ngữ nghĩa sao chép trên ghi trong nội bộ. Một constructor di chuyển sẽ để lại bộ nhớ heap một mình và chỉ sao chép con trỏ bên trong Matrixđối tượng.


2
Làm thế nào là xây dựng di chuyển và xây dựng sao chép khác nhau?
dicroce

1
@dicroce: Chúng khác nhau theo cú pháp, một cái giống như Matrix (const Matrix & src) (copy constructor) và cái kia trông giống Matrix (Matrix && src) (di chuyển constructor), kiểm tra câu trả lời chính của tôi để biết ví dụ tốt hơn.
snk_kid

3
@dicroce: Một người tạo một đối tượng trống và một người tạo một bản sao. Nếu dữ liệu được lưu trữ trong đối tượng lớn, một bản sao có thể tốn kém. Ví dụ: std :: vector.
Billy ONeal

1
@ kunj2aan: Nó phụ thuộc vào trình biên dịch của bạn, tôi nghi ngờ. Trình biên dịch có thể tạo một đối tượng tạm thời bên trong hàm và sau đó di chuyển nó vào giá trị trả về của trình gọi. Hoặc, nó có thể có thể trực tiếp xây dựng đối tượng trong giá trị trả về mà không cần sử dụng hàm tạo di chuyển.
Greg Hewgill

2
@Jichao: Đó là một tối ưu hóa có tên RVO, hãy xem câu hỏi này để biết thêm thông tin về sự khác biệt: stackoverflow.com/questions/5031778/
Greg Hewgill

30

Nếu bạn thực sự quan tâm đến một lời giải thích sâu sắc, tốt về ngữ nghĩa di chuyển, tôi khuyên bạn nên đọc bài báo gốc về chúng, "Đề xuất thêm hỗ trợ ngữ nghĩa cho ngôn ngữ C ++."

Nó rất dễ truy cập và dễ đọc và nó là một trường hợp tuyệt vời cho những lợi ích mà họ cung cấp. Có nhiều bài báo gần đây và cập nhật khác về ngữ nghĩa di chuyển có sẵn trên trang web WG21 , nhưng đây có lẽ là bài viết đơn giản nhất vì nó tiếp cận mọi thứ từ góc nhìn cấp cao nhất và không hiểu nhiều về chi tiết ngôn ngữ nghiệt ngã.


27

Di chuyển ngữ nghĩa là về việc chuyển tài nguyên thay vì sao chép chúng khi không ai cần giá trị nguồn nữa.

Trong C ++ 03, các đối tượng thường được sao chép, chỉ bị hủy hoặc gán trước khi bất kỳ mã nào sử dụng lại giá trị. Ví dụ: khi bạn trả về theo giá trị từ một hàm, trừ khi RVO khởi động trong giá trị mà bạn trả về được sao chép vào khung ngăn xếp của người gọi, sau đó nó vượt ra khỏi phạm vi và bị phá hủy. Đây chỉ là một trong nhiều ví dụ: xem giá trị truyền qua khi đối tượng nguồn là tạm thời, các thuật toán giống như sortsắp xếp lại các mục, phân bổ lại vectorkhi capacity()vượt quá, v.v.

Khi các cặp sao chép / hủy như vậy đắt tiền, thường là do đối tượng sở hữu một số tài nguyên nặng. Ví dụ, vector<string>có thể sở hữu một khối bộ nhớ được phân bổ động có chứa một mảng các stringđối tượng, mỗi khối có bộ nhớ động riêng. Sao chép một đối tượng như vậy rất tốn kém: bạn phải phân bổ bộ nhớ mới cho từng khối được phân bổ động trong nguồn và sao chép tất cả các giá trị trên. Sau đó, bạn cần giải quyết tất cả bộ nhớ mà bạn vừa sao chép. Tuy nhiên, di chuyển một vector<string>phương tiện lớn chỉ cần sao chép một vài con trỏ (tham chiếu đến khối bộ nhớ động) đến đích và loại bỏ chúng trong nguồn.


23

Nói một cách dễ dàng (thực tế):

Sao chép một đối tượng có nghĩa là sao chép các thành viên "tĩnh" của nó và gọi newtoán tử cho các đối tượng động của nó. Đúng?

class A
{
   int i, *p;

public:
   A(const A& a) : i(a.i), p(new int(*a.p)) {}
   ~A() { delete p; }
};

Tuy nhiên, để di chuyển một đối tượng (tôi nhắc lại, theo quan điểm thực tế) chỉ ngụ ý sao chép các con trỏ của các đối tượng động và không tạo ra các đối tượng mới.

Nhưng, điều đó có nguy hiểm không? Tất nhiên, bạn có thể phá hủy một đối tượng động hai lần (lỗi phân đoạn). Vì vậy, để tránh điều đó, bạn nên "vô hiệu hóa" các con trỏ nguồn để tránh phá hủy chúng hai lần:

class A
{
   int i, *p;

public:
   // Movement of an object inside a copy constructor.
   A(const A& a) : i(a.i), p(a.p)
   {
     a.p = nullptr; // pointer invalidated.
   }

   ~A() { delete p; }
   // Deleting NULL, 0 or nullptr (address 0x0) is safe. 
};

Ok, nhưng nếu tôi di chuyển một đối tượng, đối tượng nguồn sẽ trở nên vô dụng, phải không? Tất nhiên, nhưng trong một số tình huống nhất định, điều đó rất hữu ích. Điều rõ ràng nhất là khi tôi gọi một hàm với một đối tượng ẩn danh (đối tượng tạm thời, giá trị, ..., bạn có thể gọi nó với các tên khác nhau):

void heavyFunction(HeavyType());

Trong tình huống đó, một đối tượng ẩn danh được tạo, tiếp theo được sao chép vào tham số hàm và sau đó bị xóa. Vì vậy, ở đây tốt hơn là di chuyển đối tượng, bởi vì bạn không cần đối tượng ẩn danh và bạn có thể tiết kiệm thời gian và bộ nhớ.

Điều này dẫn đến khái niệm về một tham chiếu "giá trị". Chúng tồn tại trong C ++ 11 chỉ để phát hiện xem đối tượng nhận được có ẩn danh hay không. Tôi nghĩ rằng bạn đã biết rằng "lvalue" là một thực thể có thể gán được (phần bên trái của =toán tử), vì vậy bạn cần một tham chiếu có tên đến một đối tượng để có thể hoạt động như một giá trị. Một giá trị hoàn toàn ngược lại, một đối tượng không có tham chiếu được đặt tên. Do đó, đối tượng ẩn danh và giá trị là từ đồng nghĩa. Vì thế:

class A
{
   int i, *p;

public:
   // Copy
   A(const A& a) : i(a.i), p(new int(*a.p)) {}

   // Movement (&& means "rvalue reference to")
   A(A&& a) : i(a.i), p(a.p)
   {
      a.p = nullptr;
   }

   ~A() { delete p; }
};

Trong trường hợp này, khi một đối tượng của loại A nên được "sao chép", trình biên dịch sẽ tạo một tham chiếu giá trị hoặc tham chiếu giá trị theo nếu đối tượng được truyền có được đặt tên hay không. Khi không, hàm tạo di chuyển của bạn được gọi và bạn biết đối tượng là tạm thời và bạn có thể di chuyển các đối tượng động của nó thay vì sao chép chúng, tiết kiệm không gian và bộ nhớ.

Điều quan trọng cần nhớ là các đối tượng "tĩnh" luôn được sao chép. Không có cách nào để "di chuyển" một đối tượng tĩnh (đối tượng trong ngăn xếp và không phải trên heap). Vì vậy, việc phân biệt "di chuyển" / "sao chép" khi một đối tượng không có thành viên động (trực tiếp hoặc gián tiếp) là không liên quan.

Nếu đối tượng của bạn phức tạp và hàm hủy có các hiệu ứng phụ khác, như gọi đến chức năng của thư viện, gọi đến các chức năng toàn cầu khác hoặc bất cứ điều gì, có lẽ tốt hơn là báo hiệu chuyển động bằng cờ:

class Heavy
{
   bool b_moved;
   // staff

public:
   A(const A& a) { /* definition */ }
   A(A&& a) : // initialization list
   {
      a.b_moved = true;
   }

   ~A() { if (!b_moved) /* destruct object */ }
};

Vì vậy, mã của bạn ngắn hơn (bạn không cần thực hiện một nullptrbài tập cho từng thành viên năng động) và tổng quát hơn.

Câu hỏi điển hình khác: sự khác biệt giữa A&&và là const A&&gì? Tất nhiên, trong trường hợp đầu tiên, bạn có thể sửa đổi đối tượng và trong lần thứ hai không, nhưng, ý nghĩa thực tế? Trong trường hợp thứ hai, bạn không thể sửa đổi nó, vì vậy bạn không có cách nào để vô hiệu hóa đối tượng (ngoại trừ với một cờ có thể thay đổi hoặc một cái gì đó tương tự) và không có sự khác biệt thực tế đối với một nhà xây dựng sao chép.

chuyển tiếp hoàn hảo là gì? Điều quan trọng cần biết là "tham chiếu giá trị" là tham chiếu đến một đối tượng được đặt tên trong "phạm vi của người gọi". Nhưng trong phạm vi thực tế, một tham chiếu giá trị là một tên cho một đối tượng, vì vậy, nó hoạt động như một đối tượng được đặt tên. Nếu bạn chuyển một tham chiếu giá trị cho một hàm khác, thì bạn đang truyền một đối tượng được đặt tên, vì vậy, đối tượng không được nhận như một đối tượng tạm thời.

void some_function(A&& a)
{
   other_function(a);
}

Đối tượng asẽ được sao chép vào tham số thực tế của other_function. Nếu bạn muốn đối tượng atiếp tục được coi là một đối tượng tạm thời, bạn nên sử dụng std::movechức năng:

other_function(std::move(a));

Với dòng này, std::movesẽ achuyển thành một giá trị và other_functionsẽ nhận đối tượng dưới dạng một đối tượng không tên. Tất nhiên, nếu other_functionkhông quá tải cụ thể để làm việc với các đối tượng không tên, sự khác biệt này không quan trọng.

Là chuyển tiếp hoàn hảo? Không, nhưng chúng tôi rất thân nhau. Chuyển tiếp hoàn hảo chỉ hữu ích khi làm việc với các mẫu, với mục đích để nói: nếu tôi cần chuyển một đối tượng sang một chức năng khác, tôi cần rằng nếu tôi nhận được một đối tượng được đặt tên, đối tượng được truyền dưới dạng một đối tượng được đặt tên và khi không, Tôi muốn vượt qua nó như một đối tượng không tên:

template<typename T>
void some_function(T&& a)
{
   other_function(std::forward<T>(a));
}

Đó là chữ ký của một chức năng nguyên mẫu sử dụng chuyển tiếp hoàn hảo, được triển khai trong C ++ 11 bằng phương tiện std::forward. Hàm này khai thác một số quy tắc khởi tạo mẫu:

 `A& && == A&`
 `A&& && == A&&`

Vì vậy, nếu Tlà một tham chiếu giá trị cho A( T = A &), acũng ( A & && => A &). Nếu Tlà một tham chiếu giá trị A, acũng (A && && => A &&). Trong cả hai trường hợp, alà một đối tượng được đặt tên trong phạm vi thực tế, nhưng Tchứa thông tin về "loại tham chiếu" của nó theo quan điểm của phạm vi người gọi. Thông tin này ( T) được truyền dưới dạng tham số mẫu tới forwardvà 'a' được di chuyển hoặc không theo loại T.


20

Nó giống như ngữ nghĩa sao chép, nhưng thay vì phải sao chép tất cả dữ liệu bạn có để lấy cắp dữ liệu từ đối tượng được "di chuyển" từ đó.


13

Bạn biết một ngữ nghĩa sao chép có nghĩa là gì phải không? điều đó có nghĩa là bạn có các loại có thể sao chép được, đối với các loại do người dùng xác định, bạn xác định điều này hoặc mua rõ ràng bằng cách viết một hàm tạo và gán toán tử sao chép hoặc trình biên dịch tạo ra chúng hoàn toàn. Điều này sẽ làm một bản sao.

Di chuyển ngữ nghĩa về cơ bản là một loại do người dùng định nghĩa với hàm tạo lấy tham chiếu giá trị r (loại tham chiếu mới sử dụng && (có hai ký hiệu)) không phải là hằng số, đây được gọi là hàm tạo di chuyển, tương tự với toán tử gán. Vì vậy, một nhà xây dựng di chuyển làm gì, thay vì sao chép bộ nhớ từ đối số nguồn của nó, nó 'di chuyển' bộ nhớ từ nguồn đến đích.

Khi nào bạn muốn làm điều đó? well std :: vector là một ví dụ, giả sử bạn đã tạo một std :: vector tạm thời và bạn trả lại nó từ một hàm nói:

std::vector<foo> get_foos();

Bạn sẽ có chi phí hoạt động từ hàm tạo sao chép khi hàm trả về, nếu (và nó sẽ trong C ++ 0x) std :: vector có một hàm tạo di chuyển thay vì sao chép, nó chỉ có thể đặt con trỏ và 'di chuyển' được phân bổ động bộ nhớ cho trường hợp mới. Nó giống như ngữ nghĩa chuyển quyền sở hữu với std :: auto_ptr.


1
Tôi không nghĩ rằng đây là một ví dụ tuyệt vời, bởi vì trong các ví dụ về giá trị trả về hàm này, Tối ưu hóa giá trị trả về có thể đã loại bỏ thao tác sao chép.
Zan Lynx

7

Để minh họa sự cần thiết của ngữ nghĩa di chuyển , hãy xem xét ví dụ này mà không cần ngữ nghĩa di chuyển:

Đây là một hàm lấy một đối tượng kiểu Tvà trả về một đối tượng cùng loại T:

T f(T o) { return o; }
  //^^^ new object constructed

Hàm trên sử dụng lệnh gọi theo giá trị , có nghĩa là khi hàm này được gọi là một đối tượng phải được xây dựng để được sử dụng bởi hàm.
Bởi vì hàm cũng trả về theo giá trị , một đối tượng mới khác được xây dựng cho giá trị trả về:

T b = f(a);
  //^ new object constructed

Hai đối tượng mới đã được xây dựng, một trong số đó là một đối tượng tạm thời chỉ được sử dụng trong suốt thời gian của chức năng.

Khi đối tượng mới được tạo từ giá trị trả về, hàm tạo sao chép được gọi để sao chép nội dung của đối tượng tạm thời sang đối tượng mới b. Sau khi chức năng hoàn thành, đối tượng tạm thời được sử dụng trong chức năng sẽ ra khỏi phạm vi và bị phá hủy.


Bây giờ, hãy xem xét những gì một nhà xây dựng sao chép làm.

Đầu tiên nó phải khởi tạo đối tượng, sau đó sao chép tất cả dữ liệu liên quan từ đối tượng cũ sang đối tượng mới.
Tùy thuộc vào lớp, có thể là một thùng chứa rất nhiều dữ liệu, sau đó có thể đại diện cho việc sử dụng nhiều thời gianbộ nhớ

// Copy constructor
T::T(T &old) {
    copy_data(m_a, old.m_a);
    copy_data(m_b, old.m_b);
    copy_data(m_c, old.m_c);
}

Với ngữ nghĩa di chuyển , giờ đây có thể làm cho hầu hết công việc này bớt khó chịu bằng cách di chuyển dữ liệu thay vì sao chép.

// Move constructor
T::T(T &&old) noexcept {
    m_a = std::move(old.m_a);
    m_b = std::move(old.m_b);
    m_c = std::move(old.m_c);
}

Di chuyển dữ liệu liên quan đến việc liên kết lại dữ liệu với đối tượng mới. Và không có bản sao diễn ra cả.

Điều này được thực hiện với một rvaluetài liệu tham khảo.
Một rvaluetham chiếu hoạt động khá giống như một lvaluetham chiếu với một điểm khác biệt quan trọng:
một tham chiếu giá trịthể được di chuyển và một giá trị không thể.

Từ cppreference.com :

Để đảm bảo ngoại lệ mạnh có thể, các nhà xây dựng di chuyển do người dùng xác định không nên ném ngoại lệ. Trong thực tế, các thùng chứa tiêu chuẩn thường dựa vào std :: move_if_noexcept để chọn giữa di chuyển và sao chép khi các yếu tố của container cần được di dời. Nếu cả hai hàm tạo sao chép và di chuyển được cung cấp, độ phân giải quá tải sẽ chọn hàm tạo di chuyển nếu đối số là một giá trị (có thể là một giá trị như tạm thời không tên hoặc xvalue như kết quả của std :: move) và chọn hàm tạo sao chép đối số là một giá trị (đối tượng được đặt tên hoặc hàm / toán tử trả về tham chiếu lvalue). Nếu chỉ cung cấp hàm tạo sao chép, tất cả các loại đối số sẽ chọn nó (miễn là nó cần tham chiếu đến const, vì các giá trị có thể liên kết với tham chiếu const), điều này làm cho việc sao chép dự phòng khi di chuyển, khi di chuyển không khả dụng. Trong nhiều tình huống, các nhà xây dựng di chuyển được tối ưu hóa ngay cả khi họ sẽ tạo ra các hiệu ứng phụ có thể quan sát được, xem bản sao bỏ phiếu. Hàm tạo được gọi là 'hàm tạo di chuyển' khi nó lấy tham chiếu giá trị làm tham số. Không bắt buộc phải di chuyển bất cứ thứ gì, lớp không bắt buộc phải có tài nguyên để di chuyển và 'nhà xây dựng di chuyển' có thể không thể di chuyển tài nguyên như trong trường hợp được phép (nhưng có thể không hợp lý) trong đó tham số là const rvalue tham chiếu (const T &&).


7

Tôi đang viết điều này để đảm bảo rằng tôi hiểu đúng.

Di chuyển ngữ nghĩa đã được tạo ra để tránh việc sao chép không cần thiết của các đối tượng lớn. Bjarne Stroustrup trong cuốn sách "Ngôn ngữ lập trình C ++" sử dụng hai ví dụ trong đó việc sao chép không cần thiết xảy ra theo mặc định: một, hoán đổi hai đối tượng lớn và hai, trả lại một đối tượng lớn từ một phương thức.

Hoán đổi hai đối tượng lớn thường liên quan đến việc sao chép đối tượng thứ nhất sang đối tượng tạm thời, sao chép đối tượng thứ hai sang đối tượng thứ nhất và sao chép đối tượng tạm thời sang đối tượng thứ hai. Đối với loại tích hợp, việc này rất nhanh, nhưng đối với các đối tượng lớn, ba bản sao này có thể mất một lượng lớn thời gian. "Chuyển nhượng di chuyển" cho phép lập trình viên ghi đè hành vi sao chép mặc định và thay vào đó hoán đổi các tham chiếu đến các đối tượng, điều đó có nghĩa là không có bản sao nào cả và thao tác hoán đổi nhanh hơn nhiều. Việc gán di chuyển có thể được gọi bằng cách gọi phương thức std :: move ().

Trả về một đối tượng từ một phương thức theo mặc định bao gồm tạo một bản sao của đối tượng cục bộ và dữ liệu liên quan của nó ở một vị trí mà người gọi có thể truy cập được (vì đối tượng cục bộ không thể truy cập được đối với người gọi và biến mất khi phương thức kết thúc). Khi một kiểu tích hợp đang được trả về, thao tác này rất nhanh, nhưng nếu một đối tượng lớn được trả về, việc này có thể mất nhiều thời gian. Hàm xây dựng di chuyển cho phép lập trình viên ghi đè hành vi mặc định này và thay vào đó "sử dụng lại" dữ liệu heap được liên kết với đối tượng cục bộ bằng cách trỏ đối tượng được trả về cho người gọi để heap dữ liệu được liên kết với đối tượng cục bộ. Do đó, không cần sao chép.

Trong các ngôn ngữ không cho phép tạo các đối tượng cục bộ (nghĩa là các đối tượng trên ngăn xếp), các loại vấn đề này không xảy ra vì tất cả các đối tượng được phân bổ trên heap và luôn được truy cập bởi tham chiếu.


"Một" chuyển nhượng "cho phép lập trình viên ghi đè hành vi sao chép mặc định và thay vào đó trao đổi các tham chiếu đến các đối tượng, điều đó có nghĩa là không có bản sao nào cả và hoạt động hoán đổi nhanh hơn nhiều." - những tuyên bố này là mơ hồ và sai lệch. Để hoán đổi hai đối tượng xy, bạn không thể chỉ "trao đổi tham chiếu đến các đối tượng" ; có thể các đối tượng chứa các con trỏ tham chiếu dữ liệu khác và các con trỏ đó có thể được hoán đổi, nhưng các toán tử di chuyển không bắt buộc phải trao đổi bất cứ thứ gì. Họ có thể xóa sạch dữ liệu khỏi đối tượng được di chuyển, thay vì bảo toàn dữ liệu định mệnh trong đó.
Tony Delroy

Bạn có thể viết swap()mà không cần di chuyển ngữ nghĩa. "Việc gán di chuyển có thể được gọi bằng cách gọi phương thức std :: move ()." - đôi khi cần phải sử dụng std::move()- mặc dù điều đó không thực sự di chuyển bất cứ thứ gì - chỉ cần cho trình biên dịch biết đối số có thể di chuyển được, đôi khi std::forward<>()(với các tham chiếu chuyển tiếp) và các lần khác trình biên dịch biết giá trị có thể được di chuyển.
Tony Delroy

-2

Đây là câu trả lời từ cuốn sách "Ngôn ngữ lập trình C ++" của Bjarne Stroustrup. Nếu bạn không muốn xem video, bạn có thể xem văn bản dưới đây:

Hãy xem xét đoạn trích này. Trở về từ một toán tử + liên quan đến việc sao chép kết quả ra khỏi biến cục bộ resvà vào một nơi nào đó nơi người gọi có thể truy cập nó.

Vector operator+(const Vector& a, const Vector& b)
{
    if (a.size()!=b.size())
        throw Vector_siz e_mismatch{};
    Vector res(a.size());
        for (int i=0; i!=a.size(); ++i)
            res[i]=a[i]+b[i];
    return res;
}

Chúng tôi không thực sự muốn có một bản sao; chúng tôi chỉ muốn lấy kết quả ra khỏi hàm. Vì vậy, chúng ta cần di chuyển một Vector hơn là sao chép nó. Chúng ta có thể định nghĩa hàm tạo di chuyển như sau:

class Vector {
    // ...
    Vector(const Vector& a); // copy constructor
    Vector& operator=(const Vector& a); // copy assignment
    Vector(Vector&& a); // move constructor
    Vector& operator=(Vector&& a); // move assignment
};

Vector::Vector(Vector&& a)
    :elem{a.elem}, // "grab the elements" from a
    sz{a.sz}
{
    a.elem = nullptr; // now a has no elements
    a.sz = 0;
}

&& có nghĩa là "tham chiếu giá trị" và là một tham chiếu mà chúng ta có thể liên kết một giá trị. "rvalue" 'được dự định để bổ sung cho "lvalue" có nghĩa đại khái là "thứ gì đó có thể xuất hiện ở phía bên trái của một bài tập." Vì vậy, một giá trị có nghĩa đại khái là "một giá trị mà bạn không thể gán cho", chẳng hạn như một số nguyên được trả về bởi một lệnh gọi hàm và resbiến cục bộ trong toán tử + () cho vectơ.

Bây giờ, tuyên bố return res;sẽ không sao chép!

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.