読書月間です。Effective Python (第1版)を読んで、個人的に「へぇ~、明日から使おう」なところをまとめました。
文法書を一通り読んでPythonのお勉強を進めて来たので、少しわかった気になってきました。少し難しそうなのも読んでみます。とはいえもう第二版もあるんですね…。
59項目の中で心にしみた項目を列挙していきます。
Python流思考(Pythonic Thinking)
Pythonのプログラマは、明示すること、複雑さより単純さを選ぶこと、可読性を最大化すること。Python流を知っておくことが重要。だそうです。
本文中にも
「Pythonでは1行で複雑な式が書きやすいけど、複雑な式は書くな。」とか
「and/orつかうよりif/else使え。」とか
「複雑な式にするくらいならヘルパー関数を作れ」など
複雑化させることを抑制させ、可読性を高めたいようです。
またドキュメンテーションやキーワード引数を使うなど、明示する事も強く推奨されています。
1つのスライスでは、start, end, strideを使わない
冗長な記述にならないように、startに0を指定したり、endに列の長さを指定したりしてはならない。というのもありましたが、スライスするときにたまに使うstrideもstart/endと併用すべきではない。としています。
負のstrideも推奨していません。
確かに1行でカッコよくスライスしたくなりますが、そのコードを理解するのにかける時間以上のメリットがない限りやるべきではない。というのが基本的な考え方です。Pythonicです。
リスト内包表記には、3つ以上の式を避ける
この鉄則も複雑度を下げたいからですね。
リスト内包表記は強力で、複数のループと複数の条件をサポートしています。ただしこの組み合わせは2つまでにし、3つ以上は読むのが難しくなるため避けるべき。とのことです。
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] filtered = [[x for x in row if x % 3 == 0] for row in matrix if sum(row) >= 10] print(filtered) >>> [[6], [9]]
確かにややこしい…。値が3で割り切れて、各リストの合計が10以上の要素。
mapやfilterを使わずリスト内包表記を使うべし。というのもあり、基本的にはリスト内包表記は推奨されているのですが、複雑に書くなよ。ってことです。
大きな内包表記にはジェネレータ式を考える
リスト内包表記では、リストを作るのでその要素数が膨大だとメモリを大量に消費させます。なのでその場合にはジェネレータを使いましょう。
ジェネレータとはリストのようなイテレータのように振る舞う関数のようなもので、イテレーションの間動的に値を作っては返すを繰り返しています。なのでリストのようにがばっとメモリを消費することがないので、メモリを節約できる。というわけです。
ジェネレータに関しての詳細は割愛します。この書籍でも詳しくは述べられてはいません。このあたり知ってる前提で来るあたりがなかなか手ごわいです。
この後でも、関数ではリストを返さずにジェネレータを返す。という教訓も出てきます。見た目の読みやすさもメモリの観点でもジェネレータは積極的に使うことを推奨しているようです。ファイルのように終わりが見えないものを順繰りに処理するときなど、一発で全部読んだらそりゃメモリパンクですよね。
forとwhileループの後のelseブロックは使うのを避ける
Python固有のfor/elseの記述です。ループの後で(breakしなかったら)elseが走るという構文ですが、これは推奨しないようです。
理由はif/elseやtry/except/elseといったほかの文法と意味合いが逆に見えるから。
total = 0 for i in item: if i < 0: break total += i else: print(total) # ループが完了したら実行?完了しなかったら実行?
この例文を見ても確かにあまり直感的ではないかも。(答えはループが完了したら実行されtotalが表示される)
ifであれば条件に合致しなかったら。try/except/elseであれば、tryして失敗したらexceptでそれ以外はelse。なのでfor文で「ループが完了しなかったらこれをしなさい」と逆の意味だと思い込んでしまいます。なので非推奨。
個人的には覚えたての知識だったのですが、早速忘れて良さそうな構文になってしまいました。
try/except/else/finallyの各ブロックを活用する
例外処理では全部使えってことですね。tryで挑戦して結果がどうであろうとfinallyで後始末。tryで挑戦して、exceptしなかったらelseでとらえる。まぁこれも自然な文法に見えます。exceptしようが、else行こうがfinallyは確実にとらえてくれる。
open() try: read() except READError as e: return 0 # 読み込み失敗した場合 else: proc() # ここで例外が出てもfinalyがとらえる finally: close() # tryしたらここは必ず実行される
こんなイメージです。open失敗は外の関数に例外が行きますね。finallyでは捕まりません。
関数
関数はいいものだから使っていきましょう。ってことですね。
Noneを返すよりは例外を選ぶ
関数の戻り値の話ですね。Noneを返す関数だと戻ってきた値の評価でミスすることが多いので推奨しない。というのがその理由
a = b() if not a: # 失敗したらNone(false)だと思っている print("error") else print("success")
確かに0や””(空文字)でもfalseになってしまいます。正常に処理されたのに0が返ることはありますよね。色々回避方法はありそうですが、ここでは例外が推奨されています。
try: a = b() except ValueError: print("error") else: print("success")
こちらのスタイルが取れるような関数bを作りましょう。というのがここでの教えです。
引数に対してイテレータを使うときには確実さを尊ぶ
なんだか直訳した感じの変な言い回しですが、ジェネレータを使うときは気を付けて、怖ければ積極的にイテレータプロトコルを実装した新しいコンテナクラスを作ろうね。ってことかと。
リストを使うとメモリを食うし、ジェネレータを使うと、そのイテレータが一周回ったら空っぽになってしまい、2周目まわしても何も起こらないよ。エラーにすらならないよ。だからちゃんと__iter__の関数を実装したコンテナクラスを作ろうね。ってことだと思います。
def normalize(numbers): total = sum(numbers) # ここで1周使っている result = [] for value in numbers: # ここで2周目に入っている percent = 100 * value / total result.append(percent) return result
この関数にジェネレータを使った関数を渡しても期待した答えは出ません。
だからちゃんと
class a(object): def __init__(self): pass: def __iter__(self): for b in c: yield b
みたいな__iter__メソッドをジェネレータとしたイテラブルなクラスを作って、そのイテレータを返そうね。これなら複数回まわされても大丈夫だし、巨大なオブジェクトもへっちゃら。ってことになります。
ただしやはり中身が少ないときは2度もこの__iter__関数を呼ぶくらいならリストの方が有利な場合もあるので、適材適所かと。
可変長位置引数を使って、見た目をすっきりさせる
表現としてはなんだかなぁな感じですが、可変長引数を気を付けてうまく使おうね。って言ってます。引数が可変になりうる場合にはとても便利です。
def log(msg, *val): print(msg, val) log("start") a = [10, 20, 30] log("end", 1, 2, 3) log("result", *a) # *でリストをそのまま引数にできる。
こんな感じで使おうね。って感じです。
ただ気をつけようねの部分があります。引数はタプルに変換されているので、この例のようにイテラブルなもの(これだとリスト)を引数として与えると、大量のメモリ消費を起こす可能性があります。リストじゃなくてジェネレータでも渡せるので、無限ループも簡単に起こせます。
入力の個数はほどほどにしましょう。
もう一つ気をつけようねの部分として、関数の機能拡張で、位置引数を追加した場合、過去の関数呼び出し部はすべて手直ししないといけない。という点です。
def log(msg, id, *val): print(id, ':' , msg, val) log("start") # これはエラー a = [10, 20, 30] log("end", 1, 2, 3) # エラーにならない log("result", *a) # エラーにならない
例えばこのようにあとからidを足したいなぁ~って変更したところ、最初のコールこそエラーなので気づけますが、2,3回目のコールではエラーも発生せず、何事もなかったように進みます。バグの元です。
後から機能拡張するのであればキーワード引数を使いましょう。というのが教訓です。
def log(msg, *val, id): print(id, ':' , msg, val) log("start") # エラー a = [10, 20, 30] log("end", 1, 2, 3) # エラー log("result", *a) # エラー
これなら全部エラーになります。(可変長引数の後に指定した引数は強制的にキーワード専用引数になります。)
動的なデフォルト引数を指定するときにはNoneとドキュメンテーション文字列を使う
長い。デフォルト引数の挙動には気をつけよ。ってことだと思います。
デフォルト引数が評価されるのはモジュールがロードされた時だけです。なので、デフォルト引数に現在時刻を設定しても、評価されるのは関数ロード時であり、関数コールの時ではありません。
def a(now = datetime.now()): print(now) a() sleep(100) a()
これは同じ時刻を表示します。期待した動きと異なる気がします。また別の恐ろしい例だと
def b(data = []): data.append(10) return data c = b() print(c) # [10] d = b() print(c, d) # [10, 10] [10, 10] e = b([]) print(c, d, e) # [10, 10] [10, 10] [10] if c is d: print("same") # same
これもデフォルト引数は関数bが生成された時に作られるため、cに代入した瞬間こそ期待通りの動きですが、dに代入したときは同じオブジェクトを使いまわされるので、リストにさらに値が加わり、さらには同じ参照になってしまいます。
当然eのように引数に空のリストを渡せばそれは別の空のリストを意味しているのでまったく別の動きになります。関数を眺めた時に期待するのはeへの代入のように振る舞うことだと思えるので、この書き方は適切ではないです。
その場合はNoneを使って、ちゃんとドキュメンテーション文字列を入れておきましょう。というのがこの教えですね。
def b(data = None): """ デフォルトは空のリストだよ~ """ if data is None: data = [] data.append(10) return data
これだと期待通りの動きになります。元の関数に比べて冗長に見えてしまいますが、致し方ありません。だって期待通りの動かないんだもん。
キーワード専用引数で明確さを高める
キーワード引数の機能をちゃんと使おうね。ってことですね。コードを見たときに何を意味する引数に、何の値を入れているかちゃんと見てわかるようになります。
def menseki(*, teihen, takasa): return teihen * takasa / 2 a = menseki(10, 20) # TypeError: menseki() takes 0 positional arguments but 2 were given a = menseki(teihen=10, takasa=20)
三角形の面積を出す関数の引数の順番が、底辺、高さどっちがどっちかわかりにくいですが、キーワード引数を使うことではっきりします。まぁこの例では逆にしても答え同じですけどね。
この引数リストの中で*を入れると、それより後ろはキーワード専用引数だよ。って意味にになります。キーワードを入れないとエラーになります。
複数のフラグでTrue/Falseを設定するような関数でも同じように、何番目のフラグが何か、コードを見ただけだとよくわかりませんが、キーワード専用引数を入れることで確かに読みやすくなります。
別の教訓で、キーワード引数はオプションの振る舞いを与えよう。ってのもありました。キーワード引数にデフォルト値を持っておき、そのオプションを切り替えるときにはキーワード引数で明示的に指定する。というような使い方を推奨しています。
明示するにはいい方法なのだと思います。
コメント