Gọi lại thành ngữ trong Rust


99

Trong C / C ++, tôi thường thực hiện các lệnh gọi lại với một con trỏ hàm đơn giản, có thể truyền một void* userdatatham số nữa. Một cái gì đó như thế này:

typedef void (*Callback)();

class Processor
{
public:
    void setCallback(Callback c)
    {
        mCallback = c;
    }

    void processEvents()
    {
        for (...)
        {
            ...
            mCallback();
        }
    }
private:
    Callback mCallback;
};

Cách thành ngữ để làm điều này trong Rust là gì? Cụ thể, setCallback()hàm của tôi nên sử dụng những loại nào và loại nào nên mCallbacklà? Nó có nên mất một Fn? Có thể FnMut? Tôi có lưu nó Boxedkhông? Một ví dụ sẽ rất tuyệt vời.

Câu trả lời:


193

Câu trả lời ngắn gọn: Để có tính linh hoạt tối đa, bạn có thể lưu trữ lệnh gọi lại dưới dạng một FnMutđối tượng được đóng hộp , với bộ cài đặt gọi lại chung về kiểu gọi lại. Mã cho điều này được hiển thị trong ví dụ cuối cùng trong câu trả lời. Để có lời giải thích chi tiết hơn, hãy đọc tiếp.

"Con trỏ hàm": gọi lại dưới dạng fn

Tương đương gần nhất của mã C ++ trong câu hỏi sẽ là khai báo gọi lại dưới dạng một fnkiểu. fnđóng gói các hàm được định nghĩa bởi fntừ khóa, giống như các con trỏ hàm của C ++:

type Callback = fn();

struct Processor {
    callback: Callback,
}

impl Processor {
    fn set_callback(&mut self, c: Callback) {
        self.callback = c;
    }

    fn process_events(&self) {
        (self.callback)();
    }
}

fn simple_callback() {
    println!("hello world!");
}

fn main() {
    let p = Processor {
        callback: simple_callback,
    };
    p.process_events(); // hello world!
}

Mã này có thể được mở rộng để bao gồm một Option<Box<Any>>để giữ "dữ liệu người dùng" được liên kết với hàm. Mặc dù vậy, nó sẽ không phải là Rust thành ngữ. Cách Rust để liên kết dữ liệu với một hàm là nắm bắt nó trong một bao đóng ẩn danh , giống như trong C ++ hiện đại. Vì không phải là bao đóng fn, nên set_callbacksẽ cần phải chấp nhận các loại đối tượng chức năng khác.

Gọi lại dưới dạng các đối tượng hàm chung

Trong cả hai lệnh đóng Rust và C ++ với cùng một chữ ký cuộc gọi có các kích thước khác nhau để chứa các giá trị khác nhau mà chúng có thể nắm bắt. Ngoài ra, mỗi định nghĩa bao đóng tạo ra một kiểu ẩn danh duy nhất cho giá trị của bao đóng. Do những ràng buộc này, struct không thể đặt tên cho kiểu trường của nó callback, cũng như không thể sử dụng bí danh.

Một cách để nhúng một bao đóng vào trường struct mà không tham chiếu đến một kiểu cụ thể là làm cho struct chung chung . Cấu trúc sẽ tự động điều chỉnh kích thước của nó và loại lệnh gọi lại cho hàm hoặc hàm cụ thể mà bạn chuyển cho nó:

struct Processor<CB>
where
    CB: FnMut(),
{
    callback: CB,
}

impl<CB> Processor<CB>
where
    CB: FnMut(),
{
    fn set_callback(&mut self, c: CB) {
        self.callback = c;
    }

    fn process_events(&mut self) {
        (self.callback)();
    }
}

fn main() {
    let s = "world!".to_string();
    let callback = || println!("hello {}", s);
    let mut p = Processor { callback: callback };
    p.process_events();
}

Như trước đây, định nghĩa mới của callback sẽ có thể chấp nhận các hàm cấp cao nhất được xác định bằng fn, nhưng định nghĩa này cũng sẽ chấp nhận các bao đóng || println!("hello world!")cũng như các bao đóng nắm bắt các giá trị, chẳng hạn như || println!("{}", somevar). Bởi vì điều này, bộ xử lý không cần userdataphải đi kèm với lệnh gọi lại; sự đóng cửa được cung cấp bởi người gọi set_callbacksẽ tự động nắm bắt dữ liệu mà nó cần từ môi trường của nó và có sẵn nó khi được gọi.

Nhưng giải quyết vấn đề là gì FnMut, tại sao không chỉ Fn? Vì các bao đóng giữ các giá trị đã được bắt, các quy tắc đột biến thông thường của Rust phải áp dụng khi gọi bao đóng. Tùy thuộc vào những gì mà các bao đóng làm với các giá trị mà chúng giữ, chúng được nhóm lại thành ba họ, mỗi họ được đánh dấu bằng một đặc điểm:

  • Fnlà các bao đóng chỉ đọc dữ liệu và có thể được gọi an toàn nhiều lần, có thể từ nhiều luồng. Cả hai cách đóng cửa trên đều được Fn.
  • FnMutlà các bao đóng sửa đổi dữ liệu, ví dụ bằng cách ghi vào một mutbiến đã được capture . Chúng cũng có thể được gọi nhiều lần, nhưng không song song. (Gọi một bao FnMutđóng từ nhiều luồng sẽ dẫn đến một cuộc chạy đua dữ liệu, vì vậy nó chỉ có thể được thực hiện với sự bảo vệ của mutex.) Đối tượng bao đóng phải được người gọi khai báo là có thể thay đổi được.
  • FnOncelà các bao đóng sử dụng một số dữ liệu mà chúng thu thập được, ví dụ như bằng cách di chuyển một giá trị đã chụp sang một hàm có quyền sở hữu nó. Như tên của nó, chúng chỉ có thể được gọi một lần và người gọi phải sở hữu chúng.

Hơi phản trực giác, khi chỉ định một đặc điểm bị ràng buộc cho loại đối tượng chấp nhận một bao đóng, FnOncethực sự là một đặc điểm dễ dãi nhất. Tuyên bố rằng một kiểu gọi lại chung phải thỏa mãn FnOnceđặc điểm có nghĩa là nó sẽ chấp nhận mọi sự đóng lại theo nghĩa đen. Nhưng điều đó đi kèm với một cái giá: nó có nghĩa là chủ sở hữu chỉ được phép gọi nó một lần. Vì process_events()có thể chọn gọi lại nhiều lần và vì bản thân phương thức này có thể được gọi nhiều lần, nên giới hạn dễ chấp nhận nhất tiếp theo là FnMut. Lưu ý rằng chúng tôi phải đánh dấu process_eventslà đột biến self.

Gọi lại không chung chung: đối tượng đặc điểm hàm

Mặc dù việc triển khai chung của lệnh gọi lại là cực kỳ hiệu quả, nó có những hạn chế nghiêm trọng về giao diện. Nó yêu cầu mỗi Processorphiên bản phải được tham số hóa với một kiểu gọi lại cụ thể, có nghĩa là một phiên bản Processorchỉ có thể xử lý một kiểu gọi lại duy nhất. Cho rằng mỗi bao đóng có một kiểu riêng biệt, chung chung Processorkhông thể xử lý proc.set_callback(|| println!("hello"))theo sau bởi proc.set_callback(|| println!("world")). Việc mở rộng cấu trúc để hỗ trợ hai trường gọi lại sẽ yêu cầu toàn bộ cấu trúc phải được tham số hóa thành hai loại, điều này sẽ nhanh chóng trở nên khó sử dụng khi số lượng lệnh gọi lại tăng lên. Việc thêm nhiều tham số kiểu hơn sẽ không hoạt động nếu số lượng lệnh gọi lại cần động, ví dụ: để triển khai một add_callbackhàm duy trì một vectơ của các lệnh gọi lại khác nhau.

Để loại bỏ tham số kiểu, chúng ta có thể tận dụng các đối tượng đặc điểm , tính năng của Rust cho phép tự động tạo giao diện động dựa trên các đặc điểm. Điều này đôi khi được gọi là xóa kiểu và là một kỹ thuật phổ biến trong C ++ [1] [2] , không nên nhầm lẫn với cách sử dụng thuật ngữ có phần khác nhau của ngôn ngữ FP và ngôn ngữ FP. Người đọc quen thuộc với C ++ sẽ nhận ra sự khác biệt giữa một bao đóng thực hiện Fnvà một Fnđối tượng đặc điểm tương đương với sự phân biệt giữa các đối tượng hàm tổng quát và std::functioncác giá trị trong C ++.

Một đối tượng đặc điểm được tạo ra bằng cách mượn một đối tượng với &toán tử và ép hoặc ép đối tượng đó tham chiếu đến đặc điểm cụ thể. Trong trường hợp này, vì Processorcần sở hữu đối tượng gọi lại, chúng ta không thể sử dụng phương thức mượn, mà phải lưu trữ lệnh gọi lại trong một heap được phân bổ Box<dyn Trait>(tương đương với Rust std::unique_ptr), có chức năng tương đương với một đối tượng đặc điểm.

Nếu Processorlưu trữ Box<dyn FnMut()>, nó không cần phải là chung nữa, nhưng set_callback phương thức bây giờ chấp nhận một chung cthông qua một impl Traitđối số . Như vậy, nó có thể chấp nhận bất kỳ loại có thể gọi nào, bao gồm cả các bao đóng với trạng thái, và đóng hộp đúng cách trước khi lưu trữ trong Processor. Đối số chung để set_callbackkhông giới hạn loại gọi lại mà bộ xử lý chấp nhận, vì loại gọi lại được chấp nhận được tách ra khỏi loại được lưu trữ trong Processorcấu trúc.

struct Processor {
    callback: Box<dyn FnMut()>,
}

impl Processor {
    fn set_callback(&mut self, c: impl FnMut() + 'static) {
        self.callback = Box::new(c);
    }

    fn process_events(&mut self) {
        (self.callback)();
    }
}

fn simple_callback() {
    println!("hello");
}

fn main() {
    let mut p = Processor {
        callback: Box::new(simple_callback),
    };
    p.process_events();
    let s = "world!".to_string();
    let callback2 = move || println!("hello {}", s);
    p.set_callback(callback2);
    p.process_events();
}

Thời gian tồn tại của các tham chiếu bên trong đóng hộp

Các 'staticđời bị ràng buộc vào loại những clập luận được chấp nhận bởi set_callbacklà một cách đơn giản để thuyết phục các trình biên dịch rằng tài liệu tham khảo chứa trong c, đó có thể là một kết thúc nó liên quan tới môi trường của nó, chỉ đề cập đến giá trị toàn cầu và do đó sẽ vẫn có hiệu lực trên toàn việc sử dụng các gọi lại. Nhưng giới hạn tĩnh cũng rất nặng nề: trong khi nó chấp nhận các bao đóng sở hữu các đối tượng tốt (mà chúng tôi đã đảm bảo ở trên bằng cách thực hiện đóng move), nó từ chối các bao đóng tham chiếu đến môi trường cục bộ, ngay cả khi chúng chỉ tham chiếu đến các giá trị tồn tại lâu hơn bộ xử lý và trên thực tế sẽ an toàn.

Vì chúng ta chỉ cần các lệnh gọi lại còn sống miễn là bộ xử lý còn sống, nên chúng ta nên cố gắng gắn thời gian tồn tại của chúng với thời gian tồn tại của bộ xử lý, đây là một ràng buộc ít nghiêm ngặt hơn 'static. Nhưng nếu chúng ta chỉ xóa 'staticgiới hạn thời gian tồn tại set_callback, nó không còn biên dịch nữa. Điều này là do set_callbacktạo một hộp mới và gán nó vào callbacktrường được định nghĩa là Box<dyn FnMut()>. Vì định nghĩa không chỉ định thời gian tồn tại cho đối tượng đặc điểm được đóng hộp 'static, nên việc gán này sẽ mở rộng thời gian tồn tại một cách hiệu quả (từ thời gian tồn tại tùy ý không được đặt tên của lệnh gọi lại tới 'static), điều này không được phép. Cách khắc phục là cung cấp thời gian tồn tại rõ ràng cho bộ xử lý và gắn thời gian tồn tại đó với cả các tham chiếu trong hộp và các tham chiếu trong lệnh gọi lại nhận được bởi set_callback:

struct Processor<'a> {
    callback: Box<dyn FnMut() + 'a>,
}

impl<'a> Processor<'a> {
    fn set_callback(&mut self, c: impl FnMut() + 'a) {
        self.callback = Box::new(c);
    }
    // ...
}

Với những vòng đời này đã được thực hiện rõ ràng, nó không còn cần thiết để sử dụng 'static. Việc đóng cửa bây giờ có thể tham chiếu đến sđối tượng cục bộ , tức là không còn phải như vậy nữa move, miễn là định nghĩa của sđược đặt trước định nghĩa của pđể đảm bảo rằng chuỗi tồn tại lâu hơn bộ xử lý.


15
Chà, tôi nghĩ đây là câu trả lời hay nhất mà tôi từng có cho một câu hỏi SO! Cảm ơn bạn! Giải thích một cách hoàn hảo. Một điều nhỏ mà tôi không hiểu - tại sao lại CBphải có 'statictrong ví dụ cuối cùng?
Timmmm

9
Được Box<FnMut()>sử dụng trong trường struct có nghĩa là Box<FnMut() + 'static>. Đại khái là "Đối tượng đặc điểm được đóng hộp không chứa tham chiếu / bất kỳ tham chiếu nào mà nó chứa tồn tại lâu hơn (hoặc bằng) 'static". Nó ngăn việc gọi lại thu hút người dân địa phương bằng cách tham khảo.
bluss

Ah tôi hiểu rồi, tôi nghĩ!
Timmmm

1
@Timmmm Thêm chi tiết về 'staticràng buộc trong một bài đăng blog riêng .
user4815162342 28/09/17

3
Đây là một câu trả lời tuyệt vời, cảm ơn bạn đã cung cấp nó @ user4815162342.
Dash83
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.