関数

基本型がプログラムデータの構成要素であるように、関数はプログラムの動作の構成要素である。

関数は、プログラミングの職人技とも密接に関連している。経験豊富なプログラマーが書く関数は、簡潔で、シンプルで、明確だ。これは逆も同様で、プログラムのより小さな構成要素を特定して記述しようとする行為そのものが、より優れたプログラマーを育てる。

これまでの章では、基本的な文と式について説明した。後の章ではさらに多くの文や式を扱うが、これまで見てきたものは Dでよく使われる機能だ。しかし、それだけでは大規模なプログラムを書くには不十分だ。これまで書いたプログラムはどれも非常に短く、言語の単純な機能しか示していない。関数を使わずに、ある程度複雑なプログラムを書こうとすると、非常に困難でバグが発生しやすくなる。

この章では、関数の基本的な機能についてのみ説明する。関数については、以下の章で詳しく説明する。

関数は、文や式をプログラム実行の単位としてまとめる機能だ。こうした文や式は、その集合が達成する内容を表す名前が付けられる。その名前を使って、関数を呼び出す(実行する) ことができる。

一連のステップに名前を付けるという概念は、私たちの日常生活でもよく見られる。例えば、オムレツを作るという行為は、次のステップで一定の詳細度で表現することができる。

これほど詳細な手順は明らかに過剰なので、関連する手順は1つの名前でまとめる:

さらに進めると、すべての手順を単一の名称で表すこともできる:

関数も同様の概念に基づいている。つまり、全体としてまとめて名前を付けることができる手順をまとめて、関数を作成する。例として、メニューを表示するタスクを実行する次のコード行から始めよう。

writeln(" 0 Exit");
writeln(" 1 Add");
writeln(" 2 Subtract");
writeln(" 3 Multiply");
writeln(" 4 Divide");
D

これらの行をまとめてprintMenuと命名するのが理にかなっているので、次の構文を使用して、これらの行をまとめて関数にすることができる。

void printMenu() {
    writeln(" 0 Exit");
    writeln(" 1 Add");
    writeln(" 2 Subtract");
    writeln(" 3 Multiply");
    writeln(" 4 Divide");
}
D
functions.1

この関数の内容は、その名前を使用するだけで、main()内から実行することができる。

void main() {
    printMenu();

    // ...
}
D

printMenu()main()の定義の類似性から、main()も関数であることは明らかだ。Dプログラムの実行は、main()という名前の関数から始まり、そこから他の関数に分岐する。

パラメーター

関数の能力の一部は、その動作がパラメータによって調整できることに由来している。

オムレツの例を続けて、オムレツを 1 枚ではなく 5 枚作るように変更しよう。手順はまったく同じで、使用する卵の数が異なるだけだ。上記のより一般的な記述を、それに応じて変更することができる。

同様に、最も一般的な単一のステップは次のようにになる:

今回は、一部のステップに関する追加情報がある:"卵を5個用意する"、"卵を炒める"、"5つの卵のオムレツを作る"。

関数の動作は、オムレツの例と同じように調整することができる。関数の動作を調整するために使用する情報は、パラメータと呼ばれる。パラメータは、コンマで区切られた関数パラメータリストで指定する。パラメータリストは、関数の名の後に続く括弧の中に記述する。

上記のprintMenu()関数は、常に同じメニューを表示するため、パラメータリストは空で定義されている。メニューは、状況に応じて異なる表示をする必要がある場合があるとする。例えば、その時点で実行されているプログラムの部分に応じて、最初の項目を"Exit"ではなく"Return"と表示したほうがいい場合などである。

このような場合、メニューの最初の項目をパラメータ化して、パラメータリストで定義することができる。そうすることで、関数はリテラルではなく、そのパラメータの値を使用する。 "Exit":

void printMenu(string firstEntry) {
    writeln(" 0 ", firstEntry);
    writeln(" 1 Add");
    writeln(" 2 Subtract");
    writeln(" 3 Multiply");
    writeln(" 4 Divide");
}
D

firstEntryパラメータが伝える情報はテキストの一部であるため、その型はパラメータリストでstringとして指定されていることに注意。これで、この関数を異なるパラメータ値で呼び出して、最初のエントリが異なるメニューを表示することができるようになった。必要なことは、関数が呼び出されている場所に応じて、適切なstring値を使用することだけだ。

// プログラムのある場所:
printMenu("Exit");
// ...
// プログラムのある別の場所:
printMenu("Return");
D

注釈: string型のパラメータを持つ独自の関数を作成して使用する場合、コンパイルエラーが発生する可能性がある。上記のprintMenu()は、char[]型のパラメータ値では呼び出すことができない。例えば、次のコードはコンパイルエラーになる。

char[] anEntry;
anEntry ~= "Take square root";
printMenu(anEntry);  // ← コンパイルエラー
D

一方、printMenu()をパラメーターとしてchar[]を取るように定義した場合、stringのような値で呼び出すことはできない。 "Exit"。これは、不変性(immutability)とimmutableキーワードの概念に関連しており、これらは次の章で説明する。

メニュー関数について続けてみよう。メニューの選択番号を常に0から開始するのは適切ではないとする。その場合、開始番号を2番目のパラメータとして関数に渡すこともできる。関数のパラメータはコンマで区切らなければならない。

void printMenu(string firstEntry, int firstNumber) {
    writeln(' ', firstNumber + 0, ' ', firstEntry);
    writeln(' ', firstNumber + 1, " Add");
    writeln(' ', firstNumber + 2, " Subtract");
    writeln(' ', firstNumber + 3, " Multiply");
    writeln(' ', firstNumber + 4, " Divide");
}
D

これで、関数に開始番号を指定することができるようになった。

printMenu("Return", 1);
D
関数の呼び出し

関数のタスクを実行するために関数を起動することを、関数の呼び出しと呼ぶ。関数の呼び出しの構文は次の通りである。

function_name(parameter_values)
D

関数に渡される実際のパラメータの値は、関数引数と呼ばれる。文献では、パラメータと 引数は同じ意味で使用されることもあるが、これらは異なる概念を表している。

引数は、パラメータが定義されている順に1つずつパラメータと照合される。例えば、上記のprintMenu()の最後の呼び出しでは、引数"Return"1を使用しており、それぞれパラメータ firstEntryfirstNumberに対応している。

各引数の型は、対応するパラメータの型と一致する必要がある。

作業を行う

これまでの章では、式を"作業を行う実体"と定義してきた。関数呼び出しも式であり、何らかの作業を行う。作業を行うということは、値を生成すること、あるいは副作用を持つことを意味する。

戻り値

関数の動作の結果として生成される値は、戻り値と呼ばれる。この用語は、プログラムの実行が関数に分岐すると、最終的にはその関数が呼び出された場所に戻ってくるという事実から付けられた。関数は呼び出され、値を返す。

他の値と同様に、戻り値にも型がある。戻り値の型は、関数の名前、つまり関数が定義されている箇所の直前に指定する。例えば、int型の2つの値を加算し、その和もintとして返す関数は、次のように定義する。

int add(int first, int second) {
    // ...  関数の実際の作業 ...
}
D

関数が返す値は、関数呼び出し自体の代わりとなる。例えば、関数呼び出しadd(5, 7)が値12を生成すると仮定すると、次の2行は同等になる。

writeln("Result: ", add(5, 7));
writeln("Result: ", 12);
D

上記の1行目では、 add()が呼び出される前に、writeln()関数が引数5および7で呼び出される。関数が返す値12は、2番目の引数としてwriteln()に渡される。

これにより、関数の戻り値を他の関数に渡して、複雑な式を形成することができる。

writeln("Result: ", add(5, divide(100, studentCount())));
D

上記の行では、studentCount()の戻り値がdivide()の2番目の引数として渡され、divide()の戻り値がadd()の2番目の引数として渡され、最終的にadd()の戻り値がwriteln()の2番目の引数として渡される。

return

関数の戻り値は、returnキーワードで指定する。

int add(int first, int second) {
    int result = first + second;
    return result;
}
D

関数の戻り値は、文、式、および場合によっては他の関数の呼び出しを利用して指定される。関数は、returnキーワードによってその値を返し、その時点で関数の実行は終了する。

関数には複数のreturn文を含めることができる。特定の呼び出しに対する関数の戻り値は、最初に実行されたreturn文の値によって決まる。

int complexCalculation(int aParameter, int anotherParameter) {
    if (aParameter == anotherParameter) {
        return 0;
    }

    return aParameter * anotherParameter;
}
D

上記の関数は、2つのパラメータが等しい場合は0を返し、異なる場合はそれらの値の積を返す。

void関数

値を返さない関数の戻り値の型は、voidと指定する。これまでは、main()関数や、上記のprintMenu()関数で何度もこの型を見た。これらの関数は呼び出し元に値を返さないので、戻り値の型はvoidと定義されている。(注釈: main()は、intを返す関数として定義することもできる。これについては、後の章で説明する。)

関数の名前

関数の名前は、その関数の目的を明確に伝えるように選択する必要がある。例えば、addおよびprintMenuという名前は、それぞれ2つの値を足し、メニューを出力するという目的に適している。

関数名には、addprintなどの動詞を含めるという一般的なガイドラインがある。このガイドラインに従うと、addition()menu()といった名前は理想的とは言えない。

ただし、関数に副作用がない場合は、関数の名前を単に名詞で指定してもかまわない。例えば、現在の気温を返す関数の名前は、getCurrentTemperature()ではなくcurrentTemperature()と指定することができる。

明確で短く、一貫性のある名前を考えることは、プログラミングの微妙な技術の一部である。

関数によるコードの品質向上

関数はコードの品質を向上させることができる。責任の少ない小さな関数を使用すると、プログラムのメンテナンスが容易になる。

コードの重複は有害

プログラムの品質に悪影響を与える要因の一つに、コードの重複がある。コードの重複とは、プログラム内に同じタスクを実行するコードが複数存在することだ。

これは、コードの行をコピーして移動させることで意図的に発生することもあるが、別々のコードを書く際に偶然に発生することもある。

本質的に同じ機能を複製したコードには、バグが発生する可能性が高くなるという問題がある。このようなバグが発生して修正する必要が生じた場合、その問題の原因が複数の場所に分散している可能性があり、すべての場所を確実に修正することは困難だ。逆に、コードがプログラム内の1か所だけに存在する場合、その1か所だけを修正すれば、バグを完全に除去できる。

前述したように、関数はプログラミングの職人技と密接に関連している。経験豊富なプログラマは、常にコードの重複に注意を払っている。彼らは、コードの共通点を絶えず見つけ、共通するコードを別の関数(または、後の章で説明する共通構造体、クラス、テンプレートなど)に移動しようとしている。

コードの重複を含むプログラムから始めよう。コードを関数に移動して(つまり、コードをリファクタリングして)、その重複を削除する方法を見てみよう。次のプログラムは、入力から数字を読み込み、それらが到着した順番で、そして数値の順番で出力する。

import std.stdio;
import std.algorithm;

void main() {
    int[] numbers;

    int count;
    write("How many numbers are you going to enter? ");
    readf(" %s", &count);

    // 数字を読み込む
    foreach (i; 0 .. count) {
        int number;
        write("Number ", i, "? ");
        readf(" %s", &number);

        numbers ~= number;
    }

    // 数字を表示する
    writeln("Before sorting:");
    foreach (i, number; numbers) {
        writefln("%3d:%5d", i, number);
    }

    sort(numbers);

    // 数字を表示する
    writeln("After sorting:");
    foreach (i, number; numbers) {
        writefln("%3d:%5d", i, number);
    }
}
D
functions.2

このプログラムでは、重複しているコード行がいくつかある。数字を出力するために使用されている最後の2つのforeachループはまったく同じである。print()という適切な名前の関数を定義すると、この重複を削除できる。この関数は、スライスをパラメータとして受け取り、それを出力する。

void print(int[] slice) {
    foreach (i, element; slice) {
        writefln("%3s:%5s", i, element);
    }
}
D

パラメータは、元のより具体的な名前numbersではなく、より一般的な名前sliceを使用して参照されていることに注意。その理由は、関数はスライスの要素が具体的に何を表しているかを認識できないからだ。それは、関数が呼び出された場所でしかわからない。要素は、学生 ID、パスワードの一部などである可能性がある。print()関数ではそれを認識できないため、その実装ではsliceelementなどの一般的な名前が使用されている。

新しい関数は、スライスを出力する必要がある2つの場所から呼び出すことができる。

import std.stdio;
import std.algorithm;

void print(int[] slice) {
    foreach (i, element; slice) {
        writefln("%3s:%5s", i, element);
    }
}

void main() {
    int[] numbers;

    int count;
    write("How many numbers are you going to enter? ");
    readf(" %s", &count);

    // 数字を読み込む
    foreach (i; 0 .. count) {
        int number;
        write("Number ", i, "? ");
        readf(" %s", &number);

        numbers ~= number;
    }

    // 数字を表示する
    writeln("Before sorting:");
    print(numbers);

    sort(numbers);

    // 数字を表示する
    writeln("After sorting:");
    print(numbers);
}
D
functions.3

さらにやるべきことがある。スライスの要素を出力する直前に、常にタイトル行が出力されていることに注意しよう。タイトルは異なるが、タスクは同じだ。タイトルの出力もスライスの出力の一部と見なせる場合、タイトルもパラメーターとして渡すことができる。以下の変更を加えた:

void print(string title, int[] slice) {
    writeln(title, ":");

    foreach (i, element; slice) {
        writefln("%3s:%5s", i, element);
    }
}

// ...

    // 数字を出力する
    print("Before sorting", numbers);

// ...

    // 数字を出力する
    print("After sorting", numbers);
D

この手順には、2つのprint()呼び出しの直前に表示されるコメントを削除できるという追加の利点がある。関数の名前は、その機能を明確に表しているので、これらのコメントは不要だ。

print("Before sorting", numbers);
sort(numbers);
print("After sorting", numbers);
D

微妙だが、このプログラムにはさらにコードの重複がある。countnumberの値はまったく同じ方法で読み込まれる。唯一の違いは、ユーザーに表示されるメッセージと変数の名前だけだ。

int count;
write("How many numbers are you going to enter? ");
readf(" %s", &count);

// ...

        int number;
        write("Number ", i, "? ");
        readf(" %s", &number);
D

readInt()という適切な名前の新しい関数を利用すれば、コードはさらに良くなる。新しい関数は、メッセージをパラメータとして受け取り、そのメッセージを表示し、入力からintを読み取り、そのintを返す。

int readInt(string message) {
    int result;
    write(message, "? ");
    readf(" %s", &result);
    return result;
}
D

countは、この新しい関数の呼び出しの戻り値によって直接初期化できるようになった。

int count =
    readInt("How many numbers are you going to enter");
D

numberは、numberを読み込む際に表示されるメッセージの一部としてループカウンターiが含まれているため、同じように単純に初期化できない。これは、formatを活用することで解決できる:

import std.string;
// ...
        int number = readInt(format("Number %s", i));
D

さらに、numberforeachループの1箇所だけで使用されているため、その定義を完全に削除し、その代わりにreadInt()の戻り値を直接使用することができる。

foreach (i; 0 .. count) {
    numbers ~= readInt(format("Number %s", i));
}
D

このプログラムに最後の変更を加えて、数字を読み込む行を別の関数に移動しよう。これにより、新しい関数の名前にはその情報がすでに含まれているため、"数字を読み込む"というコメントも不要になる。

新しいreadNumbers()関数は、そのタスクを完了するためにパラメータを一切必要としない。いくつかの数値を読み取り、それらをスライスとして返す。以下は、プログラムの最終版である。

import std.stdio;
import std.string;
import std.algorithm;

void print(string title, int[] slice) {
    writeln(title, ":");

    foreach (i, element; slice) {
        writefln("%3s:%5s", i, element);
    }
}

int readInt(string message) {
    int result;
    write(message, "? ");
    readf(" %s", &result);
    return result;
}

int[] readNumbers() {
    int[] result;

    int count =
        readInt("How many numbers are you going to enter");

    foreach (i; 0 .. count) {
        result ~= readInt(format("Number %s", i));
    }

    return result;
}

void main() {
    int[] numbers = readNumbers();
    print("Before sorting", numbers);
    sort(numbers);
    print("After sorting", numbers);
}
D
functions.4

このバージョンのプログラムと最初のバージョンを比較してみてみよう。新しいプログラムのmain()関数では、プログラムの主な手順が非常に明確になっている。一方、最初のプログラムのmain()関数では、そのプログラムの目的を理解するために、関数を注意深く調べる必要があった。

この例では、2つのバージョンのプログラムの非自明な行の総数は同じになったが、一般的には、関数を使用するとプログラムが短くなる。この効果は、この単純なプログラムでは明らかではない。例えば、readInt()関数が定義される前は、入力からintを読み込むには3行のコードが必要でした。readInt()の定義後は、同じ目的を1行のコードで達成できる。さらに、readInt()の定義により、変数numberの定義を完全に削除することができた。

関数としてコメントアウトされたコード行

一連のコードの目的を説明するためにコメントを書く必要がある場合は、その一連のコードを新しく定義した関数に移動したほうがいい場合がある。関数の名前が十分に説明的であれば、コメントも不要になる。

プログラムの最初のバージョンでコメントアウトされていた3つの行は、同じタスクを実行する新しい関数の定義に使用された。

コメント行を削除するもう1つの重要な利点は、コードが時間の経過とともに変更されるにつれて、コメントが古くなる傾向があることだ。コードを更新する際に、プログラマーは関連するコメントの更新を忘れてしまうことがあり、その結果、これらのコメントは役に立たなくなるか、さらに悪いことに誤解を招くものになってしまうことがある。そのため、コメントを必要としないプログラムを書くよう努めることが有益である。

演習
  1. printMenu()関数を変更して、メニュー項目全体をパラメータとして受け取るように。例えば、メニュー項目は、次のコードのように関数に渡すことができる。
    string[] items =
        [ "Black", "Red", "Green", "Blue", "White" ];
    printMenu(items, 1);
    D

    プログラムが以下の出力を生成するようにしよう:

    1Black
    2Red
    3Green
    4Blue
    5White
  2. 次のプログラムは、2 次元配列をキャンバスとして使用している。このプログラムから始めて、さらに機能を追加して改良しよう。
    import std.stdio;
    
    enum totalLines = 20;
    enum totalColumns = 60;
    
    /* 次の行の'alias'は、'Line'を
     * dchar[totalColumns]の別名にする。プログラムの残りの部分で使用される'Line'は、
     * この時点からdchar[totalColumns]を意味する。
     *
     *
     * また、'Line'は固定長配列であることに注意。  */
    alias Line = dchar[totalColumns];
    
    /* Linesの動的配列は'Canvas'として別名付けられている。 */
    alias Canvas = Line[];
    
    /* キャンバスを1行ずつ表示する。 */
    void print(Canvas canvas) {
        foreach (line; canvas) {
            writeln(line);
        }
    }
    
    /* キャンバスの指定位置にドットを配置する。ある意味で、
     * キャンバスに"描画"する。 */
    void putDot(Canvas canvas, int line, int column) {
        canvas[line][column] = '#';
    }
    
    /* 指定位置から指定した長さの垂直線を
     * 描写する。 */
    void drawVerticalLine(Canvas canvas,
                          int line,
                          int column,
                          int length) {
        foreach (lineToPaint; line .. line + length) {
            putDot(canvas, lineToPaint, column);
        }
    }
    
    void main() {
        Line emptyLine = '.';
    
        /* 空のキャンバス */
        Canvas canvas;
    
        /* 空の行を追加してキャンバスを構築する */
        foreach (i; 0 .. totalLines) {
            canvas ~= emptyLine;
        }
    
        /* キャンバスを使用する */
        putDot(canvas, 7, 30);
        drawVerticalLine(canvas, 5, 10, 4);
    
        print(canvas);
    }
    D
    functions.5