Help us understand the problem. What is going on with this article?

ラムダ式を通じて C++ の特徴的な機能を理解する

概要

ラムダ式の理解を掘り下げると、C++ の言語機能や歴史的経緯も理解できてお得感ありました、という記事です。
ラムダ式の何たるかは こちら の解説をどうぞ(いつもお世話になってます)。
上記リンク先の内容を全て理解できる方には、釈迦に説法になるかと思います。
便利に使えているけど、いまいち内部的な仕組みは分かってない、といった方に有用な内容になることを目指してスタート。

関数ポインタから関数オブジェクトになるまで

プログラミング言語において「関数の引数や返り値とすることができる対象(第一級オブジェクト)に、関数そのものを含む(第一級関数)かどうか」という性質は、言語の仕様やそれを利用した開発の設計方針を大きく左右します。この観点から、C から現在の C++ に至るまで、言語機能がどう移り変わっていったのかをまとめます。

関数ポインタ

C の頃から存在していた関数の受け渡し機構ですが、あくまで「ポインタ」なので、計算機科学的にはこれをもって「第一級関数」とは見なさないのが一般的なようです。いわゆるコールバックや関数テーブルなどの用途で活躍してきましたが、C++ においてはクラスの非静的メンバ関数が扱えなくなるため、以前ほど万能な存在ではなくなりました。

int sum(int accumulated, int elem) {
  return accumulated + elem;
}

// C の話してますが関数ポインタ型は分かりづらいのでエイリアスします
using calc_f = int (*)(int, int);

int aggrecate(const std::vector<int>& input, calc_f func) {
  int accumulated = 0;
  for (auto& elem : input) {
    accumulated = func(accumulated, elem);
  }
  return accumulated;
}

int main() {
  std::vector<int> data = { 1, 2, 3, 4, 5 };
  std::cout << aggrecate(data, sum) << std::endl;
  return 0;
}

配列の各要素と累積値を引数に取る関数を引数にとって集計するという、まぁまぁありがちなパターンかと思います。
ここで渡したい関数がクラスのメンバ関数になったりすると、どうしようもなくなります。

言語機能としてメンバ関数ポインタも存在しますが、インスタンスへのポインタとセットで扱わなくてはならないため、コールバックやイベントハンドラとして利用するには取り回しがしづらいという側面がありますし、非メンバ関数と区別して扱わなくてはいけないというのは、なかなかに面倒です。

  • これはメンバ関数ポインタが残念な機能というわけではなく、アプリケーションの構築者が日常的に使う機能ではない、というだけの話です。
  • 型を色々取り回すライブラリやフレームワークを構築する際には、メンバ関数ポインタは欠かせません。

関数オブジェクト(ファンクタ)

C++ ではポインタベースとは異なるアプローチから、関数そのものをやりとりすることができるようになりました。
ここでやりとりする対象のことを関数オブジェクトと呼びますが、C++ では特に「ファンクタ」という呼び方がなされます。

ファンクタは、そういう名前の言語機能が提供されているわけではなく、複数の言語機能を組み合わせることによって実現されています。そのため、なかなか考え方が腑に落ちない人も居るのではないかと思います(少なくともここに一人いました)。

ここでは、ファンクタを実現する要素を列挙し、順を追って解説してみます。

型にメンバ関数が持てるようになった

何を今更感があるかもしれませんが、C からのジャンプアップとして非常に重要な機能です。
C では関数内に関数が定義できないため、ちょっとしたサブルーチンでも外側に定義する必要がありました。

// とりあえず外部から関数を受け取らずに内部で定義することを考えてみるが……
int aggrecate_tmp(const std::vector<int>& input) {
  // 関数内関数は定義できないのでコンパイルエラー
  int sum(int accumulated, int elem) {
    return accumulated + elem;
  }

  int accumulated = 0;
  for (auto& elem : input) {
    accumulated = sum(accumulated, elem);
  }
  return accumulated;
}

ですが、型の定義は関数内でも行うことができ、その型にメンバ関数を持たせられるようになったことで、事実上関数内関数が作り放題になりました。局所的に使いたい関数のスコープが制限できるのもメリットであると言えます。

int aggrecate_tmp(const std::vector<int>& input) {
  // これで func() 内にしか見えない関数が定義できる
  struct {
    int sum(int accumulated, int elem) {
      return accumulated + elem;
    }
  } func_obj;

  int accumulated = 0;
  for (auto& elem : input) {
    accumulated = func_obj.sum(accumulated, elem);
  }
  return accumulated;
}

operator() のオーバーロードができるようになった

C++ の特徴的な機能のひとつにオペレータオーバーロードがあります。言語に存在する演算子の大半が再定義できるという強力な機能ですが、その中でもこの () の再定義は非常に強烈で、クラスインスタンスがあたかも関数であるかのような扱いが可能になります。

int aggrecate_tmp(const std::vector<int>& input) {
  // operatore() を定義するついでにシンボル名を関数チックにすると……
  struct {
    int operator()(int accumulated, int elem) {
      return accumulated + elem;
    }
  } sum;

  int accumulated = 0;
  for (auto& elem : input) {
    // どう見てもただの関数呼び出し!
    accumulated = sum(accumulated, elem);
  }
  return accumulated;
}

さきほどの「関数内に定義した型のメンバ関数」が、ぐっとより本物の (?) 関数らしくなります。
このように シンボル名() で処理を呼び出せるオブジェクトを関数オブジェクトと呼びます。

  • この定義によれば関数ポインタも関数オブジェクトと言えなくもない、のが話のややこしいところです(後述あり)。
  • 現状多くのコンパイラでは、関数ポインタ経由の呼び出しがインライン展開されませんが、関数オブジェクトの場合はそれが期待できるということがアドバンテージとなっています。

テンプレートでうやむやにする

これで C++ にも第一級関数がやってきた、バンザーイ!……とはなりません。このやり方だと、何か一つ関数オブジェクトを作るたびにユニークな型が生まれます。これを馬鹿正直に解決していたら、関数の自由なやりとりができる状態からは程遠いです。

やりとりしたいのは「想定しているシグニチャの関数呼び出しができるオブジェクト」であり、具体的な型が何であるかは気にしたくありません。そんな時こそテンプレートの出番です。

template <typename TFunc>
int aggrecate(const std::vector<int>& input, TFunc func) {
  int accumulated = 0;
  for (auto& elem : input) {
    accumulated = func(accumulated, elem);
  }
  return accumulated;
}

int main() {
  std::vector<int> data = { 1, 2, 3, 4, 5 };

  struct {
    int operator()(int accumulated, int elem) {
      return accumulated + elem;
    }
  } sum;
  // 無名型のオブジェクトだがテンプレート関数には渡せる
  std::cout << aggrecate(data, sum) << std::endl;

  return 0;
}

STL によくあるコンテナ操作系の API ぽくなってきましたが、これが C++ における「関数のやりとり」の正体です。
やりとりしているのは「具体的な素性は知らんけど () 付けて呼び出せる何か」という考え方で高階関数が成り立っているんですね。

このうやむやっぷりは凄まじく、関数ポインタも巻き込んで同一視できてしまうのが便利でもあり、学習途上では混乱のしどころでもあるかと思います。

template <typename TFunc>
int aggrecate(const std::vector<int>& input, TFunc func) {
  int accumulated = 0;
  for (auto& elem : input) {
    accumulated = func(accumulated, elem);
  }
  return accumulated;
}

int sum(int accumulated, int elem) {
  return accumulated + elem;
}

int main() {
  std::vector<int> data = { 1, 2, 3, 4, 5 };

  // 関数ポインタであっても呼び出しの記法は一緒なので渡せる
  std::cout << aggrecate(data, sum) << std::endl;

  return 0;
}

テンプレートの前では第一級関数であるかどうかなどは無意味であることが、おわかりいただけるかと思います。結局マクロじゃねぇか!などと呼ばれる所以ですね。
とはいえ、それを飲み込めれば関数オブジェクトも関数ポインタも区別しなくてよい、というのはとてもありがたい話ですので、積極的に利用していきましょう。

ラムダ式

これまでの内容を踏まえた上でラムダ式を見てみると、ファンクタを生成する糖衣構文に過ぎない、ということが理解できるかと思います。

template <typename TFunc>
int aggrecate(const std::vector<int>& input, TFunc func) {
  int accumulated = 0;
  for (auto& elem : input) {
    accumulated = func(accumulated, elem);
  }
  return accumulated;
}

int main() {
  std::vector<int> data = { 1, 2, 3, 4, 5 };

  std::cout << aggrecate(data, [](int accumulated, int elem) {
    return accumulated + elem;
  }) << std::endl;

  return 0;
}

最初に紹介した定義の通り、コンパイラ側でユニークな型を定義し、式自体がその型のオブジェクトとなるので、ファンクタを受ける想定のテンプレート関数にそのまま渡すことができます。

すぐ引数にするのではなく、いったんローカルで確保しておきたい場合は、シンボル名を付けて auto で受ける必要があります。型名は自動生成されるので、プログラム書く側が知る手段がないためです( decltype などで型情報を得ることはできますが)。

その他、自作のファンクタに値や参照を保持するには、メンバ変数を定義してコンストラクタで初期化して~といった手順が必要ですが、ラムダ式ならキャプチャという形でお手軽に欲しい対象をピックアップできます。さすが糖衣構文ですね。甘々です。

その他実践上で困りそうなこと

1.ファンクタのやりとり先がみんなテンプレートになっていってつらい

全てがヘッダ実装になって困ってしまう、なんて場面もあるでしょう。そんな時は std::function を使って保持することができます。

ただし、個々のファンクタは保持する値によってサイズが異なるため、場合によっては new によるアロケーションが発生します。
それが嫌なら固定長の fixed_size_function などを検討すると良いですが、その場合は関数オブジェクトのサイズが一律で確保されるため、運用次第ではまぁまぁメモリを無駄にするかもしれません。

さらに、 std::function を経由した関数の呼び出しは、内部処理ではメンバ関数ポインタ経由でのアクセスになるため、インライン展開などの最適化は望み薄になります。パフォーマンスがクリティカルな場面では留意した方が良いでしょう。

std::function の仕組みについては(手前味噌ですが) こちらこちら の記事も参考になるかと思います。

2.テンプレートであるがゆえに間違ったものを渡した時のエラーがわかりにくい

型の検証がテンプレート関数内の呼び出しコードでのマッチング頼みだと、間違ったものを渡した場合のエラーメッセージが人間に優しいものではなくなったりするケースがあります(オブラートに包んだ表現)。
これをもっと分かりやすいエラーにしたいということであれば、上記で紹介した std::function を引数とするか、std::invoke_result_t を使った static_assert でも仕込んでおくと良いです。

template <typename TFunc>
int aggrecate(const std::vector<int>& input, TFunc&& func) {
  static_assert(std::is_same<std::invoke_result_t<TFunc, int, int>, int>::value, "Expected functor-type int(int, int)");

  int accumulated = 0;
  for (auto& elem : input) {
    accumulated = func(accumulated, elem);
  }
  return accumulated;
}
  • こういう仕組みを言語的に整備しようというのが concept らしいのですが、C++11, 14, 17 と採用が見送られています。
  • 当面は自衛するしかなさそうです。

まとめ

ラムダ式をとっかかりにして、

  • 高階関数実現までの道のり
  • テンプレートの暴力性
  • 糖衣構文のありがたみ

を紹介してみました。参考になれば幸いです。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away