入門書でのおさらい26・インライン関数、番兵法、for文のおさらい

今日は164ページから勉強します

165ページ・インライン関数

関数の定義にinlineを前置きすると高速処理になるの?なぜこれをつけるの?

はい、結論から言うと「関数を呼び出すオーバーヘッド(手間)を無くして、処理を速くするため」につけます。

なぜこれをつけると速くなるのか、イメージで説明しますね。

普通の関数(出張スタイル)

普通の関数は、呼び出されるたびに「今の作業を中断して、別の場所に保存されている関数の処理までジャンプし、終わったら戻ってくる」という動きをします。

  • デメリット: この「行って戻ってくる」というわずかな時間が、何度も繰り返されると積もり積もって無視できない遅延(オーバーヘッド)になります。
インライン関数(コピペスタイル)

inline をつけると、コンパイラは関数を呼び出すコードの部分に、関数の内容を直接「コピペ」して埋め込んでくれます。

  • メリット: 「行って戻ってくる」時間がゼロになります。
  • 結果: 処理が高速になります。
なぜ全部に inline をつけないの

「全部インラインにすれば最強じゃない?」と思うかもしれませんが、デメリットもあります。

  • プログラムが巨大化する: あちこちにコードをコピペするので、実行ファイルのサイズが太ってしまいます(おデブなプログラムになります)。
  • 複雑な関数には向かない: ループや再帰が含まれるような長い関数をインライン化すると、かえって効率が悪くなることがあります。
どんな時に使うの?

162ページ付近の「探索」などの処理で、「中身は1、2行でシンプルだけど、何万回も繰り返し呼び出される」ような関数に使うのが最も効果的です。

今の段階では、「小さい関数を速く動かすための、ちょっとした魔法の呪文」くらいに思っておけば大丈夫ですよ!

ここまではよくわかりました。関数の結合性が通常とは異なると注意が必要とはどういう意味なんでしょう??

C言語において「関数の結合性が通常と異なる」というのは、専門用語で言うと「外部結合ではなく内部結合(あるいはインライン定義)になる」という、コンパイルの仕組み上のルールのことを指しています。

ざっくり言うと、「その関数をどこ(どのファイル)に書くべきか」というルールが変わってしまうという意味です。

普通の関数のルール
  • 場所: .cファイル(ソースファイル)に書きます。
  • 結合: 「外部結合」といって、他のファイルからも名前が見える状態です。もし複数のファイルに同じ名前の関数を書くと「重複してるよ!」とエラーになります。
インライン関数のルール(ここが特殊!)
  • 場所: 通常、.hファイル(ヘッダファイル)に定義を書きます。
  • 理由: inlineは「呼び出し箇所にコードをコピペする」機能なので、コンパイラがコピペ元(関数の定義)を常に知っている必要があります。
  • 結合性の違い: ヘッダファイルに書いてあちこちの.cファイルで読み込んでも、通常の関数のときのような「名前の重複エラー」が起きないように特別な扱い(インライン定義)になります。
注意が必要な理由

もし、インライン関数を「普通の関数と同じ感覚」で.cファイルにだけ書いて、他のファイルから呼び出そうとすると、コンパイラが「コピペしたいのに中身が見つからない!」と混乱してエラーを出すことがあります。

つまり、「インライン関数を使うなら、定義(中身)ごとヘッダファイルに書かなきゃいけない」という、普通の関数とは逆の作法が必要になるため、「注意が必要」と言われるのです。

えーなんかよくわかんなかったけど、まあ入門編ではふんわりでいいみたいなのでふんわり頭に入れておくね

正直なところ、「インライン関数」は、C言語の入門段階で完璧に理解する必要はありません。 162ページの線形探索(リニアサーチ)のように、アルゴリズムの仕組みを理解することの方が100倍大事です。

今のところは、

  • 「普通の関数よりちょっと速く動く工夫があるんだな」
  • 「でも、使い方に少しクセがあるんだな」
    というイメージさえ持っておけば、コードを書く上で困ることはありません。

番兵法

次は番兵法です

いよいよ真打ち登場ですね!「番兵法(ばんぺいほう)」は、プログラミングの「ちょっとした工夫で劇的に効率を上げる」面白さが詰まったアルゴリズムです。

先ほどの162ページのコードと見比べると、その凄さがわかります。

番兵法の「賢い」ところ

先ほどのコードでは、ループのたびに2つのことをチェックしていましたよね。

  1. 「最後まで行っちゃったか?」(i == n
  2. 「見つかったか?」(v[i] == key

番兵法は、これを「見つかったか?」の1つだけに減らすテクニックです。

どうやるの?(イメージ)
  1. 配列の最後に、「探したい値」をあえて自分でおまけとして追加します。これが「番兵(見張り)」です。
  2. そうすると、絶対に「見つかる」ことになります(自分で入れたから)。
  3. ループが終わったあと、「見つかった場所が、最後に追加した番兵の場所かどうか」を確認するだけで、本当にあったのか、最後まで無かったのかが判断できます。

list6-4を勉強したけど正直「if(i == n) return FAILED;」がなくなっても三項演算子で審査するじゃん?どっちのほうが早いの?

結論から言うと、番兵法(三項演算子を使ったコード)の方が速いです!

なぜ「最後に三項演算子で審査する」方が速いのか、その理由は「ループの中の忙しさ」にあります。

前のコード(List 6-3)のループ内

毎回、マラソンの途中で2回も立ち止まって確認しているイメージです。

  • 「ここはゴール(n番目)かな?」(if (i == n)
  • 「探してる人(key)かな?」(if (v[i] == key)
    これをデータが1万個あれば、1万回繰り返します。
番兵法(List 6-4)のループ内

走っている最中は、1回しか確認しません。

  • 「探してる人(key)かな?」(if (v[i] == key)
    「ゴールかどうか」の確認を完全にサボっています。 なぜなら、最後に必ず自分が置いた偽物(番兵)がいるので、いつかは絶対に止まれると分かっているからです。
三項演算子の役割

三項演算子での「審査」は、ループが終わった後に「たった1回」だけ行われます。

  • List 6-3: 「ゴール確認」を n回 やる
  • List 6-4: 「ゴール確認(三項演算子)」を 1回 だけやる

この「n回 vs 1回」の差が、データの数が多くなればなるほど、大きな速度差になって現れます

なるほど。ループの回数が多くなる(NUMBERの数が多くなる)ほどたとえば10万とかだと「if(i == n) return FAILED;」が10万回分ループの中で立ち止まるのでループの中の処理はなるべく少なくして、ループ出た後に判別しましょうという意図でいいかな?

その通り!完璧な理解です。

まさに「10万回立ち止まるのをやめて、最後に1回だけ確認する」という引き算の発想ですね。

1回1回のチェックはCPUにとって一瞬の出来事ですが、チリも積もれば山となります。特に、今回のように「ただ探すだけ」という単純なループであればあるほど、その「1回余計なチェック」が占める割合が大きくなるので、番兵法の効果が目に見えて出てきます。

こういう「ループの中をいかに軽くするか」という考え方は、C言語らしい高速化の醍醐味です。

「if((idx = search(x, ky, NUMBER)) == FAILED)」の一文も、代入した物を判定するという書き方でちょっと難しいですね。()のつけ方で間違えました

本当にそうですよね。あれはC言語に慣れた人でも「カッコ、どこまでだっけ?」と一瞬迷う、初心者キラーな書き方です。

なぜあんなにややこしい書き方をするのか、少しだけ分解して整理しておきましょう。

なぜカッコが重なるのか?

C言語には「優先順位」という厳しいルールがあるからです。

  1. == (比較) は、= (代入) よりも力が強い。
  2. だから idx = search(...) == FAILED と書くと、コンピュータは先に search(...) == FAILED を計算してしまいます。
  3. その結果(正しいかどうかの0か1)を idx に入れちゃうんです。

これを防ぐために、「まずは代入を先にやってくれ!」という強制のカッコ (idx = ...) が必要になります。

初心者のうちは無理しなくてOK!

実は、この一文は無理に1行で書かなくても、次のように2行に分けて書いても全く問題ありません。

idx = search(x, ky, NUMBER); // まず代入する
if (idx == FAILED)           // その後に判定する

この方が圧倒的に読みやすいですよね。教科書でああいう書き方を紹介しているのは、C言語では「代入した結果をそのまま判定に使う」というテクニックがよく使われるので、今のうちに目を慣らしておいてね、という意図だと思います。

search関数内のwhileをforに書き換える例題で勘違いしてしまいました

教科書のlist6-15は、先ほどのwhile(1)のコードと全く同じ動きをしますが、「C言語らしさ」がさらに凝縮されています。

ループの中身が「空(セミコロンのみ)」
for(i = 0; v[i] != key; i++)
    ;  // 何もしない!

for文のカッコの中で「iを0にする」「見つかるまで進める」「iを増やす」という仕事が全部終わっているので、ループの中で実行する命令が何もないんです。これを空文(くうぶん)と呼びます。

継続条件 v[i] != key

「探している値じゃない間は、ずっと隣へ進め」という指示ですね。番兵(v[n])を置いているおかげで、ここでも「最後まで行ったか?」のチェックをサボれています。

この書き方で一番多いミスは、for文のあとの ;(セミコロン)を忘れることです。
もしセミコロンを忘れて、その下に別の命令を書くと、その命令がループの一部だと思われて何度も実行されてしまいます。

わたしこうかいちゃったの

for(i =0; i < n; i++){ if(v[i]==key) break;}

あなたが書いたコードは「番兵を使わない書き方」です。
番兵法の授業の文脈だと、先生(あるいは教科書)は「ほら、番兵を置けば i < n というチェックすら省略できるんだよ。凄いでしょ!」という「裏技」を教えたかっただけなんです。

「自分ならこう書く」という感覚はプログラミングで一番大事です。その感覚を保ったまま、教科書の「ひねった書き方」を「へぇ〜、そんなズルい方法もあるのね」と眺めるくらいがちょうどいいですよ!

わたしのだと一番最初のプログラム。whileで if( i==n)を判定してるとき(2度立ち止まる)のと変わらないんじゃない??

まさにその通りです!大正解。

あなたが書いた for(i = 0; i < n; i++) という書き方は、実は頭の i < n の部分で毎回しっかり立ち止まって「最後まで来たかな?」とチェックしています。

つまり、動きとしては最初の while 文の時と全く同じ(2回立ち止まる)状態です。

比較してみるとよくわかります
  • あなたの for 文:
    1. i < n かチェックする(立ち止まり1
    2. v[i] == key かチェックする(立ち止まり2
  • 教科書の番兵用 for 文:
    1. v[i] != key かチェックする(立ち止まり1のみ!
なぜ「番兵」なのにあなたの書き方だと意味がないの?

「番兵法」の最大のメリットは、「どうせ最後に偽物(番兵)がいるんだから、途中の i < n(終わりかどうかのチェック)はサボっちゃえ!」という点にあります。

あなたが書いたように i < n を判定に入れてしまうと、せっかく番兵を置いたのに、結局真面目に毎回終わりを確認しているので、番兵を置いた意味(高速化)がなくなってしまうんです。

「番兵を置くなら、i < n は書かない」
これがセットだと考えると、番兵法の「ズル賢さ」がより際立ちますよね。

納得できましたか?この「極限まで無駄を削る」感じがわかると、C言語がちょっと楽しくなってきます。

折角番兵法でどこまでループ回すかの判定を削除してるのにまたfor文にしたときに条件に置いちゃったら元も子もないですよね。for文にするときもi < nの判定を省いて書かないといけなかったんですね。すごく頭がこんがらがります。

まさに、そこが番兵法の最大の落とし穴であり、一番面白いところです!

for文といえば i < n を書くもの」という習慣が染み付いているので、それをあえて「書かない」というのは、最初はすごく勇気がいりますし、頭がこんがらがりますよね。

混乱を整理するために、番兵法でのfor文の書き方を「公式」のように見てみましょう。

番兵法での for 文の作り方

普通のfor文から、思い切って真ん中を削るイメージです。

  • 普通: for (i = 0; i < n; i++)
  • 番兵: for (i = 0; v[i] != key; i++)

というかfor文こんな書き方出来たんだって言う驚きもありました。for文の定義もう一度教えて。真ん中の式はなにだったっけ?

その驚き、よくわかります!「for文=決まった回数を数えるもの」というイメージが強いと、番兵法の書き方は魔法みたいに見えますよね。

for文の定義(構造)をもう一度おさらいしましょう。

for文の正体

実は、for文の真ん中は「回数」ではなく、「継続条件(ここが正しい間は繰り返すよ!)」というルールになっています。

for ( ①初期化 ; ②継続条件 ; ③更新 )
{
    // 繰り返したい処理
}
  1. ① 初期化:ループを始める前に1回だけやること。(例:i = 0
  2. ② 継続条件:ここが「真(成立)」である限り、ずっとループを続ける判定。(例:v[i] != key
  3. ③ 更新:1回のループが終わるたびに毎回やること。(例:i++
なぜ番兵法の書き方ができるのか?

普通のfor文は、②の部分に i < n(上限まで行ったか?)を書きますが、ここは「条件式なら何でも書いていい」というルールなんです。

だから、番兵法のように
「今見ている要素が、探している値じゃない(v[i] != key)なら、次へ行け!」
という条件を直接書き込むことができます。

おまけ:もっと変な for 文も作れる

このルールを知っていると、こんなこともできます。

  • for ( ; ; ):全部省略すると「無限ループ」になります(while(1)と同じ)。
  • for (i = 100; i > 0; i--):カウントダウンもできます。

「真ん中は回数制限じゃなくて、GOサインを出し続けるための条件判定なんだ」と考えると、一気に自由度が上がりますよ!

さて、この「真ん中の式」の正体がわかったところで、次の多次元配列(167ページ〜)へ進む準備はバッチリですか?

正直習ったはずだけど忘れちゃってて、とくに真ん中の定義。初期化の数値から真ん中の数までループ回そうっていう風にしか使ったことなかったから回数が指定できるループだと勘違いしてました

その勘違い、実はプログラミング学習者にとって「誰もが通る道」なので安心してください!

学校の授業や入門書の最初の方では、必ずといっていいほど i < 5(5回まわす)のような書き方ばかり練習します。だから、「真ん中は回数」と脳が覚えてしまうのは当然なんです。

でも、今回の番兵法で「真ん中は『YesかNoか』の判定さえ合っていれば何でもいいんだ!」と気づけたのは、初心者から一歩抜け出した証拠ですよ。

for ( ①初期化 ; ②継続条件 ; ③更新 )
② 継続条件:ここが「真(成立)」である限り、ずっとループを続ける判定。をたたきこみました!

その意気です!「真(Yes)である限り、突き進む」。この本質を掴んだのは本当に大きいです。これさえ分かっていれば、今後どんなに複雑なループが出てきても怖くありません。