Write-up on using typestates in Rust

You may be interested in the concept of session types, which is a natural extension of the pattern.

In essence, you first describe an entire session as a state-machine, and then each state becomes a type and each transition a method.

If Rust had a linear type-system, this would be sufficient. Since it has an affine type-system, you need to guard against someone dropping a partially processed state-machine by enforcing a “final” dummy state to be returned as “proof of work”.

3 Likes

Aren’t #[must_use] types a good approximation of linear types? (It’s not an error to drop them, but it sure is a noisy lint)

Not really, because they are not “infectious”. That is, you can stash a #[must_use] type in a Vec and not do anything with the Vec.

1 Like

I wonder if that is on purpose. Personally, I would consider it a bug that I must use a Result, but not, say, a vector of results.

If “infection” is considered the right behavior, then it could be implemented via auto-traits as we do for Send and Sync.

And you can always ignore a #[must_use] with let _ = returns_a_must_use(); or simply drop it with a mem::drop(returns_a_must_use())

Being able to consciously drop a must_use type is probably fine (after all, your code must work if that happens in any case due to panics), what is not fine in my opinion is when it happens silently without a conscious user action or compiler warning…

1 Like

You can also forget a must_use, which may be a problem.

Right, the ability to drop and forget a #[must_use] value means it’s only a suggestion that a value’s life should not end at that point. Fully linear types would not permit this, in theory. Presumably a panic while such an object still exists would need to do some sort of poisoning.

In cases where dropping a state machine without advancing it would be bad, I’ve used a runtime check combined with #[must_use] that panics if the object is dropped before some condition is met. This doesn’t defend against forget.

Basically, the typestate pattern in Rust today provides a way to ensure that state machines are used correctly, but does not fully defend against them being unused, or discarded before completion.

2 Likes

I believe that code must also handle forgetting in order to be 100% correct, as Rust does not guarantee that the destructors of all objects on the stack will be run. Cases that come to mind are stack overflow, Rc/Arc cycles, panic=abort, and a destructor panicking while processing a panic.

Ultimately, destructors in Rust are really a convenience, not something that can be relied upon for correctness. Treating them as correctness-critical was the big mistake made by the original scoped thread API, which had to be scraped from std as a result.

2 Likes

I’d also like to see a possibility for this: https://github.com/rust-lang/rust/issues/61061#issuecomment-495363227

(Recently it was changed for tuples so that (T,) and (u32, T, i32) are must-use if T is must-use.)

1 Like

I largely agree, but it’d need to be controlled by an attribute (which means we’d need to stabilize attributes on generic type parameters). If I’ve just used the must_use type as a phantom, I don’t necessarily want to inherit its must_use status.

Slightly contrived C++ flavored example:

struct SizeOf<T>(PhantomData<T>);

impl<T> SizeOf<T> {
  const SIZE: usize = std::mem::size_of<T>();
}

// SizeOf<Result<i32>> should not be #[must_use]

Anyhoo. I’m getting off topic.

2 Likes