エイエイレトリック

なぐりがき

pythonのsliceにおける、s[i:j:k]の挙動が複雑だったのでまとめる

pythonのスライス、特にstepについて、個人的にわかりにくいと思ったのでまとめます。

TL;DR

  • s[i:j:k] で無理やり書こうとすると可読性があまりよろしくない時があるので、 無理やり書かない のが一番。

以降のまとめはpython 3.8についてのドキュメント、実装に基づいています。 が、間違っている可能性もあるのでご了承ください。

ちなみに 'パタトクカシーー' の元ネタは 言語処理100本ノック 2015 の問題です。

スライスについてざっくりした概要

pythonは文字列やリストのようなシーケンス型に対して、[]を用いたスライスを使うことができる。 例えば、s[i:j] では、sのi以上j 未満 のインデックスの値を返す。

s = 'パトカータクシー'
s[1:4]
>> 'トカー'

さらに s[::k] ではkごとのインデックスの値を返す。k=2であれば、一つ飛ばしで値を出力する。

s = 'パタトクカシーー'
s[::2]
>> 'パトカー'

また、kは負の値をいれることができ、 k=-1とすると、逆順に値を取得できる。

p = 'パトカー'
p[::-1]
>> 'ーカトパ'

この i,j,k すべてを同時に使う s[i:j:k] ももちろん実行可能なのだが、色々と複雑になる。

Case Study(1) 'パタトクカシーー' から 'トカー' を取り出したい

まず 'パタトクカシーー' を 'パトカー' に変換してから、 'トカー' を取得する方法は以下の通り。

s = 'パタトクカシーー'
s[::2][1:]
>> 'トカー'

s[i:j:k]の形で記述するなら以下のようになる。

s = 'パタトクカシーー'
s[2::2]
>> 'トカー'

# ちなみに i=1とするとタクシーを取り出せる
s[1::2]
>> 'タクシー'

s[::2] において、'トカー' は1文字目以降の文字列であるが、 s において、 'トカー' は2文字目以降の文字列になるので注意が必要である。

f:id:sh111h:20200327095816p:plain
s[2::2]

Case Study(2) 'ーシクターカトパ' から 'トカー' を取り出したい

まず 'ーシクターカトパ' を 'パトカータクシー' に変換してから、'トカー' を取得する方法。

# 長音の区別がつくように最初のーは〜に変更した
s = '〜シクターカトパ'
s[::-1][1:4]
>> 'トカー'

s[i:j:k] の表記で書くにはどうしたらよいか? i,jをそのまま利用しても、空の文字列が返ってきてしまう。 ちなみにiとjを入れ替えてもダメ。

s[1:4:-1]
>> ''

s[4:1:-1]
>> 'ータク'

正解は

s[6:3:-1] もしくは s[-2:-5:-1] である。

このあたりで「ちょっと何言ってるか分からない」という気持ちになるので、

なぜこれが成立するのか、深掘りしてみる。

s[i:j:k] の挙動について詳しく

ドキュメントの 共通のシーケンス演算 によれば、s[i:j:k] は以下のような働きをするらしい。

s の「 i から j まででステップが k のスライス」は、インデックス x = i + nk (ただし n は 0 <= n < (j-i)/k を満たす任意の整数)を持つ要素からなるシーケンスとして定義されます。言い換えるとインデックスは i, i+k, i+2k, i+3*k と続き、 j に達したところでストップします (ただし j は含みません)。 k が正の数である場合、 i または j が len(s) より大きければ len(s) を代わりに使用します。 k が負の数である場合、 i または j が len(s) - 1 より大きければ len(s) - 1 を代わりに使用します。 i または j を省略または None を指定すると、 "端" (どちらの端かは k の符号に依存) の値を代わりに使用します。なお k はゼロにできないので注意してください。また k に None を指定すると、 1 が指定されたものとして扱われます。

組み込み型 — Python 3.8.2 ドキュメント より

長い しちょっと何言ってるか分からない

これだけ読むと、挙動として、インデックス x = i + n*k を先に決めているのか、i,jを先に決めているのかもよくわからない。

sliceobject

実装(sliceobject.c) を確かめたところ、どうやら先にiとjを決めているらしいことがわかった。

この実装に基づいて、説明する。

以降の説明では、 s[i:j:k] の i,j,kがなにを指しているかわかりやすくするため、 s[start:stop:step] とする。

start, stopをきめる

startとstopの決め方から結構複雑なので、コードで読んでもらったほうが理解がはやいと思う。

PySlice_AdjustIndices を元に、startとstepの決め方をpythonで実装すると、以下のようになる。

def adjust_index(idx, length, step):
    """ 
    :param idx: start もしくは stop indexの値
    :param length: シークエンスの長さ
    """
    if idx < 0:
        # マイナスなら最後から数えてidx要素
        idx += length
        if idx < 0:
            # それでもなおマイナスであれば端(最初の要素)
            idx = -1 if step < 0 else 0
    elif idx >= length:
        # シークエンスの長さよりも値が大きいときは端(最後の要素)
        idx = length-1 if step < 0 else length
    return idx

startとstopの決め方が複雑になっている理由のひとつは、負の数をとれることにある。 startもしくはstopが負の数であれば、最後から数えて何要素、という更新を行う。 (if idx<0 の部分)

s[-1:] が最後の1要素を取得できるのは、内部で s[-1+len(s):] という処理が行われているからである。

'ーシクターカトパ' の例で、s[6:3:-1]==s[-2:-5:-1] が成立するのも、

  • s[-2] = s[-2+len(s)] = s[-2+8] = s[6]
  • s[-5] = s[-5+len(s)] = s[-5+8] = s[3]

であるため。

f:id:sh111h:20200328101808p:plain
s[-2]=s[6], s[-5]=s[3]

また、シークエンスの長さよりも大きい数・小さい数でもエラーがでないように、はみ出していれば、 の値にあわせるように調整する。 これはstepの正負によって、 の値が変化する。

stepが正の場合の端
  • インデックスがシークエンスの長さ 以上 にマイナスの場合は、最初のインデックスの指定 (=0) に調整する。
    • つまり、length < -idx (ただし idx<0)であれば、「最後から数えて何要素」な挙動はしない
  • インデックスがシークエンスの長さより大きい時は最後の値 (=length) にあわせる。
s = 'パタトクカシーー'
s[:100]
>> 'パタトクカシーー'

s[-100:]
>> 'パタトクカシーー'
stepが負の場合の端
  • インデックスがシークエンスの長さ 以上 にマイナスの場合は、-1 に調整する。
  • インデックスがシークエンスの長さより大きい時は length-1 にあわせる。

正の場合と異なっているのは、後述のstepの挙動に理由がある。(と思われる)

s[100::-1]
>> 'ーーシカクトタパ'

s[:-100:-1]
>> 'ーーシカクトタパ'

ちなみに、

i または j を省略または None を指定すると、 "端" (どちらの端かは k の符号に依存) の値を代わりに使用します。

と書いてあるとおり、startもしくはstepを省略した場合は、上記で説明した の値になる。

なんでこんなにも面倒なことになっているのか考えたが、とにかく範囲外 (out of range) のエラーがでないような親切(?)設計にしたかったのだと思う。 実際、間違ったインデックスの渡し方をしても、空のシークエンスを返すようになっている。

とはいえ、 PySlice_AdjustIndices のコメントに

/ this is harder to get right than you might think /

というコメントがあって、色々お察しします。

stepに基づいた要素数を求める

startとstopが決まったので、ようやくstepの処理をする。

まず、stepによって出力される要素数を計算する。

def adjust_step(start, stop, step):
    # 要素数を取得する
    if step < 0 & stop < start:
        # stepが負の場合
        return ((start-stop-1)/(-step) + 1)
    elif step > 0 & start < stop:
        # stepが正の場合
        return ((stop-start-1)/step + 1)
    return 0

これは先ほど引用したドキュメントにおける

s の「 i から j まででステップが k のスライス」は、インデックス x = i + nk (ただし n は 0 <= n < (j-i)/k を満たす任意の整数)を持つ要素からなるシーケンスとして定義されます。 言い換えるとインデックスは i, i+k, i+2k, i+3*k と続き、 j に達したところでストップします (ただし j は含みません)。

のnの個数を求める部分にあたる。

  • stepが正であれば、start~stopまでの要素数をstepで割った数が要素数になる。

  • stepが負であれば、stop~start までの要素数を-step (step<0なので正の数にする)で割った数が要素数になる。

    • つまり、 start > stop が成立していなくてはいけない。

これは、stepが負の場合、startからマイナス方向にstepしていくため、stopに行くにつれ、インデックスの値がだんだん小さくなるためである。

f:id:sh111h:20200328095546p:plain
stepによる違い

s[1:4:-1] が空の要素を返したり、stepが負の場合の が正の場合と異なるのはこの仕様のため。

stepが正のときに start>stop だったり、stepが負の時に start<stop だった場合の要素数は0になる。(エラーにはならず、空のシークエンスを返す)

stepごとの要素を返す

start、stop、そしてstepの要素数が定まったところで、ようやくs[start:stop:step] が取得できる。

取得できる値のインデックスは、stepの要素数をnとすると


[ start+step\times0, start+step\times1, start+step\times2, ..., start+step\times(n-1) ]

になる。

具体的な値で考える。

stepが正の場合

s = 'パタトクカシーー' の、

s[2:5:2] について考えてみる。

まず、stepに基づいた要素数


(stop-start-1)/step +1 = (5-2-1)/2 +1 = 2

で2つ。 よって、 s[2]s[2+2*1] の2つを返す。

s = 'パタトクカシーー'
s[2] 
>> ' ト'
s[2+ 2*1] # =s[4]
>> 'カ'

s[2:5:2]
>> 'トカ'

f:id:sh111h:20200328095710p:plain
s[2:5:2]

stepが負の場合

s='ーシクターカトパ' のときの

s[6:3:-1] について考えてみる。

stepに基づいた要素数


(start-stop-1)/(-step) + 1
= (6-3-1) / -(-1) +1
= 3

で3つ。

よって、 s[6], s[6 +(-1)*1], s[6 + (-1)*2]

s='ーシクターカトパ'

s[6]
>> 'ト'
s[6 +(-1)*1] # =s[5]
>> 'カ'
s[6 + (-1)*2]] # =s[4]
>> 'ー'

s[6:3:-1]
>> 'トカー'

f:id:sh111h:20200328095806p:plain
s[6:3:-1]

まとめ

s[start:stop:step] は まず startとstopの値を調整し、その後、startからstepごと、stopにたどりつくまでの要素を返す。

それだけといえばそれだけなのだが、start・stop・step、全ての値がNoneではないとなると、なにを指しているのか分かりにくくなる。

可読性を考えれば、

s[::2][1:]s[::-1][1:4] のように、まずstepごとの要素を返してから、その部分列を取得する書き方の方がよい。

参考

ドキュメント

cpython

Qiita