Hi everyone.
This is a proposal for a new notation enabling to mix 0-based indexing (usual Python and Computer-Science conventions) and 1-based indexing (usual mathematical conventions), at any time, for every indexable object, and without any nasty interference. Just write a[i]
as usual for 0-based indexing and a{i}
for 1-based indexing.
The hope is to eventually derive a PEP from this proposal. So, comments and advices are welcome.
I wrote a proof-of-concept implementation, available on GitHub, forked repository cpython
, branch index01
. I’m quite new to git, so please tell me if my git repo is in an inconsistent state.
I am really convinced of these 2 ideas, which I will now develop:
- we need 1-based indices, and
a{i}
is the right notation for them - we need closed slices (slices including both bounds)
a{i}
: the right notation
Usually, languages have a privileged convention, e.g. 0-based indexing for Python, C, Java, Ocaml, and 1-based indexing for Julia. This makes indexing easy when the conventions of the problem match the conventions of the language, but awkward when they don’t.
I don’t think that indexing on a type per type (or class per class) basis, as done for standard types in Pascal or Ada, is convenient. We end up with code duplication or with something like
array[first_index + offset]
, a complicated version of 0-based indexing.
Writing a{i}
along with a[i]
seems far better to me:
-
It is very easy to implement: for integer indices,
a{i}
becomesa[i-1]
, and for slices with integer bounds,a{i:j:k}
becomesa[i-1:j-1:k]
. No deep modification of the language and libraries. No incompatibility with existing Python code. -
There is a very good balance between the notations
a[i]
anda{i}
. The choice of the programmer is not biased by ease of writing or so on, it is only guided by the convention which conceptually best suits the problem to solve: either CS or math, either 0-based or 1-based indices. -
a{i}
is easy to remember as the mathematical convention: everyone is already accustomed to writing an index asa_{i}
orM_{i,j}
in latex. -
The
a{i}
notation is Pythonic (as far as I understand this word): if, for any reason, you have to manipulate 1-based indices, “there should be one-- and preferably only one --obvious way to do it”, this obvious way is to writea{i}
.
a{i}
: use cases
Prototyping, converting theory into practice
In my humble opinion, Python is a wonderful language for prototyping, because it enables the programmer to smoothly convert a theoretical algorithm and its mathematical formulas into a (perhaps suboptimal but) working piece of code.
In this smooth conversion process, a trap remains, because there are two incompatible conventions to access elements of a list: the usual conventions of Computer-Science languages, i.e. 0-based indexing, and the usual mathematical conventions, i.e. 1-based indexing.
The vast majority of theoretical CS books or papers use 1-based indexing. Have a look at the Python heap queue: The authors explicitly state: “The API below differs from textbook heap algorithms […] We use zero-based indexing. This makes the relationship between the index for a node and the indices for its children slightly less obvious, but is more suitable since Python uses zero-based indexing.” This means that the authors had to convert the textbook algorithm from 1-based indices to 0-based indices, a simple but non-trivial and actually error-prone operation.
It seems very simple to convert by hand a 1-based index i
into a 0-based index: just subtract 1, so write a[i-1]
instead of a[i]
. But it is tedious and error-prone, especially since a[i-1]
is not intuitively understood as “the i
-th element, 1-based” but as “the element preceding the i
-th”,
which is confusing.
Interactive use
Python is great for interactive use, especially inside notebooks (I teach maths and CS, so I often make live demos with sympy
for my students). The trouble is, in any array (text, spreadsheet, matrix, line or column vector), the universal convention is to count lines and columns from 1,
so one has to translate 1-based indexing into 0-based Python indexing. It’s a real pain in an interactive use requiring an immediate, on-the-fly, translation.
Consider, for example, a list a
with, say, 10 items, and iterate this test: pickup 2 random 1-based indices and type-in on the fly Python code to swap the i-th and j-th element. For the 4-th and 8-th elements, I bet you’ll find it more convenient and more reliable to type a{4}, a{8} = a{8}, a{4}
than a[3], a[7] = a[7], a[3]
especially if you say out loud “Let us swap the 4-th and the 8-th element” while you are typing or just before.
Negative indices
Notice that Python already uses indices starting from 1, implicitly and without letting the programmer choose: when you write a[-1]
, a[-2]
, a[-3]
, … in general a[-i]
, you count indices from right to left, starting at index i
=1. This introduces a harmful asymmetry between the left-to-right and the right-to-left cases (even if, arguably, the right-to-left case is less important).
With the a{-i}
notation, you can write a{-0}
, a{-1}
, a{-2}
, … and the asymmetry disappears. So, the programmer can choose:
a[i]
, 0-based indices from left to righta{i}
, 1-based indices from left to righta{-i}
, 0-based indices from right to lefta[-i]
, 1-based indices from right to left
Semi-open and closed slices
A standard, 0-based, Python slice is semi-open, because its lower bound is included but its upper bound is excluded. With 1-based indexing, semi-open slices are fine, but we also need closed slices, in which both lower and upper bounds are included.
Closed ranges have been much discussed, and quite recently in this post: PEP 204 - Range Literals: Getting closure [of the debate] (messages #8 and around #30, by @Rosuav, @ntessore, @steven.daprano, @jbo and others). Notice that I’m not asking to reopen PEP 204. I’m only asking for closed slices. Slices are not ranges, but IMO ranges are very easy to derive from slices. As illustrated by the amount of discussions about it, there is a need for closed ranges, so there is a need for closed slices.
I provisionally denote a closed slice with a double colon after the upper bound, i.e. by writing :j::
or i:j::
or i:j::k
. This is currently the notation accepted in the proof-of-concept implementation. If I dared to introduce an incompatibility with current Python, I would suggest to simplify :j::
into :j:
and i:j::
into i:j:
, because in practice :j:
and i:j:
are superseded by :j
and i:j
. As usual, any advice or comment about it is welcome.
Use cases for closed slices
-
Math conventions widely use closed integer intervals, i.e. slices without an explicit step, especially from 1 to
n
inclusive, i.e.1:n::
-
With negative indices, closed slices enable to reach the end of a list without having to cope with any annoying particular case. For example,
a[:-n]
usually selects all but the n last elements of a. But this expression does not work withn
=0, and we have to writea[:-n or None]
to take this particular case into account. With a closed slice we can writea{:-n::}
and it works smoothly. -
With closed slices, we have an opportunity to avoid a confusion between included and excluded upper bound in ranges and loops. Consider for example the sum of inverse squares of the
n
first positive integers, i.e. from 1 ton
inclusive. In Python it issum(1/i**2 for i in range(1, n+1))
. Python avoids the awful trap of C and Java by which1/(i*i)
computes the integer, Euclidean division, i.e. 0 unlessi
=1. But the upper boundn+1
is a trap, it is very easy to forget+1
.Suppose now we can write
range[slice]
forrange(start, stop, step)
. Just use a closed slice and writesum(1/i**2 for i in range[1:n::])
. The indication “n
included” becomes explicit and obvious. Similarly, we could writerandom.randrange[i:j]
forrandom.randrange(i, j)
andrandom.randrange[i:j::]
forrandom.randint(i, j)
.I know there are issues because
range
is not primarily a function but a class. In my mind, this is not the point here. Just renamerange
e.g. by adding an underscore and writerange_[slice]
if needed. Or pick-up a completely different name, asseq
for sequence. If you wish, have a look atDEMOS/closedslices.py
on the GIT repository.
How to compute a closed slice
Unless I make a mistake, here is how a 0-based closed slice start:stop::step
should be transformed into an equivalent standard semi-open Python slice. We must be careful, because “up to -1 included” does not mean “up to 0 excluded”.
-
step=None
is equivalent tostep=1
-
For a positive
step
and an integerstop
, replacestop=-1
included withstop=None
, otherwise replacestop
included withstop+1
excluded. -
For a negative
step
and an integerstop
, replacestop=0
included withstop=None
, otherwise replacestop
included withstop-1
excluded.
As this transformation only works with a 0-based slice, a 1-based closed slice i:j::k
has to transformed into the corresponding 0-based closed slice i-1:j-1::k
before being processed into a semi-open slice.
Other questions
-
How to mix 0-based indices and 1-based in the same subscript ?
Often,
a[slice1, slice2]
is not equivalent toa[slice1][slice2]
. So,a[slice1]{slice2}
meansa[0-based slice1][1-based slice2]
, but how to denotea[0-based slice1, 1-based slice2]
?I suggest writing
a[slice1].{slice2}
, i.e. the dot between brackets and braces merges both subscripts into a single subscript. Notice that it does not interfere with theobject.attribute
notation. This suggestion is implemented as part of the proof-of-concept implementation, but any comment or alternate notation is welcome. -
Should 1-based slicing be configurable depending on the indexed object (e.g. applicable to non-integer data) ? To my mind, yes.
This suggestion is implemented as part of the proof-of-concept implementation: if a class defines
__xkey__(self, key, braced: bool, closed:bool)
, this method is used to translate extended keys (indices or slices) into standard keys before calling the__getitem__
/__setitem__
/__delitem__
methods. -
What should we do with methods involving indices ?
For example, should
list.index
have a 1-based counterpartlist.index1
or an option likelist.index(..., base=1)
?