בואו נכתוב משחק איקס עיגול ב Elixir
אליקסיר היא אחת השפות המדליקות ביותר שאתם לא כותבים בהן וחבל. ואני יודע שבטח תשאלו תכף למה צריך בכלל עוד שפה ומה אליקסיר עושה טוב יותר משפות אחרות, אבל אלה לא השאלות החשובות. כי לאליקסיר שתי תכונות שמייחדות אותה משאר העולם: היא פונקציונאלית והמידע בה לא משתנה לעולם מרגע שנוצר, מה שנקרא 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