📌

Elmで3目並べを実装してみた

2021/12/23に公開

はじめに

私はITエンジニアリング力を高めて、手に職をつけたいと願うプログラミング初心者です。
フロントエンド開発に入門するにあたって、圧倒的覇権のJavaScriptを勉強してみたのですが、難しすぎて「これでコーディングできる人って天才やん、、」となっていました。そこでElmを勉強してみたのですが、「これメッチャ良いんじゃない?」という感想を抱いたので、練習で3目並べのプログラムを実装してみました。実装はReactの某チュートリアルを参考にしています。コードは記事末尾においています。プログラミング初心者かつ、Elm初心者が書いたコードを解説しても仕方がないと思うので、書いてみた所感を述べたいと思います。もちろん、おもちゃレベルのコーディングとプロダクションレベルのコーディングは全く別の話であることは承知の上です。

いいところ

  1. 認知的負荷が低い
  2. パワフル

認知的負荷が低い

Elmを書いていて、一番思ったのが認知的負荷の低さです。
理由は2つあると思っています。

  • Elmアーキテクチャ
  • 関数型言語

Elmアーキテクチャ

Elmアーキテクチャは非常に書きやすいです。「Elmアーキテクチャってなに?」という方はネット上に記事が腐るほどあるので調べてみてください。
なぜ書きやすいかというとデータフローが単方向で単純だからだと思います。さらに、Elmを書くならElmアーキテクチャに従う一択なので、プログラムのデータフローアーキテクチャ(なんて言葉があるか分かりませんが)に悩まなくていいのも嬉しいポイントです。

関数型言語

関数型言語は手続き型言語(C++, Java, Rust, Python etc..)と比べると認知的負荷が低いです。理由は関数が純粋だからです[1]。関数が純粋であるとは副作用がない(=外部の状態を変更しない)ということです。また、プログラミング言語における関数は関数とは言いながら、数学の関数(引数から戻り値は一意に決まる)とは異なることが多いですが、Elmの関数は数学の関数と同じで引数から戻り値は一意に決まります。この「副作用がなく、引数から戻り値は一意に決まる」という特性は関数をとても扱いやすくしてくれるので(少なくとも私にとっては)、コーディングがしやすいです。

パワフル

ElmにはFunctor、Applicative、Monadという型クラスはないですが、それに対応する関数はあります。従って、プログラマーのMonad力が高ければ、型の力を十二分に発揮させることができそうです。残念なことに私のMonad力はとても低いのでこれは実感できていません。

大変だったところ

(いいところと対になっていないですが、悪い所が特に思い浮かばないので、大変だったところを書きます。)
一方で書いていて大変だったところもあります。全般的にElmが悪いわけではなく、私の実力不足が原因です。列挙したものは互いに重なり合っています。

  • 低品質のコードしか書けない
  • Maybeをうまく扱えない
  • すらすら書けない

低品質のコードしか書けない

「絶対もっと良い書き方あるよ」と思いながら書いていました。とくに縦横ななめにマルorバツが3つ並んでいるかチェックする処理は苦労しました。
これは関数型言語の書き方の常識の欠如が原因です。この経験から、関数型言語で開発するときは関数型言語習熟者にレビュー・指導してもらうことが非常に大切だと感じました。そのような詳しい人がいない場合、Haskellで競プロをやり、強そうな人の解答をみて書き方を学ぶというのは有効かなと思いました。

Maybeがうまく扱えない

Elmでは所望の結果が得られるか定かでない場合はMaybeを返します。例えば配列にインデックスでアクセスするArray.getです。これは至極まっとうな仕様なのですが、絶対にNothingにはならないという状況もあります。そのような場合、良いかどうかはさておき、Rustならunwrap()できるのですが。Elmにはそのような抜け道はなさそうです。おそらく、Maybeの扱いに苦労するのは私のMonad力の低さが原因なのですが、関数型初学者は同様の感想を抱くかもしれません。

すらすら書けない

手続き型言語ではちょっとした関数を実装するときにノートに書きながら考えるということはあまりないのですが、Elmでは比較的単純な関数でもノートに書いて考えることが多かったです。これも私の実力不足が原因ですが。

おわりに

本記事ではElmで3目並べを実装してみました。私の実力不足により、すらすら書くことはできませんでしたが、JavaScriptでコーディングするのに比べて、圧倒的に認知的負荷が低いのは頭が良くない自分にとってかなり魅力的でした。もちろん、プロダクションレベルになるとまた違った難しさが出てくるとは思いますが、それでもElmはかなり有望なオプションに感じました。ただなぜかまったく流行っていませんが。。

コード

Game.elm
module Game exposing (..)

import Array exposing (Array)
import Browser
import Html exposing (Html, button, div, li, ol, text)
import Html.Attributes exposing (class)
import Html.Events exposing (onClick)
import Maybe exposing (andThen, map2)
import Maybe.Extra exposing (or)
import String exposing (lines)


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


type Msg
    = CellClicked Int
    | JumpTo Int


type Cell
    = O
    | X
    | Empty


type alias Board =
    Array Cell


{-
history: 履歴 盤面の配列
xIsNext: 次はxの手番かを示すフラグ
status: ゲームのステータスを表す文字列
winner: 勝者
-}
type alias Model =
    { history : Array Board
    , xIsNext : Bool
    , status : String
    , winner : Maybe Cell
    }


initialModel : Model
initialModel =
    { history = Array.fromList [ emptyBoard ]
    , xIsNext = True
    , status = "Next player: X"
    , winner = Nothing
    }


emptyBoard : Board
emptyBoard =
    Array.repeat 9 Empty


view : Model -> Html Msg
view model =
    div [ class "game" ]
        [ div [ class "game-board" ]
            [ viewBoard model.status (getLatestBoard model.history) ]
        , div [ class "game-info" ]
            [ ol [] [ viewHistory model.history ] ]
        ]


{- 3x3の盤面 -}
viewBoard : String -> Board -> Html Msg
viewBoard status squares =
    div [ class "board" ]
        [ div [ class "status" ] [ text status ]
        , div [ class "board-row" ]
            [ viewButton squares 0
            , viewButton squares 1
            , viewButton squares 2
            ]
        , div [ class "board-row" ]
            [ viewButton squares 3
            , viewButton squares 4
            , viewButton squares 5
            ]
        , div [ class "board-row" ]
            [ viewButton squares 6
            , viewButton squares 7
            , viewButton squares 8
            ]
        ]


{- 一個のマス -}
viewButton : Board -> Int -> Html Msg
viewButton squares pos =
    let
        c =
            case Array.get pos squares of
                Just cell ->
                    cellToString cell

                Nothing ->
                    " "
    in
    button [ class "square", onClick (CellClicked pos) ] [ text c ]


{- 履歴ボタン -}
viewHistory : Array Board -> Html Msg
viewHistory history =
    div [ class "history" ]
        (List.map move (List.range 0 (Array.length history - 1)))


move : Int -> Html Msg
move n =
    let
        desc =
            if n /= 0 then
                "Go to move #" ++ String.fromInt n

            else
                "Go to game start"
    in
    li [] [ button [ onClick (JumpTo n) ] [ text desc ] ]


update : Msg -> Model -> Model
update msg model =
    case msg of
        {- マスが押下されたときに届くメッセージ -}
        CellClicked pos ->
            updateByCellClicked pos model

        {- 履歴ボタンが押下されたときに届くメッセージ -}
        JumpTo n ->
            updateByJumpTo n model


updateByCellClicked : Int -> Model -> Model
updateByCellClicked pos model =
    let
        latestBoard =
            getLatestBoard model.history

        squares =
            Array.set pos
                (if model.xIsNext then
                    X

                 else
                    O
                )
                latestBoard

        xIsNext =
            not model.xIsNext

        winner =
            calculateWinner squares

        status =
            case winner of
                Just cell ->
                    "Winner: " ++ cellToString cell

                Nothing ->
                    "Next player: "
                        ++ (if xIsNext then
                                "X"

                            else
                                "O"
                           )
    in
    case Array.get pos latestBoard of
        Just Empty ->
            case model.winner of
                Just _ ->
                    model

                Nothing ->
                    { model | history = Array.push squares model.history, xIsNext = xIsNext, status = status, winner = winner }

        _ ->
            model


updateByJumpTo : Int -> Model -> Model
updateByJumpTo n model =
    let
        history =
            Array.slice 0 (n + 1) model.history

        xIsNext =
            if remainderBy 2 n == 0 then
                True

            else
                False

        winner =
            calculateWinner <| getBoard n history

        status =
            case winner of
                Just cell ->
                    "Winner: " ++ cellToString cell

                Nothing ->
                    "Next player: "
                        ++ (if xIsNext then
                                "X"

                            else
                                "O"
                           )
    in
    { model | history = history, xIsNext = xIsNext, status = status, winner = winner }


getBoard : Int -> Array Board -> Board
getBoard n history =
    case Array.get n history of
        Just board ->
            board

        Nothing ->
            emptyBoard


getLatestBoard : Array Board -> Array Cell
getLatestBoard history =
    getBoard (Array.length history - 1) history


{- 勝負が決しているかの判定 -}
calculateWinner : Board -> Maybe Cell
calculateWinner squares =
    let
        lines =
            [ ( 0, 1, 2 )
            , ( 3, 4, 5 )
            , ( 6, 7, 8 )
            , ( 0, 3, 6 )
            , ( 1, 4, 7 )
            , ( 2, 5, 8 )
            , ( 0, 4, 8 )
            , ( 2, 4, 6 )
            ]
    in
    List.foldl or Nothing (List.map (checkWinner squares) lines)


checkWinner : Board -> ( Int, Int, Int ) -> Maybe Cell
checkWinner squares ( first, second, third ) =
    let
        one =
            Array.get first squares

        two =
            Array.get second squares

        three =
            Array.get third squares

        list =
            [ two, three ]
    in
    maybeEqualEmpty one
        |> andThen
            (\equal ->
                if not equal then
                    List.foldl maybeEqualCell one list

                else
                    Nothing
            )


maybeEqualEmpty : Maybe Cell -> Maybe Bool
maybeEqualEmpty a =
    map2 (==) a (Just Empty)


maybeEqualCell : Maybe a -> Maybe a -> Maybe a
maybeEqualCell a b =
    case map2 (==) a b of
        Just True ->
            a

        _ ->
            Nothing


cellToString : Cell -> String
cellToString cell =
    case cell of
        O ->
            "O"

        X ->
            "X"

        Empty ->
            " "

App.css
/* Reactのチュートリアルからのコピペです */
body {
  font: 14px "Century Gothic", Futura, sans-serif;
  margin: 20px;
}

ol, ul {
  padding-left: 30px;
}

.board-row:after {
  clear: both;
  content: "";
  display: table;
}

.status {
  margin-bottom: 10px;
}

.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}

.square:focus {
  outline: none;
}

.kbd-navigation .square:focus {
  background: #ddd;
}

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}
index.html
<!doctype html>
<html>
    <head>
        <link rel="stylesheet" href="app.css">
    </head>

    <body>
        <div id="app"></div>                                               

        <script src="app.js"></script>                                     
        <script>
            Elm.Game.init({node: document.getElementById("app")});  
        </script>
    </body>
</html>

追記 (2023/09)

最近Haskellを少し学んでいるので、コードを見直してみました。勝利者を計算する部分を書き直しましたが、相対的には以前よりましに書けたと思います。
ただHaskellのsequence風の関数を定義するなど、Haskell風味になってしまっているので、それが良いのかは定かではありません。。

calculateWinner : Board -> Maybe Cell
calculateWinner squares =
    let
        lines =
            [ [ 0, 1, 2 ]
            , [ 3, 4, 5 ]
            , [ 6, 7, 8 ]
            , [ 0, 3, 6 ]
            , [ 1, 4, 7 ]
            , [ 2, 5, 8 ]
            , [ 0, 4, 8 ]
            , [ 2, 4, 6 ]
            ]
    in
    List.foldl or Nothing (List.map (checkWinner squares) lines)


-- Checks whether a given row satisfies the O or X win condition
checkWinner : Board -> List Int -> Maybe Cell
checkWinner squares list =
    case map (\n -> Array.get n squares) list |> sequenceCellMaybeList |> andThen allEqual of
        Just Empty -> Nothing
        otherwise -> otherwise

sequenceCellMaybeList : List (Maybe Cell) -> Maybe (List Cell)
sequenceCellMaybeList maybeList =
    if all isJust maybeList then
        Just (map (withDefault Empty) maybeList) -- the default value is never used 
    else
        Nothing  

allEqual : List a -> Maybe a
allEqual list =
    case list of
        [] -> Nothing
        x :: xs ->
            if all (\y -> x == y) xs then
                Just x
            else
                Nothing

脚注
  1. 関数の純粋性は最近Twitterで話題になっていましたね。この議論は複数の教訓があり、味わい深いです。 https://togetter.com/li/1812838 ↩︎

GitHubで編集を提案

Discussion