C++の設計と進化
2024年10月31日読了
Credit
Bjarne Stroustrup. (1994). The Design and Evolution of C++. Addison-Wesley Professional. 岩谷宏訳. (2005). C++の設計と進化. ソフトバンククリエイティブ.
Summary
C++のwhyを解説する歴史書。C++を運営するにあたった苦労話がつらつらと書かれている。
本書を読んでC++が書けるようになるとは到底思えないし、そもそもC++を知らない人が読むとつらいだろうが、C++がどのような思想のもとに作られたかを理解することで、C++に対する見方が変わる。
Note
第-1章
プログラム言語の理念:
[1] 概念をコードの中で直接表現できる。 [2] 概念間の関係をコードの中で直接表現できる。 [3] 独立した概念を独立したコードの中で表現できる。 [4] 複数の概念を表現するコードを、その組み合わせが意味をなす限り、自由に構成できる。
C++のSTLはコンテナとアルゴリズムを分離している。
vector<int> vi;
list<double> vd;
vector<int>::iterator p = find(vi.begin(), vi.end(), 7);
list<double>::iterator q = find(vd.begin(), vd.end(), 3.14);
STLのアルゴリズムは *
や ++
等のポインタ操作を使っている。ため、イテレータだろうが配列だろうが適用できる。最適化のため?
C++が複雑だからCより遅いという考えるのは愚か。Cで速く書けるならC++でも速く書ける。
Javaは純粋なオブジェクト指向にしてしまったからパフォーマンスが悪いしC++プログラマに質の悪いプログラムを書かせるようにしてしまった。
新しい言語というものはいつだって「単純さ」を売りにし、その後、実世界のアプリケーション向けにサイズも複雑さも増して、生き延びていく。
第1章
Simulaのようなプログラムの組織化能力があり、BCPLのようなリンクや実行の速さがあり、実装系による性能差がないような言語こそが、大規模なシステムを作る適性を持つ。
Stroustrupはとにかく経験主義・実用主義。数学も哲学もプログラミング言語もなんでも、実用できて初めて価値がある。と言っているように感ぜられる。
理想を押し付けてはならない。選択の自由がなくてはならない。だからC++は選択肢が多すぎる混沌言語なのね……。
人類の歴史の最悪の惨事は、”これは良いことだからこれをやれ!”という理想主義者の強制から生じている。
第2章
C with Classesは内臓タイプもユーザ定義タイプ(class)も同様に扱える。Simula(Javaとかも?)では扱い方が違う。classのインスタンスは必ずヒープに取られる。が、これではパフォーマンスが悪い。
// C with Classes
class A {
int x;
void new();
public:
void set(int);
int get();
};
void f() {
class A a; // スタックメモリに配置される
a.set(0);
int x = a.get();
}
C++でも内臓タイプとユーザ定義タイプを同様に扱う。変数宣言時に struct
や class
のキーワードを付けなければならないのは差別。だから、付けなくても良いようにした。
struct A { int x; };
class B { int x; };
void f() {
A a;
B b;
}
C++では名前でタイプを判別する。同じプロジェクト内で同じ名前を持つタイプ(関数も変数も)を定義できない。
C++もC同様未宣言の関数を呼び出せるが、二度目の呼出しで一度目の呼出しとの型チェックを行う。賢い。
Cでは f()
としたときどんな引数でも取れる。C with Classesでは f(void)
とすることで引数が取れないことを意味する。C++では f()
とするだけで引数が取れないことを意味し自然。C++でも f(void)
って書いてたわ……。
new
演算子はメモリアロケーションとコンストラクタ呼出しのどちらもを行う。この点が malloc
と異なる。
第3章
教育機関に無償で配布したが、商用製品としてリリースされ・サポートがある状態でないと使われなかった。
なるべく概念を統一したかったからstructもclassも同じようにした。と書いてあるが、それがメモリレイアウトの話を言っているのなら、それはC++の長所だとは思うが、structにもメソッド定義できるようにしたことを言っているのなら、それはC++の短所だと思う。
コンストラクタは暗黙的な変換でもある。それが嫌なら explicit
を付ける。演算子オーバーロード関数の定義を沢山書かなくても済むようにするためらしい。
#include <iostream>
class A {
int _a;
public:
A(int);
int get();
};
A::A(int a) {
_a = a;
}
int A::get() {
return _a;
}
int main() {
A a = 2;
std::cout << a.get() << std::endl; // 2
return 0;
}
当時のCは //
というコメントを持っていなかった。次のコードはCでは x = a / b
となり、C++では x = a
となった。
x = a //* 除算 */ b
C++は静的型付けなオブジェクト指向(を支援するマルチパラダイム)言語。
タイプミスマッチの問題をコンパイル時に検出できることが、なぜこれほどまでに重要なのか? ……それは、C++のプログラムの多くが、プログラマのいないところで使われるからだ。
第6章
キーワード引数の拡張提案があったが、特に宣言に用いた名前の変更が再コンパイルを引き起こすことを嫌って却下したようだ。Swiftのようなキーワード引数が標準的である言語もあるが、確かに冗長なのでなくてよかったと思う。
まだASCIIコードが全世界で共通化していない頃のお話……。
第7章
C++は多様性を重んじているように思える。C++処理系は一つではないし、そのライブラリも一つではないし、それを望んでいる。「どんなプロジェクトに対してもC++しか使いません」という人がいたら、Stroustrupは、それはそれで苦言を呈したんじゃないかな。
Smalltalkはオブジェクト指向言語としてC++よりも”純粋”だ、とよく言われますが、私は、汎用プログラミング言語というものは、複数のプログラミングスタイルや複数のパラダイムをサポートすべきですから”不純”であるべきだ、と信じています。
第10章
こんな機能あったんだ。
void* buf = (void*)0xF00F;
X* p2 = new(buf)X;
ガベージコレクションはあっても良いしあるべきだとは思ってはいるけど、C++自体がガベージコレクションに寄ることは許されないし、標準規格に含まれている必要はない、らしい。
第11章
オーバーロードによる曖昧性(順序依存性)は良い感じのマッチングルールを決めることで解決したようだ。が、次の問題は手を付けられなかったらしい。 nullptr
登場以前の時代……。
// これに
void f(char*);
void g() { f(0); } // f(char*)を呼び出す
// f(int)の宣言を加えるとg()の意味が変わってしまう
void f(char*);
void f(int);
void g() { f(0); } // f(int)を呼び出す
->
は二項演算子ではなく、「結果をメンバ名へ再び適用できる単項後置演算子と見ることができる」らしい。「見ることができる」というのが重要で、少なくとも現在のC++では ->
は単項演算子ではない。
#include <iostream>
class A {
int* _p;
public:
A(int* p): _p(p) {}
int* operator->() { return _p; }
};
int main() {
int i = 12;
A a(&i);
std::cout << *(a->) << std::endl; // コンパイルエラー
return 0;
}
冪乗演算子をサポートしなかった理由の内、次のものが非常にC++らしい。
演算子は記法上の便宜を提供するが、新たな機能性を提供しない。当作業部会のメンバは科学計算/工学計算のヘビーユーザを代表しているが、その演算子のシンタクスが軽度のシンタクス的便宜しか提供しないと指摘した。
第13章
純粋仮想関数のシンタクス =0
は「無」を意味するC/C++の流儀らしい。今や nullptr
もあれば false
もあるし、 0
がそういう意味を持つと捉える機会が少ないと思うが。ちなみに =deleted
や =nullptr
で代替することはできない。
オーバーライドにおいて、返戻型の制約はかなり緩和されているが、引数型の制約は厳しく、 dynamic_cast
を使って解決する方針になっている。
メンバ関数の関数ポインタを使うには次。
class S { public: void f(int); };
void S::f(int i) {}
void (S::*f)(int i) = &S::f;
S* p;
(p->*f)(0);
第14章
次の文、わかるなあ。
おもしろいことに、関心と一般の議論の大きさは、機能の重要性と反比例していることが多い。その理由は、大きな機能よりも小さな機能のほうが明確な意見を持ちやすく、流行のような、一時的な関心を引きやすいからだ。
なぜC++は reinterpret_cast
のような危険な機能を入れたのか。それはどうせどんな機能も誤用可能だし誤用されるから、らしい。
良いプログラムは、良い教育と良い設計と適切な試験などから生まれるのであり、”正しい使い方しかできない”機能を言語に盛り込むことによってではない。
Stroustrupはなるべくキャストを排除しようとしている。しかし、キャストを完全に排除することはできない。だから、C由来の (T)val
のような危険なキャストではなく、ちゃんと役割が分担されている static_cast<T>(val)
等の新シンタクスのキャストを使って欲しいらしい。
第15章
却下されたテンプレート引数の制限方法に次がある。トレイト境界だなあ。
template <class T> class Comparable {
T& operator=(const &T);
int operator==(const &T, const &T);
int operator<=(const &T, const &T);
int operator<(const &T, const &T);
};
template <class T : Comparable>
class vector {
// ...
};
テンプレートには実体がない。使われて初めて実体化する(テンプレートインスタンスが作られる)。そこで、テンプレート定義内の名前が、テンプレート定義内からルックアップするのか・あるいはインスタンス化した時点からルックアップするのかという曖昧性がある。定義内で fn(T(1))
としたり T::mt()
としたり多少の情報を与えることで参照先を決めることができるから、本が執筆された時点ではテンプレート定義内からルックアップすることになっている。ただし、手元で色々試してみたが、現在では f(int)
を呼ぶようだ。
void f(double);
template<class T> void g(T t) {
f(1.0); // f(int)があってもf(double)の方にマッチングする
f(t); // この時点ではf(double)しか見えないが……
}
void f(int);
void h() {
g(1); // g()内のf(t)はf(double)かf(int)か……
// 本が執筆された時点ではf(double)を呼ぶ
}
テンプレート引数は内臓タイプでもユーザ定義タイプでも与えられる。ので、どちらも同じシンタクスを使うようにしなければならない。そのため、あたかも内蔵タイプもコンストラクタを持っているかのように書けるようにした。実際、 X(): i(int()) {}
と書ける。
vector v(10); // ユーザ定義タイプ 要素が10個のベクタ
int a(1); // 内蔵タイプ 1である整数
第16章
次の例外ハンドリングは、
int f() {
return g() catch (err) {
error("");
return -1;
}
}
例えばZigで採用されているシンタクスだが、次の理由で却下されている(「余計なもの」は try
キーワードを指す)。逆に言えば、Zigのシンタクスが受け入れられているのは、C++という第一人者が我々にtry-catch構文のミームを植え付けてくれたから。
しかしこれは説明するのが難しいと分かったので、混乱したユーザからサポートスタッフを守るために余計なものを導入した。
Impression
業務でも使っているが、しかし、ぼくはC++が大の嫌いである。理由はぱっと思いつくものだけで次の通り。
- デフォルトの公開条件がpublicかprivateかの違いしかないstructとclassが混在している。structにはコンストラクタ、デストラクタ、メソッドの構文を作らず、もし欲しいなら自前で関数テーブル作ってメンバ変数として持たせてね、ってすれば使い分けが明確なのに。
- constメソッド内でも変更可能にするための
mutable
がある。確かに、例えば並列処理前提で書かれたclassがあって、constメソッドを作るとき、そこでmutexを使いたいものね。でもすべてのメンバ変数にmutable
付けることもできちゃうじゃん。 - たった一文字のタイポが何百個のエラーを吐き出すことがある。こまめにビルドして差分を理解しておかないと修正に時間を取られる。
float f(4.0);
のように初期化できる。気持ち悪すぎる。し、lock_guard lk(_mutex);
も気持ち悪い。変数定義はT var = val;
としたい。a & b != 0
がa & (b != 0)
。- プリミティブ型のbit数が決まっていないことが気持ち悪い。あらゆるアーキテクチャに対応するために未定義にしたのだろうが、家庭用マシンしか使わないぼくにとっては不愉快。
- パターンマッチもなければif式もなければswitch文も汚ければ。
- try-catch構文がイケてない。ネストが深まって不愉快。
- メンバ関数の宣言と定義のシンタクスが違う。
void f();
と宣言してvoid X::f() {}
のように定義しなければならないのは、特に引数が多いときに単純なコピペができないので面倒くさい。 - STLが軒並み
std
という名前空間に入っているのでusing namespace std;
とする気になれない。し、それをしているコードを見ると発狂する。 - フォーマッタやリンタやテスタが標準装備されていないのは勿論、処理系が多すぎる。絶妙に記法が変わってくるのも腹が立つ。
- 競技プログラミングが流行ったせいで非開発C++プログラマが大量出現して非開発系の記事が大量出現した。それはそれでとても良いことだとは思うが、速いとか皆使っているからという理由でC++を使って、C++だけを使って、C++らしい使い方をしていないのが癪に障る。
- ゲーム業界もC#かC++の二強なので、ゲーム系の人はみんなC#C#C#C++C++C++で嫌になる。もっと他の言語も触れて欲しい。
で、ただ、なぜそんな違法建築を繰り返した混沌言語になったのかを知って、ちゃんとした書き方を知りさえすれば(こちらは『Effective C++』を読む予定)、嫌悪感が消えるのではないかと。「機能が多い? じゃあ使わなければ良いじゃん」「汚いって言ってんの!」という不毛な議論をしなくて済むのではないかと。思い至ったのだ。
本書を読了した感想の内、最も重要なのは「確かにC++ほど開発力が強くてパフォーマンスも良い言語は他にないな」である。ぼくはC++が純粋なオブジェクト指向言語じゃないとしてしばしばC#を使うのだが、しかし、C#はGCを持っているしVM上で動くしで、パフォーマンスの点で評価しかねる。Rustも大変良い言語だし、本書で却下・断念された機能をしかも改善して取り入れている言語だと分かったが、開発力が弱い。弱い。誰が何と言おうと弱い。だから、未だにC++が開発の最前線にいることを当然であると納得できた。し、使いたいと思うようになった。
また、多様性の重要性にも納得した。Stroustrupには、言語は言語、ツールやライブラリとは別、という思想があるように感ぜられる。言語に至らない点があるならば、まずはツールやライブラリがそれを補えば良いという。ツールやライブラリを標準装備することはできても、それだけでやっていくことはできないだろう。だから、ハナから標準装備すら重要視していないのだ。これはぼくのような「さっさとコンパイラバックエンドはLLVMで統一されろ」とか「すべての言語処理系はフォーマッタとリンタとテスタを装備しろ」とか言っている奴には刺さる考え方だ。
ただ、技術負債が多いなあ、と何度も思った。もう今の時代必要ないよ、って機能や構文が沢山ある。とは言っても、じゃあ美しい構文を持つRustに乗り換えられるかと言えば、上記の通りできない。RubyにもJavaにもPythonにもGoにもHaskellにも乗り換えられない。
言うなれば「C++は最悪の開発言語である。ただし、これまで存在したすべての開発言語を除けば」。言い過ぎか。
■