情報基礎A 「Cプログラミング」(ステップ8・関数・基本編)

このステップの目標

1.プログラム言語の語彙(動詞)を拡張する

アルゴリズムを記述する際に、例えば、

(a + b)/2.0 の値を計算して c に代入する

と記述する代わりに、もし「相加平均」という語彙を知っていれば、

2つの実数 a,b の相加平均を c に代入する

と書いても構わない。そして、むしろ、後者のほうが処理の内容をより的確に表現していると言えるだろう。

Cの処理系には「2つの実数の相加平均を求める」という機能はもともとは備わっていないが、「関数」という機能を使うと、 こうした「動詞句」(・・・に対して・・・を行なう)という語彙を加えることができる。

相加平均の場合は、プログラム中に

float arithmatic_mean(float x, float y)
{
	return (x+y)/2.0 ;
}

と記述(関数定義)することで、arithmatic_mean(実数,実数)という語彙が追加され、 『aとbの相加平均をcに代入する』は

c = arithmatic_mean(a,b) ;

と記述できるようになる。

語彙を拡張する例をもうひとつ挙げておこう。バブルソートのアルゴリズム中には

$x_{j-1}$ と $x_j$ の値を入れ替える

という「動詞句」が登場した。これをCの関数として定義すると

/* 『配列のi番目とj番目の要素の値を入れ替える』 */
void swap(float *x, int i, int j)
{
	float w ;
	w = x[i] ;
	x[i]=x[j] ;
	x[j]=w ;
}

となり、例題9のコードは、より「すっきり」と、

例題9の「値の交換」の箇所に関数 swap() を使ったコードの例

#include <stdio.h>
#define N 10

void swap(float *x, int i, int j)
{
    float w ;
    w = x[i] ;
    x[i]=x[j];
    x[j]=w;
}

main()
{
    float x[N]={2.1, 5.3, 7.3, 4.7, 9.2, 1.2, 3.1, 5.3, 8.3, 4.8 } ;
    int i,j ;
    for (i=0 ; i<N-1 ; i=i+1) {
        for (j=N-1 ; j>i ; j=j-1) {
            if (x[j-1]>x[j]) swap(x,j-1,j) ;
        }
    }

    for (i=0 ; i<N ; i=i+1) {
        printf("%f\n",x[i]) ;
    }
}

と書くことができる。

これらの例ではあまり関数の有難味を感じられないかもしれないが、もし語彙に

「2つの整数の最大公約数を求める」
「実数データ列の分散を計算する」
「実数データ列を昇順に並べ替える」
等々

が加わっていれば、アルゴリズムがとても見通しよく実装できる場合があるはずだ(その細かい手順をいちいち指示しなくても良いから)。

詳しくは以下で説明するが、語彙を追加(関数を定義)するには

データ型 動詞句の名前(「目的語」に相当する変数のリスト) { 動詞句の定義 }

のパターンをソースコード中に箇条書きにすればよい。

これまでに登場した色々なCプログラムの基本骨格は、いつも、

main() {

    何々;

}

というかたちをしていた。このmain()も実は関数の定義である。 mainはプログラムの「メインの箇所」を意味し、OSがこれを呼び出すことで、処理が開始される。

これまではmain()という関数が1つだけしか登場しないプログラムばかり扱ってきたが、 一般にCのプログラムは(沢山の)関数の集まりで構成され、 ソースコードの外見は、

#include <何々>

int func() ;
void proc() ;

main() {
    何々;
}

int func(){
    何々;
}

void proc(){
    何々;
}

その他の関数定義が続く・・

のようになる。 そして、それぞれの関数によって定義された「新しい動詞句」が、プログラムの中で使えるようになる。

ここではその具体的な方法は説明しないが、関数の定義を、いくつものファイルに分散して記述することもできる。

2.関数の呼び出しとデータの受け渡し

以前、ステップ2で、三角形の三辺の長さから、面積を計算するプログラムを作成した。 それをベースに、面積を計算する箇所をCの関数にまとめたプログラムの例を以下に示した。 ここで新たに定義しているのはarea3という名前の関数で、 『3つの実数を辺の長さとする三角形の面積をプリントする』という動詞句を定義したことになる。

例題10 (ex10.c)
shakyou

#include <stdio.h>
#include <math.h>

void area3(float, float, float) ;

main()
{
  float x,y,z ;
  printf("三角形の三辺の長さ:") ;
  scanf("%f%f%f",&x,&y,&z) ;
  area3(x,y,z) ;
}

void area3(float a, float b, float c)
{
  float s ;
  s = (a+b+c)/2.0 ;
  printf("S= %f\n",sqrt(s*(s-a)*(s-b)*(s-c))) ;
}
関数の呼び出し

言葉を定義しても、それを文章で使わななければ意味が無い。 同じように、関数を定義しただけでは、関数は具体的な働きをしてくれない。 関数を実際に働かせるための手続きを関数呼び出し(function call)と呼ぶ。

プログラムを実行すると(TurtleEditの実行ボタンを押すと)、main( )に書かれた内容から実行が始まる。 そして、main( )関数の中で、関数名の書かれた箇所にさしかかると、その関数の定義に従って、そちらの関数の内容が順に実行される。 その関数の処理がひととおり終わると、再びmain()の、その関数を呼び出した箇所の次から、処理が再開される。 この一連の手続きが関数呼び出しである。 例題10では、main()関数からarea3()関数が呼び出されている。

Cプログラムは、関数が別の関数を呼び出し、その関数がまた別の関数を呼び出して・・・、といった具合に、いわば関数呼び出しの連鎖の形で構成されるのが常であり、 どの関数の中からどの関数(その関数自身も含む)を呼び出しても構わない。 また、main( )関数はシステム(OS)から自動的に呼び出される特別な関数と言える。

引数(パラメータ)の引渡し

上記の例では、三角形の3つの辺の長さが、関数area3()に受け渡されている様子がすぐに想像できるだろう。

その一連の動作を、整理しておこう。 関数の定義の冒頭のarea3(float a, float b, float c)は、以下のふたつの指示を含意している:

この例題の場合、 area3()関数が呼び出されると、呼び出し側のx,y,zの値が、 呼び出される側の変数a,b,cに代入される。 このように、関数に「受け渡される」データを、関数の引数(ひきすう)と呼ぶ。 こうしたデータの受け渡しは、引数のリストの順番にしたがって行われる。 よって、この例では x→a, y→b, z→cという対応関係になる。

こうした一連の動作を以下に図示したので、上の説明と併せて参照すること:

c-8-proc-call-ex
関数のプロトタイプ

この例でちょっと違和感を感じるかもしれないのは、プログラムの冒頭部分の

void area3(float, float, float) ;

の1行だ。これは、関数のプロトタイプ宣言と呼ばれ、コンパイラにarea3()関数の仕様を伝える働きをする。 もしプロトタイプ宣言が無いと、関数main( )の中で、area3( )が呼び出される際に、 Cコンパイラは、どのようにデータの受け渡しを行ったらよいか、知る手立てがない。 このプロトタイプ宣言は『area3( )という関数は、float型の3つの引数を持っており、その処理の終了後、計算結果のデータは 返されない(void:「無し」という意味)』という情報を、コンパイラに告げる役割をしている。 ちょうど「目次」のような役割と考えると良いだろう。

3.戻り値のある関数

上の例題に少しだけ手を加えて、呼び出し側に値を戻す関数を使った例を以下に示す。 この例では、関数area3()で計算した三角形の面積を、呼び出し側で「受け取って」いる。 この動作は、これまでの演習でも使ったsqrt()sin()等と同様、 より「関数らしい」動作と言えるかもしれない。

例題10A (ex10a.c)
shakyou

#include <math.h>

float area3(float, float, float) ;

int main()
{
  float x,y,z,s ;
  printf("三角形の三辺の長さ:") ;
  scanf("%f%f%f",&x,&y,&z) ;
  s = area3(x,y,z) ;
  printf("S= %f\n",s) ;
  return 0 ;
}

float area3(float a, float b, float c)
{
  float s ;
  s = (a+b+c)/2.0 ;
  return sqrt(s*(s-a)*(s-b)*(s-c)) ;
}

計算結果を呼び出し側に「戻す」働きをさせるためには、関数の定義の際に、以下の点に注意が必要である:

これまで、main( )関数の戻り値について注意を払わなかったが、本来、main( )関数は

int main( ) {
    ....
    return 0 ;
}

のように記述すべきである(上の例題ではそのように記述した)。 main()関数の戻り値はプログラムの終了コード(exit code)を表しており、0は「正常な終了」を意味する。 終了コードによって、OSは、そのプログラムがどのような状態で終了したのかを知ることができる。 エラーの発生をOSに伝えたい場合は、0以外の値をreturnすればよい。 ただし、終了コードを気にしなくて良い場合、これまでそうしてきたように、単にmain() { ... }と書いても構わない。

4.ソフトウェアの部品としての関数

上で述べた例で、関数area3( )が働く様子を、呼び出し側から眺めてみたとすると、(講義担当者の頭の中には)下の様な情景が浮かんでくる。

c-8-func-schematics

関数の中身はブラックボックスになっていて、中の様子(どんな処理が行われていて、どんな変数が使われているか)はのぞき見ることができない。 ただ、引数に対応する「受付」の窓口があって、そこに、指定されたタイプのデータ(の値)を知らせることと、 処理の結果出てきた別のデータ(return 式;によって返されてくる戻り値)を受け取ることが、呼び出す側の処理の全てだ。 そのあとで、受け取った値をどのように使うか(変数に代入したり、式の中で使ったり、printf()で表示したり・・・)は、呼び出し側の仕事になる。

ここでは別に「会社は社会の部品である」と言いたいわけではない。

つまり、関数をデザインするということは、このような「下請け会社」を設けることに相当し、関数の呼び出しとは、 必要なデータを渡した上で、下請け会社に仕事を発注し、結果を受け取る一連の作業と言えるだろう。 ある意味で、こうしたCの関数は、ソフトウェアの部品とも言えるだろう 。

そして、複雑な仕事をどのような関数を使って(作って)分業させるか、が、Cプログラミングの重要なポイントとなる。

5.変数の見える範囲(スコープ)

ブロックの中の変数は、外からは見えない

ブロックの中で宣言された変数用の箱(メモリー)は、ブロックの箇所が実行される際に作られ、 そのブロックの処理が終わると、自動的に消滅する。

なお、Javascriptのように、ブロックの内と外が同じスコープとなるような言語もある。

C言語でブロック({何々;})は、複数の文をひとまとめにする以外に、もうひとつの重要な働きをする。 それは、変数の管理である。 これまでの例題では、変数宣言は関数定義の冒頭箇所に記述していた。 けれども、C言語のルールでは、ブロックの先頭箇所だったら、どこで変数宣言しても構わない。 例えば、次のような記述が可能である:

{
  int x=0,y=2 ;
  {
    int x=1 ;
    print("%d %d\n",x,y) ;
  }
}

これを実行すると、画面には"1 2"が表示される。 このとき、内側のブロックで宣言されている変数xと、外側で宣言されている変数xは(名前はたまたま同じであっても)別の箱(メモリー)になる。 そして、内側のブロックからは、その同じブロック内(内側のx)と、それより外側のブロックで宣言された変数(この例ではy)のみが「見える」。 また、ブロックの外側と内側に同じ名前の変数が宣言されている場合は、内側の変数のほうが見える。

このように、ブロックは、変数の見通せる範囲の「仕切り」としての役割も果たしているのだ。 もしこの機能が無ければ、大きなプログラムで、変数の「衝突」が生じてしまうだろう。 i,jといったよく使いそうな名前の変数が、プログラムの別の箇所でも使われていて、気づかないうちに 値が書き換わってしまうようなことがあったら、長いプログラムはとても記述できない。

Cの関数も(名前付きの)ブロックの一種であるから、ある関数の中から、別の関数の中で使われている変数は「見えない」し、 直接アクセスすることもできない。 このように、変数がどの範囲まで「見える」か、を、変数のスコープと呼ぶ。

グローバル変数

ある程度複雑なプログラムを書いていると、どの関数からも見える(アクセスできる)、共通的な変数があると便利なことが多い。 「同じブロックか、その外側が見える」というルールを思い出すと、どこからでも見える変数を設けるには、全てのブロックの外側で変数を宣言すれば良さそうだ。 事実、そうした記述は可能であって、

int global=1 ;
float data[1000] ;

main() {
  ・・・
}

func() {
  ・・・
}

グローバル変数
ローカル変数

のように、関数の「外側」でも変数宣言が可能だ。こうして宣言された変数は、全ての関数からアクセス可能なので、全体にわたる変数という意味で グローバル変数(global variable)と呼ぶ。 それとは対照に、関数やブロックの中だけで使われる変数を、ローカル変数(local variable)と呼ぶ。


icon-pc 練習:パリティを調べる関数

教科書 p.45 練習2.46 も参照のこと。ただし、右の練習課題では「再帰構造」を用いる必要はない。

整数を2進数表示した際、現れる1の個数が偶数の場合を偶パリティ(even parity)、奇数の場合を奇パリティ(odd parity)と言う。 例えば 21 を2進数表示すると 10101 となるから、1が3個、すなわち21は奇パリティである。

非負の整数 n を渡すと、その数が偶パリティなら整数値0を、奇パリティなら整数値1を返す関数 int check_parity(int n) を設計し、 以下のひな形にその定義を書き加えて、動作を確認しなさい。

#include <stdio.h>

/* check_parity() の関数定義 */

main()
{
   int n ;
   printf("非負の整数:") ;
   scanf("%d",&n) ;
   if (check_parity(n)==0) 
      printf("even parity\n") ;
   else 
      printf("odd parity\n") ;
}

次いで、パリティをチェックし、偶パリティなら"even parity"、奇パリティなら"odd parity"と画面に出力する関数 void print_parity(int n) を設計しなさい。 ただし、print_parity( )関数の内部ではcheck_parity( )関数を呼び出して利用すること。 以下のひな形を参考に、これらの関数を用いがパリティ検査プログラムを完成させなさい。

完成形のひな形

#include <stdio.h>

/* check_parity() の関数定義 */

/* print_parity() の関数定義 */

main()
{
   int n ;
   printf("非負の整数:") ;
   scanf("%d",&n) ;
   print_parity(n) ;
}
icon-hint ヒント

桁のひっくり返しの課題のヒントも参照のこと。 例えば、nを2進で表現した際の、一番下の桁は n%2 で得られる。


icon-pc 練習:素数を判定する関数

素数を引数に与えると 1, そうでない場合は 0 を返す、素数判定を行う関数 int is_prime(int n)をデザインし、 以下のひな形プログラムに書き加えて、正しく動作することを確認しなさい。

素数のリストを出力するプログラムのひな形

C言語の条件式は、0がfalse、0以外がtrueを表すので、
if (is_prime(i)==1) ...
の箇所は
if (is_prime(i)) ...
と書いて構わない。

#include <stdio.h>
int is_prime(int) ;
main( )
{
    int n ;
    for (n=3 ; n<=1000 ; n=n+1) {
        if (is_prime(n)==1) printf("%d ",n) ;
    }
    printf("\n") ;
}

/* 以下、is_prime() 関数の定義を追加する */
icon-hint ヒント

素数の判定アルゴリズムについては、多重ループ処理のページを参照のこと。

tfield-icon亀場で練習:素数の分布図

亀場を100×100のマス目に区切って、右上隅のマスが1、続いて、横に2, 3, 4, ・・・、そして、右下隅が10000を表すとしよう。 そのとき、素数を表すマス目だけに色を付け、下図のように1〜10000の素数の分布を可視化するプログラムを作成しなさい。

c-8-prime-dist
icon-hint ヒント

素数の判定には、ひとつ前の演習課題で作成した関数(int is_prime(int))が使えるはず。

マス目を埋めるには、その場所に四角形として描いても良いが、(四角い)点を打ったほうが高速に描画できる。 POINT()関数を使って点線を描くプログラム例を以下に示す:

#include "turtle.h"
main()
{
	int i ;
	CON("localhost") ;
	CLR() ;
	JUMP(-256, 0) ; EAST() ;
	LW(16) ;  /* 点の大きさを16に設定 */
	POINT() ;  /* 点を打つモードに切り替える */
	for (i=0; i<32; i=i+1) {
		if (i%2==0) { PD() ; PU() ;} /* iが偶数の時に、現在位置に点を打つ */
		FD(16) ;
	}
}

$n$が十分に大きな数のとき、$n$以下の素数の数は$n/\log(n)$個程度であることが分かっている(素数定理)。 つまり、素数の密度はゆっくりと減少する。得られたプロットからその様子(気配?)が観察できるだろうか。