Hãy để tôi thử nêu các chế độ khả thi khác nhau của việc truyền con trỏ xung quanh tới các đối tượng có bộ nhớ được quản lý bởi một thể hiện của std::unique_ptr
mẫu lớp; nó cũng áp dụng cho std::auto_ptr
khuôn mẫu lớp cũ hơn (mà tôi tin là cho phép tất cả sử dụng con trỏ duy nhất đó, nhưng ngoài ra, giá trị có thể sửa đổi sẽ được chấp nhận khi giá trị được dự kiến, mà không phải gọi std::move
), và trong một chừng mực nào đó std::shared_ptr
.
Để làm ví dụ cụ thể cho cuộc thảo luận, tôi sẽ xem xét loại danh sách đơn giản sau đây
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
Các trường hợp của danh sách đó (không thể được phép chia sẻ các phần với các thể hiện khác hoặc là hình tròn) hoàn toàn thuộc sở hữu của bất kỳ ai giữ list
con trỏ ban đầu . Nếu mã khách hàng biết rằng danh sách mà nó lưu trữ sẽ không bao giờ trống, nó cũng có thể chọn lưu trữ node
trực tiếp đầu tiên thay vì a list
. Không có hàm hủy nào node
cần được xác định: vì các hàm hủy cho các trường của nó được gọi tự động, toàn bộ danh sách sẽ bị xóa theo cách đệ quy bởi hàm hủy con trỏ thông minh sau khi vòng đời của con trỏ hoặc nút ban đầu kết thúc.
Kiểu đệ quy này tạo cơ hội để thảo luận về một số trường hợp ít nhìn thấy hơn trong trường hợp con trỏ thông minh đến dữ liệu đơn giản. Ngoài ra, các hàm đôi khi cũng cung cấp (đệ quy) một ví dụ về mã máy khách. Typedef cho list
tất nhiên là thiên về hướng unique_ptr
, nhưng định nghĩa có thể được thay đổi để sử dụng auto_ptr
hoặc shared_ptr
thay vào đó mà không cần phải thay đổi nhiều so với những gì được nói dưới đây (đáng chú ý là an toàn ngoại lệ được đảm bảo mà không cần phải viết hàm hủy).
Các chế độ chuyển con trỏ thông minh xung quanh
Chế độ 0: truyền con trỏ hoặc đối số tham chiếu thay vì con trỏ thông minh
Nếu chức năng của bạn không liên quan đến quyền sở hữu, thì đây là phương pháp ưa thích: đừng biến nó thành một con trỏ thông minh. Trong trường hợp này, chức năng của bạn không cần phải lo lắng ai sở hữu đối tượng được chỉ đến, hoặc điều đó có nghĩa là quyền sở hữu được quản lý, do đó, việc chuyển một con trỏ thô vừa an toàn, vừa là hình thức linh hoạt nhất, vì bất kể khách hàng luôn có thể sở hữu tạo ra một con trỏ thô (bằng cách gọi get
phương thức hoặc từ địa chỉ của toán tử &
).
Chẳng hạn, hàm tính toán độ dài của danh sách đó, không nên đưa ra một list
đối số, mà là một con trỏ thô:
size_t length(const node* p)
{ size_t l=0; for ( ; p!=nullptr; p=p->next.get()) ++l; return l; }
Một máy khách chứa một biến list head
có thể gọi hàm này là length(head.get())
, trong khi một máy khách được chọn thay thế để lưu trữ một node n
danh sách đại diện không trống có thể gọi length(&n)
.
Nếu con trỏ được đảm bảo là không null (không phải là trường hợp ở đây vì danh sách có thể trống), người ta có thể thích truyền tham chiếu hơn là con trỏ. Nó có thể là một con trỏ / tham chiếu đến non- const
nếu hàm cần cập nhật nội dung của (các) nút, mà không cần thêm hoặc xóa bất kỳ cái nào trong số chúng (cái sau sẽ liên quan đến quyền sở hữu).
Một trường hợp thú vị nằm trong danh mục chế độ 0 là tạo một bản sao (sâu) của danh sách; trong khi một chức năng làm điều này tất nhiên phải chuyển quyền sở hữu bản sao mà nó tạo ra, nó không liên quan đến quyền sở hữu của danh sách mà nó đang sao chép. Vì vậy, nó có thể được định nghĩa như sau:
list copy(const node* p)
{ return list( p==nullptr ? nullptr : new node{p->entry,copy(p->next.get())} ); }
Mã này đáng để xem xét kỹ, cả về câu hỏi tại sao nó biên dịch hoàn toàn (kết quả của lệnh gọi đệ quy copy
trong danh sách trình khởi tạo liên kết với đối số tham chiếu rvalue trong hàm tạo di chuyển của unique_ptr<node>
, hay list
, khi khởi tạo next
trường của được tạo ra node
) và cho câu hỏi tại sao nó an toàn ngoại lệ (nếu trong quá trình phân bổ đệ quy hết bộ nhớ và một số lệnh new
ném std::bad_alloc
, thì tại thời điểm đó, một con trỏ tới danh sách được xây dựng một phần được ẩn danh theo kiểu tạm thời list
được tạo cho danh sách khởi tạo và hàm hủy của nó sẽ dọn sạch danh sách một phần đó). Bằng cách này, người ta nên chống lại sự cám dỗ để thay thế (như tôi ban đầu đã làm) lần thứ hainullptr
bằngp
, mà sau tất cả được biết là null tại thời điểm đó: người ta không thể xây dựng một con trỏ thông minh từ một con trỏ (thô) thành hằng số , ngay cả khi nó được biết là null.
Chế độ 1: vượt qua một con trỏ thông minh theo giá trị
Hàm lấy giá trị con trỏ thông minh làm đối số chiếm hữu đối tượng được trỏ ngay lập tức: con trỏ thông minh mà người gọi giữ (cho dù là biến có tên hoặc tạm thời ẩn danh) được sao chép vào giá trị đối số ở lối vào hàm và trình gọi con trỏ đã trở thành null (trong trường hợp tạm thời, bản sao có thể đã bị xóa, nhưng trong mọi trường hợp, người gọi đã mất quyền truy cập vào đối tượng được trỏ đến). Tôi muốn gọi chế độ này bằng tiền mặt : người gọi trả trước cho dịch vụ được gọi và không thể ảo tưởng về quyền sở hữu sau cuộc gọi. Để làm rõ điều này, các quy tắc ngôn ngữ yêu cầu người gọi bọc đối số trongstd::move
nếu con trỏ thông minh được giữ trong một biến (về mặt kỹ thuật, nếu đối số là giá trị); trong trường hợp này (nhưng không phải cho chế độ 3 bên dưới), hàm này thực hiện những gì tên của nó gợi ý, cụ thể là chuyển giá trị từ biến sang tạm thời, để lại biến null.
Đối với các trường hợp hàm được gọi vô điều kiện sở hữu (điều khiển) đối tượng trỏ tới, chế độ này được sử dụng với std::unique_ptr
hoặc std::auto_ptr
là một cách tốt để chuyển một con trỏ cùng với quyền sở hữu của nó, tránh mọi nguy cơ rò rỉ bộ nhớ. Tuy nhiên, tôi nghĩ rằng chỉ có rất ít tình huống trong đó chế độ 3 dưới đây không được ưu tiên (bao giờ hơi quá) so với chế độ 1. Vì lý do này, tôi sẽ không cung cấp ví dụ sử dụng nào cho chế độ này. (Nhưng hãy xem reversed
ví dụ về chế độ 3 bên dưới, trong đó nhận xét rằng chế độ 1 cũng sẽ làm ít nhất.) Nếu hàm có nhiều đối số hơn chỉ con trỏ này, có thể có thêm lý do kỹ thuật để tránh chế độ 1 (với std::unique_ptr
hoặc std::auto_ptr
): do một hoạt động di chuyển thực tế diễn ra trong khi truyền một biến con trỏp
bằng biểu thức std::move(p)
, không thể giả định rằngp
giữ một giá trị hữu ích trong khi đánh giá các đối số khác (thứ tự đánh giá không được chỉ định), điều này có thể dẫn đến các lỗi tinh vi; ngược lại, sử dụng chế độ 3 đảm bảo rằng không có sự di chuyển nào p
diễn ra trước khi gọi hàm, vì vậy các đối số khác có thể truy cập một cách an toàn một giá trị thông qua p
.
Khi được sử dụng std::shared_ptr
, chế độ này thú vị ở chỗ với một định nghĩa hàm duy nhất, nó cho phép người gọi chọn giữ bản sao chia sẻ của con trỏ trong khi tạo một bản sao chia sẻ mới được sử dụng bởi hàm (điều này xảy ra khi một giá trị đối số được cung cấp, hàm tạo sao chép cho các con trỏ dùng chung được sử dụng trong cuộc gọi làm tăng số tham chiếu) hoặc chỉ cung cấp cho hàm một bản sao của con trỏ mà không giữ lại một hoặc chạm vào số tham chiếu (điều này xảy ra khi có thể cung cấp đối số giá trị một giá trị được bọc trong một cuộc gọi của std::move
). Ví dụ
void f(std::shared_ptr<X> x) // call by shared cash
{ container.insert(std::move(x)); } // store shared pointer in container
void client()
{ std::shared_ptr<X> p = std::make_shared<X>(args);
f(p); // lvalue argument; store pointer in container but keep a copy
f(std::make_shared<X>(args)); // prvalue argument; fresh pointer is just stored away
f(std::move(p)); // xvalue argument; p is transferred to container and left null
}
Điều tương tự có thể đạt được bằng cách định nghĩa riêng void f(const std::shared_ptr<X>& x)
(đối với trường hợp giá trị) và void f(std::shared_ptr<X>&& x)
(đối với trường hợp giá trị ), với các cơ quan chức năng chỉ khác nhau ở phiên bản đầu tiên gọi ngữ nghĩa sao chép (sử dụng xây dựng / gán sao chép khi sử dụng x
) nhưng phiên bản thứ hai di chuyển ngữ nghĩa (viết std::move(x)
thay thế, như trong mã ví dụ). Vì vậy, đối với các con trỏ được chia sẻ, chế độ 1 có thể hữu ích để tránh một số sao chép mã.
Chế độ 2: vượt qua một con trỏ thông minh bằng cách tham chiếu giá trị (có thể sửa đổi)
Ở đây, hàm chỉ yêu cầu có một tham chiếu có thể sửa đổi đối với con trỏ thông minh, nhưng không đưa ra dấu hiệu nào về việc nó sẽ làm gì với nó. Tôi muốn gọi phương thức này bằng thẻ : người gọi đảm bảo thanh toán bằng cách cho số thẻ tín dụng. Tham chiếu có thể được sử dụng để sở hữu đối tượng trỏ tới, nhưng nó không phải. Chế độ này yêu cầu cung cấp một đối số giá trị có thể sửa đổi, tương ứng với thực tế là hiệu ứng mong muốn của hàm có thể bao gồm việc để lại một giá trị hữu ích trong biến đối số. Một người gọi với biểu thức giá trị mà nó muốn chuyển đến một hàm như vậy sẽ bị buộc phải lưu nó trong một biến được đặt tên để có thể thực hiện cuộc gọi, vì ngôn ngữ chỉ cung cấp chuyển đổi ngầm định thành hằng sốtham chiếu lvalue (đề cập đến tạm thời) từ một giá trị. (Không giống như trường hợp ngược lại được xử lý bởi std::move
, việc truyền từ Y&&
sang Y&
, với Y
loại con trỏ thông minh, là không thể; dù sao, chuyển đổi này có thể có được bằng một hàm mẫu đơn giản nếu thực sự mong muốn; xem https://stackoverflow.com/a/24868376 / 1436796 ). Đối với trường hợp hàm được gọi có ý định vô điều kiện sở hữu đối tượng, đánh cắp đối số, nghĩa vụ cung cấp đối số lvalue đưa ra tín hiệu sai: biến sẽ không có giá trị hữu ích sau cuộc gọi. Do đó, chế độ 3, cung cấp các khả năng giống hệt nhau bên trong chức năng của chúng tôi nhưng yêu cầu người gọi cung cấp một giá trị, nên được ưu tiên cho việc sử dụng đó.
Tuy nhiên, có một trường hợp sử dụng hợp lệ cho chế độ 2, cụ thể là các hàm có thể sửa đổi con trỏ hoặc đối tượng được trỏ theo cách liên quan đến quyền sở hữu . Chẳng hạn, một hàm có tiền tố một nút list
cung cấp một ví dụ về việc sử dụng đó:
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
Rõ ràng ở đây sẽ là điều không mong muốn khi buộc người gọi sử dụng std::move
, vì con trỏ thông minh của họ vẫn sở hữu một danh sách được xác định rõ và không trống sau cuộc gọi, mặc dù khác với trước.
Một lần nữa thật thú vị khi quan sát những gì xảy ra nếu prepend
cuộc gọi thất bại vì thiếu bộ nhớ trống. Rồi new
cuộc gọi sẽ ném std::bad_alloc
; tại thời điểm này, vì không node
thể được phân bổ, nên chắc chắn rằng tham chiếu giá trị đã qua (chế độ 3) từ std::move(l)
chưa thể được điều chỉnh, vì điều đó sẽ được thực hiện để xây dựng next
trường của trường node
không được phân bổ. Vì vậy, con trỏ thông minh ban đầu l
vẫn giữ danh sách ban đầu khi lỗi được ném; danh sách đó sẽ bị hủy bởi bộ hủy con trỏ thông minh hoặc trong trường hợp l
nên tồn tại nhờ vào một catch
mệnh đề đủ sớm , nó vẫn sẽ giữ danh sách ban đầu.
Đó là một ví dụ mang tính xây dựng; với một cái nháy mắt cho câu hỏi này, người ta cũng có thể đưa ra ví dụ phá hủy hơn về việc loại bỏ nút đầu tiên có chứa một giá trị nhất định, nếu có:
void remove_first(int x, list& l)
{ list* p = &l;
while ((*p).get()!=nullptr and (*p)->entry!=x)
p = &(*p)->next;
if ((*p).get()!=nullptr)
(*p).reset((*p)->next.release()); // or equivalent: *p = std::move((*p)->next);
}
Một lần nữa sự đúng đắn là khá tinh tế ở đây. Đáng chú ý, trong câu lệnh cuối cùng, con trỏ (*p)->next
được giữ bên trong nút cần loại bỏ sẽ không được liên kết (bởi release
, nó trả về con trỏ nhưng tạo null ban đầu) trước khi reset
(ngầm) phá hủy nút đó (khi nó phá hủy giá trị cũ được giữ bởi p
), đảm bảo rằng một và chỉ một nút bị phá hủy tại thời điểm đó. (Trong hình thức thay thế được đề cập trong bình luận, thời gian này sẽ được để lại cho bên trong việc thực hiện toán tử gán chuyển động của std::unique_ptr
thể hiện list
; tiêu chuẩn nói 20.7.1.2.3; 2 rằng toán tử này sẽ hành động "như thể bởi đang gọi reset(u.release())
", từ đó thời gian cũng sẽ an toàn.)
Lưu ý rằng prepend
và remove_first
không thể được gọi bởi các khách hàng lưu trữ một node
biến cục bộ cho một danh sách luôn trống và đúng vì vậy các triển khai đưa ra không thể hoạt động cho các trường hợp như vậy.
Chế độ 3: vượt qua một con trỏ thông minh bằng cách tham chiếu giá trị (có thể sửa đổi)
Đây là chế độ ưa thích để sử dụng khi chỉ cần sở hữu con trỏ. Tôi muốn gọi phương thức này bằng séc : người gọi phải chấp nhận từ bỏ quyền sở hữu, như thể cung cấp tiền mặt, bằng cách ký séc, nhưng việc rút tiền thực tế bị hoãn cho đến khi chức năng được gọi thực sự điều khiển con trỏ (chính xác như khi sử dụng chế độ 2 ). Việc "ký séc" một cách cụ thể có nghĩa là người gọi phải bọc một đối số trong std::move
(như trong chế độ 1) nếu đó là một giá trị (nếu đó là một giá trị, phần "từ bỏ quyền sở hữu" là rõ ràng và không yêu cầu mã riêng).
Lưu ý rằng về mặt kỹ thuật chế độ 3 hoạt động chính xác như chế độ 2, do đó, chức năng được gọi không phải đảm nhận quyền sở hữu; tuy nhiên tôi sẽ nhấn mạnh rằng nếu có bất kỳ sự không chắc chắn nào về chuyển quyền sở hữu (trong sử dụng bình thường), chế độ 2 nên được ưu tiên hơn cho chế độ 3, do đó, việc sử dụng chế độ 3 là một tín hiệu cho người gọi rằng họ đang từ bỏ quyền sở hữu. Người ta có thể vặn lại rằng chỉ có đối số chế độ 1 chuyển qua thực sự báo hiệu việc mất quyền sở hữu đối với người gọi. Nhưng nếu một khách hàng có bất kỳ nghi ngờ nào về ý định của hàm được gọi, thì cô ấy có nghĩa vụ phải biết các thông số kỹ thuật của hàm được gọi, điều này sẽ loại bỏ mọi nghi ngờ.
Thật đáng ngạc nhiên khi tìm thấy một ví dụ điển hình liên quan đến list
loại của chúng tôi sử dụng đối số chế độ 3 đi qua. Di chuyển một danh sách b
đến cuối danh sách khác a
là một ví dụ điển hình; tuy nhiên a
(tồn tại và giữ kết quả của hoạt động) sẽ tốt hơn khi sử dụng chế độ 2:
void append (list& a, list&& b)
{ list* p=&a;
while ((*p).get()!=nullptr) // find end of list a
p=&(*p)->next;
*p = std::move(b); // attach b; the variable b relinquishes ownership here
}
Một ví dụ thuần túy về việc truyền đối số chế độ 3 là sau đây lấy một danh sách (và quyền sở hữu của nó) và trả về một danh sách chứa các nút giống hệt nhau theo thứ tự ngược lại.
list reversed (list&& l) noexcept // pilfering reversal of list
{ list p(l.release()); // move list into temporary for traversal
list result(nullptr);
while (p.get()!=nullptr)
{ // permute: result --> p->next --> p --> (cycle to result)
result.swap(p->next);
result.swap(p);
}
return result;
}
Hàm này có thể được gọi là trong l = reversed(std::move(l));
để đảo ngược danh sách thành chính nó, nhưng danh sách đảo ngược cũng có thể được sử dụng khác nhau.
Ở đây, đối số ngay lập tức được chuyển đến một biến cục bộ để đạt hiệu quả (người ta có thể đã sử dụng tham số l
trực tiếp ở vị trí của nó p
, nhưng sau đó truy cập vào nó mỗi lần sẽ liên quan đến một mức độ gián tiếp bổ sung); do đó sự khác biệt với việc truyền đối số mode 1 là tối thiểu. Trong thực tế sử dụng chế độ đó, đối số có thể đã phục vụ trực tiếp dưới dạng biến cục bộ, do đó tránh được động thái ban đầu đó; đây chỉ là một ví dụ của nguyên tắc chung rằng nếu một đối số được truyền bởi tham chiếu chỉ phục vụ để khởi tạo một biến cục bộ, thì người ta cũng có thể chuyển nó theo giá trị thay vào đó và sử dụng tham số làm biến cục bộ.
Sử dụng chế độ 3 dường như được ủng hộ bởi tiêu chuẩn, như được chứng kiến bởi thực tế là tất cả các chức năng thư viện được cung cấp chuyển quyền sở hữu con trỏ thông minh bằng chế độ 3. Một trường hợp thuyết phục cụ thể là nhà xây dựng std::shared_ptr<T>(auto_ptr<T>&& p)
. Hàm tạo đó đã sử dụng (in std::tr1
) để lấy tham chiếu giá trị có thể sửa đổi (giống như hàm tạo auto_ptr<T>&
sao chép), và do đó có thể được gọi với một auto_ptr<T>
giá trị p
như std::shared_ptr<T> q(p)
sau, sau đó p
đã được đặt lại thành null. Do sự thay đổi từ chế độ 2 thành 3 trong việc truyền đối số, mã cũ này hiện phải được viết lại std::shared_ptr<T> q(std::move(p))
và sau đó sẽ tiếp tục hoạt động. Tôi hiểu rằng ủy ban không thích chế độ 2 ở đây, nhưng họ có tùy chọn thay đổi sang chế độ 1, bằng cách xác địnhstd::shared_ptr<T>(auto_ptr<T> p)
thay vào đó, họ có thể đảm bảo rằng mã cũ hoạt động mà không cần sửa đổi, bởi vì (không giống như các con trỏ duy nhất) có thể được âm thầm quy định thành một giá trị (chính đối tượng con trỏ được đặt lại thành null trong quá trình). Rõ ràng ủy ban rất thích ủng hộ chế độ 3 hơn chế độ 1, đến mức họ chọn chủ động phá vỡ mã hiện có thay vì sử dụng chế độ 1 ngay cả đối với việc sử dụng đã bị phản đối.
Khi nào thích chế độ 3 hơn chế độ 1
Chế độ 1 hoàn toàn có thể sử dụng được trong nhiều trường hợp và có thể được ưu tiên hơn chế độ 3 trong trường hợp giả sử quyền sở hữu sẽ có hình thức di chuyển con trỏ thông minh sang một biến cục bộ như trong reversed
ví dụ trên. Tuy nhiên, tôi có thể thấy hai lý do để thích chế độ 3 trong trường hợp tổng quát hơn:
Việc chuyển một tham chiếu hiệu quả hơn một chút so với việc tạo một con trỏ cũ và tạm thời con trỏ cũ (xử lý tiền mặt có phần tốn công sức); trong một số trường hợp, con trỏ có thể được chuyển không thay đổi nhiều lần sang hàm khác trước khi nó thực sự được điều khiển. Việc vượt qua như vậy thường sẽ yêu cầu viết std::move
(trừ khi sử dụng chế độ 2), nhưng lưu ý rằng đây chỉ là một diễn viên không thực sự làm gì cả (đặc biệt là không có hội thảo), vì vậy nó không có chi phí kèm theo.
Có thể hiểu được rằng bất cứ điều gì ném một ngoại lệ giữa lúc bắt đầu cuộc gọi hàm và điểm mà nó (hoặc một số cuộc gọi có chứa) thực sự di chuyển đối tượng trỏ vào một cấu trúc dữ liệu khác (và ngoại lệ này chưa bị bắt trong chính hàm đó ), sau đó khi sử dụng chế độ 1, đối tượng được gọi bởi con trỏ thông minh sẽ bị hủy trước khi catch
mệnh đề có thể xử lý ngoại lệ (vì tham số hàm bị hủy trong quá trình hủy bỏ ngăn xếp), nhưng không phải vậy khi sử dụng chế độ 3. Cái sau cho người gọi có tùy chọn khôi phục dữ liệu của đối tượng trong các trường hợp đó (bằng cách bắt ngoại lệ). Lưu ý rằng chế độ 1 ở đây không gây rò rỉ bộ nhớ , nhưng có thể dẫn đến việc mất dữ liệu không thể phục hồi cho chương trình, điều này cũng có thể không mong muốn.
Trả về một con trỏ thông minh: luôn luôn theo giá trị
Để kết luận một từ về việc trả về một con trỏ thông minh, có lẽ chỉ vào một đối tượng được tạo bởi người gọi. Đây thực sự không phải là một trường hợp có thể so sánh với việc chuyển con trỏ vào các hàm, nhưng để hoàn chỉnh tôi muốn nhấn mạnh rằng trong những trường hợp như vậy luôn trả về giá trị (và không sử dụng std::move
trong return
câu lệnh). Không ai muốn có được một tham chiếu đến một con trỏ có lẽ vừa được trộn.