技術

Pythonの参照渡しを理解しよう!【トラブル事例付き】

プログラミング初心者、いや中級者でもはまりやすいポイントに「参照渡し」があります。

参照渡しは理屈だけだとそれほど難しくないように感じるかもしれません。しかし、いざ実践するとなると話は別です。言語ごとによって作法が違ったりするため、使うつもりがないのに参照渡しになっていて、予期せぬ動作を引き起こすということがあります。

Pythonは参照渡しにはまりやすい言語です。C系のように参照渡しを意識した記法はありませんし、Javaのように参照渡しを意識しなくて良い言語でもありません。

この記事は、Pythonの参照渡しについて、参照渡しを知らないとやりがちなトラブルとともに解説しています。

  • 参照渡しの理論は知っているけどコード上でどう扱うかわからない人
  • 参照渡しを意識しないでPythonのコードを書いている人

と、いう方の参考になる記事になっています。

理屈はいいから、とにかく応急処置を知りたい人

参照渡しの理屈は聞き飽きた、ゴチャゴチャとした説明はいらないからPythonで参照渡しのトラブル回避の方法だけ知りたいという方は、以下の2点だけ実施して下さい。

  • 引数のリストや辞書は、メソッド内では参照だけに止める
  • リストや辞書を複製しようとしない

少し乱暴な理屈ですが、この2点だけ守っていれば、参照渡しが起因のトラブルはほぼ起きません。

どういうことか、もう少し詳細に解説します。

対象方1 – 引数のリストや辞書は、メソッド内では参照だけに止める

例えば以下のサンプルコード。

def show_list(names: dict):
    for v in names.values():
        print(f"hello {v}-san!")


names = {1: "suzuki", 2: "sato"}
show_list(names)

これはOKです。メソッド内では、namesは参照しかされていません。

def show_list2(names: dict):
    for k, v in names.items():
        names[k] = f"{v}-san"
    for v in names.values():
        print(f"hello {v}!")


names = {1: "suzuki", 2: "sato"}
show_list2(names)

こちらはNGです。メソッド内で、引数のnamesが更新されています。

もちろん下のコードでエラーが出るわけではありませんし、上のコードも下のコードも同じ出力結果になります。

hello suzuki-san!
hello sato-san!

ただ下のコードのように、メソッド内で引数そのものを更新すると予想外の挙動になりやすいです。(詳細は後述します。)

なので、メソッド内でリスト型もしくは辞書型のお引数を直接更新することは止めましょう。これは、それほどきつくない縛りだと思います。

もし直接更新しないといけない箇所があるなら、それは実装の仕方が悪い可能性があります。コードデザインなどを見直し、それでも取り除けないなら、諦めて参照渡しについて、しっかりと学ぶ必要があます。

対処方2 – リストや辞書を複製しようとしない

Pythonでリストや辞書を複製してはいけません。

たとえば、[1, 2, 3][1, 2, 3, 4] という2つの配列を用意したくなったとき、

sample1 = [1, 2, 3]

sample2 = sample1
sample2.append(4)

とやってはいけません。

同値の配列や辞書を2つ以上用意したくなったときは、値を直接入れるか、メソッドの返値を受け取るようにしましょう。以下の様なコードはOKです。

値を直接入れる場合

sample1 = [1, 2, 3]
sample2 = [1, 2, 3, 4]

メソッドの返値として受け取る場合

def get_list123(has_4th=False):
    return [1, 2, 3] if not has_4th else [1, 2, 3, 4]


sample1 = get_list123()
sample2 = get_list123(True)

これは少し面倒な縛りに感じるかもしれません。でも参照渡しが起因のトラブルを避けるためには必要なことです。

以上が、理屈を抜きにして、参照渡しのトラブルをさけるめにとりあえずこれだけ守っていれば大丈夫ということになります。次からは理由を説明していきます。

Pythonの参照渡しの特殊なところ

Pythonは変数の型によって、参照渡しになったり値渡しになったりします。これが他の言語と比べると少し特殊なところです。

Pythonのデータ型は、mutable(ミュータブル、変更可)のものとimmutable(イミュータブル、変更不可)のものがあります。これだけ聞くと、だから何? 参照渡しとどう関係あるの? と思われるかもしれませんが、Pythonの参照渡しを理解するには、まずこのmutableとimuutableについて知っておく必要があります。

Pythonで参照渡しが発生するときは、mutableのデータ型を扱うときです。Pythonの代表的なmutableのデータ型は、list, dict, set です。他にも bytearray とかもあります。

Pythonで参照渡しのトラブルが発生するのは2パターン

Pythonで参照渡しのトラブルが起きやすいのは2パターンです。

  • メソッドの中でmutableの引数を更新しようとする
  • mutableの変数を複製しようとする

前述の「引数のリストや辞書は、メソッド内では参照だけに止める」や「リストや辞書を複製しようとしない」のセクションのNG例は、いずれも参照渡しになっています。

引数を更新するケース

前述のNGコードです。

def show_list2(names: dict):
    for k, v in names.items():
        names[k] = f"{v}-san"
    for v in names.values():
        print(f"hello {v}!")


names = {1: "suzuki", 2: "sato"}
show_list2(names)

これは参照渡しになっているので、呼び出し側でメソッド実行後にnamesを使用としたとき、予期せぬ動作になっています。

show_list2(names) の下で

print(names)

とやると、出力結果は

{1: 'suzuki-san', 2: 'sato-san'}

になります。メソッド内でやった-san付与の更新処理が反映された状態になっています。これは他の言語の感覚からすると、えっ?となる挙動です。

変数を複製しようとするケース

前述のNGコードです。

sample1 = [1, 2, 3]

sample2 = sample1
sample2.append(4)

これも理屈は同じです。参照渡しになっているので、コピーソース側も変更されています。sample2.append(4) の後に

print(sample1)
print(sample2)

とやると、結果は

[1, 2, 3, 4]
[1, 2, 3, 4]

となります。sample2が[1, 2, 3, 4]なのは想定内ですが、sample1まで[1, 2, 3, 4]になっています。他のコードの感覚だと、sample1は[1, 2, 3]ですが。

参照渡しになっているかはidで確認できる

参照渡しになっているかは、データのオブジェクトIDを見ればわかります。

sample1 = [1, 2, 3]
sample2 = sample1
print(id(sample1))
print(id(sample2))

とやると、同じ数値が出力されます。

オブジェクトIDが同じ、つまりデータオブジェクトは同じなので、片方を更新すればその内容はもう片方にも反映されます。

ミュータブルの変数を参照渡したくないときはcopyを使う

前述の「理屈はいいから、とにかく応急処置を知りたい人」では参照渡しを避ける方法として、以下を紹介しました。

  • メソッド内でリストや辞書の引数は更新しない
  • リストは辞書の複製は、値を直接埋め込む or 関数の返値で受ける

参照渡しが何かわかっていない場合は、この対処方で問題ありませんが、mutableと参照渡しの関係がわかっていて、その上で参照渡しをさける方法としてpythonの標準ライブラリであるcopyがあります。

import copy

sample1 = [1, 2, 3]
sample2 = copy.deepcopy(sample1)
print(id(sample1))
print(id(sample2))

とやると、別々のIDの値が出力されます。IDが別、つまりデータオブジェクトが別なので、片方のみ更新することができます。

import copy

sample1 = [1, 2, 3]
sample2 = copy.deepcopy(sample1)
sample2.append(4)
print(sample1)
print(sample2)

とやると、

[1, 2, 3]
[1, 2, 3, 4]

となります。

引数で渡すときも同じで、メソッドの中で更新処理をしたいときは、メソッドの中、もしくはコール側でcopyします。

import copy


def show_list(names: dict):
    new_names = copy.deepcopy(names)
    for k, v in new_names.items():
        new_names[k] = f"{v}-san"
    for v in new_names.values():
        print(f"hello {v}!")


names = {1: "suzuki", 2: "sato"}
show_list(names)
print(names)

copyには2種類ある shallow copy と deep copy

copyライブラリには、shallow copy(浅いcopy)deep copy(深いcopy)の2種類のcopyがあります。

shallow copyのほうは、listやdictの中のlistやdict要素はcopyできません。deep copyはできます。

ケースによって使い分けるべきなのでしょうが、私自身はshallow copyを使用したことはありません。実装時はshallow copyで十分でも、後の更新でdeepcopyが必要になったりして、その更新漏れでバグを引き起こしたりします。

余程の事情がない限り、deepcopyでいいかと個人的には思います。