Tại sao chúng ta sao chép sau đó di chuyển?


98

Tôi đã thấy mã ở đâu đó trong đó ai đó quyết định sao chép một đối tượng và sau đó di chuyển nó đến thành viên dữ liệu của một lớp. Điều này khiến tôi bối rối ở chỗ tôi nghĩ toàn bộ điểm cần di chuyển là tránh sao chép. Đây là ví dụ:

struct S
{
    S(std::string str) : data(std::move(str))
    {}
};

Đây là những câu hỏi của tôi:

  • Tại sao chúng ta không tham chiếu đến rvalue str?
  • Một bản sao sẽ không đắt, đặc biệt là được cung cấp một cái gì đó như thế std::string?
  • Đâu sẽ là lý do để tác giả quyết định thực hiện một bản sao rồi chuyển đi?
  • Khi nào tôi nên tự làm điều này?

Tôi có vẻ là một sai lầm ngớ ngẩn, nhưng tôi sẽ quan tâm xem liệu ai đó có nhiều kiến ​​thức hơn về chủ đề này có nói gì về nó không.
Dave


Câu hỏi & Đáp này ban đầu tôi quên liên kết cũng có thể liên quan đến chủ đề.
Andy Prowl

Câu trả lời:


97

Trước khi tôi trả lời câu hỏi của bạn, có một điều bạn có vẻ đã sai: lấy theo giá trị trong C ++ 11 không phải lúc nào cũng có nghĩa là sao chép. Nếu một giá trị được thông qua, giá trị đó sẽ được di chuyển (miễn là tồn tại một phương thức khởi tạo di chuyển khả thi) chứ không phải được sao chép. Và std::stringcó một hàm tạo chuyển động.

Không giống như trong C ++ 03, trong C ++ 11, việc lấy các tham số theo giá trị là một điều thành ngữ, vì những lý do tôi sẽ giải thích bên dưới. Ngoài ra, hãy xem phần Hỏi & Đáp này trên StackOverflow để biết bộ nguyên tắc chung hơn về cách chấp nhận các tham số.

Tại sao chúng ta không tham chiếu đến rvalue str?

Bởi vì điều đó sẽ làm cho nó không thể vượt qua các giá trị, chẳng hạn như trong:

std::string s = "Hello";
S obj(s); // s is an lvalue, this won't compile!

Nếu Schỉ có một hàm tạo chấp nhận các giá trị, thì ở trên sẽ không biên dịch.

Một bản sao sẽ không đắt, đặc biệt là được cung cấp một cái gì đó như thế std::string?

Nếu bạn vượt qua một rvalue, giá trị đó sẽ được chuyển vào strvà cuối cùng sẽ được chuyển vào data. Không sao chép sẽ được thực hiện. Mặt khác, nếu bạn vượt qua một giá trị, giá trị đó sẽ được sao chép vào strvà sau đó được chuyển vào data.

Vì vậy, tóm lại, hai lần di chuyển cho các giá trị, một bản sao và một lần di chuyển cho các giá trị.

Đâu sẽ là lý do để tác giả quyết định thực hiện một bản sao rồi chuyển đi?

Trước hết, như tôi đã đề cập ở trên, cái đầu tiên không phải lúc nào cũng là một bản sao; và điều này cho thấy, câu trả lời là: " Bởi vì nó hiệu quả (chuyển động của std::stringcác đối tượng là rẻ) và đơn giản ".

Theo giả định rằng các bước di chuyển là rẻ (bỏ qua SSO ở đây), chúng thực tế có thể bị bỏ qua khi xem xét hiệu quả tổng thể của thiết kế này. Nếu chúng tôi làm như vậy, chúng tôi có một bản sao cho các giá trị (như chúng tôi sẽ có nếu chúng tôi chấp nhận một tham chiếu giá trị đến const) và không có bản sao cho các giá trị (trong khi chúng tôi vẫn có một bản sao nếu chúng tôi chấp nhận một tham chiếu giá trị const).

Điều này có nghĩa là việc lấy theo giá trị cũng tốt như việc lấy theo tham chiếu giá trị về constthời điểm cung cấp giá trị và tốt hơn khi giá trị được cung cấp.

Tái bút: Để cung cấp một số ngữ cảnh, tôi tin rằng đây là Câu hỏi & Đáp mà OP đang đề cập đến.


2
Đáng nói hơn, đó là một mẫu C ++ 11 thay thế const T&việc truyền đối số: trong trường hợp xấu nhất (giá trị) thì điều này cũng giống như vậy, nhưng trong trường hợp tạm thời, bạn chỉ phải di chuyển tạm thời. Đôi bên cùng có lợi.
syam

3
@ user2030677: Không có bản sao đó, trừ khi bạn đang lưu trữ một tham chiếu.
Benjamin Lindley

5
@ user2030677: Ai quan tâm bản sao đắt như thế nào miễn là bạn cần nó (và bạn có, nếu bạn muốn giữ một bản sao trong datathành viên của mình )? Bạn sẽ có một bản sao ngay cả khi bạn lấy bằng tham chiếu giá trị đếnconst
Andy Prowl

3
@BenjaminLindley: Về cơ bản, tôi đã viết: " Theo giả định rằng các thiết bị di chuyển là rẻ, chúng thực tế có thể bị bỏ qua khi xem xét hiệu quả tổng thể của thiết kế này. ". Vì vậy, có, sẽ có chi phí của một động thái, nhưng điều đó nên được coi là không đáng kể trừ khi có bằng chứng rằng đây là mối quan tâm thực sự có thể biện minh cho việc thay đổi một thiết kế đơn giản thành một thứ gì đó hiệu quả hơn.
Andy Prowl

1
@ user2030677: Nhưng đó là một ví dụ hoàn toàn khác. Trong ví dụ từ câu hỏi của bạn, bạn luôn luôn giữ một bản sao data!
Andy Prowl

51

Để hiểu tại sao đây là một mô hình tốt, chúng ta nên kiểm tra các lựa chọn thay thế, cả trong C ++ 03 và C ++ 11.

Chúng tôi có phương pháp C ++ 03 để lấy std::string const&:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str)
  {}
};

trong trường hợp này, sẽ luôn có một bản sao được thực hiện. Nếu bạn xây dựng từ một chuỗi C thô, a std::stringsẽ được xây dựng, sau đó được sao chép lại: hai phân bổ.

Có phương thức C ++ 03 lấy tham chiếu đến a std::string, sau đó hoán đổi nó thành cục bộ std::string:

struct S
{
  std::string data; 
  S(std::string& str)
  {
    std::swap(data, str);
  }
};

đó là phiên bản C ++ 03 của "ngữ nghĩa chuyển động", và swapthường có thể được tối ưu hóa để thực hiện rất rẻ (giống như a move). Nó cũng nên được phân tích trong ngữ cảnh:

S tmp("foo"); // illegal
std::string s("foo");
S tmp2(s); // legal

và buộc bạn phải hình thành một không tạm thời std::string, sau đó loại bỏ nó. (Một tạm thời std::stringkhông thể liên kết với một tham chiếu không phải const). Tuy nhiên, chỉ có một phân bổ được thực hiện. Phiên bản C ++ 11 sẽ sử dụng một &&và yêu cầu bạn gọi nó bằng std::movehoặc với một tạm thời: điều này yêu cầu người gọi tạo một bản sao rõ ràng bên ngoài lệnh gọi và chuyển bản sao đó vào hàm hoặc hàm tạo.

struct S
{
  std::string data; 
  S(std::string&& str): data(std::move(str))
  {}
};

Sử dụng:

S tmp("foo"); // legal
std::string s("foo");
S tmp2(std::move(s)); // legal

Tiếp theo, chúng ta có thể tạo phiên bản C ++ 11 đầy đủ, hỗ trợ cả sao chép và move:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str) {} // lvalue const, copy
  S(std::string && str) : data(std::move(str)) {} // rvalue, move
};

Sau đó, chúng ta có thể kiểm tra cách thức này được sử dụng:

S tmp( "foo" ); // a temporary `std::string` is created, then moved into tmp.data

std::string bar("bar"); // bar is created
S tmp2( bar ); // bar is copied into tmp.data

std::string bar2("bar2"); // bar2 is created
S tmp3( std::move(bar2) ); // bar2 is moved into tmp.data

Rõ ràng là 2 kỹ thuật quá tải này ít nhất cũng hiệu quả, nếu không muốn nói là hơn hai kiểu C ++ 03 ở trên. Tôi sẽ gọi phiên bản quá tải 2 này là phiên bản "tối ưu nhất".

Bây giờ, chúng ta sẽ kiểm tra phiên bản từng bản sao:

struct S2 {
  std::string data;
  S2( std::string arg ):data(std::move(x)) {}
};

trong mỗi tình huống đó:

S2 tmp( "foo" ); // a temporary `std::string` is created, moved into arg, then moved into S2::data

std::string bar("bar"); // bar is created
S2 tmp2( bar ); // bar is copied into arg, then moved into S2::data

std::string bar2("bar2"); // bar2 is created
S2 tmp3( std::move(bar2) ); // bar2 is moved into arg, then moved into S2::data

Nếu bạn so sánh song song phiên bản này với phiên bản "tối ưu nhất", chúng tôi bổ sung chính xác một lần move! Không phải một lần chúng tôi làm thêm copy.

Vì vậy, nếu chúng ta giả định rằng đó movelà giá rẻ, phiên bản này mang lại cho chúng ta hiệu suất gần tương đương với phiên bản tối ưu nhất, nhưng ít mã hơn 2 lần.

Và nếu bạn đang sử dụng giả sử từ 2 đến 10 đối số, thì việc giảm mã theo cấp số nhân - giảm 2 lần với 1 đối số, 4x với 2, 8x với 3, 16x với 4, 1024x với 10 đối số.

Bây giờ, chúng ta có thể giải quyết vấn đề này thông qua chuyển tiếp hoàn hảo và SFINAE, cho phép bạn viết một hàm tạo hoặc mẫu hàm duy nhất có 10 đối số, SFINAE có đảm bảo rằng các đối số thuộc loại phù hợp, rồi di chuyển-hoặc sao chép chúng vào trạng thái địa phương theo yêu cầu. Mặc dù điều này ngăn cản vấn đề kích thước chương trình tăng lên hàng nghìn lần, nhưng vẫn có thể có cả đống hàm được tạo từ mẫu này. (khởi tạo hàm mẫu tạo ra các hàm)

Và nhiều hàm được tạo ra có nghĩa là kích thước mã thực thi lớn hơn, chính nó có thể làm giảm hiệu suất.

Đối với chi phí của một vài movegiây, chúng tôi nhận được mã ngắn hơn và hiệu suất gần như giống nhau và mã thường dễ hiểu hơn.

Bây giờ, điều này chỉ hoạt động vì chúng ta biết, khi hàm (trong trường hợp này là một hàm tạo) được gọi, chúng ta sẽ muốn một bản sao cục bộ của đối số đó. Ý tưởng là nếu chúng ta biết rằng chúng ta sẽ tạo một bản sao, chúng ta nên cho người gọi biết rằng chúng ta đang tạo một bản sao bằng cách đưa nó vào danh sách đối số của chúng ta. Sau đó, họ có thể tối ưu hóa thực tế là họ sẽ cung cấp cho chúng ta một bản sao (ví dụ: bằng cách chuyển sang đối số của chúng ta).

Một ưu điểm khác của kỹ thuật 'lấy theo giá trị' là các hàm tạo di chuyển thường không được chấp nhận. Điều đó có nghĩa là các hàm nhận theo giá trị và di chuyển ra khỏi đối số của chúng thường có thể không được chấp nhận, di chuyển bất kỳ hàm nào throwra khỏi phần thân của chúng và vào phạm vi gọi (đôi khi ai có thể tránh nó bằng cách xây dựng trực tiếp, hoặc xây dựng các mục và movevào đối số, để kiểm soát nơi xảy ra ném).


Tôi cũng sẽ nói thêm nếu chúng ta biết rằng chúng ta sẽ tạo một bản sao, chúng ta nên để trình biên dịch làm điều đó, vì trình biên dịch luôn biết rõ hơn.
Rayniery

6
Kể từ khi tôi viết bài này, một lợi thế khác đã được chỉ ra cho tôi: thường thì các hàm tạo sao chép có thể ném, trong khi các hàm tạo di chuyển thì thường noexcept. Bằng cách lấy dữ liệu từng bản sao, bạn có thể tạo ra hàm của mình noexceptvà có bất kỳ cấu trúc sao chép nào gây ra các lỗi tiềm ẩn (như hết bộ nhớ) xảy ra bên ngoài lệnh gọi hàm của bạn.
Yakk - Adam Nevraumont

Tại sao bạn cần phiên bản "lvalue non-const, copy" trong kỹ thuật quá tải 3? Không phải "lvalue const, copy" cũng xử lý trường hợp không phải const?
Bruno Martinez

@BrunoMartinez chúng tôi không!
Yakk - Adam Nevraumont

13

Điều này có lẽ là cố ý và tương tự như thành ngữ sao chép và hoán đổi . Về cơ bản vì chuỗi được sao chép trước hàm tạo nên bản thân hàm tạo là ngoại lệ an toàn vì nó chỉ hoán đổi (di chuyển) chuỗi tạm thời str.


+1 cho song song sao chép và hoán đổi. Quả thực nó có rất nhiều điểm giống nhau.
syam

11

Bạn không muốn lặp lại chính mình bằng cách viết một hàm tạo cho việc di chuyển và một cho bản sao:

S(std::string&& str) : data(std::move(str)) {}
S(const std::string& str) : data(str) {}

Đây là mã soạn sẵn nhiều, đặc biệt nếu bạn có nhiều đối số. Giải pháp của bạn tránh được sự trùng lặp đó với chi phí của một lần di chuyển không cần thiết. (Tuy nhiên, hoạt động di chuyển sẽ khá rẻ.)

Thành ngữ cạnh tranh là sử dụng chuyển tiếp hoàn hảo:

template <typename T>
S(T&& str) : data(std::forward<T>(str)) {}

Phép thuật mẫu sẽ chọn di chuyển hoặc sao chép tùy thuộc vào tham số mà bạn truyền vào. Về cơ bản, nó mở rộng đến phiên bản đầu tiên, nơi cả hai hàm tạo đều được viết bằng tay. Để biết thông tin cơ bản, hãy xem bài đăng của Scott Meyer về tài liệu tham khảo phổ quát .

Từ khía cạnh hiệu suất, phiên bản chuyển tiếp hoàn hảo vượt trội hơn phiên bản của bạn vì nó tránh được các động thái không cần thiết. Tuy nhiên, người ta có thể lập luận rằng phiên bản của bạn dễ đọc và dễ viết hơn. Dù sao thì tác động hiệu suất có thể xảy ra không phải là vấn đề trong hầu hết các tình huống, vì vậy cuối cùng nó có vẻ là vấn đề về phong cách.

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.