はじめに
D言語は以下のような特徴をもった汎用プログラミング言語である。
- C言語に似た構文
- ネイティブなバイナリにコンパイルされる
- 静的型付け
- GCを持つが手動でのメモリ管理も可能
- 低レイヤにアクセスするようなシステムプログラミングが可能
こういった特徴は、すべてを兼ね備えているものは多くはないかもしれないが、個々でみるとそれほど特出するようなものはない。
GCはないがC++やRustは似た言語といえるし、GoやNimも近い特徴を持った言語といえるだろう。
それではD言語をD言語たらしめているものはなんだろうか。
それについていくつかの観点で考察してみよう。
言語設計
D言語は言語理論の研究のために生まれたものではなく、当然esolangでもない。
D言語は実用的であること、つまりサービスや製品の開発での利用を主眼において設計されている。
こうした目的のため、統一された言語デザインがあるわけではなく、雑多なそして有用である考えられている言語機能の寄せ集めによって成り立っている。
例として、言語設計において重視している要素を並べてみる。
- 高速な実行バイナリをわかりやすいコードで生成すること
- マルチパラダイム - 関数ベース、テンプレートメタプログラミング、オブジェクト指向といったパラダイムをサポートする言語機能を提供
- プログラミングでよくある間違いを起こさせないような構文設計
- メモリ安全性
- 低レイヤへアクセスするための手段の提供
- C言語資産との相互運用性
こうした設計は半ば相反するものも存在するが、プログラマは豊富な言語機能の中から取捨選択することが可能であり、その責任を負う。
C++
D言語の出自について言及する際に、C++について触れずにいることは不可能だろう。
C++を知らない人のために説明をすると、C++はひどい構文を持った言語であり、普通の人間が読んだり書いたりするのに適していない。
さらに残念な事実として、この構文は人間にとってだけでなく、構文解析器や静的解析を行うツールなどにとっても好ましくないものとなっている。
これは言語エキスパートのみが記述するような複雑な構文を利用したコードだけではなく、頻繁に記述されるような表面上簡易な構文においても起きうる問題である。
たとえば、以下のコードにおいて f
はどのようなセマンティクスを持ちうるだろうか?
f(x)
このコードはC++において次のような意味論を持っている可能性がある:
- 関数(もしくは関数ポインタ)
-
operator()
をオーバーロードしたクラスのインスタンス - 上述したオブジェクトに暗黙に変換可能ななにかのオブジェクト
- オーバーロードされた関数名
- テンプレートの名前
ここでC++にはプリプロセッサマクロが存在することについても留意されたい。
またC++のさらなる特徴として、メモリ安全性に問題を抱えている言語という視点もある。
俗にいう「自分の足を撃ち抜く」権利についての話になる。
C++はメモリ安全なプログラミングのサポートするどころか、そのようなプログラムを書くことを難しくするような設計にさえなっている。
(よい一例としてSTL std::swap_ranges
などがある)
それではなぜC++が使われているかというと、パフォーマンスがその理由のひとつとしてある。
C++以上の性能を達成するのは一般にとても困難な作業となる。
またハードウェアと直接介するような低レベルの操作を行うことも可能であり、たとえば組込みプログラミングなどではこういった機能を持った言語で記述される必要がある。
Cのような言語と比してC++が優れている点として、テンプレートによって強力な抽象化を実行時コストをかけることなく行うことが可能となる。
(その分コンパイル時間にしわ寄せがくることもある)
それでは、人間と構文解析器に優しい構文設計・メモリ安全性を持ち、C++に近いパフォーマンス・低レベル操作・テンプレートによる強力な抽象化を兼ね備えたプログラミング言語が存在したら?
もちろんD言語の冒険はまだ道半ばである。それでもD言語は
- 人間と構文解析器により優しい構文、これはコンパイル速度にもよい影響を与えている
- GCによるメモリ管理によってメモリ安全性を担保
- コンパイラの最適化機能による、高速なネイティブコード生成
- ポインタ操作・インラインアセンブリといった低レベルな操作も可能
- テンプレートによるメタプログラミングの言語機能での支援
を高いレベルで達成している。
構文論
D言語は人間にとって理解しやすい構文を志向していると同時に、構文解析器にとっても解析しやすい構文設計となっている点については前述したが、少しだけ例をみてみよう。
例としてテンプレートのパラメータや引数を囲むために用いられる < >
について説明する。
<
>
>>
はC++において比較演算やシフト演算などでも用いられる記号である。
あなたがヘッダファイルで以下のような宣言を目にしたとき、これがテンプレート適用なのか比較演算なのか一目で判別可能だろうか?
a<b, c>d;
a<b<c>>d;
D言語は !
が二引数をとる演算子として用いられていないことを利用して、以下のような構文でテンプレート適用を表現している:
a!(b, c)
別の例をみてみよう。
多くの言語で見かけるだめだめな構文デザインの典型的な例として、+
に数学的な意味の加算と配列の結合を、+=
に加算+代入と配列の結合+代入の意味論を付与しているというものがある。
とくに型のない言語などで文字列->数値への変換ロジックの記述を忘れてしまった場合に、ある数の計算結果ではなく巨大な文字列が生成されてメモリ空間を圧迫し、一見不可解な障害として露見してしまうといったことが起きる。
D言語はそういった類のバグを混入しにくいような構文デザインに価値を見出しているため、異なるアプローチをとっている:
たとえばこの例では動的配列の結合には~
を、結合+代入には~=
を使うという、構文デザインによって潜在的なバグへの対処を行っている。
ガーベジコレクタ
GCについては、特にシステムプログラミングに携わる人にとって最も紛糾する議題となっている。
D言語はGCが銀の弾丸でないことは認めながら、いくつかのメリット・デメリットを比較した上でGCを採用する方向に踏み切った。
- GCを使った場合のほうがしばしば高速である点
- 参照カウント方式は循環参照の問題・カウンタ・スライスのようなデータ構造にラッパー層が必要といった問題がある
- メモリリークを防げる
- ハンディーなメモリ管理機構を自身でメンテナンスしなくてもよくなる
GCの弱点としては、
- メモリの確保タイミングが自明でないこと、期待しないタイミングで動作が一時的に停止することがある
- スキャンの時間に制約を保証できないこと
- Stop the World、GC中はすべてのスレッドが動作を停止しうる(ランタイム・GCの実装によってはSTWが存在しないGCもありえる)
といった点が挙げられる。
将来的にD言語が参照カウントによるメモリ管理や、Rustのようなshared XOR mutable(所有権・借用)をコアにしたメモリ管理機構を導入する可能性は否定できないが、
現時点においては標準ライブラリの実装含め多くの資産がGCの上に成り立っている。
メモリ安全性
メモリ安全性の定義は Wikipediaによれば、「メモリ安全性 (メモリあんぜんせい、英語: Memory safety) は、バッファオーバーフローやダングリングポインタ(英語版)などの、RAMアクセス時に発生するバグやセキュリティホールなどから保護されている状態」となっている。
具体的にどのような操作がメモリ安全性を毀損しているだろうか。メモリ安全性のページでは、同時に以下のようなものをメモリエラー・もしくはその原因となる要素としてあげている。
- バッファオーバーフロー
- ポインタ演算
- 未初期化メモリ
- 危険なキャスト
- 不正なスタックフレームへの参照
- ダングリングポインタ
- 競合状態
D言語はメモリ安全性を重視した言語でありながら、システムプログラミング言語として低レベルへのアクセス、例えばポインタ演算のような操作も行える必要がある。
D言語はこの矛盾を解決するために関数属性によってメモリ安全性を保証するスコープを分割している。
safe関数
D言語では関数に @safe
という属性を付与すると、それはsafe関数とみなされる。
safe関数内ではメモリ安全性を毀損するようないくつかの操作が禁止されている。
void safety() @safe
{
...
}
たとえば、以下のような操作はsafe関数内で行うことはできない。
- 非ポインタ型からポインタ型へのキャスト
- ポインタ演算
- 未初期化変数の利用
- system関数の呼び出し
-
__gshared
で定義したグローバル変数もしくはstatic変数へのアクセス
いくつか補足しよう。
system関数は、危険なメモリ操作を行うかもしれない関数である。D言語はメモリ安全性を分類する関数属性としてsafe/trusted/systemという属性がある。
trusted関数はsafe関数では禁止されている操作を行っているが、実装者が安全性を保証可能である場合に、その責任において付与される属性である。
trusted関数はsafe関数から呼び出すことが可能となるが、メモリ安全性の保証はコンパイラではなく完全に実装者に委ねられる。
真の意味でプログラマを trusted
している関数なのだ。
__gshared
にかんしてもD言語に固有の事情となる。
D言語でCやC++と同様の形式でグローバル変数を定義した場合、実はその変数はスレッド局所記憶(TLS)に配置されている。
これらの言語と同様のメモリ配置を行いたい場合に shared
型を付与するか __gshared
属性を付与する必要がある。
この2つの属性の違いはアトミックな処理を行っているかの検査を型によって行っているかの違いである。
つまり、__gshared
属性を付与した変数を用いた場合に各スレッド間で共有しているグローバル変数へのアクセスに対して、その操作がアトミックであることを保証できないということであり、これは潜在的に競合状態が起きうるということになる。
そのためsafe関数にはこのような制約も設けられている。
以下、個別に具体例をソースコードを通して眺めてみよう。
バッファオーバーフロー
Cのような言語でしばしば行われがちな配列の範囲外アクセスを含むような以下のコードを考える。
void main()
{
int[10] a;
for (size_t i; i <= 10; ++i)
a[i] = 42;
}
D言語は実行時に配列境界のチェックがデフォルトで行われる。
$ dmd -g -run bufferoverflow.d
core.exception.ArrayIndexError@bufferoverflow.d(5): index [10] is out of bounds for array of length 10
----------------
??:? _d_arraybounds_indexp [0x5619ef10e745]
./bufferoverflow.d:5 _Dmain [0x5619ef10e66e]
配列境界のチェックは安全なプログラムを書く際に有用な機能であるが、速度を犠牲にしたくないという要求があるかもしれない。
D言語は-releaseオプションを用いた場合は実行時の境界チェックを行わないようになる。
普段の開発では境界チェックを有効にし、製品のリリースなどでは無効にするといった使い方が可能となる。
$ dmd -release -run bufferoverflow.d
$ echo $?
0
$ dmd -boundscheck=off -run bufferoverflow.d # もしくは境界チェックのみ明示的にoffにする
$ echo $?
0
未初期化メモリ
D言語では型に対するデフォルトの初期化処理が割り当てられているため、こういった問題は起きにくくなっている。
int i; // 常に 0
int* p; // 常に null
float f; // 常に NaN
char c; // 常に 0xFF
Walter Brightによるとデフォルト初期化のメリットとして、
- デフォルトオブジェクトの構築は失敗しないことが保証できる
- 単にコンパイルが通ることを検査するダミーオブジェクトを作成できる
などの点をメリットをあげている。
特殊な初期化構文を用いることによって初期化処理をスキップすることもできる。
int* p = void; // 不定な値がはいっている
ただし、このコードは @safe
関数もしくはブロック内では禁止されている。
void someFunc() @safe
{
int* p = void; // Error: `void` initializers for pointers not allowed in safe functions
}
不正なキャスト
safe関数内ではいくつかのキャスト操作が禁止されている。
たとえば、非ポインタ型からポインタ型へのキャストはsafe関数内で行うことはできない。
void main() @safe
{
auto p = cast(int*) 1234; // Error: cast from `int` to `int*` not allowed in safe code
}
また範囲外となるようなメモリ領域への参照を含んでしまうようなポインタの変換操作も禁止されている。
int* p = new int(42);
long* lp = cast(long*) p; // Error: cast from `int*` to `long*` not allowed in safe code
不正なスタックフレーム参照
少しばかりわざとらしいが、以下のようなプログラムはCのような言語で典型的に行われがちな失敗だ。main関数は関数fooのスタックフレーム内の変数への参照を持ってしまっている。
int* foo()
{
int i = 42;
int* p = &i;
return p;
}
void main()
{
auto p = foo();
}
先に正解を書いてしまうと、不正なスタックフレームへの参照は return ref
を用いることで禁止にできる。
その前に ref
とはなんだろうか?D言語におけるrefはポインタにいくつかの制約を加えたキーワードとなる。
refはパラメータか関数の返り値にしか用いることができず、ポインタ演算を行うことができず、エスケープすることができない。
上述したようにrefはパラメータか返り値にしか用いることはできない。そのため以下のコードは合法なD言語のプログラムではなくコンパイルもできないが、
ref int foo()
{
int i = 42;
ref int p = i; // これは非合法
return p;
}
その代わりに以下のような恒等関数を定義して呼び出すことは可能である。
ref int foo(ref int i)
{
return i;
}
ただし上記のコードをrefに置き換えただけでは不十分である。
ref int foo(ref int i)
{
return i;
}
ref int bar()
{
int i = 42;
return foo(i);
}
void main()
{
auto p = bar(); // <-- `i` の寿命を超えて生き残ってしまう
}
そこで引数のライフタイムを超えて生き残らないことを保証する手段として return ref
を用いることができる。
return ref
を使って書き換えたものは以下のようになる。
ref int foo(return ref int i)
{
return i;
}
ref int bar()
{
int i = 42;
return foo(i);
}
void main()
{
auto p = bar();
}
$ dmd -run expiredstackframe.d
expiredstackframe.d(9): Error: returning `foo(i)` escapes a reference to local variable `i`
メモリ安全性のことを考えた場合、ポインタの利用は基本的に避けるべきだが、たとえばCで書かれたライブラリを利用する必要がある場合などで必要となるかもしれない。
そのような場合は return scope
を使うことができる。
int* foo(return scope int* i)
{
return i;
}
int* bar()
{
int i;
return foo(&i);
}
void main()
{
auto p = bar();
}
$ dmd -run expiredstackframe2.d
expiredstackframe2.d(9): Error: returning `foo(& i)` escapes a reference to local variable `i`
競合状態
上述したように、__gshared
属性を持ったグローバル変数へのアクセスはsafe関数内では禁止される。
__gshared int fortyTwo = 42;
void main() @safe
{
auto i = fortyTwo;
}
$ dmd -run race.d
race.d(5): Error: `@safe` function `main` cannot access `__gshared` data `fortyTwo`
これはshared
型を付与すればよい。
shared int fortyTwo = 42;
void main() @safe
{
// これはコンパイルが通る
auto i = fortyTwo;
}
shared型はアトミック操作でなければならない。そのため以下のようなコードはsystem関数であってもコンパイルエラーになる。
shared int fortyTwo = 42;
void main()
{
fortyTwo++;
}
非常に優しいエラーメッセージとともに、しっかりエラーとしてくれる。
$ dmd -run race2.d
race2.d(5): Error: read-modify-write operations are not allowed for `shared` variables
race2.d(5): Use `core.atomic.atomicOp!"+="(fortyTwo, 1)` instead
ちなみに並行・並列処理に強いという触れ込みのGo言語であるが、こういったことを静的に保証はできない。
package main
import (
"fmt"
"sync"
)
var g int = 0
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
// sync/atomic を用いるべきだが、これを静的に検証することはできない
g++
wg.Done()
}()
}
wg.Wait()
fmt.Println(g)
}
結果はランダムになる。
$ go run race.go
96
$ go run race.go
97
$ go run race.go
99
$ go vet race.go # 静的解析ツールでも検出できず
なお、GoはRace Detectorで競合検知を実行時検査することは可能である。
$ go run -race race.go
==================
WARNING: DATA RACE
Read at 0x0000005cc580 by goroutine 8:
main.main.func1()
/home/kubo39/dev/kubo39/race.go:16 +0x32
Previous write at 0x0000005cc580 by goroutine 7:
main.main.func1()
/home/kubo39/dev/kubo39/race.go:16 +0x4a
Goroutine 8 (running) created at:
main.main()
/home/kubo39/dev/kubo39/race.go:14 +0x64
Goroutine 7 (finished) created at:
main.main()
/home/kubo39/dev/kubo39/race.go:14 +0x64
==================
100
Found 1 data race(s)
exit status 66
まとめ
- D言語は実用的な言語を目指して開発され、メモリ安全や低レベルへのアクセスのような相反するような機能を同時に提供している汎用言語
- C++の構文的な失敗をふまえて人間にも機械にも優しい構文設計となっている
- メモリ安全性を実現するために様々な手法を使うことができ、自身の責任でもってそれを使わないことも可能である
おまけ
みなさんは当然ご存じだろう、恋するD言語は、アニメ「恋する小惑星」のもじりである。
これはD言語がかつて開発元Digital MarsよりMars Programming Language(もちろん社名共に由来は火星である)として呼称されていた歴史があることに由来する。
ちなみに本文の内容とタイトルは直接の関連はない。(は?)
参考資料
- https://dlang.org/overview.html
- https://dlang.org/spec/intro.html
- https://dlang.org/articles/safed.html
- https://dlang.org/articles/faq.html
- https://dlang.org/articles/templates-revisited.html
- https://dlang.org/spec/garbage.html
- https://dlang.org/spec/function.html#function-safety
- https://wiki.dlang.org/Language_issues#Default_constructors
- https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%A2%E3%83%AA%E5%AE%89%E5%85%A8%E6%80%A7
- https://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%AC%E3%83%83%E3%83%89%E5%B1%80%E6%89%80%E8%A8%98%E6%86%B6
- https://www.walterbright.com/gonewild.pdf