Dの文字列 vs C++の文字列
何故D言語では、C++の文字列のように全てをライブラリに入れるのではなく、 文字列を言語のコアに組み込んでいるのでしょう?どこがポイントで、どこが改善されているのでしょうか?結合演算子
C++ の文字列は、既存の演算子のオーバーロードによって成り立っています。 結合に対しては += と + が明白な選択とされました。しかし、 ただコードを見るだけの人は + を見て、"足し算" だと思うでしょう。 その人は型情報を探して(しかも型はしばしば、 何回も typedef した奥の方に埋まっていたりします)文字列型であることを突き止め、 文字列を足しているのではなく結合しているのだ、ということがやっとわかるのです。
さらに言えば、float の配列があるとき、'+' はオーバーロードされて ベクトルの加算の意味になるのでしょうか、それとも配列の結合の意味になるのでしょうか?
Dでは、新しい二項演算子 ~ を結合演算子として導入することで、 この問題を解決しました。これは配列(文字列は配列の一種です) に対しても動作します。~= はこれに応じて、 末尾追加演算になります。 floatの配列の ~ なら結合で、+ はベクトル加算を暗示するでしょう。 新しい演算子を追加することで、配列の扱いを直交的で一貫性のあるものにできます。 (Dでは、文字列は単に文字の配列であって、 特別な型ではありません。)
C言語の文字列構文との相互運用
演算子のオーバーロードは、オペランドの最低どちらか一方が オーバーロード可能な時のみ有効です。このため、C++ の string クラスは文字列を含む任意の式を扱うことができません。次の例を考えて下さい:
const char abc[5] = "world"; string str = "hello" + abc;
これは動作しません。しかし、言語自体が文字列について知っていれば、 動作するのです。
const char[5] abc = "world"; char[] str = "hello" ~ abc;
C言語の文字列構文との一貫性
C++では、文字列の長さを調べるのに三つの方法があります:
const char abc[] = "world"; : sizeof(abc)/sizeof(abc[0])-1 : strlen(abc) string str; : str.length()
この手の一貫性のなさは、汎用のテンプレートを書くときに問題になります。 Dの場合を考えてみて下さい:
char[5] abc = "world"; : abc.length char[] str : str.length
空文字列チェック
C++ の string では文字列が空かどうかの判定に関数を使います:
string str; if (str.empty()) // 文字列は空
Dでは、空文字列の長さはゼロです:
char[] str; if (!str.length) // 文字列は空
既存の文字列のサイズ変更
C++ ではこれをメンバ関数 resize() で扱います:
string str; str.resize(newsize);
D では str が配列と知っているという利点がありますから、 リサイズは単にlengthプロパティを変更するのみです:
char[] str;
str.length = newsize;
文字列のスライシング
C++ では特別なコンストラクタによって、既存の文字列の一部分を切り出します:
string s1 = "hello world"; string s2(s1, 6, 5); // s2 は "world"
Dには、C++では不可能な配列切り出しの構文があります:
char[] s1 = "hello world"; char[] s2 = s1[6 .. 11]; // s2 は "world"
これはもちろん、文字列だけでなくDの配列全てに使えます。
文字列の複写
C++ での replace 関数は文字列の複写を行います:
string s1 = "hello world"; string s2 = "goodbye "; s2.replace(8, 5, s1, 6, 5); // s2 は "goodbye world"
D ではスライスを左辺値として使います:
char[] s1 = "hello world"; char[] s2 = "goodbye ".dup; s2[8..13] = s1[6..11]; // s2 は "goodbye world"
.dup は、D では文字列リテラルが読み取り専用なために必要となります。 この .dup でコピーを作成することで、 書き込み可能な文字列が作られます。
C文字列への変換
これは C の API との互換性のために必要となります。 C++ では、c_str() メンバ関数を使います:
void foo(const char *); string s1; foo(s1.c_str());
Dでは、文字列は.ptrによってchar*へ変換します:
void foo(char*); char[] s1; foo(s1.ptr);
これはしかし、foo がゼロ終端文字列を期待している場合、 s1 の末尾に0がついていないと正しく動作しません。代わりに、 std.string.toStringz 関数を使うとそれが保証されます:
void foo(char*); char[] s1; foo(std.string.toStringz(s1));
配列の境界チェック
C++ では、[] での文字列の境界チェックは行われません。 Dでは、配列の境界チェックはデフォルトでONで、 デバッグの後にコンパイラのスイッチによってOFFにもできます。
文字列によるswtich文
これはC++では不可能で、 ライブラリに付け加えることも不可能です。 Dでは、明白な構文で記述されます:
switch (str) { case "hello": case "world": ... }
このとき str は、文字列リテラル"string"や、char[10] のような固定長文字配列、 あるいは char[] のような動的文字列のどれであっても構いません。 高品質な実装では、もちろん、case 文字列の内容によって効率的にこのswitch文を実装する戦略を選ぶことができます。
文字列を埋める
C++では、replace() メンバ関数で実現されています:
string str = "hello"; str.replace(1,2,2,'?'); // str は "h??lo"
Dでは、自然なスライス構文を使います:
char[5] str = "hello"; str[1..3] = '?'; // str は "h??lo"
値 vs 参照
C++の文字列は、STLPortで実装されているように、 値のセマンティクスを持ち 0終端 です。[ 後者は実装依存ですが、STLPort がもっとも広く使われている実装のようです。 ] このことと、 ガベージコレクションが無いことをあわせると、幾つかの結論が導かれます。まず どの文字列オブジェクトも、文字列データのコピーを自分用に作らなくてはなりません。 文字列データの’所有者’が削除されると全ての参照が無効になるため、 ’所有者’を注意深く追跡する必要があります。文字列を値として扱うことで dangling reference 問題を避けようと思ったら、 メモリ割り当て/コピー/解放 のオーバーヘッドが大量に発生します。 次に、0終端であることは、文字列が他の文字列を参照できないことを示します。 静的データセグメントやスタック中などなど... を参照することができません。
D言語の文字列は参照型で、メモリはガベージコレクトされます。 これは、文字列データでなく参照だけをコピーすれば済むことを意味しています。 Dの文字列は静的データセグメントやスタック内のデータ、 他の文字列やオブジェクトの一部分、 ファイル用のバッファなどを直接参照できます。 プログラマが文字列データの '所有者' を追跡する必要もありません。
当然の質問として、複数のDの文字列が一つの同じ文字列データを参照するのなら、 そのデータが書き変わったときにどうなるの? とお考えでしょう。 この場合、全ての参照は書き変わったデータを指すことになります。 これもこれで影響がありますが、copy-on-write の慣習を守ってコーディングすることで回避できます。copy-on-write とは要するに、文字列を書き換える必要があるときは、 まず内容をコピーしてから書き込む、ということです。
Dの文字列が参照型のみでガベージコレクトされる、ということは、 結果として、 例えばlzw圧縮のような文字列操作を多数繰り返す場合の メモリ消費と速度の両面での効率改善につながっています。
ベンチマーク
小さなユーティリティ、テキストファイル中の各単語の出現回数を数える 語数カウンタを見てみましょう。D では、こんな感じになります:
import std.file; import std.stdio; int main (char[][] args) { int w_total; int l_total; int c_total; int[char[]] dictionary; writefln(" lines words bytes file"); for (int i = 1; i < args.length; ++i) { char[] input; int w_cnt, l_cnt, c_cnt; int inword; int wstart; input = cast(char[])std.file.read(args[i]); for (int j = 0; j < input.length; j++) { char c; c = input[j]; if (c == '\n') ++l_cnt; if (c >= '0' && c <= '9') { } else if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z') { if (!inword) { wstart = j; inword = 1; ++w_cnt; } } else if (inword) { char[] word = input[wstart .. j]; dictionary[word]++; inword = 0; } ++c_cnt; } if (inword) { char[] w = input[wstart .. input.length]; dictionary[w]++; } writefln("%8s%8s%8s %s", l_cnt, w_cnt, c_cnt, args[i]); l_total += l_cnt; w_total += w_cnt; c_total += c_cnt; } if (args.length > 2) { writefln("--------------------------------------%8s%8s%8s total", l_total, w_total, c_total); } writefln("--------------------------------------"); foreach (char[] word1; dictionary.keys.sort) { writefln("%3d %s", dictionary[word1], word1); } return 0; }
(巨大なファイルを扱うために、バッファ付きのファイルIOを使う 別の実装 もあります。)
二人の方が、C++の標準テンプレートライブラリを用いて C++ 版を実装してくれました。 wccpp1 と wccpp2 です。 入力ファイル alice30.txt は”不思議の国のアリス”の文章です。 Dコンパイラ dmd と C++コンパイラ dmc, は同じ最適化ルーチンとコード生成ルーチンを使っているため、 最適化の性能などによらず、 より確実に言語自体の効率を突きあわせて比較ができます。 テストはWinXP機で実行されました。 dmcではテンプレートの実装として STLport を使っています。
プログラム | コンパイルオプション | コンパイル時間 | 実行オプション | 実行時間 |
---|---|---|---|---|
D wc | dmd wc -O -release | 0.0719 | wc alice30.txt >log | 0.0326 |
C++ wccpp1 | dmc wccpp1 -o -I\dm\stlport\stlport | 2.1917 | wccpp1 alice30.txt >log | 0.0944 |
C++ wccpp2 | dmc wccpp2 -o -I\dm\stlport\stlport | 2.0463 | wccpp2 alice30.txt >log | 0.1012 |
以下のテストはLinuxで実行しました。再び、最適化部とコード生成部を共有する Dコンパイラ(gdc) とC++コンパイラ(g++)を使っています。 動作環境は、Pentium III 800MHz 上の RedHat Linux 8.0 と、 gcc 3.4.2 です。 Linux版の Digital Mars D compiler(dmd) も比較に含まれています。
プログラム | コンパイルオプション | コンパイル時間 | 実行オプション | 実行時間 |
---|---|---|---|---|
D wc | gdc -O2 -frelease -o wc wc.d | 0.326 | wc alice30.txt > /dev/null | 0.041 |
D wc | dmd wc -O -release | 0.235 | wc alice30.txt > /dev/null | 0.041 |
C++ wccpp1 | g++ -O2 -o wccpp1 wccpp1.cc | 2.874 | wccpp1 alice30.txt > /dev/null | 0.086 |
C++ wccpp2 | g++ -O2 -o wccpp2 wccpp2.cc | 2.886 | wccpp2 alice30.txt > /dev/null | 0.095 |
以下のテストは gdc で、PowerMac G5 2x2.0GHz マシン上の MacOS X 10.3.5 と gcc 3.4.2 で行いました。 (時間はやや不正確です。)
プログラム | コンパイルオプション | コンパイル時間 | 実行オプション | 実行時間 |
---|---|---|---|---|
D wc | gdc -O2 -frelease -o wc wc.d | 0.28 | wc alice30.txt > /dev/null | 0.03 |
C++ wccpp1 | g++ -O2 -o wccpp1 wccpp1.cc | 1.90 | wccpp1 alice30.txt > /dev/null | 0.07 |
C++ wccpp2 | g++ -O2 -o wccpp2 wccpp2.cc | 1.88 | wccpp2 alice30.txt > /dev/null | 0.08 |
wccpp2 by Allan Odgaard
#include <algorithm> #include <cstdio> #include <fstream> #include <iterator> #include <map> #include <vector> bool isWordStartChar (char c) { return isalpha(c); } bool isWordEndChar (char c) { return !isalnum(c); } int main (int argc, char const* argv[]) { using namespace std; printf("Lines Words Bytes File:\n"); map<string, int> dict; int tLines = 0, tWords = 0, tBytes = 0; for(int i = 1; i < argc; i++) { ifstream file(argv[i]); istreambuf_iterator<char> from(file.rdbuf()), to; vector<char> v(from, to); vector<char>::iterator first = v.begin(), last = v.end(), bow, eow; int numLines = count(first, last, '\n'); int numWords = 0; int numBytes = last - first; for(eow = first; eow != last; ) { bow = find_if(eow, last, isWordStartChar); eow = find_if(bow, last, isWordEndChar); if(bow != eow) ++dict[string(bow, eow)], ++numWords; } printf("%5d %5d %5d %s\n", numLines, numWords, numBytes, argv[i]); tLines += numLines; tWords += numWords; tBytes += numBytes; } if(argc > 2) printf("-----------------------\n%5d %5d %5d\n", tLines, tWords, tBytes); printf("-----------------------\n\n"); for(map<string, int>::const_iterator it = dict.begin(); it != dict.end(); ++it) printf("%5d %s\n", it->second, it->first.c_str()); return 0; }