Meticulous - a tiny crate for better unwrapping

I often meet the problem where I want to be more precise in defining unwrapping semantics. Sometimes it's something that you should fix later, sometimes, errors should never happen according to the expected behavior, or there were preliminary checks that already validated that it will not fail. I've built meticulous create to treat each case separately and simplify code readability and reviewing.

7 Likes

This is a neat little crate.

Have you considered implementing ResultExt for Option<T>, too?

4 Likes

I really like the todo() method, and could see myself using it. I do wonder though what assured() and verified() bring over expect()?

2 Likes

expect() doesn't tell you why the programmer expected the outcome to be Ok(_). assured() and verified() document why the programmer had that expectation.

Specifically, verified() tells you that there is expected to be some form of conditional above that guarantees that the result will always be Ok(_). During code review, I can question that assumption based on the conditionals I see, and it's reasonable to expect the original developer to explain why you cannot get here with something that's not Ok(_) - at the most trivial example, you get:

if res.is_ok() {
    return res.verified("Checked is_ok() above");
}

For a less obvious (but still trivial) example where verified() can actually panic, you could use this with a call to Arc::try_unwrap, on the basis that you have checked that Arc::weak_count(&arc) == 0 && Arc::strong_count(&arc) == 1. This check is supposed to ensure that you have the only strong reference, but it's possible that when you called weak_count(), there were two strong references, another thread downgraded their strong reference to a weak one before you called strong_count, getting you weak_count() == 1 and strong_count() == 1, and then before you called try_unwrap, the other thread upgraded its weak reference again. I can bring up this order of events in code review, and be told why it can't happen (or have the developer using verified go back and change things).

In contrast, assured() tells you that the assumption that's been violated is not one that's checked in the code. For example, if I've used Stdio::piped to set up the input to a Command, I might assume that I can write up to 4096 bytes of input to the pipe without failure, because a Linux pipe accepts up to 4096 bytes even if the recipient hasn't called read yet. This is also a bad assumption, since the write can fail if the other end of the pipe has been closed.

You'd also use it with things like let x: u32 = iter.size_hint().0.try_into().assured("usize of an iterator lower bound fits in a u32"). This is a safe assumption on a 32 bit platform, but on a 64 bit platform, you could get surprised.

4 Likes

Thanks! I don't know what the different semantics of Option unwrapping are. Do you have any ideas of what methods can be implemented?

Thanks for the detailed response!

I'm also thinking about more advanced things for the next version. assured should be used based on some assumption, which intends to make unwrapping safe, e.g.:

let u: usize = random();
let a: u8 = (u % 100).try_into().assured("100 < 255");

My idea is that this condition 100 < 200 can be written in some "assertion" form, which can be later tested by the test runner and/or through criterion-like approach. It could be something like this (pseudo-code):

define_assurance!(
  Mod100AlwaysU8,
  assert!(100 < u8::MAX),
)

let u: usize = random();
let a: u8 = (u % 100).try_into().assured(Mod100AlwaysU8);

Something similar may be implemented through arbitrary or criterion if the case is more complex.

The define_assurance! macro will generate tests so that potentially unsafe assumptions will be tested as a part of test runs.

1 Like

The semantics of Option unwrapping are similar to those of Result unwrapping::

  • If the variant is Option::Some(v), then the value is v (compare Result::Ok(v)).
  • If the variant is Option::None, then the program panics (compare Result::Err(e))

There's also Result::ok and Option::ok_or to translate Result to Option and Option to Result, which follow the same pattern. In the case of translating Option to Result, you must supply the error value so that Option::None can become Result::Err(e).

One difficulty you may run into (although I like the idea in principle) is that not all of the assertions I might want to make can be machine-checked in a test case.

For example, my assurance may be that a given API endpoint returns a string that can always be converted into a u128; this may be a documented fact about an API endpoint, but the only way to test this assertion is to make a request, extract the string, and see if u128::try_from(endpoint_string) is an error.

You might get inspired by looking at the various things that proptest lets you state about property-based testing. I can tell proptest to assume that something is true (thus if it's not true for a given expression, that expression is not a valid input to the testing matrix), I can assert that a condition holds for all valid inputs (which causes proptest to hunt for counterexamples), and I can give proptest strategies for finding counterexamples.The limitations of property based testing are also worth noticing.

Sure, I meant protest, not criterion :man_facepalming:

1 Like

I have a suggestion:

std::time::SystemTime::now()
    .duration_since(std::time::UNIX_EPOCH)
    .unwrap()

Here I would like to replace unwrap with assured, and since I don't even know in what case it could fail, I don't want to add a panic string here (because I don't know what to say), but if it fails, I want to print the original error.

So I think it would be useful to also have methods like this: (but I don't know how it should be named)

fn assured(self) -> T {
    self.unwrap_or_else(|err| {
        panic!(
            "the success was expected to be assured, but the error was returned: {:?}",
            err
        )
    }
}

Would your panic string not be "now() should always be after the Unix epoch"?

It could be, it makes sense, but it's not helpful: I may want to know why it failed, not only what failed. (ok this was not a good example. A better example would be opening a file: there are many possible errors (I/O, permission, non-existence, filesystem corruption...) and "this file should be readable" is not enough)

If "this file should be readable" isn't enough context, then you're probably in the place where you need decent error handling (something like miette or eyre, perhaps anyhow at the simpler end), not just panics.

The space meticulous fills is semantic panicking - all you want to do is crash and burn because you've determined that your program's view of what is possible does not match reality, but you want the maintainer who has to handle this mismatch to have some idea why you're in this state. You could just use unwrap meaning "the world is wrong and I don't know why", but meticulous provides todo meaning "this is a reasonable state, but I've not thought about how to handle it", verified meaning "there is a bug, because the program checks that this state can't happen, yet it did happen", and assured meaning "I have good reason to believe that this state can't happen, but it did anyway".

In all cases, though, unwrap, expect and the extras from meticulous are meant for cases where this should not be possible right now, not for things that are reasonably possible but that prevent the program from running. Now, one of those "this should not be possible right now" cases is "it can probably happen, but at this stage of development I haven't thought about how to handle it", but for cases where you have thought about it, panicking is not the right answer - error handling is.

2 Likes