2021.02.19追記
反省してアタマを丸めました笑
いただいたコメントを参考にして大幅に内容を修正いたしました。過去の誤りの多い記事内容に興味のある方は編集履歴をご確認ください。再度ないように誤りが見つかりましたらお手数ですがご指摘をお願いします、正しい記事を残したいので…!
また、タイトルと内容とにズレがある、との指摘をいただいたため、タイトルを【新人「先輩、変数のスコープについてホントにちゃんと理解できてます?😊」】から現タイトルに変更しました。
元々煽りタイトルにしていたので今回も煽りタイトルにしました、書いている本人が一番知らないのにこのタイトルにしたのはどうかご容赦いただきたいです笑
2021.02.17追記 たくさんの方々からの閲覧ありがとうございます。 また、コメントも読ませていただいております。 私の書いた内容にいろいろ誤りもあるとのことですので、初見の方はコメントも交えながら読んでいただけますと幸いです! また、時間ができ次第、記事内容の修正も行う予定です。
2021.02.16追記 変数のスコープの話…というよりは参照渡しとかの話題なのでタイトル変えたほうが良いかもですね
とある職場にて
※この物語は実話をもとにしたフィクションです。
※初Qiita記事です。お手柔らかにお願いいたします笑
関数内でグローバル変数を書き換えられてしまう?
おれ「おーい新人くん」
新人「え?なんですか?(すっとぼけ)」
おれ「こんなコード書いちゃいけないでしょ」
※Qiita用にコードは簡素化しています
【例1】Python
foo = {"foo": 123}
def bar(v):
v["bar"] = 12345
bar(foo)
# ↓変わらず{'foo': 123}が出力される?
print(foo)
新人「えっ、なにがいけないんですか?」
おれ「引数に渡したグローバル変数に対して関数内で新しい要素を追加するこの関数だけど…
この書き方では関数内で要素の追加処理自体は行われるけど、グローバル変数fooには影響を及ぼさないぞ。」
新人「えっ?」
おれ「こんな感じで…」
【例2】Python
foo = {"foo": 123}
def bar(v):
v["bar"] = 12345
return v
foo = bar(foo)
# {'foo': 123, 'bar': 12345}が出力される
print(foo)
おれ「関数でreturnを返すようにして、変数fooに新しい値を再代入するようにして書かないと変数fooの値を変更できないんだよ」
新人「でもおれさん、コード動かしてみたらちゃんと書き換えされましたよ?」
おれ「えっ」
【例1】Python出力結果
{'foo': 123, 'bar': 12345}
おれ「…ふっ、ふーん…?(ま、まぁ、たまには間違えるよねー、適当に言い訳しとこ)」
RubyもJavaScriptでも同様の動作になる?
おれ「あ…あれだ!きっとPythonは変数スコープが曖昧なんだよ、RubyやJavaScriptを使ってたらこううまくはいかないぞ!ははははは!」
【例3】Ruby
foo = {"foo": 123}
def bar(v)
v["bar"] = 12345
end
bar(foo)
# ↓書き換えられないはずだ…
puts foo
【例3】Ruby出力結果
=> {:foo=>123, "bar"=>12345}
【例4】JavaScript
let foo = {"foo": 123}
function bar(v) {
v["bar"] = 12345;
}
bar(foo);
// これも…?
console.log(foo);
【例4】JavaScript出力結果
{ foo: 123, bar: 12345 }
おれ「えー」
**おれが間違っていました…**
新人「先輩、変数のスコープについてホントにちゃんと理解できてます?😊」
おれ「」
## 文字列や数値では書き換えされない? いろいろ調べてみると、 変数で扱う値が文字列や整数の場合には、関数内で値を変更してもグローバル変数は書き換えされませんでした。
【例5】Python グローバル変数の値が整数の場合
foo = 123
def bar(v):
v = 456
print(v)
# 出力結果 456
bar(foo)
# 出力結果 123
print(foo)
【2021.02.19追記】変数に辞書オブジェクトを再代入した場合は書き換えされなかった
また、例1では辞書オブジェクトの要素を追加する処理でしたが、変数に辞書型オブジェクトを再代入する処理をした場合にはグローバル変数の書き換えは起こりませんでした。
foo = {"foo": 123}
def bar(v):
v = {"foo": 123, "bar": 456}
print(foo)
bar(foo)
print(foo)
おれ「どういうこと?」
なぜこうなったのか
Pythonでは関数に引数を渡す場合に参照渡しという方式が使われます。
また、参照渡しによって関数に渡されるオブジェクトの性質がイミュータブルかミュータブルかによって動作が変わります。
2021.02.19追記 ※以後大幅に書き直し
Pythonでは関数に引数を渡す場合の方式として参照の値渡しが使われています。
変数に値やオブジェクトを代入する場合と辞書オブジェクトに要素を追加する場合とで結果が変わった理由は、この参照の値渡しの性質にあります。
参照の値渡しとは?
Pythonの公式ドキュメントにこのように書かれています。
参考 : 出力引数のある関数 (参照渡し) はどのように書きますか?
前提として、Python では引数は代入によって渡されます。代入はオブジェクトへの参照を作るだけなので、呼び出し元と呼び出し先にある引数名の間にエイリアスはありませんし、参照渡しそれ自体はありません。
この関係性を参照渡しであると説明している文献が他でもいくつか見受けられたのですが、
参照渡しは変数への参照を渡す(変数共有する、エイリアス変数を作る)ものであり間違いです。PythonやJavaScriptなどの言語では参照渡しはできません。
Pythonで行われている参照の値渡しは変数への参照を渡すのではなく「オブジェクトへの参照を作る」処理になります。
これはオブジェクトを参照することができるidを渡すことで実現しています。
※@shiracamus様にコメントいただいた内容を自分の言葉に置き換えてみました。ご指摘くださり本当にありがとうございました。
値の参照渡しイメージ
言葉だけの説明では難しい と思いますので図でもご説明します。
※@shiracamus様のコメントに記載があったものを許可をいただいた上でご利用させていただいています、本当にありがとうございます。
そもそも変数に代入、再代入したときはどうなっているのか
まずは単純な変数に代入した例を用いて、そもそもどういう動きをしているのかをご説明します。
foo = 123
foo = 234
変数にオブジェクトを代入すると、変数にはそのオブジェクトの場所を示すオブジェクトidの値が記録されます。変数はそのidを参照してデータ型や値などを利用します。
そして、変数にオブジェクトを再代入すると、元々のオブジェクトに記録されている値などが上書きされるのではなく、再代入した値についての新しいオブジェクトが作成され、そのオブジェクトidが変数に書き込まれます。
本当にオブジェクトidが変わっているの?と疑いのあなた!!!
Pythonのid関数を使うと変数に設定されているオブジェクトidを調べることができますので、上記の処理に書き足して出力結果を確認してみてください。
foo = 123
# 結果 4510925744
print(id(foo))
foo = 234
# 結果 4510929296 <= 変わった!
print(id(foo))
本当に"参照先"の"値"だけを渡していますね…。 ※【疑問】参照の値渡しは関数の引数に変数を渡す際の用語という認識なのですが、この場合も「参照の値渡し」という用語は当てはまる?
関数に引数として渡した変数に再代入するケース 算術式の場合
では、関数に引数で変数を渡す場合に参照の値渡しがどのように働いているのか、を見ていきます。
foo = 123
def bar(v):
v += 123
print(v)
print(id(v))
# 結果 123
print(foo)
# 結果 4510925744
print(id(foo))
# 結果 246
# 4451054352
bar(foo)
# 結果 123 <= グローバル変数では関数での計算結果が反映されていない!
print(foo)
# 結果 4510925744 <= オブジェクトid値は元々の値と変わっていない!
print(id(foo))
このように、それぞれの変数の値のオブジェクトidを出力してみると、関数内変数のidのみ違う、という結果になりました。
bar関数に引数としてfooを渡すと、ローカル変数vに変数fooに記録されているオブジェクトidが記録されることで「123」を読み取ります。
その後、算術式によりvの値が変更されます。
先述の例でも紹介したように、変数に値を再代入される場合にはその値のための新しいオブジェクトが発行されますので、変数vには新しいオブジェクトidが入り参照先が変更されます。
これにより変数fooは元のオブジェクトidを、関数内の変数vは新しい方のオブジェクトを参照している状態となるため、上記のような出力結果となります。
※ちなみに、bar関数内の変数名を同じfooで関数定義した場合にも同様の結果となりますよ!
関数に引数として渡した変数に再代入するケース 辞書型に新しい値を記憶する場合
では、今回グローバル変数の値が書き換わってしまった?辞書型に値を記憶する場合のオブジェクトidなどの動きを見ていきましょう。
※公式ドキュメントを見て初めて知ったのですが、辞書型への値追加は"記憶する"と言うのですね 参考リンク
foo = {"foo": 123}
def bar(v):
print(id(v))
v["bar"] = 12345
print(id(v))
# キーごとにオブジェクトidを出力
print(id(v["foo"])
print(id(v["bar"])
# 結果 {"foo": 123}
print(foo)
# 結果 4451050416
print(id(foo))
# 結果 4451050416
# 4451050416 # <= 辞書型に値を記憶してもオブジェクトidには影響しない!
# 4510925744 # <= キーごとにオブジェクトidが与えられている
# 1231453011 # <= キーごとにオブジェクトidが与えられている
bar(foo)
# 結果 {"foo": 123, "bar": 12345} データが変わった!!!
print(foo)
# 結果 4451050416 オブジェクト自体は関数実行前と同じ
print(id(foo))
このように、それぞれの変数の値のオブジェクトidを出力してみると、関数内変数においても辞書型オブジェクトのidは変化しない、という結果になりました。
bar関数に引数としてfooを渡すと、ローカル変数vに変数fooに記録されているオブジェクトidが記録され「{"foo": 123}」を読み取ります。
その後、変数vにbarをキーとして値12345が記憶されますが、このときは変数vの値が変わるのではなく、**変数vの先にある辞書型オブジェクトの中に影響します。**今回の場合は新しいオブジェクトidが追加されます。
このような動きで、辞書型オブジェクトのid自体は変わらず、その中身だけが変更されるため、グローバル変数fooの辞書型オブジェクトの値が書き換えられる、というわけです。
ちなみに、先述したように辞書型オブジェクトを関数内で再代入した場合には、辞書型オブジェクト自体が変更になるためオブジェクトidの変更が伴うため、グローバル変数の値には影響しません。
このような動きが値の参照渡しです。おわかりいただけたでしょうか・・・?
ちなみに…
修正前の記事のような内容をたくさんまとめていただいている方のQiita記事を見つけました。参照渡しで間違えて理解される方が多いんですね…
"call by reference"ではない動作を「参照渡し」と言っている記事まとめ
おわりに
自分の言葉で言い換えることに努めた結果、理解や表現が間違っている場合もあるかもしれません。大変恐れ入りますが、間違いなどありましたらご指摘いただきますと幸いでございます。
調べてみると言語特有の仕様などでは決してなく、プログラミングのかなり基本的な部分の論点であることがわかり大変反省しました(T_T)
本職ではエンジニアインターン生に開発を教える立場ですので、今後も正しい指導者、リーダーであるためにもあいまいだった知識を潰していけたらと思います。
また、ミュータブルなオブジェクトであるリストや辞書型のデータは大規模開発になると変数内に予期しない値が入ったりすることも想定されるので、その対策などについても考えていけたらと思います。
2021.02.19追記
ほんと、公式ドキュメントをちゃんと読まないといけないですね(T_T)