🖋️

Elmで入門する関数型プログラミングの世界

2023/12/08に公開2

関数型プログラミング初心者が純粋関数型言語であるElmに入門してみた備忘録です。

本記事の成果物はこちら

https://github.com/JY8752/elm-demo-app

対象読者

  • 関数型プログラミングを学びたいと思ってる方
  • Elmを学びたいと思ってる方

モチベーション

筆者は普段サーバーサイドエンジニアとしてコードを書いていますが最近設計やテストについて学習する中で質の良い単体テストを書きたいという気持ちが強くなってきました。

質の良い単体テストを書くにはどうすればいいかを突き詰めた結果副作用のない関数設計をしたくなり、関数型プログラミングに入門してみることにしたというのがモチベーションです。

なのでゴールとしては単体テストが書きやすいような関数設計をするための何かしらを今回の入門で感じれればいいかなという感じです。

なぜElm?

筆者は以前「JavaScript関数型プログラミング」という技術書を読み、Kotlinで関数型プログラミングをするためのArrowというライブラリを使用し、関数型プログラミングに入門したことがあります。

https://zenn.dev/jy8752/articles/3fdbd84b9710e1

しかし、実際に書いてて思ったのです。「書いてるコードがちゃんと関数型っぽく書けてるのかわからん!!」と。

なんか学んだことを実践してみただけで、よくわからなかったのが正直なところです。

そんなとき、「マルチパラダイムな言語で関数型をやると正解かわからないから純粋関数型言語で入門した方がいい」というアドバイスをもらいまして、さらにElmがおすすめということで機会があったらElmに入門しようと思ったのがElmに入門することにしたきっかけです。

以下の記事の作者様からアドバイスをいただきました!(とても納得感のある素晴らしい記事でした。)

https://zenn.dev/ababup1192/articles/fb25358a570763

Elmとは

Elmの公式ドキュメントの日本語訳では以下のように書かれています。

Elm は JavaScript にコンパイルできる関数型プログラミング言語です。 ウェブサイトやウェブアプリケーションを作るのに役立ちます。Elm はシンプルであること、簡単に使えること、高品質であることを大切にしています。

上記の通り、ElmはWebアプリケーションの画面を構築するための純粋関数型言語です。現代のフロントエンド開発ではReactやNextといったフレームワークを使用した開発が主流だと思いますが、ElmはThe Elm Architecture(TEA)という設計パターンで開発する言語です。

https://guide.elm-lang.jp/architecture/

簡単にまとめるとElmは以下のような特徴があります。

  • 純粋関数型言語
  • TEAというシンプルで安全に画面構築ができるためのアーキテクチャが組み込まれている
  • 言語仕様がシンプルで学習しやすい

Elmは新しい機能や言語仕様を追加することに非常に慎重なように感じました。これは筆者が普段書いているGo言語に似た思想を感じ、非常に好感が持てました。(言語仕様がシンプルで学びやすいことは良いこと。)

本記事ではElmの基本文法などの説明はしませんので、Elmを学びたい方はまずは以下の公式ドキュメントを一読することをおすすめします。すぐ読めるくらいの分量ですし、Elmコミュニティの方々が公式ドキュメントを日本語に翻訳してくれている大変ありがたいドキュメントです。

https://guide.elm-lang.jp/

(本格的にElmのコードを読んでいるとおそらくlet-in|><|といったパイプ演算子、ラムダ式などが登場してきますが上記のサイトはElmに入門するための手引きをしてくれるガイドなので詳しくElmの文法を知りたい方は以下のサイトなどを参照してください。)

https://elm-lang.org/docs/syntax

webアプリケーションを作る

さっそくですがElmでwebアプリケーションを作成していきたいと思います。Elmや関数型言語に入門したいという方はまずは上記の公式ドキュメントでElmの書き方やTEAの仕組みを学ぶことをおすすめします。可能であれば、入門書レベルでいいので何かしらの関数型プログラミングの技術書などを読んでおくと理解が進むと思います。最低限関数型プログラミングの登場人物(関数合成とか副作用とかモナドとかカリー化とか)が何かなんとなく知ってるとElmを書きながら答え合わせのようなことができると思います。

今回はいつも作っているガチャのシミュレーションアプリを作成したいと思います。

セットアップ

とりあえず、Elmのインストールします。

以下からインストーラー経由でインストールします。

https://guide.elm-lang.jp/install/elm.html

% elm --version
0.19.1

# nodeも必要なのでインストールされてない方はインストール必要です
% node -v
v20.6.1

次にVSCodeの拡張機能を入れていきます。VSCode以外のエディタを使用している方はこちらを参照してください。

拡張機能はこちら

https://marketplace.visualstudio.com/items?itemName=Elmtooling.elm-ls-vscode

(elmで検索すると別の拡張機能がヒットするかもしれませんが非推奨になっているのでインストールするのはElm toolingから出ている方。)

フォーマット用に以下もインストール

% npm install -g elm-format

プロジェクトの作成にはいろいろ方法があるみたいで以下の記事が参考になりました。

https://zenn.dev/y047aka/articles/install-elm-2021

ざっくり以下のような方法がたぶんあります。

  • elm reactor elmに組み込まれてるのですぐ使える。
  • create-elm-app create-react-appのElm版。動かしてみたけど起動時にエラー吐いたのでやめた
  • vite 公式のテンプレートはないのでコミュニティ提供のテンプレートを使うか、自力で構築
  • elm-spa ElmでSPAやるなら。
  • elm-pages ElmでSSG的なことやりたいとき。

elm-spaelm-pagesは今回の入門にはやりすぎ感があったのと、一応見た目を整えたく慣れてるtailwindを採用することも考えて今回はviteを採用しました。

viteとTailwindを使うには以下の記事を参考にさせていただきました。

https://zenn.dev/g4yamanaka/articles/50ccec23caf176

https://zenn.dev/ababup1192/articles/a51c8e2ddcde77

% bunx create-vite --template vanilla-ts demo-app
% elm init
% bun i -d vite-plugin-elm

特に意味はないですがbunを使用してcreate-viteを実行します。テンプレートはvanilla-tsを使用します。次にelmの方の初期化でelm initを実行します。これでelm.jsonが作成されると思います。Elmで書いたコードをビルドに組み込むためにvite-plugin-elmをインストールします。インストールが完了したら以下のconfigファイルを作成して完了です。

vite.config.ts
import { defineConfig } from "vite";
import elmPlugin from "vite-plugin-elm";

export default defineConfig({
  plugins: [elmPlugin()],
});

次にmain.tsを以下のように修正します。

main.ts
import { Elm } from "./Main.elm";

Elm.Main.init({ node: document.querySelector("#app") });

これでElmで書いたプログラムを組み込むことができましたが、Elmモジュールの型定義がなくてエラーが出ていると思うので以下のように型定義ファイルも作成します。

Main.elm.d.ts
export var Elm: any;

(型付は今回anyにしてしまっていますがより厳密に型定義することも可能なようです。こちらを参照)

次にMain.elmファイルを作成してとりあえず公式ドキュメント記載のカウンタープログラムをコピペして動かしてみます。

Main.elm
Main.elm
import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)

main =
  Browser.sandbox { init = 0, update = update, view = view }

type Msg = Increment | Decrement

update msg model =
  case msg of
    Increment ->
      model + 1

    Decrement ->
      model - 1

view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]
% bun dev

tailwindの導入

tailwindを以下のようにインストールします。

% bun i -d tailwindcss@latest postcss@latest autoprefixer@latest

設定ファイルを作成

% bunx tailwindcss init -p

作成できたらtailwind.config.jsを以下のように修正

tailwind.confgi.js
module.exports = {
  content: ["index.html", "./**/*.{css,ts,elm}"],
  theme: {
    extend: {},
  },
  plugins: [],
}

最後にmain.tsにtailwindをimportするように修正して完了です。

main.ts
import { Elm } from "./Main.elm";

++ import "tailwindcss/tailwind.css";

Elm.Main.init({ node: document.querySelector("#app") });

あとはVSCodeを使用している方はElmを書いている時にtailwindの補完を効かせたいと思いますのでこちらを参照して設定することをおすすめします。

Elmのプログラムを書く

いよいよ本題のElmのプログラムを作成していきます。まずは以下のようにカスタム型と型エイリアスを定義しました。

型定義

Main.elm
type Rarity
    = N
    | R
    | SR


type alias Item =
    { rarity : Rarity
    , name : String
    , weight : Int
    }



-- Model


type alias Model =
    { result : Maybe Item
    }



-- Msg


type Msg
    = Draw
    | GenerateRandomValue Int

Item型がガチャから排出されるアイテムを表しています。今回はレア度とアイテム名だけ設定しています。weightというフィールドはガチャの抽選ロジックで重み付け抽選をするときに使用する想定で設定しています。

次にModelですがElmにおけるModel型はアプリケーションの状態を表します。今回はガチャで抽選したアイテムの情報だけModelで管理するものとします。

Msgの型定義は画面更新するトリガーのようなものです。今回はDrawGenerateRandomValueという二つを定義しました。Drawはガチャを引く時に発行される想定です。GenerateRandomValueは後述する乱数生成のためのMsgとなります。

ガチャの抽選ロジックを実装する

ここでガチャの抽選の流れを確認したいと思います。流れとしてはざっくり以下のような流れです。

  1. ガチャに含まれる全てのアイテムの重みの総和を求める。
  2. 重みの総和の範囲で疑似乱数を生成する。
  3. アイテムの重みを足していって乱数の重みを超えたらそのアイテムを排出する。

手続き型のプログラミングで書くならこれらの処理を順番に実行する関数を定義するだけです。ただし、今回は純粋関数型言語であるElmで書くので手続き型とは書き方が異なります。特に乱数の生成の仕方についてはなるほどーとなりました。

まず1の重みの総和を求める関数を以下のように定義しました。

Main.elm
-- トータルの重みを算出


getTotalWeight : List Item -> Int
getTotalWeight items =
    foldl (\item acc -> acc + item.weight) 0 items

List Itemを引数にとり重みの総和をIntで返す関数を定義しました。関数型っぽいところは関数型プログラミングではforやwhileといったループ構文を使わずにmapやreduceといった高階関数を使用することが一般的です。今回はfoldlという関数を使用してアイテムの総和を求めるような関数を作成しました。

次に2の疑似乱数の生成ですがこれにはElmのelm/randomモジュールを使用します。モジュールのインストールは以下を実行します。

% elm install elm/random

作成した関数は以下のようになります。

Main.elm
-- 乱数生成


generateRandomValue : Int -> Cmd Msg
generateRandomValue total =
    Random.generate GenerateRandomValue (Random.int 0 total)

この関数は重みの総和をInt型で引数にとりCmd Msgを返す関数です。ElmではCmd Msgを返す関数を実行することでTEAにおけるupdate関数の実行がされます。つまり、この関数は疑似乱数を生成して返すのではなく疑似乱数を生成するメッセージを発行する関数です。

疑似乱数の生成は代表的な副作用となりますがElmではこのようにCmd Msgを経由することで疑似乱数の生成という副作用が関数内に含まれないようになっているようです。すごい!!

最後に乱数を使用してアイテムを抽選する関数を以下のように定義しました。

Main.elm
-- アイテム抽選


lottery : List Item -> Int -> Maybe Item
lottery items randomValue =
    lotteryHelper items randomValue 0


lotteryHelper : List Item -> Int -> Int -> Maybe Item
lotteryHelper items randomValue acc =
    case items of
        [] ->
            Nothing

        item :: rest ->
            let
                newAcc =
                    acc + item.weight
            in
            if newAcc >= randomValue then
                Just item

            else
                lotteryHelper rest randomValue newAcc

ガチャに含まれるアイテム一覧と生成した疑似乱数を引数に取り、抽選したアイテムを返す関数を作成しました。返すアイテムは抽選失敗することを考慮してMaybe Item型としました。

これも関数型っぽいところだと思いますが関数型プログラミングでは他の言語にあるようなnullやnilといった値が存在しません。関数が値を返さない場合があればMaybe型を使用することで値をラップします。これは関数に参照透過性を持たせ副作用のない純粋関数を作るための重要な概念です。(という理解ですがもし間違ったことを言ってたらコメントください。。)

さらにlottery関数はlotteryHelper関数を呼び出していますがloteryHelper関数は再起処理をする再帰関数となっています。これも関数型の特徴のひとつだと思われますが前述したように関数型プログラミングではforやwhileといったループ処理の代わりにこういった再帰関数を利用します。

再帰関数内では乱数の重みを越えるまでアイテムの重みを再帰的に加算し、抽選に成功した場合はアイテムを返します。もし、抽選ができなかった場合はMaybe.Nothingを返すような関数になっています。

この関数の呼び出し元ではこの関数の戻り値であるMaybe型をパターンマッチングでハンドリングすることになります。

(この関数で抽選アイテムが見つからないのは明らかなシステムバグなのでResult型の方がもしかしたらいいのかもしれないと書いていて思いましたがとりあえずMaybe型にしておきます。)

実装した抽選ロジックを呼び出す

次に作成した関数の呼び出し部分を作っていきます。Elmでは画面描画処理をview関数として以下のように作成します。

Main.elm
view : Model -> Html Msg
view model =
    div [ class "w-screen h-screen flex justify-center items-center flex-col bg-slate-800" ]
        [ img [ src "/public/gachagacha.png", class "h-70 w-50" ] []
        , button [ onClick Draw, class "mt-10 p-5 rounded bg-indigo-500 hover:bg-indigo-300" ] [ text "ガチャを引く" ]
        , div [ class "mt-10 grid grid-cols-1" ]
            [ div [ class "font-bold text-lg text-pink-400 col-span-1 text-center mb-2" ] [ text "Result" ]
            , div [ class "col-span-1" ]
                [ div [ class "grid grid-cols-6 text-center text-white" ]
                    [ div [ class "col-span-3 -white" ] [ text "rarity" ]
                    , div [ class "col-span-3 text-center -white" ] [ text (getDisplayRarity model) ]
                    ]
                ]
            , div [ class "col-span-1" ]
                [ div [ class "grid grid-cols-6 text-center text-white" ]
                    [ div [ class "col-span-3" ] [ text "item name" ]
                    , div [ class "col-span-3 text-center" ] [ text (getItemName model) ]
                    ]
                ]
            ]
        ]

詳細な説明は省略しますがclass属性に指定しているのはtailwindのクラスです。大事な部分は以下のDrawメッセージをクリックイベントで発行するbuttonタグです。

Main.elm
button [ onClick Draw, class "mt-10 p-5 rounded bg-indigo-500 hover:bg-indigo-300" ] [ text "ガチャを引く" ]

このボタンがクリックされるとDrawメッセージが発行され以下のupdate関数の処理に渡ります。

Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Draw ->
            ( model, getTotalWeight gacha |> generateRandomValue )

        GenerateRandomValue randomValue ->
            ( { model | result = lottery gacha randomValue }, Cmd.none )

update関数は引数にModelMsg型を取り、Msg型をパターンマッチングすることで特定の処理を実行します。

今回はガチャを引くボタンがクリックされることでDrawメッセージが渡ってきているので以下の処理が実行されます。

( model, getTotalWeight gacha |> generateRandomValue )

定義されたガチャのアイテム一覧を引数に取り、getTotalWeight関数で重みの総和を求めて、乱数を生成するgenerateRandomValueに引数として渡ります。

generateRandomValue関数は乱数を返すのではなくCmd Msg型を返すので新たにメッセージを発行します。そのため、乱数を外部で生成したのち再度update関数の以下の処理に入ります。

( { model | result = lottery gacha randomValue }, Cmd.none )

この処理でガチャに含まれるアイテム一覧と生成した乱数を引数にガチャの抽選が行われ、その結果をmodelに反映して処理が終わります。

これで無事アイテムが抽選されていれば更新されたアイテム情報が画面に描画されるような流れです。

最終的なElmのコードは以下のようになりました。

最終的なMain.elm
Main.elm
module Main exposing (..)

import Browser
import Html exposing (Html, button, div, img, text)
import Html.Attributes exposing (class, src)
import Html.Events exposing (onClick)
import List exposing (foldl)
import Maybe exposing (map, withDefault)
import Platform.Cmd as Cmd
import Random


type Rarity
    = N
    | R
    | SR


type alias Item =
    { rarity : Rarity
    , name : String
    , weight : Int
    }



-- Model


type alias Model =
    { result : Maybe Item
    }



-- Msg


type Msg
    = Draw
    | GenerateRandomValue Int



-- ガチャに含まれるアイテム一覧


gacha : List Item
gacha =
    [ { rarity = N, name = "item1", weight = 10 }
    , { rarity = N, name = "item2", weight = 10 }
    , { rarity = R, name = "item3", weight = 5 }
    , { rarity = R, name = "item4", weight = 5 }
    , { rarity = SR, name = "item5", weight = 2 }
    ]


getRarity : Item -> String
getRarity item =
    case item.rarity of
        N ->
            "N"

        R ->
            "R"

        SR ->
            "SR"


getDisplayRarity : Model -> String
getDisplayRarity model =
    model.result
        |> map getRarity
        |> withDefault ""


getItemName : Model -> String
getItemName model =
    map (\item -> item.name) model.result
        |> withDefault ""



-- トータルの重みを算出


getTotalWeight : List Item -> Int
getTotalWeight items =
    foldl (\item acc -> acc + item.weight) 0 items



-- 乱数生成


generateRandomValue : Int -> Cmd Msg
generateRandomValue total =
    Random.generate GenerateRandomValue (Random.int 0 total)



-- アイテム抽選


lottery : List Item -> Int -> Maybe Item
lottery items randomValue =
    lotteryHelper items randomValue 0


lotteryHelper : List Item -> Int -> Int -> Maybe Item
lotteryHelper items randomValue acc =
    case items of
        [] ->
            Nothing

        item :: rest ->
            let
                newAcc =
                    acc + item.weight
            in
            if newAcc >= randomValue then
                Just item

            else
                lotteryHelper rest randomValue newAcc



-- init


init : () -> ( Model, Cmd Msg )
init _ =
    ( { result = Nothing
      }
    , Cmd.none
    )



-- update


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Draw ->
            ( model, getTotalWeight gacha |> generateRandomValue )

        GenerateRandomValue randomValue ->
            ( { model | result = lottery gacha randomValue }, Cmd.none )



-- subscriptions


subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.none



-- view


view : Model -> Html Msg
view model =
    div [ class "w-screen h-screen flex justify-center items-center flex-col bg-slate-800" ]
        [ img [ src "/public/gachagacha.png", class "h-70 w-50" ] []
        , button [ onClick Draw, class "mt-10 p-5 rounded bg-indigo-500 hover:bg-indigo-300" ] [ text "ガチャを引く" ]
        , div [ class "mt-10 grid grid-cols-1" ]
            [ div [ class "font-bold text-lg text-pink-400 col-span-1 text-center mb-2" ] [ text "Result" ]
            , div [ class "col-span-1" ]
                [ div [ class "grid grid-cols-6 text-center text-white" ]
                    [ div [ class "col-span-3 -white" ] [ text "rarity" ]
                    , div [ class "col-span-3 text-center -white" ] [ text (getDisplayRarity model) ]
                    ]
                ]
            , div [ class "col-span-1" ]
                [ div [ class "grid grid-cols-6 text-center text-white" ]
                    [ div [ class "col-span-3" ] [ text "item name" ]
                    , div [ class "col-span-3 text-center" ] [ text (getItemName model) ]
                    ]
                ]
            ]
        ]



-- main


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

テストを書く

アプリケーションの実装は以上になりますが最後に作成した関数のテストコードを書いて終わりとします。ElmのテストをCLIで実行するには一般的にnode-test-runnerをインストールして使うようです。

ただ、今回はRustで作られたテストランナーでelm-test-rsというものがあるみたいだったのでこちらを使用してみました。

グローバルインストールしてもいいですが、今回はプロジェクトにローカルインストールしました。

% bun i -d elm-test-rs
% bunx elm-test-rs init
The file tests/Tests.elm was created

% bunx elm-test-rs
Running 1 tests. To reproduce these results later,
run elm-test-rs with --seed 597517184 and --fuzz 100

◦ TODO: Implement the first test. See https://package.elm-lang.org/packages/elm-explorations/test/latest for how to do this!

TEST RUN INCOMPLETE because there is 1 TODO remaining

Duration: 1 ms
Passed:   0
Failed:   0
Todo:     1

作成されたTests.elmにテストを書いていきます。

Tests.elm
module Tests exposing (..)

import Expect exposing (equal, notEqual)
import Fuzz exposing (intRange)
import Main exposing (Rarity(..), gacha, getRarity, lottery)
import Test exposing (Test, describe, fuzz, test)


suite : Test
suite =
    describe "The Main module"
        [ test "Hello" <|
            \_ ->
                let
                    act =
                        "Hello"
                in
                equal act "Hello"
        , fuzz (intRange 0 32) "lottery fuzzy test" <|
            \weight ->
                notEqual (lottery gacha weight) Maybe.Nothing
        ]


getRarityTest : Test
getRarityTest =
    describe "getRarityTest"
        (List.map
            (\( item, expected ) ->
                test ("Testing getRarity with " ++ item.name) <|
                    \_ -> getRarity item |> Expect.equal expected
            )
            [ ( { rarity = N, name = "Normal Item", weight = 10 }, "N" )
            , ( { rarity = R, name = "Rea Item", weight = 10 }, "R" )
            , ( { rarity = SR, name = "SuperRea Item", weight = 10 }, "SR" )
            ]
        )

今回作成したアプリケーションの最も重要な抽選関数のテスト部分は以下の部分です。

fuzz (intRange 0 32) "lottery fuzzy test" <|
    \weight ->
        notEqual (lottery gacha weight) Maybe.Nothing

詳細な説明は省略しますがこれは関数型言語でよく書かれるFuzzテストなどと呼ばれるものです。これはさまざまなエッジケースを含む多くの入力パターンをテストするテスト手法です。

このテストでは0-32の範囲で重みの総和を関数に渡しアイテムが抽選できていることをテストしています。0-32という範囲は実際のアプリケーションで使用している以下のアイテム一覧の重みの範囲です。

gacha : List Item
gacha =
    [ { rarity = N, name = "item1", weight = 10 }
    , { rarity = N, name = "item2", weight = 10 }
    , { rarity = R, name = "item3", weight = 5 }
    , { rarity = R, name = "item4", weight = 5 }
    , { rarity = SR, name = "item5", weight = 2 }
    ]

もう一つのテストはgetRarityというレアリティを文字列で返す関数のテストです。これは全てのレアリティに対してテストを網羅したかったのでデータ駆動テストやテーブル駆動テストとよばれるテスト手法で書いています。

getRarityTest : Test
getRarityTest =
    describe "getRarityTest"
        (List.map
            (\( item, expected ) ->
                test ("Testing getRarity with " ++ item.name) <|
                    \_ -> getRarity item |> Expect.equal expected
            )
            [ ( { rarity = N, name = "Normal Item", weight = 10 }, "N" )
            , ( { rarity = R, name = "Rea Item", weight = 10 }, "R" )
            , ( { rarity = SR, name = "SuperRea Item", weight = 10 }, "SR" )
            ]
        )
% bunx elm-test-rs -v 

elm-test-rs 3.0.0 for elm 0.19.1
--------------------------------

✓ Compilation of tests modules succeeded

Running 5 tests. To reproduce these results later,
run elm-test-rs with --seed 4221462656 and --fuzz 100

Tests listing:

↓ The Main module
  ✓ PASSED: Hello
  ✓ PASSED: lottery fuzzy test
↓ getRarityTest
  ✓ PASSED: Testing getRarity with Normal Item
  ✓ PASSED: Testing getRarity with Rea Item
  ✓ PASSED: Testing getRarity with SuperRea Item

TEST RUN PASSED

Duration: 3 ms
Passed:   5
Failed:   0
Running duration (since Node.js start): 55 ms

テストが無事全てパスしました!

感想

以上、駆け足でしたがElmを通して関数型プログラミングに入門してみました。以下やってみた感想です。

関数型プログラムに目を慣らす

ElmはTEAに従ってModelやview, update関数を定義すればいいようになっているのでそこまで困惑することはなかったですが手続き型のプログラムとぱっと見の印象が全然違うように感じました。最初let-inの構文を知らないで書いていた時は一呼吸で処理を実行しないといけないと思いコンパイル通すのも一苦労してしまいました。

感覚的な話ですが手続き型のプログラムだとif文やfor文が多く目につきますが今回作成したプログラムをみるとパイプ演算子で処理をつないだり、mapやcase文などが多く目につきます。

確かにこういった関数型の書き方が強制されるのでElmのような純粋関数型言語で関数型に入門するのは非常に有効だなと感じました。

Elmの書き心地

Elmは関数型言語なので関数型言語に慣れていないと難しく感じると思いますが、それでもElmの言語仕様はそれほど多くないのでそこまで苦労せず書くことができました。加えて、TEAというプログラムを書くための道標のようなものがあるためより書きやすく感じました。

最初は慣れない書き方に戸惑いましたが慣れると非常に楽しかったです。

MaybeやResult型

既存言語でも採用していたりするようですがMaybe型とResult型があることで副作用の少ない関数につながるんだろうなと思い、みなさんがこれらの型を欲しがる理由がなんとなくわかりました。

乱数の生成について

前述してますが疑似乱数の生成は副作用です。手続き型の言語だと意識して書かないと関数内で乱数を生成してしまい、テストが書けないということがよくあります。これが今回Cmd Msgを経由することで副作用の出ないように関数を自然と作成することができました。感動

再帰関数

lotteryという関数内で再起処理をするのに今回lotteryHelperという関数を定義しましたが正直命名が微妙だなと思っていてこういう時どういう命名するのかどなたか関数型詳しい方教えてください。

関数型プログラミングの学習について

Elmは純粋関数型言語なのでElmを書くことで関数型の書き方が自然とできてるんだと思うんですが、これは関数型の概念でこういうものですみたいな説明は特にElmの公式ガイドなどにはないのでざっくり関数型の知識がある方が理解度は上がるような気がしました。今回特にやってないですが関数のカリー化がいまいち何でやるのかわからなかったのですがElmを知ったことでなんとなくですが理解できた気がします。

テストについて

実際テストは書きやすかったと思うんですがFuzzテストはあれでよかったのかよくわかってないです。入力のパターンを無数に生成できるのは理解したのですが、それに対して期待値を設定してテストをしなければならないと思うんですが、期待値の設定の仕方がいまいちわかってないです。結局無数の入力パターンに対して成功するような期待値を設定しなければならないと思うのですが、その方法がいまいちピンときてません。これは課題としてもう少し学習していきたいなと思います。

あとは、関数の数が手続き型と比べて多くなってくるのでどの関数をテストして、どの関数はテストしないのかみたいな判断が正直あんまりピンときてないです。

まとめ

まだまだ学ぶことが多いですがElmを通して関数型プログラミングがどういうものかが前より理解できた気がします。そして、今回ゴールとして設定していた単体テストをうまく書くための関数の書き方については正直理解したとは言えないですがElmの関数がお手本だと思えば良い経験になったと思います。加えて、Fuzzテストのような関数型の世界で使用されているテスト手法などもより知ることができました。

本記事が関数型を学ぼうとしている方の参考に少しでもなれば幸いです。
今回は以上です🐼

GitHubで編集を提案

Discussion

ABAB↑↓BAABAB↑↓BA

まずはじめにElm入門したばかりなのに、とてもチャレンジングで素晴らしい完成度の記事と感じました!

なのでとても細かいところで気になったものです⇩

文中で、getTotalWeight という関数が出てきましたが

List.sumを使えば処理の間違える可能性を減らせそうです。今回で言えば、foldで前の結果に今見ている値を足し込む, 0から始める などの処理における注意が必要です。例えば、掛け算の場合は0から始めてしまうとどんなに掛けても0になってしまうようにですね。

こう言ったよく書かれる処理は一般化し(今回だとList.sum関数)それらを組み合わせるだけのコードを書くことが、関数型プログラミング言語らしさのひとつと思います。そうすると、もはやテストすら必要がない、関数に切り出さずインラインに書ける部分が増えコードが描きやすくなるという利点が生まれます(もちろん複雑になったらテストは書くべきですが)

getTotalWeight : List Item -> Int
getTotalWeight items =
    List.sum <| List.map (.weight) items

もうひとつ気になったのはこちらです。

関数が値を返さない場合があればMaybe型を使用することで値をラップします。これは関数に参照透過性を持たせ副作用のない純粋関数を作るための重要な概念です。

副作用が絡んでくる部分は、あくまで変数のような再代入を許すような言語機構の場合は、nil, null等に気をつけましょうという話な気がします。

Maybeは、ぱんださんが定義したtype Rarityと同じように、値がある時・ない時を制限している(一般化された)型に過ぎません。

type Maybe a = Just a | Nothing

直接の恩恵は、副作用のラップではなく、パターンマッチ時に値がある時・ない時、両方網羅して分岐を書かなければならない強制力にあります。また、一般化していることによる豊富な関数群があることでしょうか。

もちろん、もっと踏み込めばいろんな話ができる型ではありますが、軸は網羅性の部分かなと思います。

以上、長々とすみませんでした。

ぱんだぱんだ

ありがたいお言葉ありがとうございます🙇‍♂️

getTotalWeightについて。なるほど、そのように書けばいいんですね!

そうすると、もはやテストすら必要がない、関数に切り出さずインラインに書ける部分が増えコードが描きやすくなるという利点が生まれます(もちろん複雑になったらテストは書くべきですが)

テストすら必要がないっていう感覚は手続き型でやっているとあんまり思わなそうな感覚ですね。例にしている関数の処理がそんなに複雑ではないというのがありますが。勉強なります!

Mabeについて。

直接の恩恵は、副作用のラップではなく、パターンマッチ時に値がある時・ない時、両方網羅して分岐を書かなければならない強制力にあります。また、一般化していることによる豊富な関数群があることでしょうか。

なるほどです!Maybeの恩恵はパターンマッチ時の強制力というのは非常に納得できました!ありがとうございます!