同僚のデータサイエンティストのコードをレビューしていて、「破壊的メソッドを関数の引数に対して行うとヤバい」って話をしたのですが、語彙力不足のせいでちゃんと説明できなかったので記事にしました。
たしかに、R言語ではたしか破壊的な処理が無いため(ですよね?)、その感覚でnumpy
やpandas
を使っているだけでは身につかない気がします。良いドキュメントや記事が無いか探したのですが、Rubyのものしか見つからず、それはそれで説明が難しそうだったので書きました。
※(補足)Pythonでは「in-placeな処理」という表現のほうがよく使われていたので、Rubyの記事しか見つけられなかったのはそれが原因かもしれません。詳しくはこちらの記事で。
破壊的(destructive)メソッドとは?
「リストの末尾にリストを追加する処理」の破壊的/非破壊的な処理の例を出します。
# 破壊的な例
x = [1, 2, 3]
x.extend([4, 5, 6])
print(x)
# => [1, 2, 3, 4, 5, 6]
# 非破壊的な例
x = [1, 2, 3]
y = x + [4, 5, 6]
print(y)
# => [1, 2, 3, 4, 5, 6]
print(x)
# => [1, 2, 3]
「リストの並び替え」の操作として、Pythonではリストのメソッドとしてlist.sort()
やlist.reverse()
、非破壊的な処理としては関数としてsorted
やreversed
が用意されています。詳しくはこの公式ドキュメントとかを見てください。
また便宜上「破壊的メソッド」という言葉を使っていますが、Pythonの標準機能では数が少ないものの関数の場合もあります。例えばリストをランダムに並び替えるrandom.shuffle
などです。
破壊的メソッドを使うときに気をつけること
「関数の引数に対して破壊的操作を行うと、引数自身が変更されてしまってコードが追いづらくなる」というデメリットがあります。
以下のような「jsonを組み立てるためにデータ加工する関数」を考えましょう。
import json
from typing import List
def convert_to_json(values: List[int]) -> str:
"""json形式で結果を保存するための関数
"""
# データを処理する過程で`append`という破壊的処理を行ってしまう
new_value = 3
values.append(new_value)
result = {"values": values, "size": len(values)}
return json.dumps(result)
x = [1, 2, 3]
log = convert_to_json(x)
# 計算結果が得られた!
print(log)
# => {"values": [1, 2, 3, 3], "size": 4}
# convert_to_json内部でx自身も変更されてしまう!
print(x)
# => [1, 2, 3, 3]
変数x
が更新されてしまっていることがお分かり頂けたでしょうか?これを意図せず行ってしまうと、「え?プログラムのどこかでリストに値が追加されてるんだけど?」と気づいた後に泣きながら延々とprintデバッグすることになります。
意図せず行ってしまわないためには、例えば次のように非破壊的な処理を行えばokです。
def convert_to_json(values: List[int]) -> str:
# Note: リストに対して `values += [3]` とすると破壊的操作になるので注意!
# https://stackoverflow.com/questions/2347265/why-does-behave-unexpectedly-on-lists
# 初見殺しですが、実行効率重視で実装されているようです
values = values + [3]
result = {"values": values, "size": len(values)}
return json.dumps(result)
もしくは、以下のように、関数の冒頭でコピーするのでも大丈夫です。ところどころ不穏なコメントがありますが、キリがないので詰まったときに教えますw
def convert_to_json(values: List[int]) -> str:
# Note: `copy`メソッドではリストや辞書のネストした階層までコピーしないので注意!
# https://qiita.com/ninomiyt/items/2923aa3ac9bc06e6a9db
# 詳しくはdeep copy/shallow copyで調べてください
copied = valus.copy()
copied.append(3)
result = {"values": values, "size": len(values)}
return json.dumps(result)
また、x
をこの後で使わないのであればあまり気にしなくていい場合もあると思います。ただそうすると、他の箇所で関数を再利用する際に同じ問題が出てきてしまうので要注意です。また、グローバル変数(や関数の外側の変数)に対して破壊的処理する場合も同様です。
破壊的メソッドはいつ使うべきなのか?
「そんな危険性があるなら非破壊的な処理だけでよくないか?」と思うかもしれませんが、破壊的メソッドでは新しく値を作らない分、プログラムの効率は良いです。Pythonのlist.sort
とsorted
を比較した公式ドキュメントにもこう書かれています。
元々のリストが不要な場合には、わずかですがより効率的です。
例えば、「ものすごい数の値もつリストを操作する処理」は、毎回コピーを作るとメモリ使い切ってしまう場合もあります。データサイエンティストなら「pandas.DataFrame
で行数の多い処理を行うとき」にこういったメモリ効率の問題に遭遇しやすいと思います。
その場合は破壊的メソッドもうまく使い分けていきましょう。ただpandasは破壊的/非破壊的な操作がメソッド名から推測しづらいし、メソッド内部で巨大なデータをコピーしている場合も見かけたことがあるので慣れが必要かもしれません。
少し余談ですが、R言語では破壊的な処理が無い(内部的に毎回コピーを作っている)ため、こうしたプログラムの効率化の手段が取れず、場合に応じて使い分けられることが汎用プログラミング言語であるPythonを使うメリットの一つだと思います。
少し難解な話をすると、「副作用(side effect)を局所的にすること」を意識して、プログラムの効率とのトレードオフを考える必要があります。ちょうど「Unit Testing in R」という記事に良い図があったので引用します。
関数のinput(引数)とoutput(returnされる値)以外の効果を副作用(side effect)と呼びます。引数以外のデータ(グローバル変数、外部のデータベース)などを
- 関数の引数に対する破壊的操作
- データベースやファイルからデータを読み込む
- データベースやファイルに結果を出力する
副作用の無い関数(純粋関数)を使うと、「テストがしやすい」「再利用しやすい」などのメリットがあります。例えばpytestでコードを書く場合、簡単にテストが終わりますが、
def test_f():
x = [1, 2, 3]
y = f(x)
assert y == [1, 2, 3, 4, 5, 6]
もし関数f
の中でデータベースにSQLを投げていたり、端末からの入力を受け付けていたりするとそのままテストできない(モックオブジェクトを用意するコードを書く必要がある)し、引数x
を意図せず破壊してしまっている場合はチェック漏れしてしまうことも多いです。
つまり、破壊的操作の影響が関数内部だけに収められているならば、無理に非破壊的なコードに直す必要は無いと思います。例えば次のコードではappend
という破壊的操作を行っていますが、関数f
から外には影響が無い(副作用が無い)ので、テストや再利用には問題がありません。
def f(x):
matrix = []
for i in range(x):
row = []
for j in range(x):
if i == j:
row.append(1)
else:
row.append(0)
matrix.append(row)
return matrix
また、破壊的操作以外の副作用も同様に気をつける必要があります。長くなりそうなのでその都度教えますが、一つだけアドバイスすると
- 必要なデータを読み込む(副作用あり)
- ①のデータを受け取って複雑な数値計算をする(副作用なし)
- ②の結果を出力する(副作用あり)
って形で実装すると、本当にデータサイエンティストが価値を出すべき数値計算部分のテストや再利用が楽になるので良いと思います。
(完全に余談ですが、純粋関数と副作用のある関数を厳密に分けることが強制される言語もあり、Land of Lispの副作用使用罪の漫画はめっちゃ笑うので読んでみてください)
まとめ
まとめました。なにか間違ってたらコメントください。
- 関数の引数やグローバル変数に対して破壊的処理を行うと、後で延々とprintデバッグすることになる
- 破壊的メソッドのほうが効率が良い(場合が多い)ので、デカいデータで計算が遅いとき破壊的処理を検討しよう
- 破壊的メソッドを使う場合は、その影響が局所的(できれば関数内部だけ)に収まるようにしよう