📌

ジェネリクスのメリットって、何?

2022/01/25に公開約5,600字

こんにちは。株式会社プラハCEO松原です。

だいぶ前ですが、弊社に入社したメンバーから「ジェネリクスのメリットがいまいちピンとこない」と聞かれたので思ったことをまとめてみます

ジェネリクスの使い方

ひとまずTypeScriptでジェネリクスの使い方をおさらいしてみます

function hoge1(input1: string) {
  console.log(input1)
  return input1
}

function hoge2(input2: number) {
  console.log(input2)
  return input2
}

hoge1("string only")
hoge2(100000)

随分似たことをやっている関数が2つあったとします。まとめたくてウズウズしますね。まとめてみましょう。

function hogeGeneric<T>(inputGeneric: T): T {
  console.log(inputGeneric)
  return inputGeneric
}

「これがジェネリクスだよ!」

・・・と言われても正直いまいち良さが伝わらないですよね。以下のような書き方でも同じことが実現できそうです。

function hogeBoth(inputBoth: string | number) {
  console.log(inputBoth)
  return inputBoth
}

hogeBothとhogeGenericは何が違うのか?

型ガードが要らない1

hogeBothした後の値を使う必要が生じたとします

let a: string | number = hogeBoth('string only')
let b: string | number = hogeBoth(10000)

a.length // もしnumber型だったらlengthメソッドが存在しないのでコンパイルエラー
b + 1 // もしstring型だったら、stringにnumberは足せないのでコンパイルエラー

hogeBothは stringかnumberのどちらか になることは確定していますが、どちらが返ってくるのか分からないので、.lengthや+1など、どちらか片方の型にしか存在しない機能が使えません。

どちらの型なのか確定させるためには型ガードが必要です。

if (typeof a === 'string') {
  a.length // 間違いなくstring型なのでエラーは起きない
}

if (typeof b === 'number') {
  b + 1 // 間違いなくnumber型なのでエラーは起きない
}

hogeGenericsならこういう型ガードが不要です。

let c = hogeGeneric<string>('string only')
let d = hogeGeneric<number>(100000)

c.length // string型なのが分かっているので問題なし
d + 1 // number型なのが分かっているので問題なし

hogeGenericsのコードを見てみると

function hogeGeneric<T>(inputGeneric: T): T {

T型の引数を受け取ったら返り値もT型になることが保証されています。さきほどhogeBothは「stringかnumber どちらかが返る 」なのに対してhogeGenericsは「 stringが来たらstringを返す、numberが来たらnumberを返す 」と、より具体的な型の制御が可能です

なので型ガードが不要になり、純粋に記述量が減るのがひとつ目の嬉しいポイント。

実行時エラーが起きづらい

プログラミングにおいて「実行してみないと起きないエラー(実行時エラー)」はできるだけ減らしたいものです。

「やってみないと分からないだろ!!(ドンッッッ!!!)」は漫画だと前向きな良いフレーズですが、プログラミングにおいては絶対言っちゃダメなやつです。やってみてエラーに気づけたらまだマシな方で、一見動いてるように見えて不整合データを延々と生み出し続けている可能性もあるわけですから。

「やってみないと分からない(ドンッッッ!!!)エラー」をどれだけ「やってみる前から分かってるエラー」に変えていくかが重要で、 「やってみる前から分かってるエラー」のひとつがコンパイラが指摘してくれるコンパイルエラーです。コンパイルエラーを無くさないとそもそもプログラムを実行できないので、不具合を抱えたプログラムが不整合データを延々と作り出すリスクもありません。

ジェネリクスを使うと実行時エラーをコンパイルエラーに変えて、事前にプログラマのミスを検知しやすくなります。

例えば、あまり型周りのことを学んでいないエンジニアがhogeBothの問題(stringなのかnumberなのか分からないからコンパイルエラーが発生する)を解決するために、こんなコードを書いたとします:

let a = hogeBoth(10000) as number // 無理矢理number型にする
a + 1 // 無理矢理number型だとコンパイラに信じ込ませているから、コンパイルエラーが起きない

as(型アサーション)を使って強制的にhogeBothの結果をnumber型だとコンパイラに信じ込ませています。こうすれば当初のhogeBothの問題(stringなのかnumberなのか分からないからコンパイルエラーが発生する)は解決するのですが、新たな問題を生み出しています。プログラマのミスをコンパイラが指摘してくれなくなることです。

型アサーションは、コンパイラが「それ違うよ!何かおかしいよ!」と教えてくれているのに「コンパイラ、俺はお前より賢いんだ。いいから黙って俺のいうことを聞け」と言いくるめているような状況です。

なのでこういうミスが起き得る:

let a = hogeBoth(10000) as string // 黙れコンパイラ、10000はstringだ!
a.includes('hoge') // コンパイラ「あっ、はい・・・(多分違うと思うけど、黙っとこ)」

で、いざ開発が終了して、堂々とファンファーレが鳴り響く中サービスをグランドリリースして100万人のユーザーが殺到したタイミングで実行時エラーが発生します。number型にはincludesなんて名前の関数はありませんから。

a.includes is not a function 

そして上司、顧客、同僚に怒られます。
「なんでこんなミスを事前に防げなかったんだ!」
「やってみないと分からないだろ!(ドンッッッ!!!)」

やってみなくても分かるんですよね、ジェネリクスなら。
先ほどと同じように数字の10000を渡したhogeGenericsの返り値に対して型アサーションを実行してみましょう

let d = hogeGeneric<number>(10000) as string // 「流石にnumberをstringって言い切るのはおかしくね?」と、コンパイルエラーが起きます

コンパイルエラーが起きて、numberなのにstringとして扱ってしまった先程のミスを未然に防げました。

先程のhogeBothと比較すると

  • hogeBothの返り値はstring or number型
  • hogeGenericsの返り値はnumber型

hogeBothでは「もしかしたらnumberかもしれないしstringかもしれないけど、俺はstringだと思うよ」ぐらいのニュアンスだったので「まぁ、それなら・・」とコンパイラを黙らせることができたのですが、流石のコンパイラもnumberをstringだと言われたら「明らかに違うじゃん」と気づいてくれるので、コンパイルエラーで教えてくれるんですね。

おかげでグランドリリースの瞬間にエラーが発覚するのではなく、開発中の今このタイミングでエラーに気づけました。やったね!

なのでジェネリクスの嬉しいポイント二つ目として「実行時例外が起きづらい(コンパイルエラーの時点で気づける)」が挙げられます

ちなみに実行時例外が起きないとは言い切れないのは、こんなふうにコンパイラを騙せるからです。

let d = hogeGeneric<number>(100000) as any as string

ワイ「なぁコンパイラよ」
コ「は、はい」
ワイ「numberはanyだよな?anyは『何にでもなれる可能性がある』だもんな?numberだってanyのうちの1つだよなぁ?」
コ「は、はい」
ワイ「anyは『何にでもなり得る』からstringにもなれるよなぁ?」
コ「は・・・はい」
ワイ「じゃあnumberはstringだよなぁ?(ニチャァ)」
コ「はい・・・」

みたいなイメージ。any怖い!

型ガードが要らない2

「型ガードが要らない1」の例が単純すぎたかもと思ったので、もう1つ例を追加してみます。stringの配列、あるいはnumberの配列に対して要素を追加するような処理を共通化したいとします:

function hogeBoth(element: string | number, list: string[] | number[]): string[] | number[] {
  list.push(element) // コンパイルエラー
  console.log(element)
  return list
}

listはstring型の配列あるいはnumber型の配列なので、string配列にnumberを入れることになりかねない上記のコードはコンパイルエラーが起きます。このコンパイルエラーを解消するには型ガードを書いていくしかないのですが、非常に面倒ですよね。

function isStringArray(array: any[]): array is string[] {
  const stringArray = array.filter<string>((e): e is string => typeof e === 'string')
  return stringArray.length === array.length
}

function hogeGenerics(element: string | number, list: string[] | number[]): string[] | number[] {
  if (typeof element === 'string' && Array.isArray(list)) {
    if (isStringArray(list)) {
      console.log(element)
      list.push(element) // ここでやっとlistがstring[]であることが保証されたので、element(string型)を入れられる
    }
  }
  // 似たような処理をnumber型にも用意しなきゃ・・・
  // あっ、上のコード、listがstring[]じゃなかった時の例外処理を書いてない・・・
  // 引数がもっと増えたら、どんどん型ガードが複雑になっていく・・・
  return list
}

そして例によって上記のコードを以下のように呼び出しても、100万人に向けてグランドリリースしてサービスを実行するその瞬間までエラーは出ません。

hogeGenerics(3, ['a']) // コンパイルエラーなし

これもジェネリクスを使えば簡単に書けます:

function hogeGenerics<T>(element: T, list: T[]): T[] {
  list.push(element)
  console.log(element)
  return list
}

hogeGenerics<number>(3,['a']) // コンパイルエラーが起きるので、すぐ気づける

まとめ

  • ジェネリクスを使うと型ガードを書かなくて済む
  • ジェネリクスを使うと一部の実行時エラーをコンパイルエラーとして事前に気づける
  • やってみないと分からないだろ!!!(ドンッッッ!!!) を減らそう

Discussion

ログインするとコメントできます