英語版
このページの英語版を見る

SafeD

DデザインチームのメンバーであるBartosz Milewskiによる

私は、優秀なプログラマーたちがC++から離れ、JavaやC#などの言語に移行するのを見てきた。

私は、非常に優秀なプログラマーたちがC++から離れ、JavaやC#などの言語に移行していくのを目の当たりにしてきた。筋金入りのC++プログラマーである私としては、なぜ誰もが、よりパワフルで効率的な言語に乗り換えたいと思うのか不思議でならなかった。もちろん、初心者の方がよりシンプルで習得が容易な言語を選ぶ理由は理解できるが、いったん時間をかけて努力してC++を習得した人が、いったいなぜそれを放棄したいと思うのか?

D-man in rain

私が裏切り者から聞いた普遍的な理由は「生産性」だった。プログラマーはC++よりもJava、C#、Ruby、Pythonを使用した方が生産性が高いというのがコンセンサスのようだ。

C++での生産的なプログラミングの主な障害は何だろうか?

恐ろしい構文もその一つだ。

ひどい構文もその一つである。これは、言葉で表現する以上に深刻な問題である。優秀なプログラマーであれば、時間をかければ恐ろしくひどい構文も習得できるだろう。問題は、C++の構文と文法が人間にとってだけでなく、パーサーにとっても不親切であることだ。Java市場が生産性向上ツールで飽和状態にあるのは、この言語の解析可能性を反映している。Javaでは一般的であるような強力なリファクタリングツールを提供するC++プログラミング環境は、まだ見たことがない。

言語の安全性も、もう一つの大きな要因である。C++は、足を撃つ機会を無限に提供することで悪名高い。実際、C++は危険なコードを書く機会を提供するだけでなく、それを推奨している 。ある時点で、安全性への懸念から、大手C++コンパイラベンダーがSTLアルゴリズムの一部を「非推奨」とマークした。特にC++標準ライブラリは、C++の精神に従って、バッファオーバーフローバグがプログラムに忍び込む可能性のある方法を増やしている。

悪名高い例として、3つのイテレータを使用するstd::swap_ranges アルゴリズムがある。最初の2つのイテレータは1つの範囲を区切るものだが、3つ目のイテレータは2つ目の範囲の始まりを示す。2つ目の範囲がコンテナの終わりを越えて拡張されないかどうかはテストされない。そうなった場合、ウイルス作成者は歓喜する!

プログラミング言語設計者の夢物語は、プログラムが正常にコンパイルされた場合、それが動作することを保証できることだ。もちろん、「動作する」プログラムの定義については、妥当な範囲で考える必要がある。例えば、プログラムが「スタック(stuck)」しないことを要求するかもしれない。スタックとは、コンピュータサイエンスでは厳密な意味を持つ用語だが、ここでは、プログラムが GP-fault(グッド・パーフォマンス・フォールト)しないという意味で、曖昧に用いられている。このような性質を持つ言語は「健全(sound)」と呼ばれる。

なんと、明確に定義された(かつ意味のある)Javaのサブセットは、サウンドである。現実のJavaプログラムは、実際的な理由から、このサウンドサブセットの範囲外で作成されることもあるが、少なくとも、安全でない機能の使用は、C++プログラムよりもJavaプログラムの方が少なく、発見しやすい。実際には、C++コンパイラよりもJavaコンパイラの方が、プログラムのバグをより多く検出する。これは、デバッグに費やす時間を短縮することに直接つながり、結果として生産性が高まる。

では、C++の優れた機能とは何だろうか?

パフォーマンスがその一つである。C++のパフォーマンスを上回ることは非常に難しい。プログラムの高速性と応答性が求められる場合、C++(まれにCやアセンブリ言語)で書く以外に選択肢はない。

パフォーマンスがその一つである。C++のパフォーマンスを上回ることは非常に難しい。もし、プログラムの高速性と応答性が求められるのであれば、C++(またはまれにCやアセンブリ言語)で書く以外に選択肢はない。

また、C++にはハードウェアと直接やりとりするプログラムを記述できる低レベルの機能もある。例えば、CとC++は組み込みプログラミングの王様であることに変わりはない。

C++は強力な抽象化機能を提供しており、特に汎用コードを記述する能力に優れている。JavaとC#にもジェネリクスがあるが、C++のジェネリクスと比較すると見劣りする。

これらの機能により、C++はオペレーティングシステムを書くのに理想的な言語となっている 。オペレーティングシステムは巨大なプログラムであり、高速で、 ハードウェアと直接やりとりする必要がある。しかし、オペレーティングシステム以外でも 、多くのアプリケーションが存在する。

これらの機能により、C++はオペレーティングシステムを書くのに理想的な言語となっている。 オペレーティングシステムは巨大なプログラムであり、高速で、 ハードウェアと直接やりとりする必要がある。しかし、オペレーティングシステム以外でも、 大規模で高速なアプリケーションは数多く存在する。

そのため、プログラミングの世界はC++、Java、C#などにうまく分割できると思われる。トレードオフの不可避性を信じる限り、すべて理にかなっている。しかし、「生産性と引き換えにパワーを手に入れなければならない」という自然法則は存在しない。

タマネギのように構築された言語はどうだろうか。JavaやC#と似た、適度にシンプルで安全なコアがある。プログラマーは、その安全なサブセットをすぐに習得でき、Javaプログラマー(あるいはそれ以上)と同等の生産性を発揮できる。そして、その安全なサブセットがC++と同等のパフォーマンスを発揮できるとしたらどうだろうか。

そして、同じ言語には必要に応じて徐々に習得できる外側のレイヤーがある。ハードウェアを処理するための低レベル機能と、必要に応じてコードを生成する高レベル機能を提供する。モジュール性と実装の隠蔽性も備えている。他に類を見ないコンパイル時の機能により、超高速のランタイムパフォーマンスを実現する。

秘密を打ち明けよう。この言語はD言語だ。

プログラミングの落とし穴

プログラミングの落とし穴

有名な"Hello World!" プログラムは、通常、C言語で最初に書くプログラムであるが、この言語の最も危険な特徴のいくつかを露呈していることをご存知だろうか? このプログラムには、次のような文が含まれている。

printf ("Hello World!\n");
printf のシグネチャを考えてみよう:
int printf (const char * restrict format, ...); 

(restrict は新しいC言語のキーワードである。)まず、これは可変個の引数を取る関数である。引数の数と型はフォーマット文字列にエンコードされる。

フォーマットと引数リストの一致はいつ確認されるのか? コンパイル時ではない。コンパイラはフォーマット文字列を理解できない(ただし、文字列が静的に既知である場合、一部のコンパイラは警告を発する場合がある)。では実行時か? いや、そうでもない。考えてみよう。プログラマーが printf を呼び出す際に引数の数が少なすぎた場合、プログラマーは適切なエラーコードや例外を受け取ることができない。C 標準では、この状況について次のように述べている。 to printfprintf

フォーマットに不十分な引数がある場合、動作は未定義である。

未定義の動作は、プログラムにとって最悪の事態である。運が良ければ、プログラムはエラーを起こして終了する。運が悪ければ、プログラムは妥協した状態で動作を継続し、最悪の場合、悪意のあるコードを実行してコンピュータを乗っ取られることになる。

printf の2つ目の危険な特徴は、ポインタの使用である。ポインタが有効なデータ部分を指していることを保証するのは、プログラマーの勤勉さ次第である。"Hello World!" の例では、ポインタはヌル文字で終端する静的文字列を指しているので問題ない。しかし、以下のプログラムもコンパイルできる。

char * format = 0;
printf (format);        

何が起こるか想像できるだろうか。printf の内部ではポインタが参照解除され、すべてが台無しになる。再びC標準規格を引用すると、

ポインタに無効な値が割り当てられた場合、単項演算子「*」の動作は未定義である。

ポインタについてもう少し詳しく説明しよう。 メモリの割り当てはすべて有効なポインタを返す(プログラムがメモリ不足に陥らない限り)。 このようなポインタの参照解除は安全だと考えるかもしれない。 プログラムが割り当てられたメモリを解放してオブジェクトの寿命を終えることがない限り、その通りである。 それ以降は、無効なポインタを扱うことになり、すべてが台無しになる。 繰り返しになるが、C標準規格ではこの点について明確に述べている。

単項演算子*によるポインタの参照において無効な値には、ヌルポインタ、参照先のオブジェクトの型に対して不適切なアラインメントのアドレス、オブジェクトの有効期限が切れた後のオブジェクトのアドレスなどがある。

ご覧の通り、C言語は臆病者のために設計されたものではない。C言語は低レベル言語であり、プログラマーは自分が何をしているのかをよく理解していなければならない。さもなければ、その代償を支払うことになる。

C++は違うよね?

C++は違うよね?

C++の歴史を通じて、C++はC言語のレガシーと格闘してきた。C言語の安全でない機能を補うために、多くの構文が提供されてきた。例えば、C++のxml-ph-0000@deepl.internalプログラムは、

C++の歴史を通じて、C++はC言語のレガシーと格闘してきた。C言語の安全でない機能を補うために、多くの構文が提供されてきた。例えば、C++の"Hello World!" プログラムは、より安全なバージョンに変換できるかもしれない。

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

引数の数は可変ではなく、std::cout オブジェクトは渡された引数の型を理解するのに十分賢い(それでも、構文が単純であるという理由だけで、多くのプログラマーはC++でprintf を使い続けている)。

C言語とは異なり、C++ではメモリ割り当ては型付けされ、オブジェクトの構築と組み合わされる(mallocfree を避ける限り)。これが良い点だ。しかし、オブジェクトは依然として明示的にリサイクル(削除)しなければならない。そして、リサイクル後もプログラムには未使用のポインタが残され、その参照解除は、ご想像の通り、未定義の動作につながる。

C言語ではポインタが重要であったのに対し、C++では標準ライブラリの主要な手段としてポインタが採用された。STLアルゴリズムでは、ポインタそのもの、またはポインタの動作(および落とし穴)を模倣するオブジェクトであるイテレータが使用される。ポインタの場合と同様に、イテレータを使用する際のプログラマーのエラーは未定義の動作につながる(swap_ranges の例を参照)。

C/C++の安全性の欠如に対応して、JavaやC#などの言語は異なる道を歩んだ。 ポインタを完全に禁止するか、あるいは「非安全」な特別なブロックに追いやった。 リサイクルされたデータにアクセスするリスクのあるメモリ管理は、プログラマーの手を離れ、自動的な「ガベージコレクション」によって処理されるようになった。 C++には他にも多くの簡素化や安全性の向上が施されている。 残念ながら、それらはすべて表現力やパフォーマンスを犠牲にして実現されている。

SafeDサブセット

Dでは、大多数のプログラマーがDの安全なサブセット、すなわちSafeDの範囲内で作業を行うと想定している。SafeDの安全性と使いやすさはJavaに匹敵する。実際、Javaプログラムは機械翻訳によりDのこの安全なサブセットに変換することができる。SafeDは習得が容易であり、プログラマーを未定義の動作から遠ざける。また、非常に効率的でもある。

SafeD に入ると、ポインタ、未チェックキャスト、共用体は不要となる。 メモリ管理は "ガベージコレクション" によって提供される。 クラスオブジェクトは不透明なハンドルを使用して受け渡される。 配列と文字列はバインドチェックされる(コンパイラのスイッチでチェックを無効にすることは可能だが、その場合、SafeD ではなくなる)。それでも、実行時に例外(例えば、配列の境界外エラーや未初期化クラスオブジェクトエラーなど)をスローするコードを書くことはできるが、自分には割り当てられていないメモリやすでに再利用されているメモリを上書きすることはできなくなる。

xml-ph-0000@deepl.internalのDプログラムを見てみよう。一見したところ、C言語のプログラムとそれほど違いはない。

Dの"Hello World!" プログラムを見てみよう。一見したところ、C言語のプログラムとそれほど違いはない。

writeln("Hello Safe World!");

関数writeln は、Cのprintf と同等である(より正確に言えば、write やその書式設定バージョンであるwritefwritefln を含む出力関数ファミリーの代表である)。printf と同様に、writeln は任意の型の可変個数の引数を受け入れる。しかし、類似点はここまでである。writeln にSafeD引数を渡す限り、未定義の動作が発生することはない。ここでは、writelnstring 型の単一引数で呼び出される。Cとは対照的に、Dのstring はポインタではない。これはimmutable char の配列であり、配列はDの安全なサブセットに組み込まれている。

writeln の安全性がDでどのように実現されているか、ご興味があるかもしれない。一つのアプローチとしては、writeln をコンパイラ固有の機能として、正しいコードがケースバイケースで生成されるようにすることが考えられる。Dの優れた点は、このようなケースバイケースのコード生成を可能にする洗練されたプログラマー向けのツールを提供していることである。writelnの実装で使用されている高度な機能は以下の通りである。

codecode

SafeDライブラリ

JavaとDの主な違いのひとつは、Dには高度なプログラマーがSafeD内で使用できるライブラリを実装できるだけの十分な機能があるということだ。

Dの高度な機能の多くは、ユーザーに安全でない型を使用させるものでない限り、SafeDと互換性がある。例えば、ライブラリが汎用リストの実装を提供している場合、そのリストはあらゆる型、特にポインタ型でインスタンス化することができる。ポインタのリストは、ポインタの算術が安全ではないため、定義上安全であることはできない。しかし、intsやクラスオブジェクトのリストは安全であり、また安全であるべきである。これが、SafeD外での使用が安全ではない場合でも、このような汎用リストをSafeDで使用できる理由である。

さらに、リストの内部実装をポインタに基づいて行う方が効率的である可能性もある。これらのポインタがクライアントに公開されない限り、このような実装はSafeDと互換性があることが証明される可能性がある1。Dの高度な機能を活用しながら、SafeDでもそれを利用できる。

あるユーザーの体験談

あるユーザーの経験

SafeD2のアイデアを思いつく前から、 私はほとんどのプロジェクトでDの安全なサブセットに制限しようとしていた。 私は、どれほど多くのことが達成でき、生産性が飛躍的に向上するかに驚いた。 また、SafeDを同僚のC++プログラマーに見せたところ、 彼は非常に短い時間でそれを習得することができた。

これまでのところ、私の経験では、SafeDプログラムがエラーなしでコンパイルできれば、 ほとんどの場合、エラーなしで実行でき、 私が望む通りに動作する。これは、C++では間違いなく私の経験ではない。

さらに驚くのは、 ツールのサポートがほとんどない状態で、しかも 不可解なエラーメッセージを出すコンパイラで、これだけのことを達成できたことだ。Dはまだ多くのインフラが不足しているが、 生産性向上ツールがDの周りに多数出現すれば、プログラミングがいかに簡単になるかを想像できる。また、C++とは異なり、Dは 解析が容易であり、フロントエンドはオープンソースである。そのため、 ツール開発者にとって参入障壁がない。

脚注

  1. このような認証を発行する中央機関は存在せず、各ライブラリプロバイダーはクライアントとの信頼関係を築く必要がある。特に、コンパイラプロバイダーによるD標準ライブラリのSafeD認証を期待すべきである。
  2. SafeDという名称は、David B. Held氏によって提案された。

謝辞

この記事に貴重なフィードバックと修正を加えてくださったD設計チームの他のメンバーに感謝する。