StephenCleary / Comparers

The last comparison library you'll ever need!
MIT License
427 stars 33 forks source link

All-Members Comparer #26

Closed StephenCleary closed 5 years ago

StephenCleary commented 5 years ago

Some developers like to have comparers for their types that treat each of its members as a "key". Some alternative comparison libraries actually have this as the default behavior, e.g., OpenCompare.

TL;DR: Nito.Comparers does not directly support this, but it's possible to write your own code to have this behavior.

Design Problems

The first problem with having this behavior by default is that it is seldom the desired behavior. An all-members comparer defines "equivalent" as "nothing has changed", which is not always what the code wants "equivalent" to mean.

Consider a type Circle that has X, Y, and Radius properties. In this case, we could say "equivalent" means X, Y, and Radius are all equal, and in this case an all-members comparer makes sense; if any of those values are different, it's a different circle. But what about a Person? If a person changes their PhoneNumber, are they the same person? To me it makes more sense to have "key" properties that are explicitly specified as such; e.g., an entity Id.

Furthermore, it is often useful to have different parts of the code compare objects differently. With the Circle example, one part of the code may want to sort by size (Radius) only; another part of the code may want to sort by distance from the origin (a combination of X and Y). Person objects may be sorted by birthday in one part of the code, and grouped by zip code in another, and equated by Id in another. In my experience, it's a better design to have each part of the code explicitly specify its desired comparer.

Implementation Problem: Which Members?

One of the big questions when implementing an all-members comparer is: Which Members? In the general case, this becomes a tangle of custom attributes and/or options for opting in and out at various levels.

Do we compare properties? What about computed properties? What about fields?

If members are complex types, do we descend into them? Collections are relatively straightforward, but what about Parent kinds of properties, where Parent contains a collection that includes the current instance? A thorough all-members comparer should have code to detect and break out of reference loops like this.

A truly generic all-members comparer would end up looking a lot like a serialization library, with all the complexity that comes with that.

Implementation Problem: Order

Member ordering is compiler-dependent. This means that a list of members for a type may have a different order depending on compiler/runtime version. So, if an app orders objects with an all-members comparer and saves that collection in persistent storage, then recompiling (or updating the runtime) may make the next invocation of the app with the same all-members comparer use a different ordering. It's possble to work around this behavior by inventing ordering rules for members used internally by the comparer.

There's a second problem with all-member comparers: if a member only supports equality comparison rather than ordering, then it's not possible to build a full ordering comparer with valid semantics (i.e., taking that member into account only for equality).

For this reason, it generally makes sense to restrict all-member comparers to be equality comparers only.

Conclusion

Due to the complexity of maintaining a generic all-members comparer, and due to the limited usefulness of such a type, Nito.Comparers does not provide an all-members comparer out of the box.

However, any given codebase may choose to make simplifying decisions for its own code, which makes an all-members comparer feasible. The following code defines a simple all-members equality comparer by comparing all properties (ignoring fields) and always "drilling down" into reference types:

https://github.com/StephenCleary/Comparers/blob/806620bdad657597f2f07c2d82e5a8e115ae0417/test/UnitTests/Examples/AllMembers.cs#L55-L70