読者です 読者をやめる 読者になる 読者になる

Arantium Maestum

プログラミング、囲碁、読書の話題

Clojure Web Development勉強 - ReactチュートリアルをReagentでやってみる(その3)

Clojure Clojurescript Web React Reagent

公式チュートリアルのFunctional ComponentsからDeclaring a Winnerまで。

Functional Components

前回散々JavaScript版が明示的じゃない、ボイラープレートが多いと煽っていたが、実際にはJSでも純粋関数的に書くとより簡潔になる:

function Square(props) {
  return (
    <button className="square" onClick={() => props.onClick()}>
      {props.value}
    </button>
  );
}

これまで見てきたReactコンポーネントはオブジェクト(クラス)だったが、上記のコンポーネントは関数として定義されている。このFunctional ComponentsがForm 1 Reagent Componentと同等のものになる。関数なのでデータを保持せず、thisを持たない代わりに明示的にpropsを引数で受け取っている。欲をいえばpropsの中のフィールドを関数の中で参照するんじゃなくてdestructuring的な何かで引数を受け取る段階でpropsの何を使うか明示してほしいが、それはまあそこまで重要なポイントではない。

とりあえずClojureScript版も再掲:

(defn Square [value click-fn]
  [:button.square {:on-click click-fn} value])

Taking Turns

二プレーヤーゲームなので、Xの次はO、Oの次はXと手番が入れ替わらないといけない。

Boardコンポーネントに変更を加えて実装する。

Javascriptだとこんな感じ:

class Board extends React.Component {
  constructor() {
    super();
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }
  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }
  ...
}

BoardコンポーネントにxIsNextというプロパティをつけ、handleClick関数でそのプロパティの状態によってXかOをsquaresの正しい箇所(クリックされた四角の位置に相当するindex)に入れ、そしてhandleClick関数の中でxIsNextの真偽を入れ替える。

ClojureScript版も非常に似ている:

(defn Board []
  (let [squares (r/atom (apply vector (repeat 9 nil)))
        xIsNext (r/atom true)
        handle-click #(do (swap! squares assoc % (if @xIsNext "X" "O"))
                          (swap! xIsNext not))
        ...]
    ...))

xIsNextをreagent atomとして定義、handle-clickでその値によってsquareにXを入れるかOを入れるか決め、最後にxIsNextの値を反転させている。

Declaring a Winner

ゲームとして最低限成り立たせる最後の一歩として、勝者判定をしないといけない。

○×ゲームなので、三つ同じシンボルが縦横斜めのいずれかに並んだらそのプレーヤーの勝ちである。

まずは判定用のcalculateWinner関数を定義。

JavaScript版:

function calculateWinner(squares) {
  const 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],
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

勝利条件のindexのセットをリスト化して、そのリストをループしてsquaresの三つ並びなlineの中で要素がnullじゃなく全部同一なものを探し、そのlineの第一要素を返す。ループで見つからなければnullを返す、というよくある命令的ループ。

ClojureScript版:

(defn 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]]]
    (->> lines
         (filter (fn [[a b c]]
                   (and
                     (not (nil? (get squares a)))
                     (= (get squares a)
                        (get squares b)
                        (get squares c)))))
         first
         first
         (get squares))))
  • linesという名前のローカル変数で勝利条件となるindexの並びを定義
  • threading-macroでこのlinesからはじめてWinnerのシンボルかnilを返すまで処理をつなげている
  • linesの中の要素(便宜上lineと呼ぶ)を以下の条件でフィルタ
    • 最初のindexの位置にあるsquaresの要素がnil以外
    • lineの要素a b cのすべての位置にあるsquaresの要素が同じシンボル
  • その条件にマッチしている最初のlineだけを選択
  • そのlineの第一要素(フィルタがかかっているので第二、第三要素も同一のはず)を選択
  • その第一要素をindexとしてsquaresから値を取ってくる

ちなみにフィルタにマッチする条件がない場合nilが返ってきて、それ以降のステップでも引数がnilだと戻り値もnilになる。

JavaScript版に比べてちょっと行数が長くなっているが、主にfilterに渡す関数のところで条件を別の行にわけているのが大きい。

あと本当はgetいらないんだけど、どうだろう、あったほうが個人的には好き。squaresという名前に束縛されているデータ構造を関数として扱うのはちょっと抵抗がある。

まあないほうがずっと簡潔になるよな。ただし(squares nil)ってやると第一要素が帰ってくるので気をつけないといけないけど・・・

この実装だとJavaScript版を結構忠実に真似ているけど、linesを手で書くよりいい方法が絶対ありそうなものだ。

次に、このcalculateWinnerを使ってコンポーネントの挙動を変更する。具体的には、勝者がいる場合は「次の手番のプレーヤー」の代わりに「勝者」を表示し、その後ボードをクリックしても何も起こらないようにする。

JavaScript版:

class Board extends React.Component {
  ...
  handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    ...
  }
  render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }
    ...
  }
}

onClickではcalculateWinnerがnull以外を返すか、クリックされた箱にすでに値が表示されている場合はすぐにreturnするようになった。

またrender関数ではwinnerの有無によってstatus変数(以前はプロパティだと思ったのだがよく見たらただの変数だった)を作成して表示している。

ClojureScript版:

(defn Board []
  (let [squares (r/atom (apply vector (repeat 9 nil)))
        xIsNext (r/atom true)
        handle-click #(if-not (or (calculateWinner @squares) (@squares %))
                          (do (swap! squares assoc % (if @xIsNext "X" "O"))
                              (swap! xIsNext not)))
        renderSquare (fn [i] 
                       [Square (get @squares i) #(handle-click i)])]
    (fn []
      [:div
       [:div.status (if-some [winner (calculateWinner @squares)]
                      (str "Winner: " winner)
                      (str "Next player: " (if @xIsNext "X" "O")))]
        ...]])))

handle-clickではif-notを使って条件が合わない場合のみ処理を実行、条件があったらnilを返すようにしてある。

そしてとりあえずstatusを完全に消してしまった。render関数の中で直接文字列を[:div.status]に渡している。if-someでcalculateWinnerがnil以外を返した場合はwinner変数に束縛した上で(str "Winner: " winner)を表示し、calculateWinnerがnilな場合は(str "Next player: " (if @xIsNext "X" "O"))を表示する。

if-notやif-someみたいな小技はまだなかなか自然には出てきてくれないが、「あ、このコードであれ使えるんじゃ?」とふと気づくと嬉しい。

ちなみにcalculateWinnerはBoardの前に宣言、r/renderの前に定義しておかないと呼び出すものがないと怒られるので注意。JavaScriptは一番最後にcalculateWinnerが来ていたが、言語仕様としてコードの宣言・定義の順番には無頓着なのか?

今回のコードでとりあえずゲームとしての体裁は整ってきた。次回でとりあえずReactチュートリアルは終わり。過去の手順の表示など、不特定数のDOM要素を動的に作る時の注意点などがポイント。

今回のコード:

gist.github.com