プリント基板設計・シミュレーション

TOP > アポロレポート > コラム > 第86回 プログラミングについて『いまさらですがC言語の門を叩こう その9』~ 関数 ~
コラム
2024/11/29

第86回 プログラミングについて『いまさらですがC言語の門を叩こう その9』~ 関数 ~

アポロレポート
これまでのC言語の入門の話の中で特に説明もしないで関数を使っていましたが、やっと関数を説明できるところまできました。

 関数とはある機能の単位をひとまとめにしたもので、値を返すものも返さないものもあります。値の返さない関数とはFORTRANなどのサブルーチンと同じものです。何気なく使う printf などというものも関数です。多分どんな方でもC言語の入門のときにはこの関数を使ったことがあるはずです。余談になりますがC言語には画面への入出力やファイルへの入出力といった機能を持っていません。BASICの PRINT や FORTRANの WRITE といったものは言語の機能ですが、C言語の printf は言語の機能ではないのです。
 私が始めて勉強したときに入出力の機能がC言語にはないということを知って非常に驚いたものです。それまでに使った言語はBASICとFORTRANぐらいなものだったのでこの考え方の違いには目からうろこが落ちるというか、カルチャーショックというか不思議な感じでした。
 どうしてC言語には入出力の機能がないのかを考えてみました。まず最初にC言語を作った人たちが面倒くさかった止めてしまったと考えられます。あながち外れてはいないようにも思えるのですが説得力がありません。次に考えられることは、言語の中に入出力の機能がなくてもそれを実行する関数があればいいだろうと安易に考えてしまった。
自分ではこれが一番当たっているように思えるのですが、これもちょっと大きな声では言えません。最後に一番もっともらしいのが、言語には変数の宣言とフロー制御程度のものがあれば良く、それ以外のものは全部関数にしてしまった方が言語がコンパクトにまとまるということです。
 まあ本当に理由はどれでもいいのですが、C言語には入出力の機能がないので他の計算機に移植するとか、他のコンパイラで作成したプログラムを別のコンパイラでコンパイルできるようにするために、コンパイラの作成者に必ず標準で用意しなければならない関数が定義されているのです。これが標準関数なのです。例えば、

 #include <stdio.h>
 #include <stdlib.h>

などというファイルをインクルードすることが頻繁にありますが、この std... というのが、Standard ... というものを省略したもので日本語で言えば『標準 ...』となります。
 ですので、

 stdio.h は Standard i/o
 stdlib.h は Standard library

と言ったものの省略で、日本語では、

 標準入出力
 標準ライブラリ

といったところでしょう。

余談はこれくらいにしておきましょう。

 関数は基本的に定義、プロトタイプ宣言、呼び出しの3つの部分で成り立ちます。定義とは関数そのものの記述でいわゆる関数の本体です。宣言とはコンパイラに対して『この関数はこんなものなんだよ』と教えるためのもので、C言語では必要がなければ省略することができます(C++言語ではダメです)。呼び出しは字の通りに関数を実際に呼び出すことです。

 まず関数の定義について考えてみましょう。関数の定義は次の形式です。

性格 型 関数名(引数リスト)引数の型{文}

 ここで、性格とは(私が勝手に付けた名前ですので悪しからず)、static などの他のソースファイルから参照できないとか、逆に参照できるといったものです。型は整数値を返す関数とか、文字列のポインタを返す関数とかいった関数の戻り値の種類のことです。関数名はそのとおり関数の名前。引数リストは関数が呼び出し側から受け取るパラメータです。このパラメータは必要がなければなにもなくても構いません。
 引数の型は引数リストに記述した引数の型を宣言するもので、K&R仕様のコンパイラのときには必ずこの形式をとりますが、ANSI仕様のものでは引数リストに型も同時に宣言できるようになっています。このへんの詳細は後で述べます。
 そして最後に{}の部分に関数の内容を記述することになります。コンパイラは{}の部分があれば関数の本体の定義とみなし、{}の代わりに ; を記述すると関数のプロトタイプの宣言とみなすようになっています。

 K&R時代のコンパイラは、引数リストに変数の型の宣言はできませんでした。またのようなプログラムがあってもコンパイラはエラーの検出は行ないませんでした。

 sub1(i,s)
 int  i;
 char *s;
 {
  strcpy(s,"Result.");
 }

 sub2()
 {
  int  i;
  double d;

  sub1(i,d);
 }

 この例は、関数 sub2 が関数 sub1 を呼び出すときの第2引数に誤りがあるものです。多分このようなプログラムを実行しようとしればプログラムが異常停止するはずです。K&R時代のC言語はこのような誤りの検出を行なわなかったので、かなりきわどいプログラムが書けたのでこれを好む人もいましたが、殆どの場合は無意識に記述してしってバグの原因になってしまうものです。
 ANSIのC言語を制定するときにこのような不都合を解消するために次のように引数リストの引数の型も同時に宣言できるようにC言語が拡張されました。

 sub1(int i,char *s)
 {
  strcpy(s,"Result.");
 }

 sub2()
 {
  sub1(int i,double d);
 }

 このように記述してある場合、コンパイラは関数 sub1 を読み込んだときに戻り値の型、引数の数と型を記憶しておき、関数 sub2 が sub1 を呼び出してきる部分を処理するときに相違を検出するようになるのです。
もし、

 sub2()
 {
  sub1(int i,double d);
 }

 sub1(int i,char *s)
 {
  strcpy(s,"Result.");
 }

 と、関数 sub1 と sub2 の定義の順番が逆のときにはコンパイラが関数 sub2 が sub1を呼び出す部分の処理を行なうときに関数 sub1の内容が分からないため型や引数のチェックは行ないません。このようにANSIのC言語では、宣言や定義があったときにはチェックを行いないときには行なわないようになったのです。くどいようですが引数のチェックは引数リストに引数の型が同時に宣言されているときにだけ行われることに注意して下さい。
 
このようにどっちつかずにしたのは、K&R時代のC言語の柔軟さをそのまま残さざるを得なかったからなのです。K&R時代のプログラムでもそのままコンパイルできるようにすることが目的だったとは思いますが、やっぱりきわどいプログラムを書きたい人たちの要望があったことも否定できないと思います。
 上の例では関数 sub1 の定義が後になっているので関数 sub2 内からの呼び出し時にコンパイラが検査を行なえませんので、このままではプログラムは main 関数がリストの最後にしなければならず、ソースファイルを分割して作成することができませんので、関数のプロトタイプの宣言内でも引数リストの記述を行なうことができるようになっています。
 K&R時代のC言語でのプロトタイプの宣言は、

 int sub1();

といった関数の戻り値の宣言しかできませんでしたがANSIでは、

 int sub1(int i,char *s);

 と引数リストも宣言できます。この場合は引数の名称は何も意味を持ちませんので、

 int sub1(int,char *);

と宣言してもコンパイラは文句を言いませんが、プログラムが理解しづらくなるので引数の名称も記述することをお勧めします。引数が何もない関数の場合は、

 int sub1();

としたのではK&R時代の記述方法だとコンパイラに解釈されてしまいますので、

 int sub1(void);

と引数リストに void を指定して引数が何にもないことを教えます。

 K&RからANSIへとC言語も少しずつ進化し、強力な言語になってきました。これからも進化すると思われますが、その1つの方向がC++言語です。ちょっと余談ですが、C++言語では関数を呼び出すときには必ず呼び出される関数の本体の定義かプロトタイプの宣言が行われていなければいけません。そして引数リストもANSIのC言語の記述方法しか許されていないのです。K&RのC言語に慣れた方にはいちいち宣言を行なうのは苦痛だとは思いますが、このようになったおかげでプログラムの単純なミスがコンパイル時に検出できるので実際にプログラムを作ってみると意外に重宝しています。ただ、特急で数回しか使わない500行程度のプログラムを書かなければならないときには時間がもったいないので、C++は使わずにK&R方式でプログラムを書くことの方が多いのも事実です。要するに時と場合によって使い分ければいいのです。

 話しを本題に戻しましょう。

 関数は戻り値を持つことはお話ししました。戻り値はそのまま変数に代入したり、そのまま他の関数の引数にしたり、そのままIF文などで値を検査することに使用できます。ここまでは多分理解できると思います。
 次に呼び出した関数に渡した引数の値をその関数に変更してもらいたいときにはどうすればいいのでしょうか。C言語は基本的に配列はアドレス渡し、それ以外は値渡しで引数を渡します。FORTRANに慣れた方には値渡しというのがなかなか馴染めないものではないでしょうか。FORTRANはどんなものでも引数はアドレス渡しで行うのです(特殊な例外はありますが)。ですので、

 CALL SUB(I)

 PRINT *,I

   :
   :

 SUBROUTINE SUB(I)

 I=10

 RETURN
 END

 と記述すれば、変数 I の内容はサブルーチン SUB によって簡単に変更することができてしまいます。簡単にできるということはこれはこれで良い事なのですが、別の面から考えると、不用意に変更してしまう可能性があるということなのです。ということはFORTRANでプログラムを作るときには値を変更してはならない引数を変更してしまってとんでもない(つまらない)バグを引き起こすことが少なくありません。

 こうした不便と、関数呼び出しのときの引数の値を変更する確率が低いことからC言語では値渡しにしたのだと思われます。

 i=5;

 sub(i);

 printf("%d\n",i);

   :
   :

 sub(i)
 int i;
 {
  i=10;
 }

 C言語では上のように値渡しで引数を渡したとき、呼び出された側の関数内でいくらその内容を変更しても呼び出し側の値は変りません。
 ここで少し不思議に思いませんか。値渡しということは上のプログラムは、

 sub(5);

 printf("%d\n",5);

   :
   :

 sub(5)
 int 5;
 {
  5=10;
 }

ということになるようになることになり、なんだか変なことになってしまうように感じませんか。もしこのようになっていたのであれば本当に変なことになってしまいますが、実際にはこのようなことにはなっていないのです。値渡しをするとは言っても、実際に値を渡すのではなく、メモリー上に値を入れる領域を確保してそこに渡し値を代入して、そのアドレスを渡しているのです。呼ばれた方はそのアドレスにある値をそのまま使用するという仕組みなのです。呼び出した関数から処理が戻ったときには引数用に確保したメモリーは消去されてしまうので結局は呼び出し側は何も影響を受けないのです。

 もし、呼び出した関数に値を変えてもらいたいときには、変数のアドレスを渡さなければなりません。アドレス渡しのときでも値渡しのときと同じく、引数としてアドレスを渡すためにアドレス値を代入するためのメモリーが確保され、そこに値ではなくアドレスを代入するのです。そしてその引数用に確保したメモリーのアドレスを関数に渡し呼ばれた方はそのアドレスに代入されているアドレスの値を参照または変更するという仕組みになるのです。

 値渡しとアドレス渡しの違いを図にして見てみましょう。

 sub(i);                 sub(&i);

  :                    :
  :                    :

 sub(i)                  sub(i)
 int i;                  int *i;
 {                    {
  i=5;                   *i=5;
 }                    }

               <呼び出し側>

 i   のアドレス □□□□      i   のアドレス □□□□ ←────┐
           │                 │       │
          i の値を代入          i のアドレスを代入   │
           ↓                 ↓       │
 引数用のアドレス □□□□      引数用のアドレス □□□□      │
           │                 │       │
     引数のアドレスを関数に渡す     引数のアドレスを関数に渡す │
           ↓                 ↓       │
                                     │
              <呼ばれた関数側>               │
                                     │
 引数用のアドレス □□□□      引数用のアドレス □□□□      │
           ↑                 │       │
           │                 └───────┘
         5 を代入する                 5 を代入する

ということになります。図が下手なので理解しづらいかも知れませんが、引数の渡し方はどちらも同じで、引数に何が入っているかが違っています。さらに呼ばれた方はその引数をそのまま扱うのか、引数の示しているところを扱うかが違っているという2つの違いがあるのです。

 最後に、関数が自分自身を呼び出すことについてお話しします。関数が自分自身を呼び出すということは簡単にプログラムを書くと、

 sub(i)
 int i;
 {
  sub(i);
 }

ということです。もっともこのように書いてしまえば、無限に呼び出して最後にはエラーを起こしてしまいますので戻りかたを工夫しなければなりません。

 関数が自分自身を呼び出すことを再帰呼び出しと言います。FORTRAN77以前のFORTRANはこれが許されていなかったのですが、

 SUBROUTINE SUB1

 CALL SUB2

 END

 SUBROUTINE SUB2

 CALL SUB1

 END

というのはコンパイラはエラーを検出しませんので、FORTRANで間接的に自分自身を呼び出すことを以前いろいろと研究してみたのですが、どうしてもうまくいきませんでした。FORTRANの変数がすべて静的なものだということが原因だったのです。これもFORTRAN90ではちゃんと再帰呼び出しを行なうことができるようになっています。

 再帰呼び出しを使わなくてもどんなアルゴリズムも表現することができることを忘れてはいけませんが、単純なアルゴリズムでも非常に面倒になることがあります。
 いま、2分木のデータがあったとします(構造体はまだ説明していませんが、雰囲気で読んで下さい)。

 struct node_strct{
  int        value;
  struct node_strct *left ;
  struct node_strct *right;
 };

 struct node_strct *node;

 いまこのデータが次の図のようになっているとします。

            node
            ↓
            5
          ┌─┴─┐
          4   6
         ┌┴┐ ┌┴┐
         2     8
        ┌┴┐   ┌┴┐
        1 3   7 9

 node が木の根である 5 の構造体を指し、その left は 4 の構造体を、right は 6の構造体を指し示しており、さらにそれぞれの木の枝の left は自分より小さいもの、right は大きいものを指し示しているとします。指し示していないときには NULL とします。このとき、値の小さい順に表示しようとしたとき、再帰呼び出しを使用しないでプログラムを書くのは結構骨の折れることです(ここでは大変なので紹介しません)。
 これを再帰呼び出しで書くとうれしいくらいにスッキリします。

 display(node);

    :
    :

 display(node)
 struct node_strct *node;
 {
  if(node){
   display(node->left);
   printf("%d\n",node->value);
   display(node->right);
  }
 }

 これだけなのです。この display の内容は left を書いて、自分の値を書いて、最後に right を書くというだけのもので、アルゴリズムとしてはそのものズバリといった感じです。もしこのことを再帰呼び出しを使わずに行なうとすれば、関数内にスタック用の変数を用意しておき、left あるいは right があったときには現在のものをスタックに保存しておき書きおわったらスタックを1つ戻すといった順で処理していかなければなりません。

 なにがなんでも再帰呼び出しがいいとは言えません。再帰呼び出しの入れ子が深くなりすぎるとメモリーを消費し過ぎてプログラムが異常停止してしまう可能性もありますので注意が必要ですし、速度が要求されるときには再帰呼び出しを使わない方が有利なことの方が多いと思われます。

 それではまた次回。

そのお悩み、
アポロ技研に話してみませんか?

アポロ技研でワンランク上の物創りへ!
そのお悩み、アポロ技研に話してみませんか?