見出し画像

C言語の for ループにみる副作用

先日、プログラミングにおける「副作用」について記載しました。

その際、次のようにも申しました。

『実は、C言語の for ループは副作用があると言われています。』

今回はその『C言語の for ループにみる副作用』について記述しようかと思います。


C言語の for 文の例

例えば、n の階乗(n!)を求める関数です。

int fact (int n)
{
    int i;
    int f = 1;
    for (i = 1; i <= n; i++)
    {
        f *= i;
    }
    return f;
}

1 から n まで、全部掛け合わせました。

前回、「変数に値を代入する」ことが副作用だと申しました。そうしますと、このコードは副作用だらけということになります。「i」も「f」も変数です。しかも、その2つの変数ともに、これでもかというくらいに変更しています。それは全て堂々たる「副作用」。

え?
大げさすぎる?

そう感じることもあるかもしれません。
C言語でこのような for 文は何度もお目にかかります。『ループカウンタの「i」などは「1 から n まで変化しているだけ」だし、「副作用」とは言い過ぎでしょう』。そんな気もしますよね。でも、やはり副作用です。

それに、このループ変数。
この例では、もちろん、単純に 1 から n まで変化しているだけですが、途中で変更することは簡単です。

「偶数の階乗」が必要になったときに、こんな風に書く人がいるかもしれません。

int fact_e (int n)
{
    int i;
    int f = 1;
    for (i = 1; i <= n; i++)
    {
        if ((i & 0x01) != 0)
        {
            i++;
        }
        f *= i;
    }
    return f;
}

え………

えーーーーっ!
っていう感じですよね。
n が奇数だったら、ややこしいことになりそうです。

途中でループカウンタを変更するなんて、そんなコード見たことないって?
そんなあなたは幸せ者です。ループカウンタを途中で変更するようなコードはままあります。いくつかの静的解析ツールでは「ループカウンタを変更していなかどうか」をチェックするものさえあります。逆に言うと、「望まれない書き方だけどそれでも書く人がいる」ということでもあります。

このコードの是非はともかく、知りたかったことは、変数を変更するとコードはどんどん複雑さを増していくということです。


副作用を回避するのはやぶさかではないが・・・

副作用がないに越したことがない、ということはわかりました。
では、この階乗プログラム。
副作用を回避する手段などあるのでしょうか。

それが、あるのです。
再帰呼出を使います。

再帰呼出を使うとどうなるのか。

こうなります。

int fact_r (int n)
{
    if (0 < n)
    {
        return (n * fact_r(n-1));
    }
    return 1;
}

うーむ。
見事に変数がなくなりました。

これねぇ。
ホンマに驚きました。
この30年間、C言語で書いてきて「再帰呼出は書くな書くな」と、そう言われ続けてきたのですから。「え!再帰呼出使うん!?」っていう感じでした。

ちなみに、先の for ループによる書き方を「手続き型」、この再帰呼出による書き方を「関数型」と称するのだそうです。「関数型」と聞いた場合、C言語は関数の定義が基本ですから「C言語
関数型」と思いがちなんですが、C言語は「関数型」ではありません。「手続き型」の言語です。「関数型」を実現するための最大の難所が「スタック」にあるからです。

C言語で再帰呼出が煙たがられるには理由があります。まかり間違うと「スタックオーバーフロー」に遭遇しかねないからです。


C言語とスタックの関係

C言語では、頻繁にスタックを使用します。

C言語とスタックの関係についてはこちら。

スタックが何かについてはこちら。

C言語において、スタックは頻繁に使用されます。
関数を呼び出す場合は・・・
引数を次々にスタックに放り込み
リターン時の戻り先をスタックに積み上げて
しかる後、関数を呼出します。

呼び出された関数側でも更にスタックを使います。
オート変数はどんどんスタック領域に確保します。

関数から戻ってくるときにはスタックのポインタを戻します。こうすることで、引数、戻り先、オート変数、全てが巻き取られて領域が解放されることになるのです。


C言語と再帰呼出

C言語で再帰呼出を使用する場合、再帰的に呼び出す度にスタックを消費します。毎回、引数戻り先オート変数をスタック領域に確保していくことになります。一方で、スタックのサイズは有限です。アプリケーションを起動する時にOSから「あなたのスタックはここから何バイト」という風に割り当てられます。それを越えてしまうとき、「スタックオーバーフロー」となります。この場合、異常終了となり、それ以上は実行してくれません。というより実行できません。

「このプログラムでは何バイトくらいのスタックが必要か」

プログラムを設計するときにはそれも検討する必要があります。C言語の場合は関数を呼び出す毎にスタックを消費します。関数呼出しのネストが深いとたくさんのスタックが必要となります。それでも、普段はあまり意識する必要もありません。スタックは比較的潤沢に与えられるからです。

ところが。

再帰呼出となると話は違います。

関数呼出しのネストの深さがプログラミングの設計時点に静的に決まるのではなく、実行時に動的に決まるからです。実行してみないと再帰呼出の深さがわからないことが困難を増大させます。このため、C言語では、余程慎重に設計された再帰呼出しか許容されません。

では。

この「関数型」による設計実装は不可能なのでしょうか。

結論から言うと、C言語を使って「関数型」を駆使し「副作用」の撲滅に立ち向かうことはできません。「手続き型言語」であるC言語で実現するにはプログラマーの荷が重すぎるからです。


関数型プログラミングを目指した言語

実は、「関数型」を目指したプログラミング言語があるのだそうです。

純粋関数型を目指した「Haskell」。

副作用」や「関数型」を検索するとよく目にします。設計はわりと古く1990年頃。私は不勉強にして初めて知りました。少しマイナーな言語で、プログラミング言語そのものの研究対象になることが多いようです。

手続き型言語だけでプログラムしてきた私として興味がわくのですが、なかなか時間を作ることも難しい。リタイアしてから勉強しますか(笑)。

理想のプログラミングを目指して「関数型プログラミング言語」を勉強してみるのは如何でしょうか。


この記事が気に入ったらサポートをしてみませんか?