Động lực và sử dụng các hàm tạo di chuyển trong C ++


17

Gần đây tôi đã đọc về các hàm tạo di chuyển trong C ++ (xem ví dụ ở đây ) và tôi đang cố gắng hiểu cách chúng hoạt động và khi nào tôi nên sử dụng chúng.

Theo tôi hiểu, một hàm tạo di chuyển được sử dụng để giảm bớt các vấn đề về hiệu năng gây ra bằng cách sao chép các đối tượng lớn. Trang wikipedia cho biết: "Một vấn đề hiệu suất kinh niên với C ++ 03 là các bản sao sâu tốn kém và không cần thiết có thể xảy ra ngầm khi các đối tượng được truyền theo giá trị."

Tôi thường giải quyết các tình huống như vậy

  • bằng cách chuyển các đối tượng bằng cách tham chiếu, hoặc
  • bằng cách sử dụng các con trỏ thông minh (ví dụ boost :: shared_ptr) để truyền xung quanh đối tượng (các con trỏ thông minh được sao chép thay vì đối tượng).

Các tình huống trong đó hai kỹ thuật trên là không đủ và sử dụng một hàm tạo di chuyển là thuận tiện hơn?


1
Bên cạnh thực tế là ngữ nghĩa di chuyển có thể đạt được nhiều hơn (như đã nói trong câu trả lời), bạn không nên hỏi những tình huống mà việc chuyển qua tham chiếu hoặc bằng con trỏ thông minh là không đủ, nhưng nếu những kỹ thuật đó thực sự là cách tốt nhất và sạch nhất để làm như vậy (thần hãy cẩn thận shared_ptrchỉ vì mục đích sao chép nhanh) và nếu ngữ nghĩa di chuyển có thể đạt được điều tương tự mà gần như không có mã hóa, ngữ nghĩa- và sự sạch sẽ-hình phạt.
Chris nói Phục hồi lại

Câu trả lời:


16

Di chuyển ngữ nghĩa giới thiệu toàn bộ chiều cho C ++ - nó không chỉ ở đó để cho phép bạn trả lại giá trị với giá rẻ.

Ví dụ, không có ngữ nghĩa di chuyển std::unique_ptrkhông hoạt động - hãy nhìn vào std::auto_ptr, điều này không được chấp nhận khi giới thiệu ngữ nghĩa di chuyển và bị loại bỏ trong C ++ 17. Di chuyển một nguồn tài nguyên rất khác với việc sao chép nó. Nó cho phép chuyển quyền sở hữu của một mặt hàng độc đáo.

Ví dụ, chúng ta đừng nhìn vào std::unique_ptr, vì nó được thảo luận khá tốt. Hãy nhìn vào, giả sử, một đối tượng đệm của Vertex trong OpenGL. Một bộ đệm đỉnh biểu thị bộ nhớ trên GPU - nó cần được phân bổ và phân bổ bằng các chức năng đặc biệt, có thể có các ràng buộc chặt chẽ về thời gian tồn tại của nó. Điều quan trọng là chỉ có một chủ sở hữu sử dụng nó.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Bây giờ, điều này có thể được thực hiện với một std::shared_ptr- nhưng tài nguyên này không được chia sẻ. Điều này làm cho nó khó hiểu khi sử dụng một con trỏ chia sẻ. Bạn có thể sử dụng std::unique_ptr, nhưng điều đó vẫn đòi hỏi ngữ nghĩa di chuyển.

Rõ ràng, tôi đã không triển khai một hàm tạo di chuyển, nhưng bạn hiểu ý.

Điều liên quan ở đây là một số tài nguyên không thể sao chép . Bạn có thể vượt qua các con trỏ thay vì di chuyển, nhưng trừ khi bạn sử dụng unique_ptr, đó là vấn đề về quyền sở hữu. Điều đáng giá là càng rõ ràng càng tốt về mục đích của mã là gì, vì vậy một hàm tạo di chuyển có lẽ là cách tiếp cận tốt nhất.


Cảm ơn câu trả lời. Điều gì sẽ xảy ra nếu một người sử dụng một con trỏ chia sẻ ở đây?
Giorgio

Tôi cố gắng tự trả lời: sử dụng một con trỏ dùng chung sẽ không cho phép kiểm soát thời gian tồn tại của đối tượng, trong khi yêu cầu đối tượng chỉ có thể sống trong một khoảng thời gian nhất định.
Giorgio

3
@Giorgio Bạn có thể sử dụng một con trỏ dùng chung, nhưng nó sẽ sai về mặt ngữ nghĩa. Không thể chia sẻ bộ đệm. Ngoài ra, điều đó về cơ bản sẽ khiến bạn chuyển một con trỏ đến một con trỏ (vì vbo về cơ bản là một con trỏ duy nhất cho bộ nhớ GPU). Ai đó xem mã của bạn sau này có thể tự hỏi 'Tại sao có một con trỏ được chia sẻ ở đây? Nó có phải là một tài nguyên được chia sẻ? Đó có thể là một lỗi! '. Tốt hơn là nên rõ ràng nhất có thể như ý định ban đầu là gì.
Tối đa

@Giorgio Vâng, đó cũng là một phần của yêu cầu. Khi 'trình kết xuất' trong trường hợp này muốn phân bổ một số tài nguyên (có thể không đủ bộ nhớ cho các đối tượng mới trên GPU), không được có bất kỳ xử lý nào khác cho bộ nhớ. Sử dụng shared_ptr vượt ra ngoài phạm vi sẽ hoạt động nếu bạn không giữ nó ở bất cứ nơi nào khác, nhưng tại sao không làm cho nó hoàn toàn rõ ràng khi bạn có thể?
Tối đa

@Giorgio Xem bản chỉnh sửa của tôi để thử làm rõ.
Tối đa

5

Di chuyển ngữ nghĩa không nhất thiết phải là một cải tiến lớn khi bạn trả về một giá trị - và khi / nếu bạn sử dụng shared_ptr(hoặc một cái gì đó tương tự) có lẽ bạn sẽ sớm bi quan. Trong thực tế, gần như tất cả các trình biên dịch hiện đại hợp lý thực hiện những gì được gọi là Tối ưu hóa giá trị trả về (RVO) và Tối ưu hóa giá trị trả về được đặt tên (NRVO). Điều này có nghĩa rằng khi bạn đang trả lại một giá trị, thay vì thực sự sao chép các giá trị ở tất cả, họ chỉ đơn giản chuyển một con trỏ / tham chiếu ẩn đến nơi giá trị sẽ được chỉ định sau khi trả về và hàm sử dụng giá trị đó để tạo giá trị nơi nó sẽ kết thúc. Tiêu chuẩn C ++ bao gồm các quy định đặc biệt để cho phép điều này, vì vậy ngay cả khi (ví dụ), trình tạo bản sao của bạn có tác dụng phụ có thể nhìn thấy, không bắt buộc phải sử dụng hàm tạo sao chép để trả về giá trị. Ví dụ:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

Ý tưởng cơ bản ở đây khá đơn giản: tạo một lớp có đủ nội dung, chúng tôi muốn tránh sao chép nó, nếu có thể ( std::vectorchúng tôi điền vào 32767 ints ngẫu nhiên). Chúng tôi có một ctor sao chép rõ ràng sẽ hiển thị cho chúng tôi khi / nếu nó được sao chép. Chúng tôi cũng có thêm một ít mã để làm một cái gì đó với các giá trị ngẫu nhiên trong đối tượng, vì vậy trình tối ưu hóa sẽ không (ít nhất là dễ dàng) loại bỏ mọi thứ về lớp chỉ vì nó không làm gì cả.

Sau đó chúng ta có một số mã để trả về một trong các đối tượng này từ một hàm và sau đó sử dụng tính tổng để đảm bảo đối tượng thực sự được tạo, không chỉ bị bỏ qua hoàn toàn. Khi chúng tôi chạy nó, ít nhất là với hầu hết các trình biên dịch hiện đại / gần đây, chúng tôi thấy rằng trình tạo bản sao mà chúng tôi đã viết không bao giờ chạy cả - và vâng, tôi khá chắc chắn rằng ngay cả một bản sao nhanh với một bản sao shared_ptrvẫn chậm hơn so với việc không sao chép ở tất cả.

Di chuyển cho phép bạn thực hiện một số lượng lớn những việc bạn không thể làm (trực tiếp) mà không có chúng. Hãy xem xét phần "hợp nhất" của một loại hợp nhất bên ngoài - bạn có 8 tệp bạn sẽ hợp nhất với nhau. Lý tưởng nhất là bạn muốn đặt tất cả 8 tệp đó vào một vector- nhưng vì vector( kể từ C ++ 03) cần có thể sao chép các phần tử và ifstreamkhông thể sao chép, bạn bị mắc kẹt với một số unique_ptr/ shared_ptr, hoặc một cái gì đó theo thứ tự đó để có thể đặt chúng vào một vectơ. Lưu ý rằng ngay cả khi (ví dụ) chúng tôi reservekhông gian trong vectorđó vì vậy chúng tôi chắc chắn rằng chúng tôi ifstreamsẽ không bao giờ thực sự bị sao chép, trình biên dịch sẽ không biết điều đó, vì vậy mã sẽ không được biên dịch mặc dù chúng tôi biết rằng hàm tạo sao chép sẽ không bao giờ sử dụng nào.

Mặc dù nó vẫn không thể được sao chép, trong C ++ 11 ifstream có thể được di chuyển. Trong trường hợp này, các đối tượng có thể sẽ không bao giờ bị di chuyển, nhưng thực tế là chúng có thể nếu trình biên dịch hài lòng, vì vậy chúng ta có thể đặt các ifstreamđối tượng của mình vectortrực tiếp mà không cần hack con trỏ thông minh.

Một vectơ không mở rộng là một ví dụ khá hay về thời gian di chuyển ngữ nghĩa thực sự có thể / rất hữu ích. Trong trường hợp này, RVO / NRVO sẽ không giúp đỡ, vì chúng tôi không xử lý giá trị trả về từ một hàm (hoặc bất cứ thứ gì tương tự). Chúng ta có một vector giữ một số đối tượng và chúng ta muốn di chuyển các đối tượng đó vào một khối bộ nhớ mới, lớn hơn.

Trong C ++ 03, điều đó đã được thực hiện bằng cách tạo các bản sao của các đối tượng trong bộ nhớ mới, sau đó phá hủy các đối tượng cũ trong bộ nhớ cũ. Tuy nhiên, việc tạo ra tất cả những bản sao đó chỉ để vứt bỏ những bản cũ là khá lãng phí thời gian. Trong C ++ 11, bạn có thể mong đợi chúng sẽ được di chuyển thay thế. Điều này thường cho phép chúng ta, về bản chất, thực hiện một bản sao nông thay vì một bản sao sâu (nói chung là chậm hơn nhiều). Nói cách khác, với một chuỗi hoặc vectơ (chỉ một vài ví dụ), chúng ta chỉ sao chép (các) con trỏ trong các đối tượng, thay vì tạo các bản sao của tất cả dữ liệu mà các con trỏ đề cập đến.


Cảm ơn đã giải thích rất chi tiết. Nếu tôi hiểu chính xác, tất cả các tình huống trong đó di chuyển có thể được xử lý bởi các con trỏ bình thường, nhưng sẽ không an toàn (dễ bị lỗi và phức tạp) để lập trình tất cả các con trỏ tung hứng mỗi lần. Vì vậy, thay vào đó, có một số unique_ptr (hoặc cơ chế tương tự) dưới mui xe và ngữ nghĩa di chuyển đảm bảo rằng vào cuối ngày chỉ có một số sao chép con trỏ và không sao chép đối tượng.
Giorgio

@Giorgio: Vâng, điều đó khá đúng. Ngôn ngữ không thực sự thêm ngữ nghĩa di chuyển; nó thêm tài liệu tham khảo giá trị. Một tham chiếu giá trị (rõ ràng là đủ) có thể liên kết với một giá trị, trong trường hợp đó bạn biết rằng nó an toàn để "đánh cắp" biểu diễn bên trong của dữ liệu và chỉ sao chép con trỏ của nó thay vì sao chép sâu.
Jerry Coffin

4

Xem xét:

vector<string> v;

Khi thêm chuỗi vào v, nó sẽ mở rộng khi cần và trên mỗi lần tái phân bổ, chuỗi sẽ phải được sao chép. Với các nhà xây dựng di chuyển, điều này về cơ bản là không có vấn đề.

Tất nhiên, bạn cũng có thể làm một cái gì đó như:

vector<unique_ptr<string>> v;

Nhưng điều đó sẽ làm việc tốt chỉ bởi vì std::unique_ptrthực hiện di chuyển xây dựng.

Việc sử dụng std::shared_ptrchỉ có ý nghĩa trong các tình huống (hiếm) khi bạn thực sự có quyền sở hữu chung.


Nhưng nếu thay vì stringchúng ta có một ví dụ về Foonơi nó có 30 thành viên dữ liệu thì sao? Các unique_ptrphiên bản sẽ không có hiệu quả hơn?
Vassilis

2

Giá trị trả về là nơi tôi thường muốn chuyển theo giá trị thay vì một loại tham chiếu nào đó. Có thể nhanh chóng trả lại một đối tượng 'trên ngăn xếp' mà không bị phạt hiệu suất lớn sẽ rất tuyệt. Mặt khác, không khó để khắc phục điều này (các con trỏ được chia sẻ rất dễ sử dụng ...), vì vậy tôi không chắc rằng nó thực sự đáng để làm thêm trên các đối tượng của mình chỉ để có thể làm điều này.


Tôi cũng thường sử dụng các con trỏ thông minh để bọc các đối tượng được trả về từ một hàm / phương thức.
Giorgio

1
@Giorgio: Điều đó chắc chắn vừa khó hiểu vừa chậm chạp.
DeadMG

Trình biên dịch hiện đại sẽ thực hiện di chuyển tự động nếu bạn trả về một đối tượng đơn giản, vì vậy không cần chia sẻ ptrs, v.v.
Christian Severin
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.