3

I'm trying to make floating point equality comparisons explicit for my custom classes (exact comparison vs approximate comparison). I can avoid overloading the == operator and force users to call functions like exactly_equal or almost_equal instead but this doesn't work with std algorithms out of the box. I was wondering if there was a nice way to get the best of both worlds by forcing some explicit operator lookup at the call site (kind of like std::rel_ops).

For example, let's say I have this CustomType class and some operator== overloads:

struct CustomType {
    double value;
};

namespace exactly_equal {

auto operator==(CustomType const& lhs, CustomType const& rhs) -> bool {
    return lhs.value == rhs.value;
}

} // namespace exactly_equal

namespace almost_equal {

auto operator==(CustomType const& lhs, CustomType const& rhs) -> bool {
    return std::abs(lhs.value - rhs.value) < 1e-2; // This isn't the "right" way, just an example.
}

} // namespace almost_equal

Using this class I can do something like this, which compiles and runs fine:

auto main() -> int {
    auto const a = CustomType{1.0/3.0};
    auto const b = CustomType{0.3333};

    {
        using namespace exactly_equal;
        if (a == b) {
            std::cout << "Exact!" << std::endl;
        }
    }
    {
        using namespace almost_equal;
        if (a == b) {
            std::cout << "Inexact!" << std::endl;
        }
    }
    return 0;
}

The thing that doesn't work is the argument-dependent lookup when using std functions:

auto main() -> int {
    auto const items = std::vector<CustomType>{{1.0/3.0}};
    auto const value = CustomType{0.3333};

    {
        using namespace exactly_equal;
        // error: no match for 'operator==' (operand types are 'const CustomType' and 'const CustomType')
        if (std::find(items.begin(), items.end(), value) != items.end()) {
            std::cout << "Exact!" << std::endl;
        }
    }
    {
        using namespace almost_equal;
        // error: no match for 'operator==' (operand types are 'const CustomType' and 'const CustomType')
        if (std::find(items.begin(), items.end(), value) != items.end()) {
            std::cout << "Inexact!" << std::endl;
        }
    }
    return 0;
}

Most suggestions for adding operators involve some sort of base class with operator overloads or a pattern similar to using namespace std::rel_ops (which also fails argument-dependent lookup). I am not sure a base class would help for this problem and ideally I would want to use this solution on classes I don't own (and can't modify).

I could use explicit functions and types for my data structures and algorithms:

struct ExactlyEqualPredicate{
    auto operator()(CustomType const& lhs, CustomType const& rhs) const -> bool { 
        return lhs.value == rhs.value; 
    }
};

struct AlmostEqualComparator{
    CustomType value;

    auto operator()(CustomType const& other) const -> bool { 
        return std::abs(value.value == other.value) < 1e-2;
    }
};

auto main() -> int {
    auto const items = std::vector<CustomType>{{1.0/3.0}};
    auto const value = CustomType{0.3333};

    if (std::find_if(items.begin(), items.end(), AlmostEqualComparator{value}) != items.end()) {
        std::cout << "Inexact!" << std::endl;
    }

    auto custom_map = std::unordered_map<CustomType, 
                                         std::string,
                                         std::hash<CustomType>,
                                         ExactlyEqualPredicate>{
                                             {CustomType{0.3333}, "Exact!"},
                                         };

    if (auto iter = custom_map.find(value); iter != custom_map.end()) {
        std::cout << iter->second << std::endl;
    }

    return 0;
}

but this becomes quite verbose and breaks down when I have nested containers (std::vector<std::vector<CustomType>>) or other complex structures. Template classes seem to have a couple extra rules for argument-dependent lookup but a nice way to accomplish this with templates was not immediately clear to me.

The goal is to keep the equality comparison explicit (exact comparison vs approximate comparison) at the call site without making usage of external functions and std algorithms overly difficult and/or verbose. Is there a solution I'm missing or some black magic I haven't thought of that might make this possible? I currently use C++17 in my codebase.

Thanks!

Logan Barnes
  • 179
  • 12
  • 3
    That's an interesting question, but having an operator whose behaviour silently changes basing on context is kind of scary, as user I would prefer a more explicit and controllable interface. – MatG May 01 '21 at 12:04
  • It's a good point and perhaps we'll stick with the explicit functions we currently use. I'm mostly curious at this point what sort of things are in the realm of possibilities. – Logan Barnes May 01 '21 at 12:18
  • sorry for having been so blunt ,but you must think of your users (probably you in 3 months). Do you really think it's logical to have an operator == to mean almost equal ? – engf-010 May 01 '21 at 12:52
  • Yeah I don't disagree. We're trying to find a balance. We want to prevent bugs from junior developers who don't yet have the nuances of floating point comparisons ingrained but we also don't want to annoy senior devs with a bunch of extra verbosity. Perhaps we will just have to make a choice one way or the other and deal with the consequences. I am still curious if something like what I proposed is even possible though. – Logan Barnes May 01 '21 at 13:03
  • `if (a =almost= b) {`, using an `=almost=` infix operator, using the technique here: https://stackoverflow.com/a/36359574/4641116 – Eljay May 01 '21 at 14:14
  • Also keep in mind Knuth's algorithms for almost equal: https://stackoverflow.com/a/253874/4641116 – Eljay May 01 '21 at 14:19
  • Keeping the above in mind, the way you probably ought to do it is have a few more helper types, and use `if (Almost(a) == b) {` or `if (Exactly(a) == b) {`. So explicit rather than implicit context at the callsite. – Eljay May 01 '21 at 14:30
  • haha that infix idea is amazing. – Logan Barnes May 01 '21 at 15:05

1 Answers1

1

You have already enumerated the plausible, flawed approaches with the exception of wrapper types that define their own comparisons. They might store pointers or references to the underlying objects to avoid copying, although even that would not allow algorithms to interpret containers differently. For that, the modern solution is to use Ranges (perhaps as included in C++20) with its composable transformations and projections (much like in your last approach).

Davis Herring
  • 36,443
  • 4
  • 48
  • 76