【Python実践自作問題集】No.2 list/tuple問題の解消

# 1. はじめに

この問題集は、Pythonの基礎を習得した後、次の段階へ進みたい人のサポートをすることが目的です。
また、競技プログラミングとは異なり、複雑なアルゴリズムの問題ではなく「**可読性の最大化**」に焦点を当てた問題が中心となります。

[目次](https://qiita.com/__Lily__/items/6f70cd6ae0e80593f09d)
前の問題：[型変数の活用](https://qiita.com/__Lily__/items/3f478edd70d5cf6fbeb9)
次の問題：[整数？実数？](https://qiita.com/__Lily__/items/cce12790e536667f12bd)


# 2. 問題

```python:Question
"""
No.2 list/tuple問題の解消

以下のget_first関数を、次の要件を満たすように改修してください。

* 引数valuesは、list型だけではなくtuple型、str型、listを継承したMyList型など、
    values[0]で値を取得できるような全ての型を受け入れる。
* 関数の機能は変えないこと。
    つまり、valuesの長さが0であればNoneを、1以上であれば先頭要素を返す。
"""


class MyList(list):
    pass


def get_first[T](values: list[T]) -> T | None:
    if values:
        return values[0]
    else:
        return None
```


# 3. 解答例

```python:Answer
from collections.abc import Sequence


def get_first[T](values: Sequence[T]) -> T | None:
    if values:
        return values[0]
    else:
        return None
```


# 4. 採点基準

* TypeHintが適切であること


# 5. 解説

## 5.1 TypeHint

改修前の`get_first`関数では、引数`values`に`tuple`型を受け取ることができませんでした。

```python
def get_first[T](values: list[T]) -> T | None:
    if values:
        return values[0]
    else:
        return None


get_first(values=(0, 1, 2))  # invalid type
```

`values`の型を修正し、`tuple`型などを受け取れるようにするのがこの問題の意図です。
しかし、次のようなTypeHintは望ましくありません。

```python
def get_first[T](values: list[T] | tuple[T] | str | MyList[T]) -> T | None:
    ...
```

`Union`型を使った書き方ではTypeHintが際限なく長くなってしまいます。
そこで、別の方法を考えることにしましょう。
まずは要求の内容を観察します。
すると、`values`は次の条件を満たせばよいということがわかります。

* `values[0]`のように角括弧アクセスができる
* `len(values)`で長さを取得できる

そして、それぞれ内部では次のメソッドが呼び出されています。

* 角括弧アクセス　→　`__getitem__`
* 長さの取得　→　`__len__`

つまりこの状況では、この2つのメソッドをサポートするクラスなら`values`に指定しても正常に処理できます。
では、`values`の型はどう書くべきでしょうか？
ここで登場するのが`collections.abc`モジュールです。
この中で、`__getitem__`, `__len__`メソッドをサポートするクラスとして`Sequence`が定義されています。

[collections.abc --- コンテナの抽象基底クラス - docs.python.org](https://docs.python.org/ja/3.13/library/collections.abc.html)

![base.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2573710/143fc21b-c401-4e2f-a239-78d0bb75b431.png)

[Add an UML class diagram to the collections.abc module documentation - github.com](https://github.com/python/cpython/issues/76652)

ただし見ての通り継承関係が複雑なため、理解するのには骨が折れます。
そこで今回は、「`list`/`tuple`のどちらでもよい場合、TypeHintに`Sequence`を使う」ということだけ覚えればOKです。

以上の議論から、`values`の型は`Sequence[T]`とするのが適切です。


# 6. 補足

if文の真理値判定について捕捉します。
次のif文では`values`の長さが0以外のとき`True`と判定されます。

```python
if values:
```

実はこの短いコードの裏には、巧妙なトリックが仕掛けられています。
まずは一般的な状況で考えましょう。
次のif文で条件が成立するパターンを**正確に**挙げてみてください。

```python
if something:  # somethingの型は不明とする
```

正解は次のいずれかのパターンです。

* `something`に`__bool__`メソッドが定義されていて、`True`を返す
* `something`に`__bool__`メソッドが定義されていない、かつ`__len__`メソッドが定義されていて0以外の値を返す
* `something`に`__bool__`, `__len__`メソッドが定義されていない

[組み込み型 真理値判定 - docs.python.org](https://docs.python.org/ja/3/library/stdtypes.html#truth)

実際に試してみましょう。

* `__bool__`メソッドが定義されているかで2パターン
* `__bool__`メソッドの戻り値(`False`, `True`)で2パターン
* `__len__`メソッドが定義されているかで2パターン
* `__len__`メソッドの戻り値(0, 1)で2パターン

が考えられます。
（ただし、メソッドが定義されていない場合は戻り値を考えられないので重複が生じます。）

|No.|`__bool__`の定義|`__bool__`の戻り値|`__len__`の定義|`__len__`の戻り値|
|:-:|:-:|:-:|:-:|:-:|
|1|なし|`False`|なし|0|
|2|なし|`False`|なし|1|
|3|なし|`False`|あり|0|
|4|なし|`False`|あり|1|
|5|なし|`True`|なし|0|
|6|なし|`True`|なし|1|
|7|なし|`True`|あり|0|
|8|なし|`True`|あり|1|
|9|あり|`False`|なし|0|
|10|あり|`False`|なし|1|
|11|あり|`False`|あり|0|
|12|あり|`False`|あり|1|
|13|あり|`True`|なし|0|
|14|あり|`True`|なし|1|
|15|あり|`True`|あり|0|
|16|あり|`True`|あり|1 |

<details><summary>Pythonコード</summary>

```python
class Something1:
    pass


class Something2:
    pass


class Something3:

    def __len__(self) -> int:
        return 0


class Something4:

    def __len__(self) -> int:
        return 1


class Something5:
    pass


class Something6:
    pass


class Something7:

    def __len__(self) -> int:
        return 0


class Something8:

    def __len__(self) -> int:
        return 1


class Something9:

    def __bool__(self) -> bool:
        return False


class Something10:

    def __bool__(self) -> bool:
        return False


class Something11:

    def __bool__(self) -> bool:
        return False

    def __len__(self) -> int:
        return 0


class Something12:

    def __bool__(self) -> bool:
        return False

    def __len__(self) -> int:
        return 1


class Something13:

    def __bool__(self) -> bool:
        return True


class Something14:

    def __bool__(self) -> bool:
        return True


class Something15:

    def __bool__(self) -> bool:
        return True

    def __len__(self) -> int:
        return 0


class Something16:

    def __bool__(self) -> bool:
        return True

    def __len__(self) -> int:
        return 1


somethings = {
    1: Something1(),
    2: Something2(),
    3: Something3(),
    4: Something4(),
    5: Something5(),
    6: Something6(),
    7: Something7(),
    8: Something8(),
    9: Something9(),
    10: Something10(),
    11: Something11(),
    12: Something12(),
    13: Something13(),
    14: Something14(),
    15: Something15(),
    16: Something16(),
}
for number, something in somethings.items():
    if something:
        print(f'something{number}: True')
```

</details>

```text:実行結果
something1: True
something2: True
something4: True
something5: True
something6: True
something8: True
something13: True
something14: True
something15: True
something16: True
```

それぞれ次のパターンに該当します。

* `something`に`__bool__`メソッドが定義されていて、`True`を返す
　→ No.13, 14, 15, 16
* `something`に`__bool__`メソッドが定義されていない、かつ`__len__`メソッドが定義されていて0以外の値を返す
　→ No.4, 8
 * `something`に`__bool__`, `__len__`メソッドが定義されていない
　→ No.1, 2, 5, 6

 要するに、`__bool__`の戻り値が最優先、次いで`__len__`の戻り値が採用され、どちらも定義されていない場合は`True`判定となります。
そのため、次のように「`__bool__`と`__getitem__`をサポートするが、`__len__`はサポートしない」クラスを`values`に指定してもエラーは発生しません。

```python
def get_first[T](values) -> T | None:
    if values:
        return values[0]
    else:
        return None


class SupportsBoolAndGetItem:

    def __init__(self, values: list) -> None:
        self._values = values

    def __bool__(self) -> bool:
        return bool(self._values)

    def __getitem__(self, index: int):
        return self._values[index]


values = SupportsBoolAndGetItem([0, 1, 2])
assert bool(values) is True
assert values[0] == 0
try:
    _ = len(values)
except TypeError as e:
    print(e)
print(get_first(values=values))  # no error
```

```text:実行結果
object of type 'SupportsBoolAndGetItem' has no len()
0
```

この`SupportsBoolAndGetItem`クラスは`__len__`メソッドをサポートしていないため、`collections.abc`モジュールの`Sequence`クラスの子クラスではありません。
このことから、`get_first`の`values`引数に指定できる型は`Sequence`より広いことがわかります。
しかしここまで話を広げると流石に収集つかなくなるので、今回は要件に「`values`の長さが～」と書くことで、`values`が`__len__`メソッドを持つことを保証しました。