【世界一分かりやすい】関数・引数・返り値とは?使い方から内部実装(アセンブリ)までプロが解説
はじめに
どうも私立YouTube高専校長です。 本日は関数・引数・返り値をテーマにお話したいと思います。 どんな言語を使うにしても、プログラムを書く上で、関数の理解は必須です。 関数を使わないと、プログラムを書くことはおろか、読むこともままなりません。 しかし、逆に言えば、一つの言語でコツさえ掴めば、どんな言語にも応用できるので、その分だけ学習する価値があります。
この動画をご覧のあなたは、すでに関数をご存じかもしれません。 しかし、あなたは、自分で定義した関数を使っていて、原因不明の不具合に出会ったことはないでしょうか? 私も、似たような経験があります。Python初心者の頃に、プログラムで関数を使ったら、原因不明の不具合に何時間も溶かしてしまいました。そして、これは、関数が呼び出し元の変数を破壊していた事が原因でした。 このように、関数や引数の仕組みをきちんと理解して、プログラムを書かないと、意図せぬ動作をする場合があります。そして、そのようなバグは事前に知識がないと、解決するのに何時間もかかってしまいます。 そこで、本日は単に関数の使い方を説明するだけでなく、引数の種類として値渡しと参照渡しというものについて説明したいと思います。 例えば、C++では、値渡しと参照渡しの両方があって、適切に使い分けることで、初めて安全なプログラミングが可能になります。 あなたがもし、何らかの言語を使ってブログラムを書くときは、必ずその引数が値渡しなのか参照渡しなのか意識する必要があり、それは、言語ごとの実装に依存します。 また今回の動画では、関数の内部実装についても解説したいと思います。ここでは、x64のアセンブリを読み解き、関数がどのように実現されているのかを解説したいと思います。 最後に、関数の内部実装を踏まえて、関数を利用する上での注意点をご紹介したいと思います。 ここまで勉強しておけば、関数に関する不具合は、殆ど自力で解決できるようになると思います。
また今回紹介する情報以外にも、こういった有益なコンピュータ関連の技術情報を発信しているので、 そういった動画を見逃したくない方は、今のうちにチャンネル登録しておいて貰えればと思います。 このチャンネルでは、私が、高専・東大・スタートアップ・日系大手メーカー・外資ハイテク企業、、と人生の大半をコンピュータに投下して、学んできた全てをぶつけます。
それでは、本編どうぞ
関数の使い方
まずは、既にご存じかもしれませんが、関数の使い方についてご説明したいと思います。
あなたが、ソースコードを書いているときに、同じような処理を繰り返し書いているなと感じる事はないでしょうか? 同じような処理を何回もベタ書きしいていると、ソースコードが多くなり、ミスする可能性が増え、バグに繋がる可能性があがります。また、可読性が低く、保守性にも影響がでます。
したがって、プログラミングの世界には、DRY(Don't Repeat Yourself)という設計指針があり、同じ処理は、サブルーチンとして定義して再利用することが良いとされています。そして、殆ど全ての言語では、そのために関数という仕組みが実装されています。 この関数を使うと、同じような処理は一元化出来て、バグの可能性が減るだけでなく、さらに可読性も上がります。
例えば、プログラムで、台形の面積の和を求めるとします。 関数を使わずに書くと、このようになります。 台形の面積を3回計算していますが、この書き方だと、毎回台形の公式をベタ書きしているので、計算が正しいかどうか確かめるには、全てのソースコードを読む必要があります。 これだけならまだマシですが、台形が10,000個あったらどうしましょうか? 計算が正しいかどうか確認することが、人の目ではもはや不可能になるでしょう。
実際には、台形の公式は、毎回同じなので、台形の面積を計算する関数を定義すると、これをもっとシンプルに書くことができます。 その結果がこちらです。 関数は、それぞれの台形のパラメータを受け取って、それを元に面積を計算して、関数の呼び出し元に計算結果を返します。このパラメーターを引数と呼び、関数を定義するときに関数名と一緒に定義します。面積を計算する具体的な値は、関数呼び出し時に、初めて確定します。今回は、台形の面積の計算に必要なパラメーターが上辺、下辺、高さなので、その3つを引数にして定義しています。
そして、関数の中でreturnが呼ばれたら、その瞬間に関数は終了して、計算結果を呼び出し元に返します。このreturnされる値を、戻り値や返り値といいます。
重要なことは、関数の定義と和の計算を分離できたので、関数の定義さえ正しければ、引数の検証だけで、計算の検証ができること。 そして、もう一つが関数の名前から、計算の意味が分かるので、読みやすいということです。
ここまでは非常に理解しやすかったともいますが、 ここで1つ、Pythonで関数の定義するときに、ハマりやすい穴をご紹介したいと思います。 今日の話を聞いておけば、将来の数時間が節約できる可能性があるので、是非ご覧ください。
まず、Pythonで、似たような2つの関数があります。 一方は、値そのもので、もう一方はリストを使っています。 結果を想像してみてください。それでは、実行してみます。
なんと、同じような関数なのに、2つめのリストを使った関数では、元の変数が破壊されてしまいました。 これは、決して、Pythonの不具合ではありません。Pythonのれっきとした正しい仕様です。 実は、引数には、2種類あって、それを考慮してプログラムを書かないと、このような予期せぬ振る舞いで、バグを踏みます。 これは、Python以外の言語でも同じです。 続きましては、この2つの引数の種類について、詳しく説明したい思います。
値渡しと参照渡し
言語にもよりますが、引数には、値渡しと参照渡しというものがあります。
C言語やC++言語には、ポインタ渡しというものもありますが、アドレスを値として渡しているので、本質的には値渡しです。
そして、先程のPythonでの予期せぬ結果は、Pythonの引数はすべてが、参照渡しなためなのです。 Python的に正確に言うと、オブジェクトの参照渡しになりますが、今回はそれも含めて参照渡しとして、扱いたいと思います。 言語ごとに、同じ言葉でも微妙に概念が違ってくるので、詳細な仕様は、言語ごとの実装を調べてみてください。 C++は、値渡しも参照渡しも両方可能なので、C++で、2つの引数の振る舞いを比較して見ていきたいと思います。
まずは、callByValue関数は、値渡しで引数を渡しています。値渡しは、呼び出し元の値そのものが別の領域にコピーされて引数として渡されるので、関数の中で引数を変更しても、元の変数には影響がありません。 一方で、callByRef関数は、参照渡しで引数を渡しています。参照渡しは、変数に別名をつけて関数の中で参照できるだけなので、物理的には全く同じものを指しています。したがって、関数の中で引数を変更すると、元の変数も影響を受けます。
図解して、もう少し詳しく説明します。 値渡しでは、関数呼び出し時に、新しい変数が作られるので、その変数に何をしようと、元の変数に影響はありません。 一方で、参照渡しでは、呼び出し元の変数を、直接利用します。つまり、参照渡しは変数に別名をつけるだけで、物理的実体は全く同じなので、変数に変更を与えると、元の変数にも影響があります。
先程、Pythonで変数が壊れたのも、Pythonはすべての引数を参照渡しとして実装されているためです。実際にオブジェクトidを見てみましょう。
同じオブジェクトを指していることが分かりました。これが、オブジェクトの参照渡しと言われる所以です。 ただし、Pythonはオブジェクトが、ミュータブルオブジェクトとイミュータブルオブジェクトという2つの種類に分けられていて、見かけ上破壊されないときもあります。 ミュータブルオブジェクトとイミュータブルオブジェクトの違いについては、今回の動画の範囲を超えるので、説明はしません。
いずれにせよ、関数を書くときは、引数がどのように渡されているのかきちんと意識しないと、バグが起きたときに、理解不能なバグになり、時間が溶けてしまうので、必ず意識するようにして置いてください。 続いて、関数がどのように実現されているのか、内部実装についてお話したいと思います。
関数の内部実装
これまでに、ご紹介した値渡しと参照渡しというのは、あくまでC++やPythonのような高級言語のプログラマー目線での話です。 プロセッサーレベルの目線でみると、実はすべて値渡しです。
関数の内部実装に由来するバグというものもあるので、続いて、アセンブリを読み解きながら、内部実装について解説したいと思います。 アセンブリを読むのは、コンピュータの勉強をする上で、非常に良い教材になるので、少し難しいですが、是非続きも御覧ください。
皆さんは、スタックという言葉をご存知でしょうか?スタックは、LIFO(last in, first out)の仕組みの非常に単純なデータ構造ですが、コンピューター科学を語る上で、非常に重要なデータ構造になります。 pushがスタックにデータを格納する操作で、popがスタックからデータを取り出す操作になります。
実は、関数の呼び出し元と呼び出し先の間との、引数や返り値は、スタックを使って、やり取りしています。これは、コールスタックと呼ばれています。 なぜ、このようなことをするかというと、関数というのは、いつどこから呼ばれるか分かりません。したがって、引数に必要なメモリ領域を動的に確保する必要があるのですが、スタックを使うと簡単にこれを管理できます。また、関数は、引数だけではなく、関数の中で使われるオート変数や、関数が終了したら戻るべき呼び元のアドレス、関数が呼ばれる前のプロセッサの状態(レジスタ)の保管・復元もスタックが使われます。
そして、関数が終わるときは、すべてポップして、スタックは元の状態に戻ります。
したがって、関数の呼び出し元と呼び出し先は、一定のルールに基づいて、スタックにデータをプッシュしたりアクセスしたりする必要があるので、それはきちんと規格化されていて、ABI(Application Binary Interface)と呼ばれています。ABIはプロセッサーだけでなく、OSや標準ライブラリに依存します。しかし、同じABIに従っていれば、違うプログラミング言語やコンパイラーでコンパイルされた実行ファイルでも、きちんとこのルールを守っていればリンクできて相互に関数を呼ぶ事ができます。
ここまでの説明だと、抽象的でまだ分からないと思うので、今からx64のコールスタックを、より具体的に解説していきたいと思います。 あなたが普段使用しているPCもおそらくx64だと思います。
実は、x64は、引数のやり取りにスタックだけではなく、レジスタも使っています。これは、スタックがメモリ上にある一方で、レジスタはプロセッサーの中にあり、非常にアクセスが速いためです。メモリは、一般的にDRAMというコンデンサーにデータがあるので、数倍遅いです。引数6個までをレジスタで渡して、それ以降をスタックで渡しています。 スタックには、関数が終了した後に戻るreturn addressもpushされます。関数終了後は、返り値は、EAXレジスタに格納されています。 より具体的に、x86のアセンブリを読み解いてみましょう。
こちらは、8つの引数を使う関数のソースコードとアセンブリの比較になります。 関数の呼び出しの所を見ると、num変数(-8(%rbp))を先ほどのレジスタにコピーしているのが見えます。 残りの、第七引数と第八引数はスタックにpushされています。 43-44行目のレジスタのコピーは、なんなのか私には理解できませんでした。デバッガーで動かしても、レジスタの値は変わらなかったので、実行しなくても、動くと思います。普段私は、x64のアセンブリをそんなに読むわけじゃないので、ご存じの方がいたら、是非コメントで教えてください。 そして、最後にcall命令です。これで、return addressがスタックにpushされます。 次に、関数側を見てみましょう。関数側では、まずは、base pointerをスタックに退避しています。こちらは、関数終了後に、プロセッサーを元の状態に戻すために、一時的に保存しています。これも、先ほど説明したスタックを使う一つの大きなメリットです。
次に、レジスタに保存された引数をスタックにコピーしています。そのあとは、スタックからレジスタに値を一つずつコピーして、和を計算しています。 計算結果は、RAXレジスタに格納されているので、関数終了後は計算結果をRAXレジスタで参照できます。 最後に、スタックからポップした値をベースポイントレジスターに格納しています、これによって先ほど退避したレジスタを復元することが出来ます。最後に、retとすると、popされたreturn addressに戻って、関数の処理が終了します。
以上で、アセンブリを読み解き、関数呼び出しの内部実装を確認ができました。 これで関数の呼び出しでは、実際にスタックが使われていることが理解できたと思います。 最後に、内部実装を意識したうえで、関数を使用する際の注意点があるので、それを解説したいと思います。
関数の注意点
内部実装の説明で、関数を呼び出すときは、スタックにデータをプッシュする必要があるということが分かったと思います。 それを踏まえて、2つ関数を使う上で注意点があるので、それをご紹介したいたいと思います。
まず、関数の呼び出しには、引数をスタックに積むため、ベタ書きに比べて余分な処理時間がかかります。したがって、パフォーマンスの求められる処理では、inline関数やマクロなどを使って実質ベタ書きにすることで、高速化することができます。
もう一つ注意点があって、関数の中でさらに関数を呼ぶと、スタックはどんどん積み上がる事になります。 したがって、特にマイコンのような、メモリが小さい環境だと、関数のネストが深くなると、スタックが溢れて、他の領域を破壊したり、メモリ違反でフォルトを起こして、プログラムが動かなくなったりします。これが、有名なプログラミング質問サイトの名前にもなっている、stack overflowというバグで、プログラムが復旧不能な形で現れる最悪なバグなうちの一つです。
Pythonでも、再帰関数のネストが深すぎるとエラーが出るので、実際に、確認してみましょう。
この関数は、階乗を計算する関数です。 1000!を実行すると、ご覧のように、"maximum recursion depth exceeded"というエラーが出て、プログラムが止まります。 この上限の回数は、実はPythonの設定で変えることができるのですが、 いずれにしても、関数の呼び出しは、ほとんどどんな言語でも、メモリを消費するので、関数を使うときは、ネストが深くなっても、スタックが溢れないか必ず確認するようにしてください。 特に今回のように、再帰関数を使っていると、どれくらいネストが深くなるのか静的にわからないので、品質の保証ができない場合があります。 したがって、マイコンのようなリソースが限られた環境での開発では、使用禁止にしている会社もあります。 これからあなたが、再帰関数を使うときは、スタックの容量を検証して、スタックオーバーフローが発生しないことを確認するようにしてください。 長くなったので最後にまとめをしたいと思います。
まとめ
本日のまとめです。 もしかしたら、プログラミングを勉強中の方は、今日の話を聞いて、こう思ったかもしれません。 「関数を使うだけでも、内部実装を理解していないと、バグが解決できないなんて。難しすぎるよ。。」と。
たしかに、内部実装を理解するのは難しいです。 しかし、逆に言うと、プログラムを書くだけなら、誰にでも、今やChatGPTですら、できます。 プログラムを書けることももちろん重要ですが、それ以上に意図しない挙動を示したとき、それを解決できるかどうかが大事なんです。 内部実装を理解できるようになると、どんなバグでも上流まで遡って、自力で解決出来るようになります。 そうすると、内部実装をろくに理解せずに、APIやライブラリを叩いていて、バグがあったら、OSSコミュニティで文句だけ垂れているような エンジニアとは一線を画す存在になります。 ソフトウェアエンジニアとしては、アセンブリまで、遡れるようになれれば十分かなと思います。
**その分学習コストも高いですが、安心してください。**私のチャンネルでは、ソフトウェアの内部実装を理解するために必要な、コンピュータ関連の技術を原理から分かりやすく解説します。 この動画を見ているあなたは、自力でバグを解決できるようになるかと、不安になる必要はまったくありません。
なぜかというと、こんな関数だけの話を、30分も聞ける、あなたはポテンシャルの塊なんです。 自信を持って好奇心のままに勉強してください。自信を無くして、投げ出してしまう人が殆どです。 5年後には、天地の差が開いています。
なにか分からないことがあったら、ぜひコメントで聞いてください。
自分もまだまだ知らないことはあるので、一緒に勉強していきましょう。 私が高専・東大・スタートアップ・日系大手メーカー・外資ハイテク企業と、、、人生の大半をコンピュータに投下して、学んできた全てをこのチャンネルにぶつけるので、私の動画を見ることによって、他では得られないコンピュータの原理的な考え方が身に付きます。
絶対にできます!一緒に頑張りましょう! 最後に復習をして、本日の動画を終わりにしたいと思います。
復習
本日は、関数・引数・返り値をテーマに、お話をしました。 最初に、関数の使い方として、そもそも引数とは返り値とはどのように定義するのかプログラマー目線で紹介しました。 その後に、引数には、値渡しと参照渡しがあって、それらを適切に使い分けないと、理解不能なバグに時間が溶けるというお話をして、値渡しと参照渡しについて、詳しく説明しました。 その後に、さらに関数がどのように実現されているのか、内部実装についてお話しました。ここでは、コールスタックというものが使われ、ABI(Application Binary Interface)というもので規格化されているというお話をしました。
そして、実際にx64のアセンブリを読み解き、関数の呼び出しに、スタックが使われていることを確認しました。 そして、関数は内部実装のスタックに由来する2つの注意点があり、それが実行時間のオーバーヘッドと、スタックオーバーフローというスタックが溢れるバグであることを説明しました。したがって、関数のネストが深くなるときや、再帰関数を使う場合は、スタックの容量に気をつけてください。
あなたが、次に、関数に関する、バグに出会ったときは、本日ご紹介した内容を思い出して頂ければと思います。 本日の内容は以上になります。ここまでご視聴ありがとうございました。