Here A Const, There A Const
by Walter Bright
ちょっとしたプログラムを書くなら、柔軟で寛大で、 そんなに杓子定規でないようなプログラミングシステムが便利です。 しかしプログラムの複雑さが増すにつれて、 コード中の宣言の意味をコードそのものできっちり記述することに利点が出てきます。 プログラマは、巨大なアプリケーションを部分部分に区切って、 特定の状態変化の影響を狭い範囲に限ることができるようにすべきです。 そうすることで、遠く離れた位置にあるコードが同じデータを書き換えるという結合を避けることができます。 コードではなくドキュメントに書くという方法ではどうしても、間違った、ミスリーディングな、 不完全で古い、そもそも記述されないドキュメントしか残らない危険があります。 このような場面が、"const性" の重要な使い道です。 C や C++ には、変数や関数のconst性を指定できるという機能があり、 長い間広く使われ有用な機能であると証明されてきました。 今では多くのプログラマが、 constは大規模なプログラムを開発するには不可欠な機能と考えています。 単純化を目的として、Javaはconstを無くしてしまいました。 しかし、書き換え不能文字列や、よく使われる防御的コピーのテクニックは ひいき目に見ても不格好です。結果として、言語に const を取り戻すのが 産業界やアカデミアで人気のお遊戯になってしまっています。 とはいえ、C++ の const にも幾つもの重大な欠点があります。そこで、D ではこのconstの概念を一から十まで設計し直しました。 この記事は、const性がどのような場面に有用なのか、C++のconstはそれをどう解決しているのか、 そしてDのconstがそれをどう解決しているのかについて詳しく考察したものです。
Constから何が得られるのか?
何かが定数である(const)という情報を使って得られる利点はさまざまで、 最適化やコード生成にも効果的です:
- constデータはコピーの必要がありません! 競合が起こらないので、 (たとえばポインタや参照を通して) 無限に共有することができます。 これによって、プログラムは正確かつ効率的になります。
- もっとも明白な利点は、 記号定数(manifest定数)や文字列に名前をつけられることです。
- constデータは ROM (Read Only Memory) に配置できます
- const引数は、関数がその引数を通して参照できるデータを決して書き換えないことを保証し、 モジュール性を向上します。
- constデータは、 他のスレッドやそのデータへの他のエイリアスがデータを変更しないことを示します。
- const性を伝達してたたみ込むことができます。つまり、 演算を実行時からコンパイル時に持ち込むことができます。
- constデータは他の演算の副作用で書き換わることが決してない、 と保証されていることはデータフロー解析にも役立ちます。
- constデータは、メモリとの同期の必要なしにキャッシュしたり レジスタにミラーしたりできます。
- constは、プログラマの認識負荷を軽減します - 宣言にconstを見つけたら、ただちに、 その宣言を用いるコード全てに対する情報を実際のコードを水に得ることができます。
C++ の const の問題点は?
C++ の const には、二つの種類があります。記憶域クラスとしてのconstと、 型の属性としてのconstです。
記憶域クラスとしてのconstは、 記号定数を宣言するのに特に有用です。例:
const int X = 3;
この場合、言語は X が決して 3 以外にならないことを保証します。 X は ROM に配置できますし、最適化の際には 全ての右辺値 X を 3 に問題なく置き換えることができます。 const は、宣言のトップレベルの型に適用されたときに記憶域クラスになります。[1]
型の属性としてのconstは、ちょっと違っています。 トップレベルでない型にconstが適用されたときには、型の属性となります:
int x = 3; const int *p = &x;
この例では、const は p 自身ではなく、p の指す int に適用されます。 型の属性としての const は、データの読み取り専用ビューを意味しています。 データが定数であることを意味しているのではありません。例えば:
int x = 3; const int *p = &x; *p = 4; // エラー。読み取り専用ビュー const int *q = &x; int z = *q; // z は 3 になる x = 5; // ok int y = *q; // y は 5 になる
*q が const だからといって、z と y が等しくはなりません。 これが、 エイリアシング問題と呼ばれるものの一例です。 上のコード片程度であればとてもシンプルなものですが、 複雑なプログラム中でこのようなエイリアスの存在を検出するのは至難の業です。 コンパイラが確実にこれを検出するのは不可能です。この結果、 コンパイラが 3 をレジスタにキャッシュして *q の代わりにその値を使うような最適化はできず、 毎回 q の指す値を参照しなければならないということになります。
以下のように定義された関数を考えてみましょう:
void foo(const int *p);
表面上は、foo() にint変数を渡しても foo() がそのint変数を書き換えないことは保証されているように見えます。 しかし、そうとは限りません:
void foo(const int *p)
{
int *q = const_cast<int *>(p);
*q = 4;
}
foo() のインターフェイスがconst性を保証しているにも関わらず、 foo() はconst性をcastで取り除き、 渡されたint値を書き換えています。 最悪なのはこれが正当でwell-definedなC++のコードであるということで、 C++ コンパイラはこれを必ずサポートしなければいけないのです。 こんなコードを書くのはプロのC++プログラマならあり得ないのにもかかわらず、 正当なコードであるという事実によって、コンパイラはこれをどうにもできません。
そんなわけで、例えばコードレビューをしている時に関数の引数が constへのポインタと宣言されているのを見たら、その引数を書き換えていないかチェックするためには 関数の中身を注意深く読んで その引数を渡している他の関数のコードまで全部読んで行くはめになってしまいます。 こんなことでは、引数をconstと宣言した意味がありません。
しかしさらに、C++ の const には問題があります。以下のコードを見てみましょう:
class C; void foo(const C *p); ... C c; foo(&c);
foo() が c の内容を書き換える可能性はあるでしょうか? もちろん const_cast が使われていればあります。しかし、 別の正当な方法により書き換わりも起こりえます。クラス C が mutable なメンバを持っている場合です:
class C
{
public: mutable int x;
};
void foo(const C *p)
{
p->x = 3; // ok。C::x は mutable
}
つまり我らが困り果てたコードレビューアは、foo() が c を書き換えるか調べるために、 C の 定義を探してmutableなメンバがないか見に行くはめになります。
mutable というものが存在する論拠として、logical const という概念があります。where これは、内部的なデータが違っても外部から見ればconstに見える、という状況を指します。 例としては、重い処理の結果をオブジェクト内部にキャッシュしているようなクラスがあります。 この考え方の問題点は2つあります。 1点目は、mutable が logical const 以外の用途では使われないといった保証をしてくれるような 言語側のサポートが一切存在しないという点です。コードレビューアからしてみれば、 mutableが正しい方法で使われているかを判断するのは困難です。 また、logical const性を機械的に判定することは不可能です。 mutable は他の用途にも使えてしまいますし使われてしまっていて、 それでも完全に合法なC++のコードとなってしまっています。 2点目は、mutableデータへのconst参照を取得すると、 const参照が書き換え不可能であることに依存した最適化の挙動があやしくなってしまい、 スレッドセーフなコードの記述や最適化結果に良くない影響を与える点です。 結果として、変数の参照する先を決して書き換えないジェネリックなコードを 記述するのは不可能となっています。
さらにもう1つの問題があります。クラス C が、T* で表される コレクションのルートであると仮定します:
class C
{
T *q;
};
関数 foo() がコレクションのデータを読み取って、何かその情報を 返す関数としましょう:
int foo(const C *p);
この const は、クラス C の内容にのみ適用され、 q の指す先には適用されません:
int foo(const C *p)
{
*p->q = ...; // ok。 C::q の指す先は書き換えられる
return 0;
}
そして、foo() が引数からたどれるすべてのデータを書き換えないことを表明するように インターフェイスを記述する術はありません。 言い方を変えると、constは推移的ではないのです。 これは、未知の型を扱うような汎用のAPI関数を書くときに、 特に面倒な問題になります:
template<T> int foo(const T *p) { ... }
インスタンス化された型 T を知らない限り、 foo() が引数経由でなにかを書き換えるかどうか判定するのは不可能です。
C++ の const の問題点をまとめると:
- 型の属性としての const は、不変データではなく、 データの読み取り専用ビューという意味を持つ。 他の参照経由でいつでもそのデータが書き換わるかもしれない。
- const性をキャストで取り除いて、 あたかも元々mutableであったかのようにデータを書き換えられてしまう。
- mutable メンバが宣言のconst性を上書きしてしまう。
- const が推移的でない。 複雑な型のconst性を使用者側で宣言する方法がない。
C++ の const は、この記事の冒頭で並べたゴールを達成するにはかならずしも適していませんでした。 逆に言うと、これは再設計をする価値があるということです。
D での const
明らかなのは、当たり前のように混同されていることも多いですが、const には2つの異なる意味があるということです。一つは、const データが本当に 定数である、不変である、という意味です。これは、違う名前をつけるべきと言えるくらい 異なった概念です。D では、この種の性質を invariant (不変) と呼んでいます。
invariant データは、エイリアシング問題を解決します。たとえ同じデータに対する別の参照が あったとしても、invariantである以上、どの参照経由でもデータを書き換えることはできません。 invariant データを使えば使うだけ、 プログラムはわかりやすくなります。 invariant は、コードの他の部分を読む手がかりであり、試金石となります。 invariantな値が変化するようなことが起きるとしたら、 それは明らかに重大なプログラムのバグです。 このような制約を静的に強制できるのはとても便利な機能です。
2種類目のconstの意味は、データの読み取り専用ビューです。 データそのものは別の参照経由で変更されるかもしれません。 この概念はDではconstと呼ばれ、モジュール性に重要な役目を果たします。 ある関数があるデータの内容を見たいとして、一方でデータを保持しているモジュールはそのデータに対する 変更をコントロールしたいという状況を考えます。必要なのは、 データを絶対に書き換えないという保証のもとにモジュールから関数にデータを読み取らせるプロトコルです。
mutableな参照は(C++と同様に)暗黙にconstに変換できます。 invariantな参照も、同様に暗黙にconstに変換できます。 しかし、 逆にconstをinvariantやmutableな参照へ暗黙変換することはできません。 要するに、constは「あなたはこのデータを変更できませんが、他の誰かが書き換えるかもしれませんし 書き換えないかもしれません」という、弱い形のinvariantです。
const参照は通常、 参照するデータを勝手に書き換えないことを保証するような API関数で使われます。
これによって、D の const の一つの特徴が浮かび上がってきます - D の const は推移的なのです。 C++ の const は推移的ではなく、たとえば mutable int への const ポインタが存在しました。 あらゆるレベルでconstな変数を宣言するには、以下のように書かねばなりません:
int const *const *const *p; // C++
const は左結合なので、この宣言はconst intへのconstポインタへのconstポインタ へのポインタ、です。D で const が推移的であるということは、 D の const からたどれる参照もすべて自動的にconstになるということを意味しています。 アプリケーションのある領域全体が、 修飾子を一個つけるだけで保護されます。 この特徴を反映するために、constの構文はC++とは異なるコンストラクタ風の記法となっています:
const(int **)* p; // D
この const は、括弧の内側の型に適用されます。 この構文では、mutableな型へのconstポインタへのポインタ、 のようなものを宣言するのは不可能になっています。 この表現力のちょっとした低下は、 推移性による保護の重要さを考えれば十分見合うものです。
推移的constによって、関数引数が本当に読み取り専用であるという 関数インターフェイスを記述できるようになります。 未知の型を扱うようなジェネリック関数を書くときにも問題になりません。
constと同じように、invariant型も推移的で、 constと同じ形の構文になっています。
静的型システムは時に足枷となるので、 特別な場合のために迂回する方法も必要です。C++ と同様に、D でもconst性やinvariant性を キャストで取り除くことが許可されています。C++ と違うのは、そのようなキャストを行った後は プログラマ自身がconst性やinvariant性を保証しなければならないことで、 それに反するデータ書き換えを行った際の動作は未定義となることです。
参考文献
- Const-correctness Wikipedia
謝辞
Andrei Alexandrescu, Bartosz Milewski, Brad Roberts, David Held, Eric Niebler をはじめとする多くの D コミュニティのメンバの、 新しい const システムの設計に関する強力に感謝します。
また、この記事をレビューして、たくさんの改善案をコメントくださった Andrei Alexandrescu に大変感謝しています。
注
[1] 何人かからこの点について、 const_cast が const オブジェクトを合法に変更できるのではないかという質問がありました。 関係する標準規格の項目は C++98 7.1.5.1-4 です:
Except that any class member declared mutable (7.1.1) can be modified, any attempt to modify a const object during its lifetime (3.8) results in undefined behavior.
