Python | list.sort()の引数keyにtuple(タプル)を渡した時の話

sort

こんにちは。バッチとパッチがカタカナにすると同じにしか見えない小幡です。昔から疑問に思っていたのですが、これが見にくいと感じているのは、私だけなのでしょうか?

タプルもダブルと見間違えてしまいそうで怖いのですが、そんなどうでもいいような話は置いておきまして、本題に入りたいと思います。

今日は、Pythonの標準ライブラリにあります、list型のsort関数について触れていきます。

list.sort()については公式でもHOW TOが丁寧に書かれています。単純に「文字列を全て小文字にしたものをkeyとして渡す場合」などは、公式ドキュメントがとても参考になりますので、是非そちらを読んでみてください。とは言っても、少し複雑なので、できるだけわかりやすく解説してみたいと思います。

さらに、公式ドキュメントを読んでもあまり理解できなかった、「keyにtupleを渡したときの処理」についても触れていきます。公式ドキュメントのHOW TOは以下リンクよりどうぞ。

公式ドキュメント

前提条件

今回は初歩的な内容はないと思いますので、以下の様な人向けの記事だと思います。

  • Pythonの入門的なテキストを一通り読んだ人
  • lambda式が少しわかる人
  • list.sort()を使ったことがある人
  • 使用環境:python3.9, Windows10

list.sort()とは?

おさらいではありますが、ここで説明するlist.sort()とは何かを簡単に説明します。(公式ドキュメントとは少し違う説明をしている箇所もあるかと思いますので、正確な情報は公式ドキュメントを参照してください。)

まずここで言うlistとはpythonで言うところのlist型のオブジェクトを指しています。もっと具体的に言うと以下のように、user_listの変数に代入されているもののようなイメージです。

# ユーザのリスト
user_list = ['Yamada', 'Tanaka', 'Sato']

ここまでをまとめると「user_listはユーザを集めたlistだよ」というイメージです。

これは入門サイトなどでよくある説明だとおもいますが、ここから一歩踏み込んだlistのsort()に触れてみます。

「list.sort()」という表記からもわかるように「listのsortメソッド」であることがわかるかとおもいます。先ほどのように作成されたlistには、最初からsort()が付属されているのです。

なので、listとして作成されたものは、作成後いつでも自由にソートすることが可能というイメージです。逆に言うとlistではないものでは、list.sort()は使えません。

では、listはuser_listとして作成したので、早速sort()を使って見ましょう。

ちなみにHOW TOには積極的に記述されていますが、

list以外でもソートできるsorted()というメソッドも参照しておくと、ソートの幅が広がると思います。こちらは、listの中身をソートするのではなく、新しいものを再作成するイメージです。

list.sort()の戻り値はNoneです。

最も単純なlist.sort()

まずは、もっとも単純なソートを見ていきましょう。先ほど作成したuser_listも再掲します。

user_list = ['Yamada', 'Tanaka', 'Sato']
user_list.sort()
# 結果 ['Sato', 'Tanaka', 'Yamada']

ここでのポイントは以下の通りです。

  • user_list自身がソートされている(list.sort()の戻り値はNone)
  • 新しいリストが作成される訳ではないので、変数への代入は不要
  • 文字列の昇順でソートされている

最終行に記述しているコメントの結果は、仮にprint()などを使用しソートしたリストを表示した場合の状態を表しています。

なのでlist.sort()しただけでは、特に何かしらの結果が帰ってくる訳ではありません(list.sort()の戻り値はNone)

このように、list.sort()を使用すると単純な文字列の昇順でのソートが可能です。同様にint型を格納したlistなども期待通りの結果が得られます。

そしてここから少し複雑なソートの方法について触れていきます。

キーワード引数、key、reverse

list.sort()には特定のキーワード引数が割り当てられています。

キーワード引数とは、引数として渡す場合にそのキーワードに代入する必要がある引数というイメージです。list.sort()の場合は以下のように設定します。これについては、公式ドキュメントにも記述がありますので、正確な表現はそちらを参考にしてください。

user_list.sort(
    key=str.lower,
    reverse=True,
)

詳しく見ていきます。

1行目は先ほどと同様に、user_listをsort()でソートする、という宣言のイメージです。

2行目と3行目はキーワード引数を渡しているわけですが、2行目のキーワード引数は「key」です。このkeyというのは、list()によって定められている値で、そこにstr.lowerを代入しているイメージです。keyは「何を基準にソートするか?」を設定するイメージです。ここではstr.lowerなので「文字列型の文字を全て小文字にした値(オブジェクト)」を使ってソートしてね。と設定しているようなイメージです。

(このstr.lowerが謎の1つでもあります:未解決のため以下曖昧な表現が含まれます)

ここで言う「str.lower」の「str」は、user_listの値を1つずつ取り出したものを使用しています。それぞれのユーザーの名前というイメージです。

(あくまでイメージです。実際の処理はもっと複雑なものかもしれません。「lower()」ではないので、メソッドではないと思うのですが、詳細は不明です。もしかして特殊メソッド「__str__」を使っている?という具合です。私のイメージを記述しておきます。)

3行目のreverseはboolean型を受け付けています。何も設定されていない状態だと昇順でソートされます。文字列の昇順は「a, b, c, d, … y, z」というイメージです。数値であれば「1, 2, 3, ….」というイメージです。(実際に使用される場合は一度検証することをお薦めします。)

このreverseの引数にTrueが設定されると、降順でソートができます。「z, y, x, …. b, a」、「9,8,7, … 2,1」というイメージです。

key引数にlistの中身が渡される件(未解決)

結果的にはlistの中身が渡されています。この後紹介するlambda式でも、渡されているものが中身であることがわかります。

そして、突然user_listの1つずつの値が登場してきて困惑するポイントかと思います。(実際は1つずつ取り出しているわけではないかもしれません。)入門的な説明だとlistの中身を1つずつ取り出すためには、for文などを使わなければいけないはずだと考えるからです。

では、どうしてuser_listの中身を取り出すことができたのでしょうか?その答えは、公式のsort()のkeyに渡す値に説明文に書いてあるかと思いましたが、そういう訳でもないみたいです。以下が公式ドキュメントの引用文です。

key パラメータの値は関数または呼び出し可能オブジェクトであって、単一の引数をとり、ソートに利用されるキー値を返すものでなければいけません。

https://docs.python.org/ja/3/howto/sorting.html

ここでわかるのは、「関数か呼び出し可能なオブジェクト」を渡しているらしい、ということだけです。str.lowerはstrクラスのlowerオブジェクトという認識です。つまり呼び出し可能なオブジェクトを渡しているらしいです。

ソートするリストの中身が以下の様な辞書型を入れたリストの場合は、エラーとなります。エラーの説明からもわかるように「strオブジェクトのlowerは~」と書いてあります。

TypeError       (note: full exception trace is shown but execution is paused at: test_sort_string_list)
descriptor 'lower' for 'str' objects doesn't apply to a 'dict' object

辞書型を入れたリストをソートする

さて、本題に向けて話を続けます。

次は辞書型を入れたリストをソートする場合です。以下のようなリストを用意します。

user_list = [
    {
        'id': '456',
        'name': 'Yamada',
    },
    {
        'id': '001',
        'name': 'Tanaka',
    },
    {
        'id': '119',
        'name': 'Sato',
    },
]

先ほどのユーザーリストにIDを追加しました。これでIDと名前を持つ辞書型ユーザのリストが作成されました。

このリストをID順にソートしてみます。ソートするためには以下の様にLambda式を使用する必要があります。

user_list.sort(
    key=lambda x: x.get('id'),
)

このラムダ式では先ほど記述した通り、リストの中身がkeyの引数として1つずつ渡されることを利用しています。渡される引数を「x」としてラムダ式の引数に与えています。するとxはユーザ単体を表すことになり、その単一ユーザのIDをゲットしているイメージです。

まとめると、「単一ユーザのIDをソートするキーに使います」という設定になるイメージです。

もっと厳密に標準ライブラリの中身をのぞいて見ると以下の様な形でsort()は定義されています。

def sort(self, *, key: Callable[[_T], SupportsLessThan], reverse: bool = ...) -> None: ...

「key: Callable[[_T], SupportsLessThan]」という記述があります。なにやらすごく難しい処理をしているように感じます。なんとなくSupportsLessThanクラスというもので、小なりイコール()(未満)の比較(サポート?)をしているのかな?というイメージは付きますが・・・。

ちなみに、辞書型のソートのラムダ式にてx.get(‘id’)という記述をしてNoneの場合を回避しているような気持になりますが、Noneや値なしの場合はエラーになります。エラー内容を以下に記します。

TypeError: '<' not supported between instances of 'NoneType' and 'str'

どうやらx.get()は出来た後Noneが取得され、Noneとstrを”<”で比較した際にエラーとなっているようです。

ここで重要なのは、Noneの場合は比較するときにエラーになるということです。

tupleをソートのkeyにする

しかしながら、ずっと昔に登録したユーザにはIDが降られていない場合があったとします。これは先にも記述した通り、値にNoneや値梨が存在するという状況です。

以下の様なデータのイメージです。

user_list = [
    {
        'id': '456',
        'name': 'Yamada',
    },
    {
        'id': None,
        'name': 'Tanaka',
    },
    {
        'name': 'Sato',
    },
]
  • ユーザYamada氏のIDは正常です。
  • ユーザTanaka氏のIDはNoneが設定されています。
  • ユーザSato氏のIDは設定されていません。

先ほども実行してエラーが出たことでわかる通り、

このNoneや値なしの状況のまま、単純にIDでソートしようとするとNoneは比較できないためエラーとなることが予想できます。

これをソートするために、そもそもIDが存在しているかを判定し、その後IDでソートするという関数を作成します。

以下のようになりました。

まずは存在チェックする関数です。タプルで(bool型, IDの値, )を返却します。

これはこの記事のタイトルとなっている「list.sort()の引数keyにtuple(タプル)を渡す」ための関数ということです。

def check_exists(value):
    return (bool(value), value, )

bool()では、任意の値をbool型に型変換しています。空文字はFalse、NoneはFalse、int型の0はFalse、になり、何かしらの文字列が入っていればTrueとなります(python3.9)

なので、ラムダ式でx.get()として、Noneの場合はFalseとなり、値が入っていればTrueを返却することになります。

タプルの後ろ側には、IDの実際の値が入ります。これは、Trueだったもの同士を最終的にはIDでソートするためです。

まとめると以下の流れになります。

  • 値があればTrue、無ければFalse
  • IDはタプルの二番目に格納
  • Trueのものをまとめて、IDで比較

これを使用したラムダ式は以下のようになります。

user_list.sort(
    key=lambda x: check_exists(x.get('id')),
    reverse=True,
)

実際に先ほどのuser_listをソートしてみましょう。実際に実行したコードは以下です。pytestを利用しています。コマンドライン実行時にオプション「-s」を付与するとprint()などで出力した文字列を確認できます。

class TestListSort:
    def test_sort_string_list(self):
        """list型をtupleを使ってソートする
        """
        user_list = [
            {
                'id': '456',
                'name': 'Yamada',
            },
            {
                'id': None,
                'name': 'Tanaka',
            },
            {
                # 'id': '119',
                'name': 'Sato',
            },
            {
                'id': '999',
                'name': 'Kobayashi',
            },
        ]
        user_list.sort(
            key=lambda x: check_exists(x.get('id')),
            reverse=True,
        )

        print(user_list)


def check_exists(value):
    return (bool(value), value, )

結果は以下の通りです。(見やすくするため改行しています。)

[
    {'id': '999', 'name': 'Kobayashi'},
    {'id': '456', 'name': 'Yamada'},
    {'id': None, 'name': 'Tanaka'},
    {'name': 'Sato'},
]

今回のソートではreverseをTrueにしていますので、新しく追加したID999を持つKobayashi氏が先頭にくるという期待通りの結果になると共に、Noneが存在していても無事にソートされました。

さらに注目なのは、check_exists()で作成したタプルは結果として残らないところです。元のリストは影響を受けていません。

list.sort()の安定(stable)を確認する

念のためにreverseをFalseにしてもう一度ソートしてみます。以下が結果です。

[
    {'id': None, 'name': 'Tanaka'},
    {'name': 'Sato'},
    {'id': '456', 'name': 'Yamada'},
    {'id': '999', 'name': 'Kobayashi'},
]

期待通りにKobarashi氏とYamada氏の順番が逆になっていますね。ID:999が一番したに来ており期待通りの結果です。

さらに注目したいのは、IDが存在しないTanaka氏とSato氏の順番です。reverseがTrueかFalseに関係なく、もとの順番が維持されています。これは公式のHOW TOでも記述されている通り、「 安定 (stable) であることが保証されています。」の結果の通りかなぁと思います。実際はタプルを渡した時の処理を先ほどの複雑そうな中身を見ていないのでわかりませんが・・・。「安定である」というのはどういうことか?以下引用文です。

ソートは、 安定 (stable) であることが保証されています。これはレコードの中に同じキーがある場合、元々の順序が維持されるということを意味します。

https://docs.python.org/ja/3/howto/sorting.html

ということで、IDがNoneだったTanaka氏とSato氏は、もとの順番が維持されたというイメージです。IDを持つユーザーと比較すると、IDを持たない2人は、「ID:0」が与えられたと考えても良いかもしれません。

まとめ

以上で、「list.sort()の引数keyにtuple(タプル)を渡した話」は終わりです。

長文にお付き合い頂きありがとうございました。

まとめとしては、

  • tuple型を渡しても安定なソートが可能(要検証)
  • tuple型で渡すとNoneと値なしも処理できる(要検証)

一応要検証としました。テストの結果は上に記していますが、抜けている部分がないとも言い切れません。

こうしてブログ記事を書きながらなんども公式ドキュメントを見返しましたが、結局sort()のkeyで何が行われているのか、詳細を把握するまでには至りませんでした。もうすこし踏み込んでみていくことができれば、プログラミング言語そのものの開発などにも携われるのかなぁ?などと夢みていましたが、そう簡単には行きそうもありませんでした。

途中で出てきたstr.lowerなどを見ているうちに、__str__のような特殊メソッドが呼ばれているのでは?と考える様になり、またわからないことが増えてしまいました。特殊メソッドの呼び出し方もまだまだわからないことが多いので、今回はこの辺でおわりとします。

テストケースをエビデンスとして提示できれば、今回のtupleを使用したソートは使用可能だと考えていますが、保証はできません。万が一使用される場合は自己責任でお願い致します。

以上です。ありがとうございました。

この記事を書いた人

小幡 知弘

1990年茨城県神栖市生まれ
2013年大阪芸術大学卒業
Python×Webエンジニア