556
Help us understand the problem. What are the problem?

posted at

updated at

きれいなPythonプログラミング(パイソニックなコードを書こう)を読んでみた

はじめに

日頃、Pythonを使う機会があるのですが、「もう一歩詳しくなり、中級者を目指したい!」という思いから、2022/2/15に発売された書籍「きれいなPythonプログラミング ~クリーンなコードを書くための最適な方法」を読みました。

特に、第6章にある「パイソニックなコードを書こう」が非常に勉強になったので自分なりの解釈/調査結果を含めてメモを残しておきます。
※解釈が誤っている箇所もあるかと思います。誤りがあればご指摘いただけると幸いです。

誤用の多い構文

Python以外の言語を使ったことがある人は、その言語と同じ考え方/手法でコードを書くかもしれません。
Pythonにおける標準的なアプローチを学ぶことで時間と労力を削減することができます。

ループ処理ではrange()ではなくenumerateを使う

慣習的にrange(len())とインデックス番号でループを回すのは単純ですが、これは読みにくく理想的ではありません。

パイソニックでない例
fruits = ["orange", "banana", "apple"]
for i in range(len(fruits)):
  print(i, fruits[i])

#...出力結果...
0 orange
1 banana
2 apple

代わりにenumerate()を使うとスッキリします。

パイソニックな例
fruits = ["orange", "banana", "apple"]
for i, fruit in enumerate(fruits):
  print(i, fruit)

#...出力結果...
0 orange
1 banana
2 apple

なお、インデックス番号が不要でリストの要素だけ必要な場合は直接リストを走査することができます。

パイソニックな例
fruits = ["orange", "banana", "apple"]
for fruit in fruits:
  print(fruit)

#...出力結果...
orange
banana
apple

open()close()ではなくwith文を使う

pythonでファイル操作を行う際は以下の順序で処理を記述します。

  1. open()関数によるファイルオープン
  2. ファイルに対するなんらかの処理
  3. close()関数によるファイルクローズ

open()関数とclose()関数は必ずセットで利用する。

しかし、close()メソッドを記述し忘れてしまったり、以下のようにtryブロック内でエラーが発生してclose()処理がスキップされてしまうとファイルが閉じられなくなってしまい、他のプログラムがファイルにアクセスできない状態となってしまいます。

パイソニックでない例
try:
  f = open("sample.txt", "w")
  print(20 / 0)  # <- ZeroDivisionError発生により、処理がexceptブロックに移る
  f.close()      # <- 実行されない
except:
  print("エラー発生")

with文を使えば、withブロックを抜けるときにcloseメソッドを自動的に呼び出してくれます。

パイソニックな例
with open("sample.txt", "w") as f:
  f.write("hogehoge")

Noneとの比較は==ではなくisを使う

pythonで2つの値を比較する際は、等号演算子==と恒等演算子isを利用することができます。

等号演算子==とは?

2つのオブジェクトの値(変数の値)を比較します。

# fruit_1とfruit_2に代入されている値は同じ
fruit_1 = "banana"
fruit_2 = "banana"

print(fruit_1 == fruit_2) # <- True

恒等演算子isとは?

2つのオブジェクトの同一性を比較します。同一性とは、変数が持っている参照先(メモリのポインタ)が同じかどうかを意味します。

fruit_1 = "banana"
fruit_2 = fruit_1

# fruit_1とfruit_2は同じ参照先を持っているので同一オブジェクト
print(id(fruit_1)) # <- ID:22550541970544
print(id(fruit_2)) # <- ID:22550541970544

print(fruit_1 is fruit_2) # <- True

2つが演算子の違いがわかったところで、NoneのID(参照先)ついて確認してみます。

fruit_1 = None
fruit_2 = None
 
print(id(fruit_1)) # <- ID:9484816
print(id(fruit_2)) # <- ID:9484816

print(fruit_1 is fruit_2) # <- True

NoneはNoneTypeデータ型の唯一の値であるため、Pythonプログラム内ではNoneオブジェクトが1つだけ存在します。
そのため、変数の値がNoneに設定されている場合、上記の例のようにis Noneの比較は常にTrueと評価されます。

一方、等号演算子による== Noneでの比較の場合、以下のように比較ダンダーメソッドのオーバーロードを使うことで==の挙動を変更することができます。

class BananaClass:
    pass

class AppleClass:
    # 比較ダンダーメソッドのオーバーロード
    def __eq__(self, other):
        if other is None:
            return True

banana = BananaClass()
print(banana == None) # <- False

apple = AppleClass()
print(apple == None)  # <- True

上記のように==演算子をオーバーロードしている場合、正しくNoneか否かの判定ができない可能性があります。
よって結論としては、== Noneではなく、念のためis Noneで比較するのが慣習となっています。

また、値がBoolean値の場合はis演算子を使ってはいけません。
Booleanの場合は==を使って判定する、もしくは、if apple:if not appleのように記述するのが一般的です。

文字列のフォーマット

バックスラッシュ(\)が多い文字列の場合はraw文字列を使う

raw文字列とは、文字列の先頭にrを付けたものであり、バックスラッシュをエスケープ文字として扱いません。
例えば、以下のようなWindowsのファイルパスを記述する場合は\\と書いてバックスラッシュ文字をエスケープする必要があります。

パイソニックでない例
# バックスラッシュ文字を文字列に含めたい場合は、\\と記述してエスケープする必要がある
print("C:\\Users\\admin\\Desktop\\sample.txt")

#...出力結果...
C:\Users\admin\Desktop\sample.txt

raw文字列を使えばエスケープ不要なのでコードが読みやすくなります。

パイソニックな例
# 文字列の前にr接頭辞を付与
print(r"C:\Users\admin\Desktop\sample.txt")

#...出力結果...
C:\Users\admin\Desktop\sample.txt

f-stringによる文字列のフォーマット

変数の値を含めた文字列を生成する手法として、元々は以下2つがありました。

# ① %sを使う
test1 = "apple"
test2 = "banana"
print("私は %s と %s が好きです。" % (test1, test2))

# ② format()メソッドを使う
test1 = "apple"
test2 = "banana"
print("私は {0} と {1} が好きです。".format(test1, test2))

python3.6以降では、f-string(フォーマットストリング) を使うことで文字列フォーマットが直感的で読みやすくなります。
前述のrow文字列が先頭にrを付けるように、f-stringでは文字列の先頭にfを付けます。

test1 = "apple"
test2 = "banana"

# 文字列の前にf接頭辞を付与
print(f"私は {test1}{test2} が好きです。")

Listのコピーについて

以下のようにスライス構文を使うことで、既存のListから新しいListを作成することができます。

fruits = ["apple", "orange", "banana", "grape"]

# 既存のListから新しいListを作成
fruits_copy = fruits[2:] # <- 終了インデックスを省略すると、終了インデックスはデフォルトでListの最後になる。
print(fruits_copy)

#...出力結果...
# ['banana', 'grape']

スライス構文にて、開始インデックス/終了インデックスの両方を省略すると、実質的にリストのコピーを作成することと同じになります。

fruits = ["apple", "orange", "banana", "grape"]

# 既存のListから新しいListを作成
fruits_copy = fruits[:] # <- 開始インデックス/終了インデックスを省略
print(fruits_copy)

#...出力結果...
# ['apple', 'orange', 'banana', 'grape']

# (※1)2つのListは異なるオブジェクトであることがわかる
print(id(fruits) == id(fruits_copy)) # <- False

(※1)fruits_copy = fruits[:]の行はfruitsの内容をコピーしますが、fruits_copy = fruitsListの参照 をコピーする点に注意です。

ただ、[:]によるListのコピーはあまり見た目がよくない(わかりづらい)ため、以下のようにcopyモジュールのcopy()関数を使うのがパイソニックなコードです。

パイソニックな例
import copy
fruits = ["apple", "orange", "banana", "grape"]

# 既存のListから新しいListを作成
fruits_copy = copy.copy(fruits) # <- copy()関数を使うとコピー処理を実行していることが一目瞭然
print(fruits_copy)

#...出力結果...
# ['apple', 'orange', 'banana', 'grape']

# 2つのListは異なるオブジェクトであることがわかる
print(id(fruits) == id(fruits_copy)) # <- False

浅いコピーと深いコピー

上記で紹介されている[:]およびcopy.copy()を使ったListのコピーはいずれも浅いコピーになります。
浅いコピーの場合、以下のように多次元配列のように入れ子になったオブジェクトはうまくコピーできません。

浅いコピー
import copy

fruits = [["banana", "apple"], ["orange", "grape"]]

# 浅いコピーを実行
fruits_copy = copy.copy(fruits)

# 2次元リストの"banana"を"Lime"に変更する
fruits_copy[0][0] = "Lime"

# コピー元の「fruits」も"Lime"になっている
print(fruits)      # [['Lime', 'apple'], ['orange', 'grape']]
print(fruits_copy) # [['Lime', 'apple'], ['orange', 'grape']]

# 内包されているオブジェクトは同じ
print(id(fruits[0]))        # ID:22461722900352
print(id(fruits_copy[0]))   # ID:22461722900352

上記のように、多次元配列に対して浅いコピーを行うと中身が参照渡しとなってしまいます。(浅いコピーは1次元のリストに対してのみ有効)

多次元配列を独立した異なるオブジェクトとしてコピーする場合は、深いコピーを行う必要があります。
深いコピーはcopy.deepcopy() を使います。

深いコピー
import copy

fruits = [["banana", "apple"], ["orange", "grape"]]

# 浅いコピーを実行
fruits_copy = copy.deepcopy(fruits)

# 2次元リストの"banana"を"Lime"に変更する
fruits_copy[0][0] = "Lime"

# コピー元は変わらない
print(fruits)      # [['banana', 'apple'], ['orange', 'grape']]
print(fruits_copy) # [['Lime', 'apple'], ['orange', 'grape']]

# 内包されているオブジェクトは異なる(参照渡しではない)
print(id(fruits[0]))        # ID:22601618573056
print(id(fruits_copy[0]))   # ID:22601618667328

@shiracamusさん、ご指摘ありがとうございます。

パイソニックな辞書の使い方

get()を使う

Pythonでは、存在しない辞書のキーにアクセスした場合、KeyErrorが発生します。
try-catch等でエラーハンドリングを実装していないとそこで処理が強制終了されてしまいます。

fruits = {"banana" : 1}
print(f"私はorangeを{fruits['orange']}つ持っています") # <- KeyError: 'orange'

これを回避するため、以下のようにif文を使って特定の値が辞書に含まれているか確認することがありますが、これはパイソニックではありません。

パイソニックでない例
fruits = {"banana" : 1}
if "orange" in fruits: # <- キーに"orange"が含まれているかを確認する
    print(fruits["orange"]) 
else:
    print("not orange")

上記のように、キーが存在しないパターンが頻繁に発生することから、Pythonの辞書にはget()メソッドが用意されています。
get()メソッドでは、キーが存在しない場合に返すデフォルト値を指定することができます。

パイソニックな例
fruits = {"banana" : 1}
print(f"私はorangeを{fruits.get('orange', 2)}つ持っています")

get()メソッドでは、まずget()の第1引数の値(orange)が辞書(fruits)内のキーに存在するかどうかをチェックします。
存在する場合、辞書内の第1引数の値(orange)に対応する値を返します。
存在しない場合、第2引数に指定された値(2)を返します。
これにより、if-else文で事前チェックするよりも短く読みやすくなります。

setdefault()を使う

get()メソッドと同様にsetdefault()メソッドでもキーが存在しなかった場合にデフォルト値を返すことができます。

fruits = {}
print(fruits.setdefault("banana", 1)) # <- "banana"は辞書内に存在しないので第2引数の"1"を返す

ここでget()メソッドとの違いですが、キーが存在しなかった場合、setdefault()内で指定したkey-valueが新しい要素として辞書にセットされます。

fruits = {}

# get()メソッドの場合
print(fruits.get("banana", 1)) # <- 1
print(fruits) # <- {}

# setdefault()メソッドの場合
print(fruits.setdefault("banana", 1)) # <- 1
print(fruits) # <- {'banana': 1}

辞書にキーが存在するか否かをチェックし、キーがない場合はデフォルト値を設定するような処理がある場合はsetdefault()で代用してみてください。

collections.defaultdictを使う

collections.defaultdictクラスを使用すると、KeyErrorを完全になくすことができます。
defaultdictを使うためには、標準collectionsライブラリから別途importする必要があります。

from collections import defaultdict

defaultdictにデフォルト値で使用するデータ型を渡すことで、デフォルトの辞書を作成します。
例えば、以下のようにdefaultdictにintを渡すことで、キーが存在しない場合のデフォルト値として0を返す辞書を作ることができます。

from collections import defaultdict

num_dict = defaultdict(int) # <- intを渡す
print(num_dict) # <- defaultdict(<class 'int'>, {})

num_dict["banana"] += 1 # <- キー"banana"に対する値をセットする必要がない
print(num_dict["banana"]) # <= 1

num_dict["orange"] # <- キー"orange"に対する値をセットする必要がない
# "orange"のvalueにデフォルト値"0"がセットされている
print(num_dict["orange"]) # <- 0

なお、defaultdictにはint以外にもlistやdict、boolを渡すことができます。

考えられるすべてのキーに対してデフォルト値が必要な場合は、通常の辞書を使用して逐一setdefault()メソッドを呼び出すよりも、collections.defaultdictを使用する方がはるかに簡単です。

switch文の代わりに辞書を使う

Java等の言語にはswitch文がありますが、Pythonにはswitch文がありません。
⇒Python3.10からmatch文が使えるようになったようです。

Pythonでswitch文と同様の処理を行う場合はif-elif-else文を使って以下のように記述することがあります。

if fruits == "apple":
    color = "red"
elif fruits == "banana":
    color = "yellow"
elif fruits == "lime":
    color = "green"
else:
    color = "no color"

上記コードはパイソニックでないとまでは言えませんが、少し冗長です。
そこで、if-elif-else文の代わりに辞書を使って以下のように記述するのがパイソニックです。

パイソニックな例
color= {
    "apple"  : "red",
    "banana" : "yellow",
    "lime"   : "green"
}.get(fruits, "no color")

上記コードは代入文が1つあるだけで非常に簡潔です。
get()の第1引数に指定したキーの値を返しますが、キーが存在しない場合は第2引数にセットしたデフォルト値を返します。

しかしながら簡潔になる一方、コードが直感的ではなく読みづらくなることがあります。この方法を使うかどうかは検討の余地があります。

Pythonの三項演算子

条件に基づいて2つの値のどちらかを取得するような場合、通常はif-elseを使います。これはパイソニックです。

パイソニックな例
is_apple = True

message = ""
if is_apple:
    message = "これはappleです"
else:
    message = "これはappleではありません"

print(message) # <- これはappleです

三項演算子を使うことで上記パターンに当てはまるコードを1行で簡潔に表現できるのですが、Pythonの三項演算子は他のほとんどのプログラミング言語とは書き方が異なります。

Javaの三項演算子の書き方
変数 = 条件式 ? 条件が成立したときの値 : 条件が成立しなかったときの値
Pythonの三項演算子の書き方
変数 = 条件が成立したときの値 if 条件式 else 条件が成立しなかったときの値

上記のように、Pythonではifelseの間に条件式を挟む独特な配置で実装されています。具体的な記述例は以下です。

is_apple = True

true_value = "これはappleです"
false_value = "これはappleではありません"

message = true_value if is_apple else false_value

print(message) # <- これはappleです

なお、以下のように記述することで他のプログラミング言語のような擬似三項演算子を作ることができるのですが、これはパイソニックではありません。

パイソニックでない例
is_apple = True

true_value = "これはappleです"
false_value = "これはappleではありません"

message = is_apple and true_value or false_value

print(message) # <- これはappleです

パイソニックでない理由として、上記の擬似三項演算子には微妙なバグがあります。
それは、以下のようにtrue_valueが偽の場合(0, False, None, 空白文字など)、条件式(is_apple)がTrueであってもfalse_valueと評価されます。

擬似三項演算子の微妙なバグ
is_apple = True

# 例として空白文字をセット
true_value = ""
false_value = "これはappleではありません"

message = is_apple and true_value or false_value

print(message) # <- これはappleではありません

よって、擬似三項演算子は使わないようにしましょう。

上記を踏まえた結論として、三項演算子はパイソニックというほどでもないですが、パイソニックでないというわけでもありません。
使用する場合は、条件式の中に条件式を入れるといったネスト構造にしないようにしましょう。

パイソニックでない例
age = 30

age_range = "child" if age < 13 else "teenager" if age >= 13 and age < 18 else "adult"

print(age_range) # <- adult

上記のように、三項演算子のネストはコードの可読性が悪くなり、読んでいてイライラします。

変数の扱い

変数の値を確認したり変更したりすることはよくありますが、Pythonではこれを行う方法がいくつかあります。

比較演算子の連結

特定の数値が範囲内に収まっているかどうかを確認する場合は、以下のように比較演算子とand演算子を使います。

パイソニックでない例
num = 50
if 30 < num and num < 100:

しかし、Pythonでは比較演算子を連鎖させることができるため、and演算子を使う必要がありません。

パイソニックな例
num = 50
if 30 < num < 100:

代入演算子の連結

代入演算子=も連鎖させることが可能です。
1行のコードの中で複数の変数に同じ値を代入することができます。

パイソニックな例
banana = apple = orange = "fruits"
print(banana, apple, orange)

#...出力結果...
fruits fruits fruits

上記3つの変数がすべて同じかどうかを確認するにはand演算子を使ってもよいですが、==を連鎖する方法もあります。

パイソニックな例
banana = apple = orange = "fruits"
print(banana == apple == orange == "fruits")

#...出力結果...
True

演算子の連鎖はちょっとしたことですが便利なコード短縮方法です。

注意

banana = apple = orange = ["banana", "apple"]

上記のように連鎖を使ってリスト等のミュータブルな値を代入した場合、参照渡しとなってしまうため注意が必要です。
詳しくは本記事のコメント欄、@shiracamusさんの解説をご参照ください。

変数の値が複数の値のどれと等しいかを調べる

変数の値が複数候補のどれと等しいか調べることがあります。この場合、or演算子使って以下のように書くこともできますが、パイソニックではありません。

パイソニックでない例
if fruits == "banana" or fruits == "apple" or fruits == "orange": # <- # "fruits ==" を複数記述する必要があり冗長 

そこで、以下のように比較対象の値をタプルにまとめ、そのタプル内に変数の値が存在するかどうかをin演算子でチェックするのがパイソニックです。

パイソニックな例
fruits = "banana"
print(fruits in ("apple", "orange", "banana"))

#...出力結果...
True

さいごに

本書での学習を通じて今まで曖昧だった使い方をしっかり理解することができました。
本書では他にも、

  • Pythonのよくある落とし穴
  • Pythonの要注意コード
  • 良い関数の書き方

など、Pythonの基礎的な使い方をある程度理解した人が中級者へステップアップするためのTipsを学ぶことができると思います。
一度手にとって読んでみることをお勧めします!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
556
Help us understand the problem. What are the problem?