実践編!Haskellらしいアプリケーション開発。まず型を定義すべし【第二言語としてのHaskell】

トランプを使った有名なゲーム「ブラックジャック」の手札の値を計算をするアプリケーションを書きながら、Haskellによるプログラミングの中心となる「型を定義し、その型を利用した関数を書く」ことを実践してみましょう。

実践編!Haskellらしいアプリケーション開発。まず型を定義すべし【第二言語としてのHaskell】

こんにちは。Haskell-jpの山本悠滋(igrep)です。
Haskellでプログラミングを始めるのに最低限必要となるものを「Haskellらしさって?「型」と「関数」の基本を解説!」という記事でお話しました。

その際に「Haskellによるプログラミングの大きな部分を占めるのは、問題に合わせた型を自分で考えて定義し、その型を利用した関数を書くこと」 と宣言しましたが、実践するところまでは踏み込みんでいません。

この記事では、実際にアプリケーションの一部を書きながら、「型を定義し、その型を利用した関数を書く」ことを実践してみましょう。 その途中で、Haskellのさまざまな機能や、関数の組み立て方も学んでいきます。

題材としては、トランプを使った有名なゲーム、「ブラックジャック」の手札の値を計算をする関数を作ります。

ブラックジャックにおける手札の数え方

ブラックジャックは、最初に配られる2枚の手札をもとに、追加のカードを1枚ずつ要求していって、手札の合計を21になるべく近づけていくゲームです。追加のカードによって手札の合計が21を越えてしまったら負けです。 手札の合計を計算するときは、次のようなルールに従ってカードを数えます。

1

ブラックジャックにおける手札の数え方

今回紹介するソースコードは、すべて{$_2}Yuji Yamamoto / haskell-as-second-language-blackjack · GitLabに公開してあります。 hspecを使ったテストコードのほか、stackを使ったHaskellで書かれたプロジェクトのサンプルにもなっていますので、参考にしてみてください。

型を定義して、カード(Card)を作る

手始めに、ブラックジャックのカードを表す型を定義しましょう。 ブラックジャックで使うトランプのカードはジョーカーを除いた52枚ですが、今回は絵柄(ハート、ダイヤ、クラブ、スペード)の違いは無視し、AからKまでの13種類のカードを表現する型を考えます。

カードの種類を単純に列挙した型を考える

Haskellでは、dataというキーワードを使って、ユーザー定義型を定義できます。 dataは、Javaのようなオブジェクト指向言語でクラスを定義するのと同じような気分で使っていただいてかまいません。 詳細はこれから説明しますが、大体同じこと、あるいは比較対象の言語によってはそれ以上のことができます。

13種類のカードを表す型をdataで定義するには、たとえばこのようにします。

data Card =
  A | N2 | N3 | N4 | N5 | N6 | N7 | N8 | N9 | N10 | J | Q | K deriving (Eq, Show)

data Card = ...で始まっていることから察せられるとおり、この例ではCardという名前の型を宣言しています。 型の名前Cardの先頭Cが大文字になっている点に注意してください。 原則として、型の名前はアルファベット大文字で始めなければならないことになっています。

その次の行では、Card型がどのような値を取り得るのかを表すために、値コンストラクターを列挙しています。 縦棒(|)は、「または」と読み替えてくださいor演算子(||)などと由来は同じです)。 上記の例は、「型Cardは、AまたはN2またはN3またはN4または(... 長いので省略 ...)またはQまたはKという値を取り得る」という意味になります。

上記の定義でやっていることは、Javaのような言語でenumを定義しているのと同じようなものだといえます。 「AまたはN2またはN3またはN4または(... 長いので省略 ...)またはQまたはK」という値をとるenumを定義しているのと同等なのです。

上記のコードをblackjack.hsというファイルに保存して、このCard型が正しく定義できていることを確認してみましょう。 stack ghciコマンドでGHCiを起動した後、:l blackjack.hsとして、作成したファイルを読み込んでください。

$ stack ghci
> :l blackjack.hs

エラーなくファイルを読み込めたら、定義した型の値を入力して何が起こるか確認してみましょう。

> A
A
> N2
N2

dataで宣言したCardの値であるAN2を入力してもエラーにならないことから、GHCiがCard型の値を認識できていることが分かります。

とはいえ、今のところ入力した値が表示されるだけなので、本当に定義できているのか実感が分かないかもしれません。 そういう場合は、いったん:qでGHCiを終了させた後、再起動してもう一度AN2などを入力してみてください。 GHCiがAN2などを認識できず、エラーになるはずです。

型が定義できたことをGHCiのコマンドで確認する

前回の復習になりますが、:t:iといったGHCiのコマンドを使ってCard型が定義できたかを確認することもできます(やはり復習になりますが、:l:qと同様、これらはGHCi専用のコマンドです。Haskellの文法とは直接関係がないのでご注意ください)

:tコマンド:typeの略)は、前回説明したとおり、指定した値がどんな型の値か教えてくれるコマンドです。AKに使うと、ちゃんとCard型の値となっていることが分かりますね。

> :t A
A :: Card
> :t K
K :: Card

:iコマンド:infoの略)は、値に対して使うだけでなく、型に対しても使える便利なコマンドでした。 型の名前を与えると、型の定義を詳しく見ることができます。

> :i Card
data Card = A | N2 | N3 | N4 | N5 | N6 | N7 | N8 | N9 | N10 | J | Q | K
        -- Defined at blackjack.hs:95:1
instance [safe] Eq Card
  -- Defined at blackjack.hs:96:35
instance [safe] Show Card
  -- Defined at blackjack.hs:96:39

先ほどblackjack.hsに保存したCard型の定義が、ほぼそのまま出てきました。意図した通りにCard型ができているようです (「instance [safe] Show Card」などといった部分が気になるかもしれませんが、申し訳なくも今回は割愛させていただきます)

【コラム】カードの型を生の整数で表すのではだめなの?

トランプの大半のカードは数字のカードなのですから、Card型を自分で定義したりせず、単純に整数Int型など)で表すのではだめなのでしょうか。 そのほうが、あとで合計を計算するときにも楽そうです。

しかし、Intでカードを表すことには、次のような問題があります。

  • ブラックジャックのルール上、Aは1点とも11点とも数えることができる。単なるIntではこれを表現できない
  • カードをIntで表現してしまうと、Intで表せそうなほかの値と紛らわしくなる
    • ある変数に整数1が代入されていたとき、それが手札を計算した合計の「1」なのか、それとも手札を計算する前のカードAを表す「1」なのかを区別できない
    • ほかの意味でも整数の1を使うことがありうる。たとえば、オンライン対戦やコインをかける機能があれば、「1」でユーザーのIDを表したり、ユーザーがベットしたコインの枚数を表したりするかもしれない

カードにはカードのための型を割り当てたほうが、こうした紛らわしさを軽減し、間違えを少なくできるのです。

2~10のカードだけ、パラメータを1つとる別の型にしよう(直和型)

:iコマンドでCard型の情報を表示させると、型の定義が次のように表示されました。

> :i Card
data Card = A | N2 | N3 | N4 | N5 | N6 | N7 | N8 | N9 | N10 | J | Q | K

この結果を見ると、この定義の「N2 | N3 | N4 | N5 | N6 | N7 | N8 | N9 | N10」という部分がちょっと冗長に思えますね。

今回のカードの仕様では、2から10までのカードは同じ値の整数と1対1に対応しています。 これらをまとめてIntに関連付けて表せれば、Card型の定義はかなりすっきりしそうです。 ただし、見かけ上の区別があるJQKと、点数計算のルールがほかのカードと異なるAは、Intに関連付けて表すわけにはいきません。 AJQK以外の値だけIntに関連付けて表すことができる、より直感的で実態に合うカードの型が作れないものでしょうか。

これをそのまんま実現するのが、Haskellの直和型と呼ばれる機能です。 直和型を使ったCard型の定義はこうなります。

data Card =
  A | N Int | J | Q | K deriving (Eq, Show)

Card型が取り得る値コンストラクターを縦棒|を用いて列挙している点は、先ほどの定義と同じです。 AJQKの4つの値コンストラクターについても、何も変えていません。 しかし、N2 | N3 | N4 | N5 | N6 | N7 | N8 | N9 | N10と長ったらしかった部分が、N Intとまとめられていますね。

このN Int、一体何を表しているのでしょうか。ほかの値コンストラクターとは異なり、Nの後ろにIntが付いた形をしていますが、実はこのN Intも値コンストラクターです。

まず、N Intの先頭にあるNは、この値コンストラクターの名前です。型の値コンストラクターに名前があるのは、これまでに出てきた値コンストラクタ―のAJN1などと同じです。 そして、Nの後ろに付いているIntは、「値コンストラクターNの引数の型」です。

よくあるオブジェクト指向言語において「フィールド」とか「プロパティ」と呼ばれているもの、というとピンとくる人もいるかもしれませんね。 ここで重要なのは、このInt型の引数を保持できるのは値コンストラクターがNのときだけという点です。

3

値コンストラクターを使った直和型の定義

値コンストラクターの引数について、もう少しだけ別の例でも説明してみましょう。 データ型の定義方法の説明では、名前Stringと年齢Intの人物を表現した、次のようなPerson型の定義がよく使われます。

data Person = Person String Int

同名なので紛らわしいですが、型の名前がPersonで、その唯一の値コンストラクタ―がPerson String Intです。 その値コンストラクターの名前がPersonで、スペースで区切られた残りのString Intは、値コンストラクターPersonの引数の型、言い換えると、プロパティの型を表しています。

プロパティに名前が付いておらず、型だけ指定して定義されていることに驚いた人も多いでしょう。Haskellでは、このように、値コンストラクターに名前を持たない引数を定義できるのです(名前をつける方法もありますが、今回は割愛します)

しかし、名前を持たないプロパティに一体どうやってアクセスすればいいのでしょう?  ほかの言語であれば、プロパティのアクセスにはperson.namecard.numberなどとするところですが、名前がないのでそれができません。 実は、Haskellでは「パターンマッチ」という機能を使って値コンストラクタ―の引数を利用します。詳細は後ほど説明しますので、お楽しみに。

Card型の話に戻ります。新しいCard型の定義では、ANJQKの5つのコンストラクターのうち、Nだけがカードの番号を表すInt型のプロパティを持つようにしました。 これでCard型がきちんと定義できているかどうか、新しいCard型の定義でblackjack.hsを書き換えてからGHCi上で試してみましょう。

> :l blackjack.hs
> :i Card
data Card = A | N Int | J | Q | K
instance [safe] Eq Card
instance [safe] Show Card
> A
A
> :t A
A :: Card

:iコマンドの結果表示は、data Cardの部分が変わった以外、今のところ変化はありません。定義を変えていない値コンストラクターAKもそのまま使えます。

では、Nはどうでしょう?

> N
<interactive>:5:1: error:
    ・ No instance for (Show (Int -> Card))
        arising from a use of ‘print’
        (maybe you haven't applied a function to enough arguments?)
    ・ In a stmt of an interactive GHCi command: print it

そのままGHCiに入力するとエラーになってしまいました。:tで型を見るとどうなるでしょうか?

> :t N
N :: Int -> Card

Int -> Cardという型が返ってきました。これは、前回も紹介した関数型です。 この場合、「Int型の値を受け取ってCard型の値を返す関数」という意味になります。 そう、NInt型の値(整数)を受け取る関数となったのです! 残念ながらGHCiでは関数を表示させることができません(これは、どちらかというとHaskellの都合です。詳しくは、英語ですがHaskellWikiの「Show instance for functions」をご覧ください)。 先ほどのエラーは、「関数を入力しても、コンソールには表示できないよ」というエラーだったのです。

今度は、Nに引数を指定し、関数として使ってみましょう。 関数を呼び出すには、前回の記事でbmi関数を使ったときのように、関数名と引数をスペースで区切って並べるだけでしたね。

> N 2
N 2

今度はエラーになりません。 では、型はどうなっているでしょう?

> :t N 2
N 2 :: Card

N 2Card型である」という結果が返ってきました。 このように、Card型の値コンストラクターNは、関数としてIntを受け取ってはじめてCard型の値となるのです。

これは、Javaなどのオブジェクト指向言語でコンストラクターに引数を与えているのと同じようなものです。 値コンストラクタ―でも、与えた引数がそのままNのプロパティーとなります。

そして、AJQKIntを受け取らずにCard型となっていたことから分かるとおり、カード番号としてIntを受け取るのはNだけなのです。 以上により、数字のカードだけに整数を関連付けて型を定義するという要件を満たすことができました。

derivingで、型クラスに属する型(インスタンス)を自動で定義する

ここまで、Card型を例として、Haskellにおいて新しい型を作成する方法を紹介しました。 しかしながら、新しい型を定義する構文について、まだ紹介し切れていない箇所があります。 先ほどのCard型の宣言の末尾にあるderiving (Eq, Show)という箇所です。

data Card =
  A | N Int | J | Q | K deriving (Eq, Show)

deriving (Eq, Show)というのは、Card型をEq型クラスとShow型クラスに所属させるCard型をEq型クラスとShow型クラスのインスタンスにする)ために必要な宣言です。

型クラスは、前回の記事で説明したとおり、「同じような特徴(振る舞い)を持った型を、ひっくるめて扱えるようにする仕組み」です。 Num型クラスが「足し算、かけ算、引き算ができる型」をまとめた型クラスであったり、Fractional型クラスが「それらに加えて割り算ができる型」をまとめた型クラスであったりしたように、それぞれの型クラスには、それぞれがまとめた型に共通する特徴があります。

では、Eq型クラスとShow型クラスにはどんな特徴があるのでしょう?  Eq型クラスとShow型クラスの特徴は非常によく使われるので、自分で定義する型にはほとんどいつもderiving (Eq, Show)を付けて宣言したくなるはずです!

2つの値が「等しいかどうか」を比較できるような型にするには(Eq型クラス)

Eq型クラスは、2つの値が「等しいかどうか」比較できる型をまとめる型クラスです。 具体的には、おなじみの==で2つの値が比較できるような型ということです。

IntCharString、リストなど、これまでの説明に出てきた型は、関数型とIO型を除いてすべてEq型クラスに所属しています。 GHCiで確かめてみましょう。

> 'a' == 'a'
True
> True == True
True
> 3 == 2
False
> "hello" == "Hello"
False
-- リストやタプルを比較する際の「==」は、すべての要素を「==」で比較して、
-- すべてが等しい場合のみTrueを返す
> ('a', True) == ('c', True)
False
> [1, 2, 3] == [1, 2, 3]
True

ちなみに、Haskellの==にはほかのプログラミング言語にはないちょっと変わった特徴があって、同じ型同士の値でないと比較ができません。 たとえば、次のように真偽値True"True"という文字列を比較しようとすると型エラーになります。

> True == "True"
<interactive>:9:9: error:
    • Couldn't match expected type ‘Bool’ with actual type ‘[Char]’
    • In the second argument of ‘(==)’, namely ‘"True"’
      In the expression: True == "True"
      In an equation for ‘it’: it = True == "True"

ちょっと厳しく思えるかも知れませんが、ほかのプログラミング言語でも、暗黙の型変換が働くようなケースを除けば、違う型の値による比較はFalseを返すことになるでしょうし、通常はやる必要がないでしょう。 型が曖昧になることを嫌うHaskellでは、暗黙の型変換も違う型の値の比較も認めていないというだけのことです。

値を文字列として表示できるような型にするには(Show型クラス)

Show型クラスに属する型は、showという関数によって、値を文字列に変換できます。 Show型クラスについても、IntCharString、リストなど、前回紹介した型は、関数型とIO型を除いてすべて所属しています。 こちらもGHCiで確かめてみましょう。

> show 'a'
"'a'"
> show False
"False"
> show "12345"
"\"12345\""
> show 12345
"12345"
> show 139.17
"139.17"
> show ('c', True)
"('c',True)"
> show [3, 2, 1]
"[3,2,1]"

文字列"12345"showした場合には、"12345"という文字列そのものではなく、ダブルクォートで囲った"\"12345\""(ダブルクォートがバックスラッシュでエスケープされていることに注意)が出力されました。 このことから分かるように、show関数によって作成される文字列は、ほかの型の値をshowした文字列から区別できるように作られています。

たとえば、上記のshow "12345"の結果とshow 12345の結果を比較してみると、型が違うと違う文字列が返ってくるように作られていることが分かります。 そうした意味では、Rubyでいえばto_sメソッドよりもinspectメソッド、Pythonでいえばstr関数よりもrepr関数に近い挙動です。 したがって、show関数は、どちらかというとデバッグで値の中身を見るために使用するのがおすすめです。

【コラム】GHCiの表示はshow関数の変換結果

実は、GHCiで入力した式をコンソール上に表示する際にも、内部ではshow関数を使用しています。 入力した式の値を、show関数で文字列に変換することで表示しているのです。 なので、下記のように、Show型クラスに属さない関数型の値を入力するとエラーになってしまいます。

> (+)
<interactive>:15:1: error:
    • No instance for (Show (a0 -> a0 -> a0))
        arising from a use of ‘print’
        (maybe you haven't applied a function to enough arguments?)
    • In a stmt of an interactive GHCi command: print it
> not
<interactive>:16:1: error:
    • No instance for (Show (Bool -> Bool))
        arising from a use of ‘print’
        (maybe you haven't applied a function to enough arguments?)
    • In a stmt of an interactive GHCi command: print it

こうした特徴のため、自分で定義した型をShow型クラスのインスタンスにしておくと、定義した型の値を返す関数をGHCiで試したとき、どんな値が返ったかを確認するのが簡単になります! そして、以下で説明するように、deriving Showとするだけでそれがタダで手に入るのです。

Eq型クラスとShow型クラスの性質を手軽に得る

この項の冒頭で説明したように、deriving (Eq, Show)と書くことによって、Card型をEq型クラスとShow型クラスに自動で所属させるCard型をEq型クラスとShow型クラスのインスタンスにする)ことができるのでした。 これらを自動で行うということは、Card型の値を==で比較する方法や、show関数でCard型の値を文字列に変換する方法を、自動で定義するという意味です。 具体的にどのように==で比較したり、show関数で文字列に変換したりするのでしょうか?

実は、Card型がshow関数でどのような文字列に変換されるかは、すでに知っています。 GHCi上で次のように入力したときのことを思い出してください。

> A
A
> N 2
N 2

値コンストラクターAを入力したときも、引数を与えた値コンストラクターN 2を入力したときも、いずれも入力した文字列がそのまま出力されました。 そして、show関数は、GHCiで入力した式をコンソール上に表示する際に内部で使用されています。 ということは、上記の実行例で表示された結果は、Card型の値をshow関数で変換した際の文字列だということですね。

試しにshow関数を実行してみると、そのことがよりはっきり分かります。

> show A
"A"
> show (N 7) -- 丸カッコで囲むのを忘れないでください!
"N 7"
> show J
"J"
> show Q
"Q"
> show K
"K"

このように、deriving Showして定義された型のshow関数は、値コンストラクターや、値コンストラクターに渡した引数(値コンストラクターのプロパティ)がそのまま文字列として返るように定義されます。 これは、先ほども触れた「デバッグの際、値の中身を見るために使用する」という用途にもマッチしていると言えるでしょう。 出力された文字列をそのままソースコードに貼り付けて再利用するといった用途にも向いています。

Card型のEq型クラスの実装についても、GHCiで試してみるとよく分かるでしょう。

-- 同じ種類の値コンストラクターだからTrue
> A == A
True

-- それ以外はすべてFalse
> A == J
False
> A == K
False
> J == J
True
> A == N 3
False

-- 引数(プロパティ)を持つ値コンストラクターも、
-- もちろん違う種類の値コンストラクターだとFalse
> N 3 == K
False

-- 同じ種類の値コンストラクターでも、
-- 渡した引数が同じ値(「==」で比較される)
-- でなければFalse
> N 3 == N 2
False
-- 値コンストラクターの種類と
-- 渡した引数が同じ値になって初めてTrue
> N 3 == N 3
True

このように、deriving (Eq, Show)して作られた==関数の実装は、両方とも非常に直感的なものとなっています。

Eq型クラスとShow型クラスのユースケースは想像以上に多くあります。 たとえば、hspecなどの自動テストフレームワークにおいて期待した結果と実際の結果が等しいかどうかを確認したり、等しくなかった場合に実際の結果を表示したりするには、対象の型がShow型クラスやEq型クラスのインスタンスである必要があります。

そのため、みなさんが新たに定義した型には、特別な事情がある場合を除いて、なるべくderiving (Eq, Show)とおまじないのように書いておくことをおすすめします。 ちなみに、「特別な事情がある場合」とは、もともとEq型クラスにもShow型クラスにも属していないIO型や関数型の値をプロパティとして持つ型を定義する場合などが該当します。

定義したCard型を生かして、都合のよい手札の合計値を求めるには

Card型が定義できたところで、その型の特徴を利用し、手札の合計を求める関数を書いていきましょう。

まず、そのような関数の型を考えます。 手札は複数のCardですから、手札の型はCardのリスト[Card]でいいでしょう。 手札を計算した合計のほうは、整数になるので、Intで表現しましょう。 この関数の名前は、手札(hand)を合計するので、そのままsumHandとしましょう。

以上をまとめると、手札の合計を求める関数sumHandは、Cardのリストを受け取ってIntを返すものになりそうですね。 そのことを型で表すと、こうなります。

sumHand :: [Card] -> Int

このように、関数の名前の後ろのコロン2つ::以降で関数の具体的な型名を示したものを、関数の型宣言といいます。 Haskellで関数を定義するときは、型宣言を1行めに明記することで、「その関数がプログラムのどこに影響を与えるのか」の範囲を示します。 関数の定義に型宣言が付いているおかげで、

  • 入出力処理を行う関数か(つまり、プログラムの外の世界に影響があるか)
  • 暗黙に共有されている変数を読み書きする関数なのか(プログラム内で広範囲に共有されている変数に影響があるか)
  • エラーを発生させて後続の処理を強制終了させる関数なのか(プログラムのフローに影響するか)
  • はたまた、単純に値を返すだけの関数か(戻り値を受け取る変数に影響するか)

といったことを、関数の型だけで明示できるのです(実際には、デバッグなどのために作られた例外もあります)

【コラム】型宣言は常に必要か?

静的型付け言語に抵抗がある方は、わざわざ型宣言を書くことが面倒くさいなと感じられるかもしれません。 実際、Haskellには強力な型推論の機能が備わっているので、多くの場合は型をわざわざ書く必要はありません(前回の記事のbmi関数でも、型宣言はあえて省略していました)

それでも、型宣言をすることで関数の型を明示すれば、コンパイラーが自動で正しさを証明してくれた信頼性の高いドキュメントになります。 みなさんも、分かりやすさのために、関数の定義では積極的に型を明示することをおすすめします。

続いて具体的な関数の中身を考えましょう。 型宣言の下に、関数の定義を書いていきます。 関数を定義するときは、関数名と引数名を書いて、イコール記号の後ろに具体的な処理を書いていくのでしたね。

sumHand :: [Card] -> Int
sumHand cards = ...

「...」の部分は、手順として考えると、以下のようなアルゴリズムになるでしょうか?

  1. 手札の各カードを、取り得る値に変換する
  2. 取り得る値の組み合わせすべてについて、それぞれ合計を求める
  3. 合計のうち、バストしない(合計が21を超えない)もののみを選ぶ
  4. バストしない合計が1つでもあった場合、その中から最も高い値の合計を選んで返す
  5. バストしない合計が1つもない場合(どの合計もバストしてしまう)、合計の中から適当なものを選んで返す(ここでは「リストの先頭」を選ぶものとします)

ブラックジャックには、「Aは1または11のどちらでも都合のよいほうで計算できる」というルールがあるので、それに対応するため、まずは手札の各カードを可能性のある値のリストに変換しています。 あとは、その値の組み合わせのうちで都合がよい値(つまり、21にもっとも近く21を越えない値)を選び出すための手順です。

4

sumHandのアルゴリズム

この関数の目的は、あくまでも「手札の合計を計算する」ことなので、上記の手順だけで要件はすべて満たせそうです。 入出力処理などは一切必要ありませんね。 そこで、「値を受け取って返す以外には何もしない」純粋な関数のみを組み合わせることで、目的の関数を作っていくことにします。

以降の解説では、いきなりsumHandを定義していくのではなく、まず上記のアルゴリズムの1つ目の手順「手札の各カードを、取り得る値に変換する」を担う関数を定義します。 そのため、書きかけのsumHandの定義は、文字通りundefined(未定義)としておいてください。

sumHand :: [Card] -> Int
sumHand cards = undefined

undefinedは、関数や変数の中身が未定義な場合に使用する、特別な値です。 ちょうどJavaにおけるnullのように、どのような型の値の代わりにもなれるので、強引に型エラーを回避することができます。 ただし、undefinedなままでsumHandを使用すると実行時に必ずエラーになってしまうので、くれぐれもご注意ください。 undefinedは、あくまでも「後で書く」という意図を伝えるためだけに使用してください。

Card型を使った関数を作る その1 toPoint関数

Card型を変換して取り得る値にする関数」の名前は、toPointとしましょう。 いきなり答えから見せてしまいますが、下記がtoPointのHaskellでの定義です。

toPoint :: Card -> [Int] -- 関数の型の宣言
toPoint A = [1, 11]      -- Aの場合は1と11を取り得る
toPoint (N n) = [n]      -- 通常の数字のカードであれば、カードに書かれた数字のみを取り得る
toPoint _ = [10]         -- そのほかの場合は10のみをとる

前回定義したbmi関数では使用しなかった構文をいくつか使用しているので、これらを丁寧に解説していきます。

関数toPointの型宣言

1行めは、関数の型宣言です。 toPoint関数の場合は、「Card型の値を受け取り、Intのリスト[Int]を返す関数」という意味になります。

toPoint :: Card -> [Int] -- 関数の型の宣言

関数の本体をパターンマッチで定義する

関数の型宣言より下の3行が、実際の関数の定義です(ちなみに、関数の型宣言と定義は、同じファイル内にあれば離れた箇所にあっても問題ありません)

toPoint A = [1, 11]      -- Aの場合は1と11を取り得る
toPoint (N n) = [n]      -- 通常の数字のカードであれば、カードに書かれた数字のみを取り得る
toPoint _ = [10]         -- そのほかの場合は10のみをとる

toPoint ... = ...と書かれた行が3つもあることから、toPoint関数の定義が3つもあるように見えて、最初はちょっと変に思えるかもしれません。 これが、先ほど名前だけ登場したパターンマッチの構文です(等価な書き方がほかにもいくつかあるので、そのうちの1つです)

上記の定義では、パターンマッチを使うことで、Card型が取る値に応じて返す値を変えるように振り分けています。

1行めから見ていきましょう。

toPoint A = [1, 11]      -- Aの場合は1と11を取り得る

この行は、「第1引数として受け取った値が、Card型の値コンストラクターのうちAであれば[1, 11]を返す」という意味です。 toPoint関数の型宣言では「Intのリスト[Int]を返す」としましたが、確かに[1, 11]111が入ったリスト)を返していますね。

その下の2行は、受け取った引数がAでなかった場合に使用されます。

toPoint (N n) = [n]      -- 通常の数字のカードであれば、カードに書かれた数字のみを取り得る
toPoint _ = [10]         -- そのほかの場合は10のみをとる

このように、値コンストラクターの種類に応じて返す値を振り分けるというのが、パターンマッチの用途の1つです。 手続き型の言語でいえば、次のようなif文と似たことをしているといえるでしょう(下記は特定の言語の構文ではなく、疑似コードです)

function toPoint(x) {
  if (x == A) {
    return [1, 11]
  } else if (...) {
    ...
  }
}

あらためて、定義の2行めを見ていきましょう。 2行めでは、値コンストラクターがNの場合に返す値を定義しています。

toPoint (N n) = [n]      -- 通常の数字のカードであれば、カードに書かれた数字のみを取り得る

返す値は[n]、つまりnだけを含むリストとなっていますが、さてこのnはどこからきた何の値でしょう?

答えは、引数の箇所に書かれている(N n)nです。 これはまさに、値コンストラクターNに与えた引数です。 例えばN 2であればn2となりますし、N 5であればn5となります。

パターンマッチは、このように、値コンストラクターの種類ごとに分岐するだけでなく、値コンストラクターに与えた引数(プロパティ)を取り出すという用途にも使えます。 前に「値コンストラクターの引数はプロパティのようなものだが、名前がなくてもいい」と説明しましたが、これで、プロパティに名前がなくてもアクセスできることが分かりましたね(ちなみに、名前を持っているプロパティでも同じ方法でアクセスできます)

定義の3行めの説明がまだ終わっていませんが、こここでいったん動作を確認してみましょう。 ここまでに書いた関数をblackjack.hs追記し、GHCiから:l blackjack.hsで再び読み込んで実験してみましょう。

$ stack ghci
> :l blackjack.hs
> toPoint A
[1,11]
> toPoint (N 1)
[1]
> toPoint (N 9)
[9]

仕様通り、Aを受け取れば111が返り、Nを受け取ればNに渡した整数がそのまま返ることが確認できました。

【コラム】関数呼び出しにしないためのカッコ

(N 1)のように、Nの関数呼び出しを丸カッコで囲むのを忘れないでください。 丸カッコで囲まずtoPoint N 3のように呼び出してしまうと、下記のようにエラーになります。

> toPoint N 3
<interactive>:2:1: error:
    • Couldn't match expected type ‘Integer -> t’
                  with actual type ‘[Int]’
    • The function ‘toPoint’ is applied to two arguments,
      but its type ‘Card -> [Int]’ has only one
      In the expression: toPoint N 3
      In an equation for ‘it’: it = toPoint N 3
    • Relevant bindings include it :: t (bound at <interactive>:2:1)
<interactive>:2:9: error:
    • Couldn't match expected type ‘Card’
                  with actual type ‘Int -> Card’
    • Probable cause: ‘N’ is applied to too few arguments
      In the first argument of ‘toPoint’, namely ‘N’
      In the expression: toPoint N 3
      In an equation for ‘it’: it = toPoint N 3

これは、Haskellの仕様上、toPoint N 3という関数呼び出しは、「toPoint関数に2つの引数N3を渡した」という意味で解釈されてしまうからです。 慣れないうちはしばしば間違えてしまうので、型エラーが出た際は真っ先に疑うといいでしょう。 特に、上記のようにexpected typeactual typeのどちらかが関数Int -> Cardのように矢印が含まれている)で、もう一方が関数でない型の場合、特にその可能性が高いです。

Cardを直和型にしたことの効能

Card型では、5つのコンストラクターのうち、Nだけがカードの数字を表すInt型のプロパティを持っているのでした。 そのため、パターンマッチでほかの値コンストラクターから値を取り出そうとしても、当然エラーになります。 それも試してみましょう。

関数の定義でAのカードに対応する下記の行を……

toPoint A = [1, 11]

次のように書き換えてみてください。

toPoint (A n) = [1, 11]

それから、再度:l blackjack.hsしてみましょう。

> :l blackjack.hs
[1 of 1] Compiling BlackJack        ( blackjack.hs, interpreted )

blackjack.hs:230:10: error:
    • The constructor ‘A’ should have no arguments, but has been given 1
    • In the pattern: A n
      In an equation for ‘toPoint’: toPoint (A n) = [1, 11]
Failed, modules loaded: none.

丁寧なエラーメッセージが出ました。エラーメッセージを日本語に訳すと以下のような感じでしょうか。

  • コンストラクター 'A' は引数を持っていないはずなのに1個与えています。
  • 発生したパターン: A n
    'toPoint' 関数における等式「toPoint (A n) = [1, 11]」にて発生。

文字通り、「引数を持っていない値コンストラクターに引数を渡した」ことを教えてくれています。

これは、直和型を使って、値コンストラクターがNのときだけプロパティを保持できるようにしたことによる効果です。 直和型をサポートしない言語でカードを単純に表現しようとすると、「数字を取らないAJQKの各値では、プロパティとしてnullを代入する」といった対応をすることになるでしょう。 その結果、数字を取らない値から間違えて数字を取得しようとしてしまい、意図せずnullを参照してしまう原因を作ってしまうかもしれません。

Haskellでは、直和型のおかげで、本当に必要なケースのみにプロパティを付与できます。 それにより、無駄なコードを排除できるだけでなく、そうした間違えの可能性を未然に排除できるのです。

「何でもよい」にマッチさせるには

さて、toPoint関数の定義も残り1行です。 ここまで触れていなかった下記の行について説明しましょう。

toPoint _ = [10]         -- そのほかの場合は10のみをとる

Aと数字が書かれたカードを除くと、残っているカードの型はJQKです。 そのことから察せられるとおり、これはtoPoint関数がAでもNでもなくJQKのいずれかを受け取った場合に対応する定義です。 その場合の返す値は、[10]10だけが入ったリスト)ですね。

GHCi上で試してみましょう。

> toPoint J
[10]
> toPoint Q
[10]
> toPoint K
[10]

でも、どうしてこんな結果が得られるのでしょうか?  toPoint _ = [10]という定義のうち、toPointが関数名、イコールより後ろの[10]が返り値を表しているのは分かると思いますが、イコールの左側に出てくるアンダースコア_が謎ですよね。

このアンダースコアは、仮引数です。 「仮」なので、アンダースコアでないといけないわけではなく、適当なアルファベットの小文字でも動きはします。 試しに、定義を下記ようように書き換えて:l blackjack.hsし、toPoint Jなどの結果を試してみてください。 動作は変わらないはずです。

toPoint n = [10]         -- そのほかの場合は10のみをとる

パターンマッチで値コンストラクターごとに処理を振り分けるとき、仮引数がアルファベット小文字またはアンダースコアで始まる識別子の場合には、(引数として宣言した型に対する)任意の値コンストラクターにマッチするようになります。 これをワイルドカードパターンと呼びます。

(ちなみに、先ほどのtoPoint (N n)の定義で使用したnも任意のIntにマッチするワイルドカードパターンです。そして、前節で定義したbmi関数の仮引数heightweightも、それぞれ任意の値にマッチするパターンです。)

では、筆者はなぜこの仮引数の名前をアルファベット小文字ではなく、あえてアンダースコアにしたのでしょう?  実は、アンダースコアが名前の先頭についている仮引数や変数は、仕様上ちょっと用途が変わっていて、「関数の定義内で参照しない仮引数」として使うことになっているのです。

この用途の仮引数にアンダースコアを使うことは、GHCでも推奨しています。 試しに、GHCi上で:set -Wallと入力して警告を有効にしてから、先ほど修正したblackjack.hsを読み直してみてください。 読み直しには、下記のように、GHCiの:rコマンドが使えます。

> :set -Wall
[*BlackJack]
> :r
[1 of 1] Compiling BlackJack        ( blackjack.hs, interpreted )

blackjack.hs:232:9: warning: [-Wunused-matches]
    Defined but not used: ‘n’

Ok, modules loaded: BlackJack.

今度は警告(warning)が出ました。 名前がアンダースコアで始まっていない仮引数が関数の定義内で使用されていない場合、GHCは、「nを使用していないけどいいの?」と警告を出してくれるのです。

__nのようにアンダースコアで始まる仮引数名をつけることによって、明示的に「この仮引数は使用しない」という意図を示せます。 Haskellでプログラムを書くときは、積極的にこのような慣例に従うようにしましょう。

以上で、toPoint関数の定義は完了です。 ここまで説明したパターンマッチによる関数定義の方法を下記の図にまとめておきます。

5

パターンマッチを使った関数の定義

toPoint関数をいじって、パターンマッチの特徴を知る

エンジニアHubに会員登録すると
続きをお読みいただけます(無料)。
登録のメリット
  • すべての過去記事を読める
  • 過去のウェビナー動画を
    視聴できる
  • 企業やエージェントから
    スカウトが届く