[C++]宇宙船演算子のフォールバック処理

宇宙船演算子の導入によって比較演算子の定義が著しく楽になります。ただ、テンプレートな所ではそう単純にはいかない事もあります。

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap&) const = default;
  //Tが<=>を持っていなかったら?
  //Tが参照型だったら???
};

例えばこの様に任意の型を保持するようなクラステンプレートです。この様な型は標準ライブラリにもpairtupleoptionalなどがあります。
このような型ではTによっては<=>がそのまま利用できないかもしれません・・・

この様な時でも<=>だけを書くのがC++20からのC++でしょう。しかし、そんな時でもなるべく楽をしたい・・・

なるべくdefaultにお任せしたい

与えられたT<=>を定義しておらず利用可能でなかったとしても、比較カテゴリ型の指定と< ==演算子からdefault<=>を実装する仕組みがあります。

template<typename T>
struct wrap {
  T v;

  std::weak_ordering operator<=>(const wrap&) const = default;
};

この様に明示的に戻り値型を指定することで、Tが持つ< ==演算子が利用可能であればそれらを用いて<=>を実装してもらうことができます。

しかし、この様に書いてしまうと逆に<=>を使用可能な型に対して戻り値型を明示してしまうことになり、T<=>が返す比較カテゴリ型が指定したものに変換可能でなければコンパイルエラーを引きおこします。

上記の場合、例えばTdoubleの時にエラーになります。しかし、doubleの時は普通にdefault実装が利用可能であるはずです・・・

template<typename T>
struct wrap {
  T v;

  //この2つの宣言を両立できない・・・
  auto operator<=>(const wrap&) const = default;
  std::weak_ordering operator<=>(const wrap&) const = default;
};

この様な場合、指定する戻り値型がTに合わせて変化すればいいはずです。つまり、T<=>を使用可能ならその戻り値型を指定し、使用可能でないなら特定の比較カテゴリ型を指定する、という風になればいいわけです。

template<typename T, typename Cat>
using fallback_comp_cat = std::conditional_t<std::three_way_comparable<T>, std::compare_three_way_result_t<T>, Cat>;

そこでこの様なマジックアイテムを用意します。これを使って先ほどの<=>宣言を修正します。

template<typename T>
struct wrap {
  T v;

  //Tが<=>を使用可能ならそれを使って、そうでないなら< ==を利用して、<=>をデフォルト実装
  fallback_comp_cat<T, std::weak_ordering> operator<=>(const wrap&) const = default;
};

この様にすることでT<=>を使用可能であるかに関わらず、1つの宣言から<=>のデフォルト実装を利用することができます。

//<=>を実装していない型
struct no_spaceship {
  int n;

  bool operator<(const no_spaceship& that) const noexcept {
    return n < that.n;
  }

  bool operator==(const no_spaceship& that) const noexcept {
    return n == that.n;
  }
};

int main()
{
  wrap<no_spaceship> t1 = {{20}}, t2 = {{30}};

  std::cout << std::boolalpha;

  //全て利用可能!
  std::cout << (t1 <=> t2 < 0) << std::endl;
  std::cout << (t1 <  t2) << std::endl;
  std::cout << (t1 <= t2) << std::endl;
  std::cout << (t1 >  t2) << std::endl;
  std::cout << (t1 >= t2) << std::endl;
  std::cout << (t1 == t2) << std::endl;
  std::cout << (t1 != t2) << std::endl;
}

特に、default実装にしておくことで==を暗黙宣言してもらえるのはとても嬉しいです。

ところでfallback_comp_catとは一体・・・

template<typename T, typename Cat>
using fallback_comp_cat = std::conditional_t<std::three_way_comparable<T>, std::compare_three_way_result_t<T>, Cat>;

three_way_comparable<T>C++20から利用可能になる、型が三方比較可能であることを表明するコンセプトです。コンセプトの制約式そのものはbool値を生成する定数式として利用可能です。
それをconditional_tの条件とすることで、T<=>を利用可能であるかに応じて型を変化させます。

利用可能なら、compare_three_way_result_t<T>(これもC++20から利用可能)によってその戻り値型を、利用不可能ならば指定したカテゴリ型(Cat)を返します。

これによって、fallback_comp_cat<T, Cat>T<=>を利用可能なときはその戻り値型になり、利用できなければ指定したカテゴリ型Catになるわけです。

これをdefault<=>の戻り値型に指定してやることで、Tの型に応じて自動で最適なdefault実装を選択してもらうようにするわけです。

defaultを諦めて更にフォールバックする

しかしT==を持っていなかったらどうしましょうか・・・
さすがにそうなるともうコンパイラに頼ることは出来ません。
そのような場合でも、なんとか<演算子だけから<=>を構成したいものです。

弱順序(weak_orderin)における順序付けにおいてなら、!(a < b) && !(b < a)ならばa == bと判断することができます。
そこで、先ほどと同じ感じで<=>を使用可能であるか否かで分けることにしましょう。

template<typename T>
concept less_than_compareble = requires(T a, T b) {
  //<演算子が利用可能であり、戻り値型がbooleanコンセプトを満たすこと
  {a < b} -> boolean;
}

auto fallback_cmp_3way = []<typename T>(const T& a, const T& b)
requires less_than_compareble<T>
{
  if constexpr (std::three_way_comparable<T>) {
    //<=>が使えるならそれを使う
    return a <=> b;
  } else if constexpr (less_than_compareble<T> && std::equality_comparable<T>) {
    //==と<を使って三方比較
    if (a == b) return std::weak_ordering::equivalent;
    if (b < a) return std::weak_ordering::less;
    return std::weak_ordering::greater;
  } else {
    //<だけから三方比較
    if (a < b) return std::weak_ordering::less;
    if (b < a) return std::weak_ordering::greater;
    return std::weak_ordering::equivalent;
  }
};

またこんなマジックアイテムを用意して、これを利用して<=>を定義します。

ちなみに、std::equality_comparableというのはC++20から標準で用意されてるコンセプトで、その名の通り==による比較が可能であることを表明します。

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }
};

こうすることで、T<=>を持っていなかったとしても、最悪<だけから<=>を構成することができるようになります。

なお、<演算子すら持っていなかったらもはや諦めるしかないでしょう・・・

一般化

先ほどのfallback_cmp_3wayは一つの型Tの間でのみ<=>を構成しますが、二つの型の間で同じことをするように一般化しておきたくなってしまうでしょう。

ほとんどそのまま、T, Uの2つの型を受け取るように関連するものを書き換えます。

template<typename T, typename U>
concept less_than_compareble_with = requires(T a, U b) {
  //<演算子が利用可能であり、戻り値型がbooleanコンセプトを満たすこと
  {a < b} -> boolean;
  {b < a} -> boolean;
}

auto fallback_cmp_3way = []<typename T, typename U>(const T& a, const U& b)
requires less_than_compareble_with<T, U>
{
  if constexpr (std::three_way_comparable_with<T, U>) {
    //<=>が使えるならそれを使う
    return a <=> b;
  } else if constexpr (less_than_compareble_with<T, U> && std::equality_comparable_with<T, U>) {
    //==と<を使って三方比較
    if (a == b) return std::weak_ordering::equivalent;
    if (b < a) return std::weak_ordering::less;
    return std::weak_ordering::greater;
  } else {
    //<だけから三方比較
    if (a < b) return std::weak_ordering::less;
    if (b < a) return std::weak_ordering::greater;
    return std::weak_ordering::equivalent;
  }
};

< ==演算子が利用可能かを調べる両コンセプトはT, Uの順番に関わらず比較可能である事をチェックするために、引数順を入れ替えて両方向から比較可能かをチェックしておきます。
ただし、==演算子だけは片方しか使わないので、この用途だけを目的とするならばチェックするのは片方だけでよいかもしれません。

std::three_way_comparable_withC++20より標準で用意されるコンセプトで、2つの型の間で<=>による比較が使用可能であることを表明します。std::equality_comparable_with==について同様のものです。

同値比較演算子の導出

<=>を自前定義してしまうと==はもはや暗黙に宣言されなくなります。そもそも、T==を利用可能でないの場合はdefault==も利用可能ではありません。
そのため、<=>と同様に==も自分で定義してあげる必要があります。

凄く楽をするのであれば、定義済みの<=>を用いて実装することができます。

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  bool operator==(const wrap& that) const {
    //<=>を使って同値比較
    retunr (*this <=> that) == 0;
  }
};

しかし、T==が使えるのならそれを使いたいのが世の常というものでしょう・・・

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  //普段はdefault任せ
  bool operator==(const wrap& that) const = default;

  //Tの==が使えないときにこちらを使用
  bool operator==(const wrap& that) const
    requires (!std::equality_comparable<T>)  //==演算子が使用可能でない
  {
    //<=>を使って同値比較
    retunr (*this <=> that) == 0;
  }
};

T==を使える時はデフォルト実装を使います。この場合、もう片方の==は制約(!std::equality_comparable<T>)を満たさないので曖昧にはなりません。
そして、T==が使えない場合はwrapのデフォルトな==は暗黙deleteされ、もう片方は制約を満たすことから使用可能になり、やはり曖昧にはなりません。

参照型メンバがあるとき

これで完璧!と思われますが、比較演算子はデフォルト実装に頼ってしまうとTが参照型の時に実装されないという問題に直面するかもしれません。

となれば、参照型のためのケアも必要になります。
そんなに難しいことは無く、非デフォルトの方に制約を一つ追加するだけです。

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  //普段はdefault任せ
  bool operator==(const wrap& that) const = default;

  //Tの==が使えないかTが参照型のときはこちらを使用
  bool operator==(const wrap& that) const
    requires (!std::equality_comparable<T> || std::is_reference_v<T>)
  {
    //<=>を使って同値比較
    retunr (*this <=> that) == 0;
  }
};

非デフォルトの方の制約にstd::is_reference_v<T>||で追加します。こうすることで、T==が使用可能でないかTが参照型の時にのみ非デフォルトの===が仕様可能になります。
そして、その場合はいずれもデフォルトの方は暗黙deleteされているので曖昧にはなりません。

とはいえ先ほど同様、Tが参照型であっても==演算子は本来参照先の型による比較になります。となれば、それを使えると良いですよね・・・

template<typename T>
struct wrap {
  T v;

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  //普段はdefault任せ
  bool operator==(const wrap& that) const = default;

  //Tの==が使えないかTが参照型のときはこちらを使用
  bool operator==(const wrap& that) const
    requires (!std::equality_comparable<T> || std::is_reference_v<T>)
  {
    if constexpr (std::equality_comparable<T>) {
      //Tが==を使えるのならばそれを使う
      return v == that.v;
    } else {
      //<=>を使って同値比較
      retunr (*this <=> that) == 0;
    }
  }
};

if constexprstd::equality_comparableでさらに分岐させます。
オーバーロードにしてしまうのも良いかもしれませんが、コンセプトのオーバーロードの半順序は複雑なのでお勧めしません・・・

実は、union-likeな型(共用体そのものか匿名共用体をメンバに持つ型)でも同じ問題が起きますが、それを解決することは難しいので触れないでおきます・・・

pairのような型に対しての拡張

今まで見てきたwrapはしょせん1つのテンプレートパラメータしか受け取らない型です。std::pairのように、2つ以上の型を受けるものに対しても同じことがしたいことがあるかもしれません。

とはいえ難しくは無く、型が増えた分制約が増えるだけです。

template<typename T, typename U>
struct my_pair {
  T first;
  U second;

  auto operator<=>(const my_pair& that) const
    -> std::common_comparison_category_t<decltype(fallback_cmp_3way(first, that.first)), decltype(fallback_cmp_3way(second, that.second))>
  {
    if (auto comp = fallback_cmp_3way(first, that.first); comp != 0) return comp;
    return fallback_cmp_3way(second, that.second);
  }

  //普段はdefault任せ
  bool operator==(const my_pair& that) const = default;

  //TかUの==が使えないかTかUが参照型のときはこちらを使用
  bool operator==(const my_pair& that) const
    requires (
      (!std::equality_comparable<T> || !std::equality_comparable<U>) ||
      (std::is_reference_v<T> || std::is_reference_v<U>)
    )
  {
    if constexpr (std::equality_comparable<T> && std::equality_comparable<U>) {
      //==を使えるのならばそれを使う
      return first == that.first && second == that.second;
    } else {
      //<=>を使って同値比較
      retunr (*this <=> that) == 0;
    }
  }
};

std::common_comparison_category<=>の正しい戻り値型である比較カテゴリ型を複数受け取り、変換可能な最も強い型(共通比較カテゴリ型)を返すメタ関数です。

まあすでに非デフォルト==の宣言がおかしなことになっています、もうちょっと数が増えたら考えたくないですね・・・。

番外編、変換可能な型との比較

wrpa<T>Tに変換可能な型Uとの比較演算子を実装したくなることもあるんじゃないでしょうか。

fallback_cmp_3wayはさっき2つの型の間で比較できるようになったのでそのまま使えそうです。
==は異種型間比較の場合全くdefaultに頼れないので、それによる困ったことを考慮する必要は無さそうです。

template<typename T>
struct wrap {
  T v;

//---自分自身との比較用---

  auto operator<=>(const wrap& that) const -> decltype(fallback_cmp_3way(v, that.v)) {
    return fallback_cmp_3way(v, that.v);
  }

  //普段はdefault任せ
  bool operator==(const wrap& that) const = default;

  //Tの==が使えないかTが参照型のときはこちらを使用
  bool operator==(const wrap& that) const
    requires (!std::equality_comparable<T> || std::is_reference_v<T>)
  {
    if constexpr (std::equality_comparable<T>) {
      //Tが==を使えるのならばそれを使う
      return v == that.v;
    } else {
      //<=>を使って同値比較
      retunr (*this <=> that) == 0;
    }
  }

//---変換可能な型との比較用---

  template<typename U>
  auto operator<=>(const U& other) const -> decltype(fallback_cmp_3way(v, other))
  requires std::convertible_to<U, T>
  {
    return fallback_cmp_3way(v, other);
  }

  template<typename U>
  bool operator==(const U& other) const
  requires std::convertible_to<U, T>
  {
    if constexpr (std::equality_comparable_with<T, U>) {
      //T, U間で==を使えるのならばそれを使う
      return v == other;
    } else {
      //<=>を使って同値比較
      retunr (*this <=> other) == 0;
    }
  }
};

変換可能な型との比較用のものには、それをチェックするためにstd::convertible_toコンセプトによって制約をかけておきます。
あとは、<=>はほぼそのまま、==TUの間で==を使えるかをチェックするだけです。

なお、requires節をどこに置くかは好みで好きにしてください。

参考文献

この記事のMarkdownソース

[C++]コンセプトの5景

C++20にてついに導入されたコンセプト、書け方にムラがあるので少し整理してみます。

1. typenameの代わりに

まず、従来template<typename T>と書いていたところのtypenameの代わりにコンセプト名を書けます。

#include <concepts>
#include <iostream>

//環であるか?
template<typename T>
concept ring = requires(T a, T b) {
  {a + b} -> std::convertible_to<T>;
  {a * b} -> std::convertible_to<T>;
};

template<ring T>
void f(T&&) {
  std::cout << "T is ring" << std::endl;
}

template<typename T>
void f(T&&) {
  std::cout << "T is not ring" << std::endl;
}

//足し算だけはある型
struct S {
  S operator+(S);
};


int main() {
  f(10);
  f(1.0);
  f(S{});
  f(std::cout);
}

/*
出力
T is ring
T is ring
T is not ring
T is not ring
*/

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

ringというコンセプトによって型が環であるかどうかを雑に判定しています。使用しているのは関数f()の1つ目のオーバーロードです。

template<ring T>
void f(T&&);

従来typenameclassを書いていた所にコンセプトを書けます。分かりやすいので基本的にはこう書かれることが多いのではないかと思います。
標準ライブラリでコンセプトが使用される時も基本的にこの形で書かれています。

クラスの場合は次のようになります。

#include <concepts>
#include <iostream>

template<typename T>
concept ring = requires(T a, T b) {
  {a + b} -> std::convertible_to<T>;
  {a * b} -> std::convertible_to<T>;
};


template<typename T>
struct wrap {
  static constexpr char Struct[] = "T is not ring";
};

template<ring T>
struct wrap<T> {
  static constexpr char Struct[] = "T is ring";
};


struct S {
  S operator+(S);
};

int main() {
  std::cout << wrap<int>::Struct << std::endl;
  std::cout << wrap<S>::Struct << std::endl;
}

/*
出力
T is ring
T is not ring
*/

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

クラスの場合はオーバーロード?するのに部分特殊化を利用する必要がありますが、基本的には同じように書くことができます。

ただし、この方法では1つの型に1つの制約しかかけられません。制約を追加したい場合は新しくコンセプト定義を書かなければなりません。

//環かつデフォルト構築可能と言う制約をかけようとしたができない・・・
template<ring && std::default_constructible T>
void f(T&&);
  • メリット
    • 宣言が複雑にならない(相対的に)
  • デメリット
    • 1つの型に1つの制約しかかけられない

以降、このf()wrapだけを見ていくことにします。

2. 前置requires

requires節というものを利用してテンプレートパラメータの宣言のすぐ後に制約を書きます。

//関数
template<typename T>
requires ring<T>
void f(T&&);

//クラス
template<typename T>
requires ring<T>
struct wrap<T>;

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

この形では追加の制約をかけたいときに簡単に書くことができます。

//デフォルト構築可能(std::default_constructible)と言う制約を追加する

//関数
template<typename T>
requires ring<T> && std::default_constructible<T>
void f(T&&);

//クラス
template<typename T>
requires ring<T> && std::default_constructible<T>
struct wrap<T>;
  • メリット
    • &&||で繋いで複数の制約をかけられる
  • デメリット
    • 関数宣言が複雑になりうる

3. 後置requires節 ※関数のみ

先ほどのrequires節、関数の後ろにも置けます。

//関数
template<typename T>
void f(T&&) requires ring<T>;

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

ただし、クラスではこの書き方はできません。関数のみになります。

この書き方ではクラステンプレートの非テンプレートメンバ関数に対して制約をかけることができます。

template<typename T>
struct wrap {
  T t;

  //Tが環である時のみfma()を使用可能
  T fma(T x) requires ring<T> {
    return t * x + t;
  }
};


struct S {
  S operator+(S);
};


int main() {
  wrap<int> wi{10};
  wrap<S> si{};
  
  std::cout << wi.fma(20) << std::endl; //ok、210
  si.fma({});   //コンパイルエラー
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

この様な非テンプレート関数に制約をかけるにはこの書き方をするしかありません。

  • メリット
    • &&||で繋いで複数の制約をかけられる
    • 非テンプレート関数に制約をかけられる
  • デメリット
    • 関数宣言が複雑になりうる
    • クラスで使えない

4. autoによる簡略構文 ※関数のみ

C++20より通常の関数でもジェネリックラムダのようにautoを使って引数を宣言できます。その際にコンセプトを添えることで制約をかけることができます。

void f(ring auto&&);

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

  • メリット
    • 記述量が減る
    • 引数と制約の対応が分かりやすい
  • デメリット
    • 1つの型に1つの制約しかかけられない
    • クラスで使えない
      • 非型テンプレートパラメータの時は使用可能

5. 1と2(or 3)

1の書き方に2の書き方を合わせて書くことで、基本制約+追加の制約みたいな気持ちを込めて書くことができます。標準ライブラリではこの形式で制約されていることが多いようです。

//環でありデフォルト構築可能、という制約をする

//関数
template<ring T>
requires std::default_constructible<T>
void f(T&&);

//クラス
template<ring T>
requires std::default_constructible<T>
struct wrap<T>;

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

クラスでは使えませんがこの時に3の方法(関数名の後)で書いても大丈夫です。

  • メリット
    • 複数の制約を少しすっきりと書ける
    • 基本+追加のような意味を込められる
  • デメリット
    • 1つの型に対する制約が散らばるので見辛くなりうる

4と3

一応書けますよ、という・・・

void f(ring auto&& x) requires std::default_constructible<decltype(x)>;

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

あえてこういう書き方をしたいことがあるのか、わかりません・・・

全部盛り

2つと言わずに全部使っても構いませんよ、もちろん!

//関数
template<ring T>
requires ring<T>
void f(T&&) requires ring<T>;

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

参考文献

謝辞

この記事の9割は以下の方によるご指摘によって成り立っています

この記事のMarkdownソース

[C++]Deprecating volatile を見つめて

※この記事はC++20を相談しながら調べる会 #3の成果として書かれました。

C++20より、一部のvolatileの用法が非推奨化されます。提案文書は「Deprecating volatile」という壮大なタイトルなのでvolatileそのものが無くなるのかと思ってしまいますがそうではありません。
この提案文書をもとに何が何故無くなるのかを調べてみます・・・

そもそもvolatileってなんだろう・・・

長くなったので別記事に分離しました。以下でお読みください。

onihusube.hatenablog.com

C++におけるvolatileの意味とは

  • volatile指定されたメモリ領域はプログラム外部で利用されうるという事をコンパイラに通知
    • 特に、そのような領域は外部から書き換えられうる
  • そして、実装は必ずしもそれを検知・観測できない
  • volatile領域への1回のアクセスは正確に1回だけ行われる必要がある
    • 0回にも2回以上にもなってはならない

そして、volatileの効果は

  • シングルスレッド実行上において
  • volatileオブジェクトへのアクセス(読み/書き)の順序を保証し
  • volatileオブジェクトへのアクセスに関してはコンパイラの最適化対象外となる

となります。

そして、マルチスレッドにおいての同期用に使用すると未定義動作の世界に突入します。マルチスレッド時のための仕組みにはなっていません。

volatileの正しい用法

  • 共有メモリ(not スレッド間)
    • 共有相手はGPU等の外部デバイスや別プロセス、OSカーネルなど
    • 特に、time-of-check time-of-use (ToCToU)を回避する正しい方法の一つ
  • シグナルハンドラ
    • シグナルハンドラ内で行えることは多くなく、共有メモリにシグナルが発生したことを書きこみ速やかに処理を終えることくらい
    • そのような共有メモリにvolatile static変数を利用できる
  • setjmp/longjmp
    • setjmpの2回の呼び出しの間で(つまり、longjmpによって戻ってきた後でも)、変数が変更されないようにするのに使用する
  • プログラム外部で変更されうるメモリ領域
    • メモリマップドI/Oにおける外部I/Oデバイスレジスタなど
    • コンパイラはこのようなメモリ領域がプログラム内でしか使われていない事を仮定できない
  • 無限ループにおいて副作用を示す
    • 無限ループが削除されてしまわないようにvolatileオブジェクトを使用する
    • これはstd::atomicや標準ライブラリのI/O操作によっても代替可能
  • volatileの効果を得るためのポインタキャスト
    • volatileなのはデータではなくコードである、という哲学の下で一般に利用されている
    • volatileへのキャストが有効なのはポインタ間のみであり、オブジェクト間で非volatileからvolatile付の型にキャストしてもvolatileの効果は得られない
  • Control dependenciesを保護する
    • コンパイラの最適化によってこのような依存関係が削除されないようにする
  • memory_order_consumeのいくつかの実装の保護
    • コンパイラの最適化によってデータ依存の順序が崩されないようにする?

この様な正しい用途の中には、volatileの代替となる方法がインラインアセンブリを使う(コンパイラへの指示 or 機械語命令の直接出力)しか無いものがあります。
そのような方法には移植性がありません・・・

なおここには含まれていない正しい用例があるかもしれませんが、そこにマルチスレッド間共有メモリが入ることは決してありません。

この提案(P1152R4)の目的

この提案の目的は、C++標準内で間違っているvolatileの利用を正すことにあります。
volatileが意味がないから非推奨とか、マルチスレッド利用について混乱の下だから非推奨とか言う事ではありません。

そのため、volatileの正しい用法のための文言には一切手を付けていません。たとえそこにいくつかの問題がある事が分かっていても、この提案ではその修正さえもしていません。

また、そのような間違ったvolatileの用法はC++20に対してはとりあえず非推奨としていますが、将来的にそれらを削除することを目指しています。

C++20から非推奨となるコア言語のvolatile

ここから、この提案によって非推奨とされるvolatileの用法を見て行きましょう。その際重要な事は、volatileメモリへのアクセスは読み込みと書き込み、およびその順序に意味があるという事です。

複合代入演算子、インクリメント演算子

復号代入演算子とは+= -= *= /=のような演算子の事です。

インクリメント演算子++ --)と合わせて、これらの演算子は「読み出し - 更新 - 書き込み」という3つの操作を1文で行います。
すなわち、復号代入演算子の左辺オペランドvolatileだった場合に、そのメモリ領域には少なくとも2回のアクセスが発生します。

volatile int a = 0;
int b = 10;

a += b;
//これは以下と等価
//int tmp = a; 
//a = tmp + b;

++a;
//int tmp = a;
//a = tmp + 1;

a--;
//int tmp = a;
//a = tmp - 1;

実際にはこの様な展開はアセンブラコードとしてのものであり、tmpレジスタ上のどこかです。

ですが、復号代入演算子及びインクリメント演算子はこの場合のaに一回しかアクセス(最後の書き込みのみ)しかしないと思われがちです。また、このような一連の操作がアトミックに行われるとも勘違いされがちです。
std::atomicでさえも、このような操作には「読み出し - 更新 - 書き込み」という3つの操作が必要です。

volatileに対するこのような複合操作は明示的に「読み出し - 更新 - 書き込み」を分けて書くか、volatileなatomic操作を利用すべきです。

従って、volatileオブジェクトに対するこれらの演算子はバグの元であり、その使用は適切ではないため、非推奨とされます。
ただし、非推奨となるのは算術型・ポインタ型に対する組み込みの演算子のみです。

連鎖した代入演算子

類似の問題としてa = b = cのように連なった代入演算子の用法があります。

volatile int a, b, c;

a = b = c = 10;
//これは
//c = 10;
//b = 10;
//a = 10;
//それとも
//c = 10;
//b = c;
//a = b;
//もしくは
//c = 10;
//b = c;
//a = c;
//どうなの!?

実際は2番目の形になる様子ですが、この場合のb cにどのような順番で何回の読み書きが発生するのかが不明瞭です。
そのため、この場合のb, cvolatileである場合に限ってこの様な代入演算子の使用は非推奨となります。

volatile int a, b, c;

a = b = c = 10; //ng

int e;

a = e = 10;     //ok

a = e = c = 10; //ng

c = 10;
a = c;          //ok

a = e = c;      //ok

代入演算子が2つ以上連なる場合に、両端にある変数を除いてvolatile修飾された変数が現れてはいけません。ただし、これは非クラス型の場合のみ非推奨です。

関数引数のvolatile、戻り値型のvolatile

関数の引数がポインタや参照ではないのにvolatile修飾されている場合、const修飾でも同様ですが関数の内部では明確な意味を持ちます。

しかし、呼び出し側から見ると引数型のトップレベルのCV修飾の有無は無視され、呼び出し規約もC++コード上では無視されるため、その意味は非volatile引数をとる関数と全く同様になります。 また、わずかとはいえ関数実装詳細が漏洩してしまいます。

//以下関数は全て同じ関数と見なされ、オーバーロード出来ない
void f(volatile int n);
void __fastcall f(const int n);
void __stdcall f(int n);

volatile int g(volatile int n);

int n = 10;
int r = g(n);  //非volatileな変数をコピーして渡し、volatileな戻り値を非volatileな変数にコピーして受ける

仮に引数をvolatileとして扱いたい場合、非volatile引数をvolatileなローカル変数にコピーする方が良いでしょう。処理系によっては、この場合のコピーは省略されます。

同様に、ポインタや参照でないvolatileな戻り値型には意味がありません。GCCやclangでは効果が無いとして警告を出します。

これらの事はvolatileの正しい効果を考えると自明です。この様に、引数及び戻り値型に対するvolatile修飾は無意味であるので非推奨とされます。
これはトップレベルのvolatile修飾がある場合のみ非推奨とされます。従って、volatile修飾されたポインタや参照型の引数・戻り値は以前使用可能です。

void f1(volatile int);  //ng
void f2(volatile int*); //ok
void f3(int volatile*); //ok、f2と同じ引数型
void f4(int* volatile); //ng
void f5(volatile int&); //ok
void f6(int volatile&); //ok、f5と同じ引数型

volatile int  g1(); //ng
volatile int* g2(); //ok
int volatile* g3(); //ok、g2と同じ戻り値型
int* volatile g4(); //ng
volatile int& g5(); //ok
int volatile& g6(); //ok、g5と同じ戻り値型

また、この提案では、const修飾も同様であるとしてまとめて非推奨とする提案を行っていましたが、それは承認されなかったようです。

構造化束縛宣言のvolatile

構造化束縛宣言にもCV修飾を指定できますが、実際の所そのCV修飾は構造化束縛宣言に指定した変数名に直接作用しているわけではありません。
構造化束縛宣言の右辺にある式の暗黙の結果オブジェクトに対してCV修飾がなされます。

構造化束縛宣言の動作の詳細については以下をお読みください。 onihusube.hatenablog.com

その結果オブジェクトがstd::tupleの場合、std::getを用いて要素の参照が行われるため、そこでエラーになります(std::get()volatile std::tuple<...>を受け取るオーバーロードを持たないため)。これはstd::pairでも同様です。
ただ、配列や構造体の場合は意図通りになります。

auto f() -> std::tuple<int, int, double>;

volatile auto [a, b, c] = f();   //コンパイルエラー!!
//ここでは以下の様な事が行われている
//volatile auto tmp = f();
//std::tuple_element_t<0, decltype(tmp)>& a = std::get<0>(tmp);

int array[3]{};

volatile auto [a, b, c] = array; //ok
//ここでは以下の様な事が行われている
//volatile int tmp[] = {array[0], array[1], array[2]};
//volatile int a = tmp[0];

static_assert(std::is_volatile_v<decltype(a)>); //ok

この様な非一貫的な挙動及び、前項の関数の戻り値型のvolatileと同様の無意味さがあることから、構造化束縛宣言に対するvolatile修飾は非推奨とされます。
もし構造化束縛にvolatile修飾したい場合は、分解元の型の要素・メンバに対してvolatile修飾しておくべきであり、volatileの用途としてはおそらくそれが適切でしょう(構造化束縛の名前自体は変数名ではないので・・・)。

auto f() -> std::tuple<int, int, double>;

volatile auto [a, b, c] = f();  //ng

auto g() -> std::tuple<volatile int,volatile int,volatile double>;
auto [a, b, c] = g();  //ok

static_assert(std::is_volatile_v<decltype(a)>); //ok
static_assert(std::is_volatile_v<decltype(b)>); //ok
static_assert(std::is_volatile_v<decltype(c)>); //ok

C++20より非推奨となる標準ライブラリ内のvolatile

当初の提案は標準ライブラリ内にあるvolatileに関する所も非推奨とする提案を含んでいましたが、のちにそれは別の提案(P1831R1 Deprecating volatile: library)として分離され、その後同様にC++20に採択されました。

標準ライブラリ内テンプレートのvolatileオーバーロード

標準ライブラリ内で提供されているクラステンプレートにはvolatile用にオーバーロード(部分特殊化)が明示的に提供されているものがあります。

  • numeric_limits
  • tuple_size
  • tuple_element
  • variant_size
  • variant_alternative
  • std::atomic関連

このうちnumeric_limitsは有用性があるという事でそのままとのことです。また、std::atomic関連は次の項で説明します。

tuplevariantはその実装がどうなっているのか規定されておらず、その実装にどのようにvolatileアクセスするのかが不明瞭です。
また、標準ライブラリのその他のクラステンプレートは特にvolatile修飾を意識して書かれておらず、一貫していません。

従って、std::atomic関連とnumeric_limitsを除くこれらのクラステンプレートのvolatileに対する部分特殊化は非推奨とされます。

std::atomicvolatileメンバ関数

volatileオブジェクトの操作はアトミックではなく、その順序も変更される可能性がります(非volatileオブジェクトとの間の相対順序やCPUのアウトオブオーダー実行時)。しかし、そのようなアクセスは確実に実行され、volatileな領域の各バイトに対して正確に1度だけアクセスし、コンパイラによる最適化の対象とはなりません。

std::atomicオブジェクトへの操作は分割されることは無く、完全なメモリモデルを持ち、それらは最適化の対象となります。

volatile std::atomicはこれらを合わせた性質を持つことが期待されますが、現在の実装はそうなってはいません。

提案文書によれば、ロックフリーではないアトミック操作(の実装)はvolatileオブジェクトに対して行われたときに、その原子性が失われることがある(アクセス順序や回数の変更ができないことから?)。とされています。

さらに、複合代入のような「読み出し - 更新 - 書き込み」操作の実装は特に指定されておらず、実装としては、再試行ループ、ロック命令、トランザクショナルメモリ、メモリコントローラの操作等の実装方法がありますが、volatile領域に正確に1度だけアクセスするという事を達成できるのはメモリコントローラの操作という実装だけです。
volatileの効果を適切に再現するためにはこうした実装を指定する必要がありますが、この様なハードウェアでの実装を指定することはC++標準の範囲から逸脱しています。

このように、現状のvolatilestd::atomicの同時利用は必ずしも両方の特性を合わせたものにはなっておらず、場合によってはどちらかの性質が失われていることがあります。

この様な理由から、std::atomicの全てのメンバ関数volatile修飾版及び、フリー関数のstd::atomic_initvolatileオーバーロードが、std::atomic<T>::is_always_lock_free == falseとなる特殊化に対してのみ非推奨とされます。

検討されていた他の候補

メンバ関数のvolatile修飾

volatile修飾メンバ関数は特殊な場合を除いて通常使用されません。これはSTLのクラスがconstメンバ関数を用意していてもvolatileメンバ関数を用意していない事からも伺うことができます。
それに対応しようとすると、あるメンバ関数を定義するのにその記述量が倍になってしまう(CV無し+C有+V有+CV有)割にその恩恵が不明瞭で使用頻度も低い為だと思われます。

クラスはconstでもそうでなくても利用でき、そのconst修飾の有無でメンバ関数の挙動を変えることができます。この有用性は誰もが認めることだと思われます。しかしvolatileはどうでしょうか?

クラスがvolatile修飾されているとき、そのオブジェクトは外部から変更されうる領域に置かれていることになります。しかし果たして、そのようなクラスがvolatileとしてもそうでない場合も使えるように設計される必要は本当にあるのでしょうか?そしてその場合、volatile修飾されたメンバ関数とそうでない関数の違いとは一体何でしょうか・・・・

また、あるオブジェクトに対するconst修飾の効果が表れるのは、そのオブジェクトの最派生コンストラクタの呼び出しが完了したときであり、コンストラクタ内ではそのメンバに対するアクセスでもconst性はありません(これはデストラクタも同様)。
これはvolatileでも同様ですが、volatileの場合はその領域は外部から変更されうる場所であり、その領域へのアクセス順序や回数には意味があるはずです。その時、volatile性なしでオブジェクトの構築・破棄を行う事は適切ではありません。

このように、volatile修飾メンバ関数は実質的に無意味であるため、非推奨とする事が提案されていました。

ただし、上記の事を踏まえても、集成体(aggregate)はvolatileで適切に使用されている可能性があります。その場合volatile修飾メンバ関数が非推奨となると、そのような集成体の全てのメンバを再帰的にvolatile修飾して回ることになってしまいます。
そのため、この文書では3つのアプローチを提案していました。

  1. 全てのメンバ関数volatile修飾相当である、又は全くそうではない、という事を規定する
  2. 例えばstruct volatileのように、volatileで使用される集成体の宣言を追加する
  3. 集成体ではvolatileを禁止する(PODクラスとフリー関数を使用するようにする)

いずれの場合でも、メンバ変数のvolatile修飾を禁止しません。
提案文書の著者は3番目の方法を押している雰囲気でした。

他にも共用体やビットフィールドの扱いなど考慮すべきところはあったようですが、結局これはC++20では見送られました。
std::atomicvolatileメンバ関数の意味合いについて検討する必要があったためのようです。ただ、volatileメンバ関数の非推奨については合意を得られていたようなので将来的には非推奨とされる可能性があります。

volatile_load<T> / volatile_store<T>

正確に言えばこの提案には含まれていませんでしたが、この提案から派生した提案として、volatileな値の読み書きを行う特別な関数の提案が出されています。

namespace std {
  template<typename T>
  constexpr T volatile_load(const T* p);

  template<typename T>
  constexpr void volatile_store(T* p, T v);
}

このstd::volatile_load<T>std::volatile_store<T>は引数として渡されたメモリ領域から値を読み込み、また書き込みます。
そこでは引数がvolatileかどうかに関わらず、volatileセマンティクスが適用されます。すなわち、1回のアクセスは正確に1回だけpの指すメモリ領域へアクセスし、この関数は最適化対象外となりその順序の入れ替えも行われません。
また、単なるvolatileオブジェクトへのアクセス以上の保証を与えているようです(可能な限りアクセスを分割しない等)。

これは、volatileなのはデータ(変数)ではなくコードであるという考え方を体現したものといえ、Linuxカーネルで使用されるREAD_ONCE()/WRITE_ONCE()マクロや、D言語peek()/poke()、Rustのstd::ptr::read_volatile()/std::ptr::write_volatile()と同様のアプローチです。

ここまで見てきた(そしてこの後紹介する)ような、volatile修飾の間違った用例を見ると、このアプローチの方が正しいのでは?という気もしてきます・・・

合意は取れているようなので将来的にC++に入ることは間違い無いと思われますが、C++20に入るかは不透明です。

不適切と思われるvolatileの用例

最後に、提案文書にある不適切なvolatileの使用例をコピペ紹介しておきます。上記の事の理解の一助になるかと思われます。

struct foo {
  int a : 4;
  int b : 2;
};
volatile foo f;

//どんな命令が生成されるでしょう?また、領域に何回アクセスするでしょう??
f.a = 3;
struct foo {
  volatile int a : 4;
  int b : 2;
};
foo f;

f.b = 1; // aの領域へアクセスする?
union foo {
  char c;
  int i;
};
volatile foo f;

//これはsizeof(int) [byte]の領域へアクセスする?それとも、sizeof(char) [byte]だけ??
f.c = 42;
volatile int i;

//それぞれ何回領域アクセスが発生するでしょう?
i += 42;
++i;
volatile int i, j, k;

//iへの代入時にjの値を再読み込みしますか?
i = j = k;
struct big { int arr[32]; };
volatile _Atomic struct big ba;
struct big b2;

//この操作はatomicに行われる?
ba = b2;
int what(volatile std::atomic<int> *atom) {
    int expected = 42;

    //ここでは*atomの領域に何回のアクセスが起こるでしょう?
    atom->compare_exchange_strong(expected, 0xdead);
    
    return expected;
}
void what_does_the_caller_care(volatile int);
volatile int nonsense(void);
struct retme { int i, j; };
volatile struct retme silly(void);
struct device {
  unsigned reg;
  device() : reg(0xc0ffee) {}
  ~device() { reg = 0xdeadbeef; }
};
volatile device dev; //初期化(コンストラクタ内)、破棄(デストラクタ内)はともにvolatileではない

参考文献

謝辞

この記事の7割は以下の方々によるご指摘によって成り立っています。

この記事のMarkdownソース

[C++]volatile修飾子についての考察

※この記事はC++20を相談しながら調べる会 #3の成果として書かれました。

P1152R4 : Deprecating volatileを読み解く過程に生じたvolatileについての調査(脱線)メモです。ほぼ文章です。

C++におけるvolatileの効果

C++におけるvolatile指定の効果は次のように規定されています(6.9.1 Sequential execution [intro.execution])。

Reading an object designated by a volatile glvalue ([basic.lval]), modifying an object, calling a library I/O function, or calling a function that does any of those operations are all side effects, which are changes in the state of the execution environment.

volatileオブジェクト(メモリ領域)へのアクセス(読み/書き)、およびそれを実行する関数呼び出しは全て副作用(side effects)である!と言っています。

そしてそのあとに以下のように書いてあります。

Every value computation and side effect associated with a full-expression is sequenced before every value computation and side effect associated with the next full-expression to be evaluated.

完全式(full-expression)の全ての値の計算および副作用(side effects)は、次の完全式の全ての値の計算および副作用の評価の前に位置付けられる(sequenced before)、と言っています。
ここでの完全式(full-expression)とは、セミコロン;で終わる一つの式のことです。変な書き方をしていなければ、通常それは1行で書かれていると思います。

ついでにsequenced beforeと言うのも見てみると

Sequenced before is an asymmetric, transitive, pair-wise relation between evaluations executed by a single thread ([intro.multithread]), which induces a partial order among those evaluations.
Given any two evaluations A and B, if A is sequenced before B (or, equivalently, B is sequenced after A), then the execution of A shall precede the execution of B.

sequenced beforeとは、シングルスレッド実行においての 評価間の関係のことで、評価Aが評価Bよりも前に位置づけられる(sequenced before)場合、Aの実行はBの実行より前に行われる、と書かれています。シングルスレッドです。

もう一つ、C++規格がその動作の対象としている抽象機械の観測可能な動作(observable behaviorのところに次のようにあります

Accesses through volatile glvalues are evaluated strictly according to the rules of the abstract machine.
〜中略〜
These collectively are referred to as the observable behavior of the program.

volatileオブジェクトへのアクセスは抽象機械の規則に従って厳密に評価される。〜中略〜 これらは、プログラムの観測可能な動作(observable behavior と呼ばれる、とあります。

そして、4.1.1 Abstract machine [intro.abstract]/54.1.1 Abstract machine [intro.abstract]/1にある様に、C++の適合実装(コンパイラ及び実行環境)はこの抽象機械上プログラムの 観測可能な動作 をエミュレートするように実装され、それ以外の事をエミュレートする必要はありません。
volatileな変数へのアクセスはこの観測可能な動作に含まれていないので、最終的なプログラムの出力に影響を与えない範囲において、それらのアクセス・計算は削除されたり順序が変わったりします。これこそがコンパイラに許可されている最適化の範囲です(この事を「as-ifルール」と呼びます)。

volatileオブジェクトへのアクセスおよびその先で起こることは全て副作用(side effects)になりますが、観測可能な動作(observable behavior)はそのうちのvolatileオブジェクトへのアクセスそのものしか含んでいません。
従って、コンパイラが気にするのはvolatileオブジェクトへアクセスしたかどうかだけであり、その先で起こる事については感知しなくてもいいわけです。
またこのことから「観測可能な動作⊂副作用」と言う関係が成り立ち、副作用は必ずしも最適化対象外にあるわけではないこともわかります。

これらの条文から、C++が規定するvolatile指定の効果とは

  • シングルスレッド実行上において
  • volatileオブジェクトへのアクセス(読み/書き)の順序を保証し
  • volatileオブジェクトへのアクセスに関してはコンパイラの最適化対象外となる

と言う事だとわかります。

この事は次の様な意味でもあります。

  • volatileオブジェクトへのアクセス(読み/書き)の順序を保証し
    • C++コード上で書いた通りの順序になる事を保証
  • volatileオブジェクトへのアクセスに関してはコンパイラの最適化対象外となる
    • volatileオブジェクト(の関与する部分)は最適化されない、と言うことと等価

マルチスレッドとvolatile

マルチスレッド時に同期のために用いられる変数には以下3つの性質が必要です。

  • 原子性(atomicity
    • データの読み書きが分割不可能な1操作で行える事
    • データを読み出した際に別のスレッドが更新途中の中途半端な値を読み出さない事が保証される
  • 可視性(visibility
    • あるスレッドにて変数に書き込まれた結果が、別のスレッドから(いつかは)観測できる事
  • 順序性(ordering
    • あるスレッドから見た別スレッド上のメモリアクセスが同一の順序であるように観測できる事

volatileの保証する効果は上記のようにシングルスレッドにおいてのものであり、マルチスレッドにおいてはなんら記述がないのでこの3つの性質の全てを満たしません。volatile変数をスレッド間同期等に利用すると常に未定義動作の世界にこんにちはします。

原子性(atomicity)や可視性(visibility)に関してはなんら関わっていない事が直ぐにわかるでしょう。
順序性(ordering)だけは、概念的に少し重複する部分があるので保証されるように見えてしまうかもしれませんが、volatileが保証するアクセス順序はシングルスレッド上のvolatile変数間のものだけです。やはりマルチスレッドでは何の意味も持ちません。

マルチスレッドでのスレッド間同期にはstd::atomicと適切なメモリバリアを利用しましょう。volatileは無力です。

ただ、他のプログラミング言語の中にはvolatile指定の効果に、C++volatileの効果に加えてstd::atomicの持つ一部の性質がくっついている事があります。例えばC#Javaがそうですが、このような言語においてはその規則の範囲内において期待する効果が得られるかもしれません。
これがvolatileの勘違いの原因の一つでもある気がしますが・・・・

用途と副次的効果

ではこのvolatileオブジェクトへのアクセス、というのはいつ有効に活用できるのでしょうか?
おそらく一番のユースケースは、メモリマップドI/O方式を採用している環境においてI/Oアクセスを行う時に使用する変数に付加することです。
メモリマップドI/O方式というのは、同一のアドレス空間上にメインメモリのアドレスと外部I/Oデバイスのポートやレジスタ、メモリ等のアドレスをマッピングする事でメインメモリアクセスと同じようにI/Oアクセスできるようにする仕組みのことです。

#include <cstdint>

#define IO_ADDR 0xffffff;  //どこかのI/Oポートを指すアドレス

int main() {
  volatile std::unit16_t* io_ptr = IO_ADDR;

  *io_ptr = 0x01;       //I/Oに書き込み
  auto data = *io_ptr;  //I/Oから読み込み
  *io_ptr = 0x80;       //I/Oに書き込み
}

例えばこのような場合、IO_ADDRのアドレスの指す先は外部デバイスレジスタであり、その領域へのアクセスはI/Oという意味を持ちます(例えばLチカだったり、センサからのデータ取得だったり・・・)。
しかし上記プログラムはコンパイラから見ると何の意味もない処理でしかなく、最適化の結果io_ptrへのアクセスは消去可能です。

このようなI/Oアクセスはこのコード上からは観測不可能な外部デバイスで何かしらの意味を持ち、アクセス及びその順序にもデバイス毎に違った意味がある筈です。
そのため、そのアクセスを消されたり順序を変えられてしまうと意図通りの結果を得ることができなくなるので、volatileの出番というわけです。

この様な意味から考えると、volatileなオブジェクト(volatileと指定されたメモリ領域)へのアクセスは、CPU内部のキャッシュ機構を通り越してその領域へ直接読み書きする形になっているはずです。そうしないとこのような目的を達せないためです。
それはすなわち、volatileな領域への書き込みはすぐにそのメモリ領域に書き込まれ、読み込みは常にそのメモリ領域から最新のデータを読みだしてくることになります。

この副次的な効果として、マルチスレッドからvolatileオブジェクトを通してメモリ領域を読み書きした場合、CPU内部のキャッシュが介在することによって生じるデータ同期の遅延が生じなくなります。
また、volatileの効果によってシングルスレッド上では少なくともvolatile領域への読み書きの順序が入れ替わることはないため、結果としてマルチスレッド間でアクセス順序が意図通りに行われているように見える事もあるでしょう。
そして、CPUのデータバス幅未満のサイズの変数でアライメント要求などを満たしていれば、その読み書きはatomicに行われうるため、CPUと型のサイズの組み合わせによっては変な値を読みだしてしまう事が起きないかもしれません。

すなわち、マルチスレッド間のvolatile指定した変数アクセスが原子性、可視性および順序性を満たしているかのように見えることがあるかもしれません。
無論これは保証されるようになったわけではなく、未定義動作の範疇としてたまたまそうなっているだけです。完全に砂上の楼閣であり、1つでも条件が変われば地獄に落ちるかもしれません。
結局、volatile指定をマルチスレッド処理の同期用などに用いることは間違っています、std::atomicと適切なメモリバリアを利用しましょう。

この様な事が起こりうるのもvolatileの効果についての混乱の原因の一つであるのでしょう・・・

アウトオブオーダー実行とvolatile

volatileオブジェクトへのアクセスはコンパイラの最適化の対象とならず、その順序が保たれたコードが出力されることが保証される、という事は分かりました。
しかし、このようなメモリ領域へのアクセス順はもう一つの所で変更される可能性があります。それは、CPUにおけるアウトオブオーダー実行時です。

アウトオブオーダーにおいては、最終的な結果が変わらない範囲において命令順を並べ替えて効率的に実行しようとします。この時、計算の順序だけでなく、メモリアクセスの順番も変化しえます。

C++抽象機械の観測可能な動作を厳密にエミュレートするのであれば、アウトオブオーダー実行においてもその順序を維持するという事は変わらないように思えます。実際、それはメモリバリア命令を挿入するなどによって実現可能です。

しかしどうやら、C++標準はこれを保証しないようです。

C++抽象機械としてのvolatileオブジェクトへのアクセスは観測可能な動作(observable behavior)として実装は厳密にエミュレートしなければならず、結果的にC++標準が定義するsequenced before関係が保たれたプログラムが出力されます。
しかし、観測可能な動作(observable behavior)とはあくまで抽象機械上のことであり、実際の観測可能な順序(observable ordering)に関してを何ら規定していません。

従って、C++標準はアウトオブオーダー実行によって実行時に命令が並べ替えられてvolatileアクセスの順序が変更されないことを保証していません。
もやもやしますが、これがC++の現在の見解のようです・・・・

この様な場合に命令順序を入れ替えられたくない場合、適切なメモリバリア命令を明示的に利用するか、std::atomicを利用することができます。
デフォルトのstd::atomic変数の各種オペレーションはマルチスレッドにおける逐次一貫性(sequential consistency)を保証します。
このため、シングルスレッドにおいてもメモリバリアがその読み書きにおいて適切に利用されるため、アウトオブオーダー実行でも順序関係が維持されるようになります。

参考文献

謝辞

この記事の9割は以下の方々によるご指摘によって成り立っています。

この記事のMarkdownソース

[C++]構造化束縛の動作モデルとラムダキャプチャ

一部のコンパイラでは構造化束縛宣言で導入された変数をラムダ式によってキャプチャすることができません。
実は、厳密に規格にのっとればC++17ならば出来ない、C++20からはできる、が正しい動作になります。

ただ、規格を眺めてもC++17でできず、C++20できるようになる理由を見出せません。なぜなら、結果的にどこにも書かれていないからです・・・

C++17における構造化束縛宣言の動作モデル(N4659時点の仕様)

※ここの項目は構造化束縛宣言の仕様を読み解くことに熱を入れすぎて脱線しています。構造化束縛宣言に指定する名前は変数名ではない、という結論だけ頭に入れて次に進んでも構いません・・・。

構造化束縛宣言は変数宣言のように見えますが、その見た目と用途ほど動作は単純ではありません(しかし、通常はこれを知る必要はありません・・・)。

構造化束縛宣言は次のような形式の宣言です。

  • attribute-specifier-seq(opt) decl-specifier-seq ref-qualifier(opt) [identifier-list] initializer ;

何書いてあるのか良く分からないので説明のために単純化します。属性指定(attribute-specifier-seq)は多分使わないので省略し、decl-specifier-seqはCV autoの事なのでそう書き直し、参照修飾(ref-qualifier)も直接書いておきます。
また、初期化子(initializer)には=, (), {}の3つの形が使用できますが、専ら使うのは=だけだと思うので=の形だとしておきます。

  • (cv) auto (&|&&) [identifier-list] = expression;

構造化束縛宣言はまず、右辺にあるexpressionの結果となるオブジェクト(resultと名付けておきます)を受けるための暗黙の変数を導入します。名前をtempとでもしておきます(規格書中ではe)。
この変数tempの型の決定及び初期化は次のように決まります。

  • resultの型が参照型でない型Aの配列なら、(cv) Aの配列(CV修飾は構造化束縛宣言のcvから)
    • tempの各要素は対応するresultの要素からコピーor直接初期化される
  • それ以外の場合、構造化束縛宣言の[identifier-list]tempで置き換えた式によってtempを定義する
    • (cv) auto (&|&&) temp = result;

この様な決定の仕方によって、構造化束縛宣言に指定されているCV・参照修飾(及び属性指定)は暗黙の変数tempに対して適用され、identifier-list内の変数名に適用される訳ではない事が分かります。

この様に初期化されたtempの、参照を除去した型をEとすると(E = std::remove_reference_t<decltype(temp)>)、Eの型に応じて次の3つのパターンに分岐します。

  1. Eが配列型
  2. Eがtuple-likeな型
  3. Eが構造体

配列型の場合

[identifier-list]内の変数名を順にviとすると、各vitempi番目の要素を示す左辺値の名前です。
Eの要素型を(cv) Tとすると参照される型(cv) Tであり、そのCV修飾は構造化束縛宣言の指定によります。

当然ですが、tempresult)の要素数[identifier-list]内の名前の数は一致している必要があります。

tuple-likeな型の場合(user-defined case)

tuple-likeな型というのはstd::tupleと同様に扱うことができるような、std::tuple_size<T>std::tuple_element<N, T>が適用可能な型の事です。

まず前提として、std::tuple_size<E>::valueが整数定数として取得可能であり、その値が[identifier-list]内の変数名の数と一致していなければなりません。

ここでのtempの値カテゴリは、Eが左辺値参照であればlvalue、そうでなければxvalueになります。
Eが左辺値参照となるのは、構造化束縛宣言の参照指定がauto&の場合、もしくはauto&&resultがlvalueである場合です。すなわち、ここでは完全転送が行われます。

次に、メンバ関数temp.get<i>()、見つからなければフリー関数のget<i>(temp)を探索します。説明のため、以降ここで見つかった関数を単にget<i>(temp)と統一します。

最後に、[identifier-list]内の変数名を順にviとすると、各viget<i>(temp)を初期化子として初期化された参照型の変数になります。
その型Tistd::tuple_element<i, E>::typeで与えられ、参照修飾は対応する初期化子get<i>(temp)の結果が左辺値なら&、そうでなければ&&になります。

iに対して、参照される型は参照修飾無しのTiとなります(すなわち、std::tuple_element<i, E>::type)。

このケースの場合は、std::tuple_sizestd::tuple_element、及びget()を適切に定義してやることで任意のユーザー定義型をアダプトできることから、user-defined caseと呼ばれるようです。

構造体の場合

構造体の直接のデータメンバをその宣言順にmiとして、[identifier-list]内の変数名を順にviとすると、各vitempi番目のデータメンバ(temp.mi)を示す左辺値の名前です。

それらviの型Tiは対応するmiの宣言された型であり、CV修飾はEと同一(つまり構造化束縛宣言のcv指定)になります。
参照される型(cv) Tiになります。

この時、対応するmiがビットフィールドならばviもビットフィールドになります。

参照される型

各ケースでそれぞれ参照される型として定義されている型は、構造化束縛宣言の[identifier-list]内のそれぞれの識別子をdecltypeしたときに返される型に当たります。

これらの手順を見れば、構造化束縛宣言に指定している属性指定・参照修飾はその右辺の結果を受けている暗黙のオブジェクト(temp)に対して行われ、CV修飾もかならずしも変数名として指定している[identifier-list]内の名前に伝播しないことが分かるでしょう。

identifier-list内の名前の扱い

「示す左辺値の名前」と言っているように、2番目のtuple-likeな型の場合を除いて構造化束縛宣言の[identifier-list]内の識別子名は変数名として導入されません。
その名前はあくまで暗黙のオブジェクトの対応する要素・メンバの別名として使われるものであり、C++コードの意味論としての参照ですらありません。

そのため、それらの名前は変数名ではないのでラムダ式でキャプチャすることができない、ということのようです。

C++17規格(N4659)完成後の変更

N4659で規定されている構造化束縛宣言では、tuple-likeの場合のみ[identifier-list]内の識別子名が変数名として導入されます。すなわち、その場合のみそれらの名前は普通の変数として扱うことができます。もちろんラムダによるキャプチャも。
ですが、この挙動は構造化束縛宣言の挙動としては一貫性を欠いており、多くのC++ユーザーからしてみれば不可思議な挙動にしか見えません。

そのため、C++17規格完成後に欠陥報告としてCore issue 2313が採択され、tuple-likeの場合も変数名を導入しないように変更されました。

先ほどの手順では最後の所が次のように変更されます。

最後に、新しい変数名riを導入し、各riget<i>(temp)を初期化子として初期化された参照型の変数になります。
その型Tistd::tuple_element<i, E>::typeで与えられ、参照修飾は対応する初期化子get<i>(temp)の結果が左辺値なら&、そうでなければ&&になります。
[identifier-list]内の変数名を順にviとすると、各viは対応するriが参照するオブジェクトを示す左辺値の名前です。 その型は参照なしのTiであり、参照される型も同様にTiとなります。

これによって、構造化束縛宣言の[identifier-list]内の識別子名は常に変数名として導入されることはなくなり、ラムダでのキャプチャもできなくなりました。
これがC++17規格としての最終的な挙動になります。

その後、C++20を対象としたP0588R1 : Simplifying implicit lambda captureによって、「ラムダ式は明示的にも暗黙的にも構造化束縛宣言で導入された名前をキャプチャしてはならない」と明記されました。

めでたしめでたし・・・。

C++20での構造化束縛宣言の拡張

しかし、その後の構造化束縛の扱いを通常の変数宣言に近づける提案P1091R3 : Extending structured bindings to be more like variable declarationsの採択によって、構造化束縛宣の[identifier-list]内の識別子名をラムダ式がキャプチャできるようになりました。
この結果、P0588R1で明記されたラムダは構造化束縛宣の導入する名前をキャプチャできない、という文章は削除されました。

P1091R3には「構造化束縛をラムダがキャプチャできないことを禁止する技術的な理由はないようだ」のように書かれています。確かに、参照している名前をそのままラムダ内部までもっていけばいいだけなので最初からこうなってほしかった感があります・・・
また、P0588R1にも備考として「この(構造化束縛をキャプチャできないという)構造化束縛に関する文言はプレースホルダであり、(後程)構造化束縛のラムダキャプチャに必要な文言に置き換えられる」という風に書いてあるので、元からこれを見据えていたようです。

これがC++20としての挙動であり、結果的に正式な規格書にはラムダが構造化束縛宣の導入する名前をキャプチャできない、という文章が載ることはありませんでした・・・

ちなみに、C++20ではこのほかにも構造化束縛宣言に対して変更が入っています。上記C++17時点の処理モデルから大きな変更はありませんが細部が異なっています。
ただし、構造化束縛宣言の[identifier-list]内の識別子名が変数名として導入されるようにはなっていません。

余談:なぜこんな回りくどいことに・・・?

このような複雑な処理手順を規定しているのは、結果オブジェクトのサブオブジェクトをコピーする回数を最小にするためだと思われます。

構造化束縛以前のstd::tieを使ったコードを見ると、この処理は右辺の結果を受けて暗黙のオブジェクトを初期化したうえで、指定されている変数に対応する要素をコピーして初期化していることが見えます。

構造化束縛も単純に行ってしまえばそうなりますが、パフォーマンスの観点から見れば右辺の結果オブジェクトを初期化したうえでそのサブオブジェクトをさらに別の変数にそれぞれコピーするのは明らかに無駄です。
しかし、それをしなければ構造化束縛のうまみは皆無です・・・

この葛藤を解決するのが、プログラマからみるとローカル変数に右辺のオブジェクトを分解しているように見え、コンパイラから見ると右辺の結果オブジェクトのサブオブジェクトをそのまま利用しているように見える、というこの動作モデルなのだと思われます。

構造化束縛(Structured binding)という名前も、指定した名前を右辺の分解可能な型の要素に対してstructuredに(構造的に : 一対一で)bindして(結びつけて)同一として扱う、みたいな意味を見出せるかもしれません・・・

参考文献

この記事のMarkdownソース

[C++]de Bruijn sequenceを用いたLSB/MSB位置検出テクニック

de Bruijn sequence(ド・ブラウン列)

de Bruijn sequenceとは、いくつかの文字である長さの文字列を作ることを考えた時、その組み合わせの全てを重複なく含んだ文字列のことを言います。

例えば、文字a, bを使った長さ3の文字列はaaa, aab, aba, baa, abb, bab, bbb, bbaの8通りなので、このde Bruijn sequenceは例えばaaababbbになります(通常いくつか存在します)。
aaababbbを先頭から3文字、1文字づつ右にずらしながら見ていくと(途中で巡回します)、確かに8通りの組み合わせ全てを含んでおり、重複が無い事が分かります。

aaababbb
aaa|||||
 aab||||
  aba|||
   bab||
    abb|
     bbb
      bba (先頭へ戻る
       baa

文字a, b0, 1に置き換えてやれば、2進数列のde Bruijn sequenceを考える事ができそうです。

de Bruijn sequenceによるLSB位置検出

この2進数列のde Bruijn sequenceを用いて、高速にLSB位置を検出するアルゴリズムがあります。

Wikipediaより

unsigned int v;   
int r;

static const int MultiplyDeBruijnBitPosition[32] = 
{
  0, 1, 28, 2, 29, 14, 24, 3, 30, 22, 20, 15, 25, 17, 4, 8, 
  31, 27, 13, 23, 21, 19, 16, 7, 26, 12, 18, 6, 11, 5, 10, 9
};
r = MultiplyDeBruijnBitPosition[((uint32_t)((v & -v) * 0x077CB531U)) >> 27];

これによって符号なし32bit整数値の最下位ビット位置を得ることができます。
何やってるのかさっぱりわかりません・・・

なにがおきているの?

少しコードを整理してみます(ついでにC++的になおします)。

int lsb_pos(std::uint32_t v) {

  //1
  static constexpr int MultiplyDeBruijnBitPosition[32] = 
  {
    0, 1, 28, 2, 29, 14, 24, 3, 30, 22, 20, 15, 25, 17, 4, 8, 
    31, 27, 13, 23, 21, 19, 16, 7, 26, 12, 18, 6, 11, 5, 10, 9
  };

  //2
  std::uint32_t pop_lsb = (v & -v);

  //3
  std::uint32_t hash = std::uint32_t(pop_lsb * 0x077CB531U) >> 27;

  //4
  int r = MultiplyDeBruijnBitPosition[hash];

  return r;
}

少し見やすくなったでしょうか、番号を振ってあるところを順に見て行きますと

  1. 謎の配列、ここに入ってる値がビット位置になっている様子
  2. 謎のビット演算テク、これは最下位ビットだけを残すポピュラーなテクニック
  3. 一番意味分からない所、後続の処理を見るにどうやら[0, 31]の数値を出力している
  4. 3の結果をインデックスとして1の配列を参照。参照先がビット位置に対応している

3番以外は何やってるのかわかるのではないかと思います。3番が分からないので全部わからないのですが・・・

しかし、整理したコードをよく見るとこのアルゴリズムはハッシュテーブルによって高速にビット位置を求めていることが見えてきます。
すると、意味分からない3番目の処理はハッシュ値を求めている事に相当しそうです。そして、その入力は最下位ビットのみが立っている状態になっています。

つまり3番目の処理は、最下位ビットだけが立った値に対して完全ハッシュ(ダブりが無いハッシュ)を求めていることになります。

de Bruijn sequenceによる完全ハッシュ

//3
std::uint32_t hash = std::uint32_t(pop_lsb * 0x077CB531U) >> 27;

3番目の処理に出てくる謎の数字0x077CB531ですが、これが実はde Bruijn sequenceになっています。2進数に直して5文字づつ見て行くと確かに[0, 31]の数字(2進数列)がすべて含まれ、なおかつ重複がないことが確認できるでしょう(右から4ビットの所は先頭へ循環して見る必要があります)。

5文字・・・2^5 = 32であることに気付くと、27ビット右シフトというのは最上位5ビット分を残す処理である事が分かります(32 - 5 = 27)。

すると残った所はde Bruijn sequenceとpop_lsbのかけ算です。普通の数値のかけ算なら結果がどうなるかを考えるのは少し難しいですが、pop_lsbはどこか1ビットだけが立った値です。
つまり、その値は必ず2^nの値になります。その2のべき乗数値との掛け算はすなわちnビット左シフトに相当します。

ここでの2進de Bruijn sequenceは左から5桁づつ重複なく5ビットの表現全てを含んでいます。
整数型のビット数(今は32)未満であれば、nビット左シフトして左から5桁分を数値として読み取ると、n毎に異なった値が得られます。
しかも、5ビット数値なので2^5 = 32未満 = [0, 31]の値が得られます。

今、入力は最下位ビットのみが立った値であり、32ビット符号なし整数型なら取りえる値は32個だけです。従って、そのビット位置に応じた[0, 31]の個別の値をこの一行は計算しています。
あとは、テーブルによってその値をそのビット位置を示す数字に対応させてやれば、処理は完了です。つまり、1番初めに宣言されている配列はこの対応を取っているものである事が分かります。

8ビットの例

文字だけだと分からないので例を見てみましょう、しかし32ビットは長いので8ビットで見てみます。
8ビットの値は8桁なので、3ビットでその位置を表現可能です。従って、0, 1を使った3文字を尽くすような長さ8のde Bruijn sequenceが必要になります(元論文から拾ってきます・・・)。

先ほどの3番目の処理は以下のようになります。

std::uint8_t hash = std::uint8_t(pop_lsb * 0x1DU) >> 5;

当然ですがpop_lsbはどこか1ビットだけが立った8ビット符号なし整数値であることを前提とします。
つまり、入力となるpop_lsbは8個の値しかとりえません。それぞれについて処理を見てみると以下のようになります。

pop_lsb pop_lsb * 0x1D hash index
1 0001 1101 000 0
2 0011 1010 001 1
4 0111 0100 011 3
8 1110 1000 111 7
16 1101 0000 110 6
32 1010 0000 101 5
64 0100 0000 010 2
128 1000 0000 100 4

こうしてみるとどこか1ビットだけが立った値をうまいこと3ビット数値に押し込めた完全ハッシュになっている事が分かるでしょう。
後はこのindex位置に、対応する桁数を持つような配列を用意してあげるだけです。

constexpr int table[8] = { 1, 2, 7, 3, 8, 6, 5, 4 };

上の表で得られたindextable[index]として値を取得すると、元のpop_lsbの立っているビットの位置が得られる事が分かるでしょう。

Nビット整数への一般化

Nは2のべき乗である必要がありますが、上記アルゴリズムは以下のようにNビットへ一般化できます。

hash(x) = (x * debruijn) >> (N - log_2(N))

ここで、Nは符号なし整数型の幅、debruijn0, 1を使ったlog_2(N)文字の組み合わせを尽くすような長さNの適切なde Bruijn sequenceです。
そのようなde Bruijn sequenceを求める方法はいくつかあるようです。しかし良く分からない・・・

なお、使用するde Bruijn sequenceは先頭log_2(N)桁が0で始まる必要があります。これはオーバーフロー対策と、掛け算(左シフト演算)時に全体が自然に循環するようにするためです。

64ビット数値のLSB/MSB位置を求める

では、64ビット符号なし整数型の最上位/最下位ビット位置を求める処理をC++で実装してみます。

最下位はここまでやってきたことの流れで実装できますが、最上位ビット位置は少し違った処理が必要です。
とはいっても、最上位ビット位置だけを残すビット演算テクニックが必要になるだけで、幸いそれはネットに落ちてました。

64bitで使用するde Bruijn sequenceは0x03F566ED27179461になります。これもネットに落ちてました・・・

最後の右シフト量は上記式から64 - log_2(64) = 58と求められます。

最終的に桁位置に写すテーブルは簡単な計算で求められます

constexpr auto hash_64(std::uint64_t x) -> int {
  return std::uint64_t(x * 0x03F566ED27179461UL) >> 58;
}

inline constexpr char hash2pos[] = {1, 2, 60, 3, 61, 41, 55, 4, 62, 33, 50, 42, 56, 20, 36, 5, 63, 53, 31, 34, 51, 13, 15, 43, 57, 17, 28, 21, 37, 24, 45, 6, 64, 59, 40, 54, 32, 49, 19, 35, 52, 30, 12, 14, 16, 27, 23, 44, 58, 39, 48, 18, 29, 11, 26, 22, 38, 47, 10, 25, 46, 9, 8, 7};

constexpr auto msb_pos(std::uint64_t x) -> int {
  if (x == 0) return 0;

  //最上位ビットだけを残す
  x |= (x >> 1);
  x |= (x >> 2);
  x |= (x >> 4);
  x |= (x >> 8);
  x |= (x >> 16);
  x |= (x >> 32);
  x = x ^ (x >> 1);

  int h = hash_64(x);

  return hash2pos[h];
}

constexpr auto lsb_pos(std::uint64_t x) -> int {
  if (x == 0) return 0;

  std::uint64_t v = x & -x; //最下位ビットだけを残す

  int h = hash_64(v);

  return hash2pos[h];
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

なお、このテクニックを応用することで更なるビット演算黒魔術を行えるようです(1が2つ並んでいる最上位の位置を求めるとか・・・)。
また、de Bruijn sequenceは南京錠の様な対象への総当たり攻撃の効率化や、ハミルトン路を求める問題をオイラー路を求める問題へ変換するなど色々応用できるみたいです(良く分かってない)。

参考文献

この記事のMarkdownソース

[Meson]Meson for C++の苦闘記

MesonでC++プロジェクトをクロスプラットフォームにビルドできるようにしたときのメモです。C++以外の事は分かりません・・・

基本

基本的なビルドスクリプトは以下のようになります。

# 必ずproject()から始める
project('test_project', 'cpp', default_options : ['warning_level=3', 'werror=true', 'cpp_std=c++17'], meson_version : '>=0.50.0')

# インクルードディレクトリ指定
include_dir = include_directories('include', 'oher/include')

# 実行可能ファイルを出力
executable('test_project', 'test.cpp', include_directories : include_dir)

公式のリファレンスとか

コンパイラを検出する

meson.get_compiler('cpp')コンパイラオブジェクト?を取得して、そこからget_id()コンパイラ文字列を取得します。
あとはifで分岐するだけです。

if cppcompiler == 'msvc'
# msvc用の処理
elif cppcompiler == 'gcc'
# gcc用の処理
elif cppcompiler == 'clang'
# clang用の処理
endif

ちなみに、コンパイルオプションを主要3コンパイラで分けたいだけならば、get_argument_syntax()を使うと便利です。これによって得られる文字列は、オプションの互換性があるコンパイラで同一になります。

project('test_project', 'cpp', default_options : ['warning_level=3', 'werror=true', 'cpp_std=c++17'], meson_version : '>=0.50.0')
cppcompiler = meson.get_compiler('cpp').get_argument_syntax()

if cppcompiler == 'msvc'
    # MSVC,clang-cl,icc(windows)用
    options = ['/std:c++latest']
elif cppcompiler == 'gcc'
    # gcc,clang,icc(linux)用
    options = ['-std=c++2a']
else
    # その他
    options = []
endif

include_dir = include_directories('include', 'oher/include')

executable('test_project', 'test.cpp', include_directories : include_dir, cpp_args : options)

例えばこうしておくと、それぞれのコンパイラで言語バージョンの指定ができます。
(ただし、デフォルトオプションとして指定している言語バージョンもそのままになってしまうので、MSVC等では警告が出ます・・・)

以下のページにこれらの関数で取得できるコンパイラ文字列の一覧があります。

VC++プロジェクトの癖

仕方ないことなのかもしれませんが、Mesonの出力するVC++プロジェクトは少し変わっています・・・

  • VS同梱の開発者コマンドプロンプトからmeson build --backend vsを実行しないといけない
  • 出力されたVC++メインのプロジェクトのプロパティはほぼ空(デフォルト)
    • 指定したコンパイルオプション等はビルド時には渡されているが、プロパティからは見えない・・・
      • このため、インテリセンスがC++14準拠になってしまう
  • プロジェクトプロパティの変更は、ビルド時にmeson.buildが変更されていてプロジェクト再出力が自動で行われた場合にリセットされる
    • 基本的にはこれ便利なんですけどもね・・・

VC++プロジェクトにヘッダを含める

出力されるVC++プロジェクトには指定したソースファイルは含まれていますが、インクルードディレクトリ内のヘッダは含まれていません。
例えばそれらのファイルを編集したくてVS上で開いたとしても、プロジェクト外のファイルに対してはインテリセンスがうまく働きません。
そのため、プロジェクトにそれらのヘッダを含めたいことがあるでしょう・・・

その場合は、ソースファイルと同じようにヘッダファイルを指定してやれば出力プロジェクトに含めることができます。

executable()extra_filesにプロジェクトに含めたいファイルを指定してやると含めておくことができます。

project('test_project', 'cpp', default_options : ['warning_level=3', 'werror=true', 'cpp_std=c++17'], meson_version : '>=0.50.0')
cppcompiler = meson.get_compiler('cpp').get_argument_syntax()

files = ['include/header1.hpp', 'include/header2.hpp']

include_dir = include_directories('include', 'oher/include')

executable('test_project', 'test.cpp', extra_files : files, include_directories : include_dir)

あるフォルダ内のファイルの列挙、ファイル名にワイルドカードを使う、等

できません。

ビルド高速化のために、この様な曖昧な書き方ができないようになっているようです。
残念ながら、プロジェクトに含めるファイルは1つづつ明示的に指定する必要があります。

依存ライブラリをダウンロードしてもらう

依存ライブラリの指定はsubproject()を使えば出来ます。これはインストール済みCMake(もしくはパッケージマネージャ)を検出して、そこから依存ライブラリ情報を取得してダウンロードして・・・と自動でやってくれる様子です。

でもWindowsだとそんなの入ってないし、githubから引っ張ってきたリポジトリとかでもよろしくやってほしいものです。
そのままだとこれは出来ない様子ですが、ラップファイルを用意してやることでやってもらえます。

meson.buildがあるフォルダにsubprojectsというフォルダを作り、その中にライブラリ名.wrapというファイルを用意しておきます。

例えば、doctestというライブラリを使いたいとしますと。

subprojects/doctest.wrapは以下のように書きます。

[wrap-git]
directory=doctest
url=https://github.com/onqtam/doctest.git
revision=2.3.4
clone-recursive=true

意味はなんとなくわかると思います。directory=の所を変えるとダウンロードされるディレクトリ名が変わるようです。revision=はダウンロードしてくるものの指定です。HEADとかコミットハッシュが使えるようです。

そして、meson.buildを以下のようにします。

project('test_project', 'cpp', default_options : ['warning_level=3', 'werror=true', 'cpp_std=c++17'], meson_version : '>=0.50.0')

#サブプロジェクトの指定
doctest_proj = subproject('doctest')
#依存オブジェクトの取得(名前が決まっている)
doctest_dep = doctest_proj.get_variable('doctest_dep')

files = ['test.cpp', 'include/header1.hpp', 'include/header2.hpp']

include_dir = include_directories('include', 'oher/include', 'subprojects/doctest')

executable('test_project', files, include_directories : include_dir, cpp_args : options, dependencies : doctest_dep)

subproject('プロジェクト名')で依存ライブラリを指定し(多分ここでダウンロード等がなされる)、その戻り値からget_variable('ライブラリ名_dep')で依存オブジェクト?を取得します。
この依存オブジェクトは、対象ライブラリの持つmeson.buildに書かれている名前を指定しなければなりません(慣例的にライブラリ名_depとなっているようです)。

最後に、executable()に依存オブジェクトを指定してあげます。もし静的ライブラリ等の出力がある場合はここで自動的に取り込まれるようです(対象ライブラリの持つmeson.buildが適切に書かれていれば)。

この方法、git submoduleで対象のライブラリを管理していても、なんだかよろしくやってくれます。

ちなみにこれらの時、ダウンロードしてきたプロジェクトのトップにmeson.buildが無いとたぶん上手くいきません・・・。 ただ、ヘッダーオンリーライブラリならインクルードパスの指定だけしてやればいい気がします(get_variable()してexecutable()で依存関係指定をしないで、subproject()だけしておく)

CI(Travis AppVeyar)

これはまだ試していないのでどうなるのかわかりませんが、公式サイトにTravisとAppVeyarに対するymlのサンプルがあります。この通りにやれば出来そうです。

参考文献

この記事のMarkdownソース