Using optional callbacks in Rust

Moderator note: split from How to become a Rust ninja?

I can't speak to becoming a ninja, but one thing I'd add to the list is how to store and pass optional callbacks.

It's bread-and-butter beginner stuff in JS (or even C if we squint), but a shockingly advanced topic in Rust.

Consider all the prerequisite knowledge involved here and how long it would take to explain what's going on to someone new to Rust... I'm not even that new and I struggle with understanding the exact reasons behind the requirements here in detail:

playground

struct State {
  name: String,
  //Box, trait object, and Fn Traits
  on_click: Option<Box<dyn Fn(String)>>
}

impl State {
  //'static as a trait bound
  fn new(name: String, on_click: Option<impl Fn(String) + 'static>) -> Self {
    Self {
        name,
        //why can't I just do .map(Box::new)... what's with "as _" ?
        on_click: on_click.map(|f| Box::new(f) as _)
    }
  }
  
  fn evaluate(&self) {
      if let Some(on_click) = self.on_click.as_ref() {
          on_click (format!("got click from {}", self.name));
      }
  }
}

fn main() {
    //this is fine, pass a Some(closure), cool...
    let state_a = State::new("A".to_string(), Some(|s| println!("state says: {}", s)));
    
    //this is the part that **REALLY** bothers me and is a HUGE wart atm
    //I'm sure there's good technical reasons - but this gets SUPER awkward
    //for "larger" traits, like functions with returns, multiple parameters, etc.
    //I would love to learn that I'm just completely doing something wrong here
    //But I can't find a way to just pass `None` here the way I'd pass `null` in JS
    
    let state_b = State::new("A".to_string(), None::<fn(String)>);
    
    
    state_a.evaluate();
    state_b.evaluate();
    
    //Output: state says: got click from A
}

//Alternative "simpler" approach but then storing this State in a
//higher-level struct means infecting it with the generic too
//and ultimately that can get *very* hard to manage
//Anyway it doesn't solve the `None::<fn(String>)>` problem

/*
struct State<F: Fn(String) + 'static> {
  name: String,
  on_click: Option<F>
}

impl <F: Fn(String) + 'static> State<F> {
  fn new(name: String, on_click: Option<F>) -> Self {
    Self {
        name,
        on_click
    }
  }
  
  fn evaluate(&self) {
      if let Some(on_click) = self.on_click.as_ref() {
          on_click (format!("got click from {}", self.name));
      }
  }
}
*/

Take a look at

1 Like

why can't I just do .map(Box::new)... what's with as _?

If you map(Box::new) over the Option<impl Fn(String)>, then the return type will be the generic Box<impl Fn(String)>, which is not the same as Box<dyn Fn(String)>. This could be an implicit coercion, but closures are not (yet) coercion sites. AFAICT this is at least being considered, but I don't know of a concrete RFC.

I'm sure there's good technical reasons

There are indeed. If the Option is generic over its wrapped type, then what concrete type should be inferred for None? The compiler can't just make up a new type out of thin air… and even if it could, how would it know that such a type is indeed correct and does what the programmer meant? You have to specify the concrete type because None in itself doesn't have any information about the wrapped type, since it doesn't mention it at all (as it contains no associated value).


Anyway, there's a much easier solution to your problem: don't use Option at all! Instead, define a convenience constant for a no-op callback, like this. This approach actually optimizes for the common case when there is a callback, as it removes the branching necessary for unwrapping the Option.

struct State {
    name: String,
    on_click: Box<dyn Fn(String)>,
}

const NOOP: fn(String) = |_| {};

impl State {
    fn new(name: String, on_click: impl Fn(String) + 'static) -> Self {
        Self {
            name,
            on_click: Box::new(on_click),
        }
    }

    fn evaluate(&self) {
        (self.on_click)(format!("got click from {}", self.name));
    }
}

fn main() {
    let state_a = State::new("A".to_string(), |s| println!("state says: {}", s));
    let state_b = State::new("A".to_string(), NOOP);

    state_a.evaluate();
    state_b.evaluate();
}

Depends on the use-case, there may be other reasons why you’d want the option, but indeed if it isn’t needed, one should probably avoid it.

Nonetheless, in either case I’d avoid function pointer types when they’re not necessary. In case of the Box<dyn Fn(…)>, converted from Box<fn (…)> requires allocation and introduces an extra level of indirection for the function call, while using a function type doesn’t. So really, the right approach would be to just do

fn no_op(_: String) {}

let state_b = State::new("A".to_string(), no_op);

which does not require allocation for the Box (because the type of no_op is zero-sized).

:thinking: Generalizing no_op would work, too…

fn no_op<T>(_: T) {}

but now we’ve re-implemented mem::drop. So really, you could just do

let state_b = State::new("A".to_string(), drop);

(although that might look slightly confusing to readers)

6 Likes

Thanks @H2CO3 - great tips! I'm gonna have to start using that... I've been doing something pretty similar in defining a NOOP type alias, but didn't think of getting rid of the Option. Good stuff!

Notwithstanding that (which I really do appreciate), I'd still find it very hard to explain all of this to someone new to Rust... touches on many parts of the language at once!

(the need to wrap self.on_click in parens adds another one, fwiw)

To be clear - I'm not criticizing the language, I get that there's tradeoffs and the language designers are passionate about improving ergonomics wherever possible.

Just... for someone coming to use Rust for wasm especially, this is a genuinely hard hill to climb over imho.

I see, but I don't think that's even specific to Rust. Writing practical, correct, elegant, efficient code requires a lot of experience in any language. In general, doing something realistic in any field of science requires a lot of experience. I don't think it is a realistic expectation to change that situation, and no amount of language features or documentation will cause beginners to completely and effortlessly absorb every prerequisite in one go.

To that end, I find continuing "Rust is hard" discussions largely pointless – nothing can replace learning and experience even when educators have the best of intention and best of resources. Newcomers will always need to put in a reasonable amount of effort anyway.

3 Likes

Good catch!

Just one comment to this: that would work in this specific case, but it doesn't work in general, when the callback has more than one argument. Consequently, for reasons of consistency and style, I'd go with the custom no-op function anyway, even when only one argument is needed.

I almost agree with you, but...

Despite it is true that you need to study a lot and you have to commit yourself diving into a new scientific topic, on the other hand it is also true that if you have good books, good references your commitment could be more efficient and gratifying.

Don't take me wrong, I think Rust has a good documentation and a great community (just look at this thread and how you are helping people like me :clap:), but I also think that having some good document explaining advanced features (that sooner or later you will find along your path) could help a lot people that is getting into this new language.

Just look at this article Common Rust lifetime misconceptions, maybe it could be obvious to expert Rust developers, but it is so precious to beginner like me! So IMHO it would be great having such kind of article available and maybe linked by official documentation

I wholeheartedly agree. I guess what I'm saying is that "how to store and call optional callbacks" is a good topic for "How to become a Rust ninja" due to all the parts of the language it touches - and merely noting that this is especially strange to someone coming to Rust from JS for the purposes of writing WebAssembly, since it's almost the most basic thing you do in JS.

Shouldn't we instead focus on teaching those people how to design software without ubiquitous optional callbacks? Rather than introduce them to a dozen new concepts so they can go on writing JS in Rust.

3 Likes

I think that's too broad of a sweeping statement...

Option is, in many cases, the right thing to do here. e.g. "if no callback is supplied, make the button inactive". Very simple to do that with match or is_some or whatever. Directly comparing to some noop function pointer is not as elegant imho. But I guess this is a bit subjective...

(re the callback part of it, that's just the foreign API we're given to interface with, no getting around that- if you want something to happen when a button is clicked you're storing a callback somewhere somehow. Turning it into a Stream-based API and having a framework to deal with that is of course possible, but that's a totally separate discussion)

Indeed, something like this can be a valid reason to use Option. Note that in the case of using Option, you can even pass a zero-sized Option type (that cannot ever be Some(…)), as demonstrated in the other thread I linked above.

#[inline]
pub fn no_op() -> Option<impl Fn(String) + 'static> {
    enum Void {}
    None::<Void>.map(|void| move |_: String| match void {})
}

fn main() {
    // the return value of `no_op()` really is zero-sized
    dbg!(std::mem::size_of_val(&no_op())); // → 0
}

You can then pass that to State::new(…, no_op()) which will guarantee the .map inside of State::new to be trivial. But wouldn’t be suprised if rustc managed to optimize any additional overhead away even without such an approach, too, so it might be overkill. If you use fn(), it’s even possible to define an Option<fn(String)> constant, and I guess that’s what your statement

was suggesting anyway.

1 Like

I really hope I never have to write code like that. In fact I refuse to. It's unintelligible. Or at least imposes a gigantic cognitive overload to do what seems like such a simple thing.

Right. Eventually, arguably, we should have an implementation for !: Fn(String) in the language, and thus the code would become

#[inline]
pub fn no_op() -> Option<impl Fn(String) + 'static> {
    None::<!>
}

or

#[inline]
pub fn no_op() -> Option<!> {
    None::<!>
}

or

const NONE: Option<!> = None;

or you’d just use None::<!> directly. (! is the “never” type.)

Furthermore, if that’s at all possible to realize, I’d advocate for rustc to try considering ! as an implicit default whenever a type is left ambiguous and ! does the job. Then you could just write None.

1 Like

It's not clear to me that is helping the syntactic and semantic cognitive overload I was getting at.

Apart from the ability to make code portable I always thought the main argument for his level languages was making it software easier to write and easier for others to understand.

When I see examples of Rust "line noise" as discussed in this thread I start to think something has gone badly wrong. It starts to feel like writing in assembler would of more clarity!

I had to look up what ! means in Rust. Apart from the C like operators and macro invocation I learn that it is something to do with:

Always empty bottom type for diverging functions

I cannot even begin to guess what that is trying to tell me. Are you proposing yet another meaning to overload it with?

Not to worry, I mostly write Rust as if I was writing C. Works fine :slight_smile:

That’s why I provided a link:

For more information about uninhabited types, see this chapter in the nomicon:

Exotically Sized Types - The Rustonomicon


I’m not proposing anything new here myself, this meaning of ! is already stable as a return type. You can write functions

fn foo() -> ! {
    panic!()
}

or

fn bar() -> ! {
    loop {
        println!("hi, again!");
    }
}

in stable Rust.

I agree that ! having 3 meanings can be considered a bit much, but I guess it is what it is now.


Another common example of an empty type is convert::Infallible.

3 Likes

Thanks.

But, but, as a non-expert Rust user it's very unlikely that I would ever find that "never type" page. How would I even know I should be looking for "never type"?

As it happens while you were typing that I tried to find a description of "!" in The Book. Eventually found it in section 19.3: "it stands in the place of the return type when a function will never return. " Which sounds straight forward enough and the example clearly motivates the reason for its existence.

Why doesn't Appendix B: Operators and Symbols just say that instead of that gibberish about "Always empty bottom type for diverging functions". Which introduces at least two concepts, "bottom type" and "diverging function", I have never heard of in decades of programming. The likely third being "always empty" ?

Perhaps shamefully I had never looked at Chapter 19 Advanced Features.

By the way, those terms do have Wikipedia articles


Nonetheless, I do agree with you

that that table could be more helpful by using less technical terms and/or linking to more detailed descriptions of what’s meant.

1 Like

One approach is to enter ! in the standard library docs search bar:


And the “Always empty bottom type for diverging functions” description at least works for Google search:

3 Likes

They are standard terms in type theory. They have been around for decades.

That's the same as "bottom type".

2 Likes