67
101

Pythonユーザー必見!知らなきゃ損する小ネタ集

Last updated at Posted at 2024-08-20

はじめに

Python のあまり知られていない便利な小ネタを筆者の独断と偏見でとりとめなくまとめました。標準だけどドキュメントを隅々まで読まないと見落としてしまうような機能を、周辺知識にも触れながら紹介していきます。

ちなみに記事のタイトルは ChatGPT 先生に考えてもらいました。知らなくても特に損はしないと思います。

本記事の内容は Python 3.8 以降を想定しています。また、3.9 以降で新しく追加された機能については、追加されたバージョンを記載しています。

スワップ

変数のスワップには専用の記法があります。

a = 1
b = 2
a, b = b, a
print(a, b)  # 2 1

余計なタプルが作られそうな見た目をしていますが、実際は下記の動作に置き換わるため、パフォーマンスの心配はありません。

  1. 右辺の変数をスタックに一つずつロードする
  2. スタックを スワップ(または ローテーション)する
  3. スタックから左辺の変数に一つずつストアする

3つ組でも同様です(4以上は見た目通りにタプルが作られます)。

# 下記どれでもOK
a, b, c = c, b, a
a, b, c = b, c, a
a, b, c = c, a, b

スタックのスワップなので、右辺は演算結果でもOKです。

a = 1
b = 2
a, b = b + 10, a + 10
print(a, b)  # 12 11

辞書やリストに対しても使えます。

d = {"x": 1, "y": 2}
d["x"], d["y"] = d["y"], d["x"]
print(d)  # {'x': 2, 'y': 1}

a = [1, 2]
a[0], a[1] = a[1], a[0]
print(a)  # [2, 1]

スライスでも使えますが、numpy などのスライスがビューを返すケースでは注意が必要です。

a = list(range(10))
a[2:4], a[6:8] = a[6:8], a[2:4]
print(a)
# [0, 1, 6, 7, 4, 5, 2, 3, 8, 9]

a = np.arange(10)
a[2:4], a[6:8] = a[6:8], a[2:4]
print(a.tolist())
# [0, 1, 6, 7, 4, 5, 6, 7, 8, 9]
#                    ^  ^

また、長さが違うスライスのスワップもできますが、直感に反する挙動になるのでお勧めしません。

a = list(range(10))
a[1:2], a[4:8] = a[4:8], a[1:2]
print(a)
# [0, 4, 5, 6, 1, 5, 6, 7, 8, 9]

これは下記と等価です。

a = list(range(10))
t1 = a[1:2]
t2 = a[4:8]
a[1:2] = t2  # a = [0, 4, 5, 6, 7, 2, 3, 4, 5, 6, 7, 8, 9]
a[4:8] = t1  # a = [0, 4, 5, 6, 1, 5, 6, 7, 8, 9]

連鎖比較

比較演算子は連鎖できます。

if 0 <= left < right < len(arr):
    ...

これは下記と等価です。

if 0 <= left and left < right and right < len(arr):
    ...

あくまでも隣接する比較が and で連結されるだけで、全体として評価される訳ではないことに注意です。

start, left, right, end = 0, 7, 2, 3
print(start < left < right < end)  # False
print(start < left != right < end)  # True
# (start < left) and (left != right) and (right < end) であり、
# (start < left < end) and (start < right < end) and (left != right) ではない。

連鎖と明示的 and の最大の違いは、連鎖すると各オペランドが1回しか評価されない点です。つまり、上記の and を使う例では leftright がそれぞれ2回ずつロードされるのに対して、連鎖するとロード回数は1回ずつになります。

この特性は、何らかの計算結果を比較したい時に特に有効です。

if 0 <= (left - margin) < (right + margin) < len(arr):
    ...

in range

in 演算子は range オブジェクトにも使えます。

target_range = range(len(arr))
if i in target_range:
    ...

これは下記と等価であり、O(1) の処理です。

if 0 <= i < len(arr):
    ...

ただし、range オブジェクトの生成自体のオーバーヘッドはあるので注意です。

for i in range(n):
    if i in range(len(arr)):  # これは range オブジェクトが毎回作られる。
        ...

つまり、チェックしたい範囲が固定な時に有効ですね。これはチェックしたい範囲が複雑な時に使うと可読性に貢献すると思います。

for x, y in points:
    if (x in a_range or y in b_range) and (x not in c_range and y not in d_range):
        ...

range のスライス

range(10) の逆順の range をそらで書けますか?

正解は↓

list(range(9, -1, -1))
# [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

書けたあなたは range マスターです。えらい!

書けなかったあなたに朗報です。range オブジェクトはスライスできます。

range(10)[::-1]
# range(9, -1, -1)

range オブジェクトが返ってくるので、繰り返しスライスすることもできます。

range(100)[::-2][:10]
# range(99, 79, -2)

これを利用すればリストに対する複数回のスライス操作を1つにまとめるのも簡単です。

r = range(len(arr))[::-2][:10]
arr[r.start:r.stop:r.step]

イテレータのアンパック

タプルなどにパックされた要素を展開することをアンパックと言います。スワップ記法も論理的にはアンパックの一種と言えます。

t = (1, 2)  # パッキング(packing)
a, b = t  # アンパッキング(unpacking)
print(a, b)  # 1 2

上記は要素数が固定のアンパックですが、要素数が不明な時はイテラブルアンパックが使えます。

[*a] = range(5)
print(a)  # [0, 1, 2, 3, 4]

これは下記と等価です。

a = list(range(5))

ちなみに上記ではリスト [] 形式で受け取っていますが、左辺はリストでもタプルでもバイトコードは一緒になるので好きな方が使えます。個人的には、1要素の時は末尾 , の有無で挙動が変わるのは嫌なのでリストを、複数ある時は括弧を省略したいのでタプルを使っています。

さて、ここで紹介したいのが、イテレータの固定長アンパックというテクニックです。

from pathlib import Path

[path] = Path("log").glob("*.txt")

これは log ディレクトリ直下に該当するファイルが 1 個だけあることを要求し、0 個または 2 個以上あると例外になります。つまり下記と等価です。

path_iterator = log_dir.glob("*.txt")
try:
    path = next(path_iterator)
except StopIteration:
    raise ValueError("not enough values to unpack")
try:
    _ = next(path_iterator)
    raise ValueError("too many values to unpack")
except StopIteration:
    pass

例えば「ファイルがそこにあることが分かっているけど、タイムスタンプ等が付いていてファイル名が固定ではない時」などで有効です。

他にも下記のようなケースもあります。

text = "20240101_123456_00001"
date, time, number = text.split("_")

正規表現で実装するならこんな感じでしょうか。

import re

text = "20240101_123456_00001"
match = re.match(r"(\d{8})_(\d{6})_(\d{5})", text)
assert match
date, time, number = match.groups()

正規表現の方が上記例のように厳格なチェックができますが、str.split で十分なケースも多々あるはずです。

アンパックのネスト

アンパックはネストできます。

t1 = (1, 2)
t2 = (3, 4)
t = (t1, t2)

(a, b), (c, d) = t  # ネストされたアンパック
print(a, b, c, d)  # 1 2 3 4

よく使うのはループ変数のアンパックです。

d = {"a": 1, "b": 2, "c": 3}

for i, (key, value) in enumerate(d.items()):
    ...
xy_list1 = [(1, 1) for _ in range(5)]
xy_list2 = [(1, 1) for _ in range(5)]

for (x1, y1), (x2, y2) in zip(xy_list1, xy_list2):
    ...

イテラブルアンパックをネストすることもできます。

from itertools import groupby

for key, [*group] in groupby(items, key=lambda e: e.key):
    ...

これは下記と等価です。

for key, group in groupby(items, key=lambda e: e.key):
    group = list(group)
    ...

これは内包表記で書きたい時にたまに役立ちます。

value_range_map = {
    key: (min(group), max(group))  # イテレータのままだと複数回イテレートできない。
    for key, [*group] in groupby(items, key=lambda e: e.key)
}

アンパックによるマージ

アンパックはリストやタプルのマージにも使えます。

a = [1, 2]
b = (3, 4)
c = {6, 5}  # set の順番は未定義(結果が入れ替わっていることに注目)
d = range(7, 9)
merged = [0, *a, *b, *c, *d, 9]
print(merged)  # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

型がバラバラですが変換等は不要なところに注目です。アンパックはイテラブルなら何にでも使えます。

内包表記でも同様に使えます。

merged = [
    *[i for i in range(3)],
    *(i for i in range(3)),
    *{i for i in range(3)},
]

上記の例では結果はリストになりますが、タプルや set にすることもできます。

merged = (*a, *b, *c)
merged = {*a, *b, *c}

辞書も同様にアンパックでマージできます。辞書の場合、キーが重複すると後(右)側で上書きになります。

default_config = {"a": 1, "b": 2, "c": 3}
user_config = {"a": 10, "c": 20}
merged = {**default_config, **user_config}
print(merged)  # {'a': 10, 'b': 2, 'c': 20}

ちなみに、Python 3.9 以降なら | 演算子でも同じことができます。

merged = default_config | user_config

アンパックが ugly だから導入されたらしいですが、アンパックの方が汎用性が高いのでアンパックだけ覚えておけば十分と思います。

辞書が巨大でコピーしたくないなら ChainMap も便利かもしれません。

空コンテナのアンパック

空のリストやタプルをアンパックしても何も起こりません。(エラーになりません)

a = []
b = [0, *a, 1]
print(b)  # [0, 1]

これを利用して、Dart の collection if のように条件を満たす時だけリストに追加する操作が書けます(お勧めはしません)。

condition: bool = False
items = [
    1,
    *([2] if condition else []),
    3,
]
print(items)  # [1, 3]

これ自体は正直あんまり読みたくないコードですが、空コンテナのアンパック自体は、例えば次のようにインターフェースを少し拡張して定義すれば十分に実用的なテクニックです。

def get_items(config) -> list[Item]:
    ...


items = [
    *get_items(config_a),
    *get_items(config_b),
]

without with

Python で JSON などのファイルを読み書きする時、with 構文を使うと思います。

import json

with open("a.json", mode="r") as fo:
    data = json.load(fo)

この書き方も間違ってはいないのですが、代わりに pathlibPath.read_text を使って一括読み込みにした方がコードは単純になります(Path は普段から使っている前提です)。

import json
from pathlib import Path

path = Path("a.json")
data = json.loads(path.read_text())

テキスト読み込み以外の API も一式揃っています。

path.read_text()    # mode='r'
path.read_bytes()   # mode='rb'
path.write_text()   # mode='w'
path.write_bytes()  # mode='wb'

これらの API も内部的には普通に with 構文使って読み書きしているだけで、いわば Python におけるファイル操作の高レベル API と言っても差し支えないでしょう。言い換えると with 構文はもはや低レベル API であり、具体的にはファイルストリームが必要な時しか使わないと思います。

ちなみに最初の例の json.load(ファイルオブジェクトを受け取る方)も内部的には メモリ上に一括読み込み しています。なので json モジュールの利用時に with 構文を使うのは無意味です。今まで気にしてこなかった方はこれを機に移行してはいかがでしょうか。

また、openmode 引数次第で様々なオブジェクトを返しますが、ルートクラスである IOBaseオブジェクトの削除時にファイルを閉じる ので、実は下記のように書くこともできます。

data = json.load(open("a.json"))

ただし、Python 界隈では一般的にこのやり方は好まれていません。この理由はいくつかありますが、根本的にはPython の言語仕様として オブジェクトが削除されるタイミングが未定義 であることに由来すると思います。CPython の実装仕様としては参照カウンタを使っているので予測可能な挙動をしますが、例えば PyPy などの他のインタプリタでは挙動が異なってしまいます。

ヒアドキュメント

docstring でお馴染みの """ または ''' で囲った文字列は docstring 専用の構文ではなく、普通の文字列です。ただし、文字列の途中で改行もできる、いわゆるヒアドキュメントになります。

CODE = """\
for i in range(10):
    if i % 2:
        print(i)
"""
print(CODE)
for i in range(10):
   if i % 2:
       print(i)

上の例でも使っていますが、ヒアドキュメント内でも行末に \ を追加すれば改行が消えます。

text = """\
a\
b\
c\
"""
print(text)
abc

ヒアドキュメントにはインデント(空白)もそのまま含まれてしまうので、関数やクラスメソッドの中では扱いにくいです。

class Gen:
    def gen_code(self):
        code = """\
            for i in range(10):
                if i % 2:
                    print(i)
        """
        return code


print(Gen().gen_code())
            for i in range(10):
                if i % 2:
                    print(i)
        
|<-------->| インデントの空白を含んでしまう

こういう時は textwrapdedent を使うと、最初の行を基準にインデントが解除されます。

from textwrap import dedent


class Gen:
    def gen_code(self):
        code = dedent(
            """\
            for i in range(10):
                if i % 2:
                    print(i)
            """
        )
        return code


print(Gen().gen_code())
for i in range(10):
    if i % 2:
        print(i)

ちなみにヒアドキュメントも f-string にできます。

class Gen:
    def gen_code(self, n):
        code = dedent(
            f"""\
            for i in range({n}):
                if i % 2:
                    print(i)
            """
        )
        return code

r-string

正規表現でパターン文字列の先頭に r を付けているのを見たことがありませんか?

import re

re.match(r"\d+", "123")

これは Python で正規表現(regular expression)を書くための機能、ではありません。

正しくは raw string と言って、エスケープシーケンス(\n など)を無視すること以外は普通の文字列です。正規表現以外でも使えますし、ヒアドキュメントや f-string と組み合わせたりもできます。

text = rf"""# サンプルコード
with open("{input_path}", "rt") as fi, \
    open("{output_path}", "wt") as fo:
    for line in fi:
        fo.write(line)
        fo.write("\n")
"""

この機能が正規表現の次に輝くのが、Windows 環境でパスをコピペしてきた時です。

path = r"C:\Users\nyancat\Documents\temp\x001.txt"

これは raw string にしないとパスとして機能しません。

path = "C:\Users\nyancat\Documents\temp\x001.txt"
#         ^^    ^^                ^^   ^^^^
#         │     │                 │    └ ナル文字
#         │     │                 └ タブ文字
#         │     └ 改行文字
#         └ ユニコード文字(構文エラー)

私は書き捨てのスクリプトとかでよくコピペするんですが、これに気付くまでは毎回 \/ に置換してました……。

ただし raw string でも末尾を \ で終わらせることはできないので注意です。

path = r"C:\Users\"
# SyntaxError: unterminated string literal (detected at line 1)

これは、raw string は厳密には「エスケープシーケンスを無視する」ではなく「エスケープシーケンスをエスケープする」からです。例えば下記は構文エラーになりません。

text = r"abc\"def"

エスケープシーケンスが無視されるなら真ん中の \" は「\ に続く " の2文字」になり、トータルで " が3つある文字列(閉じていない文字列)になってしまいますが、そうはなりません。

これは「\ に続く " の2文字」ではなく「\" というエスケープシーケンス」がエスケープされて格納されるので正しい構文です。逆に \" で終わっている時は文字列が閉じていないので構文エラーになるという理屈ですね。

f-string のネスト

f-string のフォーマットはネストできます。

print(f"{1:0{3}d}")  # 001

ファイル群を連番出力したい時に便利ですね。

n_digits = len(str(len(images)))
for i, image in enumerate(images):
    cv2.imwrite(f"{i:0{n_digits}d}.jpg", image)

簡易的なテーブル出力とかにも使えます。

table = [
    ["The Dark Knight", "July 2008"],
    ["Inception", "July 2010"],
    ["Interstellar", "November 2014"],
    ["Dunkirk", "July 2017"],
    ["Tenet", "August 2020"],
]

t_max_len, r_max_len = (max(len(cell) for cell in column) for column in zip(*table))
for t_cell, r_cell in table:
    print(f"| {t_cell:<{t_max_len}} | {r_cell:>{r_max_len}} |")
| The Dark Knight |     July 2008 |
| Inception       |     July 2010 |
| Interstellar    | November 2014 |
| Dunkirk         |     July 2017 |
| Tenet           |   August 2020 |

format template

str.format でも f-string のように名前付きプレースホルダーを使えます。

formatted = "{key}: {value}".format(key="foo", value="bar")

この時、余分な引数を指定してもエラーになりません。

formatted = "{key}: {value}".format(key="foo", value="bar", x="y")

これを利用すると、「ユーザーが任意のフォーマット文字列を指定できる機能」を簡易的に実現できます。

params = {
    "title": "Last Night",
    "artist": "Morgan Wallen",
    "duration": "2:43",
    "released": "January 31, 2023",
    "year": "2023",
    "genre": "Country pop",
}

# どれを使うかはユーザーが決められる。一部しか使わなくてもOK。
formatted_title = user_template.format(**params)

dataclass やビルトイン関数の vars を上手く使うと辞書の作成もスキップできるかもしれません。

from dataclasses import asdict, dataclass


@dataclass
class Item:
    x: int
    y: int


item = Item(1, 2)
print("x is {x}, y is {y}.".format(**asdict(item)))
def func(x):
    y = x + 1
    print("x is {x}, y is {y}.".format(**vars()))  # 引数なしで呼び出すとローカル変数が取れる。

また、str.format_map の方を使えば遅延評価も可能です。

class LazyMapping:
    def __getitem__(self, key):
        value = map_key_to_value(key)
        return value


"{foo} {bar}".format_map(LazyMapping())

_

f-string のフォーマットに , を指定すると3桁区切りの , が追加されるのはご存じと思います。

n = 1234567890
print(f"{n:,}")  # 1,234,567,890

しかし、Python は , 区切りの数値を「入力」として扱えないので、出力された文字列はそのままでは Python コードに入力できません。

value = 1,234,567,890  # タプルになる

value = int("1,234,567,890")
# ValueError: invalid literal for int() with base 10: '1,234,567,890'

計算に時間のかかる定数を事前に計算して埋め込みたい時とか、一度ログ等で出力したものを読み込んで使いたい時とかにちょっと不便です。

こういう用途の場合、代わりに _ 区切りのフォーマットを利用することができます。

n = 1234567890
print(f"{n:_}")  # 1_234_567_890

_ 区切りのフォーマットはそのまま入力として扱えます。

value = 1_234_567_890  # OK
value = int("1_234_567_890")  # OK

ちなみに _ 区切りのフォーマットは 10 進数以外にも使えます。むしろこちらが主用途でしょうか。

n = 1234567890
print(f"{n:_b}")  # 100_1001_1001_0110_0000_0010_1101_0010

コンパイル時定数

計算に時間のかかる定数を事前に計算して埋め込む話をしましたが、これは必ずしも必要なことではないので注意です。

例えば次のコードを見てください。

ms = year * 365 * 24 * 60 * 60 * 1000

「年数をミリ秒換算に直す式」と一目で分かりますが、これだと見た目通り無駄な掛け算が4回発生してしまいます。これをループ内で繰り返し計算したり、year が巨大な DataFrame のカラムだったりすると無視できないコストになります。

こういう時、括弧を付けて定数部分を先に計算させると、コンパイル時に 1 回だけ計算され、バイトコードに定数が埋め込まれます。

ms = year * (365 * 24 * 60 * 60 * 1000)

どのようなバイトコードが生成されるかは dis モジュールを使って調べます。

import dis

dis.dis("ms = year * (365 * 24 * 60 * 60 * 1000)")
  1           2 LOAD_NAME                0 (year)
              4 LOAD_CONST               0 (31536000000)  <-- 計算されている
              6 BINARY_OP                5 (*)
             10 STORE_NAME               1 (ms)

この機能を知った時は「Python でコンパイル!?」と思いましたが、そもそもバイトコードにはコンパイルされるんだからこのくらいのことはやってくれても不思議ではなかったですね。

定数計算はイミュータブルなリテラルであれば様々なオブジェクトに対して行われます。

def func(x):
    return x == (
        1.1 * 2.0,  # float の掛け算
        (3 % 4) / 5,  # 割り算
        (6 << 1) & 7,  # ビット演算
        (  # タプルのネスト
            "a" + "b",  # 文字列の結合
            b"c" + b"d",  # バイト列の結合
            (1, 2) + (3,),  # タプルの結合
        ),
    )
  5           0 LOAD_FAST                0 (x)
              2 LOAD_CONST               1 ((2.2, 0.6, 4, ('ab', b'cd', (1, 2, 3))))
              4 COMPARE_OP               2 (==)
              6 RETURN_VALUE

反対に対象外なのは、比較演算や in 演算、ミュータブルなオブジェクトや、frozenset などの関数呼び出しが必要なオブジェクトなどです。また、あくまでもリテラル同士の演算だけが対象になります。

def func(x):
    return x == (1 == 2)  # 比較演算は対象外


def func(x):
    return x == [1, 2]  # ミュータブルなオブジェクトは対象外


def func(x):
    return x == frozenset((1, 2, 3))  # 関数呼び出しを伴うものは対象外


def func(x):
    i = 0
    return x == i + 1  # リテラル同士の演算ではないので対象外

glob パターン

ドキュメントではすごくあっさりと流されていますが、pathlib の Path.glob はめちゃくちゃ複雑なクエリも扱えます。

from pathlib import Path

for path in Path(".").glob("**/foo/**/ba[rz]/*/*/*_qux_???.png"):
    ...
Pattern 意味
* すべてにマッチします
? 任意の一文字にマッチします
[seq] seq にある任意の文字にマッチします
[!seq] seq にない任意の文字にマッチします

参考: https://docs.python.org/ja/3/library/fnmatch.html#module-fnmatch

Python 3.11 からは末尾に / を付けるとディレクトリだけ探索できるようになりました。これは os.DirEntry を利用した実装になっており、自分でディレクトリ判定をするより高速(らしい)です。

for directory in Path(".").glob("*/"):
    ...

ファイルだけ探索する方法はまだないようです。拡張子を付けて検索するか、Path.is_file でフィルタする必要があります。

files = (path for path in Path(".").glob("*.json") if path.is_file())

Path.is_file はシステムコールが発生してしまうので、対象が多数ある時は os.walk(Python 3.12 以降なら Path.walk)を使った方が良いかもしれません。

# 注意:glob の挙動を完全に再現するものではありません。
def rglob_files(root_dir: Path, pattern: str):
    pattern = Path(pattern)
    return (
        path
        for dirpath, _, filenames in Path(root_dir).walk()
        for name in filenames
        if (path := dirpath / name).match(pattern)
    )

dataclass な Enum

Python の Enum は各列挙子の値を明示的に設定する必要があります。auto を使うと自動でいい感じにやってくれるので、普段はこれを使っています。

from enum import Enum, auto


class Color(Enum):
    RED = auto()
    GREEN = auto()
    BLUE = auto()

逆にこればっかり使っていると忘れてしまうのですが、Enum には任意の値を指定できます。特に公式ドキュメントにある フィールドを生やす例 はシンプルながらとても便利です。

from __future__ import annotations  # Python 3.9 以降は不要
from enum import Enum, unique


@unique
class Color(Enum):
    RED = ((255, 0, 0), "")
    GREEN = ((0, 255, 0), "")
    BLUE = ((0, 0, 255), "")

    def __init__(self, rgb: tuple[int, int, int], title: str):
        # name, value など一部のフィールド名は使えないので注意。
        self.rgb = rgb
        self.title = title


color = Color.RED
print(color.rgb, color.title)

これは列挙子の値としては見た目通りタプルですが、それをパースした rgb フィールドと title フィールドが列挙子に追加されます。

これを利用すれば、Enum から値へのマッピングは基本的に不要になります。

# rgb フィールドで取れるのでこれは不要。
RGB_MAP = {
    Color.RED: (255, 0, 0),
    Color.GREEN: (0, 255, 0),
    Color.BLUE: (0, 0, 255),
}

なお、pickle で列挙子をシリアライズすると列挙子の名前ではなく値を出力してしまうため、値を変えると後方互換性が失われることに注意です。これは auto を使ってても起こり得る問題ですが、頻繁に値が変わるマッピングに使うのは避けた方が良いかもしれません。

dataclass な tuple

typing モジュールにある NamedTuple というクラスをご存じですか?NamedTuple はタプルでありつつ dataclass っぽくもあるクラスを作れる便利クラスです。

from typing import NamedTuple


class ItemTuple(NamedTuple):
    """ItemTuple is a tuple of name and value."""

    name: str
    """name is a string."""

    value: float
    """value is a float."""


item = ItemTuple("pi", 3.14)
print(item[0])  # タプルのようにもアクセスできる。
print(item.value)  # dataclass のようにもアクセスできる。

以前は「普通に dataclass 使えばよくない……?」と思ってたんですが、API の返り値に使うとユーザーの自由度が広がります。

from typing import NamedTuple


class FuncResult(NamedTuple):
    x: int
    y: int


def func() -> FuncResult:
    """ NamedTuple を返す関数。"""
    return FuncResult(1, 2)


# 普通のタプルのようにアンパックできる。
x, y = func()

# namedtuple として受け取ればフィールドアクセスもできる。
result = func()
print(result.x, result.y)

numpy も np.unique_counts などの一部の API でこの形式を使うようになりました。

他にも、タプルしか受け付けないサードパーティーライブラリの引数に情報を付加する用途にも使えますね。下記は紛らわしい OpenCV の引数を直感的に扱えるようにした例です。

from typing import NamedTuple

import cv2
import numpy as np


class CVPoint(NamedTuple):
    x: int
    y: int


class CVSize(NamedTuple):
    height: int
    width: int
    channels: int = 3


class CVColor(NamedTuple):
    b: int
    g: int
    r: int


size = CVSize(width=400, height=300)
center = CVPoint(x=50, y=150)
radius = 50
color = CVColor(r=0, g=0, b=255)

image = np.zeros(size, dtype=np.uint8)
cv2.circle(image, center, radius, color, -1)

ただ、個人的にはこれをやるなら dataclass に明示的にタプルを作って返すプロパティを付けた方がより安全で良い気がします。

byte is int

ASCII 文字列を「ASCII コードの intlist」に変換してください。

original_str = "123ABCxyz"
# expected   = [49, 50, 51, 65, 66, 67, 120, 121, 122]

愚直に実装するとこうですね。

int_values = [ord(c) for c in original_str]
print(int_values)  # [49, 50, 51, 65, 66, 67, 120, 121, 122]

ところで、上記のように str オブジェクトをイテレートすると各要素は str ですが、bytes オブジェクトをイテレートすると各要素が何になるか分かりますか?

正解は int です。

print([b for b in b"123"])  # [49, 50, 51]

ということで ASCII 文字列を intlist にするには、これで十分です。

int_values = list(original_str.encode("ASCII"))
print(int_values)  # [49, 50, 51, 65, 66, 67, 120, 121, 122]

文字列に戻すのも同様です。

s = bytes(int_values).decode("ASCII")
print(s)  # '123ABCxyz'

bytes はイテレートもインデックスアクセスもスライスもできるので、要素にアクセスできれば良いなら list に変換する必要もないでしょう。

int_values = original_str.encode("ASCII")
print(int_values[0])  # 49

日本語を含むマルチバイト文字を扱う場合は少し複雑です。恐らく一番高速なのは、固定長の UTF-32 にエンコードして、array でパースする方法です。

from array import array

original_str = "あいabcうえお☺"

int_values = array("I", original_str.encode("utf-32le"))  # エンディアン注意

print(int_values)  # array('I', [12354, 12356, 97, 98, 99, 12358, 12360, 12362, 9786])
print(int_values.tolist() == [ord(c) for c in original_str])  # True

ただし、numpy 配列にしたい場合は直接 numpy の機能を使って変換した方が速いでしょう。

import numpy as np

# ASCII 文字列
int_values = np.array([original_str], dtype="S").view(np.uint8)

# Unicode 文字列
int_values = np.array([original_str], dtype="U").view(np.uint32)

int is bytes

あまり使うことはないんですが、Python ならではの面白い小技を一つ。

次の 1KB のバイト列を 1 ビット左シフトしてください。

b = b"\x01" * 1024
print(b[:3])  # b'\x01\x01\x01'

下記のようにしたいところですが、残念ながらこれはできません。

b << 1  # TypeError: unsupported operand type(s) for <<: 'bytes' and 'int'

Python の bytes オブジェクトは文字列と似たカテゴリのオブジェクトで、算術・論理演算はサポートしていません。

愚直にこれを実装すると結構面倒くさいのですが 、「Python の int は上限値がない」という性質を利用すると、任意長のバイト列を 1つの int 型変数で表現できます。

すると、ビットシフトはこうなります。

# バイト列から int 値を生成する。
int_value = int.from_bytes(b, "big")

# 普通の int 変数なので任意の算術・論理演算を適用できる。
int_value = int_value << 1

# バイト列に戻す。 ※注意:バイト長が変わるケースを考慮していない。
new_bytes = int_value.to_bytes(len(b), "big")
print(new_bytes[:3])  # b'\x02\x02\x02'

ちなみに上記例だと int_value 変数には 10 進数で 2464 桁の整数が格納されています。

ただし、パフォーマンス的には numpy を使って頑張って計算した方が速いので、実際に使うことはまずないです……。

例外処理の使い分け

最後は例外処理についてです。意外と多機能な Python の例外処理、基本ですがおさらいしていきましょう。

基本形

基本はこれですね。

try:
    d["x"] += 1
except KeyError as e:
    # 例外処理

try 節に例外を監視したいコードを書き、expect 例外クラス as 変数名 で該当の例外をキャッチできます。

複数種類の例外をキャッチしたい時

except を並べて書くか、

try:
    d["x"] += 1
except KeyError as e:
    # 例外処理
except TypeError as e:
    # 例外処理

タプル形式でも書けます。

try:
    d["x"] += 1
except (KeyError, TypeError) as e:
    # 例外処理

(推奨されませんが)あらゆる例外をキャッチしたい時は Exception をキャッチするようにします。

try:
    d["x"] += 1
except (KeyError, TypeError) as e:
    # 例外処理
except Exception as e:
    # 例外処理

例外を消したい時

expect 節で例外をキャッチした後、例外を投げ直さなければ無視(処理を継続)します。

try:
    d["x"] += 1
except KeyError:
    pass

上記のように何もしなくて良い時は contextlibsuppress を使うとシンプルに書けます。

from contextlib import suppress

with suppress(KeyError):
    d["x"] += 1

例外を素通りさせたい時

expect 節で例外をキャッチした後、引数なしで raise を実行するとキャッチした例外をそのまま投げ直します。

try:
    d["x"] += 1
except KeyError:
    raise
Traceback (most recent call last):
  File "...", line 4, in <module>
    d["x"] += 1
    ~^^^^^
KeyError: 'x'

例外に情報を付け足す必要はないけれど、中断処理などを挟みたい時に便利ですね。

try:
    d["x"] += 1
except KeyError:
    abort()
    raise

出力する情報を付け足したい場合は print で吐き出すこともできますが、スタックトレースの前に表示されるので見落とされたりする可能性があります。

この時、Python 3.11 で追加された add_note というメソッドが便利です。

try:
    d["x"] += 1
except KeyError as e:
    e.add_note(f"Existing Keys: {list(d)}")
    raise
Traceback (most recent call last):
  File "...", line 4, in <module>
    d["x"] += 1
    ~^^^^^
KeyError: 'x'
Existing Keys: ['y', 'z']

例外を上乗せしたい時

上記例では KeyError を素通りさせましたが、KeyError のような汎用的な例外だけだと状況が伝わらない可能性があります。この場合、raise...from を使って適切な例外を上乗せします。

class UnexpectedState(Exception):
    def __str__(self):
        return "Did you forget to initialize?"


try:
    d["x"] += 1
except KeyError as e:
    raise UnexpectedState from e
Traceback (most recent call last):
  File "...", line 10, in <module>
    d["x"] += 1
    ~^^^^^
KeyError: 'x'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "...", line 12, in <module>
    raise UnexpectedState from e
UnexpectedState: Did you forget to initialize?

from e を付けなくても except 節の中で raise すれば同様に例外が2つ出ますが、2つの例外の間に出力されるメッセージの内容が変わります。

from e を付けた時:

The above exception was the direct cause of the following exception:
(上記の例外が次の例外の直接の原因です。)

from e を付けなかった時:

During handling of the above exception, another exception occurred:
(上記の例外処理中に、別の例外が発生しました。)

微妙な違いですが、後者は「例外処理中に意図せずして別の例外が発生してしまった時」と区別できません。そのため、「意図的に発生させた別の例外である」ことを明示するために from e を付けて出すべきです。

例外を差し替えたい時

もう一つのオプションとして、from None を付けて古い例外を非表示にすることができます。

try:
    d["x"] += 1
except KeyError as e:
    raise UnexpectedState from None
Traceback (most recent call last):
  File "...", line 12, in <module>
    raise UnexpectedState from None
UnexpectedState: Did you forget to initialize?

KeyError が表示されなくなったことに注目してください。

一般的に、プログラムがエラーで落ちた時はスタックトレースを辿って一番最初に発生したエラーを調査すると思います。この時、最初に発生した例外がエラーの直接の原因でないと混乱を招くので、あえて前の例外を揉み消すという選択肢が出てきます。特に Python は StopIteration などのいわゆる制御フロー系の例外もあるため、これは重要なテクニックです。

なお、どの投げ方でも例外の __context__ フィールドに一つ前の例外が入っていますので、必要ならそこから辿れます。あくまでも表示する内容を制御する機能ですね。

try:
    try:
        d["x"] += 1
    except KeyError as e:
        raise UnexpectedState from None
except Exception as e2:
    print(repr(e2))  # UnexpectedState()
    print(repr(e2.__context__))  # KeyError('x')

おわりに

これで今回の紹介は終わりです。これらのテクニックを活用することで、あなたのコードが効率化され、生産性が向上すること間違いなしです。ぜひ、今日からでも試してみてください。

ただし、便利なテクニックが必ずしも最善の選択とは限らないことを忘れないでください。コードの読みやすさや保守性、そしてチーム全体の理解を考慮した選択が、最終的にはプロジェクトの成功に繋がります。皆さんがPythonを駆使して、素晴らしいプロジェクトを生み出していくことを願っています。

ちなみにこの最後のまとめは ChatGPT 先生に考えてもらいました。先生も仰る通り、筆者もチーム開発の場では可読性を優先して使わなかったり、冗長なやり方を選択することも多々あります。他にも「そのやり方はあかん!」というのがあればコメント欄でご指摘お待ちしてます。ご清覧ありがとうございました。

67
101
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
67
101