最終更新日時(UTC):
が更新

履歴 編集

ほとんどのvolatileを非推奨化(C++20)

概要

C++20より、volatileの本来の役割に照らして不正確、あるいは誤解を招く用法や無意味な用法について非推奨とされるようになる。

非推奨となるのは次のもの

  1. volatile値に対する複合代入演算子(算術型・ポインタ型のみ)
    • C++23で非推奨化解除
  2. volatile値に対するインクリメント/デクリメント演算子(算術型・ポインタ型のみ)
  3. 間にvolatile値がある場合の連鎖した代入演算子(非クラス型のみ)
  4. 関数引数のトップレベルvolatile修飾
  5. 関数戻り値型のトップレベルvolatile修飾
  6. 構造化束縛宣言のvolatile修飾
  7. std::tuple, std::variant関連のクラステンプレートのvolatile特殊化
  8. ロックフリーではないstd::atomic特殊化のvolatileメンバ関数

非推奨とされるだけで削除されてはいないが、おそらくコンパイラはそれらの用法について警告を発するようになる。そのような用法はバグの原因となり危険であるため可能な限り使用を避けるべきである。

volatile

volatileな変数(メモリ領域)への1度のアクセスは正確に1度だけ行われる必要があり、0回にも2回以上にもなってはならない。そして、volatile領域へのアクセスはその順序がコード上の順序と一致する必要がある。

volatileの効果(保証)は単純にはこれだけである。

ただし、volatileはそのようなメモリアクセスが分割されない事は保証していない。volatileメモリ領域の個々のバイトに対しては正確に1度のアクセスが保証されるが、volatile領域全体を見たときにアクセスが1度だけになるとは限らない。

そして、非volatile領域とvolatile領域へのアクセスの間の相対的な順序が前後しない事まで保証していない。すなわち、volatile変数へのアクセスと通常の変数へのアクセスは順番が入れ替わりうる。

また、volatileはマルチスレッドプログラムの実行順を規定するC++メモリモデルとは直接的な関係性を持たず、volatile領域へのアクセス順序とはC++メモリモデルにおける観測可能な順序を意味しない。プロセッサはC++コード上での順序で読み取ったvolatile領域へのアクセス命令を、アウトオブオーダーで発行・実行することができる。C++メモリモデルにおいて動作が保証されている同期機構を用いない場合、あるコアにおける命令の実行順は、他のコア(あるいはプロセッサの外部)からは異なった順序で実行されたかのように観測されうる。

volatileは主として、プログラムの実行環境のハードウェアなどのプログラム外部の環境との通信手段の一つとして利用され、スレッド間のやりとりなどプログラム内部での通信の手段としては適さない。そのようなvolatileの正しい用法によるメモリの読み書きは、他のどの手段よりも移植性があり機能的にも優れており、言語機能として有用なものである。

コア言語における非推奨化

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

複合代入演算子とは*= /= %= += -= >>= <<= &= ^= |=の10個の演算子のことで、ある操作とその結果の代入をまとめて行うような演算子のことである。

複合代入演算子およびインクリメント演算子++とデクリメント演算子--は、「読み出し - 更新(処理) - 書き込み」という3つの操作を1文で行う。(簡素化のため、以降は++/--をまとめてインクリメント演算子と表記する。)

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;

複合代入演算子の左辺にあるvolatile変数、およびインクリメント演算子のvolatileオペランドには2回のアクセス(読み込みと書き込み1回づつ)が発生するが、このアクセスは複合代入演算子やインクリメント演算子の見た目や一般的な理解とは必ずしも一致しない。

volatile変数においてはそのアクセス(読み書き)が重要であり、コード上での1回のアクセスは実行時にも1回だけアクセスされる必要がある。しかし、複合代入演算子およびインクリメント演算子のアクセス回数は多くのプログラマにとって曖昧であるか、誤解されている。

従って、算術型・ポインタ型のvolatile変数に対する組み込みの複合代入演算子およびインクリメント演算子の使用はバグの元であるので、非推奨とされる。

この場合、これらの複合的な演算子を用いず、明示的に「読み出し - 更新 - 書き込み」を分けて書くことでvolatile変数へのアクセスをコード上でも明確にする事が推奨される。

ただし、複合代入演算子についてはC++23で非推奨化が解除された。

連鎖した代入演算子

代入演算子の一部の用法には、複合代入演算子・インクリメント演算子と同様の問題がある。

volatile int a, b, c;

a = b = c = 10;
// このような順序でアクセスが発生する
// c = 10;
// b = c;
// a = b;

このような連なった代入演算子の用法においては、どの変数にどんな順番で何回アクセスされるのかが非常に分かりづらくなる。

volatile変数においてはそのアクセス(読み書き)が重要であり、コード上での1回のアクセスは実行時にも1回だけアクセスされ、かつその順番が前後してはならない。しかし、代入演算子を連鎖させた場合、そのアクセス回数および順序は非常に認識しづらくなる。

従って、非クラス型のvolatile変数に対するこのような代入演算子の使用はバグの元であるので、非推奨とされる。

ただし、非推奨となるのは代入演算子の両端のオペランド以外にvolatile変数が表れるケースである。

volatile int v1, v2, v3;

v1 = v2 = v3 = 10; // NG(非推奨)

int n;
v1 = n = 10;      // OK

v1 = n = v3 = 10; // NG(非推奨)

v3 = 10;          // OK
v1 = v3;          // OK
v1 = n = v3;      // OK

関数引数と戻り値型

関数引数をvolatile修飾することは、シグナルやsetjmp/longjmpによって外部から変更されている可能性を示唆するために有効であり、引数のconst修飾同様に関数定義内では明確な意味を持つ。

一方呼び出し側から見ると、参照・ポインタではないvolatile引数の意味は不明瞭である。参照・ポインタではない関数引数がvolatile修飾されている場合、その関数はシグナルハンドラやsetjmp/longjmpと共に使用されるはずであり、呼び出し側にもそのような実装詳細の一部が漏洩してしまう。

また、呼出規約によっては一部の引数を配置するレジスタがvolatileとなる事があるが、呼出規約はC++コード上で意味を持たず、そのような呼び出し規約がマークされている関数は非volatile関数宣言と同様に扱われる。しかし、一部のレジスタがvolatileである事はABIによって処理されている。

このように、関数引数のvolatile修飾は有用ではないため非推奨とされる。関数の引数をvolatileとしたい場合、関数内でvolatileローカル変数に引数をコピーする事が推奨される。一部の実装では、そのようなコピーは省略され、オーバーヘッドとはならない。

void f1(volatile int n);   // NG(非推奨)
void f2(volatile int* p);  // OK
void f3(volatile int& r);  // OK
void f4(int volatile * p); // OK
void f5(int volatile & r); // OK
void f6(int * volatile p); // NG(非推奨)

また、参照・ポインタではない関数戻り値型のvolatile修飾は完全に意味を持たない。

例えば、ローカルvolatile変数を返す場合、そのアクセスは関数リターン時に値をコピーするために一度実行されるが、コピーした後の値はもはや元のvolatile領域とは別のスタック領域にある。volatileにおいて重要なのは特定領域へのアクセスであり、暗黙にそのようなコピーが行われる事はほとんどの場合にプログラマの意図とは異なる。

ローカルの非volatile変数をvolatileとして返すことには意味がない。戻り値をvolatile領域に配置したい場合、関数の呼び出し側でvolatile変数に受ければよい。

そして、戻り値型のvolatile修飾は容易に無視する事ができる。

このように、volatile戻り値型は無意味であるため、非推奨とされる。戻り値をvolatileとして扱いたい場合は、戻り値をvolatile変数に受ければよい。

volatile int  f1();   // NG(非推奨)
volatile int* f2();   // OK
volatile int& f3();   // OK
int volatile* f4();   // OK
int volatile& f5();   // OK
int* volatile f6();   // NG(非推奨)

ただし、関数引数・戻り値型いずれにしても、ポインタ・参照へのvolatile修飾は明確な意味を持ち有用である(値ではなく、領域にvolatileとマークしているため)。従って、非推奨とされるのは関数引数・戻り値型へのトップレベルvolatile修飾のみであって、volatileポインタ・参照型は依然として許可される。

構造化束縛宣言

構造化束縛宣言にもvolatile修飾を行う事ができるが、ここでのCV修飾は右辺にある式の結果である暗黙のオブジェクトに対して作用している。

右辺の式の結果がstd::tuple/std::pair等のtuple-likeな型のオブジェクトである場合、構造化束縛はまずその結果オブジェクトをvolatile修飾して受けておき、その結果オブジェクトに対してstd::getで要素の取得を行う。しかし、std::getにはvolatileオーバーロードが欠けており、コンパイルエラーを起こす。

一方、構造化束縛の残りのケース(配列・構造体)の場合はstd::getを用いないためこのような問題は起こらない。

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

volatile auto [a, b, c] = f();  // NG
// ここでは以下の様な事が行われている
// 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においてはその領域へのアクセスが重要であり、1度のアクセスは正確に1度だけ行われる必要があり、その順序は前後してはならない。構造化束縛宣言はその裏側で多くの事が行われており、それはCV・参照修飾と右辺の式の結果型によって様々に変化するが、そこでどのオブジェクトがvolatileとなりどのような順番でアクセスが発生するのかは非常に不明瞭である。

従って、構造化束縛宣言のvolatile修飾を正しく扱う事は非常に困難であるため、非推奨とされる。

構造化束縛した名前がvolatileである必要がある場合は、分解対象の右辺の結果オブジェクトの各要素型をあらかじめvolatile修飾しておく事が推奨される。

auto f() -> std::tuple<int, int, double>;
int array[3]{};

volatile auto [a, b, c] = f();   // NG(非推奨)
volatile auto [a, b, c] = array; // NG(非推奨)

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

この場合でも、各要素型のトップレベルvolatile修飾は意味をなさない。

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

auto [a, b, c] = f();  // OK、要素ごとコピーされている、volatile修飾は無意味
auto&& [a, b, c] = f();  // OK、一時オブジェクト内各要素へのバインド、volatile修飾は無意味

ライブラリにおける非推奨化

クラステンプレートのvolatileな特殊化

標準ライブラリのクラステンプレートには、volatileな対象に対する特殊化が提供されているものがある。

このうち、numeric_limitsに関しては有用性が明らかであるのでそのままにされ、atomic関連のものはあとで説明する。

残ったのはtuplevariantに関連したものであるが、C++標準はtuplevariantがどのように実装されるかを指定しておらず、これらの型のvolatileオブジェクトへのアクセスがどのように振る舞うのかは不透明である。さらに、メンバ関数は特にvolatile修飾されたものが用意されているわけではなく、標準ライブラリのそのほかのクラスも特にvolatileを意識してはいない。

従って、tuplevariantそのものはvolatileで使用されるために設計されておらず、これら4つのクラステンプレートのvolatile特殊化はCV修飾を網羅するために用意されているだけであるため、非推奨とされる。

std::atomicvolatileメンバ関数

volatileなアクセスは不可分ではなく、順序保証がなく、各バイトに正確に一度だけアクセスされ、コンパイラの最適化の対象とならない。

atomicなアクセスは不可分であり(原子性が保証され)、C++メモリモデルによってその順序が保証され、ループによって処理される可能性があり(各バイト一度だけのアクセスは保証されない)、コンパイラの最適化の対象となりえる。

volatile std::atomic<T>はこれらの性質を組み合わせたものとなる事が期待されるが、そうなっていない。

ロックフリーではないstd::atomicvolatile修飾されている場合、そのアクセスの不可分性(原子性)が必ずしも保証されない。例えば、C++プログラム上ではmutexなどでロックして正しくatomicにアクセスされていたとしても、その領域にアクセスするプログラム外部の観測者からはそのアクセスは分割されて見えうる。

また、volatileかつatomicな複合代入操作を正しく行う(不可分に、各バイトに正確に一度だけアクセスする)実装は限られているが、そのような実装詳細を指定することはC++標準の範囲内ではない。

従って、volatile std::atomic<T>は必ずしも両方の性質を備えた実装になっておらず、それを保証する事が困難であるため、std::atomic<T>::is_always_lock_freetrueとなる特殊化を除いてstd::atomicvolatileメンバ関数は非推奨とされる。

検討されたほかの選択肢

関数引数・戻り値型へのconst修飾

関数引数・戻り値型へのvolatile修飾が非推奨とされたのとほぼ同様の理由によって、const修飾も非推奨とする事が提案されていたが、合意が取れなかったため非推奨とはならなかった。

おそらく、間違っていたり意味がなかったとしても、volatileと比べて幅広く使用されているために非推奨とする事が忌避されたものと思われる。

メンバ関数のvolatile修飾

メンバ関数のvolatile修飾は通常使用されることはない。標準ライブラリにおいても、constメンバ関数は意図を持って用意される事がある一方で、volatileメンバ関数が用意されることはほとんどない。

また、メンバ関数のvolatile修飾はジェネリックプログラミングにおいてはCV修飾のパターン網羅のために追加の負担をかけるか、全く無視されるかのどちらかである。

クラスはconstな文脈でもそうでない文脈でも使用可能であり、constメンバ関数はそれぞれの場合に合わせてその振る舞いを変化させる事ができる。volatileの場合を考えてみると、これは当てはまらない。

あるクラスがvolatileな領域に配置されることもあればそうでない場合もある、という状況は考えづらく、そのような状況にあったとして、メンバ関数がどう有意義に異なるのかはさらに不明瞭である。

さらに、コンストラクタやデストラクタはconstvolatile修飾もできないため、クラスのオブジェクトへのvolatile性が有効になるのは派生先も含めてコンストラクタの呼び出しが完了した後からとなる(同様に、volatile性はデストラクタの呼び出し前までしか有効ではない)。

volatileにおいてはその領域へのアクセスが重要であり、1度のアクセスは正確に1度だけ行われる必要があり、その順序は前後してはならない。クラスのオブジェクトがvolatileな領域に配置されるとき、volatileの保証なしで構築や破棄をすることは間違っている。クラスオブジェクトがvolatileな領域に配置される場合は、そのメンバ変数をvolatile修飾しておく事が望ましい。

このように、メンバ関数のvolatile修飾は無意味なものであり、使用されていないため、非推奨とする事が提案されていた。

しかし、集成体のような単純なクラス型においては、クラスオブジェクトのvolatile領域への配置とメンバ関数のvolatile修飾は使用されている。これに対処するために当初の提案では、メンバ関数のvolatile修飾はあるかないかどちらかとする、struct volatileを導入する、などの回避案を提案していた。

また、std::atomicは意図を持ってvolatile修飾されたメンバ関数を提供しており、それは有用であるため、これを非推奨としてしまうかどうかも問題となった。

結局、合意に至ることはできず、メンバ関数のvolatile修飾は現状維持となった。

備考

非推奨化で触れられてはいないが、volatile変数を並行処理の共有変数として使用することは常に間違っている。

不適切な使用の例

提案文書より不適切と思われるvolatileの用例をいくつか引用する。中には今回の非推奨化の対象となっていないものもある。

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

// どんな命令が生成され、fの領域の各バイトに何回アクセスするか不透明
f.a = 3;

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

// f.aの領域へのアクセスが発生するか不透明
f.b = 1;

union foo {
  char c;
  int i;
};
volatile foo f;

// sizeof(int) [byte]の領域へアクセスするか、sizeof(char) [byte]の領域へアクセスするのか、不透明
f.c = 42;

volatile int i;

// iの領域へ何回のアクセスが発生するか不透明
// これはどちらも非推奨化
i += 42;  // C++23で非推奨化解除
++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ではない
volatile device dev;

参照