HaskellのApplicative(アプリカティブ)の説明を読むと、まあ使い方はわかるのだけど、なんだか意味がしっくりこないモヤモヤが残るので、Gemini先生に教えを請うた。
少し霧が晴れた気がするので、貼り付けておく。
Haskellの Applicative(アプリカティブ)は、一言でいうと「文脈(コンテキスト)に入った関数を、文脈に入った値に適用する」ための仕組みです。
「使い方はわかるけど意味がしっくりこない」という状態、非常によく分かります。Maybe やリストで Just (+1) <*> Just 2 のようなコードを見ても、「それ fmap やパターンマッチで良くない?」と思ってしまいますよね。
Applicative の真価(意味)が最もスッキリ理解できるのは、「複数の『失敗するかもしれない値』を組み合わせて、新しい値を作るとき」です。
理解を深めるための、実用的でちょうどいい難易度の例題を用意しました。一緒に解きながら意味を落とし込んでいきましょう!
例題:ユーザー登録のバリデーション(検証)
Webアプリのユーザー登録画面をイメージしてください。
ユーザーから入力された「名前」と「年齢」を使って、システムに登録するための User データを型安全に作りたいとします。
1. 準備(データの定義)
まずは必要な型を定義します。
-- 最終的に作りたいクリーンなデータ構造
data User = User { name :: String, age :: Int } deriving Show
-- バリデーション用関数(条件に合わない場合は Nothing を返す)
-- 1. 名前は空であってはならない
checkName :: String -> Maybe String
checkName "" = Nothing
checkName s = Just s
-- 2. 年齢は0歳から120歳まで
checkAge :: Int -> Maybe Int
checkAge n | n >= 0 && n <= 120 = Just n
| otherwise = Nothing
2. 問題:これらを組み合わせて makeUser を作成せよ
画面から入力された未検証のデータ rawName と rawAge を受け取り、両方のチェックをパスした場合のみ Just User を返し、どちらか一方でも失敗したら Nothing を返す関数 makeUser を作ってください。
makeUser :: String -> Int -> Maybe User
makeUser rawName rawAge = ???
解説:なぜ Applicative なのか?
この問題を解くアプローチを3ステップで見ていくと、Applicative の意味が「これしかない!」というレベルでしっくりきます。
アプローチA:パターンマッチ(泥臭い方法)
まずは Applicative を使わずに書くとどうなるでしょう。
makeUserPattern :: String -> Int -> Maybe User
makeUserPattern rawName rawAge =
case checkName rawName of
Nothing -> Nothing
Just n -> case checkAge rawAge of
Nothing -> Nothing
Just a -> Just (User n a)
「もし名前がOKなら、次は年齢をチェックして…」と、いわゆるコールバック地獄(ネスト)になってしまいます。非常に面倒ですね。
アプローチB:fmap (<$>) だけを使ってみる
「Haskellには fmap があるじゃないか!」ということで、User 関数(中身は String -> Int -> User という2引数関数)に、まず名前のチェック結果を適用してみます。
-- checkName rawName の結果が Just "Alice" だとすると...
User <$> checkName rawName -- 結果は: Just (User "Alice") という「関数が入ったMaybe」
ここで行き詰まります。手元にあるのは Just (User "Alice") という「関数がMaybeに包まれたもの」と、checkAge rawAge という「値がMaybeに包まれたもの」です。
fmap(<$>)は、「普通の関数」を「文脈付きの値」に適用する道具なので、「文脈に入った関数」を適用することができません。
アプローチC:ここで登場するのが Applicative (<*>)!
「文脈に入った関数」を「文脈に入った値」に適用したい。これこそが Applicative の存在意義です。
makeUser :: String -> Int -> Maybe User
makeUser rawName rawAge = User <$> checkName rawName <*> checkAge rawAge
意味のイメージ図
-
User <$> checkName rawName
⇒Maybeという箱の中に、「年齢を入れてくれればUserを作るよ」という状態の関数が入る。 -
<*> checkAge rawAge
⇒Maybeの箱に入った関数と、Maybeの箱に入った年齢を、箱から出さずに合体させる。
もし checkName が Nothing(エラーの箱)を返していたら、その後の <*> は何もせず、最終結果も自動的に Nothing になります。
演習問題:さらに項目を増やしてみよう
Applicative の本当の美しさは、検証する項目がいくつ増えてもコードの形が変わらないことにあります。
問題
User 型に「メールアドレス(String)」を追加したくなりました。
以下のコードの ??? を埋めて、makeUser2 を完成させてください。
data User2 = User2 { name2 :: String, age2 :: Int, email2 :: String } deriving Show
-- メールアドレスは '@' を含んでいなければならない
checkEmail :: String -> Maybe String
checkEmail s | '@' `elem` s = Just s
| otherwise = Nothing
makeUser2 :: String -> Int -> String -> Maybe User2
makeUser2 rawName rawAge rawEmail = ???
ヒント
パターンマッチ(case文)で書こうとすると、ネストが3重になって発狂しそうになりますが、Applicative なら <*> を1つ繋げるだけです。
この「複数の文脈付きの値を、一つの関数にまとめて流し込む(ただし途中で一つでも失敗したら全体が失敗になる)」という一連の流れの制御こそが、Applicative の持つ「意味」です。
演習問題の解答コードを考えてみて、しっくりくるか試してみてください!