Improve this page Github へのログインが必要です。 簡単な修正は、ここから fork、オンライン編集、pull request ができます。 大きな修正については、 通常の clone で行って下さい。 Page wiki 関連するWikiページを参照・編集 English このページの英語版(原文)

SafeD

by Bartosz Milewski, a member of the D design team

私は、多くの良いプログラマ達が、C++ を離れて Java や C# に移るのを目にしてきました。かなりヘビーな C++ プログラマである私としては、なぜ人が表現力に劣り効率にも劣る言語に移りたがるのか、疑問でした。ええ、もちろん、新しくプログラムを学ぼうとする人がシンプルで学習曲線の浅い言語を選ぶのはわかります、でも、一度 C++ のプロになるべく時間と努力を投資した人が、一体全体なにゆえそれを諦めるのでしょう?

転向した人誰もが答える理由は、“生産性” でした。プログラマは Java や C# や Ruby や Python を使った方が C++ を使うよりも生産的になる、というコンセンサスがあるようです。

C++ でのプログラミングを生産性から遠ざけている主な原因は何なのでしょう?

構文がひどい というのが一つあります。これは実際、想像以上に重大な問題です。良いプログラマなら、十分な時間があればこの大変ひどい構文を把握することができるでしょう。問題は、C++ の文法は人間に優しくないばかりか、構文解析器にとっても悪夢だということです。Java の市場が生産性改善のためのサポートツールで溢れているのは、言語の構文解析の容易さのあらわれです。私は、Java ではすでに常識となっているようなリファクタリング機能をそなえたC++の開発環境を見たことがありません。

言語の安全性は、もう一つの大きな要因です。C++ は自分の足を撃ち抜くコードの終わらない展覧会が開けることで悪名高い言語です。実際のところ、C++ は危険なコードを書けるだけではなく、危険なコードを 書いてしまいやすい のです。ある時、メジャーなC++コンパイラベンダはSTLの一部をセキュリティに関する懸念から "deprecated" としました。特に C++ の標準ライブラリは、C++ スピリットに従ってプログラムにバッファオーバーフローのバグが潜み込む方法の数を拡大しています。

よく知られている例が、std::swap_ranges アルゴリズムです。これは3つのイテレータを引数に取ります。最初の2つは一つのrangeを示し、3つめは別のrangeの先頭を指すことが期待されています。二つめのrangeがコンテナの後ろにはみ出さないことを確かめるチェックは何もありません。はみ出してしまったらウイルス作者は大喜びです!

プログラミング言語の設計者の変わらぬ夢は、プログラムのコンパイルに通ったらそれは動く、ということです。もちろん、「動くプログラム」の定義は十分納得できるものでなければなりません。例えば、プログラムが決して "stuck" しない — 計算機科学では厳密な意味を持つ単語ですが、おおまかにはプログラムが一般保護違反を起こさないこと (環境非依存な形で定義された"次の実行ステップ"が無いという意味で stuck と言います) — という保証は欲しいところでしょう。そのような性質を持つ言語は「健全」であると呼ばれます。

さてここで、Java のきちんと定義された(意味ある)サブセットは、健全です。実際に動いている Java プログラムは実用上の理由からこの健全なサブセットからはみ出ていることもありますが、少なくとも、安全でない機能を使用するコードは不格好で、C++の場合と比べてそういう部分を区別するのがJavaでは容易になっています。実際、Java のコンパイラは C++ のコンパイラよりも多くのバグを検出してくれます。これは直ちに、デバッグに費やす時間が減ること — すなわち高い生産性 — に繋がります。

それなら、C++ の良いところって何なのでしょう?

ひとつは、パフォーマンスです。C++ のパフォーマンスに勝つのは並大抵のことではありません。プログラムが高速で応答性が高くなければならない場合、C++ (あるいは C かアセンブリ) で書く以外の選択肢はほとんど無いと言って良いでしょう。

また、C++にはハードウェアと直接やりとりするための低水準の機能が備わっています。例えば、C と C++ はまだまだ組み込みプログラミングの王者です。

C++ は強力な抽象化、特にジェネリックなコードを書く能力を持っています。Java と C# にもそれぞれの Generics がありますが、C++ が提供するそれと比べると貧弱です。

これらの機能すべてが、C++ を、OS を書くための言語として理想的なものにしています。 OS は巨大なプログラムで、高速でなければならず、 ハードウェアを直接操作します。しかし、OS の外にも、 巨大で高速でなければならないアプリケーションはたくさんあります。

というわけで、どうもプログラミングの世界は C++ と、Java, C# その他にくっきりと分離されてしまうようです。このトレードオフを不可避と信じる限りは、これも仕方ないことと納得するしかありません。でも、しかし、Productivity と Power の両取りはできない などという自然法則はどこにも無いのです!

たまねぎ型に構成された言語というのはどうでしょう。まず、コアとして、JavaやC#に似ていなくもない十分にシンプルで安全なコアがあります。プログラマは簡単にその安全なサブセットをマスターして、Javaプログラマと同程度には生産的になれるでしょう。そして、その安全なサブセットが同時に C++ 並のパフォーマンスを提供するとしたら?

そして、同じ言語に、必要に応じて段階的に習得される外側のレイヤがあります。そこにはハードウェアレベルの低水準の機能や、コードを必要に応じて生成するような高水準の機能があります。モジュラリティや実装の隠蔽を提供します。右に出る者のないコンパイル時処理能力と、素晴らしい実行時パフォーマンスをもたらします。

そろそろネタをばらしてしまいましょう。D が、その言語です。

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

C で誰もが最初に書くプログラム、かの有名な "Hello World!" に、この言語の最も危険な側面がいくつも現れているのをご存じでしたか? それは、こんな文です:

printf ("Hello World!\n");
printf の型を考えてみましょう:
int printf (const char * restrict format, ...);	

(restrict は新しい C の予約語です) まず、この関数は可変個の引数を受け取ります。引数の型と個数は、format文字列の中にエンコードされています。

format指定と引数リストの内容が合っているかどうかのチェックはいつ行われるのでしょう? コンパイル時ではありません — コンパイラにはformat文字列の内容はわからないからです(尤も、静的に文字列の内容がわかる場合はいくつかのコンパイラは警告を出します)。では実行時でしょうか? まあ、同じく、ご想像の通りです。 プログラマのミスによって printf の引数が足りない場合には、きちんとしたエラーや例外すら受け取ることは難しいでしょう。この場合に何が起きるか、C の標準規格にはこう書かれています:

If there are insufficient arguments for the format, the behavior is undefined. (format指定に対して引数が不足していると、動作は未定義となる)

未定義動作というのは、プログラム中で起こりうる最悪の挙動です。運が良ければ、悲惨なことになる前にプログラムは強制終了するでしょう。そんなに運が良くなかったら、プログラムはおかしな状態で走り続け、最悪、あなたのコンピュータを破滅させる悪意のあるコードを実行してしまいます。

printf の二つめの危険な点は、ポインタの使用です。ポインタが正しくデータを指しているかどうかは、完全にプログラマの責任とされています。 "Hello World!" の例では、ポインタは null 終端の文字列を指していましたので、大丈夫です。しかし、以下のようなコードも同じくコンパイルが通ってしまうのです:

char * format = 0;
printf (format);	

何が起こるか考えてみましょう。printf の中ではポインタ経由でデータが参照されますが、さて、再び C の標準を引用すると

If an invalid value has been assigned to the pointer, the behavior of the unary * operator is undefined. (ポインタに無効な値が割り当てられている場合、単項 * 演算子の動作は未定義となる)

もう少しポインタの話を続けましょうか。メモリ割り当てを行うと(プログラムがメモリを使い果たしていない限り)有効なポインタが返されます。そのようなポインタの指すデータを参照するのは安全とお考えかもしれません。これは、あなたがプログラムでそのメモリを解放せず、つまりオブジェクトの寿命が続いている限りは、その通りです。解放した後は、宙ぶらりん(dangling)ポインタを扱うことになり、またまた、C の標準はこれについても大変正直です。

Among the invalid values for dereferencing a pointer by the unary * operator are a null pointer, an address inappropriately aligned for the type of object pointed to, and the address of an object after the end of its lifetime.(単項 * 演算子による参照外しの対象として不正なポインタの値は、ヌルポインタ、正しく型に応じたアラインがなされていないアドレス、そして、寿命の終わったオブジェクトのアドレス、である)

と、ごらんの通り、C は心臓に優しい設計にはなっていません。C は低水準言語では、プログラマが自分のやっていることを良く理解していないと、ここまでに上げたような結末に苦しむことになります。

でも C++ は違います、よね?

その歴史を通して、C++ はレガシーな C の扱いに苦しんできました。C の安全でない機能を塞ぐために、多くの機能が提供されています。例えば、"Hello World!" はC++なら安全なバージョンに書き直すことができます。

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

ここでは可変個引数もなく、std::cout はコンパイル時に渡されたオブジェクトの型を正しく知っています。(まだかなり多くの C++ プログラマが、構文的な簡潔さのためだけに printf を使い続けていますが)

C とは違い、C++ のメモリ割り当ては型付きであり、オブジェクトの生成と組み合わされています (mallocfree を使うようなことをしなければ)。これは良いことです。しかしながら、依然としてオブジェクトを明示的に解放 (delete) する必要があります。そして、解放後は宙ぶらりんポインタがプログラムに残され、その参照外しは — ご想像の通り — 未定義動作になってしまいます。

ポインタは C でも重要な要素でしたが、C++ では、この概念を標準ライブラリを形作る中心的な道具として愛用しています。STL のアルゴリズムが使うイテレータとは、ポインタもしくはポインタの動作(と落とし穴)を真似るように作られたオブジェクトのことです。ポインタの場合と全く同じように、イテレータの使用に際するプログラマのミスは、未定義動作につながっています (swap_range の例を参照)。

C/C++ の本質的な安全性の欠如に対して、Java や C# は違う道を進みました。ポインタを禁止したり、特別な "unsafe" ブロックに閉じこめたのです。解放済みデータへのアクセスという危険を伴うメモリ管理はプログラマの手から話され、自動的なガベージコレクションによって行われるようになりました。他にも C++ と比べて多くの単純化と安全性の改善がなされています。しかし不幸なことに、これらは全て表現能力とパフォーマンスに代償を払って実現されています。

SafeD サブセット

D では、大部分のプログラマが安全な D のサブセット、名付けて SafeD の範囲でコードを書いていると予想されます。SafeD の安全性と使いやすさは Java と同等です — 実際、Java のプログラムはこの D の安全なサブセットへ機械的に変換できます。SafeD は学習するのが簡単で、プログラマを未定義動作に近づけません。そして効率的でもあります。

SafeD では、ポインタやチェック無しキャスト、union は使われません。メモリ管理は完全に GC に面倒を見てもらいます。クラスオブジェクトは非透過(opaque)なハンドルを通して操作されます。配列と文字列は境界検査がなされています (コンパイラのスイッチでこれをoffにはできますが、そうするともう SafeD ではなくなります)。依然として実行時例外を投げるコード (例: 配列の境界外アクセスや、未初期化クラス参照のエラー) を書くことはできますが、未割り当てや回収済みのメモリ領域を上書きするようなコードは書くことができません。

D での "Hello World!" プログラムを見てみましょう。表面的な見た目は、C のそれとそんなに変わりません:

writeln ("Hello Safe World!");

関数 writeln は C の printf に対応するものです (より正確には、これは write とその書式化版 writefwritefln のファミリの代表です)。printf と同じように、writeln は任意の型の可変個の引数を受け取ります。しかし、似ているのはここまでです。SafeD-引数を渡していれば、writeln が未定義動作を起こすことはないと保証されます。ここでは writelnstring 型の引数一つで呼び出されています。C と違い、D の文字列 string はポインタではありません。これは immutable char の配列で、配列は D の安全なサブセットに組み込みの機能です。

D でどのように writeln の安全性が実現されているか、きっと興味を持たれたことでしょう。一つ可能な方法としては、writeln をコンパイラ組み込みの命令として、全てのケースについて安全なコードを生成するという手があります。D の素晴らしいところは、各ケース毎のコード生成を洗練された方法でプログラムとして書き下すことができるところです。writeln の実装に使われている高度な機能は以下の通りです:

SafeD ライブラリ

Java と D の大きな違いは、D には SafeD から使えるライブラリを上級者プログラマが実装できるだけの表現力が備わっているところです。

D の多くの高度な機能は、ユーザーに安全でない型の使用を強制していない限り、SafeD 互換です。例えば、汎用のリストの実装を提供するライブラリがあったとします。このリストは任意の型、特にポインタ型でインスタンス化できます。ポインタのリストは、定義から、安全ではありえません。ポインタ算術が健全ではないからです。しかしながら、intのリストやクラスオブジェクトのリストは安全で(あるべきで)す。これが、たとえ SafeD の外で使用すると安全でないとしても、汎用のリストが SafeD で使用できることの理由です。

さらにここで、リストの実装の内部ではポインタを使うのが効率的ということもあるかもしれません。そのようなポインタがクライアントに晒されていなければ、このような実装も SafeD 互換として保証1 できる可能性があります。ケーキ (D の高度な機能) はすぐそこにあるし、ちゃんと食べて (SafeD からの活用) 大丈夫です。

一ユーザとしての経験談

SafeD2 というアイデアを思いつく前でも、 私は自分のプロジェクトのほとんどで、D の安全なサブセットしか使わないようにしていました。 その範囲でどれだけのことが実現できて、生産性があがるか、驚いたものです。 SafeD を同僚の C++ プログラマにも見せてみましたが、 彼も短期間でそれを把握することが出来ました。

今までのところ、私の経験では、SafeD プログラムがエラー無しでコンパイルできたなら、 ほぼ大部分の場合でエラー無く動作し、 思った通りの処理が実行されました。 これは C++ では絶対に経験できなかったことです。

もっと驚いたのは、 これをほとんどツールの類の助けなしに実現できたことです。 D には まだまだインフラが整っていませんが、 生産性改善のツールが育ったときにさらにどれだけプログラミングが楽になることでしょう。 C++とは違い、D は構文解析が簡単で、フロントエンドはオープンソースです。 ツール開発者に対する障壁もありません。

脚注

  1. このような保証を与える中心的な機関は存在せず、各ライブラリの提供者が個々にクライアントと信頼関係を結ぶ必要があります。特に、D の標準ライブラリに関しては、コンパイラベンダによって SafeD 保証がなされることとなるでしょう。
  2. SafeD という名前は David B. Held の発案です

謝辞

D design team の仲間達による、この記事への価値あるフィードバックや訂正に感謝します。