Edited at

ジェネリクスの使いどころを考えてみる

More than 3 years have passed since last update.

この記事はTypeScript Advent Calendarの6日目の記事です。

この記事では、主にTypeScriptの型定義ファイルを作成する時、どのようにジェネリクスを使うと良いのかを、jQuery の型定義を元に考えてみます。

とは言ってもあまりTypeScriptの文法面に詳しいわけではないので、むしろ学習メモですが...


動機

TypeScriptでは DefinitelyTypedコミュニティに、様々なjsライブラリの型定義ファイルが集約されており、普段、自分で型定義ファイルを作成する際に上手い書き方がわからない時など、このサイトで他のライブラリを参考にすることがよくあります。

それらのライブラリで頻繁にジェネリクスが使われているの目にするうち、自分自身では今のところほとんどジェネリクスを使うことがないのですが、「やっぱり一度理解しておかないといけないのでは...」という気持ちになってきました。

とりあえず、ジェネリクスを多用している定義ファイルである程度自分がライブラリ自体に慣れているもの、ということでjQueryの定義ファイルを見てみることにしました。


実際に型定義ファイルを見てみる

jQueryの型定義ファイル(jquery.d.ts)は、こちらを使用します。

ざっと見てみると結構いろいろな箇所でジェネリクスが使われているのですが、

とりあえず3つほど取り上げてみます。


$.merge() の型定義

jQueryのAPIドキュメントはこちら。jquery.d.ts ではこちらで定義されています。

まずは易しそうなところから考えてみます。

jquery.d.ts では、$.merge() は以下のように定義されています。


jquery.d.ts#L1201

merge<T>(first: T[], second: T[]): T[];


実際に $.merge() をTypeScript で使用する際、以下のようなコードを書くと

var first = [1, 2, 3, 4];

var second = [1, 2, 3];
var result = $.merge(first, second);

変数 result の型は number[] になります。

次に、second 配列の値を文字列に変更してみます。

var first = [1, 2, 3, 4];

var second = ["1", "2", "3"];
var result = $.merge(first, second);

この場合、result の型は any[] になります。

また、コンパイラのチェックを厳しくしたい場合は

以下のような書き方もできます。

var first = [1, 2, 3, 4];

var second = ["1", "2", "3"];
var result = $.merge<number>(first, second);

このコードは、number[] 型であるべき引数 secondstring[] になっているため、正しく構文エラーになりミスを事前に発見できます。

ジェネリクスを使用することで、js と同じ書き方にすることもでき、

TypeScript 独自の書き方によって、型チェックを厳しくすることもできるようになります。

もし、仮にジェネリクスがTypeScriptの文法に存在しなかった場合、

$.merge() の型定義は以下のように書くしかなさそうです。

merge(first: any[], second: any[]): any[];

この場合、以下のようにキャストして使えば一応は型情報を追加できるのですが、型定義の利用者に特殊な書き方を強制してしまいます。

ジェネリクスをつかったほうが、利用者が書き方を選択できるのでより親切ですね。

var first = [1, 2, 3, 4];

var second = [1, 2, 3];
var result = <number[]>$.merge(first, second);


$.map() の型定義

jQueryのAPIドキュメントはこちら。jquery.d.ts ではこちらで定義されています。

jquery.d.ts では、$.map() は以下のように定義されています。

ジェネリクスの型が T, U の2つになって、難解な気がします。


jquery.d.ts#L1186

map<T, U>(array: T[], callback: (elementOfArray: T, indexInArray: number) => U): U[];


jQuery の $.map() は、実際には以下のような使い方をします。

var arr = [ "a", "b", "c", "d", "e" ];

var result = jQuery.map(arr, function (n, i){
var x = n + n;
if (i==1) return null;
return x;
});

上記のコードでは、result の型は string[] になります。

次に、引数 arr の配列を number[] に変更すると、

var arr = [ 1, 2, 3 ];

var result = jQuery.map(arr, function (n, i){
var x = n + n;
if (i==1) return null;
return x;
});

この場合、 result の型は number[] になります。

また、引数の配列の型とは無関係に、こんな書き方も出来ます。

var arr = [ 1, 2, 3 ];

var result = jQuery.map(arr, function (n, i){
return new Date();
});

この場合、result の型は Date[] になります。

つまり、第二引数のコールバック関数の戻り値の型によって、map() 関数の戻り値が変化します。

もし、ジェネリクスがない場合、単純に全て any に置き換えると以下のような形になります。

これではほとんど構文チェックが仕事をしないため、型定義書いている意味あるのか、とちょっと疑心暗鬼になりそうです。

map(array: any[], callback: (elementOfArray: any, indexInArray: number) => any): any[];

とりあえず、$.merge()$.map() に共通して言えるのは、引数の型によって戻り値が変化する関数 、ということのようです。

配列操作を行うメソッドは、大半がこの条件に当てはまりそうな気がしました。


$.Deferred() の型定義

$.Deferred() は不勉強ながら概念をよく知らなかったので、とりあえずこちらで前提となる知識を得ました。

http://qiita.com/yuku_t/items/1b8ce6bba133a7eaeb23

http://www.html5rocks.com/ja/tutorials/async/deferred/

jQueryのAPIドキュメントはこちら。jquery.d.ts ではこちらで定義されています。

... これはかなり難解な気がするのですが、一応取り組んでみます。


jquery.d.ts#L427-L517

interface JQueryDeferred<T> extends JQueryPromise<T> {

/**
* Add handlers to be called when the Deferred object is either resolved or rejected.
*
* @param alwaysCallbacks1 A function, or array of functions, that is called when the Deferred is resolved or rejected.
* @param alwaysCallbacks2 Optional additional functions, or arrays of functions, that are called when the Deferred is resolved or rejected.
*/

always(alwaysCallbacks1?: JQueryPromiseCallback<T>, ...alwaysCallbacks2: JQueryPromiseCallback<T>[]): JQueryDeferred<T>;
always(alwaysCallbacks1?: JQueryPromiseCallback<T>[], ...alwaysCallbacks2: JQueryPromiseCallback<T>[]): JQueryDeferred<T>;
always(alwaysCallbacks1?: JQueryPromiseCallback<T>, ...alwaysCallbacks2: any[]): JQueryDeferred<T>;
always(alwaysCallbacks1?: JQueryPromiseCallback<T>[], ...alwaysCallbacks2: any[]): JQueryDeferred<T>;

....
}


よくわかりません。

とりあえず、手がかりを探すために型定義のテストファイルを見てみます。


jquery-tests.ts#L3267-L3275

var dfd = $.Deferred<string>();

dfd
.done([fn1, fn2], fn3, [fn2, fn1])
.done(function (n) {
$("p").append(n + " we're done.");
});
$("button").bind("click", function () {
dfd.resolve("and");
});

最初の行で、型の指定付きで $.Deferred() を呼び出しています。

いくつか .done() を設定した後で、最終的にボタンがクリックされると .resolve() が実行される、というコードです。

ここで、最後の行で使われている resolve() の定義を見てみます。

resolve(value?: T, ...args: any[]): JQueryDeferred<T>;

ジェネリクス型 T は、jQueryDeferred インターフェースの定義を引き継いでいます。

var dfd = $.Deferred<string>(); を呼び出す際に型を指定することで、JQueryDeferred インターフェース内のメソッドが引数として受ける型を制限することが出来るようです。

例えば、上記のテストコードについて、 resolve() の引数をを以下のように変更すると、エラーになります。

var dfd = $.Deferred<string>();

...

$("button").bind("click", function () {
dfd.resolve(12345); // NG
});

もし、var dfd = $.Deferred(); のように型を指定しない場合、このケースでは型推論をするための手がかりが無いために var dfd = $.Deferred<any>(); と同じ扱いになり、 JQueryDeferred 内のメソッドは全ての引数型を許容してしまいます。

$("button").bind("click", function () {

dfd.resolve(123456); // OK
dfd.resolve("ABCDEF"); // OK
dfd.resolve(new Date()); // OK
});

まあ、個人的にはこれでも特に問題ない場合も多い気が...(と言うと怒られそうですが)。

前述の $.merge() と同様に、TypeScript 独自の書き方にはなるが型チェックを厳しくすることもできるように作っている、ということのようです。

一応、 扱う型が不定だが、インスタンスの生成時に型を決定できるクラスやインターフェース にはジェネリクスが使えそうです。


まとめ

いままでジェネリクスは、割と高度な機能というイメージを持っていたのですが、単純に無いと困るケースが存在するな、と意外な印象でした。

型に対して柔軟な、よりJS的なライブラリほど、こういった機能がないと上手くTypeScript側で互換性を持たせられないのかもしれないですね。

※ 余談ですが、普段僕が使用している PHPStorm 8 では、$.map()$.Deferred() の構文チェックが正常に動作しないようです。基本的にはとても良いエディタなのですが... TypeScript関連の精度が向上する日を待ち望んでいます。