24

I have a custom class,

class A:
    def __init__(self, a, b):
        self.a = a
        self.b = b

The class is not iterable or indexable or anything like that. If at all possible, I would like to keep it that way. Is it possible to have something like the following work?

>>> x = A(1, 2)
>>> min(x)
1
>>> max(x)
2

What got me thinking about this is that min and max are listed as "Common Sequence Operations" in the docs. Since range is considered to be a sequence type by the very same docs, I was thinking that there must be some sort of optimization that is possible for range, and that perhaps I could take advantage of it.

Perhaps there is a magic method that I am not aware of that would enable this?

Mad Physicist
  • 76,709
  • 19
  • 122
  • 186
  • This other post may help you, though I'm not completely sure it would be working with min/max; it may be worth a try, though: https://stackoverflow.com/questions/7875911/how-to-implement-a-minimal-class-that-behaves-like-a-sequence-in-python – Haroldo_OK Jul 25 '17 at 15:59
  • @depperm in this instance I believe `a` will be passed as `self`. Using `self` is just just convention. – cssko Jul 25 '17 at 15:59
  • 1
    do you mean make it work without defining `__iter__`/`__next__`? I'm not sure I understand your confusion. – Dimitris Fasarakis Hilliard Jul 25 '17 at 16:00
  • 2
    When you say "the class is not iterable", do you mean "the class is not iterable but I'm willing to make it iterable", or "the class is not iterable and it absolutely must remain that way"? – Kevin Jul 25 '17 at 16:04
  • 4
    "I was thinking that there must be some sort of optimization that is possible for range" Interestingly, this appears to not be the case; while a O(1) min/max algorithm should be theoretically possible for range objects, the [implementation](https://github.com/python/cpython/blob/master/Python/bltinmodule.c#L1505) appears to be O(N) regardless of the type of input. – Kevin Jul 25 '17 at 16:14
  • @JimFasarakisHilliard. I really want to avoid making the class iterable. – Mad Physicist Jul 25 '17 at 16:14
  • @Kevin. I will only make the class iterable if I absolutely must. Really trying to avoid it though. I will edit that in. – Mad Physicist Jul 25 '17 at 16:15
  • @Kevin It really is a shame too because optimizing a `min`/`max` call for `range` would be trivial. You'd just return `step * max_value % step`. Er.. well maybe not that trivial. – Rick supports Monica Jul 25 '17 at 17:52
  • 2
    If you don't mind telling, I'm a bit curios about why you don't want your object to be iterable so badly. Is it to keep users from doing something dumb? Perhaps there is a better way? – Christian Dean Jul 25 '17 at 22:02
  • 2
    @ChristianDean. I was working on something equivalent to `range` for `float` numbers. I came up with a solution where it is iterable when `step` is specified and not iterable otherwise. It would still provide a valid containment test and a few other things, so `min` and `max` would make sense, even if the step is not there. I guess I will just have to allow it to iterate over the bounds if there is no explicit step. – Mad Physicist Jul 26 '17 at 02:31

3 Answers3

25

Yes. When min takes one arguments it assumes it to be an iterable, iterates over it and takes the minimum value. So,

class A:
    def __init__(self, a, b):
        self.a = a
        self.b = b
    def __iter__(self):
        yield self.a
        yield self.b

Should work.

Additional Note: If you don't want to use __iter__, I don't know of way to do that. You probably want to create your own min function, that calls some _min_ method if there is one in the argument it is passed to and calls the old min else.

oldmin = min
def min(*args):
    if len(args) == 1 and hasattr(args[0], '_min_'):
        return args[0]._min_()
    else:
        return oldmin(*args)
Lærne
  • 2,544
  • 16
  • 30
  • 11
    [PEP 8 forbids](https://www.python.org/dev/peps/pep-0008/) the creation of your own dunder methods. Use `__min` instead. – Rick supports Monica Jul 25 '17 at 16:55
  • 6
    What did I miss, why can't you just call the method `min` instead of any underline variant? It doesn't seem to be a "magical" method or anything, it's just a method that will be called elsewhere outside of the standard library. – Mephy Jul 25 '17 at 17:53
  • 3
    @Mephy It's a good point. I suppose the original idea was to emulate the special method name approach employed by the standard library, but this isn't really necessary. `.min()` would work fine. Using underscores is a way of saying "there is something special about this method", though. – Rick supports Monica Jul 25 '17 at 21:05
  • 1
    `'__min__' in dir(args[0])` is a bad way of writing `hasttr(args[0], '__min__')`. The semantics of `dir()` are not well defined, and it's also very slow to first create a list of all attribute names and then linearly scanning it, when all you need is a single dictionary look-up. – Sven Marnach Jul 28 '17 at 21:12
  • 1
    @SvenMarnach. Probably a better way in terms of consistency with the other dunders is `hasattr(type(arg[0]), '__min__')`. – Mad Physicist Jul 14 '20 at 21:46
  • 1
    @MadPhysicist Agreed. I'll add that it is discouraged to define your own dunder functions – the namespace is reserved for use by the Python language. – Sven Marnach Jul 15 '20 at 12:21
7

There are no __min__ and __max__ special methods*. This is kind of a shame since range has seen some pretty nice optimizations in Python 3. You can do this:

>>> 1000000000000 in range(1000000000000)
False

But don't try this unless you want to wait a long time:

>>> max(range(1000000000000))

However creating your own min/max functions is a pretty good idea, as suggested by Lærne.

Here is how I would do it. UPDATE: removed the dunder name __min__ in favor of _min, as recommended by PEP 8:

Never invent such names; only use them as documented

Code:

from functools import wraps

oldmin = min

@wraps(oldmin)
def min(*args, **kwargs)
    try:
        v = oldmin(*args, **kwargs)
    except Exception as err:
        err = err
    try:
        arg, = args
        v = arg._min()
    except (AttributeError, ValueError):
        raise err
    try:
        return v
    except NameError:
        raise ValueError('Something weird happened.')

I think this way is maybe a little bit better because it handles some corner cases the other answer hasn't considered.

Note that an iterable object with a _min method will still be consumed by oldmin as per usual, but the return value is overridden by the special method.

HOWEVER, if the _min method requires the iterator to still be available for consumption, this will need to be tweaked because the iterator is getting consumed by oldmin first.

Note also that if the __min method is simply implemented by calling oldmin, things will still work fine (even though the iterator was consumed; this is because oldmin raises a ValueError in this case).

* Such methods are often called "magic", but this is not the preferred terminology.

Rick supports Monica
  • 33,838
  • 9
  • 54
  • 100
6

Since range is considered to be a sequence type by the very same docs, I was thinking that there must be some sort of optimization that is possible for range, and that perhaps I could take advantage of it.

There's no optimization going on for ranges and there are no specialized magic methods for min/max.

If you peek at the implementation for min/max you'll see that after some argument parsing is done, a call to iter(obj) (i.e obj.__iter__()) is made to grab an iterator:

it = PyObject_GetIter(v);
if (it == NULL) {
    return NULL;
}

then calls to next(it) (i.e it.__next__) are performed in a loop to grab values for comparisons:

while (( item = PyIter_Next(it) )) {
    /* Find min/max  */

Is it possible to have something like the following work?

No, if you want to use the built-in min* the only option you have is implementing the iterator protocol.


*By patching min, you can of-course, make it do anything you want. Obviously at the cost of operating in Pythonland. If, though, you think you can utilize some optimizations, I'd suggest you create a min method rather than re-defining the built-in min.

In addition, if you only have ints as instance variables and you don't mind a different call, you can always use vars to grab the instance.__dict__ and then supply it's .values() to min:

>>> x = A(20, 4)
>>> min(vars(x).values())
4
Dimitris Fasarakis Hilliard
  • 119,766
  • 27
  • 228
  • 224
  • That makes sense, disappointing as it is. I will ask another question on how to identify the caller so that I can raise an exception if anyone but `min` or `max` tries to grab the iterator. I hope that you don't mind me giving the points to the other guy. – Mad Physicist Jul 25 '17 at 16:19
  • @MadPhysicist I don't think you need to post another question for that. Unless someone reading the comment here disagrees with me, I think if you do something like this to check it should suffice: https://pastebin.com/AFNGcWHw – idjaw Jul 25 '17 at 16:21
  • 1
    @MadPhysicist: Can't do that. (The fact that you're even trying is a giant "reconsider your design decisions" red flag.) – user2357112 supports Monica Jul 25 '17 at 16:23
  • @user2357112 Are you saying no to trying to *catch* `min`/`max` being the callers? – idjaw Jul 25 '17 at 16:25
  • 2
    @idjaw: You can't detect whether you're being called from `min`/`max`. – user2357112 supports Monica Jul 25 '17 at 16:26
  • @user2357112 Oh! I misunderstood the question then. Right. – idjaw Jul 25 '17 at 16:28
  • @idjaw. You can inspect the calling sequence and get the caller, check if it is the `min` or `max` of the `__builtin__` module, otherwise raise an error. – Mad Physicist Jul 25 '17 at 18:01
  • @MadPhysicist: You'd need to inspect the C call stack for that; there's no trace in the Python call stack. – user2357112 supports Monica Jul 25 '17 at 21:25