第二言語としてHaskellを学習するシリーズ。発展編では、実践編で定義した型と関数をモジュールにする方法と、型を見直して関数をさらに安全なものにする方法を紹介します。さらに勉強したい方向けの超発展編付き！

こんにちは。Haskell-jpの山本悠滋 （igrep） です。

Haskellらしいプログラミングとは何か？ について、これまで基本と実践を解説してきました。 実践編では、問題にあった型を自分で定義し、 その型を使った関数を定義していくというHaskellプログラミングの流れを、 トランプゲームのブラックジャックの手札の合計計算という例を使って学びました。

この記事ではさらに発展的なHaskellプログラミングの道を示すべく、次の2つの課題に取り組みます。

定義した型と関数をモジュールにする方法 型を見直して、関数をさらに安全なものにしていく例の紹介

記事の後半では、Haskellをさらに理解したい人が学ぶべきポイントや、役立つ教材・コミュニティなどを紹介します。

課題1. モジュールを使って、Card型の内部構造を隠蔽する

最初に取り組むのは、関数や型をモジュールにする方法です。 モジュールは、名前空間を分けることで、関数や型を再利用する際に名前が衝突してしまうのを防いだり、複数の関数や型を意味のあるまとまりに分割したりするための機能を提供してくれます。

そのため、モジュールには次のような効果があります。

作成した関数や型を他の人が利用できるようにする

作成した型の内部構造をライブラリーの利用者から隠蔽する

Haskellでより大きなアプリケーションを作るとき、モジュールに関する知識は必要不可欠になるでしょう。

以降では、説明の題材として、実践編で実装した sumHand 関数と Card 型を使います。

Haskellのモジュールとは

sumHand は、ブラックジャックにおける手札の最適な合計を計算する関数でした。 Card 型はトランプのカードを表す型で、 sumHand も Card 型を利用しています。 これらをモジュールにすれば、他の人にも使ってもらえるようになります。

下記のリポジトリーに、あらかじめ筆者がHaskellのモジュールとして作成済みのものがあるので、このソースコードを見ながら解説していきましょう。

Yuji Yamamoto / haskell-as-second-language-blackjack · GitLab

下記のコマンドを実行して上記のリポジトリーをcloneし、no-haddockという解説用のブランチに切り替えてください。

$ git clone https://gitlab.com/igrep/haskell-as-second-language-blackjack.git $ cd haskell-as-second-language-blackjack $ git checkout origin/no-haddock

cloneできたら、 src/BlackJack.hs をお使いのテキストエディターで開いてみてください。

下記のようなHaskellのソースコードが冒頭に見えるはずです。 この部分が、Haskellにおけるモジュールの宣言になります。

module BlackJack ( Card(A, J, Q, K) , cardForTestData , deck , heartSuit , diaSuit , cloverSuit , spadeSuit , sumHand ) where ...

冒頭の module で始まり where で終わる箇所では、このモジュールの名前と、このモジュールがエクスポートする関数や型、値コンストラクターの名前のリストを記載しています。 ここでエクスポートされた関数や型、値コンストラクターなどを、このモジュールのユーザーが実際に利用できるようになります。

上記の例では、それぞれ以下のようになっています。

モジュールの名前は、 BlackJack

エクスポートする名前のリストは、丸カッコで囲った (Card(A, J, Q, K), cardForTestData, deck, heartSuit, diaSuit, cloverSuit, spadeSuit , sumHand)

エクスポートする名前のリストには、実践編で定義した Card 型や sumHand 関数をはじめとして、さまざまな関数や型が列挙されているのがわかります。

丸カッコ内で改行するとき、カンマを各行の先頭に書いているところにも注目してください。行の末尾にカンマを書いてもよいのですが、Haskellコミュニティーの慣習的に、カンマ区切りのものを改行して列挙するときには行頭にカンマを書くことが多くあります。

型と値コンストラクターのエクスポートと隠蔽

エクスポートする名前のうち、特筆すべき点は、 Card(A, J, Q, K) という構文です。 ここで利用しているのは、型とともに「型の値コンストラクター」をエクスポートするための構文です。

型の名前(値コンストラクター 1 , 値コンストラクター 2 , ... , 値コンストラクターN)

同じことをしようとして下記のように書いてもエラーになってしまうのでご注意ください。

module BlackJack ( Card , A , J , Q , K )

なお、すべての値コンストラクターをエクスポートしたい場合は、下記のように 型の名前(..) と書きます。

module BlackJack ( Card( .. ) )

さて、今回書いたモジュールの宣言では、上記のように Card(..) と書いてCard型の値コンストラクターをすべてエクスポートするのではなく、 Card(A, J, Q, K) と書くことで、 N 以外の値コンストラクターをエクスポートすることにしました。 なぜ N をエクスポートしなかったのでしょうか？

それは、このモジュールのユーザー （このモジュールを import するモジュール） に、あり得ないカードを作らせないためです。

Card 型の定義では、2から10のカードを、 N という値コンストラクターに整数 Int 型の値を紐付けることで表現する、という方法をとりました。 この方法のおかげで、「 N2 | N3 | N4 | N5 | N6 | N7 | N8 | N9 | N10 」のように数字のカードごとに値コンストラクターを作らずに済んだり、カードが取り得る値への変換を容易に書けたりするといった効果があるのでした。

しかし、この方法には、実践編では触れていなかった重要な問題が1つあります。 Int 型の値は、当然、負の数や10より大きい値も取り得るので、たとえば N 3141592 など、あり得ない数字のカードが作れてしまうのです！

この問題を緩和するために、上記のモジュール宣言では N だけをエクスポートしないように設定したのです。 Card(A, J, Q, K) と書くことで、この BlackJack モジュールを使用する側では、値コンストラクター N を直接利用できなくなります。 BlackJack モジュールでエクスポートされている関数や値からしか、値コンストラクター N を使用した値を利用できなくなるのです。

定数の宣言と定義とエクスポート

この BlackJack モジュールでは、値コンストラクター N を直接使用させない代わりに、 heartSuit 、 diaSuit 、 cloverSuit 、 spadeSuit 、 deck という名前の「 Card のリスト」を利用できるようにしています。 そのリストを定義しているのが以下の部分です。

suit, heartSuit, diaSuit, cloverSuit, spadeSuit :: [Card] suit = [A] ++ map N [ 2 .. 10 ] ++ [J, Q, K] heartSuit = suit diaSuit = suit cloverSuit = suit spadeSuit = suit deck :: [Card] deck = heartSuit ++ diaSuit ++ cloverSuit ++ spadeSuit

実践編の復習もかねて、それぞれ簡単に定義を説明しましょう。 上記のHaskellコードでは、 suit 、 heartSuit 、 diaSuit 、 cloverSuit 、 spadeSuit 、 deck という6つの定数を定義しています。

ただし、このうち heartSuit 、 diaSuit 、 cloverSuit 、 spadeSuit の4つは、実態としては suit と同じ値であるとしています。 これは、ハート、ダイヤ、クローバー、スペードというトランプの4種類のスートごとに異なる名前を定義し分けているものの、今のところトランプを表す Card 型にはスートによる区別が一切ないため、いずれも実態としては同じ suit として扱うことにしているだけです。

まったく同じ値であれば、当然のことながら型もまったく同じものになるので、上記の1行めでは下記のように名前をカンマで区切った記法でまとめて宣言しています。

suit, heartSuit, diaSuit, cloverSuit, spadeSuit :: [Card]

suit の定義も見てみましょう。 suit の定義では、基本と実践で学んださまざまな機能を活用しています。

suit = [A] ++ map N [ 2 .. 10 ] ++ [J, Q, K]

まず、 map N [2..10] という式では、実践編で学んだ map 関数を使用しています。 map 関数は、「 <関数> と <リスト> を受け取って、 <リスト> の各要素を <関数> の引数として渡して実行し、 <関数> の実行結果を新しいリストに入れる」という処理を行う関数でした。 ここでは、 <関数> として N を、 <リスト> として [2..10] を渡しています。

関数として map に渡している N は、 Card 型の値コンストラクターの1つです。 下記のように宣言することで、 N が Int -> Card 、つまり「 Int 型の値を受け取って Card 型の値を返す」関数になることを思い出してください。 関数なので、当然のごとく N を map 関数の引数として渡せます。

data Card = A | N Int | J | Q | K

suit の定義に戻りましょう。 map の2つめの引数である [2..10] は、これまで登場していない構文ですが、 「 2 から 10 までの整数のリスト」 （つまり [2,3,4,5,6,7,8,9,10] ） を簡単に作るための構文です。

したがって、 map N [2..10] という式により作られるのは、「 2 から 10 までの整数に対して N を実行し、 2 から 10 までの数字のカードのリスト」ということになります。 1つの suit に含まれる数字 （2～10） のカードをすべて作っているというわけです。

[A] と map N [2..10] 、それに [J, Q, K] の間でそれぞれ使用している ++ は、リストを結合する演算子です。 ここでは、エースを表す [A] というリスト、絵柄のカードを表す [J, Q, K] というリスト、 map N [2..10] で作った数字のカードのリストを結合して、1つの suit に含まれるすべてのカードのリストを作っています。

最後の deck は、トランプのデッキ （ゲームを行うのに必要なすべてのカードをそろえたセット） を表しています。 やはりリストを結合する演算子 ++ を利用して各スート （ heartSuit 、 diaSuit 、 cloverSuit 、 spadeSuit ） を結合し、デッキを作っています。

deck :: [Card] deck = heartSuit ++ diaSuit ++ cloverSuit ++ spadeSuit

モジュールを利用してみよう

説明が長くなりましたが、このように定義されている BlackJack モジュールを実際に利用して試してみましょう。 利用するには、記事の冒頭で git clone してきたリポジトリー（ haskell-as-second-language-blackjack ）に cd した上で、下記のように stack build コマンドを実行してください。

$ stack build

もし次のようなエラーメッセージが表示された場合は、必要なバージョンのGHCがインストールされていません。

No compiler found, expected minor version match with ghc-8.0.2 (x86_64) (based on resolver setting in /home/yu/Downloads/Dropbox/prg/prj/haskell-as-second-language-blackjack/stack.yaml). To install the correct GHC into /home/yu/.stack/programs/x86_64-linux/, try running "stack setup" or use the "--install-ghc" flag.

その場合は、下記を実行してGHCをインストールしてください。

$ stack setup

stack build が成功したことを確認できたら、下記のサンプルプログラムを sample.hs という名前のファイルにコピペしてみてください。

import BlackJack main = print (sumHand [A, J, J])

冒頭で import BlackJack とすることで、このプログラムで BlackJack モジュールを利用可能にしています。

プログラム自体は、手札が [A, J, J] という3つのカードで構成された場合の合計点数を表示するだけの、他愛のないものです。 それでも、 main 関数があるので、基本で説明したように実行可能なプログラムになります （ほかのプログラムに import してもらうモジュールではありません） 。

上記のコードを sample.hs として用意できたら、 stack ghc コマンドでコンパイルしてみましょう。

$ stack ghc sample.hs [1 of 1] Compiling Main ( sample.hs, sample.o ) Linking sample ... $

ちゃんとコンパイルできましたね （コンパイルしたコマンドを実行してみてください） 。

データ型の内部構造を隠蔽する

続いて、 sample.hs を次のように書き換えて、 BlackJack モジュールでエクスポートされていない値コンストラクター N が利用できないことを確認してみます。

import BlackJack main = print (sumHand [A, N 9 , J])

BlackJack モジュールは値コンストラクター N をエクスポートしていないので、 N 9 といった式で BlackJack モジュールがエクスポートしていない値コンストラクターを使用すると、コンパイルエラーになるはずです。 やってみましょう。

$ stack ghc sample.hs [1 of 1] Compiling Main ( sample.hs, sample.o ) sample.hs:3:27: error: Data constructor not in scope: N :: Integer -> Card

not in scope 、すなわち存在しない、というエラーになりました！ 意図したとおり、 BlackJack モジュールの利用者からは値コンストラクター N を隠せているようですね。

このように BlackJack モジュールでは、利用者に直接使用されるとトランプのカードとして不適切なカードが作れてしまう N を隠す （エクスポートしない） ことで、より仕様に忠実な値を提供しています。 Haskellのモジュールという仕組みは、単に名前空間を分けるだけでなく、よくあるオブジェクト指向プログラミング言語における private メソッドのような「データ型の内部構造を隠蔽する」ための仕組みを提供できるのです。

もちろん、ここで紹介した方法が唯一のやり方ではありません。別のやり方でエクスポートする設計もありだと思います。 たとえば、「 Card 型の値はおろか heartSuit なども一切エクスポートせず、 deck のみをエクスポートすることで、 Card 型の値は deck に含まれるもののみが利用できるようにする」というモジュールにすることもできるでしょう。 実際に BlackJack モジュールを使ってブラックジャックのゲームを実装していくなら、それで十分なはずです。

いずれにしても、Haskellでは、モジュールがエクスポートする関数・型・値コンストラクターを限定することで、モジュールの利用者に対してモジュールが満たすべき仕様を確実に守らせたり、モジュールの使い方 （API） をより明確に示したりすることができます。

課題2. NonEmpty を使って、もっとバグが入りにくい実装を目指す

今度は、 sumHand 関数や toPoint 関数の定義や型宣言の改良を通して、「型が持っている制約によって、関数に対する要件を必然的に守らせる」というHaskellの醍醐味を体感していただきます。

sumHand 関数にはどんな問題があるか？

まず、そもそも現状の sumHand 関数にどんな問題が残っているのかをはっきりさせましょう。 sumHand 関数を実装した際、「取り得る点数すべてを組み合わせて、組み合わせごとに合計を求める」処理では、下記のような foldl を使用した式を組み立てました。

scoreCandidates = foldl plusEach [ 0 ] possiblePoints

その際、 foldl の第2引数、すなわち <初期値> の部分を空のリストにすると、 plusEach の中で呼ばれる concatMap が空のリストを受け取ってしまい、そのまま foldl も空のリストを返してしまう、という問題がありました。 この問題は、上記の式における possiblePoints の中に仮に1つでも空のリストが混ざっていた場合、同様に発生します。

でも、実際にはそうはなりません。 なぜなら、 possiblePoints を作成する際に使った toPoint 関数が、空のリストを返さないように作られているからです。

possiblePoints そのものが空のリストであった場合、 foldl plusEach [0] possiblePoints は <初期値> として渡した [0] を返します。 possiblePoints は「 Int のリストのリスト」になっているため少しややこしいのですが、ここでは「 Int のリストのリスト」の要素である「 Int のリスト」のうちいずれか、すなわち map 関数に渡した toPoint が空のリストを返した場合を問題にしているのです。ご注意ください。

確認のために、改めて toPoint 関数の定義を見直してみましょう。

toPoint :: Card -> [Int] toPoint A = [ 1 , 11 ] toPoint (N n) = [n] toPoint _ = [ 10 ]

確かに、この定義を見れば、 toPoint 関数は「いかなる引数を受け取っても空のリストを返すことがない」ことがわかりますね。

しかしながら、このことは、 toPoint 関数の定義を実際に見に行かなければわかりません。

toPoint 関数程度の短い単純な関数であれば、実際に実装を読むのもたいした手間ではないでしょう。 しかし、もっと複雑な関数、それも、あなたではない人が書いた関数について、はたして本当に空リストを返さないかどうかを見極めるのは困難になるでしょう。 これが、現状の toPoint 関数に残されている大きな問題です。

では、このような問題をどのような手段で解消すればいいでしょうか？ Haskellでは、この問題にも「型」を利用した解決方法があります。

もしあなたが toPoint 関数 （あるいは、もっと複雑な関数） を誰か別の人に書いてもらうとして、その人に toPoint 関数の振る舞いを簡単に説明し、 型宣言を教えるだけで「 toPoint 関数が返すリストが空でない」ことまで保証できるなら、もっと安心して toPoint 関数以外の部分の実装に取り組めるでしょう。

そこでここでは、文字通り空でないリストを提供してくれる Data.List.NonEmpty というモジュールを紹介します。

Data.List.NonEmpty モジュールに入っている NonEmpty という型は、「先頭の要素」と「残りの （空かも知れない、普通の） リスト」をそれぞれプロパティとして持つことによって、必ず要素が1つ以上あるリストとなることを保証してくれます。 この NonEmpty を利用して sumHand 関数と toPoint 関数、それに plusEach 関数を書き換えていくことで、「型が持っている制約によって、関数に対する要件を必然的に守らせる」ことを体験してみましょう！

NonEmpty を使う

NonEmpty を使用する準備

さっそく、 Data.List.NonEmpty モジュールから NonEmpty 型を import しましょう。 :| という演算子も合わせてインポートします。

import Data.List.NonEmpty (NonEmpty((:|)))

上記の import 文をコピーして、 blackjack.hs の先頭に貼り付けてください。 それができたら、 stack ghci コマンドを起動し、 blackjack.hs を読み込んで、 NonEmpty 型が使用できるようになったことを :i NonEmpty で確認しましょう。

$ stack ghci > : l blackjack . hs > : i NonEmpty data NonEmpty a = a :| [a] instance Eq a => Eq (NonEmpty a) instance Monad NonEmpty instance Functor NonEmpty instance Ord a => Ord (NonEmpty a) instance Read a => Read (NonEmpty a) instance Show a => Show (NonEmpty a) instance Applicative NonEmpty instance Foldable NonEmpty instance Traversable NonEmpty

上記のように NonEmpty 型の情報が出力されるはずです。 :i NonEmpty を実行した直後に出力される data NonEmpty a = a :| [a] という部分が、 NonEmpty 型の定義です。

ここまでに登場した型とは違う、ちょっと見慣れない定義ですね。詳しく解説しましょう。 あらかじめ注記しておきますが、 NonEmpty の定義はHaskellにおけるほかの型の定義と比べてけっこう変わっています。

NonEmpty の定義

NonEmpty 型は次のような定義になっています。

data NonEmpty a = a :| [a]

data NonEmpty で「 NonEmpty という型の名前を宣言」しているのは、他の型と同じです。 しかし今回は、その後にすぐイコール記号がくるのではなく、小文字の a が続いています。

この a は、 filter 関数の型宣言で出てきた型変数です。 正確には、 NonEmpty 型が受け取る型引数の宣言です。 NonEmpty には、リストと同じように任意の型の要素を格納できなければならないので、「要素の型」として型引数を受け取るものとして定義されています。

続いて、イコールより後ろ、すなわち NonEmpty 型の定義の本体に当たる部分です。ここには、 Card 型を定義したときと同様、 NonEmpty 型の値コンストラクターとその引数が列挙されているはずです。 でも、 a :| [a] という構文のどこをどう読めば、値コンストラクターと引数がわかるのでしょうか……？

実は、 a :| [a] の真ん中にある「 :| 」という、なんだか英語圏の顔文字みたいな記号が、 NonEmpty 型の値コンストラクターの名前です。 そして、この値コンストラクターは、左右にある a および [a] という （型の値の） 2つの引数を取ります。

これだけでは話が見えないと思うので、詳しく説明しましょう。 Haskellは、いろいろな記号を二項演算子として定義できるという、変わった特徴を備えています。しかも、値コンストラクターさえ、記号を使って二項演算子として定義できます。 ただし、値コンストラクターを記号で定義する場合には、必ず名前をコロン : で始めなければなりません。 実際のところ、たまにしか使われない機能なのですが、それが NonEmpty 型の定義では使用されているのです。

つまり、演算子 :| は、左辺に a 型の値、右辺に [a] 型の値 （ a のリスト） を受け取ることで、 NonEmpty 型の値を作り出します。 先ほど、 NonEmpty という型は「先頭の要素」と「残りの （空かも知れない普通の） リスト」をそれぞれプロパティとして持つと述べたとおり、値コンストラクター :| は引数として「 a 型の値」を1つと「そのリスト」を受け取るのです。

実際に NonEmpty 型の値をいくつか作って試してみましょう。

> True :| [True, False] True :| [True,False] > : t True :| [True, False] True :| [True, False] :: NonEmpty Bool > 1 :| [ 1 , 2 , 3 ] 1 :| [ 1 , 2 , 3 ] > : t 1 :| [ 1 , 2 , 3 ] 1 :| [ 1 , 2 , 3 ] :: Num a => NonEmpty a > 'a' :| "abc" 'a' :| "abc" > : t 'a' :| "abc" 'a' :| "abc" :: NonEmpty Char > [] :| [[True]] [] :| [[True]] > : t [] :| [[True]] [] :| [[True]] :: NonEmpty [Bool]

ちゃんと NonEmpty 型の値が作れましたね。

NonEmpty 型の値を操作する関数

続いて、これから使う NonEmpty 型の値を操作するための便利な関数を import します。 先ほど blackjack.hs に追記した import 文に、さらに次の import 文を書き加えてください。

import qualified Data.List.NonEmpty as NonEmpty import Data.Semigroup (sconcat)

上記のうち1つめの import qualified Data.List.NonEmpty as NonEmpty という行では、 Data.List.NonEmpty モジュールを NonEmpty という修飾語で修飾付きインポート （qualifiedインポート） しています。 このように qualified で「修飾付きインポート」をすると、そのモジュールが提供している任意の型や関数を「 モジュール名.名前 」というふうにピリオド付きの表記で参照できるようになります。

したがって、以降では Data.List.NonEmpty の任意の型や関数が NonEmpty.<名前> として参照できるようになります。 Data.List.NonEmpty モジュールでは、 NonEmpty 型の値を扱う関数として、 map や filter といった標準のリストに対する関数と同じ名前のものを数多くエクスポートしています。 NonEmpty 型に対する関数と、標準のリストに対する関数とをプログラムの中で区別できるようにするために、ここでは修飾付き import を使用しました。

2つめの import Data.Semigroup (sconcat) では、 Data.Semigroup モジュールが提供してくれる sconcat という関数を import しています。 Data.Semigroup モジュールは、 NonEmpty 型とよく似たさまざまなデータ構造に対する便利な関数や型クラスを提供してくれます。

今回は、 Data.List.NonEmpty を import するだけでは使えない sconcat という便利な関数が必要だったので import しました。 どんな関数かは後のお楽しみに。

上記の2つの import 文を追加できたら、また :l blackjack.hs して読み込んで試してみましょう。

> : l blackjack . hs > : t NonEmpty.map NonEmpty.map :: (a -> b) -> NonEmpty a -> NonEmpty b > NonEmpty.map not (True :| [False]) False :| [True] > : t sconcat sconcat :: Data.Semigroup . Semigroup a => NonEmpty a -> a

上記のように NonEmpty.map 関数や sconcat 関数などが利用できるようになっていればOKです！

toPoint 関数を書き換える

それでは、いよいよ NonEmpty 型を使って sumHand 関数や toPoint 関数を改良してみましょう。 まずは toPoint 関数です。

toPoint :: Card -> [Int] toPoint A = [ 1 , 11 ] toPoint (N n) = [n] toPoint _ = [ 10 ]

ここで NonEmpty 型を使う動機を思い出しておきましょう。 「 toPoint 関数が必ず空でないリストを返すことを、 NonEmpty 型に書き換えることにより、型レベルで保証したい」というのが動機です。 そこで、まずは型宣言から書き換えてみます。

toPoint :: Card -> NonEmpty Int toPoint A = [ 1 , 11 ] toPoint (N n) = [n] toPoint _ = [ 10 ]

NonEmpty 型の値を返すように関数の本体を書き換える

当たり前ですが、上記のように型宣言を書き換えただけの状態では型エラーになってしまいます。

stack ghci コマンドを使って再度 blackjack.hs を読み込むと、次のような内容のコンパイルエラーがいくつも出てきて圧倒されると思います。

... blackjack.hs:1404:13: error: • Couldn't match expected type ‘NonEmpty Int’ with actual type ‘[Integer]’ • In the expression: [1, 11] In an equation for ‘toPoint’: toPoint A = [1, 11] ...

これは、 Couldn't match expected type ‘NonEmpty Int’ とあるとおり、「型宣言に書いた型と実際に toPoint 関数が返している型が違うよ！」というエラーです。 上記以外にも似たようなエラーメッセージが出てくると思いますが、だいたい同じ内容になっているはずです。

上記のエラーメッセージの3行めで actual type （すなわち toPoint 関数が実際に返す型） が [Int] ではなく [Integer] となっているのは、 Num 型クラスのデフォルトが Integer となっているためなので、安心してください。 基本で説明したとおり、整数のリテラルの型が決定できない場合にはデフォルトとして Integer を採用する、というルールでした。

エラーの原因は、明らかに、 toPoint 関数の返す値が普通の （空かもしれない） リストのままであることです。そこで、空ではないリスト、つまり NonEmpty 型の値を返すように関数の本体を書き換えましょう。

NonEmpty 型の値を作るには、演算子 :| の左辺に先頭の値、右辺に残りの （空かもしれない） 普通のリストを渡せばよいのでした。

toPoint :: Card -> NonEmpty Int toPoint A = 1 :| [ 11 ] toPoint (N n) = n :| [] toPoint _ = 10 :| []

普通のリスト用の関数を NonEmpty 型用の関数に切り替える

上記のように toPoint 関数を書き換えたら、再び blackjack.hs を読み込んでみましょう。 今度はコンパイルエラーが次のように変わったかと思います。

blackjack.hs:1439:44: error: • Couldn't match type ‘NonEmpty Int’ with ‘[Int]’ Expected type: [[Int]] Actual type: [NonEmpty Int] • In the third argument of ‘foldl’, namely ‘possiblePoints’ In the expression: foldl plusEach [0] possiblePoints In an equation for ‘scoreCandidates’: scoreCandidates = foldl plusEach [0] possiblePoints

これは、「 map toPoint cards の結果を入れた変数 possiblePoints の型が異なる」というエラーです。 ここでは、 foldl の3つめの引数が [[Int]] （ Int のリストのリスト） であることを期待しているので Expected type: [[Int]] と表示されています。 Actual type: [NonEmpty Int] というのは、実際の possiblePoints の型が [NonEmpty Int] （「 Int の空ではないリスト」のリスト） であることを指しています。

今度のエラーはどうやって直せばよいでしょうか？ エラーメッセージによると、コンパイラが「期待している型 （ Expected type ） 」は [[Int]] （ Int のリストのリスト） です。 しかし、今実際に私たちがやっているのは、普通のリストを使わず、空ではないリスト [NonEmpty Int] を使うための書き換えです。 したがって、間違っているのは [[Int]] 、すなわち foldl の引数の型のほうです。 というわけで、 foldl の引数の型が possiblePoints の型 [NonEmpty Int] となるよう、 foldl の他の引数の型を変えていきましょう。

まず、 foldl の第1引数、すなわち <関数> を変えます。 ここでは、 plusEach という関数によって、「リストの各要素の組み合わせごとの和」を計算していたのでした。

plusEach :: [Int] -> [Int] -> [Int] plusEach list1 list2 = concatMap ( \ element1 -> map ( \ element2 -> element1 + element2 ) list2 ) list1

foldl の仕様上、 foldl に渡す <関数> は、第3引数である <リスト> の各要素の型を受け取れなければなりません。 ここでの <リスト> とは possiblePoints であり、その型は [NonEmpty Int] でした。 したがって、要素の型は NonEmpty Int となります。 というわけで、 plusEach 関数の型を書き換えて、下記のように NonEmpty Int を扱うようにしましょう。

plusEach :: NonEmpty Int -> NonEmpty Int -> NonEmpty Int

foldl の仕様についてしっかりと理解している方は、実際に変更する必要があるのは第2引数の型だけであることにお気づきかもしれません。 しかし、ここでは個人的な好みにより、第1引数も戻り値の型も NonEmpty Int に変えています。

もちろん、型を変えるだけではダメで、 plusEach 関数が実際に使用する関数を NonEmpty 型向けの関数に変えたり、戻り値を NonEmpty 型の値にするよう、 plusEach 関数の本体も書き換える必要があります。 具体的には、これまで使用していた map 関数を、 NonEmpty 型用の NonEmpty.map に変えてください。

concatMap も変える必要がありますが、残念ながら Data.List.NonEmpty モジュールには concatMap に相当するものが存在しません。 そこで、 NonEmpty.map と、 Data.Semigroup から import した sconcat とを組み合わせることで、 NonEmpty 向けの concatMap を実装します。 sconcat は、普通のリストで言うところの concat に相当します。

以上をまとめると、 plusEach の実装は次のように書き換えられます。 複雑で面倒なことをしているように思えるかもしれませんが、ここでやっていることは、基本的には「普通のリスト用の関数を NonEmpty 型用の関数に切り替える」だけです。

plusEach :: NonEmpty Int -> NonEmpty Int -> NonEmpty Int plusEach list1 list2 = sconcat ( NonEmpty.map ( \ element1 -> NonEmpty.map ( \ element2 -> element1 + element2 ) list2 ) list1 )

最後の型エラーを解決する

上記のように書き換えられたら、再び blackjack.hs をGHCiに読み込ませてみましょう。 まだコンパイルエラーが出るはずですが、エラーの内容はどのように変わったでしょうか？

blackjack.hs:1548:40: error: • Couldn't match expected type ‘NonEmpty Int’ with actual type ‘[Integer]’ • In the second argument of ‘foldl’, namely ‘[0]’ In the expression: foldl plusEach [0] possiblePoints In an equation for ‘scoreCandidates’: scoreCandidates = foldl plusEach [0] possiblePoints blackjack.hs:1550:31: error: • Couldn't match expected type ‘[a]’ with actual type ‘NonEmpty Int’ • In the second argument of ‘filter’, namely ‘scoreCandidates’ In the expression: filter (<= 21) scoreCandidates In an equation for ‘noBust’: noBust = filter (<= 21) scoreCandidates blackjack.hs:1555:17: error: • Couldn't match expected type ‘[Int]’ with actual type ‘NonEmpty Int’ • In the first argument of ‘head’, namely ‘scoreCandidates’ In the expression: head scoreCandidates In the expression: if null noBust then head scoreCandidates else maximum noBust

今度は、 sumHand 関数の中で直接使っている foldl 関数と filter 関数、 head 関数についてのエラーがでました。

foldl 関数については、コンパイラが NonEmpty を期待 （ expected ） しているところで実際 （ actual ） には普通のリストが渡されているので、エラーになっているようです。

filter 関数と head 関数については、コンパイラが普通のリストを期待 （ expected ） しているところで実際 （ actual ） には NonEmpty 型が渡されているので、エラーとしているようです。

いずれも、普通のリストと、「空ではないリスト」 NonEmpty とを取り違えていることによるエラーのようです。 繰り返しになりますが、私たちは今、普通のリストを使わずに「空ではないリスト」を使用するよう書き換えているところなので、いずれも「空ではないリスト」 NonEmpty を使うように寄せましょう。

foldl 関数のエラーについては、引数が普通のリスト [0] になってしまっているのが問題なので、 [0] を「空ではないリスト」 NonEmpty に変えてください。 「先頭の要素として 0 だけを持つ、空ではないリスト」なので、 (0 :| []) と変えるのが正解です （丸カッコで囲うのを忘れないでください！） 。

filter 関数と head 関数のエラーについては、引数が「空ではないリスト」 NonEmpty となっているため、引数を受ける関数のほうを NonEmpty 向けのものに変えればよいでしょう。 したがって、 NonEmpty. を頭に付けたバージョン、 NonEmpty.filter 関数と NonEmpty.head 関数に書き換えるのが正解です。

sumHand :: [Card] -> Int sumHand cards = let possiblePoints = map toPoint cards scoreCandidates = foldl plusEach ( 0 :| []) possiblePoints noBust = NonEmpty.filter ( <= 21 ) scoreCandidates in if null noBust then NonEmpty.head scoreCandidates else maximum noBust

さあ、これでエラーメッセージはどう変わるでしょうか？ 今度こそエラーがなくなるといいですね！

> : l blackjack . hs [ 1 of 1 ] Compiling Main ( blackjack . hs, interpreted ) Ok, modules loaded : Main .

エラーがなくなりました！ どうやらすべての型エラーを解決できたようです！

ここまで読んで、「 maximum noBust の部分は NonEmpty 向けに書き換える必要はないの？」という疑問を持つ人もいるかも知れません。 実際、普通のリスト向けの maximum 関数のままでコンパイルエラーが出ていないとおり、ここを NonEmpty 向けの maximum に変えてはいけません （そもそも、困ったことに、なぜか Data.List.NonEmpty モジュールには maximum 関数がありません……） 。 なぜなら、 NonEmpty.filter (<= 21) scoreCandidates の結果、すなわち noBust 変数の型は、普通の （空かもしれない） リストとなっているためです。 GHCiで NonEmpty.filter の型を確認しても、やはり NonEmpty.filter 関数は普通の （空かもしれない） リストを返すことになっています。 > : t NonEmpty.filter NonEmpty.filter :: (a -> Bool) -> NonEmpty a -> [a] どういうことかというと、理由は単純で、「 filter 関数で条件にマッチする要素を探した結果、1つもマッチする要素がなかったために、空のリストを返さざるを得ない」ということが起こりうるためです。 関数の型が関数の性質を簡潔に示す、よい例と言えるでしょう。

toPoint 関数の定義をわざと間違えてみる

ここまで、「 sumHand 関数が toPoint 関数に対して要求する性質を型レベルで必然的に守らせる」べく、 toPoint 関数の戻り値の型を変える修正をしてきました。 本当にそうなっているか試すため、わざと toPoint 関数が空のリストを返すように変更してコンパイルエラーが出ることを確認しましょう。

toPoint :: Card -> NonEmpty Int toPoint A = 1 :| [ 11 ] toPoint (N n) = n :| [] toPoint _ = []

上記のように toPoint 関数を書き換えた上で、GHCiに blackjack.hs を再読み込みさせてください。 おおむね想定通りのエラーメッセージが出るかと思います。

blackjack.hs:1571:13: error: • Couldn't match expected type ‘NonEmpty Int’ with actual type ‘[t0]’ • In the expression: [] In an equation for ‘toPoint’: toPoint _ = []

このように、間違った処理を書いた場合にコンパイルエラーを出すよう型を設計することで、実装する前にバグを根っこから摘むことができます。 実践するのは言うほど簡単ではありませんが、これからHaskellをもっと学んで複雑なアプリケーションを作ってみたいと考えたとき、ぜひこの記事を思い出してみてください！

最後に、修正後の各関数を下記にすべて再掲しておきます。

plusEach :: NonEmpty Int -> NonEmpty Int -> NonEmpty Int plusEach list1 list2 = sconcat ( NonEmpty.map ( \ element1 -> NonEmpty.map ( \ element2 -> element1 + element2 ) list2 ) list1 ) toPoint :: Card -> NonEmpty Int toPoint A = 1 :| [ 11 ] toPoint (N n) = n :| [] toPoint _ = 10 :| [] sumHand :: [Card] -> Int sumHand cards = let possiblePoints = map toPoint cards scoreCandidates = foldl plusEach ( 0 :| []) possiblePoints noBust = NonEmpty.filter ( <= 21 ) scoreCandidates in if null noBust then NonEmpty.head scoreCandidates else maximum noBust

【超発展編】もっと勉強したい人のために！

ここまで3回にわたり、主に第二言語としてHaskellを学ぼうという人向けに、Haskellらしいプログラムの書き方を知ってもらうことに主眼を置いて解説してきました。

Haskellには、この3回分の記事だけでは紹介できなかった機能もたくさんあります。 最後に、そのような機能を簡単に紹介して締めくくりたいと思います。

読んでおきたい解説書や入門コンテンツ

まず、Haskellそのものの解説書をいくつか紹介しておきます。

比較的新しい入門書として、『Haskell入門 関数型プログラミング言語の基礎と実践』『関数プログラミング実践入門──簡潔で，正しいコードを書くために』『Haskell 教養としての関数型プログラミング』の3冊があります。

とくに『Haskell入門』は、2017年9月に発売されたばかりです。実際にアプリケーションを作るところまでサポートした、かなり意欲的な内容となっています。 筆者もレビュワーとして参加しました。

上記の3つより数年前に出版され、定番の入門書となっているのが『すごいHaskellたのしく学ぼう！』です。

筆者はこの本から始めました。残念ながら古くなってしまった内容もありますが、今でも参考になる部分が多いと思います。

ウェブ上の入門コンテンツとして、下記のページもおすすめです。

Haskell 超入門 - Qiita

各機能の使用方法を素早く学習することにフォーカスしているので、新しいプログラミング言語の勉強に慣れている人にはとっつきやすいかもしれません。

発展編でも取り上げられなかった高度な機能

Haskellや、そのデファクトスタンダードなコンパイラであるGHCには、本稿や各種の入門書ではカバーしきれない数多くの高度な機能があります。 ここでは、そうした機能をかいつまんで紹介します。

ただし、Haskellの高度な機能の話に触れる前に心にとどめていただきたいのは、次の大原則です。

すべてを完全に理解しようとは思わないでください。

これはHaskellに限った話ではありませんが、HaskellやGHCのコミュニティーでは、新しいライブラリーや機能が非常に盛んに開発され、議論されています。 加えて、もともとプログラミング言語の研究者が作った言語という背景があるためか、Haskellは歴史的にプログラミング言語についての研究の実験場といった側面もあります。

そのため、研究者でもなければ聞いたことがないような新しい概念が次々と登場します。 そして、Haskellの型システムをより柔軟でより安全にしたり、構文をより簡潔にしたりする機能として、後述するGHCの言語拡張やライブラリーに実装されていきます。

言語拡張

この先、Haskell製のライブラリーのソースコードやサンプルコードを眺めていると、ファイルの冒頭で次のような表記をたくさん目にすると思います。

{-# LANGUAGE FlexibileInstances #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeFamilies #-} ...

これらは、言語拡張と呼ばれている機能を有効にする、特殊なコメントです。 言語拡張は、Haskellの標準として定められた機能に対して、 （主に） GHCが加えた新しい機能です。

言語拡張は、初見では何の目的で有効にされたのかわからず、面食らってしまうことも多いと思います。 しかし、ご安心ください。 一部を除いて、言語拡張を有効にして起こる悪影響はあまりありません。

GHCが提供するHaskellに対する言語拡張は、ライブラリーが必要としているものであればとりあえずコピペして使いましょう。

stackとcabal

Haskell製のプロジェクトを本格的に作っていこうとすると、3回の記事で触れた以上に、StackとCabal （パッケージシステム） の使い方を学ぶ必要が出てきます。 さらなる使用法を簡単に知るには、手前味噌ですが下記の記事が参考になります。

Stackでやる最速Haskell Hello world! (GHCのインストール付き！) - Qiita

tanakhさんによる下記の記事もお勧めです。

Haskellのビルドツール"stack"の紹介 - Qiita

Stackを使って楽しくHaskellスクリプティング - Qiita

Lens

Lensは、「任意のデータ構造に対するjQuery」ともいわれる巨大なユーティリティーライブラリーです。 さまざまなデータ構造、特に複雑に入れ子になった構造に対して自由自在にアクセスしたり、中身を一部だけ書き換えたバージョンを返したりといった処理を、非常に簡潔に書くことができます。

日本語の入門資料としては、ちゅーんさんによる「LensでHaskellをもっと格好良く！」という発表資料がおすすめです。 Lensを導入するモチベーション、基本的な使い方、さらにLensの仕組みまで踏み込んで説明されています。

2013年3月31日 Ekmett勉強会発表資料

▽ LensでHaskellをもっと格好良く！ （2015年5月30日 Lens&Prism勉強会発表資料）

なお、Haskellでは型が重要なドキュメントになるといわれますが、Lensをはじめ高度なライブラリーでは型宣言が非常に複雑で、 （少なくとも慣れるまでは） 型を見てもまったく理解できないことが少なくありません。 型を見るだけでは機能を理解できないライブラリーに挑むときは、サンプルコードや使い方をよく見てください。

逆に、型はわかるけど使い方がよくわからない場合には、推測して型が合うような組み合わせをいろいろ試してみるとよいでしょう。 それでもわからなければ、無理にそのライブラリーの機能を使おうとしないことです。

いずれにせよ、型を見てわからなくても、慌てる必要はありません。 どうしても使ってみたい場合は、後述するHaskell-jp （日本Haskellユーザーグループ） をはじめとするコミュニティーに助けを求めるのがおすすめです。

Monad（挑戦したけどよく分からなかった人向け）

Monad （モナド） と、Monadの扱いを楽にする do 記法は、 （Haskellでは普通の） 純粋な関数の組み合わせだけでは実装が困難な一部の処理を書くのに便利な仕組みです。 たとえば、下記のような処理は一見すると何も共通点がなさそうですが、全部同じMonadとして、 do 記法で実現できます。

入出力処理

処理が失敗した場合などにブロックから脱出すること

何重にもネストされた map 関数や concatMap 関数をフラットにすること

関数や 関数をフラットにすること ブロックで暗黙に共有している変数の参照や更新 （ダイナミックスコープのシミュレーション）

Monadは、「圏論」という数学の一分野における「モナド」という概念に由来してはいるものの、HaskellのMonadを使いこなすために圏論を理解する必要はまったくありません。 「プログラミングのためのモナド」として、圏論のモナドとは分けて考えてもいいぐらいでしょう。

残念ながら、MonadはHaskellの学習における鬼門とも言われています。 解説記事もたくさんあります。 なかでもお勧めは、筆者自身によるものも含め、下記の4つの記事です。

純粋にMonadの使い方のみを理解したい場合は、このセクションの冒頭で挙げた「Haskell超入門」における「Haskell アクション 超入門」以降の回を読んでみるのもよいでしょう。

Monad Transformer

Monadを使いこなせるようになると、複数のMonadの機能を同時に （同じ do ブロックで） 使用したくなることがあります。 Monad Transformerは、既存のMonadを組み合わせることで、そうしたニーズを満たすための仕組みです。

Monad Transformerの比較的わかりやすい解説としては、「Monad Transformers Step by Step （原文PDF） 」という英文記事が有名です。日本語訳も下記にあります。

モナドトランスフォーマー・ステップ・バイ・ステップ(Monad Transformers Step By Step) - りんごがでている

ドキュメントやパッケージの調べ方

実際にHaskellでプログラムを書いていくと、すでに用意されている関数で欲しい型のものを知りたいといったケースが頻繁に出てくるでしょう。 そのような場合に便利なのが下記のサイトです。

いずれも、関数の名前だけでなく、関数の型から調べることができる、ほかのプログラミング言語ではちょっと珍しいタイプのドキュメント検索エンジンです。

たとえば filter 関数の型は (a -> Bool) -> [a] -> [a] ですが、 これを上記のHoogleで検索してみると、 filter 関数以外にもたくさんの関数がヒットするのがわかります。

ただ、詳細は割愛しますが、3つとも収録されている内容が異なっています。 どれを使うべきか簡単に言うと、本シリーズで紹介したstackを主に使って開発する場合には、StackageのHoogleを使うのが個人的なおすすめです （筆者自身これを最もよく使います） 。

このあたりの使い方については、筆者が以前書いた次の記事が参考になると思います。

最近のHaskell製パッケージのドキュメント事情について簡単に - Qiita

サポートするコミュニティ

本シリーズで取り上げたことや、ここまで説明した新しいトピックなど、Haskellについて勉強していて困ったことが何かあれば、我らが日本Haskellユーザーグループ （Haskell-jp） という、Haskellユーザーのコミュニティーグループに助けを求めることを推奨します。

日本Haskellユーザーグループ （Haskell-jp）

Haskell-jpは、2017年の4月末に発足した、「日本におけるプログラミング言語Haskellの普及活動と、Haskellを利用する人々のサポートを行うグループ」です。 現在は、主に下記の活動を通して、Haskellユーザー （読者の皆さんも含まれます！） をサポートしたり、Haskellに関する情報を日本語で配信したりしています。

Redditでのサポート・議論

Redditは、日本ではあまり利用者が多くないですが、世界のすべてのウェブサイト全体で9番目に利用されている、巨大掲示板サービスです。 Haskell-jpでは、Redditの特徴がオープンな議論に向いていると考え、subreddit （Redditにおける、スレッドのグループ） を作りました。

「Haskellに関するこんなウェブページを見つけた」、「Haskellについてこんな質問がある」など、Haskellに関する話題であればどんなものでも気軽に投稿してみてください！

勉強会・イベント

以下は、Haskellに関する継続的に行われている勉強会で、筆者が把握しているものです。

Q&Aサイト

おなじみスタック・オーバーフローとteratailにおける、Haskellタグがついた質問です。

上記2つの質問サイトにHaskellタグのついた質問を投稿すると、次項で紹介するSlackチームの #questions-feed-jp チャンネルに自動で通知されるので、きっとサポートされやすいでしょう。

SlackとWiki

Haskell-jpが運営するSlackチームです。 こちらはRedditよりもゆるい感じで投稿されています。

Haskell-jpが運営しているWikiもあります。GitHubアカウントがあればどなたでも書き込めるようになっています。

中でもHaskellに関する日本語のリンク集には、今回紹介しなかったものも含め、さまざまなウェブサイトが紹介されています。 ぜひ一度覗いてみてください。

情報収集に役立つサイト

執筆者プロフィール

山本悠滋 （やまもと・ゆうじ） @igrep igrep id:igrep

関連記事

Haskellらしさって？「型」と「関数」の基本を解説！【第二言語としてのHaskell】

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

いま学ぶべき第二のプログラミング言語はコレだ！ 未来のために挑戦したい9つの言語とその理由