ジェネリクスがよく分からないと言われたので、噛み砕いて書いていこうと思います。
なお、ジェネリクスが何なのかの説明に終始し、実践的な活用方法にはあまり触れていません。
主に初学者向けですかね。悪しからず。
ジェネリクスとは
ジェネリックプログラミングの特徴は、型を抽象化してコードの再利用性を向上させつつ、静的型付け言語の持つ型安全性を維持できることである。
型安全性を保ちつつ、再利用性を向上させることができるようですね。
型だけじゃなくて説明も抽象的ですね。
言い換えると、型によって一定の制約を設けて安全性は担保するけど、使い回しもしやすくする、ということですね。
ジェネリクスとジェネリックは表記揺れです。
TypeScript Deep Dive日本語版でも、タイトルが「ジェネリック型」ですが次の見出しは「ジェネリクス(Generics)」となっています。そんなもんです。細かいことは気にせずいきましょう。
公式に倣って、「与えられた値がなんであれそのまま返す」、echoコマンドのような関数をもとに考えてみます。
ジェネリクスを使わないと
まずは、ジェネリクスのことを一旦忘れて、関数を文字列と数字に限定して考えてみます。
function identityForStr(val: string): string {
return val;
}
function identityForNum(val: number): number {
return val;
}
出来ましたが、似たような関数名に同じ処理です。
できれば、文字列でも数字でも、同じidentity
関数を使いまわせるようにしたいですね。
function identity(val: string | number): string | number {
return val;
}
これで文字列でも数字でもどちらでもidentity
関数を利用できるようになりましたが、二つ問題があります。
- 「与えられた値がなんであれ」ということは、
boolean
やarray
なども対応できる必要がある - 受け取った値の型が定まっていない
function identity(val: string | number): string | number {
return val;
}
// 文字列と数字以外は入れられない
// const boolOutput = identity(true);
const strOutput = identity('hoge');
// 文字列のはずだが、string | numberで定まっていない
// console.log(strOutput.length);
なんでも受け取れるようにするために、any
やunknown
を設定することが考えられますが、
これでは二つ目の問題が解決できません。
function identity(val: unknown): unknown {
return val;
}
// 文字列と数字以外も入れられるようになったが、、、
const boolOutput = identity(true);
const strOutput = identity('hoge');
// unknownで文字列として扱えないまま
// console.log(strOutput.length);
ジェネリクスを使わない場合、このように特定の型を指定する必要があり、その制約から逃れられません。
ジェネリクスを使う
要件を言い換えると、関数identity
は、下記のようになりますね。
- 定義時点ではどんなものを受け取るか定まっていない
- 利用された(引数が与えられた)時点で型が定まる
この時利用できるのがジェネリクスです。
function identity<Type>(val: Type): Type {
return val;
}
// 文字列と数字以外も入れられる
const boolOutput = identity<boolean>(true);
const strOutput = identity<string>('hoge');
// 文字列として扱える
console.log(strOutput.length);
さて、この突然出てきたType
とはどんな型なのか、それは利用されるまでわかりません。
関数名の後に付与された、<Type>
がジェネリクス型パラメータ(または単に「型パラメータ」)で、
この関数が「利用時まで型が定めないけど、型パラメータが定まったら引数と戻り値はその型ね」という制約を意味しています。
この利用時に柔軟に型が適用される働きが、型安全性と再利用性を両立させます。
つまり、
function identity<Type>(val: Type): Type
は、
利用時に呼び出し側で、
identity<string>
と記述すれば、function identity(val: string): string
と解釈でき、
identity<number>
と記述すれば、function identity(val: number): number
と解釈できます。
このようにジェネリクスは、型の制約を抽象的にし、様々な可能性を持たせることで再利用性を向上させることができます。
型パラメータ<>
内の名称は任意の文字列なので、<Type>
でも<T>
でも<hoge>
でも有効ですが、
何かしらの意図を示す、且つ、型であることを示すことが一般的で、慣習的に頭文字の大文字が利用されます。
例)
T:Typeの略
R:Returnの略
E:Elementの略
型アサーションでも<型>
という書き方をしますが、
これはキャストを意味し、ジェネリクスとは別物です。
const str: unknown = "this is a string";
const len: number = (<string>someValue).length; // これは型アサーション
型推論
ほんの少し話が逸れますが、ジェネリクスと型推論について書きます。
呼び出し側で指定した型で引数と戻り値に制約を付与すると説明しましたが、
上述の関数の場合、その指定を書かなくても成立します。
function identity<T>(val: T): T {
return val;
}
const strOutput1: string = identity<string>('hoge');
const strOutput2 = identity<string>('hoge');
const strOutput3 = identity('hoge');
上記のstrOutput1
、strOutput2
、strOutput3
は、
いずれも有効で、いずれも同じ結果になります。
strOutput1
が省略をせず全てを記述している例です。
宣言したstrOutput1
が型注釈でstring
であることを定義し、identity
の型パラメータにもstring
を設定しているため、戻り値がstring
になる、という形です。
strOutput2
は、strOutput2
に対する型注釈を省略している例です。
型パラメータから戻り値がstring
で確定しているため型注釈がなくともstring
であることが定まります。
strOutput3
は、型注釈の省略に加え、ジェネリクスの型パラメータを省略しています。
制約なくなってしまうのでは?となりますが、引数のhoge
という値から型推論が働いてstring
が適用されます。
「型パラメータの指定なかったけど、引数がstring
ってことはT
はstring
だね」と勝手に解釈してくれます。便利ですね。
型パラメータの適用範囲
先ほどの例では、型パラメータを引数と戻り値、どちらにも適用していましたが、適用する範囲は任意に設定できます。
いくつかサンプルを用意しました。
// 引数と戻り値に適用
function identity<T>(val: T): T {
return val;
}
// 引数に適用
function getLength<T>(ary: T[]): number {
return ary.length;
}
// 第二引数と戻り値に適用
function createArray<T>(length: number, defaultValue: T): T[] {
const array: T[] = [];
for (let i = 0; i < length; i++) array.push(defaultValue);
return array;
}
型パラメータの複数指定
なお、型パラメータは複数持つことができます。
type User = {
name: string;
country: string;
city: string;
}
type Address = {
country: string;
city: string;
}
type Person = {
name: string;
}
function merge<T1, T2>(obj1: T1, obj2: T2): T1 & T2 {
return { ...obj1, ...obj2 }
}
const person: Person = { name: 'hoge' };
const address: Address = { country: 'Japan', city: 'Yokohama' };
const user: User = merge(person, address);
普段利用しているライブラリを覗いてみると2つ、3つと利用していることがザラです。
ちょっぴり実務寄り?な簡単なサンプル
単純に値を返すだけでは利用イメージが沸かないと思うので、ほんの少し実践的な簡単なサンプルを考えてみます。
要件は下記のとおりとします。
- 二つの配列から共通する要素だけ探し出して新しい配列を作る
- 要素の型は問わず、何の配列でも使えるようにする
- 二つの配列は同じ型の配列であることを担保する
まずは数字だけで考えてみます。
function fetchCommonElements(ary1: number[], ary2: number[]): number[] {
return ary1.filter(item => ary2.includes(item));
}
const numAry1 = [1, 2, 3, 4, 5];
const numAry2 = [3, 5, 6];
const numRes = fetchCommonElements(numAry1, numAry2);
console.log(numRes); // [3, 5]
const strAry1 = ['a', 'b', 'c', 'd', 'e'];
const strAry2 = ['a', 'c', 'f'];
// 数字の配列ではないので渡せない
// const strRes = fetchCommonElements(strAry1, strAry2);
引数がどちらもnumber[]
で制限されているため、数字の配列のみを受け付ける関数ができました。
では、ここから要素の型を問わない形にします。
といっても型パラメータを利用するだけです。簡単ですね。
function fetchCommonElements<T>(ary1: T[], ary2: T[]): T[] {
return ary1.filter(item => ary2.includes(item));
}
const numAry1 = [1, 2, 3, 4, 5];
const numAry2 = [3, 5, 6];
const numRes = fetchCommonElements(numAry1, numAry2);
console.log(numRes); // [3, 5]
const strAry1 = ['a', 'b', 'c', 'd', 'e'];
const strAry2 = ['a', 'c', 'f'];
const strRes = fetchCommonElements(strAry1, strAry2);
console.log(strRes); // ['a', 'c']
// 第一引数と第二引数の型は同じでなければならないため、
// 文字列の配列と数字の配列では型不一致で渡せない
// const res = fetchCommonElements(strAry1, numAry1);
色々なジェネリクス
function
を用いた関数で説明してきましたが、
他にも色々利用できるのでザッと書き方だけまとめておきます。
function identity<T>(val: T): T {
return val;
}
const identity = <T>(val: T): T => {
return val;
}
class Queue<T> {
private data: T[] = [];
add(item: T): void {
this.data.push(item);
}
getAll(): T[] {
return this.data;
}
}
interface Builder<T> {
build(args?: unknown): T[];
}
type Result<T, E> = {
success: boolean;
data?: T;
error?: E;
};
最後に
上手いこと活用できると実際再利用性が高まり活躍するシーンが多いと感じています。
逆にここジェネリクスの意味あるのかな、というケースも
ジェネリクスがどんなものなのか、その触りだけでも伝われば幸いです。
別記事でもう少し踏み込んだ内容書こうかなー。