57

I am trying to provide a UI where ranges should be described inclusively. I have human-readable descriptions such as from A to B , which represent ranges that include both end points - e.g. from 2 to 4 means 2, 3, 4.

I figured out that I can use code like this to convert them into range objects representing the desired values:

def inclusive_range(start, stop, step):
    return range(start, (stop + 1) if step >= 0 else (stop - 1), step)

However, I also need to perform inclusive slice operations. Is there a way I can avoid explicitly making + 1 or - 1 adjustments every time I use range or slice notation (e.g. range(A, B + 1), l[A:B+1], range(B, A - 1, -1))?

Karl Knechtel
  • 62,466
  • 11
  • 102
  • 153
user200783
  • 13,722
  • 12
  • 69
  • 135
  • 3
    Note that the function you have written is incorrect. The second argument `stop + step` has the potential to increase the inclusive range beyond what it should be. It should be `stop + 1`. e.g. `range(0, 7, 3)` is `[0, 3, 6]` but your function would give `[0, 3, 6, 9]`. – Shashank Apr 13 '15 at 00:38
  • Are elements in range always integers in your domain? I mean e.g. `from 2 to 4` can mean `[2,3,4]` or `2:00,2:01,...,4:00`. – Dima Tisnek Apr 13 '15 at 10:17
  • @qarma - Yes, just like the built-in range() function, I only need to handle ranges of integers. – user200783 Apr 13 '15 at 12:42
  • one other possibility... subclassing `list` and extending its `__getitem__` functionality to handle sequences/tuples. then, you could pass in a range as your extraction object. e.g. right now, `l = [0,1,2,3,4]`, `l[2]` gives 2, but `l[2,3,4]` is an error. Adding support so that `l[2,3,4]` gives `[2,3,4]` would not break any existing functionality (i don't think), and would let you do things like `l[inclusive_range(2,4)]` if that helps at all. – Corley Brigman Apr 17 '15 at 15:57
  • You're explicitly saying step in the range function - does this mean your input could specify a step too? e.g.: "From 10 to 20 every 2" which means [10, 12, 14, 16, 18, 20]? if that is the case, what happens in cases when it says from 10 to 20 every 3? or will that never happen? – Rcynic Apr 20 '15 at 20:48
  • @Rcynic - For the built-in `range` function, with positive step, `range(a, b, step) == range(a, b)[::step]`. The same should apply to `inclusive_range`, meaning that `inclusive_range(10, 20, 3) == [10, 13, 16, 19]`. This means that `inclusive range` is not _always_ inclusive, only _potentially_ so. Perhaps `closed_range` would be a better name? – user200783 Apr 20 '15 at 21:41
  • You say "generate inclusive ranges of integers, but I also need to perform inclusive slice operations" which implies that these are discrete ranges, not continuous ranges. Is that the case? I ask because representing general ranges over a continuous line like the real numbers is a more complex problem than representing ranges over a discrete line where you have a well defined successor. – Mike Samuel Apr 22 '15 at 18:19
  • related: http://stackoverflow.com/q/27449310/674039 – wim Apr 23 '15 at 02:33
  • @MikeSamuel - I am not sure I understand your question. Just like the built-in range() function, I only need to handle ranges of integers. Does this not imply discreteness? – user200783 Apr 24 '15 at 16:18
  • @PaulBaker, Yes that does imply discreteness and it answers my question. I was unsure because "works to generate inclusive ranges of integers but I also need ... slices" suggests integers but numpy ranges work with fractional steps so I just thought I'd check. – Mike Samuel Apr 24 '15 at 17:06
  • Yeah the built-in "range" function should really just have a special keyword to make it inclusive when desired, e.g. `range(0,10,inclusive=True)`. Then I could save my ranges in variables like `myrange = (start,stop)` and do `range(*myrange,inclusive=True)`, instead of the much more cumbersome and error-prone `range(myrange[0],myrange[1]+1)`. – Ben Farmer Nov 15 '20 at 23:46

12 Answers12

18

Write an additional function for inclusive slice, and use that instead of slicing. While it would be possible to e.g. subclass list and implement a __getitem__ reacting to a slice object, I would advise against it, since your code will behave contrary to expectation for anyone but you — and probably to you, too, in a year.

inclusive_slice could look like this:

def inclusive_slice(myList, slice_from=None, slice_to=None, step=1):
    if slice_to is not None:
        slice_to += 1 if step > 0 else -1
    if slice_to == 0:
        slice_to = None
    return myList[slice_from:slice_to:step]

What I would do personally, is just use the "complete" solution you mentioned (range(A, B + 1), l[A:B+1]) and comment well.

L3viathan
  • 26,748
  • 2
  • 58
  • 81
9

Since in Python, the ending index is always exclusive, it's worth considering to always use the "Python-convention" values internally. This way, you will save yourself from mixing up the two in your code.

Only ever deal with the "external representation" through dedicated conversion subroutines:

def text2range(text):
    m = re.match(r"from (\d+) to (\d+)",text)
    start,end = int(m.groups(1)),int(m.groups(2))+1

def range2text(start,end):
    print "from %d to %d"%(start,end-1)

Alternatively, you can mark the variables holding the "unusual" representation with the true Hungarian notation.

ivan_pozdeev
  • 33,874
  • 19
  • 107
  • 152
  • 4
    I disagree. Because it does it this way today doesn't mean it should never do it in the future. Lots of languages have inclusive ranges as well as exclusive because it often is exactly what you need. (Ruby & Swift & Perl come to mind immediately) – uchuugaka Jan 17 '17 at 08:52
  • @uchuugaka It's effectively impossible that such a change will ever happen in the foreseeable future. 1)Python devs are very careful about backward compatibility. They will definitely never change this other than when transitioning to a next major version. 2)Python's focus is simplicity and maintainability - this'd be a Pandora's box. 3)basic computing concepts (von Neumann architecture, the binary system) that dictate that 0...N-1 indexing is more convenient than 1..N are hardly going anywhere. So, according to YAGNI principle, you shoudn't worry about this in your Python solutions. – ivan_pozdeev Jan 17 '17 at 11:15
  • For string to rage I suggest this: https://gist.github.com/raczben/76cd1229504d82115e6427e00cf4742c – betontalpfa Jun 14 '21 at 14:23
5

If you don't want to specify the step size but rather the number of steps, there is the option to use numpy.linspace which includes the starting and ending point

import numpy as np

np.linspace(0,5,4)
# array([ 0.        ,  1.66666667,  3.33333333,  5.        ])
plonser
  • 3,323
  • 2
  • 18
  • 22
  • thx for reminding `linspace` : but is there a version that returns *integers* in the given range incrementing by one? otherwise there is as much conversion work required on this one as some other answers. – WestCoastProjects Apr 17 '17 at 01:06
  • 1
    @javadba `linspace(0, 5, 4, dtype="int")` → `array([0, 1, 3, 5])`. Note that this truncates rather than rounds, if you need rounding, try `linspace(0, 5, 4).round().astype("int")` → `array([0, 2, 3, 5])`. – gerrit Sep 27 '18 at 16:29
5

Instead of creating an API that is not conventional or extending data types like list, it would be ideal to create a wrapper over the built-in slice so that you can pass it anywhere that slicing is possible.

As an example, an inclusive slice would look like

def inclusive_slice(start, stop=None, step=None):
    if stop is not None:
        stop += 1
    if stop == 0:
        stop = None
    return slice(start, stop, step)

And you can use it for any sequence type.

>>> range(1, 10)[inclusive_slice(1, 5)]
[2, 3, 4, 5, 6]
>>> "Hello World"[inclusive_slice(0, 5, 2)]
'Hlo'
>>> (3, 1, 4, 1, 5, 9, 2, 6)[inclusive_slice(1, -2)]
(1, 4, 1, 5, 9, 2)

Finally you can also create an inclusive range called inclusive_range to complement the inclusive slice:

def inclusive_range(start, stop, step):
    return range(start, (stop + 1) if step >= 0 else (stop - 1), step)
LeopardShark
  • 3,820
  • 2
  • 19
  • 33
Abhijit
  • 62,056
  • 18
  • 131
  • 204
4

I believe that the standard answer is to just use +1 or -1 everywhere it is needed.

You don't want to globally change the way slices are understood (that will break plenty of code), but another solution would be to build a class hierarchy for the objects for which you wish the slices to be inclusive. For example, for a list:

class InclusiveList(list):
    def __getitem__(self, index):
        if isinstance(index, slice):
            start, stop, step = index.start, index.stop, index.step
            if index.stop is not None:
                if index.step is None:
                    stop += 1
                else:
                    if index.step >= 0:
                        stop += 1
                    else:
                        if stop == 0: 
                            stop = None # going from [4:0:-1] to [4::-1] since [4:-1:-1] wouldn't work 
                        else:
                            stop -= 1
            return super().__getitem__(slice(start, stop, step))
        else:
            return super().__getitem__(index)

>>> a = InclusiveList([1, 2, 4, 8, 16, 32])
>>> a
[1, 2, 4, 8, 16, 32]
>>> a[4]
16
>>> a[2:4]
[4, 8, 16]
>>> a[3:0:-1]
[8, 4, 2, 1]
>>> a[3::-1]
[8, 4, 2, 1]
>>> a[5:1:-2]
[32, 8, 2]

Of course, you want to do the same with __setitem__ and __delitem__.

(I used a list but that works for any Sequence or MutableSequence.)

Francis Colas
  • 3,459
  • 2
  • 26
  • 31
  • 2
    I think using such a class confuses things more than it helps. For instance, I might expect `InclusiveList(range(11))` to _include_ `11`. – tobias_k Apr 17 '15 at 12:48
  • 1
    Well `range(11)` doesn't include `11` and is just used to initialize the list. I understand that the use of `range` here makes it confusing, I've changed the example. – Francis Colas Apr 17 '15 at 12:51
4

Without writing your own class, the function seems to be the way to go. What i can think of at most is not storing actual lists, just returning generators for the range you care about. Since we're now talking about usage syntax - here is what you could do

def closed_range(slices):
    slice_parts = slices.split(':')
    [start, stop, step] = map(int, slice_parts)
    num = start
    if start <= stop and step > 0:
        while num <= stop:
            yield num
            num += step
    # if negative step
    elif step < 0:
        while num >= stop:
            yield num
            num += step

And then use as:

list(closed_range('1:5:2'))
[1,3,5]

Of course you'll need to also check for other forms of bad input if anyone else is going to use this function.

Rcynic
  • 392
  • 3
  • 10
4

Focusing on your request for best syntax, what about targeting:

l[1:UpThrough(5):2]

You can achieve this using the __index__ method:

class UpThrough(object):
    def __init__(self, stop):
        self.stop = stop

    def __index__(self):
        return self.stop + 1

class DownThrough(object):
    def __init__(self, stop):
        self.stop = stop

    def __index__(self):
        return self.stop - 1

Now you don't even need a specialized list class (and don't need to modify global definition either):

>>> l = [1,2,3,4]
>>> l[1:UpThrough(2)]
[2,3]

If you use a lot you could use shorter names upIncl, downIncl or even In and InRev.

You can also build out these classes so that, other than use in slice, they act like the actual index:

def __int__(self):
    return self.stop
shaunc
  • 5,317
  • 4
  • 43
  • 58
  • 1
    Is there a reason you have defined `UpThrough` and `DownThrough` as classes rather than simple functions? – user200783 Apr 24 '15 at 16:32
  • The reason would be to use `__int__` and/or `__float__`, so that `int(UpThrough(5)) == 5` but in slice generates right index. You can also add arithmetic operators. – shaunc Apr 25 '15 at 02:22
3

Was going to comment, but it's easier to write code as an answer, so...

I would NOT write a class that redefines slicing, unless it's VERY clear. I have a class that represents ints with bit slicing. In my contexts, '4:2' is very clearly inclusive, and ints don't already have any use for slicing, so it's (barely) acceptable (imho, and some would disagree).

For lists, you have the case that you'll do something like

list1 = [1,2,3,4,5]
list2 = InclusiveList([1,2,3,4,5])

and later on in your code

if list1[4:2] == test_list or list2[4:2] == test_list:

and that is a very easy mistake to make, since list already HAS a well-defined usage.. they look identical, but act differently, and so this will be very confusing to debug, especially if you didn't write it.

That doesn't mean you're completely lost... slicing is convenient, but after all, it's just a function. And you can add that function to anything like this, so this might be an easier way to get to it:

class inc_list(list):
    def islice(self, start, end=None, dir=None):
        return self.__getitem__(slice(start, end+1, dir))

l2 = inc_list([1,2,3,4,5])
l2[1:3]
[0x3,
 0x4]
l2.islice(1,3)
[0x3,
 0x4,
 0x5]

However, this solution, like many others, (besides being incomplete... i know) has the achilles' heel in that it's just not as simple as the simple slice notation... it's a little more simple than passing the list as an argument, but still harder than just [4:2]. The only way to make that happen is to pass something different to the slice, that could be interepreted differently, so that the user would know on reading it what they did, and it could still be as simple.

One possibility... floating point numbers. They're different, so you can see them, and they aren't too much more difficult than the 'simple' syntax. It's not built-in, so there's still some 'magic' involved, but as far as syntactic sugar, it's not bad....

class inc_list(list):
    def __getitem__(self, x):
        if isinstance(x, slice):
            start, end, step = x.start, x.stop, x.step
            if step == None:
                step = 1
            if isinstance(end, float):
                end = int(end)
                end = end + step
                x = slice(start, end, step)
            return list.__getitem__(self, x)

l2 = inc_list([1,2,3,4,5])
l2[1:3]
[0x2,
 0x3]
l2[1:3.0]
[0x2,
 0x3,
 0x4]

The 3.0 should be enough to tell any python programmer 'hey, something unusual is going on there'... not necessarily what is going on, but at least there's not surprise that it acts 'weird'.

Note that there's nothing unique about that to lists... you could easy write a decorator that could do this for any class:

def inc_getitem(self, x):
    if isinstance(x, slice):
        start, end, step = x.start, x.stop, x.step
        if step == None:
            step = 1
        if isinstance(end, float):
            end = int(end)
            end = end + step
            x = slice(start, end, step)
    return list.__getitem__(self, x)

def inclusiveclass(inclass):
    class newclass(inclass):
        __getitem__ = inc_getitem
    return newclass

ilist = inclusiveclass(list)

or

@inclusiveclass
class inclusivelist(list):
    pass

The first form is probably more useful though.

Corley Brigman
  • 11,633
  • 5
  • 33
  • 40
3

It's difficult and probably not wise to overload such basic concepts. with a new inclusivelist class, len(l[a:b]) in b-a+1 which can lead to confusions.
To preserve the natural python sense, while giving readability in a BASIC style, just define :

STEP=FROM=lambda x:x
TO=lambda x:x+1 if x!=-1 else None 
DOWNTO=lambda x:x-1 if x!=0 else None

then you can manage as you want, keeping the natural python logic :

>>>>l=list(range(FROM(0),TO(9)))
>>>>l
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>>l[FROM(9):DOWNTO(3):STEP(-2)] == l[9:2:-2]
True
B. M.
  • 18,243
  • 2
  • 35
  • 54
0

I am not sure if this is already covered, this is how I handled it to check if my variable is within a defined range:

my var=10 # want to check if it is in range(0,10) as inclusive
limits = range(0,10)
limits.append(limits[-1]+1)
if(my_var in limits):
    print("In Limit")
else:
    print("Out of Limit")

This code will return "In Limit" since I have expanded my range by 1 hence making it inclusive

Raghu
  • 1,644
  • 7
  • 19
0

This solution works for integers as well as negative and floating point numbers, using math and numpy:

def irange(start, stop=None, step=1):
    if stop is None:
        start, stop = 0, start
    return list(start + numpy.arange(floor((stop - start) / step) + 1) * step)
cabralpinto
  • 1,814
  • 3
  • 13
  • 32
-1

Maybe the inclusive package proves helpful.

user66081
  • 420
  • 7
  • 15