C++の間違った知識に振り回されないために

  • 14
    いいね
  • 2
    コメント

はじめに

この記事はC++ Advent Calendar 2016の16日目の穴埋め記事です。

もう書いてある内容を鵜呑みにしないと心に決めた

4年くらいC++の勉強して気がついてしまった。
C++の本に書いてあることを何でもかんでも鵜呑みにしてはいけないと気がついた。

  • 理由
    1. 間違っているかもしれない
    2. 古い情報かもしれない
    3. 一面しかとらえていないかもしれない

1.の場合はコンパイルして実行すればわかるだろう。
詳細な理由については、コンパイルエラーのメッセージが教えてくれることを祈ろう。
まれにコンパイラによって挙動が違うことがある。
その場合はコンパイラのバグかCore Issues(規格書で挙動が定義されていない)の可能性がある。

2.の場合は新しい本を読めば大丈夫だろう。
古い情報が具体的に何かというと、新しい機能ができて用済みになった関数などのことだ。
例えば、C++03のbind1stbind2ndはC++11では後継のbindが登場し用済みとなった。
C++11では、さらにラムダ式が登場した。
さらにさらに、C++14でラムダ式が強化され、bindすらほほとんど使わない。

3.は厄介です。書かれていることは間違っていないのです。
ただし、(限定的な)ケースを例に解説がなされ、「〇〇を使っておけばオールオッケー」などと結論が述べられている場合です。
いざ、自分で実践してみたらうまくいかないといったことが起こりえます。
圧倒的知識・経験がなければこの落とし穴を回避するのは厳しいでしょう...。

僕の経験でいうと。
某黒っぽくて分厚い本は、
平気で間違ったことが書いてあったりした。
ちょっとコードをコンパイルしたらわかるミスがあるのは困りますなぁ。

ポケリのまだ見つかっていない誤植を見つけたりもした。

本を参考にコードを書くときだけ裏を取る

本の内容が正しいかどうかをいちいち考えるのは面倒なので。
自分が使う情報だけコードをコンパイルしたり、
パーフォーマンスを測定したりして裏を取ればいい。

という話。

情報を鵜呑みにして時間を溶かす可能性は決して低くないように思えます(個人の主観です)。
あらかじめ確認に時間を割いて安心するのがいいとおもいます。

わからんかったらStack Overflowとかで質問

とりあえず、ググりましょう。
それでもさっぱりわからなかったら、Stack Overflowに質問するのがいいでしょう。
別に、teratailでもツイッターでもいいですが。

やっぱり規格書

C++のいいところのひとつは規格がしっかりしてるところでしょう。
規格書を読めば何が正しい動作なのか、未定義なのか、実装依存なのか、など大抵のことは書いてありますので、何が正しいのかわからなくなったらとりあえずよんどけばいいでしょう。

ここからは僕が時間を溶かした事例になります。

Effective Modern C++

ラムダ式のデフォルトキャプチャは避けないで使っていくスタイル

某『Effective Modern C++』という本では、
「ラムダ式のデフォルトキャプチャを避けろ」(意訳)。
などと書かれています。

が!

がですよ?

本当にそうなのでしょうか?

次の場合を考えます。

// 変数がいっぱいある
int a{}, b{}, c{};
double d{}, e{}, f{};
std::string too_long_name_variable{};

// ラムダ式
auto result = [&a,&b,&c,&d,&e,&f,&too_long_name_variable](auto&& ...args){
  // ...
  // 何らかの処理
  // ...

  return result; // 計算結果結果を返す
}(1,2,3,4,5,5); // その場で呼び出す

ばからしい。
まず、たくさんの(名前が長い)変数をいちいち全部キャプチャするのはばからしい。
次に、ラムダ式がスコープを跨がない場合には、参照の寿命なんて尽きることが(ほぼ)ないからどうでもいい。

ラムダ式がスコープを跨ぐときはどうするかといえば、デフォルトのコピーキャプチャを使えばいい。

より実践的には、

  1. キャプチャしないので、ラムダ導入子を空にする

  2. ラムダ式がスコープを抜けないので、デフォルトの参照キャプチャを使う

  3. ラムダ式がスコープを抜けるので、デフォルトのコピーキャプチャを使う

  4. デフォルトの参照キャプチャを使いつつ、必要な変数はコピーキャプチャする

  5. デフォルトの参照キャプチャを使いつつ、必要な変数は初期化キャプチャする

  6. 必要な変数をすべて初期化キャプチャする

  7. キャプチャする変数が少ないので、すべてキャプチャ指定する

というパターンが考えられるでしょう(自分調べ)。

デフォルトのキャプチャ指定使っといて、問題が起こったときはサニタイザでいいのでは説

問題が起こったらコンパイラオプションで、
ほげほげサニタイザすればいいのでは説。

MemorySanitizer, AdrressSanitizer, LeakSanitizer, ThredSnitizer などの動的解析ツールがgcc/clnagにはある。
MSVCなら/Analyzeだっけ?

なんにせよ、問題が起こってからでもどこで何が起こったのかは意外と簡単にわかる。
わかれば修正も簡単だ。

std::bindよりラムダ式を使った方がいいとは限らない

某『Effective Modern C++』という本では、
「ラムダ式は最強で、C++14ではもはやstd::bindを使う意味はなくなった」(意訳)。
などと書かれています。

が!

がですよ?

本当にそうなのでしょうか?

次の場合を考えます。

やりたいこと
-> 可変長引数をバインドしておいて、後から指定した関数にわたす。
※その際、可変長引数はムーブしてバインドしたいとする。

これができたら一番幸せな方法(できない)

[args... = std::move(args)...](){} // 可変長引数をムーブキャプチャ!

残念ながら初期化キャプチャは可変長引数では使えません。
じゃあ、どうすんねん!?

こうすんねん!
実際にラムダ式とstd::bindの両方で実装してみた。


int main() {
  // バインドする変数
  hoge x{ 1 };

  std::cout << "#1 : " << x.get() << std::endl;
  /*
  ラムダ式バージョン
  */
  // #1 可変長引数をとる関数がラムダ式を返す
  // #2 ラムダ式に可変長引数をバインドする
  // #3 可変長引数を初期化キャプチャできないのでtupleに固めて初期化キャプチャ
  // #4 ラムダ式が実行され、関数が指定されるとtupleを展開して関数に渡す
  auto lambda = []( auto&&... args ) {
    // #1
    return [/* #2,#3 */t = std::make_tuple( std::move( args )... )]( auto&& func, auto&& ... ){
      return cranberries::apply( func, t ); // #4
    };
  }(x);

  // 呼び出し
  lambda([]( auto&& head, auto&& ... ) {
    std::cout << "#2 : " << head.get() << std::endl;
  });


  // バインドする変数
  hoge y{ 2 };

  std::cout << "#3 : " << y.get() << std::endl;

  /*
  std::bindバージョン
  */
  // #1 可変長式数をとる関数がバインドオブジェクトを返す
  // #2 bindに束縛させる関数はあとで指定する関数とその引数の両方を引数に取る関数(今回はラムダ式で書いた)
  // #3 後で関数を指定するために第1引数はプレースホルダ
  // #4 可変長引数をムーブしてバインドオブジェクトに束縛する
  auto&& bind_expr = []( auto&& ...args ) {
    // #1
    return std::bind(
      /* #2 */[]( auto&& func, auto&& ...args ) { return func( std::forward<decltype(args)>( args )... ); },
      std::placeholders::_1, // #3
      std::move( args )... ); // #4
  }(y);

  // 呼び出し
  bind_expr(
    []( auto&& head, auto&& ... ) {
    std::cout << "#4 : " << head.get() << std::endl;
  });
}

どちらが優れているのか、正直わからないな(どっちもややこしい)。

情報を整理しよう。

ラムダ式バージョン

  • いい点

    • 比較的すっきりしている(applyを自力実装してることを除けば)。
    • タプルがもしかしたら便利かもしれない。
  • 悪い点

    • タプルを構築しなければいけない。
    • タプルの展開の意味わからないひとがいそう。
    • タプルを展開して関数に引数として渡す関数std::applyはC++17の機能(自作したった)。
    • バインドする変数ひとつについて、2回ムーブされる疑惑がぬぐえない。

std::bindバージョン

  • いい点

    • タプルを構築する必要がない。
    • C++17の機能を使う(or自作する)必要がない。
    • バインドする変数ひとつについて、1回限りのムーブであることは明らか。
  • 悪い点

    • std::bindに慣れてないとplaceholders::_1に面食らう。
    • std::bindに慣れていなくて挙動がわからないひとがいそう。

さて、情報を整理したところで、実測の時間だ。

確かめるのは、2項目。

  1. ちゃんとムーブされているか?

  2. 速度に差はあるか?

以下、測定に使ったコード。
ムーブ・コピーをフックするためのクラスを使った。
時間計測は 僕がいつも使ってる魔黒

#include <iostream>
#include <functional>
#include <vector>
#include <initializer_list>
#include <utility>
#include <tuple>
#include <memory>
#include "time_elapsed.hpp"

namespace cranberries {

  template < typename F, typename Tuple, size_t... I >
  decltype(auto) apply_impl( F&& f, Tuple&& t, std::index_sequence<I...> ) {
    return f( std::forward<decltype(std::get<I>( t ))>( std::get<I>( t ) )... );
  }

  template < typename F, typename... Args >
  decltype(auto) apply( F&& f, std::tuple<Args...> const& t ) {
    return apply_impl( std::forward<F>( f ), t, std::index_sequence_for<Args...>{} );
  }

}

class hoge {
public:
  hoge() = default;
  ~hoge() = default;
  hoge( hoge const& x ) : ptr{ std::make_unique<int>( *x.ptr ) } {
    std::cout << "copy constructed" << std::endl;
  }
  hoge( hoge&& x ) : ptr{ std::move( x.ptr ) } {
    std::cout << "move constructed" << std::endl;
  }
  hoge( int a ) : ptr{ std::make_unique<int>( a ) } {}
  hoge& operator=( hoge const& x ) {
    std::cout << "copy assigned" << std::endl;
    *ptr = *x.ptr;
    return *this;
  }
  hoge& operator=( hoge&& x ) {
    std::cout << "move assigned" << std::endl;
    ptr = std::move( x.ptr );
    return *this;
  }

  auto* get() const {
    return ptr.get();
  }

  decltype(auto) print( std::ostream& os ) const {
    return os << *ptr;
  }

private:
  std::unique_ptr<int> ptr{};
};

decltype(auto) operator<< ( std::ostream& os, hoge const& x ) {
  return x.print( os );
}

int main() {
  hoge x{ 1 };
  std::cout << "#1 : " << x.get() << std::endl;

  CRANBERRIES_TIME_ELAPSED_MICRO(
  auto lambda = []( auto&&... args ) {
    return[t = std::make_tuple( std::move( args )... )]( auto&& func, auto&& ... ){
      return cranberries::apply( func, t );
    };
  }(x);

  lambda([]( auto&& head, auto&& ... ) {
    std::cout << "#2 : " << head.get() << std::endl;
  });
  );

  hoge y{ 2 };

  std::cout << "#3 : " << y.get() << std::endl;

  CRANBERRIES_TIME_ELAPSED_MICRO(
    auto&& bind_expr = []( auto&& ...args ) {
    return std::bind(
      []( auto&& func, auto&& ...args ) { return func( std::forward<decltype(args)>( args )... ); },
      std::placeholders::_1,
      std::move( args )... );
  }(y);

  bind_expr(
    []( auto&& head, auto&& ... ) {
    std::cout << "#4 : " << head.get() << std::endl;
  } );

  );
}

結果

実測したところ、とあるコンパイラにおいて疑惑が的中する。
「えむえすぶいしー」である!

#1 : 0000027810EEF2B0
move constructed
move constructed
#2 : 0000027810EEF2B0
1969[micro sec]
#3 : 0000027810EEF2D0
move constructed
#4 : 0000027810EEF2D0
1386[micro sec]

ムーブされてるな。よしよし。
まてよ、ラムダ式バージョンで2回ムーブが起こっている!

キャプチャの部分。

[t = std::make_tuple( std::move( args )... )]

この部分で、args... から一時変数のtupleを作るときに1回。
それを、ラムダ式に初期化キャプチャしてもう1回ムーブしてやがる。

そのせいでラムダ式バージョンよりstd::bindバージョンの方が(マイクロ秒オーダーで)パフォーマンスがいいという結果になっている。

clangの結果を見てみよう。

#1 : 000002816499F3A0
move constructed
#2 : 000002816499F3A0
1437[micro sec]
#3 : 000002816499F4A0
move constructed
#4 : 000002816499F4A0
1444[micro sec]

ムーブはちょうど一回だ!さすがclnag、気が利いている。

パフォーマンスに差はない。
やはり、ムーブの差がパフォーマンスに影響していたようだ。

これ、せっかく考えましたが、同じようなこと考えてた人がいたみたいだ。