בואו נכתוב משחק איקס עיגול ב Elixir

25/02/2018

אליקסיר היא אחת השפות המדליקות ביותר שאתם לא כותבים בהן וחבל. ואני יודע שבטח תשאלו תכף למה צריך בכלל עוד שפה ומה אליקסיר עושה טוב יותר משפות אחרות, אבל אלה לא השאלות החשובות. כי לאליקסיר שתי תכונות שמייחדות אותה משאר העולם: היא פונקציונאלית והמידע בה לא משתנה לעולם מרגע שנוצר, מה שנקרא Immutable. כתיבה באליקסיר עוזרת לתרגל תבניות שמקבלות מקום צדדי בשפות אחרות.

1. התקנה ותבנית פרויקט

אליקסיר רצה בתוך המכונה הוירטואלית של Erlang שנקראת BEAM. הבחירה ב BEAM תעזור לאלה מכם שמחפשים סיבות פרקטיות ללמוד את השפה: אירלנג משמשת את כל תעשיית הטלקום ומצטיינת בזמינות גבוהה, תמיכה מלאה ב Zero Downtime Deployment ומוכנות גבוהה לעבודה מבוזרת.

בשביל להתקין את אליקסיר (ועל הדרך גם את אילרנג) צריך רק להוריד ולהריץ את ה Installer מכאן: https://elixir-lang.org/install.html

לאחר ההתקנה תקבלו כלי שורת-פקודה בשם mix שאיתו גם יוצרים את התוכנית הראשונה. משורת הפקודה נכתוב:

$ mix new tictactoe

וקיבלנו את מבנה התיקיות הבא איתו אפשר להתחיל לעבוד:

.
├── README.md
├── config
│   └── config.exs
├── lib
│   └── tictactoe.ex
├── mix.exs
└── test
    ├── test_helper.exs
    └── tictactoe_test.exs

אין עדיין IDE מסודר אבל אפשר לעבוד עם כל Text Editor. כן יש תוספים מעולים ל vim ול atom.

2. נתחיל עם לוח משחק

רוב המתכנתים שיגיעו לכתוב קוד עבור לוח במשחק איקס עיגול יחשבו על מבנה של מערך, כאשר בכל פעם שמישהו מבצע מהלך מסמנים ערך מתאים באחד התאים. באליקסיר כזכור זה לא יכול לעבוד: כל המידע הוא קבוע ולכן לא נוכל ״לסמן״ תאים במערך כדי לייצג מהלכים במשחק.

במקום זה כל שלב במשחק מיוצג על ידי מערך חדש, וכל הפונקציות שנכתוב יקבלו את לוח המשחק כקלט ויחזירו לוח משחק חדש. במחשבה שניה זה לא רק הלוח שמשתנה כל מהלך, אלא גם מזהה השחק הנוכחי. נשמור את שני הערכים במבנה אחד בשביל שיהיה נוח לעבוד איתם יחד.

פתחו את הקובץ lib/tictactoe.ex והחליפו את תוכנו בקוד הבא:

defmodule GameState do
  defstruct player: 'X', board: %{}
end

defmodule Tictactoe do
  def init do
    %GameState{}
  end
end

הקוד מתחיל בהגדרת struct בשם GameState. באליקסיר struct הוא פשוט דרך לאסוף יחד מידע מסוגים שונים ולאכוף מבנה מסוים של המידע. במקרה שלנו המבנה מורכב מ player שמתחיל עם הערך X, ושדה בשם board שמתחיל בתור Map ריק. מבנה Map של אליקסיר דומה ל Hash של רובי או Dictionary של פייתון. הוא מאפשר להגדיר אוסף פריטים כאשר כל אחד מזוהה לפי מפתח ייחודי. אני חושב שיהיה נוח להשתמש באינדקס של כל תא בלוח בתור מפתח, ובמזהה השחקן שתפס את המשבצת בתור ערך.

3. ביצוע מהלך במשחק

ועכשיו לשאלת מיליון הדולר: איך לשנות ערכים בשפה שלא תומכת בשינוי? איך לבצע מהלך במשחק? התשובה כפי שאולי כבר ניחשתם היא שאנחנו לא משנים את הלוח, אלא מחזירים לוח חדש.

כל מהלך בלוח מיוצג על ידי תא במפה: המפתח הוא צמד האינדקסים (שורה, עמודה) והערך הוא מזהה השחקן ש"תפס" את המשבצת. בלוח הבא למשל שחקן X לקח את המשבצת השמאלית עליונה, ושחקן O לקח את המשבצת הימנית עליונה:

%{{0, 0} => 'X', {0, 2} => 'O'}

סימן סוגריים מסולסלים של אליקסיר מייצג ״צמד״, שזה דבר דומה מאוד ל Tuple מ Python (וגם נקרא Tuple באליקסיר).

הפונקציה play שמבצעת את המהלך מקבלת את לוח המשחק הישן ואת המהלך שרוצים לבצע, ומחזירה את הלוח לאחר הביצוע. הפונקציה צריכה לבדוק גם שהמשחק עדיין נמשך ושהמשבצת לא תפוסה, אבל דווקא המקרה האחרון ב cond הוא המעניין ביותר כאן:

  def play({row, col}, state) do
    cond do
      # tried to play a finished game
      game_over(state) ->
        state

      # square taken
      {row, col} in Map.keys(state.board) ->
        state

      # square free - take it!
      true ->
        %GameState{
          player: next_player(state.player),
          board: Map.put_new(state.board, { row, col }, state.player),
        }
    end
  end

הפקודה cond של אליקסיר אגב דומה ל switch/case בשפות אחרות ומאפשרת בדיקה של מספר תנאים והחזרת ערך לפי התנאי שמתקיים. הקוד שהוצג מטפל ב-3 תנאים אפשריים: או שהמשחק נגמר (ותכף נצטרך לראות את הקוד של game_over עצמה), או שהמשבצת כבר תפוסה, או שבוצע מהלך תקין במשבצת פנויה.

בשני המקרים הראשונים לא קרה שום שינוי בלוח המשחק ולכן מחזירים את הלוח בדיוק כפי שקיבלנו אותו. המקרה האחרון הוא כאמור המעניין יותר. אנחנו אומנם לא יכולים לשנות את הלוח אבל בהחלט אפשר להחזיר לוח חדש שמורכב מכל תוכן הלוח הקודם ועוד המשבצת החדשה שעכשיו תפסנו. אגב ב State הבא שאותו מחזירים השחקן כבר מוגדר להיות השחקן הבא, וגם את הפונקציה next_player נצטרך לראות מיד.

כשמריצים את הקוד מקבלים התנהגות פונקציונאלית טיפוסית. הפעלה של הפונקציה play על אותו קלט תמיד מחזירה את אותו הפלט:

> s=TicTacToe.init
%GameState{board: %{}, player: 'X'}

> TicTacToe.play({0, 0}, s)
%GameState{board: %{{0, 0} => 'X'}, player: 'O'}

> TicTacToe.play({0, 0}, s)
%GameState{board: %{{0, 0} => 'X'}, player: 'O'}

> s1=TicTacToe.play({0, 0}, s)
%GameState{board: %{{0, 0} => 'X'}, player: 'O'}

> s2=TicTacToe.play( {0, 0}, s1)
%GameState{board: %{{0, 0} => 'X'}, player: 'O'}

> s2=TicTacToe.play({0, 1}, s1)
%GameState{board: %{{0, 0} => 'X', {0, 1} => 'O'}, player: 'X'}

4. שתי הפונקציות החסרות

לאליקסיר יש טריק נחמד במימוש הפונקציה שמחזירה את השחקן הבא:

def next_player('X'), do: 'O'
def next_player('O'), do: 'X'

שפות מונחות עצמים רבות מאפשרות לכם להגדיר מספר מימושים לאותה פונקציה לפי סוג הפרמטרים שהפונקציה מקבלת. אליקסיר (ושפות נוספות מהמשפחה) מאפשרות להגדיר מימושים שונים גם לפי ערכים ממש שמועברים לפונקציה. כך השחקן הבא של X הוא O ולהיפך.

זיהוי סיום משחק הוא קצת יותר מורכב, כי צריך לזהות אם יש מנצח. באליקסיר הדרך המקובלת לגשת לבעיות כאלה היא הרבה יותר תיאורית מאשר במקומות אחרים. הפקודה Enum.any? מקבלת אוסף איברים ופונקציה ובודקת אם אחד האיברים מהאוסף יגרום לפונקציה להחזיר אמת. המימוש הבא מעביר ל any את אוסף האינדקסים שעשויים להביא לניצחון, ופונקציה שבודקת אם בכל 3 המשבצות יש את אותו הערך:

  def is_winning_triplet(['X', 'X', 'X']), do: true
  def is_winning_triplet(['O', 'O', 'O']), do: true
  def is_winning_triplet(triplet), do: false

  def has_winner?(state) do
    Enum.any?([
      [ {0, 0}, {0, 1}, {0, 2} ],
      [ {1, 0}, {1, 1}, {1, 2} ],
      [ {2, 0}, {2, 1}, {2, 2} ],
      [ {0, 0}, {1, 0}, {2, 0} ],
      [ {0, 1}, {1, 1}, {2, 1} ],
      [ {0, 2}, {1, 2}, {2, 2} ],
      [ {0, 0}, {1, 1}, {2, 2} ],
      [ {0, 2}, {1, 1}, {2, 0} ],
    ],
    fn x ->
      Map.take(state.board, x)
      |> Map.values
      |> is_winning_triplet
    end)
  end

  def game_over?(state) do
    has_winner?(state) || Map.size(state.board) == 9
  end

הסימן המוזר >| מציין הפעלה הפוכה של פונקציה. הרעיון הוא שבמקום לכתוב:

f(g(h(x)))

אפשר לכתוב את הקוד הבא שהוא שקול לגמרי ועשוי להיות יותר קריא כששמות הפונקציות ארוכות:

h(x)
|> g
|> f

בעברית נגיד שמפעילים את הפונקציה h על x, את התוצאה שולחים ל-g, ואת התוצאה של זה שולחים ל f. בקוד שהוצג לוקחים מהלוח את ה key/values בשלושת האינדקסים, את התוצאה שולחים ל Map.values כדי לקבל רק את הערכים, ואת התוצאה שולחים ל is_winning_triplet כדי לדעת אם הערכים אכן מעידים על ניצחון.

סך הכל קוד המשחק המלא נראה כך:

defmodule GameState do
  defstruct player: 'X', board: %{}
end

defmodule TicTacToe do
  def init do
    %GameState{}
  end

  def is_winning_triplet(['X', 'X', 'X']), do: true
  def is_winning_triplet(['O', 'O', 'O']), do: true
  def is_winning_triplet(_), do: false

  def has_winner?(state) do
    Enum.any?([
      [ {0, 0}, {0, 1}, {0, 2} ],
      [ {1, 0}, {1, 1}, {1, 2} ],
      [ {2, 0}, {2, 1}, {2, 2} ],
      [ {0, 0}, {1, 0}, {2, 0} ],
      [ {0, 1}, {1, 1}, {2, 1} ],
      [ {0, 2}, {1, 2}, {2, 2} ],
      [ {0, 0}, {1, 1}, {2, 2} ],
      [ {0, 2}, {1, 1}, {2, 0} ],
    ],
    fn x ->
      Map.take(state.board, x)
      |> Map.values
      |> is_winning_triplet
    end)
  end

  def game_over?(state) do
    has_winner?(state) || Map.size(state.board) == 9
  end

  def next_player('X'), do: 'O'
  def next_player('O'), do: 'X'

  def play(state, {row, col}) do
    cond do
      # tried to play a finished game
      game_over?(state) ->
        state

      # square taken
      {row, col} in Map.keys(state.board) ->
        state

      # square free - take it!
      true ->
        %GameState{
          player: next_player(state.player),
          board: Map.put_new(state.board, { row, col }, state.player),
        }
    end
  end
end

5. כתיבת בדיקות למשחק

מאחר והקוד כולו הוא פונקציונאלי וכל ה State נשמר באופן חיצוני לפונקציות, יחסית קל לכתוב בדיקות לקוד. פתחו את הקובץ test/tictactoe_test.exs וכתבו בו את התוכן הבא:

defmodule TicTacToeTest do
  use ExUnit.Case
  doctest TicTacToe

  test 'board starts empty' do
    s = TicTacToe.init
    assert map_size(s.board) == 0
  end

  test 'X plays first' do
    s = TicTacToe.init
    assert s.player == 'X'
  end

  test 'winning row is detected' do
    board = %{ {0, 0} => 'X', {0, 1} => 'X', { 0, 2 } => 'X' }
    s = %GameState{board: board, player: 'O'}
    assert TicTacToe.has_winner?(s)
  end

  test 'winning column is detected' do
    board = %{ {0, 0} => 'X', {1, 0} => 'X', { 2, 0 } => 'X' }
    s = %GameState{board: board, player: 'O'}
    assert TicTacToe.has_winner?(s)
  end

  test 'O can win too' do
    board = %{ {0, 0} => 'O', {1, 0} => 'O', { 2, 0 } => 'O' }
    s = %GameState{board: board, player: 'O'}
    assert TicTacToe.has_winner?(s)
  end

  test 'XOX is not a win' do
    board = %{ {0, 0} => 'X', {1, 0} => 'O', { 2, 0 } => 'X' }
    s = %GameState{board: board, player: 'O'}
    refute TicTacToe.has_winner?(s)
  end

  test 'State does not change when playing a finished board' do
    board = %{ {0, 0} => 'X', {1, 0} => 'X', { 2, 0 } => 'X' }
    s = %GameState{board: board, player: 'O'}
    assert TicTacToe.play({1, 1}, s) == s
  end

  test 'State does not change when playing a taken square' do
    board = %{ {0, 0} => 'X' }
    s = %GameState{board: board, player: 'O'}
    assert TicTacToe.play({0, 0}, s) == s
  end
end

הפקודה test מגדירה בלוק בדיקה, הפקודה assert מגדירה תנאי שחייב להחזיר ערך אמת כדי שהבדיקה תעבור והפקודה refute היא ההפך מ assert. בגלל שה State חיצוני לא צריך לשחק את כל המשחק בכדי לבדוק ניצחון, ומספיק ליצור את הלוח המנצח בצורה ישירה.

חזרו לשורת הפקודה והריצו את הבדיקות כדי לראות שכתבתם הכל כמו שצריך עד עכשיו. כך זה נראה אצלי:

$ mix test
........

Finished in 0.05 seconds
8 tests, 0 failures

Randomized with seed 600647

בשביל המשחק ננסה להכניס שינוי בקוד, למשל שהפונקציה game_over תבדוק רק את המצב שהלוח מתמלא:

  def game_over?(state) do
    Map.size(state.board) == 9
  end

הפעלה חוזרת של הבדיקות ומקבלים:

localhost:tictactoe ynonperek$ mix test
Compiling 1 file (.ex)
.....

  1) test State does not change when playing a finished board (TicTacToeTest)
     test/tictactoe_test.exs:39
     Assertion with == failed
     code:  assert TicTacToe.play({1, 1}, s) == s
     left:  %GameState{board: %{{0, 0} => 'X', {1, 0} => 'X', {2, 0} => 'X', {1, 1} => 'O'}, player: 'X'}
     right: %GameState{board: %{{0, 0} => 'X', {1, 0} => 'X', {2, 0} => 'X'}, player: 'O'}
     stacktrace:
       test/tictactoe_test.exs:42: (test)

..

Finished in 0.06 seconds
8 tests, 1 failure

Randomized with seed 997125

הבדיקות עוברות והמשחק כמעט גמור. בשבוע הבא נבנה ממשק טקסטואלי למשחק ודרכו נלמד עוד דבר או שניים על אליקסיר. אם אתם בקטע של אתגרים מוזמנים לנסות לכתוב את הממשק בעצמכם במהלך השבוע ולספר לי פה בתגובות איך הולך.

המטרה בשבוע הבא תהיה להריץ ולקבל משהו כזה:

localhost:tictactoe ynonperek$ ./tictactoe 
X/O Game.
Next Move (player: X): 0 0
 X  .  . 
 .  .  . 
 .  .  . 
Next Move (player: O): 0 1
 X  O  . 
 .  .  . 
 .  .  . 
Next Move (player: X): 1 0
 X  O  . 
 X  .  . 
 .  .  . 
Next Move (player: O): 1 1
 X  O  . 
 X  O  . 
 .  .  . 
Next Move (player: X): 2 0
 X  O  . 
 X  O  . 
 X  .  . 
Game over. X won