Idea: “Undroppable” types

Say, I want to report result and execution time of some operation. I can use struct for convenience. It rememebers starting time and reports result:

struct Metric {
  start: std::time::Instant,
}
impl Metric {
  pub fn new() -> Self {
    Metric { start: std::time::Instant::now() }
  }
  pub fn ok(self) {
    send_metric("ok", self.start.elapsed());
  }
  pub fn error(self) {
    send_metric("error", self.start.elapsed());
  }
}

fn main() {
  let metric = Metric::new();
  // …
  metric.ok();
}

However, it is easy to forget to call either ok() or error():

fn main() {
  let metric = Metric::new();
  if (something()) {
    return; // ooops
  }
  // …
  metric.ok();
}

It is possible to overcome this problem by wrapping function into another one which returns result, because returning is forced by compiler:

fn main() -> Result<(), ()> {
  let metric = Metric::new();
  match do_main() {
    Result::Ok(()) => metric.ok(),
    Result::Err(()) => metric.err(),
  }
}

fn do_main() {
  if (something()) {
    return Ok(()); // OK
  }
  // …
  return Ok(());
}

However, this is verbose and not very convenient, especially when you want to cover just some part of function body. In this case you have to extract part to new function or turn it into closure, and then you have problems with early returns.

I have idea which may help to solve this problem beautifully: “undroppable” types. Well, they are not really undroppable at all, but they can be dropped from inside their own methods only, not from outside:

struct Metric {
  start: std::time::Instant,
}

// Somehow mark struct as undroppable, syntax doesn't matter now, let it be:
impl !Drop for Metric;

impl Metric {
  pub fn new() -> Self {
    Metric { start: std::time::Instant::now() }
  }
  pub fn ok(self) {
    send_metric("ok", self.start.elapsed());
    // OK, metric is dropped here
  }
  pub fn error(self) {
    send_metric("error", self.start.elapsed());
    // OK, metric is dropped here
  }
}

fn main() {
  let metric = Metric::new();
  // …
  if (something()) {
    return; // Error: `metric` struct created at … can't be dropped,
            // invoke method which accepts `self` by value: `ok`, `error`.
  }
  metric.ok();
}

What do you thing?

3 Likes

How about this?

#[must_use]
struct Metric {
  /* ... */.
}

The must_use attribute is used by Rust's standard Result type in order to tell the compiler that the caller of a function which returns Result must check that return value. It seems to fit your use case of "must call either ok or error on the returned metric" very well.

3 Likes

Yes, #[must_use] is a cool feature, and it will definitely help in my simplified case, however, if I add any method accepting &self, compiler will not warn you that you haven't called ok/error:

impl Metric {
  pub fn debug(msg: &str) { … }
  pub fn ok(self) { … }
  pub fn error(self) { … }
}

fn main() {
  let metric = Metric::new();
  metric.debug("yay");
  // No error here, despite neither `ok` nor `error` is called
}

However, undroppable type feature would still work in this case. So, basically, it's like having several distrinct destructors and you must explicitly choose which one you want to use.

1 Like

I see. I think what you are looking for is what the type theorists call "linear types". If so, there are quite a number of people who have examined this issue before in the context of Rust (just one example), so I will leave them reply.

1 Like

This time I checked #[must_use], and it seems it doesn't work, because:

  • assigning to variable is counted as usage,
  • this is just a warning, not error.
1 Like

Why not implement Drop for Metric that reports the timing? If ok isn’t called on it, then its drop reports an error and otherwise it’s successful.

1 Like

Because if you forget to call ok/error, then your successful early return will be reported as error, which is wrong.

It is possible to control this in runtime, but it would be even better if compiler stops you from doing wrong things.

For run-time error reporting, a better approach would be to provide a Drop implementation for Metric which panics, since any situation where Metric is dropped is a usage error. But I agree with @kriomant that compile-time error reporting would be better here.

Ok. How about making the API use a closure that returns a Result: Metric::do_with<F: FnMut() -> Result<...>>? That would force the closure to indicate an outcome and there’s an implicit scope of execution here and no binding that is must_use.

3 Likes

Hi,

this idea has indeed been around for long, also for other reasons. Take for example this sketch:

struct Filelike {
  ptr: *mut FilePtr
}

impl Filelike {
  fn open() -> MyResult<Filelike> {
     //...
  }
  //... operations
  
  fn close(self) -> MyResult<Filelike> {
      internal_close(self.ptr)
  }
}

impl Drop for Filelike {
    def drop(&mut self) {
       internal_close(self.ptr);
    }
}

In case of an implicit drop, I cannot report the error that might happen during closing the file to the outside world, as drop happens out of normal control flow. So forcing consumption to get rid of the type would be very useful.

It's also a feature that would benefit session types.

But. There's a lot of buts around this. Alexis has a great writeup: https://gankro.github.io/blah/linear-rust/

3 Likes

Result<> isn't enough, because besides reporting whether it is needed to call ok or error it is also needed to report whether early return from function should be performed:

enum MetricReport { Ok, Error }
enum BlockResult<V, R> { Value(V), Return(R) }
struct Metric<V, R> {
  report: MetricReport,
  result: BlockResult<V, R>,
}

And function isn't enough too, becase it can't return from outer function, so we need macro:

pub fn metric<V, R, F: FnOnce() -> MetricReport<R, V>>(f: F) -> BlockResult<V, R> {
  let start = std::time::Instant::now();
  let m = f();
  let duration = start.elapsed();
  match m.report {
    MetricReport::Ok => report_metric("ok", duration),
    MetricReport::Error => report_metric("error", duration),
  }
  m.result
}

macro_rules! metric {
  (f) => {
    …
    match metric(f) {
      BlockResult::Value(v) => v,
      BlockResult::Return(r) => return r,
    }
    …
  }
}

fn func(arg: u32) -> u32 {
  if let Some(v) = cache.find(arg) {
    metric.ok();
    return v;
  }

  let y = metric!(|| {
	  if (arg < 3) { return BlockResult::Return(0); }
	  let x = 2 * arg;
	  let y = 3 - x;
	  BlockResult::Value(y)
  }

  cache.add(arg, y);
  y
}

Isn't it too much changes to just use metric/transaction/whatever in a safe way? And now just imagine how this all will look like if you want to use two such variables.

1 Like

I have seen traits used to define undroppable types. My own example of this is in Serde the argument to the Serialize trait is an undroppable linear type.

trait Serialize {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
        where S: Serializer;
}

The only possible way to implement Serialize is by calling exactly one method on the Serializer and returning the result. You are forced to call at most one method because they all accept self by value, and you are forced to call at least one method because there is no way to get the S::Ok associated type otherwise.

6 Likes

That’s very clever! :+1:

Ok, so this is basically “wrapping function into another one which returns result” trick mentioned before. It's clever, but verbose and not very convenient, especially when all you want from required call is it's side effect.

But I understand this is best Rust can offer right now.

Went reading “The Pain Of Real Linear Types in Rust”…

2 Likes

Ok, I've read “The Pain Of Real Linear Types in Rust”, and conclusion drawn is that used-at-least-once types are possible and useful. The only real downside is that full support for such types requires massive stdlib extending/rewriting. However, it seems there are many scenarios where they are useful even without full stdlib support.

Is there already any issue / RFC / discussion regarding such types?

1 Like

The problem is that this is rather infectious and lacking stdlib support especially on collection types and Option/Result would be an issue.

That said, there's no RFC and if you'd like to write one, that would be a good place for such discussion.

I'd very much like to encourage you to, it's a recurring topic and it will have to be discussed sooner or later.

4 Likes