3 ms·
> The problem with this is that it is easy for the programmer to forget to write the obvious final step. It wouldn’t be a problem in a different, imaginary lang
by chrismorgan 3y ago
> The problem with this is that it is easy for the programmer to forget to write the obvious final step. It wouldn’t be a problem in a different, imaginary language or IDE where the machine would recognize a library with an obvious final step, and signal a compiler/IDE error when the programmer forgets it, or if it put the obvious final step into the code for the programmer. But this is fantasy.
It’s something you’d get with a linear type system (use values exactly once), but they’re not present in any particularly popular languages. I suspect Haskell might be able to achieve roughly this, but I’m not certain.
The closest I know definitely about is Rust, which has an affine type system (use values at most once). You can mark types and functions with the #[must_use] attribute, which says that you must do something with values of the type or returned from the function at least once, or it’ll produce a compiler warning. But what’s needed in these cases is that you must consume the value. I have often wished for this in Rust, but I doubt it would be accepted into the language (and so haven’t tried proposing it formally) because of its comparatively niche usefulness due to the interactions with destructors and panic unwinding. (Basically, you couldn’t actually rely on it for resource management: if you wanted it for something like “end database transaction, but actually do something with an error code rather than silently dropping it like a destructor”, you’d still actually need to implement a regular no-return-value destructor, so is requiring that the user write `tx.commit()?;` actually useful, given that a panic before that line would lead to effectively `drop(tx);` happening instead?) For most purposes, #[must_use] is good enough. But it still disappoints me, because I think most places that #[must_use] gets used, they’d actually be better suited by a #[must_consume]. Hmm… maybe I should contemplate proposing it after all.
But yeah, the lambda approach is often a good solution.
- weinzierl 3y agoI was thinking about the Typestate Pattern because it would also take care of the order of the intermediate steps. Is it possible to mark the last type with #[must_use] and get a compile time error if I do not go through all steps? EDIT: I found this: "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." [1] So I guess #[must_use] is not enough and that's what you were saying in your comment already. EDIT 2: I found another good write-up of this topic: "The Pain Of Real Linear Types in Rust" [2] [1] https://users.rust-lang.org/t/write-up-on-using-typestates-in-rust/28997/28?u=weinzierl https://users.rust-lang.org/t/write-up-on-using-typestates-i... [2] https://faultlore.com/blah/linear-rust https://faultlore.com/blah/linear-rust
- chrismorgan 3y agoYou don’t need anything along the lines of typestate, though it can fit in fine as well: having all your functions consuming self and returning a new Self (except the consuming “destructor”) is sufficient. It’s just an ergonomics pain since you need to assign it to something every time: #[must_use] struct Transaction; impl Transaction { fn new() -> Self { … } fn action(self, …) -> Self { … } fn commit(self) -> Result<(), …> { … } } let mut tx = Transaction::new(); tx = tx.action(…); // ← `tx =` is the difference. // (Any panic at this point will roll back the transaction via Drop, // but any errors in the process will be swallowed or abort or who knows what, there’s no standard.) tx.commit()?; // unused-must-use warning on the last assignment of tx (`tx = tx.action(…);`) if this line is absent A hypothetical #[must_consume] would let it be like this: #[must_consume] struct Transaction; impl Transaction { fn new() -> Self { … } fn action(&mut self, …) { … } fn commit(self) -> Result<(), …> { …; drop(self); Ok(()) } } let mut tx = Transaction::new(); tx.action(…); // (Same panic behaviour.) tx.commit()?; // unused-must-consume warning on the last assignment of tx (`let mut tx = Transaction::new();`) if this line is absent With putting the state into the type, you’d be limited to annotating your entire generic type with #[must_use], not individual states. (#[must_use] won’t propagate through generic parameters, nor should it.)
- weinzierl 3y agoThat makes it clearer. Thanks!