Panggilan balik idiomatis di Rust

100

Dalam C / C ++ saya biasanya melakukan callback dengan pointer fungsi biasa, mungkin meneruskan void* userdataparameter juga. Sesuatu seperti ini:

typedef void (*Callback)();

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

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

Apa cara idiomatik untuk melakukan ini di Rust? Secara khusus, tipe apa yang harus setCallback()diambil fungsi saya , dan tipe apa yang seharusnya mCallback? Haruskah dibutuhkan Fn? Mungkinkah FnMut? Apakah saya menyimpannya Boxed? Sebuah contoh akan luar biasa.

Timmmm
sumber

Jawaban:

195

Jawaban singkat: Untuk fleksibilitas maksimum, Anda dapat menyimpan callback sebagai FnMutobjek dalam kotak , dengan callback setter generik pada jenis callback. Kode untuk ini ditunjukkan pada contoh terakhir dalam jawaban. Untuk penjelasan lebih detail, baca terus.

"Function pointers": panggilan balik sebagai fn

Persamaan terdekat dari kode C ++ dalam pertanyaan akan mendeklarasikan callback sebagai sebuah fntipe. fnmerangkum fungsi yang ditentukan oleh fnkata kunci, seperti pointer fungsi 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!
}

Kode ini dapat diperpanjang untuk menyertakan Option<Box<Any>>untuk menyimpan "data pengguna" yang terkait dengan fungsi tersebut. Meski begitu, itu bukanlah Rust idiomatik. Cara Rust untuk mengaitkan data dengan suatu fungsi adalah dengan menangkapnya dalam penutupan anonim , seperti di C ++ modern. Karena penutupan tidak fn, set_callbackperlu menerima jenis objek fungsi lainnya.

Callback sebagai objek fungsi umum

Dalam penutupan Rust dan C ++ dengan tanda tangan panggilan yang sama datang dalam ukuran berbeda untuk mengakomodasi nilai berbeda yang mungkin mereka tangkap. Selain itu, setiap definisi closure menghasilkan tipe anonim unik untuk nilai closure. Karena batasan ini, struct tidak dapat menamai jenis callbackbidangnya, juga tidak dapat menggunakan alias.

Salah satu cara untuk menanamkan closure di field struct tanpa mengacu pada tipe konkret adalah dengan membuat struct generic . Struct akan secara otomatis menyesuaikan ukurannya dan jenis callback untuk fungsi konkret atau closure yang Anda teruskan:

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();
}

Seperti sebelumnya, definisi baru dari callback akan dapat menerima fungsi tingkat atas yang didefinisikan dengan fn, tetapi yang ini juga akan menerima closure sebagai || println!("hello world!"), serta closure yang menangkap nilai, seperti || println!("{}", somevar). Karena itu, prosesor tidak perlu userdatamenyertai panggilan balik; penutupan yang disediakan oleh pemanggil dariset_callback akan secara otomatis menangkap data yang dibutuhkan dari lingkungannya dan membuatnya tersedia saat dipanggil.

Tapi apa masalahnya FnMut, mengapa tidak Fn? Karena closure menyimpan nilai yang ditangkap, aturan mutasi biasa Rust harus diterapkan saat memanggil closure. Bergantung pada apa yang dilakukan closure dengan nilai yang mereka pegang, mereka dikelompokkan dalam tiga keluarga, masing-masing ditandai dengan ciri:

  • Fnadalah closure yang hanya membaca data, dan dapat dipanggil dengan aman beberapa kali, mungkin dari beberapa thread. Kedua penutupan di atas adalahFn .
  • FnMutadalah closure yang mengubah data, misalnya dengan menulis ke mutvariabel yang ditangkap . Mereka juga dapat dipanggil beberapa kali, tetapi tidak secara paralel. (Memanggil aFnMut penutupan dari beberapa utas akan menyebabkan perlombaan data, jadi itu hanya dapat dilakukan dengan perlindungan mutex.) Objek closure harus dinyatakan bisa berubah oleh pemanggil.
  • FnOnceadalah closure yang mengkonsumsi beberapa data yang mereka ambil, misalnya dengan memindahkan nilai yang diambil ke fungsi yang mengambil kepemilikannya. Sesuai dengan namanya, ini hanya dapat dipanggil sekali, dan pemanggil harus memilikinya.

Agak berlawanan dengan intuisi, ketika menentukan sifat terikat untuk tipe objek yang menerima penutupan, FnOncesebenarnya adalah yang paling permisif. Menyatakan bahwa jenis panggilan balik generik harus memenuhi FnOncesifat tersebut berarti ia akan menerima penutupan apa pun secara harfiah. Tapi itu ada harganya: artinya pemegangnya hanya diizinkan untuk meneleponnya sekali. Karena process_events()dapat memilih untuk memanggil callback beberapa kali, dan karena metodenya sendiri dapat dipanggil lebih dari sekali, batas paling permisif berikutnya adalah FnMut. Perhatikan bahwa kami harus menandai process_eventssebagai bermutasi self.

Callback non-generik: objek sifat fungsi

Meskipun implementasi umum callback sangat efisien, ia memiliki batasan antarmuka yang serius. Setiap Processorinstance harus diparameterisasi dengan jenis callback konkret, yang berarti bahwa satu instance Processorhanya dapat menangani satu jenis callback. Mengingat bahwa setiap closure memiliki tipe yang berbeda, generik Processortidak dapat menangani proc.set_callback(|| println!("hello"))diikuti oleh proc.set_callback(|| println!("world")). Memperluas struct untuk mendukung dua bidang callback akan membutuhkan seluruh struct untuk diparameterisasi menjadi dua jenis, yang akan dengan cepat menjadi sulit karena jumlah callback bertambah. Menambahkan lebih banyak parameter tipe tidak akan berfungsi jika jumlah callback harus dinamis, misalnya untuk mengimplementasikan add_callbackfungsi yang mempertahankan vektor callback yang berbeda.

Untuk menghapus parameter type, kita dapat memanfaatkan objek sifat , yaitu fitur Rust yang memungkinkan pembuatan antarmuka dinamis secara otomatis berdasarkan sifat. Ini kadang-kadang disebut sebagai penghapusan tipe dan merupakan teknik yang populer di C ++ [1] [2] , jangan disamakan dengan penggunaan istilah bahasa Java dan FP yang agak berbeda. Pembaca yang akrab dengan C ++ akan mengenali perbedaan antara closure yang mengimplementasikan Fndan Fnobjek ciri yang setara dengan perbedaan antara objek fungsi umum danstd::function nilai dalam C ++.

Objek ciri dibuat dengan meminjam objek dengan &operator dan menggunakan atau memaksanya untuk mengacu pada sifat tertentu. Dalam kasus ini, karena Processorperlu memiliki objek callback, kita tidak dapat menggunakan peminjaman, tetapi harus menyimpan callback dalam heap-dialokasikan Box<dyn Trait>(setara dengan Rust std::unique_ptr), yang secara fungsional setara dengan objek ciri.

Jika Processordisimpan Box<dyn FnMut()>, itu tidak lagi perlu generik, tetapi set_callback metode sekarang menerima generik cmelalui impl Traitargumen . Dengan demikian, ia dapat menerima segala jenis callable, termasuk closure with state, dan mengemasnya dengan benar sebelum menyimpannya di Processor. Argumen umum untuk set_callbacktidak membatasi jenis callback yang diterima prosesor, karena jenis callback yang diterima dipisahkan dari jenis yang disimpan di Processorstruct.

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();
}

Referensi seumur hidup di dalam penutupan kotak

Batas waktu 'staticseumur hidup pada jenis cargumen yang diterima set_callbackadalah cara sederhana untuk meyakinkan kompiler yang terdapat referensi di dalamnya c, yang mungkin berupa closure yang merujuk ke lingkungannya, hanya merujuk ke nilai global dan oleh karena itu akan tetap valid selama penggunaan panggilan balik. Tetapi ikatan statis juga sangat berat: sementara menerima penutupan yang memiliki objek dengan baik (yang telah kami pastikan di atas dengan membuat penutupanmove ), ia menolak closure yang merujuk ke lingkungan lokal, bahkan ketika mereka hanya merujuk ke nilai yang hidup lebih lama dari prosesor dan bahkan akan aman.

Karena kita hanya perlu callback hidup selama prosesor hidup, kita harus mencoba mengikat masa pakainya dengan prosesor, yang merupakan batasan yang kurang ketat dari 'static. Tetapi jika kita hanya menghapus 'staticbatas waktu dari set_callback, itu tidak lagi dikompilasi. Ini karena set_callbackmembuat kotak baru dan menetapkannya ke callbackbidang yang ditentukan sebagai Box<dyn FnMut()>. Karena definisi tidak menentukan masa pakai untuk objek sifat kotak, 'statictersirat, dan tugas akan secara efektif memperluas masa pakai (dari masa pakai arbitrer yang tidak disebutkan namanya ke 'static), yang tidak diizinkan. Perbaikannya adalah memberikan masa pakai eksplisit untuk prosesor dan mengikat masa pakai itu ke referensi di kotak dan referensi di callback yang diterima oleh 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);
    }
    // ...
}

Dengan masa aktif ini dibuat eksplisit, maka tidak perlu lagi digunakan 'static. Closure sekarang dapat merujuk ke sobjek lokal , yaitu tidak lagi harus move, asalkan definisi sditempatkan sebelum definisi puntuk memastikan bahwa string hidup lebih lama dari prosesor.

pengguna4815162342
sumber
15
Wow, saya pikir ini adalah jawaban terbaik yang pernah saya dapatkan untuk pertanyaan SO! Terima kasih! Dijelaskan dengan sempurna. Satu hal kecil yang saya tidak mengerti - mengapa CBharus ada 'staticdi contoh terakhir?
Timmmm
9
Yang Box<FnMut()>digunakan dalam sarana bidang struct Box<FnMut() + 'static>. Secara kasar "Objek ciri kotak tidak berisi referensi / referensi apa pun yang dikandungnya lebih lama (atau sama) 'static". Ini mencegah callback menangkap penduduk setempat dengan referensi.
bluss
Ah saya mengerti, saya pikir!
Timmmm
1
@Timmmm Lebih detail tentang 'staticterikat dalam posting blog terpisah .
pengguna4815162342
3
Ini jawaban yang luar biasa, terima kasih telah menyediakannya @ user4815162342.
Dash83