Help understanding io::Error and (lack of) PartialEq


#1

I’m writing a library and the first thing I wanted to do is test it by sending it some malformed input and checking to see if it return the expected error condition.

I was then surprised to find that I can’t compare Errors (or even ErrorKinds) generated by the (defacto-standard) error-chain crate. So I inquired about the standard library and understand that not all Errors implement PartialEq either.

Prevailing wisdom is to use pattern-matching to determine Error specifics, but that is more verbose (or requires a new dependency in the form of the assert_matches crate). Why not just implement PartialEq and be done?

This is summarized well in this comment: https://github.com/rust-lang/rust/issues/34158#issuecomment-224910299.

@lambda goes on to actually implement PartialEq for io:Error, but @alexcrichton wrote that the libs team, after discussion, decided not to merge this: https://github.com/rust-lang/rust/pull/34192#issuecomment-227477665.

@alexcrichton cites reasons that include:
a) "It’s an unfortunate ergonomic loss that io::Result instances can’t be equated, but indicating that io::Error has an implementation of PartialEq is somewhat of a lie because the only type of error that can be equal is two OS errors"
b) that @brson stated “pointed out “equals” in this sense is very loosely defined because the actual error that happened could maybe be entirely different.”

It is indeed that ergonomic loss that motivates this post today.

With my (admittedly newb-by-comparison) understanding of this, my thoughts on the above rationales are:
a) then shouldn’t the other types should compare for equality as false? (I am probably misunderstanding, but don’t see this as a rationale for not implementing PartialEq)
b) not sure I understand this: if this is referring to the same error type with a different .cause(), I would not expect the two types to compare as equal (same as two containers of a given type holding different values wouldn’t be considered equal).

Again, I’m sure I’m misunderstanding Alex and Brian. So with that in mind, can some kind soul help me understand why implementing PartialEq for Rust errors in general (or specifically for std::io::Error) is not a good idea?

Thanks in advance,
Brad


#2

What semantics would you apply to std::io::Error being equal to another? Also, once you provide PartialEq you’re on a backwards compatibility hook to ensure it continues to work indefinitely; no different from any other API in principle, but you have to be sure you have a clear semantic of what equality means.

If you want to define semantics not provided by underlying type, you can wrap it in a newtype and define them there.


#3

@vitalyd the way I’m thinking, std::io::Error isn’t going to be equal to any other error type, so I’d say ‘false’. Unit variants within a given type would only equate to themselves, and variants within a type which contain values would cascade the PartialEq through those values. Only if all returned true would the variant as a whole be considered equal.

But perhaps I’m misunderstanding your question? If so, can you give me an example of the problem you have in mind?

Thank you for the newtype suggestion–I’d not thought of that. I’ll try working that through later today to understand where that could fit in as a solution.


#4

I meant how would you define equality between two std::io::Error instances? The ErrorKind enum is somewhat straightforward, but what about the custom portion? One can construct an std::io::Error with an opaque std::error::Error type as well: https://doc.rust-lang.org/std/io/struct.Error.html#method.new.

I personally think there’s enough ambiguity on the right “universal” semantics here to avoid painting the stdlib into a (potentially awkward) corner.


#5

This is great–thank you vitalyd. Let me take a deeper look at this this afternoon to see if I can come up with something.


#6

Hi, @vitalyd, here is what I would expect:

When comparing two io::Error instances, they compare as equal if and only if:

  • Given Error::Repr::Os variant: i32 values are equal OR
  • Given Error::Repr::Simple variant: ErrorKind (and values wrapped by ErrorKind variant, if any, are equal) OR
  • Given Error::Repr::Custom variant:
    • Custom::kind field: ErrorKind (and values wrapped by ErrorKind variant, if any, are equal) AND
    • Custom::error field:
      • .display() returns identical Results (using memory-resident Debug formatter) AND
      • .description() returns identical byte-for-byte value &str’s AND
      • .cause() returns identical Option variants. If Some(e), all Error::Repr::Custom::error rules (including this one) are applied to e, recursively.

For all other cases, result of equivalence test is false. Please note, “equal”, above, is as defined by PartialEq (not Eq).

I believe this would result in an unambiguous, non-surprising and non-limiting (at least AFAICT, which may not be sufficiently far) comparison behavior, but allow for the desired ergonomics around Errors.

Thoughts?


#7

So I didn’t think about this too deeply, but I’ll say that I think it’s fine … for unit testing purposes :slight_smile:. I don’t think this would be universally accepted as the default way to equate errors. One thing that springs to mind is ErrorKind::Other - two such kinds as enum variants are equal but semantics cannot be assigned because you don’t know, by definition, what happened; comparing strings thereafter extremely fuzzy.

Another thing is some of the ErrorKind variants are fairly generic. ErrorKind::PermissionDenied - to what? File? Directory? http endpoint? Etc. ConnectionRefused - similarly, to what? And so on for several of them.

This is why when you need to filter/classify errors somehow people match on them somewhere close to the failing operation so they know the context in which the error occurred. In that context, you don’t need such deep and heavy equivalence checking that you propose. Once that context is lost, these errors are fairly generic and relying on their textual output is imprecise.

It may make more sense to impl equality for your own errors that are special purposed to your domain such that you can easily reason about their meaning universally (if they allow for it).

Just my $.02


#8

Thanks, @vitalyd. It’s exactly this type of informed context that I am seeking–I’ve been unable to find this kind of context anywhere I’ve looked (and perhaps you’ve guessed by now that I have been looking :)).

Without shooting the messenger, TBH, while what you say makes sense, to my eye it speaks more to a problem in the error definitions than to anything else. I also believe inconsistent PartialEq support discourages people from fully embracing Rust’s incredibly powerful Error handling system. (Coming from C++, I couldn’t imagine how one would propagate errors affordably without exceptions. Now I wouldn’t want to go back.)

Simply ‘not allowing errors to be easily equated’ doesn’t seem to actually solve the potential problems arising from ambiguous error definitons since people subsequently resort to pattern matching when the need compare arises. And it definitely steepens the learning curve for those coming to Rust.


#9

In what contexts are you looking to equate errors? 95% of io::Error introspection I’ve seen looks like

match do_a_thing() {
    Ok(thing) => { ... },
    Err(ref e) if e.kind() == io::ErrorKind::NotFound => { ... }
    Err(e) => { ... }
}

and the other 5% comes up in situations where you need to look at the specific OS-level errno value:

match do_a_thing() {
    Ok(thing) => { ... },
    Err(ref e) if e.raw_os_error() == Some(libc::EINPROGRESS) => { ... }
    Err(e) => { ... }
}

I can’t really think of a time when I have two arbitrary io::Error values and need to compare them.


#10

Yes, I think that’s it exactly. My problem has been wrapping my arms around when I can do this comparison. After having typed out enums manually, I started out using error_def, because it’s almost a direct analog of what I’d been doing manually. But it requires nightly, and I don’t like all the breakage.

So I tried error-chain, but it doesn’t implement PartialEq for the ErrorKinds it generates. For a while I was confused by this, but I think now, that it’s merely an oversight. So I’m looking at submitting a PR to them for this. If I can match ErrorKinds, then that should be good enough to satisfy the scenarios you highlight above.


#11

Update for everyone, @withoutboats has introduced a new crate called Failure (docs, video @ 1:17:36) which addresses a great many of my issues with managing Errors in Rust (with and without error-chain).

On a personal note, one thing that stunned me is how much more comprehensive solution Failure is than the library I was working on to resolve these issues (I still have much to learn! :)). While I had explored the custom_derive path (over a macro_rules!-based approach), I had abandoned it, because I did not think to be so bold as to supercede the Error trait. Now that I see what boats has in mind, I believe he is on the correct path. I have (happily) decided to abandon development of my solution and will put my energy into improving Failure, if there is work that I can do to help.