LoginSignup
11
11

More than 5 years have passed since last update.

2010からの2年で入った新機能4つの詳細解説

Posted at

お題がコンパイラ過去話ということで、私がdmdへのcontributionを始めたのは2010年末頃からですが、その中でも私が直接関わった大きな機能拡張4つを実装面からちょっと詳しく説明してみたいと思います。

inout関数 (2011/10/02)

次のような関数を考えてみる。

int[] arr = [1,2,3];
int[] dropHead(int[] arr) { return arr[1..$]; }

dropHeadは受け取った配列の先頭を落としたスライスを返す関数だが、int[]型の配列しか受け取れないのでconst(int)[]immutable(int)[]は受け取れない。
1つ目の改良案は

const(int)[] dropHead2(const(int)[] arr) { return arr[1..$]; }

int[], const(int)[], immutable(int)[]は全てconst(int)[]に変換できるので、dropHead2は3種類のどの配列も受け取れるようになる。
が、最初のdropHeadとは次のような違いがある。

dropHead(arr)[0] = 10;
assert(arr == [1,10,3]);

これはdropHeadの戻り値を経由してarrの要素を書き換えている。がdropHead2はconst(int)[]を返すのでたとえint[]を渡してもこのような使い方はできない。

dropHead関数は受け取った配列の要素は何も変更しないので、関数内ではconst(int)[]を取り扱うのと同じ。しかし関数から戻す値は実際に与えられた引数の修飾子を維持したい…
これを表現可能なのがinout。

inout(int)[] dropHead3(inout(int)[] arr) { return arr[1..$]; }

           int[] marr = [1,2,3];
    const(int)[] iarr = [1,2,3];
immutable(int)[] iarr = [1,2,3];
           int[] marr2 = dropHead3(marr);
    const(int)[] carr2 = dropHead3(carr);
immutable(int)[] iarr2 = dropHead3(iarr);

inout型修飾は関数パラメータと戻り値の型にのみ指定できる。これは次のような特徴がある。

  • dropHead3の関数内では、inout(int)const(int)と同じものとして扱われる。 つまりarrが持つ要素の値はdropHead3関数からは変更できない
  • 戻り値の型にあるinout(int)は、dropHead3の呼び出しで与えられた「実際の引数の型」に基づいて解決される。例えばdropHead3(marr)の呼び出しの場合、dropHead3はint[]の引数を取るので、inout(int)intに解決され、これがdropHead3の戻り型にも適用される。結果drpHead3(marr)の型はint[]として扱われ、marr2への代入が成功する。 dropHead3(carr), dropHead3(iarr)では同様に、inout(int)が それぞれconst(int), immutable(int)に解決される。

inoutが付いた型は関数はテンプレートパラメータのように振舞うが、テンプレート関数を直接使うことに対する利点は、inout関数はたった1つの実体しか持たない点である。inout型の解決は常に関数の呼び出し側で行われ、関数本体は与えられた実際の引数の型について関知しないため、このような芸当が可能になっている。


もちろん、inoutはテンプレート関数と組み合わせることも出来る。

inout(T)[] dropHead4(inout(T)[] arr) {
  pramga(msg, "instantiated with ", T.stringof);
  return arr[1..$];
}

dropHead4は任意の型の配列を受け取って先頭要素をdropしたスライスを返す。これを3つの異なる型の引数で呼び出すと、

dropHead4(marr);
drophead4(carr);
dropHead4(iarr);

"instantiated with int"と1回だけ表示される、つまりT==intの実体化が1つだけ行われていることから、mutable/const/immutableの違いがinoutに吸収されていることがわかる。


判りやすいinoutの具体例としては、C標準ライブラリのstrstr関数がある。

char * strstr ( const char * str1, const char * str2 );

Cではstr1の中からstr2が見つかった位置をポインタで返すが、これはstr1が持つconst性を破ってしまっている。

const char * strstr ( const char * str1, const char * str2 );
      char * strstr (       char * str1, const char * str2 );

C++ではstr1の型に応じて関数のオーバーロード版を用意するようになった、関数が2つ必要という欠点がある。

inout(char)* strstr ( inout(char)* str1, const(char)* str2 );

Dで書かれたstrstrはinoutを使うことで、str1に実際に与えられた引数のconst性を保ったまま、1つの関数で機能を実現可能になっている。

経緯:inout関数のオリジナルのコンセプトはSteven Schveighofferが考案(issue 1961)し、これをWalterが実装していたのだが、当初の実装はdropHeadのような配列型の一部をinoutにするような例でうまく動かない問題があった。私が行ったのはこの再実装。

typeof(null)の導入 (2011/11/25)

クラス型、ポインタ型、動的配列型、連想配列型の4つはどれも別のメモリ領域に確保された値を間接参照するいわゆる参照型だが、これらの共通の初期値であるnullはかつてvoid*型を持っており、このことがさまざまな問題を引き起こしていた。

  • nullリテラルをテンプレート関数に渡したとき、IFTIはこれをvoid*で受け取るので、関数内で引数を配列などに代入しようとするコードががうまく動かない
  • void*型を持つ式がnullリテラルのときにコンパイラはこれを特別扱いしなくてはならない
  • nullリテラルを渡された関数のオーバーロード解決がうまく動かない

これを解決するため、現在nullリテラルはtypeof(null)で取れる固有の型を持っている。これは最初に挙げた4種類の参照型に暗黙変換できる型として扱われる。

static assert(is(typeof(null) : Object));       // Object(クラス型)へ暗黙変換可能
static assert(is(typeof(null) : int*));         // ポインタ型へ暗黙変換可能
static assert(is(typeof(null) : long[]));       // 動的配列型へ暗黙変換可能
static assert(is(typeof(null) : int[string]));  // 連想配列型へ暗黙変換可能

現状、dmdではtypeof(null)の実行時の表現はvoid*と同じになっている。

static assert(typeof(null).sizeof == (void*).sizeof);

経緯:このtypeof(null)は私の実装・Pull Request提出が先行した。元はScalaのNothing型から着想を得ており、あちらは「全ての型のサブタイプ」だが、Dでは「すべての参照型のサブタイプ」がtypeof(null)になっている。

ラムダの導入 (2011/12/31)

関数リテラルは昔からあったが、この区別を関数本体のコードから推論し、さらに引数の型もコンテキストから推論できるようにしたもの。

int n;
alias FP = int function(int);
alias DG = int delegate(int);
auto fp = function int(int x) { return x * 2; } // 関数リテラル(無名関数)
auto dg = delegate int(int x) { return x + n; } // 関数リテラル(無名デリゲート)、外側の変数にアクセス可能

// function/delegateの違い(+ 戻り値の型)を推論させる
auto fp2 = (int x) { return x * 2; }    // fpと同等
auto dg2 = (int x) { return x * n; }    // dgと同等

function/delegateの推論は、IFTIとの組み合わせでも動く

void foo(int function(int) fp);
void bar(int delegate(int) dg);
foo((int x) { return x * n; }); // 関数リテラルはfunctionに推論されるので、外側のnにアクセスできずコンパイルエラー
bar((int x) { return x * n; }); // 関数リテラルはdelegateに推論され、コンパイル成功する

さらに引数の型を省略することもできる。

FP fp3 = (x) { return x * 2; }  // fpと同じ
DG dg3 = (x) { return x * n; }  // dgと同じ

dg3はデリゲート型でintの引数を1つ持つ関数リテラルを要求するので、これに基づいて
(x) { return x * n; }の型はdelegateに、引数xの型はintに推論される。
※ 注意点として、引数の型を推論させる場合、どんな型が要求されているかが常に必要となる。
よってfp3dg3をautoで宣言することはできない


ラムダの糖衣構文として=>が使える

FP fp4 = (x) => x * 2;
DG dg4 = (x) => x * n;

=>の左側には引数リストが書ける。
=>の右側には式が1つ書け、その評価結果がラムダの戻り値になる。
引数が1つの場合は引数リストを囲う括弧を省略できるので、こんな書き方もできる。

FP fp4 = x => x * 2;
DG dg4 = x => x * n;

変数宣言で常に型が必要なら何の役に立つのかと思うかもしれないが、このラムダの型推論は関数呼出しの引数上でも動く。つまりこのような関数がある場合

void foo(int[] array, bool function(int) pred) {
  int[] result;
  foreach (e; array) {
    if (pred(e)) result ~= e;
  }
  return result;
}

以下の様な書き方ができる。

// fooの第二引数predの型からラムダの型推論が行われている
auto r = foo([1,2,4,7,9], x => x%1 != 0);
assert(r == [1,7,9]);

IFTIとラムダの引数型推論を組み合わせた場合は、基本的にIFTIによる型パラメータ解決が先に行われる。たとえばこのような呼出しを行った場合

void foo(Args...)(void function(Args...) fp);
foo(x => x * 2);

IFTIはx => x * 2から型パラメータArgsを推論しようとするが、ラムダはパラメータの型を持ってないので推論失敗する。

foo((int x) => x * 2);

これならArgsが(int)に推論されるので、fooの呼び出しは正しくコンパイルされる。


時々ある間違いとして

FP fp_bug = (x) => { return x * 2; };

みたいな書き方をしてしまうことがあるが、これは{ return x * 2; }が引数0個の関数リテラルとして解釈されるので、

FP fp_bug = (int x){ return (){ return x * 2; } };

と同じ意味になり、型が合わなくてコンパイルエラーになる。

経緯:このラムダの型推論自体はTDLPに記載があったもの。私がやったのはこの正式な実装作業。

UFCS(Uniform Function Call Syntax) (2012/3/8)

obj.func(...);のようなコードがあり、objがfuncをメンバ関数として持っていない場合、これを.func(obj, ...);に書き換えて関数を探してくれる機能。

これのうれしいところは

  • objが必ずしもfuncをメンバ関数として持つ必要がなくなる。 何十というヘルパー関数が必要な場合でも、objの型定義はシンプルに保ち、ヘルパー関数はモジュールレベルの関数として用意することで依存性を減らせる。 使用する側はUFCSでヘルパー関数をメソッドのように呼び出せるのでコードの読みやすさを保つことができる。
  • いわゆるメソッドチェーンができる std.algorithm,std.rangeの関数は基本的に第一引数に元のレンジを取って結果のレンジを返すので、
  auto r1 = map!(a=>a*2)(take(iota(1,100), 10));
  auto r2 = iota(1,100).take(10).map!(a=>a*2);

r1とr2が同じ結果になり、しかもr2の方が括弧の入れ子が減るので読みやすいコードになる。

制限として、UFCSは関数の探索先を現状モジュールレベルのみに限定している(先の書き換え後のコードが.funcになっているのがそれ)ので、関数ローカルimportと組み合わせるとうまく動かない、という問題がある。

import std.algorithm;
void main() {
  import std.range;
  auto r1 = [1,2,3].map!(a=>a*2);  // OK
  auto r2 = [1,2,3].take(2);       // NG, take is not found
}

これは今後のバージョンで改善予定。

経緯:UFCSは2007年のconferenceでWalterとAndreiが発表していた(資料)。その後音沙汰がほとんど無かったので実装してPull request出したところ正式採用された次第。


以上4つでした。

来週は現状のコンパイラで動くちょっとdeepな機能の使い方をリストアップしてみたいと思います。コメントにここが知りたいなど書かれていた場合、それも話題に出すかもしれません。

11
11
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
11