Python
Python3

Pythonの __getitem__ に与える引数やスライスについて調べてみた

__getitem__ とは?

Pythonの特殊メソッドのひとつで、オブジェクトに角括弧でアクセスしたときの挙動を定義できる。

__getitem__の簡単な例
class Sample:
    def __getitem__(self, item):
        return item

a = Sample()
a["foo"] # => foo
a[1] # => 1

今回使ったPythonのバージョン

比較的昔からあるので、基本的な挙動は変わってないと思うけど。念の為。

$ python --version
Python 3.6.6

カンマ区切りアクセス

Python組み込みのリストなどではできないが、例えばnumpyでは、

カンマ区切りアクセスの例
import numpy as np
a = np.array([[1,2],[3,4]])
a[0,1] # => 2 (0行目、1列目の要素)

のようなアクセスができる。
このとき、__getitem__ にはどのような引数が来ているのかを調べた。

結果、カンマ区切りで複数指定したときには、タプルとして渡されていることが分かった。

カンマ区切りの調査
class Sample:
    def __getitem__(self, item):
        return item

a = Sample()
a[123] # => 123
type(a[123]) # => int

a[1,23] # => (1, 23)
type(a[1,23]) # => tuple

a[1,2,"3"] # => (1, 2, "3")
type(a[1,2,"3"]) # => tuple

1つだけのときはタプルではなく、複数のときはタプルだという。
うーん、何とも言えない微妙な感じ。

もう少し調べてみた。

カンマ区切りのさらなる調査
# タプルを普通に入れても、タプルのタプルにはならない
a[(1,2,3)] # => (1,2,3)
a[(1,2,3)] == a[1,2,3] # => True

# なので、dict型でも、実はカンマ区切りとタプルは同じ扱い
d = {}
d[(1,2)] = 3
d[1,2] # => 3

# タプルをカンマ区切りで書くと、タプルのタプルになる
a[(1,2),3] # => ((1,2),3)

# 次のようにすれば、1要素のタプルを引数にできる
a[(1,)] # => (1,)
a[1,] # => (1,)

# 空タプルの指定。 a[] はSyntaxErrorになる
a[()] # => ()
a[] # => SyntaxError: invalid syntax

# 変数での指定も、これまでの流れをわかっていれば怖くない。
t = (3, 4)
d[t] = 5
d[3, 4] # => 5

# タプルのアスタリスクでの展開はできない
d[*t] # => SyntaxError: invalid syntax
# 1要素のタプルでももちろん同様
d[9] = 9
t = (9,)
d[t] # => KeyError: (9,)
d[*t] # => SyntaxError: invalid syntax

__getitem__ のタプルのルール、完全に理解した。

スライス

角括弧の中では a[1:3] のような、スライス表記がよく使われる。
スライスについても調べてみた。

sliceオブジェクト
class Sample:
    def __getitem__(self, item):
        return item

a = Sample()
a[1:3] # => slice(1, 3, None)
type(a[1:3]) # => slice
a[:] # => slice(None, None, None)
a[1:2:3] # => slice(1, 2, 3)
a[1:2:3:4] # => SyntaxError: invalid syntax
a[1:,2:3:4,5,::6] # => (slice(1, None, None), slice(2, 3, 4), 5, slice(None, None, 6))

スライス表記は、slice型のオブジェクトとして渡される。
また、カンマ区切りでスライスと数字を混在させることもできた。

slice型のオブジェクトは、どうやったら作ることができるのか。また、変数に入れることはできるのか。実際に試してみた。

sliceオブジェクト?
# スライス表記で変数に代入することはできない
r = 1:3 # => SyntaxError: invalid syntax
r = [1:3] # => SyntaxError: invalid syntax

# slice()を使って作成可能
r = slice(4) # => slice(None, 4, None)
r = slice(1,3) # => slice(1, 3, None)
r = slice() # => TypeError: slice expected at least 1 arguments, got 0
[0,1,2,3,4][slice(2)] # => [0,1]
[0,1,2,3,4][slice(1,3)] # => [1,2]
r = slice(2)
[0,1,2,3,4][r] # => [0,1]

ちょっとした遊び心から、クレイジーなスライスを作ってみた。

こんなsliceアリなのか!?
class Sample:
    def __getitem__(self, item):
        return item

a = Sample()
# 長くなるから書いてないけど、slice()を使っても、これらは作れる。
a["aaa":"bcd"] # => slice('aaa', 'bcd', None)
a[None:1.23:"45"] # => slice(None, 1.23, '45')
a[slice(1,2,3):slice(4,5,6):slice([], (), {})] # => slice(slice(1, 2, 3), slice(4, 5, 6), slice([], (), {}))

意味不明だが。作れるには作れる。自作オブジェクトでも何でも入る。

sliceオブジェクトでできること

dir関数を使うと、どんなメソッドを持ってるのか分かる。

dir(slice(1))
dir(slice(1))
# => ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'indices', 'start', 'step', 'stop']

start, stop, step

start, step, stopがある。いかにも、sliceが持ってる3つの値を取得するものっぽい。実際に試してみた。

start,stop,step
r = slice(1,2,3)
r.start # => 1
r.stop # => 2
r.step # => 3

# Noneでもエラーが出ないことを確認
r  = slice(1) # r: slice(None, 1, None)
r.start # => None

予想通り。

比較演算

さっきdirをしたとき、==とか<とかの比較演算をする特殊メソッドの__eq__とか__lt__とかがあったのが気になった。
==!=はともかく、他は挙動がさっぱり分からなかったので、cpythonのソースを読んでみた。
結果、これらの比較は、両辺がslice型であることを確認して、そうであった場合、左辺、右辺とも(start, stop, step)のタプルを作って、タプル同士を比較していることがわかった。
ちなみに、タプルの比較は、最も左のものから順に比較するようになっている。

手元で試したら、確かにそんな感じの挙動になっていたが、全く使い道が思い浮かばないので、検証コードの記述は省略。
(使い道あるよ!って人は、1 < Noneなど、intNoneTypeの比較ができないことに注意。これは罠!)

indicesメソッド

あと、indicesも気になった。とりあえずhelpを見る。

help(slice.indices)
help(slice.indices)
# =>
indices(...)
    S.indices(len) -> (start, stop, stride)

    Assuming a sequence of length len, calculate the start and stop
    indices, and the stride length of the extended slice described by
    S. Out of bounds indices are clipped in a manner consistent with the
    handling of normal slices.

長さを引数として渡せば、範囲などを整えて返してくれるメソッドっぽい。Noneを埋めたり、インデックスにマイナスを指定したら「後ろから何要素目」が表せるが、それをプラスのインデックスに直したり、範囲外のインデックスを無視したり、など。
え、範囲外は無視? 実はlistなどでも、スライスで書いた場合は範囲外は無視されるみたい。知らなかった。

スライスの範囲外は無視される
ls = [0,1,2]
ls[3] # => IndexError: list index out of range
ls[3:4] # => [] スライスだとIndexErrorが出ない
# ちなみに、stepが0はダメ。これはslice.indicesでも同じ。
ls[::0] # => ValueError: slice step cannot be zero

ちなみに、strとかfloatとかの入ったスライスで使うとTypeError: slice indices must be integers or None or have an __index__ methodが出る。

__index__ メソッドについてはPEP 357にある。
int型でしかインデックスを引けないことにしたらnp.int16型などでインデックスが引けなくなり、かといって__int__を持っているものにまで範囲を広げるとfloat型を指定したときに、浮動小数点以下を切り捨てる形でインデックスが引けてしまうのが問題なので、インデックスを引ける整数型には__index__メソッドを持たせた、ということらしい。

重箱の隅はこれくらいにして。挙動を見ていく。
詳細な挙動はcpythonのソースを参考にした。

大まかには分かったものの、細かい部分は、なんでそういう挙動なのか意義が分からない。

  • step
    • 0なら、ValueErrorを出す
    • Noneなら、step = 1に修正する
  • lower, upper (関数内で使う)
    • stepが正の場合
      • lower = 0, upper = length
    • stepが負の場合
      • lower = -1, upper = length - 1
  • start
    • Noneなら
      • stepが正ならstart = lower, 負ならstart = upper
    • 負なら
      • start += length
      • start < lower だった場合は start = lower
    • 0または正なら
      • start > upper だった場合は start = upper
  • stop
    • Noneなら
      • stepが正ならstop = upper, 負ならstop = lower
    • 負なら
      • stop += length
      • stop < lower だった場合は stop = lower
    • 0または正なら
      • stop > upper だった場合は stop = upper

stepが負で、startstop-lengthよりも小さかったときの挙動が、謎で仕方ない。
わざとそうすること、あるのかなぁ?

まとめ

__getitem__メソッドに与える引数やスライスについて、細かい挙動を追ってみた。
とくにスライス周りで、よく分からない仕様はあるものの、実用上は困らなさそうな程度には理解できた。