C ++ 11 giá trị và di chuyển nhầm lẫn ngữ nghĩa (tuyên bố trở lại)


435

Tôi đang cố gắng để hiểu các tài liệu tham khảo về giá trị và di chuyển ngữ nghĩa của C ++ 11.

Sự khác biệt giữa các ví dụ này là gì và chúng sẽ không sao chép vector?

Ví dụ đầu tiên

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> &&rval_ref = return_vector();

Ví dụ thứ hai

std::vector<int>&& return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

Ví dụ thứ ba

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

50
Xin vui lòng không trả lại các biến cục bộ bằng cách tham khảo, bao giờ. Một tài liệu tham khảo giá trị vẫn là một tài liệu tham khảo.
fredoverflow

63
Đó rõ ràng là cố ý để hiểu sự khác biệt về ngữ nghĩa giữa các ví dụ lol
Tarantula

@FredOverflow Câu hỏi cũ, nhưng tôi phải mất một giây để hiểu nhận xét của bạn. Tôi nghĩ rằng câu hỏi với # 2 là liệu có std::move()tạo ra một "bản sao" liên tục không.
3Dave

5
@DavidLively std::move(expression)không tạo ra bất cứ điều gì, nó chỉ đơn giản chuyển biểu thức thành xvalue. Không có đối tượng được sao chép hoặc di chuyển trong quá trình đánh giá std::move(expression).
dòng chảy

Câu trả lời:


562

Ví dụ đầu tiên

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> &&rval_ref = return_vector();

Ví dụ đầu tiên trả về tạm thời bị bắt bởi rval_ref. Điều đó tạm thời sẽ kéo dài tuổi thọ của nó vượt ra ngoài rval_refđịnh nghĩa và bạn có thể sử dụng nó như thể bạn đã bắt được nó theo giá trị. Điều này rất giống với những điều sau đây:

const std::vector<int>& rval_ref = return_vector();

ngoại trừ việc tôi viết lại, rõ ràng bạn không thể sử dụng rval_reftheo cách không thường xuyên.

Ví dụ thứ hai

std::vector<int>&& return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

Trong ví dụ thứ hai, bạn đã tạo ra một lỗi thời gian chạy. rval_refbây giờ giữ một tham chiếu đến hàm bị hủy tmptrong hàm. Với bất kỳ may mắn, mã này sẽ ngay lập tức sụp đổ.

Ví dụ thứ ba

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

Ví dụ thứ ba của bạn gần tương đương với lần đầu tiên của bạn. Việc std::movebật tmplà không cần thiết và thực sự có thể là một sự bi quan về hiệu suất vì nó sẽ ức chế tối ưu hóa giá trị trả về.

Cách tốt nhất để mã hóa những gì bạn đang làm là:

Thực hành tốt nhất

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> rval_ref = return_vector();

Tức là giống như bạn làm trong C ++ 03. tmpđược coi là một giá trị trong câu lệnh return. Nó sẽ được trả về thông qua tối ưu hóa giá trị trả về (không sao chép, không di chuyển) hoặc nếu trình biên dịch quyết định nó không thể thực hiện RVO, thì nó sẽ sử dụng hàm tạo di chuyển của vectơ để thực hiện trả về . Chỉ khi RVO không được thực hiện và nếu loại được trả về không có hàm tạo di chuyển thì hàm tạo sao chép sẽ được sử dụng cho trả về.


64
Trình biên dịch sẽ RVO khi bạn trả về một đối tượng cục bộ theo giá trị, và kiểu cục bộ và trả về của hàm là như nhau và cũng không đủ điều kiện cv (không trả về các kiểu const). Tránh xa việc quay lại với tuyên bố điều kiện (:?) Vì nó có thể ức chế RVO. Đừng bao bọc cục bộ trong một số chức năng khác trả về tham chiếu đến cục bộ. Chỉ cần return my_local;. Nhiều báo cáo trả lại là ok và sẽ không ức chế RVO.
Howard Hinnant

27
Có một cảnh báo: khi trả lại một thành viên của một đối tượng cục bộ, di chuyển phải rõ ràng.
boycy

5
@NoSenseEtAl: Không có tạm thời được tạo trên dòng trả về. movekhông tạo ra tạm thời. Nó đưa ra một giá trị cho một xvalue, không tạo ra các bản sao, không tạo ra gì, phá hủy mọi thứ. Ví dụ đó là tình huống chính xác giống như khi bạn trả về bằng tham chiếu lvalue và xóa movekhỏi dòng trả về: Dù bằng cách nào bạn cũng có một tham chiếu lơ lửng đến một biến cục bộ bên trong hàm và đã bị hủy.
Howard Hinnant

15
"Nhiều báo cáo trả lại là ok và sẽ không ức chế RVO": Chỉ khi chúng trả về cùng một biến.
Ded repeatator

5
@Ded repeatator: Bạn đã đúng. Tôi đã không nói chính xác như tôi dự định. Tôi có nghĩa là nhiều câu lệnh return không cấm trình biên dịch từ RVO (mặc dù điều đó làm cho nó không thể thực hiện được), và do đó biểu thức trả về vẫn được coi là một giá trị.
Howard Hinnant

42

Không ai trong số họ sẽ sao chép, nhưng cái thứ hai sẽ đề cập đến một vectơ bị phá hủy. Các tham chiếu rvalue được đặt tên gần như không bao giờ tồn tại trong mã thông thường. Bạn viết nó giống như cách bạn đã viết một bản sao trong C ++ 03.

std::vector<int> return_vector()
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> rval_ref = return_vector();

Ngoại trừ bây giờ, vector được di chuyển. Người dùng của một lớp không đối phó với các tham chiếu giá trị của nó trong phần lớn các trường hợp.


Bạn có thực sự chắc chắn rằng ví dụ thứ ba sẽ thực hiện sao chép vector?
Tarantula

@Tarantula: Nó sẽ phá vỡ véc tơ của bạn. Cho dù nó có làm hay không sao chép nó trước khi phá vỡ không thực sự quan trọng.
Cún con

4
Tôi không thấy bất kỳ lý do cho sự nhộn nhịp mà bạn đề xuất. Hoàn toàn ổn khi liên kết một biến tham chiếu rvalue cục bộ với một giá trị. Trong trường hợp đó, thời gian tồn tại của đối tượng tạm thời được kéo dài đến thời gian tồn tại của biến tham chiếu giá trị.
dòng chảy

1
Chỉ là một điểm để làm rõ, vì tôi đang học điều này. Trong ví dụ mới này, vectơ tmpkhông được chuyển vào rval_ref, mà được viết trực tiếp vào rval_refbằng cách sử dụng RVO (tức là sao chép bản sao). Có một sự phân biệt giữa std::movevà sao chép bầu cử. A std::movevẫn có thể liên quan đến một số dữ liệu sẽ được sao chép; trong trường hợp của một vectơ, một vectơ mới thực sự được xây dựng trong hàm tạo sao chép và dữ liệu được phân bổ, nhưng phần lớn của mảng dữ liệu chỉ được sao chép bằng cách sao chép con trỏ (về cơ bản). Cuộc bầu cử sao chép tránh 100% tất cả các bản sao.
Đánh dấu Lakata

@MarkLakata Đây là NRVO, không phải RVO. NRVO là tùy chọn, ngay cả trong C ++ 17. Nếu nó không được áp dụng, cả giá trị trả về và rval_refbiến được xây dựng bằng cách sử dụng hàm tạo di chuyển của std::vector. Không có constructor sao chép liên quan đến cả có / không có std::move. tmpđược coi là một rvalue trong returntuyên bố trong trường hợp này.
Daniel Langr

16

Câu trả lời đơn giản là bạn nên viết mã cho các tham chiếu rvalue giống như mã tham chiếu thông thường và bạn nên đối xử với chúng giống nhau về mặt tinh thần 99% thời gian. Điều này bao gồm tất cả các quy tắc cũ về trả về tham chiếu (nghĩa là không bao giờ trả lại tham chiếu cho biến cục bộ).

Trừ khi bạn đang viết một lớp container mẫu cần tận dụng std :: Forward và có thể viết một hàm chung có tham chiếu lvalue hoặc rvalue, điều này ít nhiều đúng.

Một trong những lợi thế lớn đối với hàm tạo di chuyển và gán di chuyển là nếu bạn xác định chúng, trình biên dịch có thể sử dụng chúng trong các trường hợp là RVO (tối ưu hóa giá trị trả về) và NRVO (tối ưu hóa giá trị trả về) không được gọi. Điều này là khá lớn để trả về các đối tượng đắt tiền như container & chuỗi theo giá trị hiệu quả từ các phương thức.

Bây giờ, nơi mọi thứ trở nên thú vị với các tham chiếu rvalue, là bạn cũng có thể sử dụng chúng làm đối số cho các hàm bình thường. Điều này cho phép bạn viết các thùng chứa có quá tải cho cả tham chiếu const (const foo & khác) và tham chiếu giá trị (foo && khác). Ngay cả khi đối số quá khó sử dụng với lệnh gọi của hàm tạo đơn thuần, nó vẫn có thể được thực hiện:

std::vector vec;
for(int x=0; x<10; ++x)
{
    // automatically uses rvalue reference constructor if available
    // because MyCheapType is an unamed temporary variable
    vec.push_back(MyCheapType(0.f));
}


std::vector vec;
for(int x=0; x<10; ++x)
{
    MyExpensiveType temp(1.0, 3.0);
    temp.initSomeOtherFields(malloc(5000));

    // old way, passed via const reference, expensive copy
    vec.push_back(temp);

    // new way, passed via rvalue reference, cheap move
    // just don't use temp again,  not difficult in a loop like this though . . .
    vec.push_back(std::move(temp));
}

Các thùng chứa STL đã được cập nhật để có quá tải di chuyển cho hầu hết mọi thứ (khóa băm và giá trị, chèn vectơ, v.v.) và là nơi bạn sẽ thấy chúng nhiều nhất.

Bạn cũng có thể sử dụng chúng cho các hàm bình thường và nếu bạn chỉ cung cấp một đối số tham chiếu giá trị, bạn có thể buộc người gọi tạo đối tượng và để cho hàm thực hiện di chuyển. Đây là một ví dụ hơn là sử dụng thực sự tốt, nhưng trong thư viện kết xuất của tôi, tôi đã gán một chuỗi cho tất cả các tài nguyên được tải, để dễ dàng xem mỗi đối tượng thể hiện điều gì trong trình gỡ lỗi. Giao diện giống như thế này:

TextureHandle CreateTexture(int width, int height, ETextureFormat fmt, string&& friendlyName)
{
    std::unique_ptr<TextureObject> tex = D3DCreateTexture(width, height, fmt);
    tex->friendlyName = std::move(friendlyName);
    return tex;
}

Nó là một dạng 'trừu tượng bị rò rỉ' nhưng cho phép tôi tận dụng thực tế là tôi phải tạo ra chuỗi đã hầu hết thời gian và tránh tạo ra một bản sao khác của chuỗi. Đây không phải là mã hiệu suất cao chính xác nhưng là một ví dụ điển hình về khả năng khi mọi người hiểu rõ tính năng này. Mã này thực sự yêu cầu biến đó là tạm thời cho cuộc gọi hoặc std :: move được gọi:

// move from temporary
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, string("Checkerboard"));

hoặc là

// explicit move (not going to use the variable 'str' after the create call)
string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, std::move(str));

hoặc là

// explicitly make a copy and pass the temporary of the copy down
// since we need to use str again for some reason
string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, string(str));

nhưng điều này sẽ không được biên dịch!

string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, str);

3

Không phải là một câu trả lời cho mỗi se , mà là một hướng dẫn. Hầu hết thời gian không có nhiều ý nghĩa trong việc khai báo T&&biến cục bộ (như bạn đã làm với std::vector<int>&& rval_ref). Bạn vẫn sẽ std::move()phải sử dụng chúng trong foo(T&&)các phương thức kiểu. Cũng có một vấn đề đã được đề cập là khi bạn cố gắng trả lại như vậyrval_ref từ chức năng, bạn sẽ nhận được tham chiếu tiêu chuẩn-đến-hủy-tạm-fiasco.

Hầu hết thời gian tôi sẽ đi với mẫu sau:

// Declarations
A a(B&&, C&&);
B b();
C c();

auto ret = a(b(), c());

Bạn không giữ bất kỳ ref nào để trả về các đối tượng tạm thời, do đó bạn tránh được lỗi của người lập trình (thiếu kinh nghiệm) muốn sử dụng một đối tượng đã di chuyển.

auto bRet = b();
auto cRet = c();
auto aRet = a(std::move(b), std::move(c));

// Either these just fail (assert/exception), or you won't get 
// your expected results due to their clean state.
bRet.foo();
cRet.bar();

Rõ ràng có những trường hợp (mặc dù khá hiếm) trong đó một hàm thực sự trả về T&&một tham chiếu đến một đối tượng không tạm thời mà bạn có thể di chuyển vào đối tượng của mình.

Về RVO: các cơ chế này thường hoạt động và trình biên dịch có thể tránh sao chép một cách độc đáo, nhưng trong trường hợp đường dẫn trả lại không rõ ràng (ngoại lệ, các ifđiều kiện xác định đối tượng được đặt tên bạn sẽ trả về và có thể là một vài thứ khác) rrefs là cứu tinh của bạn (ngay cả khi có khả năng nhiều hơn đắt).


2

Không ai trong số họ sẽ làm bất kỳ sao chép thêm. Ngay cả khi RVO không được sử dụng, tiêu chuẩn mới nói rằng việc xây dựng di chuyển được ưu tiên sao chép khi thực hiện trả lại mà tôi tin.

Tôi tin rằng ví dụ thứ hai của bạn gây ra hành vi không xác định mặc dù vì bạn đang trả về một tham chiếu cho một biến cục bộ.


1

Như đã đề cập trong các bình luận cho câu trả lời đầu tiên, return std::move(...);cấu trúc có thể tạo ra sự khác biệt trong các trường hợp khác ngoài việc trả về các biến cục bộ. Đây là một ví dụ có thể chạy được, ghi lại những gì xảy ra khi bạn trả về một đối tượng thành viên có và không có std::move():

#include <iostream>
#include <utility>

struct A {
  A() = default;
  A(const A&) { std::cout << "A copied\n"; }
  A(A&&) { std::cout << "A moved\n"; }
};

class B {
  A a;
 public:
  operator A() const & { std::cout << "B C-value: "; return a; }
  operator A() & { std::cout << "B L-value: "; return a; }
  operator A() && { std::cout << "B R-value: "; return a; }
};

class C {
  A a;
 public:
  operator A() const & { std::cout << "C C-value: "; return std::move(a); }
  operator A() & { std::cout << "C L-value: "; return std::move(a); }
  operator A() && { std::cout << "C R-value: "; return std::move(a); }
};

int main() {
  // Non-constant L-values
  B b;
  C c;
  A{b};    // B L-value: A copied
  A{c};    // C L-value: A moved

  // R-values
  A{B{}};  // B R-value: A copied
  A{C{}};  // C R-value: A moved

  // Constant L-values
  const B bc;
  const C cc;
  A{bc};   // B C-value: A copied
  A{cc};   // C C-value: A copied

  return 0;
}

Có lẽ, return std::move(some_member);chỉ có ý nghĩa nếu bạn thực sự muốn di chuyển thành viên lớp cụ thể, ví dụ trong trường hợp class Cđại diện cho các đối tượng bộ điều hợp tồn tại ngắn với mục đích duy nhất là tạo các thể hiện củastruct A .

Lưu ý cách struct Aluôn được sao chép ra class B, ngay cả khi class Bđối tượng là giá trị R. Điều này là do trình biên dịch không có cách nào để nói rằng class Btrường hợp đó struct Asẽ không được sử dụng nữa. Trong class C, trình biên dịch có thông tin này từ std::move()đó, đó là lý do tại sao struct Ađược di chuyển , trừ khi thể hiện củaclass C hiện là không đổi.

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.