Pythonの関数は値渡し? 参照渡し? 答えも「間違えの原因(?)」もドキュメントにあった話

こんにちは、くらめです。 めちゃくちゃ久しぶりですね。 僕ってブログに向いてないんじゃないですかね?

まあそれはさておき。 皆さん、Python の関数って「値渡し」だと思いますか? それとも「参照渡し」だと思いますか? え? よくわからない? そもそも「値渡し」とか「参照渡し」とかなんのこっちゃ? そんなときは「Python 値渡し 参照渡し」ググってみましょう! えい!

ふむふむ、上位 10 件はというと、

  • Python は値渡しである」と主張するページ:2件
  • Python はイミュータブルな型(intやstring等)では値渡しで、ミュータブルな型(リスト、辞書等)では参照渡しである」と主張するページ:3件
  • Python は参照渡しである」と主張するページ:5件

なんか割れてますね……。 でも真ん中の主張と下の主張は中身を読んでみるとほぼほぼ同じようなことが書いてありました。 すなわち、「Python は参照渡し」という主張が8件!

さて、もう大勢は決しましたね。Python は参照渡しということで……と言ったらこの記事が終わってしまうので、多数派に簡単に流されないようふんばりましょう。 ただ、先にこれだけ書いておきます。

Python は値渡しである」と主張する記事のうち片方は Python ドキュメントのFAQ です。

なお、今回の記事は

のお話です。 とは言っても、今回の話はかなり基本的な話なので、どのバージョンの Python でも変わらないと思います。 Python 4 とか 5 とかが出たりしたら流石にわからないですけども。

背景

ちょっと意味深な書き方をしましたが、まずは背景をば。 そもそもなんで Python の関数が値渡しなのか参照渡しなのかを調べることになったのかといいますと、ついこの間仕事で C 言語で書かれたライブラリの Python ラッパーを作る機会がありまして。 Python から C ライブラリの関数を呼び出すこと自体はなんとかできたんですけど、こんなシグネチャの関数を Python でどうラップしようかなと思ったわけです。 (もちろん、本来の関数はもっと難しいやつですよ!)

// x と y の値を入れ替える。戻り値には成功なら 1 、失敗なら 0 を返す
int swap(int *x, int *y)

ここで書いている「*x」というのは Python の関数で見られる「*args(可変引数)」とは異なるもので、 ポインタと呼ばれる参照を扱うための引数だと思ってもらえばいいです。

パッと思いつくのはこんな関数だったんですが……

# 成功可否, y, x の順のタプルを返す
def swap(x, y):
    success = True
    return (success, y, x)

成功可否と結果が同じタプルに入っているのもなんだかなぁ。 かと言って、このためにクラスを作るのもなんだかなぁ。 と悩んだ末に「そういえば Python ってどうやって参照渡しするんだろう?」と疑問に思ったのがきっかけです。 (ポインタの引数は参照渡しとほぼ同じ。厳密には違うらしいですが……)

それで調べてみたら、「Python は参照渡しである」という主張をする記事がごろごろ見つかりまして……。 僕の感覚としてはPythonは値渡しだと思っていたので、本腰を入れて調べてみたという経緯です。

「値渡し(call by value)」、「参照渡し(call by reference)」とは

そもそも「値渡し(call by value)」、「参照渡し(call by reference)」とはなんぞや、というところから確認していきたいと思います。 ……というか、ここが最重要ポイントで、ここの認識が間違っているとすべてを間違えます。

値渡しや参照渡しというのは関数を呼び出す際の、仮引数(parameter)に対する実引数(argument)の渡し方のことです。 実際には他にもいくつか渡し方の種類があるのですが、ここではこの2つに絞って説明します。

以下、Python から離れた一般的な話になりますので、C言語風の書き方で書いていますが、Python と違う部分にはコメントを加えているので読めると思います。

// 数値を受け取って 1 加える関数を定義。戻り値無し
void add(int x_param) {
    x_param = x_param + 1
}

int x_arg = 1
add(x_arg)

例えばこのプログラムの場合、関数定義の行にある x_param が仮引数、呼び出し側にある x_arg が実引数になります。

値渡しと参照渡しの違いは以下のようになります。

  • 「値渡し」は実引数の値のコピーを渡すやり方
  • 「参照渡し」は実引数の参照自体を渡すやり方

図にすると以下のようになるでしょうか。

f:id:kurame-yotsuba:20210307212523p:plain

x_arg のアドレスにある値を x_param のアドレスにコピーしています。 そのまんまですね。

一方で、参照渡しはというと f:id:kurame-yotsuba:20210307212619p:plain

x_param には x_arg への参照が入っています。 (アドレスがそのまま x_param に入っているわけではありません。あくまでイメージです)

では参照渡しを行うとどのようなことが起きるのでしょうか。 先ほどの add 関数では x_param の値を +1 していました。 すると、それぞれ値渡しと参照渡しでは以下のような違いが現れます。

値渡しの場合 f:id:kurame-yotsuba:20210307212642p:plain

参照渡しの場合 f:id:kurame-yotsuba:20210307212656p:plain

このように値渡しをした場合は x_param の値を変更しても呼び出し元の変数に影響はありませんが、参照渡しをした場合は x_paramx_arg への参照が入っているため、x_param の値を変更するとそれに伴って x_arg の値も変わります。 すなわち、関数の呼び出し元にある変数を関数内で変更することができるようになります。

では、Python では値と参照のどちらを渡しているのでしょうか。

Python は参照渡し」という主張について

Python は参照渡しである」と主張する記事がよく書いていることを見てみましょう。

def add(x_param):
    print(id(x_param)) # 2191247305008
    x_param = x_param + 1
    print(id(x_param)) # 2191247305040

x_arg = 1
print(id(x_arg)) # 2191247305008
add(x_arg)
  1. このようなプログラムを実行してみると、x_argx_paramid 関数の実行結果が一致している。
  2. つまり、x_argx_param は同様のアドレスを指しているため、Python の関数は参照渡しである。
  3. add 関数の中で変数に代入をしてオブジェクトのIDが変わっているのは x_param がイミュータブル(変更不可)であるからであり、イミュータブルな型では挙動が異なる。
  4. ミュータブル(変更可能)な型の場合は下記のようにオブジェクトに対する変更が関数の外に反映されている。
def my_append(list):
    list.append(1)

x = []
my_append(x)
print(x) # [1]

これらのことから、

  • Python の関数はすべて参照渡しだが、イミュータブルな型とミュータブルな型で挙動が異なる。

とか

  • Python の関数はイミュータブルな型では値渡しで、ミュータブルな型は参照渡しである。

などと主張されているのです。

それは参照渡しであることの確認じゃない!

そもそも id という関数はどのような関数なのでしょうか? Python のドキュメントを読んでみましょう。

Python 3.9.2 ドキュメント - 組み込み関数 より

オブジェクトの "識別値" を返します。この値は整数で、このオブジェクトの有効期間中は一意かつ定数であることが保証されています。有効期間が重ならない 2 つのオブジェクトは同じ id() 値を持つかもしれません。

CPython implementation detail: This is the address of the object in memory.

英文訳

CPython の実装の詳細:これはメモリ内におけるオブジェクトのアドレスです。

Python はすべてのデータをオブジェクトとして扱いますからこの id 関数は int などのシンプルなデータに対しても用いることができます。

つまり、イミュータブルである int であろうがミュータブルである list であろうが以下のように変数に対してオブジェクトが関連付けられています。

f:id:kurame-yotsuba:20210307212714p:plain

そして、id はこのオブジェクトのアドレス(図の0x3000)を返しています。 すなわち、id 関数の返す値は変数 (x_arg) のアドレス(図の0x1004)ではないのです。 そのため、id 関数を使って、関数が値渡しか参照渡しかを判断することはできません。

また、リストなどのミュータブルな型に対する変更が効くというのも、それは変数そのものに対する操作ではなく、オブジェクトに対する操作であるため、参照渡しとは関係の無いものです。 もし参照渡しであるならば、

def set_empty(list_param):
    list_param = []

list_arg = [1, 2, 3]
set_empty(list_arg)
print(list_arg) # [1, 2, 3]

の出力結果は [] となるはずですが、実際は [1, 2, 3] となり、関数内での代入が反映されていません。

といってもまだ Python の関数が参照渡しでないと決めつけることはできません。 なぜなら、仮に「参照渡しだが、イミュータブルな型は挙動が異なる」が間違っていないのであれば、「ミュータブルな型の代入時にも挙動が異なる」という可能性もあるからです。 (いや、無いと思うけど)

Python のドキュメントを見る

では、最後に答え合わせです。 こちらは最初に太字で書きました Python は値渡しである」と主張する記事のうち片方Python のドキュメント内にあるFAQです。

Python 3.9.2 ドキュメント - プログラミング FAQ - 出力引数のある関数 (参照渡し) はどのように書きますか?

こんな項目がある時点で「Python が参照渡し」ということはないでしょう。 常に参照渡しされるのならばFAQに挙がるべくもありません。

以下、このリンクページから抜粋です。

前提として、Python では引数は代入によって渡されます。代入はオブジェクトへの参照を作るだけなので、呼び出し元と呼び出し先にある引数名の間にエイリアスはありませんし、参照渡しそれ自体はありません。望む効果を得るためには幾つかの方法があります。

一見何を言っているのかわかりづらいですが、注目すべきはここです。

参照渡しそれ自体はありません

やっぱ無いんじゃん! でもここまで来たら FAQ ではなく、ドキュメント上ではっきりと「値渡しである」と言っている部分を見つけたいです。 と、思って探していたらありました。 流石 Python のドキュメント! 充実してるぜ!

Python 3.9.2 ドキュメント - 4. その他の制御フローツール より

The actual parameters (arguments) to a function call are introduced in the local symbol table of the called function when it is called; thus, arguments are passed using call by value (where the value is always an object reference, not the value of the object). [1]

(脚注) [1] 実際には、オブジェクトへの参照渡し (call by object reference) と書けばよいのかもしれません。というのは、変更可能なオブジェクトが渡されると、関数の呼び出し側は、呼び出された側の関数がオブジェクトに行ったどんな変更 (例えばリストに挿入された要素) にも出くわすことになるからです。

以下、英文の訳

関数呼び出しに対する実際のパラメータ(実引数)は呼び出し時にその関数のローカルシンボルテーブルに導入されます。このように、実引数は値渡し(ただし、値は常にオブジェクトへの参照)で渡されます。

んん? 英文には明らかに "call by value" (値渡し)と書いてあるのに、脚注には「参照渡し」の文字が。 どっちなんだろうと疑問に思いながら「参照渡し」のすぐ後ろの()の中をよく見てみると "call by object reference" と書いてあります。

つまりこれはあれか!? 「オブジェクトへの『参照渡し』」ではなく、「『オブジェクトへの参照』渡し」という意味か!? もっと丁寧に書くなら「『オブジェクトへの参照』の値渡し」と言いたいのでしょう。この文章は。 これは、ちょっと勘違いされても仕方がないような……。

最初に「Python は参照渡し」と主張し始めた人はここの文を読み違えたのかもしれません。

まあ、理由はどうあれ。 結論は出ました。

Python の関数は値渡し』 です。

まとめ

  • プログラミング言語一般において)関数の引数の渡し方には「値渡し(call by value)」と「参照渡し(call by reference)」がある(実際は他にも種類がある)
  • id 関数が返すのは変数のアドレスではなく、オブジェクトの識別値(CPython ではオブジェクトのアドレス)
  • Python の関数は値渡しであり、参照渡しは存在しない
  • Python のドキュメント内に「オブジェクトへの参照渡し(call by object reference)」というめちゃくちゃややこしいワードがあるから気をつけろ

以上! Let's Python with call by value!

参考

「Scoop + pwsh + Windows Terminal」にカスタムディレクトリ設定を混ぜたら詰まった話

こんにちは、くらめです。 初めてのブログでどんな風に書けばいいのかよくわかりませんが、気楽に書いていきたいと思います。

なお、以下はWindows Terminal v1.4.3141.0で起きたことを書いています。 別のバージョンをお使いの方は挙動が異なる可能性があるのでご了承ください。

背景

まずは今回起きた問題を一言で書いておきましょう。

Scoopのインストールディレクトリをデフォルトから変えたら、Windows TerminalのシェルのリストにPowerShell Coreが表示されなくなりました。

そもそもScoopとはなんぞやといいますと、Windows向けのとてもシンプルなパッケージマネージャーです。 PowerShellからコマンドを叩く形なので技術者よりのツールですが、 とても使いやすくて、アプリのインストールやアップデートをする手間が激減します。 Scoopの入門記事だったり解説記事だったりはQiitaなどにわかりやすい記事があるのでそちらを参照ください。

僕も1年ぐらい前からScoopに大変お世話になっているのですが、今日、悲劇が起きました。

「し、Cドライブが、赤くなってる……(Scoopのせいじゃない)」

はい、ただのアプリの入れすぎです。別にScoopに問題があるわけではないです。 Scoopはデフォルトでは

$env:USERPROFILE\scoop

というディレクトリをインストール時に作成し、そのディレクトリ下にアプリをじゃんじゃんインストールしていきます。 当然、このディレクトリはCドライブ下にあり、CドライブにはSSDを使うことが多い昨今、容量が危なくなりやすいわけです。

もちろん解決策はあります。 Scoopはアプリをインストールするディレクトリをインストール時に指定することができますので、ScoopをインストールするディレクトリをHDDドライブなどに変更すれば問題はなくなります。 インストールディレクトリの変更方法についてはScoopのWikiの通りにやればOKです。 もうScoopの運用をしちゃってるんだが? という方は「scoopのインストール先を変更する」という記事が参考になりますので、見てみてください。

そして、無事、Scoopの移行が終わったところで第二の悲劇が起こりました。

f:id:kurame-yotsuba:20201114020958p:plain

そう、Windows Terminalがエラーになっているのです。 ちなみにWindows TerminalはScoopで入れているわけではないので、インストールがミスってるということはありません。 そして、後ろのタブに表示されているアイコンをご覧ください。 そうです。こいつ、PowerShell Coreじゃありません。

キサマ! 旧世代の遺物、「Windows PowerShell」!!

問題

今回ぶち当たった問題は以下です。

Scoopでインストールディレクトリをデフォルトから変更すると、Windows TerminalがPowerShell Coreを読み込もうとしたときにエラーになる。

原因

PowerShell Coreのインストールをミスったのかなとか、Windows Terminalを再インストールしたら直るかなとか、PowerShell Coreをグローバルインストールに変えたらどうだろうとか色々試していくうちにひとつ、Windows TerminalでPowerShell Coreを開けたことがありました。 それは%USERPROFILE%\scoopディレクトリにPowerShell Coreをインストールすると上手く動いたのです!

……いや、まあそうでしょうね。今までそれで動いていましたもんね。

で、そもそもWindows TerminalはどうやってPowerShell Coreを見つけ出しているのだろうとsettings.jsonを見てみれば

"source": "Windows.Terminal.PowershellCore"

という指定はあるも、それでどうやって探しているのかはわからず。

Microsoft Docsにも

LinuxWindows サブシステム (WSL) と PowerShell のシェルがマシンにインストールされている場合、これらに対するプロファイルが自動的に作成されます。 これにより、実行可能ファイルを探す必要なしに、すべてのシェルをターミナルに簡単に含めることができるようになります。 これらのプロファイルは、source プロパティを使用して生成されます。このプロパティでは、適切な実行可能ファイルを見つける場所がターミナルに通知されます。

とあるも、その「適切な実行可能ファイルを見つける場所」とやらはわからず……。

Windows Terminalは幸いGitHubに公開されていますので、ソースコードを見ればなんとかなるかなぁと覗いて見たらなんかそれっぽい箇所を見つけ――

_accumulatePwshExeInDirectory(L"%USERPROFILE%\\scoop\\shims", PowerShellFlags::Scoop, versions);

ってハードコーディングかよ!

そりゃディレクトリ変更したら見つからないわけだよ。 つまり、ディレクトリを変更してエラーになった原因は、ディレクトリを変更したことだったんだね!

まあ、そもそもScoopを特別扱いしてくれている(他だとdotnet CLI)時点でMicrosoft優しいという気持ちも溢れてきますが……えぇー(困惑)。

解決策

なにはともあれ、原因がわかってしまえばほぼ終わりです。 %USERPROFILE%\scoopにScoopのインストールディレクトリがなくてはいけないので、今回指定したカスタムディレクトリへのシンボリックリンクなりジャンクションなりを貼ってやれば、それでとりあえずのところ問題はありません。

ただし、シンボリックリンクですと管理者権限 or 開発者モードが必要になってくるので、ジャンクションの方がいいかもしれません。

//カレントディレクトリが$env:USERPROFILEだとする
New-Item -ItemType Junction -Value カスタムディレクトリのパス -Name scoop

って感じでやればOKです。

対処療法的な感じになってしまいましたが、これで僕のCドライブもまだまだ安心して使えそうです。

参考