集合としての型

これまでBoolIntStringのような型を見てきました。 そしてカスタム型を以下のように定義しました。

type Color = Red | Yellow | Green

Elmにおけるプログラミングの中で最も重要なテクニックのひとつは、コード中で可能な値現実世界での正当な値に完全に一致させることです。これにより不正なデータの入り込む余地がなくなるため、私はカスタム型とデータ構造に注力するようにみんなに勧めています。

この目的を追求するにあたっては、私は型と集合の関係を理解することが役に立つということに気づきました。少し難しく聞こえるかもしれませんが、これは本当にマインドセットを開発するのに役立ちます!

集合

型は値の集合だとみなすことが出来ます

  • Bool{ True, False } のなす集合
  • Color{ Red, Yellow, Green } のなす集合
  • Int{ ... -2, -1, 0, 1, 2 ... } のなす集合
  • Float{ ... 0.9, 0.99, 0.999 ... 1.0 ... } のなす集合
  • String{ "", "a", "aa", "aaa" ... "hello" ... } のなす集合

つまり、x : Boolと言うとき、x{ True, False }のなす集合の要素であるというようなものです。

濃度

これらの集合にどれだけ多くの値が含まれるかを考え始めると興味深いことがわかります。例えば、Bool集合の{ True, False }は2つの値を含みます。そこで数学のわかる人ならBool濃度2を持つということがわかります。つまり概念的にはこうなります:

  • Boolの濃度 = 2
  • Colorの濃度 = 3
  • Intの濃度 = ∞
  • Floatの濃度 = ∞
  • Stringの濃度 = ∞

(Bool, Bool)のような集合の組み合せを考え始めるとより興味深いことがわかります。

Note: IntFloatの濃度は実際には無限より小さくなります。コンピュータは(ここで説明されているように)固定ビット長に数値を収めねばならないため、Int32の濃度 = 2^32 Float32の濃度 = 2^32 のようになります。大事なことは、これらの濃度は大きいということです。

乗算 (タプルとレコード)

型を組み合わせてタプルを作るとき、濃度は乗算になります:

  • (Bool, Bool)の濃度 = Boolの濃度 × Boolの濃度 = 2 × 2 = 4
  • (Bool, Color)の濃度 = Boolの濃度 × Colorの濃度 = 2 × 3 = 6

自分でこれが正しいのだと信じるためには、(Bool, Bool)(Bool, Color)で可能な値を全て挙げてみてください。挙げてみた値は予想と一致しましたか?(Color, Color)の場合はどうですか?

しかし、IntStringのような無限集合を扱う場合はどうなるでしょう?

  • (Bool, String)の濃度 = 2 × ∞
  • (Int, Int)の濃度 = ∞ × ∞

個人的に無限が2つあるという発想は好きです。ひとつで十分じゃないかって?もし無限個の無限集合があったら、どこかで収集がつかなくなるんじゃないかですって?

Note: ここまではタプル型を見てきました。しかしレコード型についても全く同じことが言えます:

  • (Bool, Bool)の濃度 = { x : Bool, y : Bool }の濃度
  • (Bool, Color)の濃度 = { active : Bool, color : Color }の濃度

そしてもしtype Point = Point Float Floatのような定義をすればPointの濃度は(Float, Float)の濃度と等しくなります。これは全て乗算です!

加算 (カスタム型)

カスタム型の濃度はそれぞれのバリアントの濃度を足し合わせることによっても求められます。まず最初にMaybe型とResult型を見てみましょう

  • Result Bool Colorの濃度 = Boolの濃度 + Colorの濃度 = 2 + 3 = 5
  • Maybe Boolの濃度 = 1 + Boolの濃度 = 1 + 2 = 3
  • Maybe Intの濃度 = 1 + Intの濃度 = 1 + ∞

自分でこれが正しいと納得するためには、Maybe BoolResult Bool Colorで可能な値を全て挙げてみてください。結果は上記の計算と一致しましたか?

その他の例はこちらです:

type Height
  = Inches Int
  | Meters Float

-- Heightの濃度
-- = Intの濃度 + Floatの濃度
-- = ∞ + ∞


type Location
  = Nowhere
  | Somewhere Float Float

-- Locationの濃度
-- = 1 + (Float, Float)の濃度
-- = 1 + Floatの濃度 × Floatの濃度
-- = 1 + ∞ × ∞

カスタム型をこのような視点からみることは2つの型が等しいときに役立ちます。例えば、LocationMaybe (Float, Float)と等しくなります。それを踏まえた上で、どちらを使うべきでしょう?私は以下の2つの理由からLocationが好ましいと思います。

  1. ソースコード自身がその意味するところを雄弁に語るようになります。Just (1.6, 1.8)が場所を表すのか、身長の値のペアを表すのか悩む必要がありません。
  2. Maybeモジュールは自分のデータでは意味をなさないメソッドを持っているかもしれません。例えば2つのLocationの値を連結することはMaybe.map2とは違う意味になるでしょう。中にひとつだけNowhereが含まれていたら、全てがNowhereになってしまうのでしょうか?それは奇妙だと思います!

言い換えれば、もし他のある部分と似た複数行のコードを書いても、ある程度明確さをもたせ、統制の取れたコードを書くことができるため、それは大規模なコードベースとチームに対して非常に価値のあるものとなります。

誰が気にするの?

「集合としての型」について考えることは不正なデータという重要なバグの一群を説明するのに役立ちます。例えば、交通信号の色を表したいとしましょう。値の集合は{ red, yellow, green }ですが、どうやってコードで表現するのでしょう。以下は3つの異なるアプローチです:

  • type alias Color = String"red", "yellow", "green"という3つの文字列を使い、その他は全て不正なデータとして扱うことが出来ます。しかし、もし不正なデータが生成されてしまったらどうでしょう?誰かが "rad"という誤字を入力するかもしれません。または大文字の"RED"と入力してしまうかもしれません。全ての関数で引数のチェックを行うべきでしょうか?すべての関数は結果の色の値の正当性を確認すべきでしょうか?根本的な問題はColorの濃度 = ∞であるため、(∞ - 3)個の不正な値があることです。これら不正な値が入り込まないように大量のチェックを行わなければなりません!
  • type alias Color = { red : Bool, yellow : Bool, green : Bool } — これは“red”をColor True False Falseという形で表現しようというアイデアです。しかしColor True True Trueという場合はどうでしょう?全ての色値が同時にTrueになるというのはどういう意味でしょう?これは不正なデータです。Stringで色を表現するときのように、たくさんのチェックやテストを行い間違いが起こらないようにすることになります。この場合、Colorの濃度 = 2 × 2 × 2 = 8です、つまり5つの不正な値しかありません。混乱を引き起こす組み合わせはぐっと減りましたが、それでもチェックとテストは行わなくてはなりません。
  • type Color = Red | Yellow | Green — この場合、不正なデータが使われることはありません。Colorの濃度 = 1 + 1 + 1 = 3であり、現実世界で可能な3つの組み合わせと完全に一致します。そのため、不正な値が使われていることをチェックしたりテストしたりする必要はありません。それらは存在し得ないのです!

肝心なことは、不正なデータを排除することはソースコードをより短くし、より単純にし、そしてより信頼性を上げることです。コード中で可能な値を現実世界で可能な値に完全に一致させることで、数多くの問題がなくなります。これは切れ味鋭いナイフのようです!

プログラムが変化していくに連れ、コード中での可能な値は現実世界で正当な値の集合から離れていってしまうかもしれません。そういうときは自分の型を見つめ直して、それらを一致させることを強く勧めます。これはナイフの切れ味が鈍くなった事に気づいて、砥石で研ぎ直すようなものです。このような保守はElmでのプログラミングの中心となる作業のひとつです。

こういった考え方をしだすと、より少ないテストしか必要にならなくなり、それでいてより信頼性の高いコードを書くことが出来ます。依存性が少なくなるにも関わらず、やるべきことをすばやく片付けられます。これはナイフを扱う技術を持った人はおそらくSlapChopを買わないことと似たようなものです。料理用ミキサーやフードプロセッサーが活躍する場面はもちろんあります、しかしあなたが思うよりもそれは少ないものです。人はつい、シンプルな道具よりも簡単に使いはじめられる道具を選んでしまいます。でも、そういった道具は実際にはシンプルな道具に比べて構造が複雑でどうやって動いているのかもわかりにくいため壊れやすく自分で修理できない上に、使える範囲も限られてしまうものです。

言語デザインの余談

このように型や集合を考えることは、ある言語がどうして「簡単」もしくは「制約的」あるいは「エラーを起こしやすい」と感じるのかを説明するのにも役立ちます。例えば:

  • JavaBoolStringといったプリミティブな値があります。そこから、固定された数の異なるフィールドを組み合わせてクラスを作ることが出来ます。これは濃度を乗算することができるためElmのレコードに似ています。しかしJavaでは濃度の加算をすることは非常に難しいです。継承をすることでそれは可能になりますが、それは負担の大きいプロセスです。ElmではResult Bool Colorと簡単に書けるのに、Javaではそれはとても大変なことなのです。Javaが濃度5の型をデザインするのがとても難しく、多くの場合割に合わないという意味で、Javaが「制約的」だと考える人もいるでしょう。

  • JavaScript — こちらでも BoolStringといったプリミティブな値があります。そこから動的なフィールドの集合を使ってオブジェクトを作ることが出来、濃度を乗算することが出来ます。これはクラスを作るより非常に軽量な作業です。しかしJavaのように濃度の加算はとても難しいです。例えば、Maybe Int{ tag: "just", value: 42 }{ tag: "nothing" }といった形でシミュレートすることが出来ます、しかし実際にはこれは濃度を乗算しているのです。これだと現実の世界での可能な値に合わせることが非常に困難になります。人によっては(∞ × ∞ × ∞)という濃度の型を作ることが非常に簡単でありそれで全てを扱えることからJavaScriptを簡単だと考える人がいるでしょう、しかし反対に濃度5の型をデザインすることは不可能で不正なデータが存在する可能性を大きく広げてしまうため「エラーを起こしやすい」と考える人もいます。

興味深いことに、いくつかの手続き型言語はカスタム型を持っています!Rustは良い例でしょう。RustはCやJavaから来た人々が直感的に理解しやすいenumsを持っています。つまりRustでは濃度の加算はElmと同じくらい簡単で、同様の恩恵を全てもたらしてくれます!

肝心な点は、一般に型の「加算」は非常に過小評価されているということです。そして「集合としての型」を考えることは、なぜある言語のデザインが特定の不満を生み出すのか明確にするのに役立ちます。

results matching ""

    No results matching ""