← Prev (02)

(04) Next →

From the Software Design Gallery 第3回

『第3回・プログラミングの歴史(3)・サブルーチン』

前回は、スパゲティ・プログラムにならないために 構造化プログラミングが提唱され、行番号が廃止され、 C 言語などのプログラミング言語に取り入れられたことを お話ししました。 今回は、もう1つのスパゲティ・プログラムの元と なったサブルーチンについて説明します。

繰り返し現れるパターンをサブルーチンにして一度だけ書く

プログラムは、コンピュータに実行させる命令が並んだものです。 これは、料理の本や本棚の組み立て方の説明書など、 やり方の手順を示したものと同じ性質があると考えることができます。 では、料理の本を例に、そこに書かれている内容の特徴を、 プログラムの視点で見てみましょう。

料理の基本として、フライパンは洗ってから使うということがあります。 フライパンを洗ってから野菜炒めをつくり、 フライパンを洗ってから今度は肉を焼く、という感じです。 洗わないと、野菜炒めの味と肉の味が混ざってしまいますよね。 そんなことは常識なので料理の本には書いてないですが、 学校の家庭科の教科書には書いてあるでしょう。 大人は長い人生の中で何度も遭遇することなので常識になって いますが、子供にとっては知らないことだから わざわざ書いてあるのです (書いてあっても読まないかもしれないけど)。 コンピュータも同じように常識がないので、 プログラムでは同じことの繰り返しであっても いちいち書かなければなりません。

しかし、コンピュータと子供は明らかに違います。 子供は、子供らしい面白い発想をして大人を和ませたりしますが、 コンピュータは、あくまで書かれたことしかやりません。 その代わり、論理的にきちんと言えば ちゃんとそのとおりにやってくれます。 その特徴を生かして、これは何度もやる手順であると ちゃんと記述すればいいのです。
コンピュータが発明されてすぐのころは、 それをサブルーチンと言っていました。 繰り返し現れる命令の列をサブルーチンにまとめて 何度も再利用しようというわけです。 家庭科の教科書でいえば、フライパンを洗ってから使うことを、 最初のほうのページに詳しく1回だけ書いておいて、 あとはさらっと触れるだけということです。

では、サブルーチンとは何かを、具体的に見てみましょう。 サブルーチンは、コール(CALL)命令により特定の行番号へ ジャンプした後から始まり、リターン(RET)命令で終わります。 RET 命令は、CALL 命令のあった場所に戻りジャンプをする 命令です。
このサブルーチンを構成するための戻りジャンプの仕組みは、 実は古くから考えられていて、 コンピュータが生まれたばかりのころから CPU 自体に組み込まれていました。 つまり機械語(アセンブラ)で次のように記述することができます。
start:        実行順番 ; メインルーチン
  MOV   A, 1    (1)    ;  レジスタ A に 1 を入れる
  MOV   B, 2    (2)    ;  レジスタ B に 2 を入れる
  CALL  sub   (3)   ;  sub へジャンプする
  MOV   A, C    (7)    ;  ここは下記の RET 命令の後に実行される
   :
sub:                ;  サブルーチンの開始位置
  MOV   C, A    (4)    ;    レジスタ C に レジスタ A の値を入れる
  ADD   C, B    (5)    ;    レジスタ C に レジスタ B の値を足す
  RET         (6)   ;  リターン命令、CALL 命令のあったところへ戻る

CPU は基本的に上から下へ命令を実行しますが、 実行順番 (3) にあるように CALL 命令があると、 GOTO 命令と同じようにジャンプします。 ただし、GOTO と違うのは、実行順番 (6) にあるような RET 命令があったら CALL 命令のあった次の命令を 実行するようになることです。これが、戻りジャンプです。

サブルーチンを使ってプログラムを小さくする

何度も現れる処理は、サブルーチンにすれば、一度記述すれば 済むようになります。と言うことは、プログラムのサイズも 小さくなります。

では、具体的に、サブルーチンを使うことでどのように プログラムが小さくなるかを見てみましょう。 たとえば、命令の並びが次のようになっていたとします。
→ A B C D E F B C D E H I J K B C D E L e(命令数20)
アルファベットは、それぞれ命令を表し、 左端の A から右端の e へ実行していくものとします。 最後の e はプログラム終了命令です。 このプログラムは、全部で命令が 20 個ありますが、 プログラムをよく見ると B C D E の並びが3ヶ所あります。 それをサブルーチンにすると次のようになります。
→ A c F c H I J K c L e B C D E r(命令数16)
c は CALL 命令、r は RET 命令です。 全部で命令数が 20 から 16 に減っています。 このように、共通した部分をサブルーチンにすることにより プログラム全体のサイズを小さくすることができました。

(もっと詳しく)
サブルーチンを使うことでプログラム全体のサイズは小さくなりましたが 実行する命令の数は 20 から 26 に増えています。これは、CALL 命令と RET 命令を余分に実行しているためです。 ですから、実行速度を求められる処理では、サブルーチンを使わない ようにすることもあります。

エントリーポイントを持ったサブルーチン

昔のコンピュータは、メモリなどの容量が少なく高価だったため、 なるべく小さなプログラムになるようにサブルーチンを 構成していました。 その最たる例が、C 言語の関数でもできない エントリーポイントによる CALL 命令です。 FORTRAN では、ENTRY という予約語があったほどです。 では、アセンブラ言語で具体的に見てみましょう。
sub:         ; サブルーチンの開始位置
  MOV   D, A   ;  レジスタ D に レジスタ A の値を入れる
entry:       ; エントリーポイント
  ADD   D, B   ;  レジスタ D に レジスタ B の値を足す
  ADD   D, C   ;  レジスタ D に レジスタ C の値を足す
  RET        ; リターン命令、CALL 命令のあったところへ戻る

上のプログラムでは、CALL sub とすると、D = A + B + C を実行し、 CALL entry とすると、D = D + B + C を実行します。 つまり、エントリーポイントによる CALL 命令とは、 サブルーチンの途中から実行を開始する CALL 命令です。

エントリーポイントは、アセンブラではラベルとして存在しますが、 機械語(プログラムファイルの内容)では、何も存在しません。 なぜなら、CALL 文のジャンプ先を変えるだけだからです。 プログラムサイズがまったく増えないで別のサブルーチンを 作れるのですから、エントリーポイントは非常に優れたもの であると言えます。

しかし、エントリーポイントは、C 言語ではサポートされませんでした。 それは、さまざまな理由が考えられます。

これらの理由を説明するのは非常に難しいので、 説明は避けさせていただきますが、特に最後の 「関数の開始と終了にはローカル変数の退避と復帰が必要なこと」 が最大のネックでした。 これは、エントリーポイントにも、ローカル変数の退避が必要に なるという意味であり、そのための命令が必要になることです。 これでは、まったくプログラムサイズがまったく増えないで 複数のサブルーチンを定義できる、という エントリーポイントのメリットが ほどんど無くなってしまいます。 だから、C 言語では採用されなかったのでしょう。

(もっと詳しく)
FORTRAN では、ラベルによるジャンプができたので、 関数の途中にエントリーポイントとしてラベルを付けて ジャンプすることも不可能ではありません。 それでも、ENTRY があったのは、ENTRY がローカル変数の 退避をやっていたためです。 C 言語でも ENTRY に相当するものを作れなくはなかったと思いますが、 上で説明したとおり、プログラムサイズが増えてしまい、 あまりメリットがないために採用されなかったのでしょう。 どうしても必要なときは、C 言語からの呼び出しが簡単な アセンブラで記述するという方針にしたのでしょう。

処理だけでなくデータもパッケージしてこそ一人前の関数である

ローカル変数の退避が必要な関数にエントリーポイントが無いことは、 逆に、サブルーチンが命令の並びにしか注目していなかったたため であると考えることができます。

こう考えられる別の事実として、 サブルーチンには引数や返り値が無いことが挙げられます。 処理には、必ずその対象となるデータが存在しますが、 サブルーチンでは対象となるデータをグローバル変数を 使って渡していました。 引数で渡すかグローバル変数で渡すかの違いは、 ほとんどのケースで記述方法が違うだけです。 アセンブラでは、レジスタを使って渡していました。 具体的に見てみましょう。
start:        実行順番 ; メインルーチン
  MOV   A, 1    (1)    ;  レジスタ A に 1 を入れる
  MOV   B, 2    (2)    ;  レジスタ B に 2 を入れる
  CALL  sub   (3)   ;  sub へジャンプする
  MOV   A, C    (7)    ;  ここは下記の RET 命令の後に実行される
   :
sub:                ;  サブルーチンの開始位置
  MOV   C, A    (4)    ;    レジスタ C に レジスタ A の値を入れる
  ADD   C, B    (5)    ;    レジスタ C に レジスタ B の値を足す
  RET         (6)   ;  リターン命令、CALL 命令のあったところへ戻る

これは、今回、最初に出てきたプログラムそのものです。 確かにレジスタを使って渡しています。
コプロセッサや DSP など、特殊な計算を高速にできるチップや、 周辺機器やネットワーク機器とのインターフェイスをするチップなどの、 ハードウェアの機能を使うときも同様に、 レジスタにデータを入れてから機能を実行開始(トリガ)します。 逆に、引数を使ったところで、データがスタックに格納されるだけなので、 スタック(メインメモリ)にアクセスできないハードウェアには データが伝わらないのです。
MS-DOS も、マニュアルに書いてあるとおりに レジスタにデータを入れてから ソフトウェア割り込みを起動することにより機能の実行を開始します。
このように、引数と別の経路でデータを渡す方法は 汎用的でもあります。

しかし、前回説明した、スパゲティ・プログラムを生み出す GOTO のように、一般に汎用的であっても、別の視点に立つと 悪いものを生み出しているものであれば、 それは無くすべきと考えます。 そうしないと、いつまでもその悪いものと付き合っていくことになり、 人生の大切な時間を失ってしまうことになります。

それでは、引数が無いことより生み出される悪いことは 何かを考えてみます。 片方だけでは不公平なので、 同時に、引数のデメリットも考えてみましょう。

グローバル変数渡しのデメリット

引数渡しのデメリット

引数渡しのデメリットは、次のようにほぼ解決できます。

引数の構成を変えると、その関数を呼び出している ところも変えなければなりません。引数の構成を変えるのは 必要な入力データが足りなかったときが多いのですが、 実は、グローバル変数渡しでも、必要な入力データが 増えることには変わりありません。逆に、プロトタイプ宣言による コンパイラの引数チェックが効かないので、 不足している入力データを指定し忘れることが多くなります。

深い位置の関数までデータを伝えなければならないと、 浅い位置の関数にとって一見不要に見えるデータも 指定しなければならないことになります。 しかし、このようなデータは構造体にまとめることで 不要に見えるデータを減らすことができます。 特にオブジェクト(C++ 言語の this)から アクセスできるようにしておけばスッキリします。

ハードウェアに直接アクセスすることができないことは、 ラッパーをかぶせることで解決できます。 引数からレジスタにアクセスする関数を作ればすみます。

一方、グローバル渡しのデメリットは、引数を使うことで 解決できます。

このように、明らかに引数渡しのほうがデメリットが 少ないことが分かりました。

ただ、GOTO を使ったほうがいいケースがあるように、 グローバル変数を使ったほうがいいケースもあることは 確かです。たとえば、デバッグ出力するために printf が stdout を指定しなくて済むようなときです。

難しい話をしてきましたけど、 ぱっと見ただけで引数があるほうが使いやすいことは、 明らかですね。
  A = 1;
  B = 2;
  sub();
  sub( 1, 2 );

引数の方が便利なことは、見た目で明らかであっても、 これまで難しいことを言ってきたように、 深いところまでよく考えて作られています。
世の中、それしか考えられないだろう、という 単純であたりまえなことほど、よく考えられて苦労して 生み出されていることが多いようです。 見た目だけというのは、どこか使い心地が悪いものです。

GOTO の問題を残した関数呼び出しの対処方法

GOTO 命令と CALL 命令の違いは、RET 命令で戻れるかどうかの 違いだけです。 関数呼び出しは善だと goto は悪と言われるのは、 これまで説明したことなど、メリットが多くあるからなのですが、 はたして、RET 命令で戻れるかどうかの違いで、GOTO の問題がすべて 解決しているとは思えません。きっと問題を継承しているはずです。 それでは、GOTO のデメリットを思い出してみましょう。

GOTO のデメリット

プログラムの開始から終了までたどることの苦労は、 関数呼び出しによって解決させることができます。 細かい部分を関数にすることで、全体的なことに 集中することができるからです。

プログラム全体を行ったり来たりして混乱することは、 改善されています。あちこちの関数に飛ぶことはありますが、 必ず戻ってくるので、GOTO よりかは迷うことは少ないはずです。

どこにジャンプしたか探さなければならないことは、 依然として残っていますが、この問題に対しては、 コーディング・ルールを使うことでやや解決できます。 私の場合、オブジェクト記述法 COOL を作成し、そこで決めているように、 関数名の始まりはファイル名と同じにしています。 これでファイルを特定したら、テキスト・エディタの検索機能を 使えば、それほど苦労もなく見つけることができるでしょう。 しかし、検索機能を使うと呼び出し側までヒットしてしまいます。 そこで、私は、 構造化エディタ を使って、より素早く見つけています。
また、このジャンプ先を探さなければならない問題に対しては、 ソースブラウズ・ツールを使うことでも解決できます。 最近の開発環境では標準でついていますが、 Windows 以外では高価なものを買うしかなかったり、 操作性の悪いものがほとんどなので、 私の場合、独自のツール(Knowledge Take! 2) を開発して使っています。


さて、以上でスパゲティ・プログラムの問題は ほぼ解決したと考えられますが、 他にもプログラムが読みにくいことの原因は たくさんあります。
検索&整列プログラムでは、アルゴリズム+データ構造が 基本であると昔から言われています。 これは、言い換えれば、プログラムを書くときは、 処理の手順だけでなく、データの構造も 注意深く設計しないとよいプログラムはできないと 言っていると私は考えています。 そこで、次回はデータ構造について考えてみたいと思います。

← Prev (02)

(04) Next →


written by T's-Neko Jun.2000  - from Sage Plaisir 2