42

I've read about and understand floating point round-off issues such as:

>>> sum([0.1] * 10) == 1.0 False >>> 1.1 + 2.2 == 3.3 False >>> sin(radians(45)) == sqrt(2) / 2 False 

I also know how to work around these issues with math.isclose() and cmath.isclose().

The question is how to apply those work arounds to Python's match/case statement. I would like this to work:

match 1.1 + 2.2: case 3.3: print('hit!') # currently, this doesn't match 
3
  • Be warned that *.isclose are heuristics, and can themselves fail in unexpected ways.
    – TLW
    Jun 13 at 23:48
  • For instance, math.isclose(a, b) and math.isclose(b, c) but not math.isclose(a, c). (E.g. with default settings, a,b,c = 1, 1.0000000005, 1.0000000015)
    – TLW
    Jun 13 at 23:52
  • 5
    The isclose() functions have precise definitions and are completely controllable. They are offered by the standard library as the accepted way to make comparisons for nearby floats. No one made the claim that isclose() is transitive nor is that relevant to this use case — it is a red herring to create a vague and unactionable sense of worry. If there is a better solution, please post it.Jun 14 at 16:20

3 Answers 3

Reset to default

Introducing: Trending sort

You can now choose to sort by Trending, which boosts votes that have happened recently, helping to surface more up-to-date answers.

Trending is based off of the highest score sort and falls back to it if no posts are trending.

51

The key to the solution is to build a wrapper that overrides the __eq__ method and replaces it with an approximate match:

import cmath class Approximately(complex): def __new__(cls, x, /, **kwargs): result = complex.__new__(cls, x) result.kwargs = kwargs return result def __eq__(self, other): try: return isclose(self, other, **self.kwargs) except TypeError: return NotImplemented 

It creates approximate equality tests for both float values and complex values:

>>> Approximately(1.1 + 2.2) == 3.3 True >>> Approximately(1.1 + 2.2, abs_tol=0.2) == 3.4 True >>> Approximately(1.1j + 2.2j) == 0.0 + 3.3j True 

Here is how to use it in a match/case statement:

for x in [sum([0.1] * 10), 1.1 + 2.2, sin(radians(45))]: match Approximately(x): case 1.0: print(x, 'sums to about 1.0') case 3.3: print(x, 'sums to about 3.3') case 0.7071067811865475: print(x, 'is close to sqrt(2) / 2') case _: print('Mismatch') 

This outputs:

0.9999999999999999 sums to about 1.0 3.3000000000000003 sums to about 3.3 0.7071067811865475 is close to sqrt(2) / 2 
Share
2
  • 1
    Since timestamps are floats, this should also work well for approximate times.Jun 13 at 7:50
  • 2
    Minor note: A type-check of some sort would be needed in __eq__ to make this work in mixed-type matches, e.g. adding if not isinstance(other, numbers.Complex): return NotImplemented (Complex is a superclass of Real, so it would support most numeric types aside from decimal.Decimal, which is a weirdo case) so it wouldn't die with a TypeError when the input might be non-numeric (because other cases are intended to catch non-numeric stuff).Jun 14 at 22:03
26

Raymond's answer is very fancy and ergonomic, but seems like a lot of magic for something that could be much simpler. A more minimal version would just be to capture the calculated value and just explicitly check whether the things are "close", e.g.:

import math match 1.1 + 2.2: case x if math.isclose(x, 3.3): print(f"{x} is close to 3.3") case x: print(f"{x} wasn't close) 

I'd also suggest only using cmath.isclose() where/when you actually need it, using appropriate types lets you ensure your code is doing what you expect.

The above example is just the minimum code used to demonstrate the matching and, as pointed out in the comments, could be more easily implemented using a traditional if statement. At the risk of derailing the original question, this is a somewhat more complete example:

from dataclasses import dataclass @dataclass class Square: size: float @dataclass class Rectangle: width: float height: float def classify(obj: Square | Rectangle) -> str: match obj: case Square(size=x) if math.isclose(x, 1): return "~unit square" case Square(size=x): return f"square, size={x}" case Rectangle(width=w, height=h) if math.isclose(w, h): return "~square rectangle" case Rectangle(width=w, height=h): return f"rectangle, width={w}, height={h}" almost_one = 1 + 1e-10 print(classify(Square(almost_one))) print(classify(Rectangle(1, almost_one))) print(classify(Rectangle(1, 2))) 

Not sure if I'd actually use a match statement here, but is hopefully more representative!

Share
6
  • 4
    ISTM that for match/case, a guards-only solution is an anti-pattern. You can always do better with an if-elif-else chain and would be better off abandoning match/case entirely rather than using guards only code. Note, the match/case specification was intentionally designed to support case clauses with float or complex literals. Essentially, this answer is a recommendation to not use that language feature at all. It seems like an objection to the language rather than demonstrating ways it can be used.Jun 14 at 16:29
  • 1
    @RaymondHettinger can you explain a bit more? I thought the use of a match/case vs if/elif depends on the semantics you're trying to implement. Why does the matching expression matter in this decision? Is this a Python specific thing or a general idea your talking about?Jun 14 at 21:10
  • @RaymondHettinger: When you say "always do better with if-elif-else chain", what do you mean? Your solution (which I up-voted) reduces code duplication in exchange for a little more magic-at-a-distance, but performance-wise they should be essentially identical (hypothetical hash-based matches can't optimize either; it'll always be equivalent to if-elif-else under the hood). You still get to pass a computed value to the match without prestoring it in a variable, you can still theoretically put in cases for other types, etc. Am I missing something that makes this fundamentally worse?Jun 14 at 21:59
  • @RaymondHettinger: Would it look better with multiple case x if math.isclose(x, some_float) patterns? As it is, it could indeed be completely replaced with if math.isclose(1.1 + 2.2, 3.3):.Jun 15 at 7:13
  • 1
    @RaymondHettinger sure, it can be substituted with if-elif, but why do it?Jun 15 at 16:31
-1

You could also do a workaround with multiplying floats to an int.

If you have for example a float with just one decimal place, you can do this:

a = 1.1 b = 2.2 def is_float_equal(a, b): match (a * 10) + (b * 10): case 33: print(f"{a} + {b} is 3.3.") case _: print(f"{a} + {b} is not 3.3.") 
Share

    Not the answer you're looking for? Browse other questions tagged or ask your own question.