はじめに
7/15、D言語作者であるWalter Bright氏がD言語の公式ブログに機能強化の方向性としてOwnershipとBorrowingに関する記事を投稿しました。
Rustで注目を集めたBorrowingの考え方ですが、反応も大きいようなので一度現状の言語機能や今後の強化方針、ブログで言っていることについてまとめておきたいと思います。
なお、公式の記事については下記リンクを参照してください。
- 公式ブログ : https://dlang.org/blog/2019/07/15/ownership-and-borrowing-in-d/
- 日本語訳 : https://blog.kotet.jp/2019/07/ownership-and-borrowing-in-d/
- Thank you @kotet !
なお、Lifetimeの話も切っても切れないところがあるので前段で軽くまとめたいと思います。
※以下、参考元にならい「Lifetime」は「生存期間」や「ライフタイム」、「Ownership」は「所有権」、「Borrowing」は「借用」と翻訳します。
TL;DR
- D言語にはすでに「ライフタイム」がある
- 所有権や借用に相当するルールは一部組み込まれておりチェックもできる
- チェックするには
-preview=dip1000
をつけてチェックするスコープを@safe
で修飾すれば良い
- チェックするには
- 標準ライブラリは、2019年7月のリリース(2.087.0)でDIP1000に完全対応した
- Rustとは異なりパラメータ制御できる仕組みはない
- ライフタイムの計算ルールはDIP1000として文書化されている(Algebra of Lifetimes)
- 所有権や借用に相当するルールは一部組み込まれておりチェックもできる
- まだ所有権や借用に関しては設計中で機能的に完全ではない
- 基本的なユースケースはある程度チェックできるが、関数呼び出し周りで設計残がある
- ブログではこの関数周りの設計残に対して、所有権と借用の考え方を適用しようと言っている
-
@live
属性を使うことで適用される範囲を明示して、段階的に移行できるようにしようとしている - これを実現するための提案は以下
前置き
無効な参照について
D言語にはガベージコレクション(GC)があります。
すべてをGCで処理するプログラミング言語は本質的にメモリセーフと言え、「無効な参照」というのはそう簡単には発生させることができません。
(手動で開放している場合を除いて)
一方でDはCやC++の流れを汲む言語でもあるため、スタック上の変数を操作しアドレスを取得することができます。
このスタック変数は宣言されたスコープを抜けると破棄されるため、破棄されたアドレスを参照するポインタというものが作れます。
これは「無効な参照(ダングリングポインタ)」と呼ばれます。
たとえば(強引ですが)以下のようにして無効な参照を発生させることができます。
import std.stdio;
void main()
{
int* n;
{
int m = 100;
n = &m; // mはここのスコープでしか使用できないはずだが参照を外に持ち出している
}
writeln(*n); // スコープ外で使うのは無効な領域を指しており本来危険
}
ちなみにこれはオンラインエディタで実行する場合、何の問題もなく100と表示されてしまいます。
(スタックを一部巻き戻す可能性こそあれど、上書きも何もないので大した問題にはつながりません)
しかし、実際には関数を挟んだりする実装が想定され、表面的には危険かどうかわかりづらくなることが容易に想像できます。
そのような経緯から、ダングリングポインタが発生するようなコードはエラーになるべきである、とされています。
ライフタイムについて
混同されがち?ですが、「Lifetime」と「Ownership」「Borrowing」は強い結びつきがあるものの異なる概念です。
ライフタイムは先の「無効な参照」問題に対して、変数の「生存期間」を定義して解決しようとするものです。
一部問題に対して効率的に対処できますが、これだけですべてのケースに対応できるわけではなく、それが後の所有権と借用につながります。
一般的なライフタイムによる解決
上記のプログラムに対しては「変数 n
の生存期間」と「変数 m
の生存期間」を考えることで少し異なる視点を与えることができます。
具体的な表現をすると
-
n
のほうが先に宣言されている -
m
はそれより狭いスコープで宣言されている - この2つを比べると、
m
はn
より生存期間が短い
ということが言えます。
これを少し一般化して、
「『生存期間が長いもの』に『生存期間が短いもの』を代入させない」
というルールを設けることで上記のプログラムをコンパイルエラーにしよう、というのがライフタイムの考え方です。
※ 少し話はそれますが、Cのように先頭宣言した変数を使いまわすことでスタックを節約する、という考え方とかなり逆を行く思想ではあります。最適化でどうとでもなる、というのが大勢を占めているのでしょうか
D言語の現状
今までの対応
D言語は元々C++との互換性も考慮されており、移植性の問題などから先の問題に対してもあまり対処をしてきませんでした。
しかし何年か前から自明に問題のあるケースに対して、特別ルールをいくつか設けることで安全性を高めていこうとしています。
たとえば、最も古く単純なルールの1つとして「スタック変数の参照をreturnしてはいけない」というものがあります。
struct S { int x; }
int* test()
{
S s;
return &s.x;
}
onlineapp.d(21): Error: returning & s.x escapes a reference to local variable s
これは当たり前といえば当たり前なのですが、ライフタイムなどを考慮していないので、このケースのみに対する安全ルールです。
単純なルールでも「ちょっとやらかした」系の問題を検知できる強みがあり、コスパの良さから導入当時はそれなりに効果が認められていました。
D言語のライフタイム
特殊ルールを追加するなどして数年、ルールを検討していく過程で2016年に提案されたのが比較的汎用性の高い「DIP1000」という提案です。
元のタイトルは「Scoped Pointer」というものですが、その内容は「D言語流のライフタイム」と言えるものです。
重要なのは、宣言された変数や配列の要素、new式、ポインタ演算等による生存期間とその変化を定義し、代数的に計算可能にする取り組みで「Algebra of Lifetimes」と呼ばれる部分です。
実際にDIP1000で無効な参照をエラーにする
DIP1000はすでに実装された提案で、公式コンパイラであれば問題なく利用できます。
これを使うと先ほどの無効な参照の例をコンパイルエラーにすることができます。
実際、現状のD言語は既にフラグを指定することでエラーとすることが可能です。
チェックするには、コンパイル時のフラグで -preview=dip1000
を指定し、チェック対象の関数に @safe
という属性をつけます。
先の無効な参照の例に対し、実際に付与してオンラインエディタで実行すると以下のようなメッセージが表示されます。
実行例:https://run.dlang.io/is/CH0g9b
onlineapp.d(10): Error: address of variable m assigned to n with longer lifetime
エラーメッセージにある通り、「ライフタイムが長い n
に変数 m
のアドレスを代入しようとしています」というメッセージが表示されます。
というわけで、D言語にライフタイムはあると言えます。
(コンパイラがライフタイムだと言っているので間違いありません)
なお、これは非常に単純な例であり、実際にはここからさらに発展したチェックも行うことができます。
※ ちょっと前に話題になった「V言語の魔法のメモリ管理」の半分くらいは達成しているかもしれません。
DIP1000でチェックできない例
というわけで、ライフタイムを定義して使えるようにしたこのDIP1000ですが、その実まだ先送りした設計残があります。
(DIPのリポジトリで元の1000がacceptやarchiveではなくotherに放り込んであるのは残を踏まえてDIP1021に引き継いでいるからでしょうか)
ここで一番問題視されているのは関数呼び出しの部分です。
先の例から一気に複雑になりますが、DIP提出者のWalterいわく、これが最も簡単な例の1つです。
struct S {
byte* ptr;
ref byte get() { return *ptr; } // 構造体の一部をrefで返す
}
void foo(ref S t, ref byte b) {
free(t.ptr); // 構造体tのptrフィールドをfreeする
b = 4; // あとの呼び出しを踏まえて見ると、freeされたフィールドにアクセスしている!!!
}
void test() {
S s;
s.ptr = cast(byte*)malloc(1); //適当にメモリを確保しておく
foo(s, s.get()); // ここで構造体そのものと、その一部を参照で渡す
}
はい。結構複雑になりましたが、戦っているのは関数fooの中、「解放後のフィールドに対するアクセス保護違反」というやつです。
愚直に考えてしまうと、構造体をrefで渡すのは良いとして、
- もう一方のbyteの参照が構造体の一部かどうかどうやって知るのか?
-
free
っていう関数を通すとアクセス不可になるというのはどうやってわかるのか?
という話が課題となるでしょうか。
何が言いたかったかというと、ここまでやってやっと所有権と借用を入れる気になった、ということです。
さて、やっとここからが本題です。
これをやっつけるために所有権と借用の考え方を適用していきます。
公式ブログで言っていること
というわけで、公式ブログでは上記を解決するために「所有権」および「借用」の考え方を取り入れようと言っています。
すでにライフタイムは手元にあり、解析する基盤も整ってきているそうです。
その仕組みを活用して所有権と借用を実現しようという話ですね。
所有権
一般に「所有権」とは「そのポインタを誰が解放すべきか」を示すものです。
少し語弊がありますが、最後に代入されたポインタが解放を担い、元のポインタには触れられなくすることで「単一の責任者」を保とうとするのが「ムーブセマンティクス」であり「所有権」の単純な構成方法です。
これは代入のデータフロー解析によって実現可能とされていますが、素朴に実現すると以下のコードで問題になります。
void g(T*); // ポインタを使って処理する関数を用意
void h() {
T* p = f(); // ここでポインタを確保し
g(p); // gの引数にムーブしたので所有権が移ります。hは以降pが使えません。
g(p); // これはコンパイルエラーになります
}
借用
ムーブセマンティクスだけではちょっと制限が厳しいので、制約を緩めるために適用するのが借用です。
データフローに影響がない範囲でそれをちょっと借りて使いたい、というのは認められるべきで、実際それができないと手続き型のプログラムは構造化がかなり難しくなります。
幸いDには scope
ストレージクラスがあり、関数の引数に scope
とつけるのがこの借用にちょうど当てはまります。
void g(scope T*); // Dには、ポインタが外部に漏れ出ないことを明示するscopeストレージクラスがあります
T* f();
T* p = f(); // ポインタを取得します
g(p); // g()はpを借用します
g(p); // g()から返ってきた後はまたpを使用できます
D言語でやろうとする所有権と借用
所有権と借用の考え方は他のプログラミング言語と大差ありません。
1つ大きく違うとすれば、そのセマンティクスが有効になる範囲です。
D言語は、関数がガベージコレクタを使わないことを明示する@nogc
、危険なポインタ演算等を禁止する@safe
、例外を発生させないことを明示するnothrow
など、多くの属性がありそれぞれ関数の実装チェックを切り替えることができます。
今回も同様に、上記の所有権と借用がチェックされることを明示する属性として @live
属性を追加しようとしています。
こうすることで、より安全なコードを既存のプロジェクトに部分的に組み込むことができるようになります。
※ 具体的には関数毎に付与できるようにしよう、ということです。一気にセマンティクスを変えてしまおうという話ではありません。
上記を踏まえると、今後想定されるのは以下のような記述になります。
void free(void*); // メモリの解放。scopeがついていないのでムーブされます
void print(scope byte* ptr) @live // scopeがついているので借用になります。liveは不要かもしれません
{
writeln(*ptr); // 実際使えるかどうかはわかりませんが多分使えるでしょう
}
ref byte make() // 適当にメモリを確保して返すだけです
{
return cast(byte*)malloc(1);
}
void test() @live // live属性によって所有権と借用がチェックされます
{
auto ptr = make();
print(ptr); // 借用なので問題ありません
free(ptr); // ムーブセマンティクスにより所有権が移ります
free(ptr); // 所有権が移った後なのでコンパイルエラーになります
}
void test2() // live属性がないので既存のチェックになります。同じプログラム中に混在可能です。
{
auto ptr = make();
print(ptr);
free(ptr);
free(ptr);
}
もう1例、DIP1021で問題となっていたケースがどうなるか見てみます。
struct S {
byte* ptr;
ref byte get() { return *ptr; }
}
void foo(ref S t, ref byte b) @live { // 安全のためlive属性をつけます
free(t.ptr); // freeするには所有権が必要なので、引数はscope修飾できません
b = 4; // 所有権のある参照としているので、これも問題ありません
}
void test() @live { // ここにもlive属性をつけます
S s;
s.ptr = cast(byte*)malloc(1);
foo(s, s.get()); // 第1引数を評価時点でmoveされます。第2引数を評価するときは既にsにアクセスできません
}
というわけで、所有権と借用によって問題の例が無事にコンパイルエラーになりました。(これからなります)
対応するために必要な作業
慣れた人ならnogcなどの属性を付ける手順と変わりません。
具体的には
- 安全にしたい関数にlive属性をつける
- 引数にscopeがついていないところが大体動かなくなる
- 借用らしき引数にscopeをつけていく
- 動く範囲が必要十分になるまで1からやり直す
といった手順になるかと思います。
まとめ
Rustなどに見られる借用の考え方ですが、D言語では属性を設けることでプログラムの一部に適用できるようにしようとしています。
関数に対する属性や既存のストレージクラスを活用することで無理なく実現する案を提示し、段階的に組み込めるようにしよう、というのが後付けならではのポイントかと思います。
この仕様であれば、いざリリースされてもそこまでの混乱はないでしょう。
そしてすでにライフタイムはあるのでみんな使おう!!!!!