「Julia言語で入門するプログラミング」第3回である。未読の方は第1回、第2回を読んで欲しい。
一覧はこちら
なお、この回から「1から始めるJuliaプログラミング」を参照している箇所がある。この本はではJuliaの言語仕様について非常にコンパクトにまとめられている。そして、コンパクトなのに気になるポイントがどれも記述されているという驚異の本である。Juliaをきちんと勉強してみようと思われた方は是非購入をお勧めする。
構造体
まず、前回の内容を振り返ろう。
次のように6つの変数を駆使してプログラムを制御しているが、これは本来はモンスター/プレイヤーの2パターンと、それぞれが持つHP/攻撃力/防御力の3パターンだ。その2 * 3 = 6つの変数として表現している。これをすっきりさせたい。
モンスターHP = 30
モンスター攻撃力 = 10
モンスター防御力 = 10
プレイヤーHP = 30
プレイヤー攻撃力 = 10
プレイヤー防御力 = 10
このために、「構造体」というものを使ってみようというところで終わった。
構造体というのは、複数の変数を一まとめにしたものだ。まずは、REPLで次のように書いてみよう。struct
というのが構造体の宣言だ。structureという英語から来ている。次に続く名前の構造体を宣言しますというものだ。
julia> struct キャラクター
HP
攻撃力
防御力
end
今回定義する構造体は、「キャラクター」という名前にしている。キャラクターは内部にHP、攻撃力、防御力を持っている。このように構造体の内部の変数のことを、フィールドと呼ぶ。メンバーという呼び方をすることもある。
これが構造体の定義だ。これはあくまで「キャラクター」というのはこういうものだと定義しただけだ。キャラクターをプログラム中で使用するには、次のように使う。
julia> プレイヤー = キャラクター(30, 20, 10)
キャラクター(30, 20, 10)
プレイヤー
という変数には、キャラクター
という構造体が代入される。構造体の引数として与えられるのは、構造体内部のフィールドの値だ。単純に上から順に割り当てられる。プログラム中で、構造体のフィールドの値を取得するには、構造体.フィールド名
とする。
julia> プレイヤー.HP
30
julia> プレイヤー.攻撃力
20
julia> プレイヤー.防御力
10
これを使って、冒頭の部分を改善してみよう。まずは、キャラクターの定義をプログラム中に書く。このとき、「トップレベル」に書く必要がある。簡単にいうと、関数の内部に書くなということだ。関数の内部でしか使わない構造体だとしても、内部に書くとエラーになる。
#正しい例
struct キャラクター
HP
攻撃力
防御力
end
function main(偽乱数列)
...
end
#エラーになる例
function main(偽乱数列)
struct キャラクター
HP
攻撃力
防御力
end
...
end
キャラクターを定義することで、6つの変数を個別に扱う必要がなくなる。直接扱うのはモンスターとプレイヤーという2つの変数だけで、それぞれのHPなどはその変数の内部に管理されている。このように、構造を持った対象として管理できるため、構造体という名前になっている。
#改良前
モンスターHP = 30
モンスター攻撃力 = 10
モンスター防御力 = 10
プレイヤーHP = 30
プレイヤー攻撃力 = 10
プレイヤー防御力 = 10
これがこのようになるのだ。
#改良後
モンスター = キャラクター(30, 10, 10)
プレイヤー = キャラクター(30, 10, 10)
このように、剥き出しの変数を扱っていた部分を、構造体の変数を扱うように置き換えていくというのが今回の主題だ。置き換えは機械的な作業ではあるが、リスクを伴うので自動テストを使って安全に置き換えていこう。
リファクタリング
しばらく長いリファクタリング作業となる。なるべく丁寧に説明するつもりだが、追いつかなくなってきたら自分でコードを書いて動かしてみよう。なお、今回は練習問題は用意していない。これも本文でかなりコードをいじるからである。そのようなわけで、なるべくコードを追うだけでなく自分の環境で修正してみてほしい。
下準備
まず、ここから処理の中身をゴソッと変えていくので、最初に下準備をしておこう。main
関数の引数に、結果
という配列を追加する。これはテストのためだけの引数だ。
function main(偽乱数列, 結果)
そして、処理の終了時に、どのような結果で終了したかを記録しよう。
if モンスターHP == 0
push!(結果, "勝利")
push!(結果, "勇者HP:" * string(プレイヤーHP))
push!(結果, "モンスターHP:" * string(モンスターHP))
println("戦闘に勝利した!")
else
push!(結果, "敗北")
push!(結果, "勇者HP:" * string(プレイヤーHP))
push!(結果, "モンスターHP:" * string(モンスターHP))
println("戦闘に敗北した・・・")
end
end
そして、呼び出し元を次のように変える。
@testset "main処理リファクタリング" begin
結果 = []
main([0.1, 0.1, 0.1], 結果)
@test 結果[1] == "勝利"
@test 結果[2] == "勇者HP:10"
@test 結果[3] == "モンスターHP:0"
結果 = []
main([0.1, 0.1, 0.9], 結果)
@test 結果[1] == "敗北"
@test 結果[2] == "勇者HP:0"
@test 結果[3] == "モンスターHP:10"
結果 = []
main([0.1, 0.9, 0.1], 結果)
@test 結果[1] == "勝利"
@test 結果[2] == "勇者HP:10"
@test 結果[3] == "モンスターHP:0"
結果 = []
main([0.1, 0.9, 0.9], 結果)
@test 結果[1] == "敗北"
@test 結果[2] == "勇者HP:0"
@test 結果[3] == "モンスターHP:10"
結果 = []
main([0.9, 0.1, 0.1], 結果)
@test 結果[1] == "勝利"
@test 結果[2] == "勇者HP:10"
@test 結果[3] == "モンスターHP:0"
結果 = []
main([0.9, 0.1, 0.9], 結果)
@test 結果[1] == "敗北"
@test 結果[2] == "勇者HP:0"
@test 結果[3] == "モンスターHP:10"
結果 = []
main([0.9, 0.9, 0.1], 結果)
@test 結果[1] == "勝利"
@test 結果[2] == "勇者HP:10"
@test 結果[3] == "モンスターHP:0"
結果 = []
main([0.9, 0.9, 0.9], 結果)
@test 結果[1] == "敗北"
@test 結果[2] == "勇者HP:0"
@test 結果[3] == "モンスターHP:10"
end
めんどくさいと思うかもしれないし、実際私も少しめんどくさかった。とはいえ作業時間は10分程度だろう。実際に結果配列がどうなるかは一度処理を流して、取得すればいい。それを@test
の期待値にすればいいだけだ。これで、この先どんなに中身を変更しても、このテストが結果を保証してくれるのだ。安い投資だ。
構造体への置き換え
では準備が整ったので、まずは、下記のように変数を追加しよう。追加しただけなので、もちろん動作には影響を与えない。一応テストを通してみても通るはずだ。
function main(偽乱数列, 結果)
モンスターHP = 30
モンスター攻撃力 = 10
モンスター防御力 = 10
プレイヤーHP = 30
プレイヤー攻撃力 = 10
プレイヤー防御力 = 10
モンスター = キャラクター(30, 10, 10) #追加
プレイヤー = キャラクター(30, 10, 10) #追加
println("モンスターに遭遇した!")
println("戦闘開始!")
上の変数から置き換えていこう。モンスターHP
となっている部分を、全てモンスター.HP
に置き換える。例えば、一番上のブロックは次のようになる。
println("----------")
println("勇者の攻撃!")
モンスターダメージ = ダメージ計算(プレイヤー攻撃力, モンスター防御力)
モンスター.HP = モンスター.HP - モンスターダメージ
println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
println("モンスターの残りHP:" * string(モンスター.HP))
if モンスター.HP == 0
@test i == 3
break
end
これを実行すると、エラーになる。
setfield! immutable struct of type キャラクター cannot be changed
このエラーを取り除くために、次のように、mutable
というキーワードを構造体に付与して欲しい。何をしているのかよくわからないかもしれないが、後ほど説明しよう。
mutable struct キャラクター
HP
攻撃力
防御力
end
これでエラーはなくなり、テストも通るはずだ。同様にして、モンスター攻撃力
、モンスター防御力
、プレイヤーHP
、プレイヤー攻撃力
、プレイヤー防御力
を置き換えていこう。ついでに@test i == 3
も、もう不要なので消しておこう。
さて、そうすると、次のようになっているだろう。薄々感づいていたことではあるが、勇者が先攻のパターンと、モンスターが先攻のパターンは、攻撃側と防御側が入れ替わっているだけで、全く同じコードだ。
#勇者が先攻
println("----------")
println("勇者の攻撃!")
モンスターダメージ = ダメージ計算(プレイヤー.攻撃力, モンスター.防御力)
モンスター.HP = モンスター.HP - モンスターダメージ
println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
println("モンスターの残りHP:" * string(モンスター.HP))
if モンスター.HP == 0
break
end
#モンスターが後攻
println("----------")
println("モンスターの攻撃!")
プレイヤーダメージ = ダメージ計算(モンスター.攻撃力, プレイヤー.防御力)
プレイヤー.HP = プレイヤー.HP - プレイヤーダメージ
println("勇者は" * string(プレイヤーダメージ) * "のダメージを受けた!")
println("勇者の残りHP:" * string(プレイヤー.HP))
if プレイヤー.HP == 0
break
end
これを明確にするため、攻撃者
と防御者
という変数を導入しよう。
#勇者が先攻
攻撃者 = プレイヤー
防御者 = モンスター
println("----------")
println("勇者の攻撃!")
モンスターダメージ = ダメージ計算(プレイヤー.攻撃力, モンスター.防御力)
...
そして、プレイヤー
変数を使っている箇所を、攻撃者
に、モンスター
変数を使っている箇所を、防御者
に置き換える。
攻撃者 = プレイヤー
防御者 = モンスター
println("----------")
println("勇者の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("モンスターは" * string(防御者ダメージ) * "のダメージを受けた!")
println("モンスターの残りHP:" * string(防御者.HP))
if 防御者.HP == 0
break
end
テストが通ることを確認しておこう。
モンスターが先攻のパターンも同様に行う。今度は攻撃者
がモンスター
に、防御者
がプレイヤー
になる。
#モンスターが後攻
攻撃者 = モンスター
防御者 = プレイヤー
println("----------")
println("モンスターの攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("勇者は" * string(防御者ダメージ) * "のダメージを受けた!")
println("勇者の残りHP:" * string(防御者.HP))
if 防御者.HP == 0
break
end
コードを見ると、ほとんど同一だ。違いは、「勇者」「モンスター」という名前だけの問題だ。もちろん、これは構造体に名前のフィールドを追加することで簡単に対応できる。
mutable struct キャラクター
名前 #追加
HP
攻撃力
防御力
end
構造体を作成される部分が次のように変わる。
モンスター = キャラクター("モンスター", 30, 10, 10)
プレイヤー = キャラクター("勇者", 30, 10, 10)
そして、「勇者」「モンスター」を攻撃者.名前
、防御者.名前
などに置き換えていく。
なお、ここまで、文字列の連結には*
記号を使っていたが、文字列には$変数名
とすると変数の値を埋め込むことができる機能があるので、同時にそれも行っていきたい。見た目がすっきりする以上のこともないのだが、見た目をすっきりさせるのも大事なことだ。
julia> x = 10
10
julia> "x is $x"
"x is 10"
ただし、日本語の文中に$変数名
を埋め込んでも、区切りをうまく認識してくれないので、$(変数名)
のようにする必要がある。
julia> "xの値は$xです"
ERROR: UndefVarError: xです not defined
julia> "xの値は$(x)です"
"xの値は10です"
これを実行すると、このようになる。
#勇者が先攻
攻撃者 = プレイヤー
防御者 = モンスター
println("----------")
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
if 防御者.HP == 0
break
end
#モンスターが先攻
攻撃者 = モンスター
防御者 = プレイヤー
println("----------")
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
if 防御者.HP == 0
break
end
攻撃者と防御者を設定する箇所以外は、全く同じだ。テストは通ることを確認しておこう。しかし、今回の変更は注意点がある。println()
の中身を変更しているが、これはテストでチェックしていない。そのため、実行結果を目視で確認する必要がある。しかし、そう難しい話ではないだろう。
首尾よくここまで到達できれば、次やることは見えている。関数化だ。
次のように関数を作ろう。中身は関数に切り出す前のロジックそのままだ。
function 攻撃実行(攻撃者, 防御者)
println("----------")
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
end
そして、関数呼び出しに差し替える。
攻撃者 = プレイヤー
防御者 = モンスター
攻撃実行(攻撃者, 防御者)
if 防御者.HP == 0
break
end
とてもすっきりした!このタイミングでコード全体を一度掲載しておこう
using Test
mutable struct キャラクター
名前
HP
攻撃力
防御力
end
function ダメージ計算(攻撃力, 防御力)
return round(Int, 10 * 攻撃力/防御力)
end
function 攻撃実行(攻撃者, 防御者)
println("----------")
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
end
function main(偽乱数列, 結果)
モンスター = キャラクター("モンスター", 30, 10, 10)
プレイヤー = キャラクター("勇者", 30, 10, 10)
println("モンスターに遭遇した!")
println("戦闘開始!")
i = 0
while true
i = i + 1
if 偽乱数列[i] < 0.5
#if rand() < 0.5
#勇者が先攻
攻撃者 = プレイヤー
防御者 = モンスター
攻撃実行(攻撃者, 防御者)
if 防御者.HP == 0
break
end
#モンスターが先攻
攻撃者 = モンスター
防御者 = プレイヤー
攻撃実行(攻撃者, 防御者)
if 防御者.HP == 0
break
end
else
#モンスターが先攻
攻撃者 = モンスター
防御者 = プレイヤー
攻撃実行(攻撃者, 防御者)
if 防御者.HP == 0
break
end
#勇者が先攻
攻撃者 = プレイヤー
防御者 = モンスター
攻撃実行(攻撃者, 防御者)
if 防御者.HP == 0
break
end
end
end
if モンスター.HP == 0
push!(結果, "勝利")
push!(結果, "$(プレイヤー.名前)HP:$(プレイヤー.HP)")
push!(結果, "$(モンスター.名前)HP:$(モンスター.HP)")
println("戦闘に勝利した!")
else
push!(結果, "敗北")
push!(結果, "$(プレイヤー.名前)HP:$(プレイヤー.HP)")
push!(結果, "$(モンスター.名前)HP:$(モンスター.HP)")
println("戦闘に敗北した・・・")
end
end
構造体の意義
いったん休憩しよう。この後もリファクタリングを続けていくが、このあたりで解説を挟まないと、「Julia言語で入門するリファクタリング(その1)」になってしまう。なにより疲れる。リファクタリングは真剣勝負なのだ。
構造体というのは、複雑なプログラムを作る上でなくてはならないものだ。構造体というのはデータの抽象化だ。Juliaは基本的なデータは用意してくれている。整数、文字列、配列などなど。これらの部品を組み合わせてプログラムを作っていくわけだが、これらの部品は、大掛かりなプログラムを書くには細かすぎるのだ。
このような基本的な部品だけを使って巨大なプログラムを書くというのは、ネジやボルトのような部品から車を作れというようなものだ。確かに、車を分解していくと、ネジやボルトになるのだろう。しかし、ネジやボルトの山から車を作るのはまず不可能だ。車を作るには、エンジンとかブレーキのような、それ自体独立した機能や構造を持った部品をつくり、それを組み立てて作るはずだ。車という巨大な構造物に対して、ネジやボルトというのは細かすぎるのだ。
エンジンは無数の部品から構成されているだろうが、個々の部品の詳細は重要ではない。部品という具体物ではなく、それらが組み合わされたエンジンという物体そのものが重要なのだ。エンジンというのはやや抽象的な存在だ。一口にエンジンと言っても、いろいろなエンジンがある。色々な車には色々なエンジンが搭載されていて、そのどれもが少しずつ違うが、大枠としては同じようなものだ。高級車のエンジンはもしかすると金のシリンダーやダイヤのネジや真珠のボルトからできているかもしれないが、タイヤを動かすという機能は普通の車と変わらないはずだ。エンジンという概念が共通して持つ性質や機能というものが何か存在し、どのエンジンもそれを実現している。その実現のためには、多種多様な部品が組み合わさり内部で協調動作させることが必要だが、その内部構造というのはエンジンがエンジンとして動く限りは通常問題にならない。エンジンは抽象化されているのだ。
プログラムも同じだ。細かな部品を組み立てて、内部構造を持ったより大きな部品を作る。それが構造体だ。構造体の内部がどうなっているかは通常あまり問題にならない。構造体がデータの抽象化だというのはそういうことだ。構造体は、内部にさらに構造体を含めることができる。そうやって徐々に複雑な部品を構成していき、大きなプログラムを作るのだ。
不変と可変
構造体のキーワードのmutableについて説明しておこう。構造体は、最初に作られる時に値が設定される。モンスター = キャラクター(30, 10, 10)
のような感じだ。普通、構造体は、この値を後から変更することができない。だから、モンスター.HP = モンスター.HP - モンスターダメージ
がエラーになったのだ。
このように、初期設定された値を変更できないという性質のことを「不変」であるという。Juliaの構造体は不変オブジェクトなのだ。
不変という性質は一般的に好ましいものとみなされる。これは、「参照透明性」という概念と強く関連している。参照透過性とも言う。
「参照透明性」というのは簡単に言えば、「関数が、同じ引数での呼び出しに対して常に同じ値を返し、かつ、副作用を持たない」ということである。副作用というのは何かというと、引数を変更したり、画面に文字を表示したりという、引数を返す以外の余計なことだ。
参照透明であることのメリットは色々ある。例えば、テストが非常に容易になる。ここまでの過程で、自動テストでカバーできない変更があったのを覚えているだろうか。println
を使っていたところだ。画面に表示される文字の確認は目視でなければできなかった。画面の描画は典型的な副作用だ。乱数も自動テストの邪魔になったのだった。同じ引数を与えたとしても、乱数に依存してはテストの結果が不定になってしまう。乱数のせいで参照透明性が満たされていないのだ。だから乱数も排除する必要があった。このように、テスト容易性と参照透明性の関連は分かりやすい。
他にも、参照透明のメリットはある。例えば、次のような処理があるとする。関数fと関数gはどちらも参照透明であるとする。
a = f(x)
b = g(x)
return a + b
引数に影響を与えないので、この2つの関数の呼び出し順序は交換可能だ。だから、次のように変更することもできる。
#こうしたり
b = g(x)
a = f(x)
return a + b
#こうしたりできる
return f(x) + g(x)
さらに言うと、gとfは順番に計算する必要もなく、全く別々のマシンで並列に計算した後、計算結果を受け取って合計してもいい。副作用のある関数だと、xの値が関数内で変えられる可能性があり、fとgの順番を安易に変更することができない。参照透明であればその心配はない。参照透明性はとてもいい性質を持っているのだ。
話を戻すと、mutable
とつけない構造体は不変なのだ。すなわち、一度作ったらその要素を変更することができない。その構造体を関数に渡したとしても、関数の中で変更することはできない。つまり参照透明であることを強制できるのだ。そして、mutable
とつけることで、構造体は可変になる。すなわち、その要素をあとから変更できる。だから、キャラクターにダメージを与えることができるのだ。
では我々は、参照透明に、不変であることにこだわる必要はないのだろうか?私は安易にmutable
をつけて可変にしたが、これは良く言って判断ミス、悪く言えばキリスト教の七つの大罪に匹敵する八つ目の大罪なのではないだろうか?
実はこの問題に答えを出すことは難しい。参照透明であることは非常に重要な性質だが、かと言ってあらゆる処理が参照透明であるべきというわけでもない。変化することが自然なモデルとなる問題はいくらでもある。実際、私にはダメージを受けたキャラクターのHPが減ることは自然に思える。不変なオブジェクトのままHPが減る処理を実現するには、次のようにする必要がある。
防御者 = キャラクター(防御者.名前, 防御者.HP - 防御者ダメージ, 防御者.攻撃力, 防御者.防御力)
構造体自体の値を変更できないので、新しい構造体を作りそれに差し替えた形だ。私はこれはそんなに素晴らしい書き方に思えない。やっていることは事実上同じで、HPを減らすと言う焦点もぼやけてしまっている。ひょっとすると、プレイヤーやモンスターの行動などのイベントが発生するたびに、プレイヤーやモンスター、その他全てをひっくるめた構造体が変換されていくようなモデルにすると美しいのかもしれない。まあ、気が向いたらそのうちやってみるかもしれない。
ともかく私が言いたいのは、構造体には、可変であることが自然なものと、不変であることが自然なものがあるということである。この区別は難しい。同じような構造体でも、そのモデルの中でどのような役割を果たすかで変わることもある。一つの有力なガイドラインとして、「すべてのフィールドが同じ値だったとき、同一と判定すべきか?」という観点がある。もしもすべてのフィールドが同じ値だったときに、同一のものだと判定できるのであれば、それは不変なオブジェクトとすべきであり、そうでなければ可変なオブジェクトとすべきだという立場だ。ドメイン駆動設計と呼ばれる分野で、前者を値オブジェクト、後者をエンティティと呼んで区別したりする。
例えば、モンスターが2体登場し、同じ名前、HP、攻撃力、防御力を持っていたとしよう。これらは同一だろうか?私は違うものとみなしたい。それらはたまたま同じ値のフィールドを持つと言うだけで、実体としては別物だからだ。この場合は可変なオブジェクトとする方が良いという方針になる。
一方で、「キャラクター能力値」という名前で構造体を定義するとしよう。「キャラクター能力値」は、「名前、HP、攻撃力、防御力」をひとまとめにした構造体だ。すべて同じ値を持つ、2つの「キャラクター能力値」構造体があったとき、これらは同一だろうか?これは同一だとみなしたい。なぜなら数値や文字列の集まりでしかないからだ。この場合は不変なオブジェクトとする方が良いという方針になる。
値による同一性の判定と不変性の関係は自明ではないと思うが、確かに納得いく部分も多い。
キャラクター能力値を引数にとる関数を考えてみよう。例えばダメージの計算である。攻撃側と防御側のキャラクター能力値をとり、何かしらのダメージ計算をする。この過程でキャラクター能力値の値が変更されることは不自然だ。計算に必要なだけの情報だからだ。そのため、不変であることは十分に合理的だ。キャラクター能力値を変更したい関数があったとしたら、何かを間違えている兆候ととらえるべきだ。
一方で、キャラクターに攻撃するという関数がキャラクターそのものを引数に取り、結果、キャラクターが持つパラメータを変えることは十分にありうる。この場合は可変であることが自然なモデルとなる。
結局、あるオブジェクトに注目したとき、それが値のみが意味を持つ不変オブジェクトであるべきなのか、個々の識別が意味を持つ可変オブエジェクトなのか、ということに対して客観的で唯一な答えはない。これは我々が世界をどう認識しているかという問題で、主観が大いに入り込む部分である。
ひとつ微妙な例を出そう。あなたは敵キャラクターのAIの機能を作ろうとしている。この敵キャラクターはとびきり頭がいい設定なので(社長の息子がモデルなのだ)、1000ターンくらい先までシミュレーションしてから行動を選択するようにしたい。このとき、シミュレーションプログラムを何か作る必要があるわけだが、それには当然、その敵キャラクターとこちらのプレイヤーキャラクターの情報が必要になる。ここで、シミュレーションを行う際に、変数として設定されたキャラクターのステータスは可変であるべきか、不変であるべきか?
1000ターンのシミュレーションというものを、各ターンのシミュレーションの積み重ねととらえ、1ターンのシミュレーションが終わるたびに、結果を次のターンのシミュレーションの入力として用意するのであれば、おそらく不変オブジェクトとして構築するのが妥当なのだろう。一方、実際の戦闘の模擬として1000ターンの行動を実際に行うがの如くとらえるのであれば、可変オブジェクトとして構築するのが妥当なのだろう。どちらが正しいとも言えない。好きに作ればいい。
だから、ある問題についてあなたが下した答えと、あなた以外の人々、例えば友人、恋人、職場の同僚、高校の恩師、近所の寺の住職、握手会で握手したアイドル俳優、たまたま捕まえた国際テロリストが下した答えが異なっても、別におかしなことではないし、どちらも間違っていないということもあり得るのだ。議論はすれば良いと思うが、頭ごなしに否定してはならない。あまり教条主義的になりすぎないことが重要だ。
そのようなわけで、私はキャラクター構造体を可変にしたことを間違った判断だとは思わない。私はそれが自然なモデル化に思えたからだ。テスト容易性が損なわれたことは確かに少しもったいない。いや実際には未練たらたらだ。第1回から主張しているように、私は自動テストをとても重要なものだと考えている。しかし、何よりも優先されるだとも思っていない。私はテスト容易性と自然なモデリングのどちらかを選べと言われたら通常自然なモデリングを選ぶ。しかしこれはあくまで私が一般論としてそう思うというだけの話だ。
唯一万人の合意を取れるであろう部分は、Juliaが採用している名前の慣例である。引数の内容を変更する関数には、関数名の末尾に!
マークをつけるという慣習である。関数内部で引数が変更されるということは、やはり気になるポイントなので、完全に排除できないにしろ、簡単に見分けられるようにという配慮である。!
マークがあってもなくてもプログラムの動作は全く変わらない。これは、この関数の内部では引数が変更されるかもという点を(コンピュータではなく)人間向けに宣言している。人間の人間による人間のための宣言なのである。見逃してはならない。
不変性やら同一性やらモデリングやら、私はいまとても難しい問題を述べた。すぐに腹落ちしなくても不思議ではないが、頭の片隅には置いておいてほしい。
引数に渡された変数の値の可変性
可変と不変の話もできたので、ついでに関数の引数に渡された変数がどのような扱いになるか確認しておきたい。関数を紹介したときからいずれ話さねばならぬと思っていたのだが、なかなか混乱する部分でもあるので、少し後回しにしたのである。ある関数fとそれに渡される引数xがあるとする。fの内部でxを変更するとどうなるだろうか?
まずはいくつかの例で動きを確認しよう。それから、細かい説明をしていく。
まずは数値だ。これは簡単で、関数内部の変更の影響を受けない。
function f(x)
x = x + 1
end
a = 0
f(a)
println(a) #0
次が文字列だ。これも、関数内部の変更の影響を受けない。
function f(x)
x = x * "b"
end
a = "a"
f(a)
println(a) #a
次は配列だ。これは、関数内部の変更の影響を受ける。これは大きな違いだ。慣例に従い!
をつけておこう。
function f!(x)
push!(x, 3)
end
a = [1, 2]
f!(a)
println(a) #[1, 2, 3]
ただし、引数の値そのものの変更ではなく、引数への代入を行うと、これは影響を与えない。
function f(x)
x = [3, 4]
end
a = [1, 2]
f(a)
println(a) #[1, 2]
次は不変な構造体だ。これも、関数内部の変更の影響を受けない。
struct A
val
end
function f(x)
x = A(1)
end
a = A(0)
f(a)
println(a) #A(0)
可変な構造体はどうか。これは配列と同じような動作をする。
変数そのものへの値の操作は呼び出し元に影響を与える。
mutable struct B
val
end
function f!(x)
x.val = x.val + 1
end
b = B(0)
f!(b)
println(b) #B(1)
一方、引数の値そのものの変更ではなく、引数への代入を行うと、これは影響を与えない。
mutable struct B
val
end
function f(x)
x = B(1)
end
b = B(0)
f(b)
println(b) #B(0)
混乱したかもしれない。Juliaの公式ドキュメントにはこう書いてある。
Julia function arguments follow a convention sometimes called “pass-by-sharing”, which means that values are not copied when they are passed to functions. Function arguments themselves act as new variable bindings (new locations that can refer to values), but the values they refer to are identical to the passed values. Modifications to mutable values (such as Arrays) made within a function will be visible to the caller. This is the same behavior found in Scheme, most Lisps, Python, Ruby and Perl, among other dynamic languages.
https://docs.julialang.org/en/v1/manual/functions/
DeepL翻訳で翻訳した内容がこちらだ。
ジュリアの関数引数は “パスバイシェアリング “と呼ばれる慣習に従っており、関数に渡されても値はコピーされません。関数引数自体は新しい変数バインディング(値を参照できる新しい場所)として動作しますが、参照する値は渡された値と同じです。関数内で行われた(配列のような)変異可能な値への変更は、呼び出し元から見えるようになります。これは、Scheme、ほとんどのLisps、Python、Ruby、Perlなどの動的言語で見られる動作と同じです。
呼び出し元の変数と関数の引数は全く別の変数になるが、関数に渡された引数の値は、呼び出し元とそのまま同じものが渡されると言うのである。
であれば、なぜ数値や文字列は関数内部で変更しても呼び出し元に影響を与えないのか?これは、次のような式が、変数に設定されている値の変更ではなく、再設定だからである。
x = x + 1
もともと、x = 5
となっていたとしよう。このとき変数と値はどのような関係になっているか。言語によってまちまちなのだが、Juliaでは、x
と5
の紐付きは割とゆるい。5
という値はコンピュータのメモリのどこかに保存されており、x
はそのメモリを参照している、という関係である。
ここで、x = x + 1
とすると、メモリ上の5
が6
に変化するのではなく、メモリ上の別の領域に6
が設定され、x
が参照する先がそのメモリに変更になるのである。(実際には場合により最適化の関係で元のメモリ上の5
が6
に直接変更されるかもしれないが、このように理解しても差し支えないように設計されている。)
xが参照するメモリ上の値を直接5
から6
に変更するような命令はJuliaには用意されていない。(本当はないことはないのだが、あえて使わなければ無視できる。気になる方はRefというキーワードで検索してみよう。)
このために、数値を関数の内部でどう変更しようが呼び出し元の変数の値を勝手に変えることがないのだ。
少し話はそれるが、第一回で、x = 1
という文は、「xに1という値を代入する」と説明した。「変数に値を代入する」という言葉とよく似た意味で、「変数に値を束縛する」という言葉が使われることがある。上記の考え方を知ったら、こちらの言葉の方がイメージに近いかもしれない。x
という変数は、本来どんな値でも自由に参照できるのだが、何かの値が代入されている状態では、参照先がその値に縛られていると言うことだ。上に書いたJuliaの公式ドキュメントに、「variable bindings」とあるが、これがまさに「変数束縛」である。今後は状況に応じて「代入」と「束縛」を使い分けていく。「代入」という表現が自然なことも多いからだ。
話を戻そう。ある変数と別の変数との関係は次のようになっている。
x = 5
y = x
x = 6
println(y) #5
y = x
と書いても、y
が直接x
を参照するわけではない。y
はx
の値を参照するだけであり、そのうえ、xの値の変更に影響を受けない。x = 6
とは、x
が参照している値を6
に変化させるのではなく、6
という値をつくり、x
の参照先の値をそこに向けているからである。
数値について説明したが、文字列や不変な構造体の例も同じことだ。関数の引数に渡された値は、関数内部で別の変数に束縛される。関数内部の変数にいくら再度代入してみても、それは関数内部の変数の束縛を変更しているだけで、呼び出し元の変数の束縛は変更していない。
これと比較して、変数が参照している先のメモリ上の値が直接書き換わったかの如く動作するデータも存在する。これが配列だったり、可変な構造体だったりするわけだ。
次のような配列の操作を考えよう。
a = [1, 2, 3]
b = a
a[1] = 4
println(b) #[4, 2, 3]
このケースでは、a
はまず、[1, 2, 3]
なる配列がある領域に束縛されている。配列は複数の変数を含む広い領域である。次にb
はa
と同じ領域に束縛される。次に、a[1] = 4
で、配列の1番目の変数の束縛先が1
から4
に変更になっている。このとき、配列自体の領域は移動していないし、a
の束縛先も特に変更されない。元と同じ領域を参照しており、その一部の参照先が変わったというだけの話である。a
の値は首尾良く変わるが、結果的に、同じところを参照していたb
の値も変更になったと言うことになる。
もしもa[1] = 4
で、配列全体が全く新しい領域にコピーされ、新しい領域の1番目の変数の束縛先が変わり、a
の束縛先も新しい領域に変わっていれば、b
に影響を与えることはなかったのだが、そうはなっていない。これはおそらくプログラムの速度の問題だ。配列は要素数が非常に多くなることがある。その際、配列の要素の一部が変わっただけで全領域をコピーしていると時間がかかりすぎてしまうことになる。
そのため、通常の値とは少し動作が違うことになる。ただ、これはどのような言語であっても多かれ少なかれ同じだ。
構造体も同様で、可変な構造体は、一部のフィールドの値を再束縛できるが、その際に構造体全体の再作成、再束縛は行われない。
配列や構造体だからと言って、メモリ上の値を直接書き換えられるわけではない。(Juliaが行う最適化により書き換わることはありうる。)
ただ、配列や構造体のような複数の変数をまとめたデータ構造は、その各々の変数の束縛が変更された際に、全体としてみたときに、値が直接変わったかのように見えると言うことである。
リファクタリング再開
思ったより休憩が長引いてしまった。全然休憩にならなかった気もするが、ともあれリファクタリングの続きを行なっていこう。ちなみに、攻撃実行
関数は副作用を持つので、ここ以降では攻撃実行!
関数に変更している。
順次処理のループへの置き換え
if文の中身もキャラクターの先攻後攻が違うだけなので、ここもすっきりさせよう。
まず上側のif文の中身を変更しよう。同じ処理を変数を変えて2度実行している。
#勇者が先攻
攻撃者 = プレイヤー
防御者 = モンスター
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
break
end
#モンスターが先攻
攻撃者 = モンスター
防御者 = プレイヤー
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
break
end
とりあえず簡単に書き換えてみるとこうなる。
for i in [1, 2]
if i == 1
攻撃者, 防御者 = [プレイヤー, モンスター]
elseif i == 2
攻撃者, 防御者 = [モンスター, プレイヤー]
end
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
break
end
end
x, y = [1, 2]
というような書き方で、複数の変数に一気に代入できる。このような代入の仕方を分割代入とか、多重代入とか、分配束縛とか呼ぶ。名前はゴツいが、やっていることは便利な代入である。
さて、上記コードに書き換えてテストを実行すると、エラーになる。簡単な書き換えなのにどこをミスしたのだろうか?これはbreak
が問題となっている。
もともとは、while文の中にbreakがあったので、breakが呼ばれるとwhile文から脱出した。ところが、さらにfor文で包むことになったので、breakが呼ばれてもfor文から脱出するだけでwhileからは脱出してくれないのだ。どうやったらwhileから脱出できるだろうか?
このような、複数の繰り返しブロックの中から脱出することを大域脱出という。大域脱出はgoto文が許されるほとんど唯一の処理だ。
goto文
goto文についても簡単に触れておこう。goto文というのは、「ここまで来たら、どこそこまで飛んで行け」という制御構文だ。次の例だと、@goto 終了処理
というところに到達すると、「終了処理」と名前のついたラベル、すなわち@label 終了処理
というところまで飛んでいる。
julia> function test()
println("開始しました")
@goto 終了処理
println("なんらかの処理です")
@label 終了処理
println("終了しました")
end
test (generic function with 2 methods)
julia> test()
開始しました
終了しました
gotoはlabelさえ見つかれば、同じ関数内であればどこへでも飛んでいく。上に戻ってループ処理のようなことができたり、下に飛んで実行したくないコードを飛ばしたり、やりたい放題できる。制約の少ない極めて強力な機能だが、それゆえに使い方を間違えると大変なことになる。あまりに強力かつ誤った使い方をしやすいので、「goto文は禁止」とされることも多い。ゲームでもよく、あまりの強力さに封印された呪文があったりするが、あんな感じだ。
そんなgoto文が唯一まあここには使ってもいいんじゃないかと言われるのが、大域脱出だ。goto文がない時の大域脱出がどうなるか見てみよう。
function 大域脱出break()
脱出フラグ = false
for i in 1:10
for j in 1:10
if i + j == 15
println("i=$i, j=$j")
脱出フラグ = true
break
end
end
if 脱出フラグ
break
end
end
end
悪くはないが、ちとダサい。goto文を使うと次のようになる。余計なフラグやif文がなくすっきりしている。
function 大域脱出goto()
for i in 1:10
for j in 1:10
if i + j == 15
println("i=$i, j=$j")
@goto 終了
end
end
end
@label 終了
end
私自身、gotoを使うことはほとんどない。今から実はgotoを使うのだが、一時の話ですぐにgotoを使わなくて済む形に変える。ただ、「goto=絶対悪」ではないことだけは伝えておきたい。節度をもって使えば非常に強力なのは間違いのない機能だ。何か落とし穴のような仕様があるわけでもない。単にgoto文がフルパワーを出すと人間の頭脳が追いついていけないだけという話だ。
gotoを使った書き換え
goto文を使って処理を書き換えよう。
for i in [1, 2]
if i == 1
攻撃者, 防御者 = [プレイヤー, モンスター]
elseif i == 2
攻撃者, 防御者 = [モンスター, プレイヤー]
end
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
@goto 終了処理
end
end
whileループが終了したところに、@label 終了処理
を置いておく。
while true
...
end
@label 終了処理
if モンスター.HP == 0
...
こうするとテストが通る。めでたしめでたし。
もう少し改善しよう。2回の繰り返し処理で、数値i
がループ変数になっている。しかし、i
は結局、攻撃者
と防御者
を決めるためだけに存在する。
for i in [1, 2]
if i == 1
攻撃者, 防御者 = [プレイヤー, モンスター]
elseif i == 2
攻撃者, 防御者 = [モンスター, プレイヤー]
end
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
@goto 終了処理
end
end
であれば、代入したい変数そのものをループ変数にすればいいのではないか。
for 攻防 in [[プレイヤー, モンスター], [モンスター, プレイヤー]]
攻撃者, 防御者 = 攻防
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
@goto 終了処理
end
end
ちょっと飛躍があるかもしれない。丁寧にいこう。ループは2回まわる。1回目のループは[プレイヤー, モンスター]
が攻防
に代入され、攻撃者, 防御者 = 攻防
に分割代入される。2回目のループは、[モンスター, プレイヤー]
が攻防
に代入され、攻撃者, 防御者 = 攻防
に分割代入される。
これでもテストは通るだろう。下側のループも同様に処理すると、次のようになる。
while true
i = i + 1
if 偽乱数列[i] < 0.5
#if rand() < 0.5
for 攻防 in [[プレイヤー, モンスター], [モンスター, プレイヤー]]
攻撃者, 防御者 = 攻防
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
@goto 終了処理
end
end
else
for 攻防 in [[モンスター, プレイヤー], [プレイヤー, モンスター]]
攻撃者, 防御者 = 攻防
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
@goto 終了処理
end
end
end
end
仕上げは目の前だ。ifとelseのブロックのそれぞれの中身はほぼ同じで、行動順が違うだけだ。
乱数の結果によって、行動順を決める関数を作りたい。
イメージで言うと、このような感じだ。
function 行動順決定(プレイヤー, モンスター, 乱数)
if 乱数 < 0.5
return [[プレイヤー, モンスター], [モンスター, プレイヤー]]
else
return [[モンスター, プレイヤー], [プレイヤー, モンスター]]
end
end
この関数を使うと、メインの処理は次のようになる。かなりシンプルになった。
i = 0
while true
i = i + 1
for 攻防 in 行動順決定(プレイヤー, モンスター, 偽乱数列[i])
攻撃者, 防御者 = 攻防
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
@goto 終了処理
end
end
end
goto文の早期リターンへの置き換え
長い長いリファクタリングもついに最終章だ。先ほどgoto文を排除すると言った。どうするかというと、上記の処理を丸ごと関数にするのだ。ゲームの終了条件を満たすまでループをしている。このループを「ゲームループ」と名付けよう。適当な名付けではない。ゲームというのは基本的に、ずーっとループ処理を行っている。そのループ処理中は、ほとんど待機状態にある。たまにユーザーの入力を受けとり、ゲームの状態を変え、画面描画する、ということを繰り返しているのだ。そういったメインのループのことをゲームループと呼ぶのだ。まだゲームループと呼べるほどのループでもないのだが、意気込みをあわらしてこの名前にした。
function ゲームループ(プレイヤー, モンスター, 偽乱数列)
i = 0
while true
i = i + 1
for 攻防 in 行動順決定(プレイヤー, モンスター, 偽乱数列[i])
攻撃者, 防御者 = 攻防
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
@goto 終了処理
end
end
end
end
さて、この処理は@label 終了処理
が見つからないとなってエラーとなる。さすがのgoto文も関数の壁をぶち抜いてジャンプはできないからだ。しかし、このケースではreturn
してしまえばいいのだ。これで関数を抜けることができる。
最終的に、このような形になる。
using Test
mutable struct キャラクター
名前
HP
攻撃力
防御力
end
function ダメージ計算(攻撃力, 防御力)
return round(Int, 10 * 攻撃力/防御力)
end
function 攻撃実行!(攻撃者, 防御者)
println("----------")
println("$(攻撃者.名前)の攻撃!")
防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
防御者.HP = 防御者.HP - 防御者ダメージ
println("$(防御者.名前)は$(防御者ダメージ)のダメージを受けた!")
println("$(防御者.名前)の残りHP:$(防御者.HP)")
end
function 行動順決定(プレイヤー, モンスター, 乱数)
if 乱数 < 0.5
return [[プレイヤー, モンスター], [モンスター, プレイヤー]]
else
return [[モンスター, プレイヤー], [プレイヤー, モンスター]]
end
end
function ゲームループ(プレイヤー, モンスター, 偽乱数列)
i = 0
while true
i = i + 1
for 攻防 in 行動順決定(プレイヤー, モンスター, 偽乱数列[i])
攻撃者, 防御者 = 攻防
攻撃実行!(攻撃者, 防御者)
if 防御者.HP == 0
return
end
end
end
end
function main(偽乱数列, 結果)
モンスター = キャラクター("モンスター", 30, 10, 10)
プレイヤー = キャラクター("勇者", 30, 10, 10)
println("モンスターに遭遇した!")
println("戦闘開始!")
ゲームループ(プレイヤー, モンスター, 偽乱数列)
if モンスター.HP == 0
push!(結果, "勝利")
push!(結果, "$(プレイヤー.名前)HP:$(プレイヤー.HP)")
push!(結果, "$(モンスター.名前)HP:$(モンスター.HP)")
println("戦闘に勝利した!")
else
push!(結果, "敗北")
push!(結果, "$(プレイヤー.名前)HP:$(プレイヤー.HP)")
push!(結果, "$(モンスター.名前)HP:$(モンスター.HP)")
println("戦闘に敗北した・・・")
end
end
@testset "main処理リファクタリング" begin
結果 = []
main([0.1, 0.1, 0.1], 結果)
@test 結果[1] == "勝利"
@test 結果[2] == "勇者HP:10"
@test 結果[3] == "モンスターHP:0"
結果 = []
main([0.1, 0.1, 0.9], 結果)
@test 結果[1] == "敗北"
@test 結果[2] == "勇者HP:0"
@test 結果[3] == "モンスターHP:10"
結果 = []
main([0.1, 0.9, 0.1], 結果)
@test 結果[1] == "勝利"
@test 結果[2] == "勇者HP:10"
@test 結果[3] == "モンスターHP:0"
結果 = []
main([0.1, 0.9, 0.9], 結果)
@test 結果[1] == "敗北"
@test 結果[2] == "勇者HP:0"
@test 結果[3] == "モンスターHP:10"
結果 = []
main([0.9, 0.1, 0.1], 結果)
@test 結果[1] == "勝利"
@test 結果[2] == "勇者HP:10"
@test 結果[3] == "モンスターHP:0"
結果 = []
main([0.9, 0.1, 0.9], 結果)
@test 結果[1] == "敗北"
@test 結果[2] == "勇者HP:0"
@test 結果[3] == "モンスターHP:10"
結果 = []
main([0.9, 0.9, 0.1], 結果)
@test 結果[1] == "勝利"
@test 結果[2] == "勇者HP:10"
@test 結果[3] == "モンスターHP:0"
結果 = []
main([0.9, 0.9, 0.9], 結果)
@test 結果[1] == "敗北"
@test 結果[2] == "勇者HP:0"
@test 結果[3] == "モンスターHP:10"
end
ひとまずはここまでで十分だろう。巨大な一枚岩だった処理が、それぞれの役割を担ったデータと関数になった。美しい。感動のあまり涙を流してしまいそうだ。キラキラと輝く、宝石のように美しい涙だ。これでようやく機能を拡張していけそうだ。
その3の終わりに
この記事を書き始めたときには、第3回ともなれば、スライムやらドラゴンやら個性豊かなモンスターが登場し、手に汗握る戦闘を楽しめるころかと思っていた。実際には勇者とモンスターは未だに1対1の素手で殴り合っている。我々はそれを鑑賞するだけであり、命令を下すことすらできていない。
しかし、今回はかなりの山場だったのではないかと思っている。不変性や可変性の話、関数内部での引数の書き換えの話ができたのは大きかった。このあたりは、あらゆる場面に関連する土台の部分で、土台の部分でありながらもややこしいという困ったトピックだったのだ。ゲーム部分はさておき、プログラミング知識としては大きく進展した。
とはいえ、ゲームの方が作る作る詐欺になりつつあるので、さすがに次回は命令くらいは下せるようにはしたいと思っている。乞う、ご期待!