Search code examples
pythoncomparison-operators

Check if two objects are comparable to each other, without relying on raised errors


By "comparable", I mean "able to mutually perform the comparison operations >, <, >=, <=, ==, and != without raising a TypeError". There are a number of different classes for which this property does hold:

1 < 2.5  # int and float
2 < decimal.Decimal(4)  # int and Decimal
"alice" < "bob"  # str and str
(1, 2) < (3, 4)  # tuple and tuple

and for which it doesn't:

1 < "2"  # int and str
1.5 < "2.5"  # float and str

even when it seems like it really ought to:

datetime.date(2018, 9, 25) < datetime.datetime(2019, 1, 31)  # date and datetime 
[1, 2] < (3, 4)  # list and tuple

As demonstrated in this similar question, you can obviously check this for two unknown-typed objects a and b by using the traditional python approach of "ask forgiveness, not permission" and using a try/except block:

try: 
    a < b
    # do something
except TypeError:
    # do something else

but catching exceptions is expensive, and I expect the second branch to be taken sufficiently frequently for that to matter, so I'd like to catch this in an if/else statement instead. How would I do that?


Solution

  • Since it is impossible to know beforehand whether a comparison operation can be performed on two specific types of operands until you actually perform such an operation, the closest thing you can do to achieving the desired behavior of avoiding having to catch a TypeError is to cache the known combinations of the operator and the types of the left and right operands that have already caused a TypeError before. You can do this by creating a class with such a cache and wrapper methods that do such a validation before proceeding with the comparisons:

    from operator import gt, lt, ge, le
    
    def validate_operation(op):
        def wrapper(cls, a, b):
            # the signature can also be just (type(a), type(b)) if you don't care about op
            signature = op, type(a), type(b)
            if signature not in cls.incomparables:
                try:
                    return op(a, b)
                except TypeError:
                    cls.incomparables.add(signature)
            else:
                print('Exception avoided for {}'.format(signature)) # for debug only
        return wrapper
    
    class compare:
        incomparables = set()
    
    for op in gt, lt, ge, le:
        setattr(compare, op.__name__, classmethod(validate_operation(op)))
    

    so that:

    import datetime
    print(compare.gt(1, 2.0))
    print(compare.gt(1, "a"))
    print(compare.gt(2, 'b'))
    print(compare.lt(datetime.date(2018, 9, 25), datetime.datetime(2019, 1, 31)))
    print(compare.lt(datetime.date(2019, 9, 25), datetime.datetime(2020, 1, 31)))
    

    would output:

    False
    None
    Exception avoided for (<built-in function gt>, <class 'int'>, <class 'str'>)
    None
    None
    Exception avoided for (<built-in function lt>, <class 'datetime.date'>, <class 'datetime.datetime'>)
    None
    

    and so that you can use an if statement instead of an exception handler to validate a comparison:

    result = compare.gt(obj1, obj2)
    if result is None:
        # handle the fact that we cannot perform the > operation on obj1 and obj2
    elsif result:
        # obj1 is greater than obj2
    else:
        # obj1 is not greater than obj2
    

    And here are some timing statistics:

    from timeit import timeit
    print(timeit('''try:
        1 > 1
    except TypeError:
        pass''', globals=globals()))
    print(timeit('''try:
        1 > "a"
    except TypeError:
        pass''', globals=globals()))
    print(timeit('compare.gt(1, "a")', globals=globals()))
    

    This outputs, on my machine:

    0.047088712933431365
    0.7171912713398885
    0.46406612257995117
    

    As you can see, the cached comparison validation does save you around 1/3 of time when the comparison throws an exception, but is around 10 times slower when it doesn't, so this caching mechanism makes sense only if you anticipate that the vast majority of your comparisons are going to throw an exception.