関数を作る | Programming Place Plus 新C++編

トップページ新C++編

このページの概要

このページでは、これまであまり説明せずに使ってきた「関数」について取り上げます。これまでは用意されている関数を使うことしかしませんでしたが、ここでは自分で目的に合った関数を作ってみることにします。なお、関数の機能は豊富なので、このページではすべてを説明してはいません。

以下は目次です。要点だけをさっと確認したい方は、「まとめ」をご覧ください。



関数

前のページで、蔵書リストのプログラムが動作するようになりました。実用的とはいいがたいほど低機能で、使いやすいものでもないですが、それでもソースファイルは割と大きくなっています。また、いくつもあるコマンドの処理をだらだらと書き並べているため、見づらいですし、いくらか重複してしまっているコードもあります。

練習問題の内容まで反映させたソースコードは、前のページの練習問題の解答ページにあります。

そこでこのページでは、ソースコードを整理して、可読性を高める方法として、関数 (function) を紹介します。

関数はこれまでにも何度も登場しています。std::getline関数、std::vector や std::string の size関数や push_back関数、また main関数もありました。

関数は、「ある1つの仕事をこなすために必要なソースコードを1か所にまとめて名前を付けたもの」という捉え方ができます。std::getline関数は、「1行分の入力を文字列として受け取る」という仕事をまとめて、それを getline という名前で表現しています。適切に名前を付けることは可読性の向上につながるので、これは関数を使う利点の1つといえます。

また、何度か必要になる処理が1つの関数としてまとめられていれば、繰り返し同じコードを記述しなくて済むようになります。これはコードを再利用できるということで、開発効率を向上させる意味があります。また、その処理の一部を修正しなければならなくなったとき、関数の中身だけを直せばよく、プログラム内のあちこちを直してまわる必要がなくなります。これも関数の重要な利点です。

関数の宣言

新たな変数を作るときに宣言を記述するように、新たな関数を作るときにも、まずは関数の宣言を記述します。

あとで説明する「関数の定義」があれば、宣言を書かずに済ませられることがあります。

宣言とは、「こういう名前のものがある」ということをコンパイラに伝える記述です。関数の宣言を記述することで、その名前の関数が存在するという事実を明示したことになります。標準ライブラリの関数を使うとき、#include <****> のような記述が必要ですが、これは標準ライブラリの関数の宣言をまとめたファイルの内容をそっくりそのまま、この記述を書いたところに取り込んでいます。たとえば、#include <iostream> は、「iostream」という名前のファイルの内容を取り込んでいます。

「iostream」という名前のファイルは、開発環境をインストールしたフォルダのどこかに実際に存在します。Visual Studio の場合なら、#include <iostream> という記述のところを右クリックして、「ドキュメント <iostream> を開く」を選ぶと簡単に中身を確認できます。絶対にファイルの内容を書き換えないように注意してください。

関数の宣言は次の構文で記述します(後述しますが、別のスタイルも存在します)。

戻り値の型 識別子(仮引数);

「戻り値の型」には、関数から返す値の型を記述します。“関数から返す値” のことを戻り値(返し値、返り値、返却値) (return value) といいます。C++ では戻り値は 1個、あるいは 0個でなければならず、1個ならその値の型を、0個なら void と記述します。もし、一度に複数の値を返したいと思うなら、std::vector や構造体型のように、複数の値をまとめて管理できる型を使います。

int 識別子(仮引数);                // int型の値を返す関数の宣言
void 識別子(仮引数);               // 戻り値がない関数の宣言
std::vector<int> 識別子(仮引数);   // int型の要素を持った std::vector を返す関数の宣言
Book 識別子(仮引数);               // Book構造体型の値を返す関数の宣言

「識別子」には、関数の名前を記述します。使える文字のルールは変数のときと同様です(「定数式と識別子」のページを参照)。別の話題になるので詳しくは説明しませんが、標準ライブラリを使うときにいつも付いてくる std:: の部分は関数名ではありません。

std::string get_name(仮引数);

【上級】std:: は名前空間の指定です。標準ライブラリに含まれる機能は原則として、std という名前空間の内側にあります。

関数に限りませんが、名前の付け方についていくつか流派のようなものがあって、代表的なものに、get_name のように小文字を使い、_ で区切る規則で付ける方法(スネークケース (snake case))と、GetName のように単語の頭を大文字にする規則で付ける方法(キャメルケース (camel case)。1文字目を大文字にするかどうかでさらに分類することもある)があります。標準ライブラリではスネークケースが用いられているので、当サイトではスネークケースを採用します(変数名でも同様の方針です)

一緒に使うほかのライブラリが異なる方法を採用していると、結局どこかで混在することになりますが、自分たちで書く部分については統一しておこうとするのが一般的だと思います。


仮引数 (parameter)」には、関数を呼び出す側から渡されてくる値を受け取る変数の型と名前を、, で区切りながら書き並べます。型を auto にすることはできません。仮引数が必要なければ () だけを書いて、中身は空で構いません(あるいは void と書いてもいい)。

std::string get_name();                 // 仮引数がない関数の宣言
std::string get_name(void);             // 仮引数がない関数の宣言(上と同じ)
std::string get_book_name(Book book);   // 仮引数が1つの関数の宣言
std::string get_book_name_by_author(Book book, std::string author);  // 仮引数が2つの関数の宣言

【C言語プログラマー】C++ では、仮引数を空にした場合と void と書いたときの意味は完全に同じです。

【上級】仮引数の初期値は渡されてくる値なので、仮引数の初期化子は書かなくていいですが、void f(int v = 10); のような構文は存在します。これはデフォルト実引数と呼ばれ、関数の呼び出し側が明示的に値を指示しなかった場合に使う値を、関数の宣言側で決められるというものです。

仮引数が複数必要な場合は、記述する順序にも気を留めるようにしてください。関数を呼び出す側は、仮引数の順番に合わせて、値を書き並べなければならないので、あまりに不自然な順序であったり、関数ごとに一貫性が欠けていたりするのは嫌われます。


関数の宣言を記述した位置よりもうしろからでないと、その関数を呼び出すことはできません。#include <****> という記述を、いつもソースファイルの先頭付近に書くのはこのためです。

// 宣言(別のところに定義が必要)
void func(int value);

int main()
{
    func(100);  // OK
}
int main()
{
    func(100);  // コンパイルエラー。宣言がみえない
}

// 宣言(別のところに定義がある)
void func(int value);

関数の宣言に、関数の名前、仮引数の型と個数・順番が記述されていることによって、その関数を呼び出そうとしている箇所の記述が適切かどうかをコンパイラが判断できます。たとえば、仮引数がないのに、name = get_name(100); のように呼び出すのは間違っているためコンパイルエラーにできます。また、仮引数が int型のところへ、12.3 のような値を渡そうとしていれば、型変換が必要であることを把握できます。

戻り値の型を後ろに書く構文

関数の宣言の構文がもう1つあります。この構文は C++11 で追加されたものであり、ごく一部の場面以外ではまったく同じ意味なので、さきほど説明した従来からの方法が圧倒的によく使われています。しかし、新しい構文で書かれたソースコードを見かける機会もあるので、知っておいたほうがいいでしょう。

新しい構文は次のとおりです。

auto 識別子(仮引数) -> 戻り値の型;

-> という矢印を模した記述を使って「戻り値の型」を後ろに書く構文になっています。従来の構文で「戻り値の型」があったところには auto と記述します。構文が異なるだけで、意味はまったく同じです。たとえば、次の2つの宣言は同じ意味です。

std::string get_name();
auto get_name() -> std::string;

【上級】この構文は、仮引数の型がテンプレート実引数によって決定され、それに応じて戻り値の型が決まるようなケースで必要性がありました。たとえば、それぞれ T1型と T2型の2つの仮引数a、b を + で加算した結果を返す関数テンプレートの戻り値の型は、decltype(a + b) によって得られますが、decltype(a + b) f(T1 a, T2); のように記述することはできません。a と b という名前が登場するより前で a と b を使おうとしているからです。そこで、戻り値の型を後ろにもっていって、auto f(T1 a, T2) -> decltype(a + b); と書けるようにしました。

関数の定義

関数の宣言には、肝心の「関数の中身」がありません。つまり、その関数が何をするのかが記述されていません。関数の中身を記述したものを、関数の定義 (definition) と呼びます。

関数の定義は、次の構文で記述します。

戻り値の型 識別子(仮引数)
{
     処理
}

このかたちはいつも書いている main関数のかたちと同じです。つまりこれまでのページで書いてきたプログラムは、main関数の定義を書いていたわけです。

戻り値の型を後ろに書く構文を使って書くこともできます。

auto 識別子(仮引数) -> 戻り値の型
{
     処理
}

いずれの構文でも最初の行は、末尾の ; がないことを除いて、関数の宣言とまったく同じですし、同じでなければなりません。

{} の内側に、関数が行う処理(関数の本体)を書きます。これまで main関数の内側に書いてきたコードが同じように書けます。たとえば、変数を宣言したり、ほかの関数を呼び出したりできます。関数の中で宣言した変数は、別の関数からは使えません。

関数が呼び出されると、呼び出し側で指定した値(f(100, n); のように呼び出したとすれば 100 と n)が、対応する仮引数の初期値として渡されてきます。関数の本体では、その仮引数を使用できます。

処理が } のところまで実行されると、呼び出し元へ戻ります。あるいは「ファイルとエラー」のページで少し登場した、return文によっても呼び出し元へ処理を戻せます。return文には戻り値の指定を加えることができます(たとえば、return 0; のように)。このとき指定する値の型は「戻り値の型」のところに指定した型に合わせる必要があります(可能であれば暗黙の型変換が行われます)。「戻り値の型」が void の場合は、戻り値を指定することはできません。

このページでは戻り値については深入りしません。次のページであらためて取り上げます。

プログラムの実行順は単純に上から下へ進むというだけでなく、ほかの関数の内側に移動したり戻ってきたりもしているということです。特に自分で関数を作るようになると、ソースコード内をどのように処理が流れていくのか、きちんと理解できていなければなりません。

Visual Studio のステップ実行の機能を使うなどして、実行の流れを確認してみるのもいいかもしれません。「Visual Studio編>ステップ実行」のページを参照してください。


なお、関数の定義は、関数の宣言の代わりを努めることができます。つまり、その関数を呼び出そうとしている位置より手前に定義があれば、宣言を記述しなくても問題ありません。

// 定義(宣言はない)
void func(int value)
{
    // ...
}

int main()
{
    func(100);  // OK
}

しかし、プログラムの複雑が大きくなってくると、宣言を記述しないとうまくコンパイルできない場面が出てくることがあります。基本的には、宣言は記述するものです。

関数呼び出し

関数は、その名前と () を使った、関数呼出し (function call) と呼ばれる式によって呼び出せます。

関数名(実引数)

() の内側に関数に渡す値を書き並べます。この値のことを、実引数 (argument) と呼びます。仮引数と実引数を区別しなくていい場面では、いずれも単に引数 (parameter、argument) と呼ぶことがあります。

実引数は、関数の宣言や定義のところに書いた仮引数の順番に合わせて記述しなければなりません。型が一致しない場合、暗黙の型変換が試みられ、それができない場合はコンパイルエラーになります。なお、値になればいいので、f(a + b) のような計算式を記述しても構いません。

当然、同じ関数を何度も呼び出すことができ、そのたびに実引数の値を変えることが可能です(これまでにも、push_back関数で次々に異なる値を追加していく、といったことをしてきました)。そのため、うまく関数を作ることで、同じ仕事をするコードを1つにまとめられます。将来同じコードが必要になったときには、その関数を呼び出すだけで簡単に済みますし、同じコードが色々なところに散らばっていないので、後からコードを修正することも容易になります。


実引数はコピーとして関数に引き渡され、対応する仮引数の初期値になります。コピーなので、仮引数の値を書き換えたとしても、元になった実引数には影響はありません。しかし、仮引数が参照型の場合は、実引数の別名であってコピーされているわけではないので、関数の中での書き換えの影響を受けます。

#include <iostream>

void f1(int value);
void f2(int& value);

int main()
{
    int value {100};

    f1(value);
    std::cout << value << "\n";

    f2(value);
    std::cout << value << "\n";
}

void f1(int value)   // value の値は、実引数からコピーされたもの
{
    value = 0;  // 仮引数の value を書き換えても、実引数とは無関係
}

void f2(int& value)  // value は、実引数の value の別名
{
    value = 0;  // 別名の側で書き換えると、元の方(実引数の value)の値が変わる
}

実行結果:

100
0

main関数で宣言した変数value を、f1 と f2 を呼び出すときの実引数に使っています。f1 の場合は値のコピーが渡されていることになるため、f1関数の内側で仮引数を書き換えても影響を受けません。f2 の場合は、仮引数が参照型なので、f2関数の内側で仮引数を書き換えると影響を受けます。

仮引数が参照型でなく、値のコピーによって引き渡されることを値渡し (pass-by-value) と呼びます。また、参照型を使って、コピーでなく別名として引き渡されることを参照渡し (pass-by-reference) と呼びます。

参照渡しは、大きなデータ(構造体型や、std::vector、std::string など)の受け渡しにかかる処理時間の削減に利用できます(「構造体」のページを参照)。std::vector の場合だと void f(std::vector<int>& vec); のような記述になります。

参照型の仮引数を、関数が仕事を行った結果として得られた値を、呼び出し元に伝える手段として使うこともできます。たとえば、円の面積を求める関数を次のように宣言することが考えられます。

// 円の面積を求める
// radius: 半径
// area: 面積を受け取る変数の参照
void calc_circle_area(double radius, double& area);

// 使い方
double area {};
calc_circle_area(10.0, area);

しかし、このような用途ならば、戻り値の仕組みを使ったほうが分かりやすいし便利です(戻り値を使うプログラムは次のページで取り上げます)。

// 円の面積を求める
// radius: 半径
// 戻り値: 面積
double calc_circle_area(double radius);

// 使い方
double area {calc_circle_area(10.0)};

仮引数が参照型の場合は、変数の別名にならなければならないので、f(a + b) のような計算式による呼び出しや、f(100) のようなリテラルを使った呼び出しはできません。

const参照

f(value) という関数呼出しがあるとき、関数f から戻ってきたときに、変数value の値が変化している可能性には注意が必要です。

value = 100;
f(value);  // 間違いなく 100 を渡している
f(value);  // 100 を渡しているかどうか断言できない(上の呼び出しで変化したかもしれない)

もし関数f の仮引数の型が参照型であれば、関数の中から、呼び出し元の変数value の値を書き換えることが可能ですから、2回目の呼び出しで渡している値は 100 ではない可能性があります。

仮引数が参照型なのかどうかは、f(value) という呼び出し側のコードだけをみても判断できません。少なくとも、関数の宣言を確認する必要があります。参照型でないことが確認されれば、変数value が書き換えられることはないのだと判断できます。

【上級】ここでは、ポインタの存在や、メモリ破壊などのバグによって値が変化する可能性は省いています。

関数の宣言を確認した結果、参照型だったとしても、必ず書き換えを行っているということではありません。仮引数を参照型にしている理由が、大きなデータのコピーにかかる処理時間を削減することにあるかもしれません(「構造体」のページを参照)。

そこで、参照型の仮引数を使いつつも、関数の中では値を変更していないことを明確にする方法があります。参照型の仮引数(変数)を宣言するとき、const というキーワードを使って、const 型名& 識別子 のように書きます。このような参照型は、ていねいにいえば、const という修飾子 (qualifier) によって修飾された参照型ですが、よく、const参照 (const reference) と呼ばれます。const参照型を経由して値を書き換えることは禁止されます

struct BigData {
     // 大量のデータメンバ
};

void f(const BigData& data);  // 宣言

void f(const BigData& data)   // 定義
{
     std::cout << data.x << "\n";  // 値を使うことは問題なし
     data.x = 100;                 // 書き換えは許さない(コンパイルエラー)
}

仮引数を参照型にする場合で、関数内での値の書き換えの意図がない場合には、const参照を使うようにしましょう。こうしておけば、関数を呼び出す側は、値が書き換えられる可能性を考えずに済みます。

実引数の評価順

複数の実引数を渡す場合、それぞれの実引数が評価される順番は保証がないことに注意してください。

たとえば、次のプログラムの実行結果は処理系によって異なる可能性があります。

#include <iostream>

void print_values(int v1, int v2, int v3);

int main()
{
    int a {10};
    print_values(a, ++a, a + 2);
}

void print_values(int v1, int v2, int v3)
{
    std::cout << v1 << ", " << v2 << ", " << v3 << "\n";
}

print_values(a, ++a, a + 2) には3つの実引数があります。左から右に処理されると思っていると、10, 11, 13 の順番で指定しているように思えますが実際にはどれから評価されるかは決まっていません。++aaa + 2 の順番で評価されて、11, 11, 13 が渡されることになるかもしれません。

このため、1つの関数呼出しにおいて、実引数の式がどのような順番で評価されても問題ないようにしなければなりません。インクリメントやデクリメントはこの問題に抵触する代表的な例です。

このサンプルプログラムの場合、次のようにインクリメントをあとで行うなどすれば、結果が保証できます。

#include <iostream>

void print_values(int v1, int v2, int v3);

int main()
{
    int a {10};
    print_values(a, a + 1, a + 2);
    ++a;
}

void print_values(int v1, int v2, int v3)
{
    std::cout << v1 << ", " << v2 << ", " << v3 << "\n";
}

実行結果:

10, 11, 12

std::initializer_list

func({10, 20, 30, 40}); のように、{} を使って要素を書き並べた初期化子リストを渡すことができます。これを実現するには、仮引数の型を std::initializer_list<T> にします(T は要素の型)。std::initializer_list を使うには、#include <initializer_list> が必要です。

なお、std::initializer_list は参照型にせず、そのまま使うようにします。

#include <initializer_list>
#include <iostream>
#include <limits>

// 一番大きい値を出力する
void print_max_value(std::initializer_list<int> list);

int main()
{
    int a {15};
    print_max_value({40, a, 20, a});
    print_max_value({30, a * 3});
    print_max_value({});
}

void print_max_value(std::initializer_list<int> list)
{
    // 要素が空の場合は、空行を出力する
    if (list.size() == 0) {
        std::cout << "\n";
        return;
    }

    int max {std::numeric_limits<int>::min()};
    for (auto e : list) {
        if (max < e) {
            max = e;
        }
    }
    std::cout << max << "\n";
}

実行結果:

40
45

いつものように、初期化子リストを使った構文では、暗黙の縮小変換は行われません。

std::initializer_list 自体にはあまり機能はなく、使ってみると少し不自由な気持ちになるかもしれません。たとえば、list[i] とか list.at(i) のような方法で要素にアクセスすることはできず、イテレータや範囲for文を使う必要があります。また、size関数はありますが、empty関数はありません。

【C++17】std::empty関数1 2が使えるようになりました。

【上級】2つのイテレータで表される範囲内から最大の値を持つ要素を見つけるには、std::max_element関数3 4を使う方法もあります。

関数の外側

プログラムが実行されるとまず main関数の先頭からはじまり、いくつかの関数を呼び出しながら進み、main関数の末尾(あるいは return文)に到達すると終了します。実行するコードはすべて、いずれかの関数の定義の内側にあり、関数の外側に飛び出すことはありません。かといって、関数の定義の外側に何も書けないというわけでもありません。たとえば、#include ~ は関数の定義の外側にありますし、関数の宣言も外側にあるといえます。

関数の定義の外側には、構造体型や、using や typedef による型の別名、constexpr変数の定義といったものを書けます。その場合、定義を書いた位置よりも下にあるコードであれば、どこからでも使えます。つまり、複数の関数で同じ定義を使いまわせます。

変数の宣言も書けますが、これについてはまたいずれ説明することにします。

#include <iostream>

// 矩形
struct Rectangle {
    int left;     // 左端の X座標
    int top;      // 上端の Y座標
    int right;    // 右端の X座標
    int bottom;   // 下端の Y座標
};
using Rect = Rectangle;  // 短い別名

constexpr Rect UnitRect {0, 0, 1, 1};  // 面積が 1 の矩形

void print_rectangle(const Rectangle& rect)
{
    std::cout << "left: " << rect.left << "\n"
              << "top: " << rect.top << "\n"
              << "right: " << rect.right << "\n"
              << "bottom: " << rect.bottom << "\n";
}

void print_rectangle_area(const Rectangle& rect)
{
    std::cout << (rect.right - rect.left) * (rect.bottom - rect.top) << "\n";
}

int main()
{
    Rect rect {5, 10, 20, 20};
    print_rectangle(rect);
    print_rectangle_area(rect);

    rect = UnitRect;
    print_rectangle(rect);
    print_rectangle_area(rect);
}

実行結果:

left: 5
top: 10
right: 20
bottom: 20
150
left: 0
top: 0
right: 1
bottom: 1
1

まとめ


新C++編の【本編】の各ページには、末尾に練習問題があります。ページ内で学んだ知識を確認する簡単な問題から、これまでに学んだ知識を組み合わせなければならない問題、あるいは更なる自力での調査や模索が必要になるような高難易度な問題をいくつか掲載しています。


参考リンク


練習問題

問題の難易度について。

★は、すべての方が取り組める入門レベルの問題です。
★★は、自力でプログラミングができるようなるために、入門者の方であっても取り組んでほしい問題です。
★★★は、本格的にプログラマーを目指す人のための問題です。

問題1 (確認★)

次のプログラムの実行結果はどうなりますか?

#include <iostream>

void f1(int n);
void f2(int n1, int n2);

int main()
{
    f1(1);
    f2(10, 20);
}

void f1(int n)
{
    f2(n, n + 1);
}

void f2(int n1, int n2)
{
    std::cout << n1 << ", " << n2 << "\n";
}

解答・解説

問題2 (基本★★)

std::vector<int> の要素の値を出力する関数を作成してください。すべての値を1行に、, で区切りながら出力するようにします。

たとえば、要素が 1, 2, 3, 4, 5 であれば、次のように出力します。

1, 2, 3, 4, 5

解答・解説

問題3 (応用★★)

std::vector<int> に複数の要素を追加する関数を作成してください。次のように呼び出せるようにしてください。

std::vector<int> v {};
push_back_values(v, {0, 1, 2, 3, 4});

解答・解説

問題4 (応用★★)

std::vector<int> の要素から、指定の値を探して、指定の値に置き換える関数を作成してください。

解答・解説

問題5 (応用★★)

蔵書リストのプログラムには、本の情報を出力しているコードが何か所かに存在しています。この部分を1つの関数に共通化してください。

蔵書リストのプログラムのソースコードは、前のページの練習問題の解答ページにあります。

解答・解説


解答・解説ページの先頭



更新履歴




はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る