Tại sao tôi không thể lưu trữ một giá trị và tham chiếu đến giá trị đó trong cùng một cấu trúc?


222

Tôi có một giá trị và tôi muốn lưu trữ giá trị đó và tham chiếu đến thứ gì đó bên trong giá trị đó theo kiểu của riêng tôi:

struct Thing {
    count: u32,
}

struct Combined<'a>(Thing, &'a u32);

fn make_combined<'a>() -> Combined<'a> {
    let thing = Thing { count: 42 };

    Combined(thing, &thing.count)
}

Đôi khi, tôi có một giá trị và tôi muốn lưu trữ giá trị đó và tham chiếu đến giá trị đó trong cùng một cấu trúc:

struct Combined<'a>(Thing, &'a Thing);

fn make_combined<'a>() -> Combined<'a> {
    let thing = Thing::new();

    Combined(thing, &thing)
}

Đôi khi, tôi thậm chí không tham khảo giá trị và tôi cũng gặp lỗi tương tự:

struct Combined<'a>(Parent, Child<'a>);

fn make_combined<'a>() -> Combined<'a> {
    let parent = Parent::new();
    let child = parent.child();

    Combined(parent, child)
}

Trong mỗi trường hợp này, tôi nhận được một lỗi rằng một trong các giá trị "không tồn tại đủ lâu". Lỗi này nghĩa là gì?


1
Ví dụ sau, một định nghĩa về ParentChildcó thể giúp ...
Matthieu M.

1
@MatthieuM. Tôi đã tranh luận về điều đó, nhưng đã quyết định chống lại nó dựa trên hai câu hỏi được liên kết. Cả hai câu hỏi này đều không xem xét định nghĩa của cấu trúc hoặc phương thức trong câu hỏi, vì vậy tôi nghĩ rằng tốt nhất là bắt chước rằng mọi người có thể dễ dàng kết hợp câu hỏi này với tình huống của họ hơn. Lưu ý rằng tôi làm hiển thị chữ ký phương pháp trong các câu trả lời.
Người quản lý

Câu trả lời:


245

Hãy xem xét một cách thực hiện đơn giản này :

struct Parent {
    count: u32,
}

struct Child<'a> {
    parent: &'a Parent,
}

struct Combined<'a> {
    parent: Parent,
    child: Child<'a>,
}

impl<'a> Combined<'a> {
    fn new() -> Self {
        let parent = Parent { count: 42 };
        let child = Child { parent: &parent };

        Combined { parent, child }
    }
}

fn main() {}

Điều này sẽ thất bại với lỗi:

error[E0515]: cannot return value referencing local variable `parent`
  --> src/main.rs:19:9
   |
17 |         let child = Child { parent: &parent };
   |                                     ------- `parent` is borrowed here
18 | 
19 |         Combined { parent, child }
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^ returns a value referencing data owned by the current function

error[E0505]: cannot move out of `parent` because it is borrowed
  --> src/main.rs:19:20
   |
14 | impl<'a> Combined<'a> {
   |      -- lifetime `'a` defined here
...
17 |         let child = Child { parent: &parent };
   |                                     ------- borrow of `parent` occurs here
18 | 
19 |         Combined { parent, child }
   |         -----------^^^^^^---------
   |         |          |
   |         |          move out of `parent` occurs here
   |         returning this value requires that `parent` is borrowed for `'a`

Để hiểu hoàn toàn lỗi này, bạn phải suy nghĩ về cách các giá trị được biểu diễn trong bộ nhớ và điều gì xảy ra khi bạn di chuyển các giá trị đó. Hãy chú thích Combined::newvới một số địa chỉ bộ nhớ giả định cho biết vị trí của các giá trị:

let parent = Parent { count: 42 };
// `parent` lives at address 0x1000 and takes up 4 bytes
// The value of `parent` is 42 
let child = Child { parent: &parent };
// `child` lives at address 0x1010 and takes up 4 bytes
// The value of `child` is 0x1000

Combined { parent, child }
// The return value lives at address 0x2000 and takes up 8 bytes
// `parent` is moved to 0x2000
// `child` is ... ?

Điều gì sẽ xảy ra child? Nếu giá trị chỉ được di chuyển như cũ parent , thì nó sẽ đề cập đến bộ nhớ không còn được đảm bảo có giá trị hợp lệ trong đó. Bất kỳ đoạn mã nào khác đều được phép lưu trữ giá trị tại địa chỉ bộ nhớ 0x1000. Truy cập bộ nhớ giả định rằng đó là một số nguyên có thể dẫn đến sự cố và / hoặc lỗi bảo mật và là một trong những loại lỗi chính mà Rust ngăn chặn.

Đây chính xác là vấn đề mà cuộc sống ngăn chặn. Thời gian tồn tại là một chút siêu dữ liệu cho phép bạn và trình biên dịch biết giá trị sẽ có hiệu lực trong bao lâu tại vị trí bộ nhớ hiện tại của nó . Đó là một sự khác biệt quan trọng, vì đó là một lỗi phổ biến mà những người mới chơi Rust mắc phải. Thời gian sống của Rust không phải là khoảng thời gian giữa khi một vật thể được tạo ra và khi nó bị phá hủy!

Tương tự như vậy, hãy nghĩ về nó theo cách này: Trong cuộc sống của một người, họ sẽ cư trú ở nhiều địa điểm khác nhau, mỗi địa điểm có một địa chỉ riêng biệt. Cả đời Rust liên quan đến địa chỉ mà bạn hiện đang cư trú , không phải về bất cứ khi nào bạn sẽ chết trong tương lai (mặc dù chết cũng thay đổi địa chỉ của bạn). Mỗi khi bạn di chuyển nó có liên quan vì địa chỉ của bạn không còn hợp lệ.

Cũng cần lưu ý rằng thời gian sống không thay đổi mã của bạn; mã của bạn kiểm soát thời gian sống, thời gian sống của bạn không kiểm soát mã. Câu nói rất hay là "cuộc sống là mô tả, không phải là kê đơn".

Chúng ta hãy chú thích Combined::newvới một số số dòng mà chúng ta sẽ sử dụng để làm nổi bật vòng đời:

{                                          // 0
    let parent = Parent { count: 42 };     // 1
    let child = Child { parent: &parent }; // 2
                                           // 3
    Combined { parent, child }             // 4
}                                          // 5

Các đời bê tông của parentlà 1-4, bao gồm (mà tôi sẽ thể hiện như [1,4]). Tuổi thọ cụ thể child[2,4]và tuổi thọ cụ thể của giá trị trả về là [4,5]. Có thể có các vòng đời cụ thể bắt đầu từ 0 - đại diện cho thời gian tồn tại của một tham số cho một chức năng hoặc một cái gì đó tồn tại bên ngoài khối.

Lưu ý rằng thời gian tồn tại của childchính nó là [2,4], nhưng nó đề cập đến một giá trị với thời gian tồn tại là [1,4]. Điều này tốt miễn là giá trị tham chiếu trở nên không hợp lệ trước khi giá trị được giới thiệu thực hiện. Vấn đề xảy ra khi chúng tôi cố gắng trở về childtừ khối. Điều này sẽ "kéo dài quá mức" cuộc sống vượt quá chiều dài tự nhiên của nó.

Kiến thức mới này sẽ giải thích hai ví dụ đầu tiên. Cái thứ ba đòi hỏi phải nhìn vào việc thực hiện Parent::child. Rất có thể, nó sẽ trông giống như thế này:

impl Parent {
    fn child(&self) -> Child { /* ... */ }
}

Điều này sử dụng elision trọn đời để tránh viết các tham số chung chung rõ ràng . Nó tương đương với:

impl Parent {
    fn child<'a>(&'a self) -> Child<'a> { /* ... */ }
}

Trong cả hai trường hợp, phương thức nói rằng một Childcấu trúc sẽ được trả về đã được tham số hóa với tuổi thọ cụ thể của self. Nói một cách khác, Childcá thể chứa một tham chiếu đến cái Parentđã tạo ra nó, và do đó không thể sống lâu hơn Parentcá thể đó .

Điều này cũng cho phép chúng tôi nhận ra rằng có điều gì đó thực sự sai với chức năng tạo của chúng tôi:

fn make_combined<'a>() -> Combined<'a> { /* ... */ }

Mặc dù bạn có nhiều khả năng thấy điều này được viết dưới một hình thức khác:

impl<'a> Combined<'a> {
    fn new() -> Combined<'a> { /* ... */ }
}

Trong cả hai trường hợp, không có tham số trọn đời nào được cung cấp thông qua một đối số. Điều này có nghĩa là thời gian tồn tại Combinedsẽ được tham số hóa mà không bị ràng buộc bởi bất cứ điều gì - nó có thể là bất cứ điều gì mà người gọi muốn. Điều này là vô nghĩa, bởi vì người gọi có thể chỉ định 'staticthời gian tồn tại và không có cách nào để đáp ứng điều kiện đó.

Làm thế nào để tôi sửa chữa nó?

Giải pháp đơn giản và được khuyến nghị nhất là không cố gắng đặt các mục này trong cùng một cấu trúc. Bằng cách này, cấu trúc lồng nhau của bạn sẽ bắt chước tuổi thọ của mã của bạn. Đặt các loại sở hữu dữ liệu vào một cấu trúc với nhau và sau đó cung cấp các phương thức cho phép bạn nhận được các tham chiếu hoặc các đối tượng có chứa các tham chiếu khi cần.

Có một trường hợp đặc biệt khi theo dõi suốt đời là quá nhiệt tình: khi bạn có một cái gì đó được đặt trên đống. Điều này xảy ra khi bạn sử dụng một Box<T>ví dụ. Trong trường hợp này, cấu trúc được di chuyển chứa một con trỏ vào heap. Giá trị nhọn sẽ vẫn ổn định, nhưng địa chỉ của chính con trỏ sẽ di chuyển. Trong thực tế, điều này không thành vấn đề, vì bạn luôn đi theo con trỏ.

Các thùng cho thuê (KHÔNG CÒN ĐƯỢC DUY TRÌ HOẶC HỖ TRỢ) hoặc thùng owning_ref nhiều cách để trình bày trường hợp này, nhưng họ yêu cầu rằng địa chỉ cơ sở không bao giờ di chuyển . Điều này loại trừ các vectơ đột biến, có thể gây ra sự phân bổ lại và di chuyển các giá trị phân bổ heap.

Ví dụ về các vấn đề được giải quyết với Cho thuê:

Trong các trường hợp khác, bạn có thể muốn chuyển sang một số loại đếm tham chiếu, chẳng hạn như bằng cách sử dụng Rchoặc Arc.

Thêm thông tin

Sau khi chuyển parentvào struct, tại sao trình biên dịch không thể có được một tham chiếu mới parentvà gán nó vào childtrong struct?

Mặc dù về mặt lý thuyết là có thể làm điều này, nhưng làm như vậy sẽ giới thiệu một lượng lớn phức tạp và chi phí chung. Mỗi khi đối tượng được di chuyển, trình biên dịch sẽ cần chèn mã để "sửa" tham chiếu. Điều này có nghĩa là sao chép một cấu trúc không còn là một hoạt động rất rẻ mà chỉ di chuyển một số bit xung quanh. Nó thậm chí có thể có nghĩa là mã như thế này đắt tiền, tùy thuộc vào mức độ tối ưu hóa giả thuyết sẽ tốt như thế nào:

let a = Object::new();
let b = a;
let c = b;

Thay vì buộc điều này xảy ra cho mọi di chuyển, lập trình viên sẽ chọn khi điều này xảy ra bằng cách tạo các phương thức sẽ lấy các tham chiếu thích hợp chỉ khi bạn gọi chúng.

Một loại có tham chiếu đến chính nó

Có một trường hợp cụ thể trong đó bạn có thể tạo một loại có tham chiếu đến chính nó. Bạn cần phải sử dụng một cái gì đó như Optionđể làm cho nó trong hai bước:

#[derive(Debug)]
struct WhatAboutThis<'a> {
    name: String,
    nickname: Option<&'a str>,
}

fn main() {
    let mut tricky = WhatAboutThis {
        name: "Annabelle".to_string(),
        nickname: None,
    };
    tricky.nickname = Some(&tricky.name[..4]);

    println!("{:?}", tricky);
}

Điều này không hoạt động, trong một số ý nghĩa, nhưng giá trị được tạo ra bị hạn chế cao - nó không bao giờ có thể được di chuyển. Đáng chú ý, điều này có nghĩa là nó không thể được trả về từ một hàm hoặc được truyền bởi giá trị cho bất cứ thứ gì. Hàm xây dựng cho thấy cùng một vấn đề với thời gian sống như trên:

fn creator<'a>() -> WhatAboutThis<'a> { /* ... */ }

Thế còn Pin?

Pin, được ổn định trong Rust 1.33, có tài liệu này trong tài liệu mô-đun :

Một ví dụ điển hình của kịch bản như vậy sẽ là xây dựng các cấu trúc tự tham chiếu, vì việc di chuyển một đối tượng có con trỏ sang chính nó sẽ vô hiệu hóa chúng, điều này có thể gây ra hành vi không xác định.

Điều quan trọng cần lưu ý là "tự giới thiệu" không nhất thiết có nghĩa là sử dụng tài liệu tham khảo . Thật vậy, ví dụ về cấu trúc tự tham chiếu nói cụ thể (nhấn mạnh của tôi):

Chúng tôi không thể thông báo cho trình biên dịch về điều đó với một tham chiếu bình thường, vì mẫu này có thể được mô tả với các quy tắc vay thông thường. Thay vào đó, chúng tôi sử dụng một con trỏ thô , mặc dù một con trỏ được biết là không rỗng, vì chúng tôi biết nó đang trỏ vào chuỗi.

Khả năng sử dụng một con trỏ thô cho hành vi này đã tồn tại kể từ Rust 1.0. Thật vậy, sở hữu-ref và cho thuê sử dụng con trỏ thô dưới mui xe.

Điều duy nhất Pinthêm vào bảng là một cách phổ biến để nói rằng một giá trị nhất định được đảm bảo không di chuyển.

Xem thêm:


1
Là một cái gì đó như thế này ( is.gd/wl2IAt ) được coi là thành ngữ? Tức là, để lộ dữ liệu thông qua các phương thức thay vì dữ liệu thô.
Hội trường Peter

2
@PeterHall chắc chắn, nó chỉ có nghĩa là Combinedsở hữu Childcái sở hữu Parent. Điều đó có thể có hoặc không có ý nghĩa tùy thuộc vào loại thực tế mà bạn có. Trả lại tài liệu tham khảo cho dữ liệu nội bộ của riêng bạn là khá điển hình.
Người quản lý

Giải pháp cho vấn đề heap là gì?
derekdreery

@derekdreery có lẽ bạn có thể mở rộng bình luận của bạn? Tại sao toàn bộ đoạn văn nói về owning_ref thùng không đủ?
Người quản lý

1
@FynnBecker vẫn không thể lưu trữ một tham chiếu và giá trị cho tham chiếu đó. Pinchủ yếu là một cách để biết sự an toàn của một cấu trúc có chứa một con trỏ tự tham chiếu . Khả năng sử dụng một con trỏ thô cho cùng một mục đích đã tồn tại kể từ Rust 1.0.
Người quản lý

4

Một vấn đề hơi khác nhau gây ra các thông điệp trình biên dịch rất giống nhau là phụ thuộc trọn đời đối tượng, thay vì lưu trữ một tham chiếu rõ ràng. Một ví dụ về điều đó là thư viện ssh2 . Khi phát triển một cái gì đó lớn hơn một dự án thử nghiệm, sẽ rất hấp dẫn khi cố gắng đặt SessionChannelthu được từ phiên đó cùng với một cấu trúc, ẩn các chi tiết triển khai khỏi người dùng. Tuy nhiên, lưu ý rằng Channelđịnh nghĩa có 'sessthời gian tồn tại trong chú thích loại của nó, trong khi Sessionkhông.

Điều này gây ra lỗi trình biên dịch tương tự liên quan đến tuổi thọ.

Một cách để giải quyết nó một cách rất đơn giản là khai báo Sessionbên ngoài trong người gọi và sau đó chú thích tham chiếu trong cấu trúc với thời gian tồn tại, tương tự như câu trả lời trong bài đăng trên Diễn đàn của Người dùng Rust này nói về cùng một vấn đề trong khi đóng gói SFTP . Điều này sẽ không có vẻ thanh lịch và có thể không phải lúc nào cũng được áp dụng - bởi vì bây giờ bạn có hai thực thể để giải quyết, thay vì một thực thể mà bạn muốn!

Hóa ra thùng cho thuê hoặc thùng sở hữu từ câu trả lời khác cũng là giải pháp cho vấn đề này. Chúng ta hãy xem xét sở hữu, có đối tượng đặc biệt cho mục đích chính xác này : OwningHandle. Để tránh đối tượng cơ bản di chuyển, chúng ta phân bổ nó trên heap bằng cách sử dụng a Box, cung cấp cho chúng ta giải pháp khả thi sau:

use ssh2::{Channel, Error, Session};
use std::net::TcpStream;

use owning_ref::OwningHandle;

struct DeviceSSHConnection {
    tcp: TcpStream,
    channel: OwningHandle<Box<Session>, Box<Channel<'static>>>,
}

impl DeviceSSHConnection {
    fn new(targ: &str, c_user: &str, c_pass: &str) -> Self {
        use std::net::TcpStream;
        let mut session = Session::new().unwrap();
        let mut tcp = TcpStream::connect(targ).unwrap();

        session.handshake(&tcp).unwrap();
        session.set_timeout(5000);
        session.userauth_password(c_user, c_pass).unwrap();

        let mut sess = Box::new(session);
        let mut oref = OwningHandle::new_with_fn(
            sess,
            unsafe { |x| Box::new((*x).channel_session().unwrap()) },
        );

        oref.shell().unwrap();
        let ret = DeviceSSHConnection {
            tcp: tcp,
            channel: oref,
        };
        ret
    }
}

Kết quả của mã này là chúng tôi không thể sử dụng Sessionnữa, nhưng nó được lưu trữ cùng với cái Channelmà chúng tôi sẽ sử dụng. Bởi vì các OwningHandleđối tượng quy ước Box, mà các hội nghị này Channel, khi lưu trữ nó trong một cấu trúc, chúng tôi đặt tên cho nó như vậy. LƯU Ý: Đây chỉ là sự hiểu biết của tôi. Tôi có một nghi ngờ điều này có thể không chính xác, vì nó dường như khá gần với thảo luận về sự không OwningHandlean toàn .

Một chi tiết gây tò mò ở đây là Sessionlogic có mối quan hệ tương tự TcpStreamnhư Channelđã có Session, tuy nhiên quyền sở hữu của nó không được thực hiện và không có chú thích loại nào xung quanh làm như vậy. Thay vào đó, người dùng phải quan tâm đến vấn đề này, vì tài liệu về phương pháp bắt tay nói:

Phiên này không có quyền sở hữu ổ cắm được cung cấp, nên đảm bảo rằng ổ cắm vẫn tồn tại trong suốt thời gian của phiên này để đảm bảo rằng giao tiếp được thực hiện chính xác.

Chúng tôi cũng rất khuyến khích rằng luồng được cung cấp không được sử dụng đồng thời ở nơi khác trong suốt thời gian của phiên này vì nó có thể can thiệp vào giao thức.

Vì vậy, với việc TcpStreamsử dụng, hoàn toàn phụ thuộc vào lập trình viên để đảm bảo tính chính xác của mã. Với OwningHandlesự chú ý đến nơi "ma thuật nguy hiểm" xảy ra được sử dụng unsafe {}khối.

Một cuộc thảo luận cấp cao hơn và cao hơn về vấn đề này nằm trong chuỗi Diễn đàn của Người dùng Rust này - bao gồm một ví dụ khác và giải pháp của nó bằng cách sử dụng thùng cho thuê, không chứa các khối không an toàn.

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.