D言語におけるライフタイム

Table of Contents

D言語は基本型と構造体はスタックに確保できたり,値セマンティクスだったりで,参照を扱うと何かとハマることがあります.そんなとき,日本語の解説記事があれば役に立つかもと思い筆をとりました.本稿の1章はD言語の基礎が学べる名著「Programming in D」から「ライフタイムと基本操作」の章を和訳させてもらいました.残りの2-3章ではD言語のとりあえずONにすると便利な機能(-dip25 -dip1000)を紹介します.

1. ライフタイムと基本操作 (Programming in D翻訳)

原作: "Programming in D: Lifetimes and Fundamental Operations," http://ddili.org/ders/d.en/lifetimes.html

本章はAli Çehreli氏の著作物をコピーレフトライセンス(CC BY-NC-SA3.0)に基づいて翻案しています.本翻案のライセンスも継承元のライセンスに従いCC BY-NC-SA3.0とします.This page is built upon Ali Çehreli's material. This page is also distributed under CC BY-NC-SA3.0 as same as the base material.

(訳注:冒頭は原作の章構成の話なので本稿にはあまり関係ないです)

我々はこの後すぐ構造体 struct を学びます.構造体とはアプリケーションのため独自にプログラマが定義できる型です.構造体は基本型や他の構造体を組み合わせて,プログラムに必要な特化した高レベルな型を定義するために使います.構造体の後は,クラス class について学びます,クラスとはD言語におけるオブジェクト指向プログラミングの基礎となる機能です.

構造体やクラスに入る前に,いくつかの重要な概念について先に話しましょう.その概念とは構造体やクラス,およびそれらの違いを理解するのに役立つものです.

我々はあらゆるデータを「変数」と呼ぶプログラム上の概念として表現します.幾つかの箇所で,我々は構造体やクラスの変数を「オブジェクト」とも呼びました.この章では,どちらの概念も「変数」と呼ぶことにします.この章では基本型,スライス,および連想配列しか扱いませんが,その概念はユーザ定義型にも同様にあてはまります.

1.1. 変数のライフタイム

変数の ライフタイム とは,変数が定義されてから ファイナライズ されるまでの期間です.多くのケースで, 無効 になるタイミングと ファイナライズ されるタイミングは必ずしも同時ではありません.

変数がどのように無効になるかは,名前スコープの章(訳注:未翻訳)から思い出してください.簡単なケースとして,変数が定義されたスコープを抜けるときにその変数は無効になります.

確認のために,以下の例を考えてみましょう.

void speedTest() {
    int speed;               // 変数の定義

    foreach (i; 0 .. 10) {
        speed = 100 + i;     // ... 10回の異なる値をとる
        // ...
    }
} // ← 'speed' はここから無効になる

このコードにおける変数 speed のライフタイムは speedTest() 関数を抜けるときです.ここで変数は100から109までの異なる値をとっています.

変数のライフタイムという観点では,以下のコードは先程の例とはかなり異なります:

void speedTest() {
    foreach (i; 0 .. 10) {
        int speed = 100 + i; // 10個の別々の変数
        // ...
    } // ← 個々の変数のライフタイムはここで終わる
}

このコードでは10個の別々の変数が,それぞれ1つずつ値をとります.ループ中の各イテレーションでは,新しい変数がライフタイムを開始して,順に各イテレーション終了と共にライフタイムを終えます.

1.2. 仮引数のライフタイム

仮引数のライフタイムは,修飾子によって決まります.

  • ref: 仮引数は単なる関数呼び出し時に指定された実引数へのエイリアスです. ref 仮引数は実引数のライフタイムに何の影響も及ぼしません.
  • in: 値型 の仮引数は,関数に入ったときにライフタイムが始まり,抜けるときに終わります. 参照型 なら,ライフタイムは ref のときと同じです.
  • out: ref と同じく仮引数は,関数呼び出し時に指定された実引数への単なるエイリアスです.唯一の違いとして,関数に入ったときに変数は .init 値に自動的にセットされます.
  • lazy: 仮引数のライフタイムは,仮引数が実際に使われるときに始まり,その直後に終わります.

以下の例はここまでの4つの型を使った仮引数を使い,それらのライフタイムをコメントで説明しています.

void main() {
    int main_in;      /* main_in の値は仮引数にコピーされます */

    int main_ref;     /* main_ref は自身が関数に渡されます. */

    int main_out;     /* main_out は自身が関数に渡されます.
                         int.init の値が関数に入ったときにセットされます */

    foo(main_in, main_ref, main_out, aCalculation());
}

void foo(
    in int p_in,       /* p_in のライフタイムは関数に入ると始まり
                        * 関数を抜けるときに終わります. */

    ref int p_ref,     /* p_ref は main_ref のエイリアスです. */

    out int p_out,     /* p_out は of main_out のエイリアスです.
                        * 関数に入る際,値が int.init にセットされます.*/

    lazy int p_lazy) { /* p_lazy のライフタイムは利用した時に始まり
                        * 利用した後に終わります.p_lazy を関数内で使うとき
                        * 値は aCalculation() を毎回呼んで計算されます. */
    // ...
}

int aCalculation() {
    int result;
    // ...
    return result;
}

1.3. 基本操作

どんな型にも,変数のライフタイムを通じて3つの基本操作があります.

  • 初期化: ライフタイムの開始
  • ファイナライズ: ライフタイムの終了
  • 代入: 値の変更の総称

オブジェクトを想定すると,まず始めに初期化されるはずです.特定の型にはファイナライズがあるかもしれません.変数の値はライフタイムを通じて変化するかもしれません.

1.3.1. 初期化

全ての変数は利用前に初期化されるはずです.初期化は2つのステップがあります:

  1. 領域の確保: ここで領域とは変数の値をメモリ上に格納するための場所です.
  2. 構築: 領域上に初期値(または構造体やクラスのメンバの初期値)の設定.

全ての変数はメモリ上の場所を確保して生存しています.コンパイラーが生成するコードのうち幾つかは各変数に領域を確保するためのものです.

以下のような変数を考えてみましょう.

int speed = 123;

これまで値型と参照型の章(訳注:未翻訳)で見てきたように,我々はこの変数がメモリ上のどこかで生存していることをイメージできます.

──┬─────┬─────┬─────┬──
  │     │ 123 │     │
──┴─────┴─────┴─────┴──

変数が格納されるメモリ上の位置をアドレスと呼びます.つまり,変数はアドレス上で生存しています.変数の値が変更されたとき,新たな値が同じ場所に格納されます.

++speed;

新たな値は昔の値と同じ場所にいるはずです.

──┬─────┬─────┬─────┬──
  │     │ 124 │     │
──┴─────┴─────┴─────┴──

構築は値を利用するために不可欠です.構築前の変数は使うことができないので,コンパイラは自動的に構築を実行します.

変数は3つの方法で構築できます:

  1. デフォルト値: プログラマが値を明示的に指定しないとき
  2. コピー: 同じ型の他の変数のコピーとして変数が構築されたとき
  3. 指定された値: プログラマが明示的に値を指定したとき

値が指定されないとき,変数の値はデフォルト値になります,つまり型の .init 値です.

int speed;

この例の speed の値は int.init で, 0 になります.当然,変数はデフォルト値またはその他の値をライフタイム中にとります (immutable でない限り).

File file;

上の定義では,変数 fileFile オブジェクトで,実際のファイルシステム上のファイルにはまだ紐付いていません.実際のファイルと紐付けるよう変更されるまで,使ってはいけません.

変数はときに,他の値をコピーすることで構築されます.

int speed = otherSpeed;

上の speedotherSpeed の値を使って構築されました.

後の章で見るように,この操作はクラス型の変数では異なる意味を持ちます.

auto classVariable = otherClassVariable;

classVariableotherClassVariable のコピーとして生存を開始したのですが,クラスには根本的に違う動作をします: speedotherSpeed は別個の値ですが, classValueotherClassValue は両方とも同じ値へのアクセスを提供します.これが値型と参照型の根本的な違いです.

最後に,変数は互換型(compatible type)の式によって構築できます.

int speed = someCalculation();

上の speedsomeCalculation() の返り値によって構築されます.

1.3.2. ファイナライズ

ファイナライズとは変数に為される終了処理,およびメモリを回収する処理です:

  1. デストラクト: 変数に為されるべき,終了処理です.
  2. 変数のメモリ回収: 変数が生存していたメモリを回収します.

単純な基本型の場合,終了処理はありません.例えば, int 型変数の値は0に戻されたりはしません.このような変数は単にメモリを回収するだけで,他の変数で後ほど再利用されます.

一方で,特定の型はファイナライズ中に特殊な操作を必要とします.例えば, File オブジェクトは出力バッファにためられた文字をディスクに書き込み,ファイルシステムに利用終了を通知する必要があります.これらの処理が File オブジェクトのデストラクトです.

配列の終了処理は,やや高レベルです: 配列のファイナライズ前に,まず各要素がデストラクトされます.もし要素が int のような単純な基本型の場合,特定の終了処理はありません.もし要素型が構造体やクラスのときは,ファイナライズが必要なので,各要素に対して実行されます.

連想配列も配列と同様です.追加で,キー型がファイナライズを必要とするとき,キーもファイナライズされます.

ガベージコレクタ: D言語はガベージコレクタのある言語です.このような言語ではオブジェクトのファイナライズはプログラマによって明示的に行う必要はありません.変数のライフタイムが終了した際,ファイナライズは自動的にガベージコレクタによって管理されます.我々はガベージコレクタと特殊なメモリ管理について後の章でカバーします.

変数には次の2種類のファイナライズがあります.

  1. ライフタイムを終えるとき: ファイナライズは変数の生存が終了するとき発生します.
  2. 将来のいつか:ファイナライズは未来の決定不能な時刻にガベージコレクタによって発生します.

この2種類のどちらによってファイナライズされるかは,変数の型によります.配列,連想配列,クラスは通常ガベージコレクタによって「将来のいつか」にデストラクトされます.

1.3.3. 代入

他の基本操作として,ライフタイム中の変数に対する代入があります.

単純な基本型における代入は変数の値を変更するだけです.先に見たメモリ表現のように, int 変数が 123 の代わり 124 という値を持つといった操作です.しかしながら,より一般的には,代入は2つのステップからなります,それらは必ずしも次の順序では行われません:

  1. 古い値のデストラクト
  2. 新しい値の構築

これらの2つのステップはデストラクトを必要としない単純な基本型においては重要ではありません.デストラクトが必要な型にとっては,代入がこれらの2ステップの組合せであることが重要なので覚えていてください.

2. 最近のライフタイム機能

前章では2017年ごろまでのD言語の基本的なライフタイムの考え方に関する解説を引用しました.ところで前章の触れなかったトピックとして,ライフタイムが終了した後の変数にアクセスする方法(未定義動作を引き起こします)と,それを防ぐ方法について,本章は解説します.

2.1. escaping reference

変数のライフタイムが終了するのは変数が定義したスコープを抜けるタイミングでした.例えば関数スコープのローカル変数を ref でうっかり返すだけで簡単にライフタイムが終了した変数にアクセスできそうです….

ref int fun() {
  int x;    // この x は fun() を抜けるとライフタイム終了して無効.絶対に参照を返してはいけない.
  return x; // Error: returning `x` escapes a reference to local variable `x`
}

しかし,最近のコンパイラはエスケープ解析が優秀なのでコンパイル時に検出してエラーにしてくれます(最近はC++(gcc)なども -Wreturn-local-addr でwarningを出してくれますね).

ところが, コンパイラをだます方法はあります.参照を受け渡すだけの関数 gun を挟むことで,エスケープ解析を打ち切ってしまいます(C++も同様だと思います).

ref int gun(ref int y) {
  return y;
}

ref int fun() {
  int x;         // この x は fun() を抜けるとライフタイム終了して無効
                 // x は gun内部でも有効だが,コンパイラは gun も x の参照を返すとは調べない
  return gun(x); // Error: returning `x` escapes a reference to local variable `x`
}

2.2. return ref 仮引数

そこでDIP25で提案されたのが, return ref 属性です.

https://dlang.org/spec/function.html#return-ref-parameters

規格にはチェックを有効にするには -dip25 スイッチをコンパイラにつける必要があると書いてありますが, このreturn ref自体は2.067からはデフォルトで有効になっています. 後述する @safe との連携にはスイッチが必要です.

先程の例では次のように return ref と修飾するだけで参照した実引数が生存できるスコープを超える場合はエラーにできます.

ref int gun(return ref int y) {
  return y;
}

ref int fun() {
  int x;
  return gun(x); // Error: returning `gun(x)` escapes a reference to local variable `x`
}

この機能は単に,自分より外側にスコープを抜けないようにしているわけではなく,きちんと参照のライフタイムを追います.

ref int gun(return ref int y) {
  return y;
}

void main() {
  int x; // x は fun の外側にいる
  ref int fun() {
    return gun(x); // OK
  }
}

なお, inout ref 仮引数や,テンプレート関数 ref T foo(T)(ref T x) の仮引数などは,暗黙のうちに return ref として推論されます.あと特別な例としてローカル関数も ref 仮引数に対してエスケープ解析が打ち切られることはないようです(規格には書いてない?).

さらに,よくやってしまうメンバの参照返しで発生するライフタイム終了後の参照も return 属性でエラーにできます.これは this に対する return 修飾子(auto foo() const などと同じ)だと考えるとわかりやすいでしょう.

struct S {
    private int x; // この x は S オブジェクトのライフタイム中のみ生存
    ref int get() return // ← ここ
    { return x; }
}

ref int escape() {
    S s;
    return s.get(); // Error: escaping reference to local variable s
}

2.3. @safe-dip25 を使う

注意点として,何処かで return ref 仮引数になっていれば安心というわけでは全くなくて,何処かで return 無しの ref 仮引数を使ってしまうとエスケープ解析が打ち切られてしまいます.そんな悲劇をさけるために, @safe: を全ソースコードに書き, -dip25 スイッチをコンパイラに渡します.

// $ dmd prog.d -ofprog.exe -dip25
@safe:
ref int hun(ref int a) { return a; } // Error: returning `a` escapes a reference to parameter `a`, perhaps annotate with `return`
ref int gun(return ref int a) { return a; } // FINE

https://wandbox.org/permlink/Oj6mwFqz3ZyNsRW3

return のない ref 仮引数は使えなくなっていることがわかります.個人的には @safe がデフォルトであってほしいというか…, @unsafe を作ってそれを明示してほしいですね.

3. DIP1000: Scoped Pointers

ここからはD言語の新機能DIP1000関連の話を解説します.Scoped Pointersとはざっくり言うと, return ref 仮引数のときは参照に限定されていたエスケープ解析をポインタやクラス全体に一般化した提案です.使い方はポインタ仮引数には return score int* a のように return scope として修飾して,ローカル変数のクラスやポインタには scope int* i;scope ClassType c; として定義することで,ライフタイム終了後にアクセスされるとコンパイルエラーで禁止できます.

https://github.com/dlang/DIPs/blob/master/DIPs/DIP1000.md

3.1. 背景

DIP1000で引用されている過去の提案を見てみます.

3.1.1. DIP25: Sealed References (2.067で実装)

https://wiki.dlang.org/DIP25 前章で解説したやつです.

3.1.2. DIP35: Sealed References Amendment (ドラフト)

https://wiki.dlang.org/DIP35 ドラフトなのでとりあえず飛ばします.必要があれば後ほど解説.

3.1.3. DIP36: Rvalue References (否決)

https://wiki.dlang.org/DIP36 否決されたので詳細は飛ばします.ちなみに2018年は左辺値参照しか扱えなかった ref T で,右辺値参照もできるようにしようという提案があり,議論の最終段階にあります.

https://github.com/dlang/DIPs/blob/master/DIPs/DIP1016.md

3.1.4. DIP69: Implement scope for escape proof references (-> DIP90 -> DIP1000)

この提案は一度消えてDIP1000として再提案されたようです."This is a reboot of DIP69."

TODO 2018/12/19: とりあえずDIP1000自体の解説に入る前に,今回はここで終わります.続きは正月に書きます.良いお年を.

Author: Shigeki Karita

Created: 2021-10-12 Tue 15:32

Emacs 27.2 (Org mode 9.4.4)

Validate