LoginSignup
119
110

More than 3 years have passed since last update.

意外と知らないマニアックC++

Last updated at Posted at 2020-03-19

概要

 時は2020年、C++20の仕様もほぼ固まり、conceptに胸をときめかせ、coroutineを待ち焦がれているであろう皆さん、各種コンパイラでC++20の実装が終わる前に少々復習もかねてC++17までの機能を見直してみませんか? 意外と知らない機能があったりなかったりするかもしれませんよ? というわけでC++の意外と知られていなさそうな機能をいくつか集めてみました。

1. std::endl

 「意外と知らない」とか銘打っておいて、いきなり誰でも知ってるstd::endlを持ち出すという衝撃展開ですが、飛ばさずに読んでいただけると意外な発見があるかもしれませんし、無いかもしれません。

1.1. Hello, World再考

std::cout << "Hello, World" << std::endl;

 誰もが書いたことのあるコードだと思いますし、小学生ですら何が起こるのか知ってます。さて、ここで問題ですがstd::coutとstd::endlとは一体何でしょうか?
 まあ焦らしても仕方ないので答えを言ってしまいますが、まずstd::coutはstd::ostream型の変数ですね。

namespace std { extern ostream cout; }

 という文言が<iostream>ヘッダー内にあるはずです。実際はもう少し複雑な書かれ方をしているかもしれませんが。ではstd::endlはどうでしょう? std::endlも何らかの型の変数でしょうか? 答えはノーです。ではstd::endlは何なのかというと、実は関数です。

namespace std {
  template<typename _CharType, typename _Traits>
  basic_ostream<_CharType, _Traits>& endl(basic_ostream<_CharType, _Traits>&);
}

 std::basic_ostreamというのはstd::ostreamの元の型で

namespace std { using ostream = basic_ostream<char, char_traits<char>>; }

 という感じになっています。つまり、std::endlはこんな使い方もできるわけです。

std::endl<char, std::char_traits<char>>(std::cout);

 template引数は実引数から推論可能なので省略可能です。また、返り値はstd::coutの参照です。

1.2. operator<<の中身

 さて、ここまでを踏まえ改めて最初のコードを考え直してみると、

std::cout << std::endl;

 これは関数ポインタを引数にとるoperator<<を呼び出しているように見えてきますね。さらにoperator<<の中ではstd::endlが関数として呼び出されているということも容易に想像がつきます。実際、

namespace std {
  template<typename _CharType, typename _Traits>
  basic_ostream<_CharType, _Traits>& basic_ostream<_CharType, _Traits>::operator<<( 
      basic_ostream<_CharType, _Traits>& (*_func)(basic_ostream<_CharType, Traits>&)) {
    return _func(*this);
  }
}

 となっています。std::endlやstd::flushなどの総称として「マニピュレータ」という単語をよく聞くと思いますが、結局のところそれらはただの関数にすぎません(ただし、<iomanip>ヘッダーにおけるマニピュレータはその限りではなく、マニピュレータ用のクラスが定義され、またそのクラスを引数として受け取るoperator<<もオーバーロードされます)。

1.3. 応用

 ここまで来たら、これを 悪用 応用してやりたくなりますね。

std::ostream& print_hello(std::ostream& out) {
  return out << "Hello, World";
}

int main() {
  std::cout << print_hello << std::endl;
  return 0;
}

 こんなことができてしまいます。std::endlは知っててもこれは知らない人もいるのではないかなと思い、取り上げた内容でした。

2. ADL

 メジャーそうでマイナーな機能です。知っている人は結構 悪用 応用して使っていることも多いです。というか知らない人もおそらく無意識に使っている機能です。ADLとはActivity of Daily Living(日常生活動作)のことではなく、Argument Dependent Lookupのことで、日本語に訳すと「実引数による探索」といったところでしょうか。どういうものかというと、例えば、

//using namespace std; は使わない。
auto sort_copy(const std::vector<int>& v){
  auto v_ = v;
  sort(begin(v_), end(v_));   //std::sort, std::begin, std::endと書かない。
  return v_;
}

 こんなことが可能です。本来ならusing namespace std; を書いていない場合は

std::sort(std::begin(v_), std::end(v_));

 と書かないといけませんが、今回の場合はstd::をつけなくてもコンパイルエラーになりません。その理由は変数v_の型にあります。変数v_の型はstd::vector<int>ですね。autoを使っているせいで少々わかりづらくはありますが。つまり変数v_の型はstd名前空間内の型です。この場合begin(v_)はstd名前空間内も探索され、std::beginが呼び出されます。同様にstd::endも呼ばれます。また、std::beginの戻り値はstd::vector<int>::iteratorなのでやはりstd名前空間内が探索されて、std::sortも呼ばれます。これがADLです。一見便利ではありますが、これによって起こる不都合もあります。

2.1. ADLによるバグと修正法

namespace ns {
  struct S {};
  void func(S) { std::cout << "ns::func" << std::endl; }
}

template<typename T> void func(T) { std::cout << "::func" << std::endl; }

int main(){
  func(ns::S());  // ::func<ns::S>を呼ぶつもりで書いたが、ns::funcが呼ばれる。
  return 0;
}

 main関数内でグローバル関数の ::func<ns::S> を呼ぼうとして書いたコードですが、実際にはこれはtemplate実体化が働く前にADLが動いて、ns::func が呼ばれます。そのため、グローバル関数を呼び出す際は ::func(ns::S()) とスコープ解決演算子を前につけた方が良いです。あるいは、ADLガードを書くという手もあります。

namespace ns {
  struct S {};
  namespace ADL_GUARD_ {  // struct Sとは別の名前空間に関数を隠す。inline namespaceはダメ
    void func(S) { std::cout << "ns::func" << std::endl; }
  }
  using namespace ADL_GUARD_;
}

template<typename T> void func(T) { std::cout << "::func" << std::endl; }

int main(){
  func(ns::S());  // ::func<ns::S>が呼ばれる。
  return 0;
}

 これによってADL_GUARD_名前空間内はADLの探索対象から外れるため、正しくグローバル関数の方のfuncが呼ばれました。

2.2. ADLの使い道

 このようにADLは様々な不具合の元となります。ではなぜこんな機能があるのでしょうか? ADLが有効な局面がいくつか存在するからです。今回は演算子オーバーロードとswap関数の呼び出しを例に見てみましょう。

2.2.1. 演算子オーバーロード

 オーバーロードされた演算子を使ったことがある人は意識的にしろ無意識的にしろADLを使っています。

namespace ns {
  struct S {};
  S operator+(const S&, const S&) { return S(); }
}
int main() {
  S s1, s2;
  auto s3 = s1 + s2;
  return 0;
}

 このコードは問題なくコンパイルできますが、よくよく考えてみるとoperator+はns名前空間内に含まれています。つまり、ADLが働いているからこそns名前空間内を自動的に探索してくれて、operator+が見つかるということです。もしADLが働かなかったら、明示的にns名前空間を指定、すなわち、

auto s3 = ns::operator+(s1, s2);

 としなければいけなくなり、演算子をオーバーロードする意味が失われてしまいます。

2.2.2. swap関数

 いろいろな型のオブジェクトをswapするとき、swap関数の呼び方は注意する必要があります。swap関数を独自実装しているならそれを呼び、それ以外のケースではstd::swapを呼ぶというのが最も理想的なswap関数の呼び方でしょう。

namespace ns1 {
  struct S {};
  void swap(S&, S&) { /* ... */ }
}

namespace ns2 {
  struct S {};
}

template<typename T> void swap_ij_1(std::vector<T>& v, int i, int j) {
  std::swap(v[i], v[j]);  // 1. std::をつける
}
template<typename T> void swap_ij_2(std::vector<T>& v, int i, int j) {
  swap(v[i], v[j]);       // 2. 何もつけない
}
template<typename T> void swap_ij_3(std::vector<T>& v, int i, int j) {
  using std::swap;        // 3. usingする
  swap(v[i], v[j]);
}

int main() {
  std::vector<ns1::S> v1(10);            // 独自定義のswapを呼びたい
  std::vector<ns2::S> v2(10);            // std::swapを呼びたい
  std::vector<std::vector<int>> v3(10);  // std::swapを呼びたい

  swap_ij_1(v1, 1, 6);    // std::swap<ns1::S>が呼ばれる。
  swap_ij_1(v2, 1, 6);    // std::swap<ns2::S>が呼ばれる。
  swap_ij_1(v3, 1, 6);    // std::swap<std::vector<int>>が呼ばれる。

  swap_ij_2(v1, 1, 6);    // ADLによりns1::swapが呼ばれる。
  //swap_ij_2(v2, 1, 6);  // error : swap関数がない。
  swap_ij_2(v3, 1, 6);    // ADLによりstd::swap<std::vector<int>>が呼ばれる。

  swap_ij_3(v1, 1, 6);    // ADLによりns1::swapが呼ばれる。
  swap_ij_3(v2, 1, 6);    // std::swap<ns2::S>が呼ばれる。
  swap_ij_3(v3, 1, 6);    // std::swap<std::vector<int>>が呼ばれる。
  return 0;
}

 これを見る限り、swap_ij_3が最も理想的であることがわかります。つまり、template等でswap関数の取りうる引数の型が一つでないとき(swapされる型があらかじめ決まっているときはこの限りではない)、swap関数は

using std::swap;
swap(/*何か*/, /*何か*/);

 と呼び出すのが適切です。この振る舞い方はADLなしでは実現できません。

3. inline指定子

 「こんなもの誰でも知ってるよ、インライン展開されるんでしょ?」と思っている方は危険です。
 「こんなもの誰でも知ってるよ、インライン展開されやすくなるんでしょ?」と思っている方は要注意です。
 inline指定子は関数につけると、C++14まではコンパイラがインライン展開をするヒントになるだけです。インライン展開される保証はありません。そしてC++17からはそもそもインライン展開とはほぼ関係がありません

3.1. inline指定子のC++17以降の意味

 端的にいうと「コンパイルされたオブジェクトファイル内で複数回定義されていてもODR違反としない」という感じです。具体的にどういうことかというと、例えば、
- Header.h : func()の宣言と定義
- Test1.h : g()の宣言
- Test1.cpp : g()の定義、Test1.h、Header.hのインクルード
- Test2.h : h()の宣言
- Test2.cpp : h()の定義、Test2.h、Header.hのインクルード
- Main.cpp : main関数の宣言と定義、Test1.h、Test2.hのインクルード
というようなファイル構成でビルドするとします。

Header.h
#ifndef HEADER_H
#define HEADER_H
void func() { }
#endif // HEADER_H
Test1.h
#ifndef TEST1_H
#define TEST1_H
void g();
#endif // TEST1_H
Test1.cpp
#include "Test1.h"
#include "Header.h"
void g() { func(); }
Test2.h
#ifndef TEST2_H
#define TEST2_H
void h();
#endif // TEST2_H
Test2.cpp
#include "Test1.h"
#include "Header.h"
void h() { func(); }
Main.cpp
#include "Test1.h"
#include "Test2.h"

int main() {
  g(); h();
  return 0;
}

 このとき、リンクエラーが発生してしまいます。なぜでしょう。理由は簡単で、func()が二回定義されているからです。Header.hで一回しか定義されてないじゃんと思うかもしれませんが、そうではなく、オブジェクトファイルに二つ定義ができてしまうということです。つまり、まずTest1.cppをコンパイルすると、Test1.objができますが、この中にはfunc()とg()の定義が含まれます。さらにTest2.cppをコンパイルすると、Test2.objができ、func()とh()が定義されます。すると、Main.cppをコンパイルするときに二つのfunc()の定義が存在することになり、リンカはどちらを用いればよいかわからなくなってエラーを吐くという仕組みになっています。
 C++14まではこれを阻止するには、func()の定義をHeader.cppに移してコンパイルしていました。こうすることで、Header.obj内にfunc()の定義ができ、Test1.cpp、Test2.cpp、Main.cppをコンパイルするときはHeader.obj内のfunc()の定義が利用されます。
 そして、やっと出てきましたが、C++17以降はinline指定子を使うことができ、

Header.h
#ifndef HEADER_H
#define HEADER_H
inline void func() { }
#endif // HEADER_H

 とすることで、オブジェクトファイルで二つ定義されても問題なしとなります。また、この意味で変数にも同様にinline指定子をつけることができるようになりました。
 ちなみに、template関数は直接ヘッダーに書くことが多いですが、templateに関してはもともとC++17より前からすでにオブジェクトファイルで複数回定義されていても大丈夫とされています。言ってしまえば暗黙のうちに(C++17以降における)inline指定がされているとみなせます。また、constexprな関数や、static constexprなメンバ変数も暗黙のinline指定がされます。

3.2. inline指定子(C++17以降)の注意点

 inline指定は場合によっては便利ですが、当然複数回コンパイルすればその分コンパイル時間も伸びますし、実行コードも大きくなります。また、オブジェクトファイルで複数回定義できるということは複数のソースファイル上で二回以上関数定義をかけてしまうということです。関数定義が同じであれば正常に動作しますが、異なる定義を書いてしまうと動作は未定義です。しかもほとんどのケースでエラーが出ません

Test1.h
#ifndef TEST1_H
#define TEST1_H
inline int f() { return 1; }
#endif // TEST1_H
Test2.h
#ifndef TEST2_H
#define TEST2_H
inline int f() { return 2; }  // Test1.hと違う定義 -> 未定義動作!!
#endif // TEST2_H

4. 実行ポリシー

 割と有名かもしれませんが、C++17から自動並列化、ベクトル化をサポートする実行ポリシー(<execution>ヘッダー)が追加されました。実行ポリシーは<algorithm>ヘッダーの各種関数や<numeric>ヘッダーの一部関数等に使用可能です。C++17ではstd::execution::seq(逐次実行)、std::execution::par(並列実行)、std::execetion::par_unseq(並列+ベクトル化)の3つを指定することができます。C++20ではstd::execution::unseq(ベクトル化)を指定できるようになります。

using namespace std;
using namespace std::execution;
int main() {
  vector<int> v(100000);
  srand(clock());
  for (auto& elem : v) { elem = rand(); }
  sort(seq, begin(v), end(v));
  auto it = unique(par, begin(v), end(v));
  rotate(par_unseq, begin(v), it, end(v));
  return 0;
}

4.1. 注意点

 必ずしも並列化、ベクトル化がされるとは限りません。並列化、ベクトル化が高速化において有効と判断された場合にのみ並列化、ベクトル化が行われます。また、データ競合にも気を付けなければいけませんし、std::for_each等で実行順序の保証もなくなります。

// iがデータ競合を起こす可能性あり。また、v = {0, 1, 2, 3, ...} と順番になるとは限らない。
for_each(par, begin(v), end(v), [i = 0](auto& elem) mutable { elem = i++; });

5. std::declval

 1つ以上の引数を取る関数の戻り値の型を知りたい時があります。そんなときに有効なのがこのstd::declvalで、例えば、

template<typename T> auto f(T a) { return a * 10LL; }
template<typename T,    // fの戻り値が整数型の時
    std::enable_if_t<std::is_integral_v<decltype(f(std::declval<T>()))>, std::nullptr_t> = nullptr>
    auto g() {
  return f(2);
}
template<typename T,    // fの戻り値が整数型でない時
    std::enable_if_t<!std::is_integral_v<decltype(f(std::declval<T>()))>, std::nullptr_t> = nullptr> 
    auto g() {
  return f(3);
}

 のようにfにT型の値を渡したときの返り値の型がdecltype(f(std::declval<T>()))でわかります。一瞬decltype(f(T()))ではだめなのかと思うところですが、デフォルトコンストラクタがない型ではエラーになります。

5.1. 注意点

 std::declvalは実際に評価してはいけません。

int main() { return std::declval<int>(); }

 上のコードはリンクエラーとなります。型情報だけが重要なstd::declvalは関数宣言だけされていて定義がありません。

namespace std {
  template<typename _Type> add_rvalue_reference_t<_Type> declval() noexcept;  //宣言のみ
}

6. テンプレート実引数推定ガイド

 変数宣言の際に、テンプレート引数をいちいち書くのは面倒ですよね? autoを使えば幾分か労力は減らせますが、それでもクラス名を書かなければいけないときはあります。そんな時に有効なのがテンプレート実引数推定です。

std::vector v(100, 0.f);  // std::vector<float, std::allocater<float>>

 これはテンプレート引数にデフォルト引数が設定されていない場合でも、コンストラクタの実引数からテンプレート引数を推定してくれます。上の例だと、コンストラクタの第二引数 0.f によって、std::vectorの第一テンプレート引数をfloatに推定してくれます。ちなみに、第二テンプレート引数std::allocater<float>の方はもとからデフォルト引数が設定されています。
 基本的には自動的に推定されますが、C++17以降ではこれを自分で定義することもできます。具体的には

template<typename T> struct S {
  T t;
  S(T t_) : t(t_) {} 
};

template<typename T, std::enable_if_t<std::is_integral_v<T>, std::nullptr_t> = nullptr>
S(T) -> S<long long>;     // 整数型はすべてS<long long>に推定させるガイド
template<typename T, std::enable_if_t<std::is_floating_point_v<T>, std::nullptr_t> = nullptr> 
S(T) -> S<long double>;   // 浮動小数点型はすべてS<long double>に推定させるガイド

int main() {
    S s(1);              // S<long long>   (推定ガイドによる推定)
    S s(1.f);            // S<long double> (推定ガイドによる推定)
    S s("const char*");  // S<const char*> (自動的に推定)
    return 0;
}

 みたいにできます。便利。まあ、推定ガイドの構文は最初は見慣れない構文なので気持ち悪いですが。ちなみに上の例だと実引数にchar型の値を入れてもS<long long>に推定されるので注意です。

7. 範囲forに使える特別な関数

 範囲for、便利ですよね。

std::vector<int> v(10);
for (auto& elem : v) { elem = 10; }  // 各要素に10を代入

 自分で作成したクラスにもぜひこの機能を対応させたくなりますね。範囲forではbegin関数とend関数を定義してやることで、簡単に自分の作成したクラスにも対応させられます。

class arr100 {
public:
  using value_type = int;
  using iterator = value_type*;
private:
  value_type elem[100];
public:
  iterator begin() { return elem; }
  iterator end() { return elem + 100; }
};

int main() {
  arr100 arr;
  for (auto& a : arr) { a = 10; }  // 各要素に10を代入
  return 0;
}

 上の例ではメンバ関数としてbeginとendを定義しましたが、非メンバ関数として定義しても大丈夫です。非メンバ関数の場合はADLが働きます。メンバ関数と非メンバ関数ではメンバ関数が優先されます。C++17まではbeginとendのどちらかがメンバ関数として定義されていると、非メンバ関数があってもメンバ関数を使おうとするので、片方しかメンバ関数が定義されていない場合コンパイルエラーを引き起こしましたが、C++20以降は、両方ともメンバ関数として定義されていない場合は非メンバ関数を探しに行くようになります。
 また、C++14まではbeginとendの返り値の型が同じであることが要求されていましたが、C++17以降は型が異なっても大丈夫です。beginの返り値の型は前置インクリメント可能、参照(operator*)可能、endの返り値の型とのoperator!=による比較が可能でなければなりません。理由はわかりませんが面白いことにbegin、endの戻り値の型がコピー構築、ムーブ構築ともに不能でも動きます。RVOが働くからですかね?

8. 基底クラス初期化

 おまけ。

struct A { int x, y; };
struct B : A { int z; };
struct C { int t, u; };
struct D : B, C { int a; };

int main() {
  // A::x = 1, A::y = 2, B::z = 3, C::t = 4, C::u = 5, D::a = 6としたい時。
  D d = {{{1, 2}, 3}, {4, 5}, 6};
  // イメージ D::{B::{A::{x:1, y:2}, z:3}, C::{t:4, u:5}, a:6}
  return 0;
} 

 C++17以降こんな初期化が可能です。便利。

終わりに

 ほとんどC++17の機能ばかりになってしまいましたね。全部知っていましたか? 他にも、ifとswitch文内の初期化、constexpr if、constexprな文字列操作(string_view)、filesystemなど候補はいくつかありましたが、割と有名な気がしたのでこの記事では触れませんでした。あとあまり使い機会のなさそうな機能も省きました。
 思ったより記事が長くなってしまいましたが、最後まで読んでくださった方はありがとうございます。記事を小分けにしてもよかったかもしれませんね。

おしまい

119
110
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
119
110