Julia言語で入門するプログラミング(その8)

「Julia言語で入門するプログラミング」第8回である。未読の方は第1回〜第7回を読んで欲しい。

Julia言語で入門するプログラミング(その1) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その2) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その3) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その4) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その5) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その6) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その7) - SE教育パパむううみんのブログ

前回の仕上げ

まず前回の仕上げを行おう。パラメトリック型などいろいろな案を提示したが、Holy Traitsパターンを使うようにする。「行動系統.jl」というファイルを新たに作り、下記のようにする。

#行動系統.jl
struct T攻撃系行動 end
struct Tかばう行動 end

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::T攻撃スキル) = T攻撃系行動()
行動系統(::Tかばう) = Tかばう行動()

「戦闘.jl」は次のようになる。

#戦闘.jl
include("行動系統.jl")

struct T行動
    コマンド::T行動内容
    行動者::Tキャラクター
    対象者::Tキャラクター
end

...

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動(T通常攻撃(), 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

function 行動実行!(行動::T行動)
    行動実行!(行動系統(行動.コマンド), 行動)
end

function 行動実行!(::T攻撃系行動, 行動::T行動) 
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::Tかばう行動, 行動::T行動) 
    #かばう実行!(行動.行動者, 行動.対象者) #未実装
end

...

終わってしまえばこぢんまりした修正である。

「かばう」の実装

だいぶ寄り道してしまったが、いよいよ「かばう」の実装に入ろう。

まずはテストを書いてみよう。こんなふうになるだろうか。太郎が花子をかばったあとに、ドラゴンが花子に攻撃したところ、花子はHPが減らず、太郎のHPが減っている。

@testset "かばう" begin
    太郎 = createキャラクターHP100()
    花子 = createキャラクターHP100()
    ドラゴン = createモンスターHP200攻撃力20()

    太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
    Game.行動実行!(太郎が花子をかばう)

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)
    @test 花子.HP == 100
    @test 太郎.HP == 80 
end 

まだテストを書いただけで実装していないので、テストを動かすと想定通り失敗する。では実装していこう。

テストは、createスキルで例外が発生して失敗していたので、まずはそこをカバーしよう。

function createスキル(スキルシンボル)
    ...
    elseif スキルシンボル === :かばう
        return Tかばう()
    ...
end

これで再度実行すると、次は予定通り、花子がダメージを受けて、太郎がダメージを受けていないという理由で失敗する。

さて、Tかばうの実現には3段階必要だ。

  1. 花子を太郎がかばっている状態にするという処理

  2. 花子が受けた攻撃を太郎に差し替える処理

  3. 次の太郎の行動前に花子のかばう状態を解除する処理

順に実装していこう。なお、このタイミングで下記のテスト用補助関数で防御力が1で指定されていたので10に変更している。防御力はデフォルト10の設定なのだ。(第1回参照)

function createキャラクターHP100()
    return Game.Tプレイヤー("", 100, 0, 1, 10, []) #防御力を10に変更
end

花子を太郎がかばっている状態にするという処理

まずは「花子を太郎がかばっている状態」を定義しよう。これは、Tキャラクター共通にフィールドを追加しよう。

mutable struct Tキャラクター共通データ
    ...
    かばってくれているキャラクター
    ...

「庇護者」とかの方がいいかと迷ったが、このくらいの方がわかりやすくていいだろう。長くて鬱陶しければリネームするかもしれない。

内部コンストラクタでは、フィールドの初期値としてnothingを指定しておく。花子をかばうと、このフィールドに花子が代入されることになる。

    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        ...
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing)  
    end

テストを実行しても、特に状況に変化はない。次に行動実行!(::Tかばう行動, ...)を実装しよう。

function 行動実行!(::Tかばう行動, 行動::T行動) 
    かばう実行!(行動.行動者, 行動.対象者)
end

function かばう実行!(行動者, 対象者)
    println("----------")
    println("$(行動者.名前)$(対象者.名前)を身を呈して守る構えをとった!")
    対象者.かばってくれているキャラクター = 行動者
end

再度テストを実行しても、特に状況に変化はない。ここまでは予定通りだ。

花子が受けた攻撃を太郎に差し替える処理

いよいよかばう行動の本体だ。T通常攻撃でダメージを受けるときに、かばってくれているキャラクターがいれば、防御者を差し替えてしまうという実装だ。

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
    #追加部分
    if !isnothing(防御者.かばってくれているキャラクター)
        println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
        防御者 = 防御者.かばってくれているキャラクター
    end
    #ここまで
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
    println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

これで追加したテストが通るようになった。printlnで表示しているメッセージも追加しているので、画面上からも確認しておきたい。太郎のスキルに「かばう」を追加しよう。ついでに本来は太郎のスキルではない「大振り」は外しておこう。

function main()
    ...
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう)])
    ...
end

動かしてみるとわかるが、太郎でスキルを選択しようとするとエラーで失敗する。これはTかばう名前消費MPのフィールドを持たないためだ。追加しておこう。

struct Tかばう <: Tスキル 
    名前
    消費MP
end

こうして「かばう」が選択できるようになる。

太郎のターン
行動を選択してください:
   攻撃
 > スキル
スキルを選択してください:
   連続攻撃10
 > かばう0

意気揚々と「かばう」を選択すると、衝撃的なメッセージが表示される。

太郎はドラゴンを身を呈して守る構えをとった!

これは裏切りだ。太郎はドラゴンの手先となったのだ。・・・いや、そうではない。これはバグだ。これまでは敵キャラクターが1体で攻撃系の行動しかなかったので、勝手に敵キャラクターが選ばれるようにしたのだ。ここもなんとかしよう。

問題はここだ。

#戦闘.jl
function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
    コマンド = コマンド選択(行動者)
    return T行動(コマンド, 行動者, モンスターs[1])#ここ!
end

コマンド選択時に対象も選べる必要がある。コマンド選択関数内で対象も選ぶことになるので、コマンド選択関数がT行動を返すようになるだろう。さらに、コマンド選択関数の中で、攻撃対象やかばう対象を指定する必要があるので、プレイヤーsモンスターsを引数に渡すようにする。

#戦闘.jl
function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
    return コマンド選択(行動者, プレイヤーs, モンスターs)
end
#ui.jl
function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...

コマンド選択関数の中身の変更を見ていこう。

まず、選択したコマンドに応じて、敵を選択するのか味方を選択するのかを決める必要がある。そして、敵一覧または味方一覧の中から対象者を選ぶのだ。これを実現するために、get対象リストという関数を作る。get対象リストは、選択された行動内容から、攻撃系の行動であれば敵の一覧を、そうでなければ味方の一覧を表示する。おお、これは行動系統で特徴付けられる性質そのものではないか!!

ui.jl行動系統.jlをincludeし、Holy Traitsパターンを炸裂させよう。

#ui.jl
include("行動系統.jl")
#ui.jl
function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    function get対象リスト(スキル::T行動内容)
        get対象リスト(行動系統(スキル))
    end

    function get対象リスト(::T攻撃系行動)
        return モンスターs
    end

    function get対象リスト(::Tかばう行動)
        return プレイヤーs
    end

get対象リストには、モンスターsプレイヤーsの引数がないのに、返り値として返している。これは、その外側のコマンド選択関数の引数を返しているのだ。一見すると妙なことが起きているように見えるが、外側のスコープの変数にアクセスできること自体はそんなにおかしなことではない。

julia> for i in 1:3
           function test()
               return 2i
           end
           println(test())
       end
2
4
6

そして、実際に選択したスキルから対象リストを使って画面に表示・選択できるようにしているのが下記の部分だ。まあ、そんなに解説するところはないだろう。通常攻撃の方の分岐も、以下同文という感じだ。

選択スキル = 行動者.スキルs[選択index]
対象リスト = get対象リスト(選択スキル)
if length(対象リスト) == 1
    return T行動(選択スキル, 行動者, 対象リスト[1])
else
    選択肢 = RadioMenu([s.名前 for s in 対象リスト], pagesize=4)
    選択index = request("誰を対象にしますか?:", 選択肢)
    if 選択index == -1
        println("正しいコマンドを入力してください")
        continue
    end
    対象者 = 対象リスト[選択index]
    return T行動(選択スキル, 行動者, 対象者)
end

まとめると、次のような感じになる。

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    function get対象リスト(スキル::T行動内容)
        get対象リスト(行動系統(スキル))
    end

    function get対象リスト(::T攻撃系行動)
        return モンスターs
    end

    function get対象リスト(::Tかばう行動)
        return プレイヤーs
    end

    while true
        選択肢 = RadioMenu(["攻撃", "スキル"], pagesize=4)
        選択index = request("行動を選択してください:", 選択肢)

        if 選択index == -1
            println("正しいコマンドを入力してください")
            continue
        end

        if 選択index == 1
            対象リスト = get対象リスト(T通常攻撃())
            if length(対象リスト) == 1
                return T行動(T通常攻撃(), 行動者, 対象リスト[1])
            else
                選択肢 = RadioMenu([s.名前 for s in 対象リスト], pagesize=4)
                選択index = request("誰を対象にしますか?:", 選択肢)
                if 選択index == -1
                    println("正しいコマンドを入力してください")
                    continue
                end
                対象者 = 対象リスト[選択index]
                return T行動(T通常攻撃(), 行動者, 対象者)
            end
        elseif 選択index == 2
            選択肢 = RadioMenu([s.名前 * string(s.消費MP) for s in 行動者.スキルs], pagesize=4)
            選択index = request("スキルを選択してください:", 選択肢)
            if 選択index == -1
                println("正しいコマンドを入力してください")
                continue
            end
            if 行動者.MP < 行動者.スキルs[選択index].消費MP 
                println("MPが足りません")
                continue
            end

            選択スキル = 行動者.スキルs[選択index]
            対象リスト = get対象リスト(選択スキル)
            if length(対象リスト) == 1
                return T行動(選択スキル, 行動者, 対象リスト[1])
            else
                選択肢 = RadioMenu([s.名前 for s in 対象リスト], pagesize=4)
                選択index = request("誰を対象にしますか?:", 選択肢)
                if 選択index == -1
                    println("正しいコマンドを入力してください")
                    continue
                end
                対象者 = 対象リスト[選択index]
                return T行動(選択スキル, 行動者, 対象者)
            end
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

うーん、正しく動きはするものの、汚いコードだ。ここらでリファクタリングしよう。

リファクタリング

配列から選択肢を作り、RadioMenuに表示し、選択されたインデックスを返却するところを共通化しておこう。

function RadioMenu作成(選択肢)
    while true
        r = RadioMenu(選択肢, pagesize=4)
        選択index = request("選択してください:", r)

        if 選択index == -1
            println("正しいコマンドを入力してください")
            continue
        else
            return 選択index
        end
    end
end

これで少しマシになった。

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...
    while true
        選択index = RadioMenu作成(["攻撃", "スキル"])
        if 選択index == 1
            対象リスト = get対象リスト(T通常攻撃())
            if length(対象リスト) == 1
                return T行動(T通常攻撃(), 行動者, 対象リスト[1])
            else
                選択index = RadioMenu作成([s.名前 for s in 対象リスト])
                対象者 = 対象リスト[選択index]
                return T行動(T通常攻撃(), 行動者, 対象者)
            end
        elseif 選択index == 2
            選択index = RadioMenu作成([s.名前 * string(s.消費MP) for s in 行動者.スキルs])
            if 行動者.MP < 行動者.スキルs[選択index].消費MP 
                println("MPが足りません")
                continue
            end
            選択スキル = 行動者.スキルs[選択index]

            対象リスト = get対象リスト(選択スキル)
            if length(対象リスト) == 1
                return T行動(選択スキル, 行動者, 対象リスト[1])
            else
                選択index = RadioMenu作成([s.名前 for s in 対象リスト])
                対象者 = 対象リスト[選択index]
                return T行動(選択スキル, 行動者, 対象者)
            end
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

しれっと「行動を選択してください」「スキルを選択してください」などのメッセージを、単に「選択してください」に統一している。引数で受け取れるようにしてもいいが、そこまでの価値もないと判断した。おかげでシンプルな実装になっている。提供する価値が同じなのであれば、仕様をシンプルにしていくための交渉も大切なことだ。

さて、もう一息頑張ろう。敵または味方のリストを表示して、行動対象を選択するところを共通化しよう。行動対象決定という関数名にしようと思ったが、行動対象を決定した上で、T行動型のデータを作ってreturnしているので、名前と実態がそぐわない。どうするか悩んだが、次のような長ったらしい名前にすることにした。文脈に依存しないUtility的な関数であればあまり長い名前は鬱陶しいのだが、このように文脈に依存する名前は長くても構わないだろう。

function 行動対象を選択し行動を決定(行動内容::T行動内容)
    対象リスト = get対象リスト(行動内容)
    if length(対象リスト) == 1
        return T行動(行動内容, 行動者, 対象リスト[1])
    else
        選択index = RadioMenu作成([s.名前 for s in 対象リスト])
        対象者 = 対象リスト[選択index]
        return T行動(行動内容, 行動者, 対象者)
    end
end

次のようにスッキリした。

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ....
    while true
        選択index = RadioMenu作成(["攻撃", "スキル"])
        if 選択index == 1
            return 行動対象を選択し行動を決定(T通常攻撃())
        elseif 選択index == 2
            選択index = RadioMenu作成([s.名前 * string(s.消費MP) for s in 行動者.スキルs])
            if 行動者.MP < 行動者.スキルs[選択index].消費MP 
                println("MPが足りません")
                continue
            end
            選択スキル = 行動者.スキルs[選択index]
            return 行動対象を選択し行動を決定(選択スキル)
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

あとは選択indexという変数を改善しよう。== 1という使われ方が嫌だ。行動者.スキルs[選択index]も2箇所に書かれているのでまとめると、次のようになる。

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ....
    while true
        選択肢 = ["攻撃", "スキル"]
        選択index = RadioMenu作成(選択肢)
        選択 = 選択肢[選択index]
        if 選択 == "攻撃"
            return 行動対象を選択し行動を決定(T通常攻撃())
        elseif 選択 == "スキル"
            選択index = RadioMenu作成([s.名前 * string(s.消費MP) for s in 行動者.スキルs])
            選択スキル = 行動者.スキルs[選択index]
            if 行動者.MP < 選択スキル.消費MP 
                println("MPが足りません")
                continue
            end
            return 行動対象を選択し行動を決定(選択スキル)
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

このくらいでいいだろう。

次の太郎の行動前に花子のかばう状態を解除する処理

仕上げが「次の太郎の行動前に花子のかばう状態を解除する処理」の実装だ。

まずはテストを作ってみたいが、ちょっと困ってしまう。「次の太郎の行動時」に「かばう」をどう解除するかを決めないと、テストも作れない。なので、どんな方針で実装するかを決めなければならないが、どうしたらいいだろうか?

今回は、下記のように、明示的に解除処理を入れることができるように、行動前処理という関数を追加するという方針をとろう。

function ゲームループ(プレイヤーs, モンスターs)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs) #追加
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

かなり基盤のループに部分に手を入れることになるため、「かばう」という固有の機能のためにそこまでしていいのか?という思いもあるが、行動の前に行動前処理が呼ばれる、というくらいなら基盤の拡張として自然なので、これでよしとしよう。

こうして方針が決まったので、テストコードは次のようにかける。「かばう」が解除された後は、花子のHPが減り、太郎のHPが減らないことを確認している。

@testset "かばう" begin
    ...
    太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
    Game.行動実行!(太郎が花子をかばう)

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)
    @test 花子.HP == 100
    @test 太郎.HP == 80 

    #↓追加
    Game.行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される

    Game.行動実行!(ドラゴンから花子へ攻撃)
    @test 花子.HP == 80
    @test 太郎.HP == 80 
end 

無事テストが失敗したことを確認して、実装に入ろう。

メインのループ部分に、行動前処理!関数の呼び出しを追加する。

#戦闘.jl
function ゲームループ(プレイヤーs, モンスターs)
            ...
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs) #追加
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                 ...
            end
            ...
end

行動前処理!関数の具体的な中身は次のようなものだ。今後は「かばう」以外にも色々入ってくるだろうか、今は「かばう」だけだ。

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs)
    isかばっている, 対象 = is誰かをかばっている(行動者, プレイヤーs, モンスターs)
    if isかばっている
        かばう解除!(行動者, 対象)
    end
end

is誰かをかばっている関数は次のようなものだ。

function is誰かをかばっている(行動者::Tキャラクター, プレイヤーs, モンスターs)
    全キャラクターs = vcat(プレイヤーs, モンスターs)
    for p in 全キャラクターs
        if p.かばってくれているキャラクター == 行動者
            return (true, p)
        end
    end
    return (false, nothing)
end

特徴的なのはreturn (true, p)のようにしているところだ。丸括弧で囲まれたデータのことを「タプル」と言う。タプルとは、複数の値をひとまとまりとして取り扱うことのできるデータ構造だ。配列は同じ種類のデータがたくさんあるときに使うが、タプルは構造体に似て、ざまざまな種類のデータをひとまとまりで扱うときに使う。配列は要素数が無数にあることが半ば前提のようなデータ構造だが、タプルは片手で数えられるくらいの要素数であることがほとんどだ。

単に、return preturn nothingのようにして、返り値がnothingかどうかで判定しても良かったのだが、nothingというのはあくまでnothingであり、それを「誰もかばっていない」という意味にするのはあまり好きではない。「誰かをかばっているかどうか」「それは誰か」に分けて表現するためにタプルを使った。なお、タプルにせずに単に次のように書いてもいい。

function is誰かをかばっている(行動者::Tキャラクター, プレイヤーs, モンスターs)
    ...
            return true, p
    ...
    return false, nothing
end

最後がかばう解除!である。

function かばう解除!(行動者, 対象者)
    println("$(行動者.名前)$(対象者.名前)をかばうのをやめた!")
    対象者.かばってくれているキャラクター = nothing
end

ここも本当は、(false, nothing)のようにしたいのだが、しっくりくるフィールド名が思い浮かばない。別途、is誰かにかばわれているというフィールドを複数作るほどでもないしな、という妥協の産物だ。

これでテストが通るはずだ。また、テストだけでなく実際に動かしてみよう。そのうち太郎が花子をかばう様子を確認できるだろう。面倒だったらドラゴンが常に花子を狙うようにするといい。

細かい対応

動かしているうちに、ちょっと細かい不具合や残件が気になってくるだろう。

  • 自分をかばうことができる問題

かばうの対象に自分を選んでもターンを消費するだけで嬉しくない。自分自身は選べないようにしよう。

  • スキル攻撃時のかばう処理

今のところ通常攻撃にしか「かばう」で攻撃を受ける対象を差し替える処理を入れていないので、スキル攻撃にも対応するようにしよう。

  • かばってくれている人をさらに別の人がかばってくれているとき

花子を太郎がかばい、太郎を遠藤君がかばっているとき、花子が攻撃を受けたらダメージを受けるのは太郎だろうか、遠藤君だろうか?うーん、わからない。

ドラゴンの爪が今まさに花子に襲いかかるとき、横から太郎がサッと前に飛び出し、身代わりで攻撃を受けるのはかっこいい。しかし、そこにさらにサッと遠藤君が飛び出してきて太郎をかばうと、ちょっとギャグみたいだ。太郎の面目は丸潰れだし、花子だって誰にお礼のチッスをかませばいいか困ってしまうだろう。これは良くない。直接かばってくれている人が身代わりになるようにしておこう。

  • 同じ人を二人がかばったとき

花子を太郎と遠藤君の両方がかばったとき、どちらが花子への攻撃を受けるかどうか。これもよくわからない。強いて考えれば、かばうからには、かばいやすい位置にいるのだろうから、先客がいたらダメということにしておくのがいいだろう。

  • かばう人が戦闘不能になったとき

HPが0になっても延々とかばい続けてしまってはいけないので、HPが0になったら「かばう」が解除されるようにしよう。

自分をかばうことができる問題への対応

自分をかばうことができる問題に対応するために、Tかばう行動で対象リストを出すときに、行動者を対象から除外するようにしたい。次のように書く。

function get対象リスト(::Tかばう行動)
    return filter(p -> p != 行動者, プレイヤーs)
end

filterというのは、指定された配列から、与えられた条件を満たす要素のみを抽出した配列を作る関数だ。第一引数が条件p -> p != 行動者であり、第二引数が配列プレイヤーsだ。

このp -> p != 行動者とはなんだろうか?これは「ラムダ式」と呼ばれるものだ。

ラムダ式

ラムダ式とは、匿名関数と呼ばれることもある。匿名関数というからには、名前のついていない関数である。

まず、普通の関数には名前がついている。例えば次の関数はdoubleという名前で定義されている。その中身では、xという引数を2xにする、という処理が行われている。

function double(x)
  return 2x
end

ところでこの関数定義を、「xという引数を2xにする、という処理に対して、doubleと名付けた」というように見ることもできる。

このとき、「xという引数を2xにする」という処理そのものを表現するときに使うのが「ラムダ式」だ。この場合、x -> 2xと表現される事になる。読み方は簡単で、矢印の左が引数、右が処理内容である。

ラムダ式は関数と同じように扱うことができる。例えば次のように書くと、「x -> 2xという(匿名の)関数に、引数5を作用させている」ということになり、結果10となる。

julia> (x -> 2x)(5)
10

ラムダ式は変数に代入することもできる。

julia> double = x -> 2x
julia> double(5)
10

double(5)は、変数に保持しているラムダ式に引数を渡しているのだが、あたかも関数呼び出しのように見える。ラムダ式を変数に代入するというのは、匿名関数に名前をつけるということであり、実質的に関数の定義をするのと同等である。

実際、「関数定義とは、ラムダ式で定義された本体に名前をつけるという意味である」という意味づけを明確にしている言語もあるくらいである。(興味のある方はSchemeという言語を調べてみよう)

なお、引数が複数になったときのラムダ式は次のように書ける。

julia> (x, y) -> x + y

もちろん、変数に代入することもできる。

julia> add = (x, y) -> x + y
julia> add(5, 10)
15

普通の関数のように複数行にわたる定義も可能で、その場合には、beginendでくるむ。

julia> add = (x, y) -> begin
                         println("x=$x")
                         println("y=$y")
                         println("x+y=$(x+y)")
                       end
julia> add(5, 10)
x=5
y=10
x+y=15

元の問題に戻ろう。次のようにしたのだった。

function get対象リスト(::Tかばう行動)
    return filter(p -> p != 行動者, プレイヤーs)
end

これは「プレイヤーs配列の各要素に対して、『pを引数に受け取って、p行動者が等しくないときにtrueを返す関数』を適用して、trueになった要素だけを抽出する」という意味になっている。なお、pという変数名にはプレイヤーの意味を込めた。私はラムダ式の変数名に長い名前をつけることを好まない。ラムダ式は短く簡潔に書くのがメリットだからだ。

これで、「かばう」の対象に自分自身が出てくることは無くなった。

スキル攻撃時のかばう処理への対応

これは簡単だ。まずはテストを作ろう。

@testset "かばう" begin
    ...
    @testset "連続攻撃" begin
        太郎 = createキャラクターHP100()
        花子 = createキャラクターHP100()
        ドラゴン = createモンスターHP200攻撃力20()

        太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
        Game.行動実行!(太郎が花子をかばう)

        ドラゴンから花子へ連続攻撃 = Game.T行動(Game.createスキル(:連続攻撃), ドラゴン, 花子)
        Game.行動実行!(ドラゴンから花子へ連続攻撃)
        @test 花子.HP == 100
        @test 100 - 10 * 5 <= 太郎.HP <= 100 - 10 * 2

        Game.行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される

        Game.行動実行!(ドラゴンから花子へ連続攻撃)
        @test 100 - 10 * 5 <= 花子.HP <= 100 - 10 * 2
        @test 100 - 10 * 5 <= 太郎.HP <= 100 - 10 * 2
    end
end 

これは期待通り失敗する。

実装は、T通常攻撃と同じようにしよう。

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
    println("----------")
    println("$(攻撃者.名前)$(スキル.名前)!")
    ### 追加
    if !isnothing(防御者.かばってくれているキャラクター)
        println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
        防御者 = 防御者.かばってくれているキャラクター
    end
    ###
    攻撃回数 = rand(スキル.攻撃回数min:スキル.攻撃回数max)
    for _ in 1:攻撃回数
        if rand() < スキル.命中率
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

これでテストが通る。テストはOKだが、実際に動かして確認もしておきたい。このためには、ドラゴンがスキルを使えるようにする必要があるので、その実装をしておこう。ドラゴンは選択可能な行動からランダムで1つ行動を選ぶ。残MPが残っているうちは、通常攻撃とスキルを織り交ぜて使ってきて、残MPがなくなると通常攻撃ばかりになる。

変更するのは次の関数だ。モンスターが行動するときには通常攻撃固定になっている。

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動(T通常攻撃(), 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

次のように修正しよう。get選択可能行動内容関数は、そのキャラクターの選択可能な行動内容の配列を返す。ここでもラムダ式が活躍していることを確認しておこう。

function get選択可能行動内容(行動者::Tキャラクター)
    選択可能行動内容 = T行動内容[]
    push!(選択可能行動内容, T通常攻撃())
    選択可能スキル = filter(s -> s.消費MP ≤ 行動者.MP, 行動者.スキルs)
    append!(選択可能行動内容, 選択可能スキル)
    return 選択可能行動内容
end

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    選択可能行動内容 = get選択可能行動内容(行動者)
    行動内容 = rand(選択可能行動内容)
    return T行動(行動内容, 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

ドラゴンに連続攻撃のスキルを設定しておこう。

function main()
    モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)])
    ...

これでドラゴンが連続攻撃を使ってくるようになる。連続攻撃に対しても「かばう」が発動することを確認しておこう。

キーワード引数とオプショナル引数

ところでテストの期待値の書き方がやや苦しい。

p = createプレイヤーHP100攻撃力10()
m = createモンスターHP200攻撃力20()
...
@test 100 - 10 * 5 <= p.HP <= 100 - 10 * 2

テストコードは明快さが大切なので、即値が入っていても必ずしも悪くはないのだが、ちょっとわかりづらい。100とか10とかいう値は初期設定したパラメータに依存するのだ。

次のように変更しよう。

@testset "連続攻撃" begin
    プレイヤーHP = 100
    プレイヤー攻撃力 = 10
    p = createプレイヤー(HP=プレイヤーHP, 攻撃力=プレイヤー攻撃力)
    モンスターHP = 200
    モンスター攻撃力 = 20
    m = createモンスター(HP=モンスターHP, 攻撃力=モンスター攻撃力)

    プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), p, m)
    Game.行動実行!(プレイヤーからモンスターへ攻撃)
    @test p.HP == プレイヤーHP
    @test モンスターHP - プレイヤー攻撃力/2 * 5 ≤ m.HP ≤ モンスターHP - プレイヤー攻撃力/2 * 2 

    モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), m, p)
    Game.行動実行!(モンスターからプレイヤーへ攻撃)
    @test プレイヤーHP - モンスター攻撃力/2 * 5 <= p.HP <= プレイヤーHP - モンスター攻撃力/2 * 2 
    @test モンスターHP - プレイヤー攻撃力/2 * 5 ≤ m.HP ≤ モンスターHP - プレイヤー攻撃力/2 * 2 
end 

詳細はあまり重要ではなく、即値から変数にしているだけだが、「キーワード引数」という新たなテクニックを使っている。次の部分だ。

...
p = createプレイヤー(HP=プレイヤーHP, 攻撃力=プレイヤー攻撃力)
...
m = createモンスター(HP=モンスターHP, 攻撃力=モンスター攻撃力)
...

通常、関数の引数は並び順に従って指定する必要があるが、キーワード引数を使うと、仮引数の名前を指定して値を渡すことができる。

キーワード引数は次のような文法で記述する。引数のリストは通常カンマで区切るが、セミコロンで区切った後の引数がキーワード引数となる。次の関数はキーワード引数のみ取るので、セミコロンから始まっている。

function createプレイヤー(;HP, 攻撃力)
    return Game.Tプレイヤー("", HP, 0, 攻撃力, 10, [])
end

キーワード引数を使うことで、値がずらずら並んでいるよりも、どの変数になんの値を設定したのか分かりやすくなることがある。

さらにもう1つ、「オプショナル引数」というものも紹介しておこう。これは、省略可能な引数のことである。オプショナル引数は、デフォルトで何らかの値が設定されるようになっている。呼び出し時に明示的に指定されたらその値になるが、省略したらデフォルト値が設定されるようになっている。

次のように、関数の仮引数に=100のように設定することで、その引数の指定が省略されたときデフォルト値を設定することができる。

function createプレイヤー(名前="太郎", HP=100, MP=20, 攻撃力=10, 防御力=10, スキルs=[])
    ...
end

なお、キーワード引数もオプショナル引数も、通常の引数の後ろに設定する必要がある。

これまで、テスト用のcreateプレイヤーHP100のような関数は、パラメータになんの値を設定しているかを分かりやすくするために、このような名前にしていた。しかし、次のようにキーワード引数とオプショナル引数を組み合わせると、任意の引数だけキーワード指定で渡し、それ以外の引数は省略してデフォルト値を設定することが可能になる。

function createプレイヤー(;名前="太郎", HP=100, MP=20, 攻撃力=10, 防御力=10, スキルs=[])
    return Game.Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
end

こうすると次のように、必要なパラメータだけ明示的に指定でき、それ以外のパラメータは無難な値に設定される、というようにできるのだ。

createプレイヤー(名前="花子", HP=100)

こちらの方が好ましいので、このようにしておこう。置き換え自体は単調な作業なので割愛する。

かばってくれている人をさらに別の人がかばってくれているときの対応

これに関しては、おそらく今のままの実装で大丈夫なのだが、確認しておこう。次のようなテストを書く。

@testset "花子を太郎がかばい、太郎を遠藤君がかばっているとき、太郎がダメージを受ける" begin
    太郎 = createプレイヤー(名前="太郎", HP=100)
    花子 = createプレイヤー(名前="花子", HP=100)
    遠藤君 = createプレイヤー(名前="遠藤君", HP=100)
    ドラゴン = createモンスター(HP=200, 攻撃力=20)

    太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
    Game.行動実行!(太郎が花子をかばう)

    遠藤君が太郎をかばう = Game.T行動(Game.createスキル(:かばう), 遠藤君, 太郎)
    Game.行動実行!(遠藤君が太郎をかばう)

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)

    @test 花子.HP == 100
    @test 太郎.HP == 80 
    @test 遠藤君.HP == 100
end

これは問題なく成功する。

同じ人を二人がかばったときへの対応

すでに誰かにかばわれている人は選択不可能にしよう。次の関数に手を加えることになる。

function get対象リスト(::Tかばう行動)
    return filter(p -> p != 行動者, プレイヤーs)
end

プレイヤーが誰かにかばわれていたら対象から除外するので、次のようにしよう。

function get対象リスト(::Tかばう行動)
    return filter(p -> p != 行動者 && isnothing(p.かばってくれているキャラクター), プレイヤーs)
end

遠藤君が「かばう」を使えるようにしておいてから、起動して動きを確認しておこう。

function main()
    ...
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:かばう)])
    ...
end

かばう人が戦闘不能になったとき

最後にかばう人が戦闘不能になったときである。これができたら「かばう」の実装は完了だ。

最初にテストコードを書こう。太郎のHPが0になったら、その後は花子がダメージを受けることになる。

@testset "戦闘不能になったらかばう解除" begin
    太郎 = createプレイヤー(名前="太郎", HP=30)
    花子 = createプレイヤー(名前="花子", HP=100)
    ドラゴン = createモンスター(HP=200, 攻撃力=20)

    太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
    Game.行動実行!(太郎が花子をかばう)
    @test 花子.かばってくれているキャラクター == 太郎

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)
    
    @test 花子.HP == 100
    @test 太郎.HP == 10
    @test 花子.かばってくれているキャラクター == 太郎

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)

    @test 花子.HP == 100
    @test 太郎.HP == 0

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)

    @test 花子.HP == 80
    @test 太郎.HP == 0
end

まずは素直に実装してみよう。かばう人が戦闘不能になったとき、その人が次のターンの経過を迎えた時と同じく「かばう」を解除したい。HP減少!関数に、HPが0になったときの分岐があるので、そこで実装することになるだろう。

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
        #ここに「かばう」を解除する処理を入れる。
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

防御者が、かばっていた対象の人のフィールドをいじる必要がある。そのため、この関数に、プレイヤーsモンスターsを引っ張ってくる必要がある。そしてそれらのキャラクターでループし、自分がかばっている人だったらフィールドを解除しようという目論見だ。

しかし、それはなんだか嫌だ。ダメージを受けた人のHPを減少させる処理の引数に、なぜ他のキャラクターの情報が必要なのだろうか?確かに、「かばう」の仕様として必要と言われたらその通りだが、違和感はある。

問題は、「かばっている人」が誰をかばっているかを知らないというところにある。なので、全キャラクターの情報を引っ張ってくる必要があるのだった。それ自体が違和感のある話ではある。キャラクターが「誰をかばっているか」を知ることができるようにしよう。

それでは、現状の「誰にかばってもらっているか」という情報はどうなるのだろうか?これは消すべきだろうか?消したらどうなるかというと、花子が太郎にかばってもらっているときも、花子から直接その情報を取得できず、全キャラクターから「花子をかばっている人」を見つけて攻撃を受ける対象を差し替える必要があるというわけだ。まあ別にいいのだが、そんなに躍起になって消す必要があるのだろうか?花子が「自分をかばってくれているのが太郎である」と知っていたっていいではないか。

すると、結局両方のフィールドを残せばいいという話になるが、そうすると、「太郎が花子をかばっている」「花子が太郎にかばってもらっている」という本質的に等価な情報を別々に管理する必要が出てくる。それはそれでデータ更新時に片方だけを更新して片方更新漏れするなどの不具合につながる恐れがある。それも嫌だ。

となると、「太郎が花子をかばっている」という情報をキャラクターから独立に切り出し、その情報を太郎も花子も参照するということになるだろうか?これが一番正攻法な気もするが、その機構まで組み込むのは、ちょっと過剰な気がするなあ・・・。

いろいろ悩むところだが、今回は「かばっている人」「かばってくれている人」をそれぞれ別のフィールドで管理するという案で進めてみよう。データの二重管理のデメリットはあるが、更新箇所も多くないのと、きちんとデータのチェックを入れてあげれば対処可能だろう。

というわけで、先程の"戦闘不能になったらかばう解除"のテストコードは一旦コメントアウトでもしておいて、先にデータの持ち方を変更するようにしよう。

かばっているキャラクターというフィールドを追加しよう。

mutable struct Tキャラクター共通データ
    ...
    かばっているキャラクター #追加
    かばってくれているキャラクター
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing)  #ここにも初期値(nothing)の追加を忘れずに
    ...

ここにも追加を忘れないようにしよう。

function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in [:名前, :HP, :MP, :攻撃力, :防御力, :スキルs, :かばっているキャラクター,  :かばってくれているキャラクター]  #追加
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val)
    if sym in [:名前, :HP, :MP, :攻撃力, :防御力, :スキルs, :かばっているキャラクター, :かばってくれているキャラクター] #追加
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

毎度追加するのも面倒だ。実はJuliaには構造体のフィールド名を取得するいい関数がある。fieldnamesという関数だ。これを使って、Tキャラクター共通データに保持しているフィールドを取得するようにしよう。

function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in fieldnames(Tキャラクター共通データ)
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val)
    if sym in fieldnames(Tキャラクター共通データ)
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

もっと委譲の階層が深くなり、同名のフィールドが委譲の階層の中の構造体にあちこち出てきたりしたら、このような単純な判定では失敗するかもしれないが、そのときはその時で考えよう。

テストを作っておこう。

@testset "かばう実行データチェック" begin
    太郎 = createプレイヤー(名前="太郎", HP=100)
    花子 = createプレイヤー(名前="花子", HP=100)

    太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
    Game.行動実行!(太郎が花子をかばう)

    @test 太郎.かばっているキャラクター == 花子
    @test 花子.かばってくれているキャラクター == 太郎
end

テストが失敗することを確認したら、かばう実行!かばっているキャラクターを設定しよう。さらに、不整合なデータとなっていないかのチェックを作っておこう。

function かばう実行!(行動者, 対象者)
    println("----------")
    println("$(行動者.名前)$(対象者.名前)を身を呈して守る構えをとった!")
    行動者.かばっているキャラクター = 対象者
    対象者.かばってくれているキャラクター = 行動者

    #事後条件
    かばうデータ整合性チェック(行動者)
    かばうデータ整合性チェック(対象者)
end

function かばうデータ整合性チェック(キャラクター)
    if !isnothing(キャラクター.かばっているキャラクター)
        if (キャラクター.かばっているキャラクター.かばってくれているキャラクター != キャラクター)
            throw(DomainError("$(キャラクター.名前)の「かばう」データに不整合が発生しています"))
        end
    end

    if !isnothing(キャラクター.かばってくれているキャラクター)
        if (キャラクター.かばってくれているキャラクター.かばっているキャラクター != キャラクター)
            throw(DomainError("$(キャラクター.名前)の「かばう」データに不整合が発生しています"))
        end
    end
end

「かばう」が解除される方も確認しておこう。

@testset "かばう解除データチェック" begin
    太郎 = createプレイヤー(名前="太郎", HP=100)
    花子 = createプレイヤー(名前="花子", HP=100)

    太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
    Game.行動実行!(太郎が花子をかばう)

    Game.行動前処理!(太郎, [花子], []) #「かばう」が解除される
    @test isnothing(太郎.かばっているキャラクター)
    @test isnothing(花子.かばってくれているキャラクター)
end

白状しておくと、あまりいいテストケースではない。今チェックしているのは、Tキャラクターの内部データのような性質のものだ。外部に見せる振る舞いはチェックしておくべきだが、内部データはあまりテストを作るべきではない。外部への振る舞いを変えない限りは、内部データは好き勝手に変更していい、というのが本来のあり方だ。内部データのテストを作ると、内部データを変えるたびにテストが壊れてしまうのだ。とはいえ、今のチェックしたい内容は、外部からの振る舞いとしては見えない部分で、しかしデータの不整合というのも起こしたくないので、折衷案的な立場のテストだ。

話を戻して、追加したテストが失敗することを確認してから、次の修正を加えよう。

function かばう解除!(行動者)
    if !isnothing(行動者.かばっているキャラクター)
        対象者 = 行動者.かばっているキャラクター
        println("$(行動者.名前)$(対象者.名前)をかばうのをやめた!")
        行動者.かばっているキャラクター = nothing                    
        対象者.かばってくれているキャラクター = nothing
        #事後条件
        かばうデータ整合性チェック(行動者)
        かばうデータ整合性チェック(対象者)
    end
end

これでテストが通るようになるはずだ。

ここまでが下準備で、いよいよ戦闘不能になったら「かばう」が解除されるケースだ。再度、"戦闘不能になったらかばう解除"をテストケースに加えよう。

@testset "戦闘不能になったらかばう解除" begin
    太郎 = createプレイヤー(名前="太郎", HP=30)
    花子 = createプレイヤー(名前="花子", HP=100)
    ドラゴン = createモンスター(HP=200, 攻撃力=20)

    太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
    Game.行動実行!(太郎が花子をかばう)
    @test 花子.かばってくれているキャラクター == 太郎

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)
    
    @test 花子.HP == 100
    @test 太郎.HP == 10
    @test 花子.かばってくれているキャラクター == 太郎

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)

    @test 花子.HP == 100
    @test 太郎.HP == 0

    ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
    Game.行動実行!(ドラゴンから花子へ攻撃)

    @test 花子.HP == 80
    @test 太郎.HP == 0
end

このテストは失敗する。正しく動かすために、次のように対応しよう。

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
        かばう解除!(防御者) #追加
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

テストは無事に通ったことだろう。

少し回り道をしたが、データの持ち方を工夫することで、HP減少!に対しておかしな引数を追加することなく対応することができた。

ちなみに、この変更に伴い、次の関数が不要になっているので消している。

function is誰かをかばっている(行動者::Tキャラクター, プレイヤーs, モンスターs)
    全キャラクターs = vcat(プレイヤーs, モンスターs)
    for p in 全キャラクターs
        if p.かばってくれているキャラクター == 行動者
            return true, p
        end
    end
    return false, nothing
end

また、現状で実は微妙なバグがある。戦闘不能になった際のメッセージにが、「太郎が代わりに攻撃を受ける!」「太郎は花子をかばうのをやめた!」「太郎は20のダメージを受けた!」「太郎の残りHP:0」となっているのだ。「太郎は花子をかばうのをやめた!」を最後に持ってきたいし、そもそもやめたのではなく戦闘不能でかばえないので、適切なメッセージにしたい。

しかし、これは次回以降で解決しよう。最後にもう一つ語っておきたいところがあるのだ。

欲張りセット

機能的には無事実装できたのだが、実はここまでの方針には不満がある。

「かばう」が解除される時の仕様をまとめると次のようになる。

  • 「かばう」の実行者が次の行動をするときか、戦闘不能になったときに、「かばう」が解除される。

問題は、この知識に対応する実装が、別々の場所に散らばり埋もれてしまっていることだ。この仕様について知っている人はいい。勘を働かせて、どこで解除処理が実装されているかを突き止めることができるだろう。しかし、知らない人はそうはいかない。あちこちに埋め込まれたコード片から仕様を推測するのは大変な苦行だ。

またそれとは別の観点として、「かばう」という個別のスキル固有の要件が、「HPを減少させる」という、このアプリケーションの根幹の処理に混ざり込んでしまっているという点がある。

アプリケーションには、変更がしばしば入る部分と、変更があまり入らない部分がある。このことを「安定度」という言葉で表現することがある。アプリケーションの根幹に当たる部分は、安定度が高くなければならない。

「かばう」に関する仕様はアプリケーションの根幹とは言えない。「かばう」なしでもゲームは成立する。「かばう」は枝葉である。枝葉の仕様はよく変わる。何か枝葉の仕様変更があるたびに、根幹の部分に手を入れる必要がある設計はまずい設計だ。そして、HPを減少させる処理は明らかに根幹の部分だ。HPが減少しなければゲームは成立しない。

我々は難題に直面している。HPが減少する処理で「かばう」を解除できるようにしたい。一方「かばう」の解除をHPの減少処理とは独立させたい。この欲張りセットを注文してもいいのだろうか?

いいのだ。欲張ってよろしい。この問題は実はよく知られた解決策がある。「オブザーバーパターン」と呼ばれる設計技法である。

オブザーバーパターン

オブザーバーパターンとは、デザインパターンと呼ばれる設計技法の一つである。オブザーバーとは観察者という意味である。オブザーバーパターンの説明をする前に、まずは普通のプログラムについて考えよう。

通常のプログラムでは、ある処理の中で別の処理を呼び出していく。処理Aが処理Bを呼び出すとしよう。処理Bは処理Aに叩き起こされるのである。この場合、処理Aは処理Bのことが気になって仕方がないのである。処理Aはお母さんのように処理Bに気を配っている。処理Aは処理Bにちゃんと仕事をしてほしいのである。処理Aがうまく仕事をできるかは処理Bにかかっているからである。処理Aは処理Bに頼り切っているのである。

一方、処理Bは処理Aのことをそんなに気にかけてはいないのである。引数が渡されるからしょーがなく返り値を返してやってるだけで、できれば寝ていたいのである。処理Bは自分の仕事が終わったら処理Aが仕事をきちんと終えられるかは別段気にしないのである。処理Aが自分の返り値をきちんと受け取ったかどうかすら、処理Bには関係ないのである。処理Bは処理Aがどうなろうと知ったこっちゃないのである。

このような状況を指して、「処理Aは処理Bに依存している」と表現する。処理Bが気持ちよく仕事ができるように、処理Aが面倒を見てやらなければならないのである。「かわいいかわいい処理Bちゃん、おつかいに行ってくださいな。帰ったらケーキと紅茶を用意しておきますからね。」

オブザーバーパターンはこれとは全く異なる関係にある。処理Aはステージで踊るアイドルで、処理Bは観客席に座るファンである。処理Aは処理Bのことなど知らない。処理Bは処理Aの一挙手一投足観察している。処理Aが歌を歌うと、処理Bは耳を澄ませる。処理Aがマイクを観客席に向けると、処理Bは声を枯らして熱唱する。処理Aが公演スケジュールを発表すると、処理Bは有給休暇を申請する。処理Aが結婚を発表すると、処理Bは藁人形と五寸釘を片手に丑の刻参りを始める。

もちろん、処理Bが別のアイドルに鞍替えすることもできる。それは処理Bの勝手である。処理Bは自分の好きなように観察対象を決めるし、処理Aは誰に観察されているかを意識することはない。処理Aはただ、〇〇を行ったというシグナルを発行する。処理Bはそのシグナルが発行されると、それに応じた処理を好き勝手に行う。

このように処理間の依存関係を切り離すことをオブザーバーパターンは目指す。一般的に、ソフトウェアの個々の処理は疎結合であることが望ましい。

  • 用語の整理

オブザーバーパターンは用語が少しややこしい。整理しておこう。

まず、イベントを発行する人を「サブジェクト」という。今回だと「キャラクター」が相当する。サブジェクトはさまざまな「イベント」を発行する。「キャラクターが行動するイベント」と「キャラクターのHPが減少してゼロになるイベント」が今回発行されるイベントだ。

次に、オブザーバーというのは観察者のことで、サブジェクトのことを観察する。ただ、実際にはオブザーバーは観察するというよりは、サブジェクトに対して、「イベントが発行されたら僕に教えてください」と事前に登録しているのだ。そして、サブジェクトは登録されたオブザーバーに対してイベントの通知をおこなう。サブジェクトは、オブザーバーを登録するリストを持っているのだ。

そういった関係から、サブジェクトは自分のイベントを聞く人を知っているということで、「イベントリスナー」という名前でデータを保持することが多い。オブザーバーパターンはオブザーバーが主役かと思いきや、ソースコードにはリスナーというものが出てきて混乱しがちなので注意しよう。

「オブザーバーは、サブジェクトのイベントリスナーとして登録され、サブジェクトはイベントの発行をイベントリスナーに通知する」ということである。

大丈夫だろうか?では、具体的にオブザーバーパターンを実装してみよう。

  • オブザーバーを登録できるようにする

キャラクターは自分自身を観察してくれる人を募集している。行動を行う前や、戦闘不能になったとき、それぞれのイベントに対するリスナーを登録できるフィールドを用意する。それぞれは単なる配列だ。リスナーは複数登録できるのでフィールド名にリスナーsとsをつけている。

mutable struct Tキャラクター共通データ
    ...
    行動前処理イベントリスナーs
    戦闘不能イベントリスナーs
    ...
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, [], [])  
    ...
end
  • リスナー登録する

リスナーとしては何を登録すればいいだろうか?リスナーには何が求められるのだろうか?リスナーは何ができる必要があるだろうか?

リスナーは、「かばう」を解除できる必要がある。つまり関数である。イベントを受け取ったら、該当キャラクターの「かばう」を解除できる関数がリスナーとして登録されていれば、それを実行すれば「かばう」が解除されるのだ。

つまり、かばう解除!関数のようなものがふさわしい。

function かばう解除!(行動者)
    if !isnothing(行動者.かばっているキャラクター)
        対象者 = 行動者.かばっているキャラクター
        println("$(行動者.名前)$(対象者.名前)をかばうのをやめた!")
        行動者.かばっているキャラクター = nothing                    
        対象者.かばってくれているキャラクター = nothing
        #事後条件
        かばうデータ整合性チェック(行動者)
        かばうデータ整合性チェック(対象者)
    end
end

「太郎」が行動する時、あるいは戦闘不能になる時、「太郎」を引数にかばう解除!関数が実行されるようにしたい。どうやってそんなことができるようになるのだろうか?

さて、ここで思い出して欲しいのが先ほど学んだラムダ式である。ラムダ式は次のように変数に代入することができた。

julia> double = x -> 2x
julia> double(5)
10

そして思い出して欲しい。関数とは名前のついたラムダ式なのだった。であれば、関数もラムダ式のごとく、変数に代入したり、配列に入れたりできていいはずだ。そして実際、それができるのだ。

julia> function test(x)
         return 2x
       end
test (generic function with 1 method)

julia> a = test
test (generic function with 1 method)

julia> a(5)
10

a = testのように、関数名を引数なしで呼び出すと、関数そのものを取り扱うことができる。

ここまでくればもう私の言いたいことがわかるだろう。かばう解除!関数を、イベントリスナーの配列に登録すればいいのだ。

全てのキャラクターに対してかばう解除!関数を登録しておく。コンストラクタで指定しておこう。

mutable struct Tキャラクター共通データ
    ...
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, [かばう解除!], [かばう解除!])  
    end
end
  • イベントを通知する

それぞれのイベントが発生したときにリスナーに通知するようにしよう。先程行動前処理!HP減少!に書いた、かばう解除!の処理の呼び出しは消し、代わりにイベント通知の関数を呼び出すようにしよう。

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs)
    行動前処理イベント通知!(行動者) #追加
end

function HP減少!(防御者, ダメージ)
    ...
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
        戦闘不能処理イベント通知!(防御者) #追加
    else
    ...
end

さらに、それぞれの通知処理の中でリスナーへ通知しよう。

function 行動前処理イベント通知!(行動者::Tキャラクター)
    for リスナー in 行動者.行動前処理イベントリスナーs
        リスナー(行動者)        
    end
end

function 戦闘不能処理イベント通知!(防御者::Tキャラクター)
    for リスナー in 防御者.戦闘不能イベントリスナーs
        リスナー(防御者)
    end
end

リスナーという変数には、関数が入っている。そして、その関数に引数を与えて呼び出していることで実行している。このようにして、かばう解除!処理が間接的に呼び出されることになる。

こうして、該当のイベントが発生するたびにかばう解除!関数が呼ばれることになった。かばう解除関数は、誰もかばっていない場合には空振りするようになっているので心配は無用だ。

テストを実行して問題なく完了することを確認しておこう。

オブザーバーパターンのまとめ

オブザーバーパターンは数あるデザインパターンの中でも非常に有用なものの一つである。この考え方は、現代的なGUIフレームワークなどに標準的に採用されており、一部の言語では言語機能としてこの仕組みが採用されていることもある。(C#のeventなど)

オブザーバーパターンにはメリットもデメリットもある。メリットは、処理呼び出しの依存関係を切り離すことができることだ。デメリットは、処理を追いかけるのが難しくなることだ。疎結合は一般的にはいいことで、オブザーバーパターンは疎結合を実現する手段ではあるが、ありとあらゆる箇所をオブザーバーパターンで表現することは感心しない。

例えば、「かばう」を実行したらかばっているキャラクターに対象を設定する、という処理だって、オブザーバーパターンで書くことはできる。しかし、この場合はデメリットしかない。「かばう」を実行したら誰かをかばっている状態にする、というのは極めて当たり前のことで、ここを抽象化しても嬉しくない。単に遠回りなだけだ。使い所を見極めるのが重要だ。

とはいえ、そういったことが実感できるのは、実際に使ってみた時だけだ。このような趣味のアプリであれば、最初は身につけた知識やテクニックを過剰なまでに使ってみるといいだろう。パターンを適用可能な領域をギリギリまで攻めてみよう。どうせ困るのは自分だけだし、困るといったってたいして困らないのだ。そうして適用すべき領域、適用すべきでない領域を見極めていくのだ。

第一級オブジェクト

ところでこのオブザーバーパターン、調べてみると複雑なクラス図が出てきたりして難しいことに気づくだろう。それを見たことのある人は、今回の実装が拍子抜けするほど簡単なことに驚くかもしれない。

今回見たように、関数を数値や文字列や構造体と同様に、変数に代入したり、引数に渡したり、という扱いができるという言語仕様を指して、「関数が第一級オブジェクトである」と表現することがある。今回の実装が簡単だったのは、この「関数を自由に受け渡しする」という機能によるところが大きい。(まあ普通に紹介されるオブザーバーパターンと比べて、「リスナーの削除」のような処理を作っていないというところもあるが。)

オブザーバーパターンという単語は、いわゆるGoFデザインパターンという本で有名になったのだが、当時のメインストリームにあった言語では、「関数を第一級オブジェクトとして扱う」という機能がないことが多かった。GoFデザインパターンC++で書かれているので、実際にはC言語から受け継いだ関数ポインタという機能があるのだが、Javaにはない。そして、そのような機能がないために、関数を渡したいだけの場面でもクラスを定義したり、複雑なクラスの継承関係などを設定する必要があり、ややこしいのだ。

現代的な言語仕様から見ると、足りない機能を補うために妙にややこしいことをやっているだけに見えて「古臭い」となどと言われることもあるが、本質的には重要なことを述べていることが多いので、今でも学ぶところの多い題材だと私は思う。このシリーズでも他にも紹介していけたらと思う。

第8回の終わりに

かなり時間がかかってしまったが、「かばう」処理が実装できた。今回はJulia固有の文法はほとんど説明しなかった。その代わり説明した「ラムダ式」「第一級オブジェクトとしての関数」はさまざまな言語で採用されている汎用的な機能なので、ぜひきちんと身につけたいところだ。

次回も引き続きさまざまなスキルを実装していこう。

コード

ここまでのコードを掲載しておこう。本文では触れなかったが、ちょこちょこ関数の定義をあるファイルから別のファイルに変更していたりする。

#game_exec.jl
include("game.jl")

Game.main()
#game.jl
module Game

using Random
import REPL
using REPL.TerminalMenus

include("キャラクター.jl")
include("戦闘.jl")
include("ui.jl")

function main()
    モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [createスキル(:連続攻撃)])
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:連続攻撃), createスキル(:かばう)])
    プレイヤー2 = Tプレイヤー("花子", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:かばう)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])

    println("モンスターに遭遇した!")
    println("戦闘開始!")

    ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター])

    if モンスター.HP == 0
        println("戦闘に勝利した!")
    else
        println("戦闘に敗北した・・・")
    end
end

end
#ui.jl
include("スキル.jl")
include("行動系統.jl")

function コマンド選択(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    function get対象リスト(スキル::T行動内容)
        get対象リスト(行動系統(スキル))
    end

    function get対象リスト(::T攻撃系行動)
        return モンスターs
    end

    function get対象リスト(::Tかばう行動)
        return filter(p -> p != 行動者 && isnothing(p.かばってくれているキャラクター), プレイヤーs)
    end

    function RadioMenu作成(選択肢)
        while true
            r = RadioMenu(選択肢, pagesize=4)
            選択index = request("選択してください:", r)

            if 選択index == -1
                println("正しいコマンドを入力してください")
                continue
            else
                return 選択index
            end
        end
    end

    function 行動対象を選択し行動を決定(行動内容::T行動内容)
        対象リスト = get対象リスト(行動内容)
        if length(対象リスト) == 1
            return T行動(行動内容, 行動者, 対象リスト[1])
        else
            選択index = RadioMenu作成([s.名前 for s in 対象リスト])
            対象者 = 対象リスト[選択index]
            return T行動(行動内容, 行動者, 対象者)
        end
    end

    while true
        選択肢 = ["攻撃", "スキル"]
        選択index = RadioMenu作成(選択肢)
        選択 = 選択肢[選択index]
        if 選択 == "攻撃"
            return 行動対象を選択し行動を決定(T通常攻撃())
        elseif 選択 == "スキル"
            選択index = RadioMenu作成([s.名前 * string(s.消費MP) for s in 行動者.スキルs])
            選択スキル = 行動者.スキルs[選択index]
            if 行動者.MP < 選択スキル.消費MP 
                println("MPが足りません")
                continue
            end
            return 行動対象を選択し行動を決定(選択スキル)
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

function 戦況表示(プレイヤーs, モンスターs)
    結果 = []
    push!(結果, "*****プレイヤー*****")
    for p in プレイヤーs
        push!(結果, "$(p.名前) HP:$(p.HP) MP:$(p.MP)")
    end
    push!(結果, "*****モンスター*****")
    for m in モンスターs
        push!(結果, "$(m.名前) HP:$(m.HP) MP:$(m.MP)")
    end
    push!(結果, "********************")
    return join(結果, "\n")
end
#キャラクター.jl
include("スキル.jl")

mutable struct Tキャラクター共通データ
    名前
    HP
    MP
    攻撃力
    防御力
    スキルs
    かばっているキャラクター
    かばってくれているキャラクター
    行動前処理イベントリスナーs
    戦闘不能イベントリスナーs
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        if HP < 0
            throw(DomainError("HPが負の値になっています"))
        end
        if MP < 0
            throw(DomainError("MPが負の値になっています"))
        end        
        if 攻撃力 < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        if 防御力 ≤ 0
            throw(DomainError("防御力が0または負の値になっています"))
        end 
        new(名前, HP, MP, 攻撃力, 防御力, スキルs, nothing, nothing, [かばう解除!], [かばう解除!])  
    end
end

abstract type Tキャラクター end

mutable struct Tプレイヤー <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

function Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tプレイヤー(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in fieldnames(Tキャラクター共通データ)
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val)
    if sym in fieldnames(Tキャラクター共通データ)
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

function 戦闘不能処理イベント通知!(防御者::Tキャラクター)
    for リスナー in 防御者.戦闘不能イベントリスナーs
        リスナー(防御者)
    end
end

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
        戦闘不能処理イベント通知!(防御者)
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

function MP減少!(行動者, コマンド::T通常攻撃)
end

function MP減少!(行動者, コマンド::Tスキル)
    消費MP = コマンド.消費MP
    if 消費MP < 0
        throw(DomainError("消費MPがマイナスです"))
    end    
    if 行動者.MP - 消費MP < 0
        行動者.MP = 0
    else
        行動者.MP = 行動者.MP - 消費MP
    end
end

function is行動可能(キャラクター)
    if キャラクター.HP < 0
        throw(DomainError("キャラクターのHPが負です"))
    end
    return キャラクター.HP > 0
end

function 行動可能な奴ら(キャラクターs)
    return [c for c in キャラクターs if is行動可能(c)]
end
#スキル.jl
abstract type T行動内容 end 
abstract type Tスキル <: T行動内容 end 
struct T通常攻撃 <: T行動内容 end
struct Tかばう <: Tスキル 
    名前
    消費MP
end

function Tかばう() 
    return Tかばう("かばう", 0)
end

struct T攻撃スキル <: Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    T攻撃スキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) = begin
        if 威力 < 0
            throw(DomainError("威力が負の値になっています"))
        end
        if !(0 ≤ 命中率 ≤ 1)
            throw(DomainError("命中率は0から1の間でなければなりません"))
        end        
        if 消費MP < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        if 攻撃回数min < 0
            throw(DomainError("攻撃回数minが負の値になっています"))
        end 
        if 攻撃回数max < 0
            throw(DomainError("攻撃回数maxが負の値になっています"))
        end 
        if 攻撃回数max < 攻撃回数min 
            throw(DomainError("攻撃回数maxが攻撃回数minより小さくなっています"))
        end 
        new(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max)  
    end
end

function T攻撃スキル(名前, 威力, 命中率, 消費MP) 
    return T攻撃スキル(名前, 威力, 命中率, 消費MP, 1, 1)
end

function createスキル(スキルシンボル)
    if スキルシンボル === :大振り
        return T攻撃スキル("大振り", 2, 0.4, 0)
    elseif スキルシンボル === :連続攻撃
        return T攻撃スキル("連続攻撃", 0.5, 1, 10, 2, 5)
    elseif スキルシンボル === :かばう
        return Tかばう()
    else
        Throw(DomainError("未定義のスキルが指定されました"))
    end
end

function かばうデータ整合性チェック(キャラクター)
    if !isnothing(キャラクター.かばっているキャラクター)
        if (キャラクター.かばっているキャラクター.かばってくれているキャラクター != キャラクター)
            throw(DomainError("$(キャラクター.名前)の「かばう」データに不整合が発生しています"))
        end
    end

    if !isnothing(キャラクター.かばってくれているキャラクター)
        if (キャラクター.かばってくれているキャラクター.かばっているキャラクター != キャラクター)
            throw(DomainError("$(キャラクター.名前)の「かばう」データに不整合が発生しています"))
        end
    end
end

function かばう実行!(行動者, 対象者)
    println("----------")
    println("$(行動者.名前)$(対象者.名前)を身を呈して守る構えをとった!")
    行動者.かばっているキャラクター = 対象者
    対象者.かばってくれているキャラクター = 行動者

    #事後条件
    かばうデータ整合性チェック(行動者)
    かばうデータ整合性チェック(対象者)
end

function かばう解除!(行動者)
    if !isnothing(行動者.かばっているキャラクター)
        対象者 = 行動者.かばっているキャラクター
        println("$(行動者.名前)$(対象者.名前)をかばうのをやめた!")
        行動者.かばっているキャラクター = nothing                    
        対象者.かばってくれているキャラクター = nothing
        #事後条件
        かばうデータ整合性チェック(行動者)
        かばうデータ整合性チェック(対象者)
    end
end
#戦闘.jl
include("行動系統.jl")

struct T行動
    コマンド::T行動内容
    行動者::Tキャラクター
    対象者::Tキャラクター
end

function ダメージ計算(攻撃力, 防御力)
    if 攻撃力 < 0
        throw(DomainError("攻撃力が負の値になっています"))
    end
    if 防御力 ≤ 0
        throw(DomainError("防御力が0または負の値になっています"))
    end
    return round(Int, 10 * 攻撃力/防御力)
end

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
    if !isnothing(防御者.かばってくれているキャラクター)
        println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
        防御者 = 防御者.かばってくれているキャラクター
    end
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
    println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
    println("----------")
    println("$(攻撃者.名前)$(スキル.名前)!")
    if !isnothing(防御者.かばってくれているキャラクター)
        println("$(防御者.かばってくれているキャラクター.名前)が代わりに攻撃を受ける!")
        防御者 = 防御者.かばってくれているキャラクター
    end
    攻撃回数 = rand(スキル.攻撃回数min:スキル.攻撃回数max)
    for _ in 1:攻撃回数
        if rand() < スキル.命中率
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
    return コマンド選択(行動者, プレイヤーs, モンスターs)
end

function get選択可能行動内容(行動者::Tキャラクター)
    選択可能行動内容 = T行動内容[]
    push!(選択可能行動内容, T通常攻撃())
    選択可能スキル = filter(s -> s.消費MP ≤ 行動者.MP, 行動者.スキルs)
    append!(選択可能行動内容, 選択可能スキル)
    return 選択可能行動内容
end

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    選択可能行動内容 = get選択可能行動内容(行動者)
    行動内容 = rand(選択可能行動内容)
    return T行動(行動内容, 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

function 行動実行!(行動::T行動)
    行動実行!(行動系統(行動.コマンド), 行動)
end

function 行動実行!(::T攻撃系行動, 行動::T行動) 
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::Tかばう行動, 行動::T行動) 
    かばう実行!(行動.行動者, 行動.対象者)
end

function 行動前処理イベント通知!(行動者::Tキャラクター)
    for リスナー in 行動者.行動前処理イベントリスナーs
        リスナー(行動者)        
    end
end

function 行動前処理!(行動者::Tキャラクター, プレイヤーs, モンスターs)
    行動前処理イベント通知!(行動者)
end

function  is全滅(キャラクターs)
    return all(p.HP == 0 for p in キャラクターs)
end

function is戦闘終了(プレイヤーs, モンスターs)
    return is全滅(プレイヤーs) || is全滅(モンスターs)
end

function 行動順決定(プレイヤーs, モンスターs)
    行動順 = vcat(プレイヤーs, モンスターs)
    return shuffle(行動順)
end

function ゲームループ(プレイヤーs, モンスターs)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動前処理!(行動者, プレイヤーs, モンスターs)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end
#行動系統.jl
struct T攻撃系行動 end
struct Tかばう行動 end

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::T攻撃スキル) = T攻撃系行動()
行動系統(::Tかばう) = Tかばう行動()

以下はテストコードだ。

include("game.jl")

using Test

function createプレイヤー(;名前="太郎", HP=100, MP=20, 攻撃力=10, 防御力=10, スキルs=[])
    return Game.Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
end

function createモンスター(;名前="ドラゴン", HP=400, MP=80, 攻撃力=20, 防御力=10, スキルs=[])
    return Game.Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
end

@testset "HP減少" begin

    @testset "ダメージ < HP" begin
        c = createプレイヤー(HP=100)
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
    end

    @testset "複数回ダメージ" begin
        c = createプレイヤー(HP=100)
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 94
    end   

    @testset "ダメージ > HP" begin
        c = createプレイヤー(HP=100)
        Game.HP減少!(c, 101) #101のダメージ
        @test c.HP == 0
    end

    @testset "ダメージ = HP" begin
        c = createプレイヤー(HP=100)
        Game.HP減少!(c, 100) #100のダメージ
        @test c.HP == 0
    end    
end

@testset "行動実行!" begin
    @testset "通常攻撃" begin
        p = createプレイヤー(HP=100, 攻撃力=10)
        m = createモンスター(HP=200, 攻撃力=20)

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.T通常攻撃(), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 190

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.T通常攻撃(), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 80
        @test m.HP == 190
    end    

    @testset "大振り攻撃" begin
        p = createプレイヤー(HP=100, 攻撃力=10)
        m = createモンスター(HP=200, 攻撃力=20)

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:大振り), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 180 || m.HP == 200

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:大振り), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 100 || p.HP == 60
        @test m.HP == 180 || m.HP == 200
    end 

    @testset "連続攻撃" begin
        プレイヤーHP = 100
        プレイヤー攻撃力 = 10
        p = createプレイヤー(HP=プレイヤーHP, 攻撃力=プレイヤー攻撃力)
        モンスターHP = 200
        モンスター攻撃力 = 20
        m = createモンスター(HP=モンスターHP, 攻撃力=モンスター攻撃力)

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == プレイヤーHP
        プレイヤー与ダメージ = round(Int, プレイヤー攻撃力/2)
        @test モンスターHP - プレイヤー与ダメージ * 5 ≤ m.HP ≤ モンスターHP - プレイヤー与ダメージ * 2 

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        モンスター与ダメージ = round(Int, モンスター攻撃力/2)
        @test プレイヤーHP - モンスター与ダメージ * 5 ≤ p.HP ≤ プレイヤーHP - モンスター与ダメージ * 2 
        @test モンスターHP - プレイヤー与ダメージ * 5 ≤ m.HP ≤ モンスターHP - プレイヤー与ダメージ * 2 
    end 

    @testset "かばう" begin
        @testset "かばう実行データチェック" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            @test 太郎.かばっているキャラクター == 花子
            @test 花子.かばってくれているキャラクター == 太郎
        end
        @testset "かばう解除データチェック" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            Game.行動前処理!(太郎, [花子], []) #「かばう」が解除される
            @test isnothing(太郎.かばっているキャラクター)
            @test isnothing(花子.かばってくれているキャラクター)
        end
        @testset "通常攻撃" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)
            @test 花子.HP == 100
            @test 太郎.HP == 80 

            Game.行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される

            Game.行動実行!(ドラゴンから花子へ攻撃)
            @test 花子.HP == 80
            @test 太郎.HP == 80 
        end
        @testset "連続攻撃" begin
            プレイヤーHP = 100
            太郎 = createプレイヤー(名前="太郎", HP=プレイヤーHP)
            花子 = createプレイヤー(名前="花子", HP=プレイヤーHP)
            モンスター攻撃力 = 20
            ドラゴン = createモンスター(攻撃力=モンスター攻撃力)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            ドラゴンから花子へ連続攻撃 = Game.T行動(Game.createスキル(:連続攻撃), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ連続攻撃)
            @test 花子.HP == プレイヤーHP
            モンスター与ダメージ = round(Int, モンスター攻撃力/2)
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 太郎.HP ≤ プレイヤーHP - モンスター与ダメージ * 2

            Game.行動前処理!(太郎, [花子], [ドラゴン]) #「かばう」が解除される
            #@test 太郎.行動前処理イベントリスナーs == []
            #@test 太郎.戦闘不能イベントリスナーs == []

            Game.行動実行!(ドラゴンから花子へ連続攻撃)
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 花子.HP ≤ プレイヤーHP - モンスター与ダメージ * 2
            @test プレイヤーHP - モンスター与ダメージ * 5 ≤ 太郎.HP ≤ プレイヤーHP - モンスター与ダメージ * 2
        end
        @testset "花子を太郎がかばい、太郎を遠藤君がかばっているとき、太郎がダメージを受ける" begin
            太郎 = createプレイヤー(名前="太郎", HP=100)
            花子 = createプレイヤー(名前="花子", HP=100)
            遠藤君 = createプレイヤー(名前="遠藤君", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)

            遠藤君が太郎をかばう = Game.T行動(Game.createスキル(:かばう), 遠藤君, 太郎)
            Game.行動実行!(遠藤君が太郎をかばう)

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)

            @test 花子.HP == 100
            @test 太郎.HP == 80 
            @test 遠藤君.HP == 100
        end
        @testset "戦闘不能になったらかばう解除" begin
            太郎 = createプレイヤー(名前="太郎", HP=30)
            花子 = createプレイヤー(名前="花子", HP=100)
            ドラゴン = createモンスター(HP=200, 攻撃力=20)

            太郎が花子をかばう = Game.T行動(Game.createスキル(:かばう), 太郎, 花子)
            Game.行動実行!(太郎が花子をかばう)
            @test 花子.かばってくれているキャラクター == 太郎

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)
            
            @test 花子.HP == 100
            @test 太郎.HP == 10
            @test 花子.かばってくれているキャラクター == 太郎

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)

            @test 花子.HP == 100
            @test 太郎.HP == 0

            ドラゴンから花子へ攻撃 = Game.T行動(Game.T通常攻撃(), ドラゴン, 花子)
            Game.行動実行!(ドラゴンから花子へ攻撃)

            @test 花子.HP == 80
            @test 太郎.HP == 0
        end
    end 
end

@testset "is戦闘終了" begin
    @testset begin
        @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=1)]) == false
        @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0)]) == true
        @test Game.is戦闘終了([createプレイヤー(HP=0)], [createモンスター(HP=1)]) == true
        @test Game.is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=1)], [createモンスター(HP=1)]) == false
        @test Game.is戦闘終了([createプレイヤー(HP=0), createモンスター(HP=0)], [createモンスター(HP=1)]) == true
        @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=1)]) == false
        @test Game.is戦闘終了([createプレイヤー(HP=1)], [createモンスター(HP=0), createモンスター(HP=0)]) == true
    end
end

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    elseif length(配列) == 2
        return 配列[1] != 配列[2]
    else
        先頭 = 配列[1]
        残り = 配列[2:end]
        return !(先頭 in 残り) && is全て相異なる(残り) 
    end
end

@testset "is全て相異なる" begin
    #要素数1
    @test is全て相異なる([1]) == true
    #要素数2
    @test is全て相異なる([1, 2]) == true
    @test is全て相異なる([1, 1]) == false
    #要素数3
    @test is全て相異なる([1, 1, 1]) == false    
    @test is全て相異なる([1, 1, 2]) == false
    @test is全て相異なる([1, 2, 1]) == false
    @test is全て相異なる([2, 1, 1]) == false    
    @test is全て相異なる([1, 2, 3]) == true
    @test is全て相異なる([2, 1, 3]) == true
    @test is全て相異なる([3, 2, 1]) == true
end

@testset "行動順決定" begin
    p1 = createプレイヤー()
    m1 = createモンスター()

    @testset "1vs1" begin
        行動順 = Game.行動順決定([p1], [m1])
        @test length(行動順) == 2
    end

    p2 = createプレイヤー()
    @testset "2vs1" begin
        行動順 = Game.行動順決定([p1, p2], [m1])
        @test length(行動順) == 3
    end

    m2 = createモンスター()
    @testset "1vs2" begin
        行動順 = Game.行動順決定([p1], [m1, m2])
        @test length(行動順) == 3
    end

    @testset "2vs2" begin
        行動順 = Game.行動順決定([p1, p2], [m1, m2])
        @test length(行動順) == 4
    end
end

@testset "is戦闘終了" begin
    @testset "1vs1 両者生存" begin
        p = createプレイヤー(HP=1)
        m = createモンスター(HP=1)
        @test Game.is戦闘終了([p], [m]) == false
    end

    @testset "1vs1 プレイヤー死亡" begin
        p = createプレイヤー(HP=0)
        m = createモンスター(HP=1)
        @test Game.is戦闘終了([p], [m]) == true
    end
end


@testset "is行動可能" begin
    p = createプレイヤー(HP=1)
    @test Game.is行動可能(p) == true
    p = createプレイヤー(HP=0)
    @test Game.is行動可能(p) == false
    m = createモンスター(HP=1)
    @test Game.is行動可能(m) == true
    m = createモンスター(HP=0)
    @test Game.is行動可能(m) == false
end

@testset "行動可能な奴ら" begin
    p1 = createプレイヤー(HP=1)
    @test Game.行動可能な奴ら([p1]) == [p1]
    p2 = createプレイヤー(HP=0)
    @test Game.行動可能な奴ら([p1, p2]) == [p1]
    p3 = createプレイヤー(HP=1)
    @test Game.行動可能な奴ら([p1, p2, p3]) == [p1, p3]

    m1 = createモンスター(HP=1)
    @test Game.行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1]
    m2 = createモンスター(HP=0)
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1]
    m3 = createモンスター(HP=1)
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3]
end

@testset "戦況表示" begin
    モンスター = Game.Tモンスター("ドラゴン", 400, 80, 40, 10, [])
    プレイヤー1 = Game.Tプレイヤー("太郎", 100, 20, 10, 10, [])
    プレイヤー2 = Game.Tプレイヤー("花子", 100, 20, 10, 10, [])
    プレイヤー3 = Game.Tプレイヤー("遠藤君", 100, 20, 10, 10, [])
    プレイヤー4 = Game.Tプレイヤー("高橋先生", 100, 20, 10, 10, [])
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター]

    @test Game.戦況表示(プレイヤーs, モンスターs) == 
    """
    *****プレイヤー*****
    太郎 HP:100 MP:20
    花子 HP:100 MP:20
    遠藤君 HP:100 MP:20
    高橋先生 HP:100 MP:20
    *****モンスター*****
    ドラゴン HP:400 MP:80
    ********************"""
end

Julia言語で入門するプログラミング(その7)

「Julia言語で入門するプログラミング」第7回である。未読の方は第1回〜第6回を読んで欲しい。

Julia言語で入門するプログラミング(その1) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その2) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その3) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その4) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その5) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その6) - SE教育パパむううみんのブログ

「かばう」の実装

次のスキルは、太郎くんの「かばう」だ。

  • かばう
    • 一定期間、指定した相手が受ける攻撃を代わりに受ける。

一定期間というのは、次に太郎のターンが回ってくるまでの間にしよう。その間に指定した相手(例えば花子)が受ける攻撃は全て太郎が引き受けるという男らしい技だ。消費MPは0にしよう。

ところで、「かばう」を実装するにはどうすればいいだろうか?これまでのところ、「大振り」「連続攻撃」は、Tスキルに設定値を当てはめる形で作ってきた。

struct Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    #以下コンストラクタ

同じように作ろうとすると、名前、消費MPはいいにしても、威力、命中率、攻撃回数などはしっくりこない。「かばう」はそういう行動ではないのだ。適当に設定してしまい、「かばう」独自の動作はif文で分けてもいいが、ここは型によるディスパッチを実行しよう。

まず、これまでTスキルとしてきたものは、実際にはT攻撃スキルとすべきものだ。そして、Tスキルは抽象型に格上げしよう。

abstract type Tスキル end

struct T攻撃スキル <: Tスキル
    名前
    ....
    T攻撃スキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) = begin
    ....

それに合わせて、Tスキルのコンストラクタ関連もT攻撃スキルに修正しよう。

function T攻撃スキル(名前, 威力, 命中率, 消費MP) 
    return T攻撃スキル(名前, 威力, 命中率, 消費MP, 1, 1)
end

function createスキル(スキルシンボル)
    if スキルシンボル == :大振り
        return T攻撃スキル("大振り", 2, 0.4, 0)
    elseif スキルシンボル == :連続攻撃
        return T攻撃スキル("連続攻撃", 0.5, 1, 10, 2, 5)
    else

さて、これだけで大丈夫なはずだ。結局階層構造を作ったとは言え、具体型は1種類しかなくて、メソッドの引数は全て抽象型なのだから、当然と言えば当然だ。

そして、ここから、「攻撃系スキル」と「かばう」で挙動を分けたいところだけ、型を特化させていこう。

それで、どこの部分の挙動を変えた以下でいうと、具体的にはここになるだろう。

function 行動実行!(行動)
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

要は何かの行動を行うときに、今の実装だと常に攻撃実行!関数が呼び出される。しかし、「かばう」のようなスキルの時には、攻撃ではなく特別な処理が行われて欲しい。

それで具体的な型を指定しようと思うわけだが、えーっと、どこに入れたらいいんだ?

やりたいことのイメージとしては、行動実行!関数が2パターンに分かれるイメージだ。こんな感じだろうか。

function 行動実行!(行動) #T攻撃スキルの時
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(行動) #かばうの時
    #かばう実行!(行動.行動者, 行動.対象者) #未実装
end

しかし、行動実行!関数の引数はTスキルではなく、T行動だ。折角なので、型の指定をしておこう。

function 行動実行!(行動::T行動)
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

T行動はこんな型だ。内部にコマンドを持つ。ついでにこちらにも型を指定しておこう。

struct T行動
    コマンド::Tスキル
    行動者::Tキャラクター
    対象者::Tキャラクター
end

これでも動きは変わらないはずだ・・・、と思ってテストを実行すると失敗してしまう。そう、T通常攻撃の存在をすっかり忘れていたのだ。これまでは全く型指定をしていなかったので、T行動構造体のコマンドにはどんな型の値も入ることができたが、Tスキルを指定してしまったがために、T通常攻撃が仲間外れになってしまったのだ。ごめんよー

そんなわけでもう一段上位の抽象的な型を作ろう。名前はT行動内容にしよう。これはTスキルT通常攻撃の上位に来る型だ。さらに、TかばうT攻撃スキルと並列に定義したい。こんな感じの階層になる。

  • T行動内容
    • T通常攻撃
    • Tスキル
      • T攻撃スキル
      • Tかばう

T行動内容Tかばうを定義し、それにまつわる階層関係も変更しよう。

abstract type T行動内容 end 
abstract type Tスキル <: T行動内容 end 
struct T通常攻撃 <: T行動内容 end
struct Tかばう <: Tスキル end

そして、T行動の型指定も変更しておこう。

struct T行動
    コマンド::T行動内容
    行動者::Tキャラクター
    対象者::Tキャラクター
end

やれやれ、これでテストが通るようになった。テストを作っていて本当によかった。ミスに迅速に気づけたのはテストがあったおかげだ。人間結構つまらないミスをするものだ。自分のミスを人類全体の問題にすげ替えるというのは気分の良いものだ。

何はともあれ、ここまで作った機能を壊さずに、型の階層構造を作れた。

しかし、気を緩めてはいけない。当初の問題は少しも解決していない。依然として我々には難題が待ち構えているのだ。

多重ディスパッチ

やりたいことをもう一度おさらいしておこう。行動実行!関数を型指定により2つ定義したい。しかし、引数に来るのはどちらもT行動型だ。T行動内部の変数の型に従ってディスパッチさせたい。

function 行動実行!(行動) #T通常攻撃、T攻撃スキルの時
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(行動) #かばうの時
    #かばう実行!(行動.行動者, 行動.対象者) #未実装
end

うーん、要は中身の型を取り出してディスパッチすればいいわけだよね、ということで、次のようにしてみよう。T行動は、その中身のコマンドフィールドを取り出し、その組み合わせで実際に処理される行動実行!関数がよばれる。

function 行動実行!(行動::T行動)
    行動実行!(行動, 行動.コマンド)
end

function 行動実行!(行動::T行動, コマンド::T通常攻撃) 
    攻撃実行!(行動.行動者, 行動.対象者, コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(行動::T行動, コマンド::T攻撃スキル) 
    攻撃実行!(行動.行動者, 行動.対象者, コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(行動::T行動, コマンド::Tかばう) 
    #かばう実行!(行動.行動者, 行動.対象者) #未実装
end

これまでは、1つの引数の型について関数が選択されていたが、今度は2つの引数の型に基づいて関数が選択されている。これがあの有名なJuliaの「多重ディスパッチ」である。名前はゴツイが大したことはない。と言うか、この例だと第一引数はどれもT行動で、実質的には第二引数でしかディスパッチされていないので、多重ディスパッチと言い切って良いのかよくわからないのだが、とりえず多重ディスパッチがそんなに身構えるようなものでもないことはわかってもらえたら幸いだ。

多重ディスパッチの関数の選択ルールは、単一ディスパッチ(1つの型だけでのディスパッチを多重ディスパッチと比較してこう呼ぶことがある)と同じで、もっとも特定的な型の組み合わせの関数が選択される。

重複再び

また重複だ。T通常攻撃T攻撃スキルのそれぞれで同じことをやっている。何とかならないだろうか?私はコードの重複を許さない星人なのだ。いやまあ、このくらいは見逃しても良いのではという穏健派ではあるのだが、ちょっと気持ち悪い。過激派の動向も気になるところだ。

function 行動実行!(行動::T行動, コマンド::T通常攻撃) 
    攻撃実行!(行動.行動者, 行動.対象者, コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(行動::T行動, コマンド::T攻撃スキル) 
    攻撃実行!(行動.行動者, 行動.対象者, コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

うまく両方の型の親となる抽象型を定義し、その型についてディスパッチできれば良いのだが・・・

もう一度、型の階層を確認しておこう。

  • T行動内容
    • T通常攻撃
    • Tスキル
      • T攻撃スキル
      • Tかばう

うーん、T通常攻撃T攻撃スキルに共通の親を定義して階層のどこかに差し込む、というわけにはいかなさそうだ。 どうしたらいいだろうか?

Union型

型階層とは全く別のレベルで、複数の型をひとまとめにして扱いたいことがある。今回の例がまさにそれで、攻撃系の行動であれば、階層とは無関係に同じ処理を呼びたい。このようなときにUnion型というものをつかうと良いことがある。

型Aと型BのUnion型は、Union{A, B}と記述する。こうすると、型Aにも型Bにもマッチするのだ。先程の2つの関数は次のようにまとめられる。

function 行動実行!(行動::T行動, コマンド::Union{T通常攻撃, T攻撃スキル}) 
    攻撃実行!(行動.行動者, 行動.対象者, コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

一度変数に受けることもできる。

攻撃系行動 = Union{T通常攻撃, T攻撃スキル}
function 行動実行!(行動::T行動, コマンド::攻撃系行動) 
    攻撃実行!(行動.行動者, 行動.対象者, コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

よかったよかった。これでコードの重複を許さない星人過激派を説得する労力も省けたというものだ。

パラメトリック

ところで、この「構造体の中身のフィールドの型に応じて動作を変更する」ということをもっと直接的に表現するための機能がJuliaにはあるのだ。

次のように、T行動構造体に引数として型の情報を渡すことができるのだ。

struct T行動{T}
    コマンド::T
    行動者::Tキャラクター
    対象者::Tキャラクター
end

このように書くと、「T型のT行動」となる。配列には、Int型の配列、文字列型の配列、のような概念があるが、それと同じだ。

構造体名の後の{}で囲まれたTがミソだ。これは関数の仮引数のようなイメージのものだ。型パラメータと呼ぶ。構造体を作るときに属性として渡された型の情報をTと名付けているのだ。別にTでなくても良いのだが、慣例的にTと書くことが多い。

次のように明示的に型を指定することができる。

T行動{T通常攻撃}(T通常攻撃(), プレイヤー, モンスター)

また、今回の例で言うとコマンドフィールドの型とT行動の型パラメータは同じなので、型パラメータを省略することもできる。

T行動(T通常攻撃(), プレイヤー, モンスター)

このように型に設定されたパラメータ型も型によるディスパッチの対象となる。次のように書くと、同じT行動であっても、どの型パラメータが指定されたかで動きを変えることができる。

function 行動実行!(行動::T行動{T通常攻撃})
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(行動::T行動{T攻撃スキル})
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(行動::T行動{Tかばう}) 
    #かばう実行!(行動.行動者, 行動.対象者) #未実装
end

またしても先ほどと似たような重複があるので、これを解消したい。

パラメトリック型の型パラメータの階層に従ったディスパッチ

ここまではまあそんなにややこしい話ではないが、ここからがややこしい。私はJuliaの文法のなかで一番ややこしいのがここだと思う。心してかかってほしい。

ここで、新たなコードの重複を許さない星人に登場してもらおう。彼は穏健派の私と違って過激派だ。あなたのコードを見つけて「ややっ重複ではないか!」と叫んだ彼はあなたに銃を突きつけて重複を無くすように通達してきた。穏健派の私はただオロオロするばかりで役に立たない。

あなたは震える手で次のように書いた。そう、どちらの型がきても良いようにするにはUnionだ。次のようにするとT行動{T通常行動}でもT行動{T攻撃スキル}でもマッチするはずだ。これでうまくいくはずだ。

function 行動実行!(行動::T行動{Union{T通常攻撃, T攻撃スキル}})
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

そして、エラーが発生する。

MethodError: no method matching 行動実行!(::Main.Game.T行動{Main.Game.T通常攻撃})

銃口は容赦無く火を吹き、あなたの見開かれた目に吹き飛ばされる私の姿が映る。私があなたをかばったのだ。かっこいい私。映画のヒーローのような私。ちなみに私は防弾チョッキを着ているので安心して欲しい。ここで重要なのは私があなたの命を救ったという事実だ。それをしっかりと胸に刻んで先に進もう。

発想は良かった。{T}の部分でディスパッチが効かせるのであれば、そこに先ほどうまくいったUnionを使ってみると言うのは自然な発想だ。ところがJuliaはこれを許してくれない。なぜだろうか?

過激派の彼は再度銃口をあなたに向けた。重複を無くすまで許してはくれないのだ。穏健派の私はただオロオロするばかりで役に立たない。

あなたは震える手で次のように書いた。そうだ、とりあえず階層の最上位を指定してしまおう。現状、型階層はこうなっている。

  • T行動内容
    • T通常攻撃
    • Tスキル
      • T攻撃スキル
      • Tかばう

なので、次のようにするとT行動{T通常行動}でもT行動{T攻撃スキル}でもマッチするはずだ。これでうまくいくはずだ。

function 行動実行!(行動::T行動{T行動内容})
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

そして、エラーが発生する。

MethodError: no method matching 行動実行!(::Main.Game.T行動{Main.Game.T通常攻撃})

銃口は容赦無く火を吹き、あなたの見開かれた目に吹き飛ばされる私の姿が映る。私がまたあなたをかばったのだ。かっこいい私。映画のヒーローのような私。ちなみに私はまだ防弾チョッキを脱いでいなかったので安心して欲しい。ここで重要なのは私があなたの命を2度も救ったという事実だ。それをしっかりと胸に刻んで先に進もう。

あなたはT通常攻撃T攻撃スキルが従う型階層構造と、T行動{T通常攻撃}T行動{T攻撃スキル}が従う型階層構造は同様であって欲しいと願った。しかし、残念なことに、パラメトリック型は型パラメータの階層構造には従わないのだ。そのため、T攻撃スキル <: T行動内容が成り立っても、T行動{T攻撃スキル} <: T行動{T行動内容}は成り立たない。

しかし、明示的に記述すれば、パラメトリック型の型パラメータの階層に従って関数のディスパッチを行ってくれるのだ。それを今から説明する。でなければ命がいくつあっても足りない。

Unionの例に戻り、次のように表記しよう。

function 行動実行!(行動::T行動{T}) where T <:Union{T通常攻撃, T攻撃スキル}
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

T行動{T}と抽象的に定義しておき、その後のwhereで「TとはT <:Union{T通常攻撃, T攻撃スキル}を満たす型なんですよ」と説明しているのだ。こうすることでメソッドは期待通りにディスパッチすることができるのだ。

全く同様のことを、Tをすっぽり置き換えたイメージで下のように書くことができる。

function 行動実行!(行動::T行動{<:Union{T通常攻撃, T攻撃スキル}})
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

Tがなくなり、一見すると奇妙な表記法に見えるかもしれない。初見では、上のwhereを使った書き方の方が理解しやすいだろうが、慣れると下の書き方でもすぐに理解できるようになる。そのうち、簡潔な下の書き方をするようになるだろう。

こうしてメソッドは正しくディスパッチされ、無事エラーなく実行されることとなった。コードの重複がなくなると、過激派の彼はあっさりと引き下がった。良かった良かった。

同様に、T行動内容を指定したければ、次のようにすると良い。

function 行動実行!(行動::T行動{<:T行動内容})
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

これも、「T行動内容の子となる型を型パラメータに持つT行動」と読めるだろう。

これがJuliaのパラメトリック型の概要である。本当はパラメトリックな抽象型というのもあるのだが、ひとまず置いておこう。そのうち出てくるかもしれない。

Holy Traits パターン

今回はJuliaの型システムについてかなり詳しく説明することとなった。ここまできたらHoly Traitsパターンを紹介せずにはいられない。

これはJuliaの文法の説明ではない。Juliaの文法に則ってプログラムを組むときの「工夫」のいう部類の話である。このような工夫のなかで特に応用範囲が広く良く使われるものを「デザインパターン」と呼ぶ。デザインといっても、この場合は画面上の見栄えの話ではない。プログラムの設計の話である。

複雑なプログラムを書いていると、色々な問題で同じような設計上の課題に直面することがある。そのようなときに、このようにしたらうまくいくんだよ、というおすすめ設計集のようなものだ。

Holy Traitsパターンは、そのようなものの一つである。Holyさんが提唱したTraitsパターンという意味である。Traitsというのは特性という意味の単語である。Juliaでは型階層はピラミッド構造になるが、ピラミッド構造とは別に特性を抽出して、その特性に応じた振る舞いをさせたいというときに便利なパターンだ。

今回のターゲットは、T通常攻撃T攻撃スキルだ。これらは同じく攻撃系の行動でありながら、スキルの階層構造としてはまとまっていない。そのため、関数のディスパッチをさせるために、Union{T通常攻撃, T攻撃スキル}を定義する必要があった。これは少々その場しのぎ感が否めない。加法的ではないのだ。ソフトウェアの重要な設計原則のひとつに「開放・閉鎖原則」というものがあり、詳しくは下記の記事でコッテリと語っているのだが、「他の攻撃系の行動が入ってきたときにUnion{T通常攻撃, T攻撃スキル}を直さないといけないってズーッとおぼえておかないといけないの?いろんなところでUnionを使ったらそれ全部?」という疑問に対する答えだと思っておいて欲しい。

非オブジェクト指向言語Juliaで書くオブジェクト指向 - SE教育パパむううみんのブログ

それではHoly Traitsパターンについて説明しよう。まずは次のように構造体を定義しよう。空っぽの構造体だ。この構造体はメソッドのディスパッチのためだけに定義される構造体なのだ。

struct T攻撃系行動 end
struct Tかばう行動 end

続いて、次のような関数を定義しよう。行動系統という関数は、与えられた行動内容に従って、上記の構造体のどちらかを作る。なお、仮引数の変数は使わず、型でディスパッチされることだけが大事なので、変数名は省略されている。

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::T攻撃スキル) = T攻撃系行動()
行動系統(::Tかばう) = Tかばう行動()

ここまでが準備だ。そして次でHoly Traitsパターンの具体的なディスパッチが炸裂している。まず最初の行動実行!で中身のコマンドフィールドを取り出しているところは同じだ。ただ、それをコマンドの型で直接ディスパッチするのではなく、行動系統という関数を通すことで、T攻撃系行動Tかばう行動に変換しているところがミソだ。

function 行動実行!(行動::T行動)
    行動実行!(行動系統(行動.コマンド), 行動)
end

function 行動実行!(::T攻撃系行動, 行動::T行動) 
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function 行動実行!(::Tかばう行動, 行動::T行動) 
    #かばう実行!(行動.行動者, 行動.対象者) #未実装
end

下2つの関数の1つ目の引数はディスパッチのためだけの引数なので型しか指定されていない。Unionがなくなり随分スッキリとした。今は行動実行!関数しかないのでよくわからないが、こうすることで、状況はずっと改善される。

現在の分類方法は下記のように型だけで判定する単純なものなので、Unionを使った場合とHoly Traitsパターンは大差ない。

行動系統(::T通常攻撃) = T攻撃系行動()
行動系統(::T攻撃スキル) = T攻撃系行動()
行動系統(::Tかばう) = Tかばう行動()

だが、行動系統関数は普通の関数なので、内部にもっともっと複雑な条件を加えることもできる。引数の型だけでなく値に応じてT行動系行動などの値を決めることも可能なのだ。このようにHoly Traitsパターンは非常に柔軟な仕組みと言える。

Holy Traitsパターンは、知らなければ空っぽの構造体ばかりを定義しているよくわからないテクニックだが、Juliaの型システムを柔軟に使いこなすための強力な武器で、よく使われる。一度理解しておくと恐るにたらないので、しっかりと理解しておこう。

第7回の終わりに

今回でJuliaの型システムで重要なポイントをかなり解説した。文章の量そのものはいつもに比べてこぢんまりしているが、難しさはかなりのものだ。パラメトリック型は抽象的な概念のため、一見して理解することは難しいかもしれないが、重要な概念のためしっかりと身につけておきたい。

今回は「かばう」の実装はまるで進まなかった。ここまでで文法事項の解説はかなりカバーできているので、次回はゴリゴリとコードを書いていきたい。

Julia言語で入門するプログラミング(その6)

「Julia言語で入門するプログラミング」第6回である。未読の方は第1回〜第5回を読んで欲しい。

Julia言語で入門するプログラミング(その1) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その2) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その3) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その4) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その5) - SE教育パパむううみんのブログ

スキルの実装

前回の続きからのスタートだ。順番にスキルを実装していこう。とりあえず、中途半端に実装した「大振り」をきちんとさせたい。

大振りの実装

大振りとはこんな技だ。

  • 大振り
    • 命中率は低いが通常の2倍の威力の攻撃を行う。

スキルを表現するための構造体を作ろう。今のところ出てきている要素は、スキルの名前、威力、命中率だ。

struct Tスキル
    名前
    威力
    命中率
end

大振りはスキルとして実装するとして、通常攻撃の扱いをどうするかは悩むところだ。「通常攻撃」という名前で、威力は1倍、命中率100%のスキルとして扱うか、明確に別のものとするかだ。スキルとして統一して扱った方がいい気もする。しかし、スキルとして扱ってはいけないシーンというものはないだろうか。

この問題に答えはないのだが、私はこういった判断は、内部の設計や実装を判断基準に置かない方がいいと思う。アプリケーションを外部から見たときの見え方を基準におくべきだ。なぜならば、アプリケーションとは常に進化しつづけるものであり、進化の圧力はたいてい外部から来るからである。外部というのは顧客かも知れないしマーケティング部門かも知れないしデザイン部門かも知れないしホワイトハウスかも知れないが、とにかく開発内部ではない。そして、外部からの圧力は、抗うことはできてもコントロールすることはできない。

アプリケーションが通常攻撃をその他のスキルとは違ったものとして見せるのであれば、それは内部でも違ったものとして扱うべきである。きっと、通常攻撃の時だけこうしてほしい、スキルに関してこうしてほしい、という要望が来るだろうからだ。逆に、通常攻撃がスキルの一種であるかのように見えるアプリケーションであれば、そのような要望が来る可能性は低いし、来たとしても時間がかかる理由としては受け入れられやすい。「いやー、他のスキルからこれだけを引き剥がすのはいろいろ大変でしてね・・・」というわけだ。そんな甘い言い訳がホワイトハウスに通用するのかは知らない。

そのようなわけで、このゲームが通常攻撃をどのように扱うかを判断基準にしたい。実のところ、その点をまだきちんと決めてはいなかったのだが、伝統的なRPGゲームと同じように、通常攻撃は明確に別のコマンドにしておこう。「攻撃」というコマンドで通常攻撃を行い、「スキル」というコマンドで特殊なスキルを使えるようにする。

というわけで、Tスキルとは別にT通常攻撃というものを作ろう。

struct T通常攻撃
    名前
    威力
    命中率
end

これらを使って、下記の攻撃実行!関数を修正する。

function 攻撃実行!(攻撃者, 防御者, コマンド)
    println("----------")
    if コマンド == "1"
        println("$(攻撃者.名前)の攻撃!")
        防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
        HP減少!(防御者, 防御者ダメージ)
        println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
        println("$(防御者.名前)の残りHP:$(防御者.HP)")
    elseif コマンド == "2"
        println("$(攻撃者.名前)の大振り!")
        if rand() < 0.4
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

それぞれの型で特定化した関数を作り、if文の中身を移植しただけのものが次になる。ただし、Tスキル型に特化した方は、引数名を「コマンド」から「スキル」に変更した。

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
    println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
    println("----------")
    println("$(攻撃者.名前)$(スキル.名前)!")
    if rand() < スキル.命中率
        防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
        HP減少!(防御者, 防御者ダメージ)
        println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
        println("$(防御者.名前)の残りHP:$(防御者.HP)")
    else
        println("攻撃は失敗した・・・")
    end
end

また、これを見てみると、T通常攻撃は特にフィールドを必要としていないことがわかる。そのため、先程の定義はやめて次のようにしておく。

struct T通常攻撃 end

それぞれの関数に重複があるが、共通化すべきだろうか?悩むところだが、一旦先に進もう。

次に、コマンドを選ぶ箇所を変更しよう。次のようにベタ書きになっているが、所持しているスキルに応じて表示がされるようにしたい。

function コマンド選択()
    function isValidコマンド(コマンド)
        return コマンド in ["1", "2"]
    end

    while true
        コマンド = Base.prompt("[1]攻撃[2]大振り")
        if isValidコマンド(コマンド)
            return コマンド            
        else
            println("正しいコマンドを入力してください")
        end
    end 
end

所持しているスキルを表示させるためには、プレイヤーがスキルを所持していなければならない。

mutable struct Tプレイヤー
    名前
    HP
    攻撃力
    防御力
    スキルs #追加。スキルは複数になりうるので複数形のsをつけた。
end

これにより、プレイヤーの作成時にスキル指定の必要が出てくる。とりあえず全員「大振り」が使えるようにする。

function main()
    モンスター = Tモンスター("ドラゴン", 400, 40, 10)
    プレイヤー1 = Tプレイヤー("太郎", 100, 10, 10, [Tスキル("大振り", 2, 0.4)])
    プレイヤー2 = Tプレイヤー("花子", 100, 10, 10, [Tスキル("大振り", 2, 0.4)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 10, 10, [Tスキル("大振り", 2, 0.4)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 10, 10, [Tスキル("大振り", 2, 0.4)])
    ...

そのうえで、コマンド選択時にプレイヤーの情報が必要になるので、引数で渡すようにする。

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    ...
    コマンド = コマンド選択(行動者) #引数に行動者を追加
    ...
end
function コマンド選択(行動者::Tプレイヤー) #引数に行動者を追加
    ...
end

そして、コマンド選択の仕様を次のように変更する。

  1. 「攻撃」か「スキル」か選択できるようにする。
  2. 「スキル」を選んだらスキルの一覧から実行するスキルを選択する

ついでに、Juliaの標準ライブラリにあるRadioMenuというものを使ってみよう。これは、REPL上でカーソルを方向キーで選択することで選択肢を選べるようにするというものだ。こんな便利なものは普通標準で用意されていない。なので、キーボードから読み取るというのが他の言語では普通ということを覚えておきつつ、便利なので使うことにしよう。

RadioMenuを使うと次のようになる。まず最初のRadioMenuで通常攻撃かスキルを選択し、スキルを選択したら次のRatioMenuが開かれ、そこでプレイヤーのスキルを選ぶことができる。

function コマンド選択(行動者::Tプレイヤー)
    while true
        選択肢 = RadioMenu(["攻撃", "スキル"], pagesize=4)
        選択index = request("行動を選択してください:", 選択肢)

        if 選択index == -1
            println("正しいコマンドを入力してください")
            continue
        end

        if 選択index == 1
            return T通常攻撃()
        elseif 選択index == 2
            選択肢 = RadioMenu([s.名前 * string(s.消費MP) for s in 行動者.スキルs], pagesize=4)
            選択index = request("スキルを選択してください:", 選択肢)
            return 行動者.スキルs[選択index]
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

ここでthrowというものが登場したことに気づいてほしい。これは「例外」と呼ばれる機構で、何回か前にそのうち説明すると言っていたものだ。ちょうどいいのでこのタイミングで解説しておこう。今回の記事の後半で説明する。

この関数では、これまではコマンドの選択結果として"1""2"という文字列を返すようにしていた。しかし、変更後の実装ではT通常攻撃またはTスキルのいずれかを返すようにしている。これに伴って、モンスターの行動も同様に修正しておこう。

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動(T通常攻撃(), 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

もともと、攻撃実行!関数は、コマンドとして受け取った引数が文字列の"1""2"かで処理を分岐していた。しかし、T通常攻撃またはTスキルの型を持つ変数に変わったので、型によるディスパッチが可能になった。このためif文は残っておらず、代わりに引数型の違う二つの攻撃実行!関数をJuliaがうまく選択してくれるということになる。

これで動かすと、通常攻撃は問題なく動くのだが、スキル選択時にエラーが出る。これは、スキルが1つしかないためだ。JuliaのRadioMenuは2つ以上の要素がないとエラーを発生させる仕様らしい。しょうがないので、とりあえず全員大振りスキルをもう一つ設定しよう。

function main()
    モンスター = Tモンスター("ドラゴン", 400, 40, 10)
    プレイヤー1 = Tプレイヤー("太郎", 100, 10, 10, [Tスキル("大振り", 2, 0.4), Tスキル("大振り", 2, 0.4)])
    プレイヤー2 = Tプレイヤー("花子", 100, 10, 10, [Tスキル("大振り", 2, 0.4), Tスキル("大振り", 2, 0.4)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 10, 10, [Tスキル("大振り", 2, 0.4), Tスキル("大振り", 2, 0.4)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 10, 10, [Tスキル("大振り", 2, 0.4), Tスキル("大振り", 2, 0.4)])
    ...

これで通常攻撃、大振りのどちらでもうまく動く状態になった。行動の選択も、JuliaのRadioMenuを使うことでかっこよくなった。

太郎のターン
行動を選択してください:
   攻撃
 > スキル
スキルを選択してください:
 > 大振り
   大振り

仕上げに、Tスキル("大振り", 2, 0.4)が何箇所もできてしまっているので修正しよう。今のままではミスタイプで威力3倍の大振りとか命中率80%の大振りなどができてしまう可能性がある。そこで、次のような共通関数を作る。

function createスキル(スキルシンボル)
    if スキルシンボル === :大振り
        return Tスキル("大振り", 2, 0.4)
    else
        Throw(DomainError("未定義のスキルが指定されました"))
    end
end

:大振りというように、先頭にコロンがついた文字列を「シンボル」という。シンボルというのはなかなか説明が難しい。文字列に似ているが、文字列よりも、もっとひとかたまりのものだ。文字列には、1文字目とか2文字目という概念があるが、シンボルにはない。文字列は文字の集合体だが、シンボルはそうではない。”大振り”は"大振"と部分一致するが、:大振り:大振と部分一致しない。:大振り:大振りとだけ一致するのだ。

シンボルの意味がはっきりしてくるのは、メタプログラミングという技法を使う時なのだが、これはなかなか上級トピックなので紹介するのはもう少し先になるだろう。今のところは融通の効かない文字列といったところだ。文字列に対するシンボルのはっきりとした利点は、同値性の比較が高速なことだ。なお、公式ドキュメントによると、シンボルの比較は===でやるべきと書いてあるのでそれに従った。

試しに比較してみると、確かに違いがあることがわかる。===の方が早い。

julia> x = :a
julia> y = :a
julia> @time x == y
  0.000026 seconds
true

julia> @time x === y
  0.000000 seconds #
true

これを使って重複を排除しよう。

function main()
    モンスター = Tモンスター("ドラゴン", 400, 40, 10)
    プレイヤー1 = Tプレイヤー("太郎", 100, 10, 10, [createスキル(:大振り), createスキル(:大振り)])
    プレイヤー2 = Tプレイヤー("花子", 100, 10, 10, [createスキル(:大振り), createスキル(:大振り)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 10, 10, [createスキル(:大振り), createスキル(:大振り)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 10, 10, [createスキル(:大振り), createスキル(:大振り)])
    ...

本体コードをいじったので、テストが失敗するようになっている。本体側コードと同じような修正なので省略するが、テストコードも修正しておこう。自明な変更以外の対応が必要になるのであれば、本体側のコードを誤って変更している可能性があるので気をつけよう。

連続攻撃の実装

大振りの実装が終わったので、次は上から順にいこう。太郎くんの「連続攻撃」だ。

  • 連続攻撃
    • 2〜5回の連続攻撃を行う。1回当たりのダメージは半減する。

これはどう考えてもお得な技だ。最悪でも通常攻撃と同等のダメージ、うまくすれば2.5倍のダメージとなる。この技を使うためのデメリットがなければ単にこの技を連発することになる。この手のデメリットの最もスタンダードな方式が、特殊攻撃を使うためのリソース、すなわちマジックポイントの消費だろう。よくMPと略される。連続攻撃はおそらく魔法ではないのでマジックポイントという名前はどうなのかという反論があるかも知れないが、細かいことは気にしない。魔法のような力を使って凄く速く動けたと解釈しても良い。

まずはテストコードを作っておこう。

#julia_test.jl

function createプレイヤーHP100攻撃力(攻撃力)
    return Game.Tプレイヤー("", 100, 攻撃力, 0, [])
end

function createモンスターHP100攻撃力(攻撃力)
    return Game.Tモンスター("", 100, 攻撃力, 0)
end


@testset "行動実行!" begin
    ...
    @testset "連続攻撃" begin
        p = createプレイヤーHP100攻撃力10()
        m = createモンスターHP200攻撃力20()

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test 200 - 5 * 5 <= m.HP <= 200 - 5 * 2 

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test 100 - 10 * 5 <= p.HP <= 100 - 10 * 2 
        @test 200 - 5 * 5 <= m.HP <= 200 - 5 * 2 
    end 
end

当然失敗するが、これを成功させるために実装していこう。

まず、Tスキル構造体を拡張し、攻撃実行!関数を変更しよう。

struct Tスキル
    名前
    威力
    命中率
    消費MP #追加
    攻撃回数min #追加  
    攻撃回数max #追加
end

攻撃回数でループするようにする。

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
    println("----------")
    println("$(攻撃者.名前)$(スキル.名前)!")
    攻撃回数 = rand(スキル.攻撃回数min:スキル.攻撃回数max)
    for _ in 1:攻撃回数
        if rand() < スキル.命中率
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

とりあえず全員、連続攻撃が使えるようにしよう。

function main()
    モンスター = Tモンスター("ドラゴン", 400, 40, 10)
    プレイヤー1 = Tプレイヤー("太郎", 100, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー2 = Tプレイヤー("花子", 100, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])

createスキル関数にも手を加える必要がある。MPはまだ活躍の機会がないが、これでとりあえず動くはずだ。

function createスキル(スキルシンボル)
    if スキルシンボル == :大振り
        return Tスキル("大振り", 2, 0.4, 0, 1, 1)
    elseif スキルシンボル == :連続攻撃
        return Tスキル("連続攻撃", 0.5, 1, 10, 2, 5)
    else
        Throw(DomainError("未定義のスキルが指定されました"))
    end
end

:連続攻撃の分岐をつくったのはもちろん、:大振りで指定する引数を増やす必要があったことにも注意しよう。

コンストラク

「『:大振りで指定する引数を増やす必要があったことにも注意しよう。』だって?なんだいそれは?口からクソを垂れ流すのもいい加減にしろよ!」そう毒づくあなたの姿が画面越しに見えるようだ。そんな汚い言葉遣いはよしなさい。

・・・いや、やはりそんなことを言うのはやめよう。それは個人の自由じゃないか。存分に毒づくと良い。なんなら録音してYoutubeにアップしてくれても良い。私までURLを送ってくれたらこのページで紹介しよう。

これまで構造体を作るときは、フィールド全てを指定してきた。それで概ね何の問題もなかったのだが、直近でTスキル構造体のフィールドはかなり増えてしまった。あらゆるスキルで全てのフィールドが必要なわけではない。名前、威力、命中率、消費MPくらいはどのスキルであっても指定が必要な「基本セット」という感じがするが、攻撃回数min, 攻撃回数maxというのは大概のスキルでは指定不要だ。

そのため、基本セットの変数のみ指定して構造体を作れるようにしたい。指定しなければ攻撃回数は、maxもminも1固定になる。このように構造体を作るために、「コンストラクタ」と呼ばれる関数を作る。

ちなみに、これまで使っていた、フィールド全てを順に指定する自動で作られるコンストラクタをデフォルトコンストラクタと呼ぶ。

外部コンストラク

さっそく作ってみよう。Juliaにはコンストラクタは二種類定義されている。一つが今から説明する外部コンストラクタと呼ばれるものだ。これは、構造体の外部に構造体の型名と同じ関数を作ることで実現できる。

struct Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
end

#外部コンストラクタの追加
function Tスキル(名前, 威力, 命中率, 消費MP) 
    return Tスキル(名前, 威力, 命中率, 消費MP, 1, 1)
end

外部コンストラクタは通常の関数呼び出しにしか見えないと思う。外部コンストラクタは複数作ることもできる。指定する必要のない項目を省略するための引数のパターンを複数用意できたりするのだ。

外部コンストラクタは内部で、デフォルトコンストラクタか、または後述する内部コンストラクタを呼ぶ。

内部コンストラク

外部コンストラクタという名前がついているからには、内部コンストラクタというものもある。内部コンストラクタは、構造体の内部で定義する。

内部コンストラクタはnewというキーワードを使う。

先程の外部コンストラクタと同じ内部コンストラクタを書くと次のようになる。

struct Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    #内部コンストラクタの追加
    Tスキル(名前, 威力, 命中率, 消費MP) = new(名前, 威力, 命中率, 消費MP, 1, 1) 
end

しかし、これだけではだめだ。なぜなら、内部コンストラクタを定義したら、デフォルトのコンストラクタが使えなくなってしまうからだ。そのため、デフォルトコンストラクタと同じ引数を6つ指定するパターンのコンストラクタも必要だ。

struct Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    Tスキル(名前, 威力, 命中率, 消費MP) = new(名前, 威力, 命中率, 消費MP, 1, 1) 
    Tスキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) = new(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max)  
end

当面今やりたいことだけを考えたら外部コンストラクタと内部コンストラクタのどちらを使ってもいい。

外部コンストラクタと内部コンストラクタの使い分け

外部コンストラクタと内部コンストラクタはどのように使い分ければいいだろうか?適当に使い分けろと言われても困る。「仏滅の日は内部コンストラクタを避ける」「牡牛座のあなたは外部コンストラクタと相性がバッチリ」というような明確で客観的な指針が欲しい。

実を言うと、その辺りはJuliaの公式ドキュメントに明記されている。

まず、内部コンストラクタにできて外部コンストラクタにできないことを説明しよう。

  • 不変性の強制

構造体を作るときにフィールドの値に何か制約を設けたくなることがある。例えば、Tスキル命中率のフィールドは0から1の間に収まってほしい。命中率のフィールドが0から1の間にあることは、常に満たされておいてほしい制約だ。このような制約のことを不変性とか不変式とか呼ぶ。常に満たされている=変わらない性質ということだ。

さて、この制約を外部コンストラクタを使って実現してみよう。書くとしたらこうだろうか。命中率が0から1の間にないとき以外は例外を発生させている。例外が発生しないときには、引数そのままで構造体を作りたいという意図だ。

function Tスキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) 
    if !(0 ≤ 命中率 ≤ 1)
        throw(DomainError("命中率は0から1の間でなければなりません"))
    end    
    return Tスキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max)
end

しかし、これを実行してみると上手くいかない。これは前回、再帰処理で説明したスタックオーバーフローを起こす。そう、よく見てみると終了条件のない再帰呼び出しの形になっているのだ。

内部コンストラクタを使うとそのような心配は起こらない。

struct Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    Tスキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) = begin
        if !(0 ≤ 命中率 ≤ 1)
            throw(DomainError("命中率は0から1の間でなければなりません"))
        end        
        new(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max)  
    end
end

これにより、構造体のフィールドの値に制限をかけることができ、不正なオブジェクトが作られるのを防ぐことができる。もちろん、mutableなオブジェクトに対して後からおかしな値を設定することは可能だが、直接フィールドの値を更新するのは悪いスタイルだ。普通は更新用の関数を別途用意するので、その中で同様の不変条件をかけるべきだ。

  • 不完全初期化

newキーワードを使うと、構造体のフィールドの数よりも少ない引数で構造体を作成することもできる。初期値が指定されてないフィールドに不用意にアクセスするとエラーが発生したりと取り扱いも難しいのだが、構造体を一発で作れないこともあるので、このようなことはできた方がいい。

特に「木構造」と呼ばれる構造のものは、そのように取り扱えると便利なことが多いが、うーん、今考えている題材ではうまい例が思い浮かばない。戦闘用の思考AIでも実装するときには出てくるかもしれないが、想像もできないくらい先の話だ。とりあえず、そのようなことが可能であるということは覚えておこう。

外部コンストラクタと内部コンストラクタの使い分けをまとめると、内部コンストラクタでしかできないことは内部コンストラクタで行い、それ以外のケースでは外部コンストラクタで行うべきだ。内部コンストラクタでは、不変式のチェックや不完全初期化など、構造体の内部データに関するロジックを担い、外部コンストラクタではデフォルト値の設定など、外部からの呼び出しに対するロジックを担う、という棲み分けをするのが良いだろう。

ここまでの範囲をまとめると、次のようにするのが良いということになる。命中率以外のフィールドも同様に不変性の制約を入れるべきだが、例外について説明した後にする。

struct Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    Tスキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) = begin
        if !(0 ≤ 命中率 ≤ 1)
            throw(DomainError("命中率は0から1の間でなければなりません"))
        end        
        new(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max)  
    end
end

function Tスキル(名前, 威力, 命中率, 消費MP) 
    return Tスキル(名前, 威力, 命中率, 消費MP, 1, 1)
end

委譲

次にMPの考慮を入れよう。MPを考慮するにあたり、当然プレイヤーの残MPが消費MP以上かどうかを判定する処理が必要になる。TプレイヤーMPフィールドを追加してもいいのだが、Tモンスターはどうすればいいだろうか?みんなにはまだ秘密にしていたのだが、そのうちモンスターにも強力なスキルを実装するつもりなのだ。その際にはプレイヤー同様のMP管理が必要になる。TプレイヤーにもTモンスターにも同じMPフィールドを追加するべきなのだろうか?今後も、そのような追加がある時には両方で追加すべきなのだろうか?

プレイヤーとモンスターは、もともとは同じキャラクターという構造体を使っていた。プレイヤーとモンスターで処理を分けたいため、ディスパッチのために型を分けたのであって、別に、別々にフィールドを管理したいわけではなかったのだ。

キャラクターとして共通化させたい部分はまとめてしまいたい。そのために、共通化させた部分をまとめた構造体を作ろう。これをTキャラクター共通データという名前にする。

#game.jl
mutable struct Tキャラクター共通データ
    名前
    HP
    攻撃力
    防御力
    スキルs
end

そして、TプレイヤーTモンスターは、それぞれで管理していた属性情報をなくし、代わりにTキャラクター共通データだけを持つようにする。(なお、話の流れ上まだTプレイヤーにしかスキルsを追加していないが、どうせいずれモンスターにも追加するので、このタイミングでTモンスターにもスキルsのフィールドの指定が必要になるようにする。)

mutable struct Tプレイヤー
    _キャラクター共通データ::Tキャラクター共通データ
end

mutable struct Tモンスター
    _キャラクター共通データ::Tキャラクター共通データ
end

やりたいことの本質はこれである。ある構造体で管理していたデータを別の構造体の管理下に移したいのだ。本質的にプレイヤーやモンスターが管理すべきデータは、TプレイヤーTモンスターが直接管理すれば良いが、キャラクターとして共通に管理すべき情報は、Tキャラクター共通データに移したい。このように、外部からはある構造体の役割であるかのように見せかけて、仕事はそっくりそのまま別の構造体に丸投げすることを「委譲」と呼ぶ。「委任」という言葉の方がよく聞くかもしれない。

なお、フィールド名についているアンダースコアは、外部からは意識させたくないフィールドだという意図を表している。pythonの流儀から拝借したもので、Juliaでそのような慣例があるわけではないのだが、ともかくこのコースではそのように扱う。外部からアンダースコア付きのフィールドにアクセスしていたら要注意だ。まあ、構造体のフィールドではないが、Juliaの標準ライブラリのコードを見ていると、内部的な補助関数のようなものの先頭にはアンダースコアをつけている節があるので、あながち的外れでもないのだろう。

さて、これだけやってみると当然困ったことになる。例えば、Tモンスター("ドラゴン", 400, 40, 10)のように構造体を作ることができない。TモンスターのデフォルトコンストラクタはTキャラクター共通データしか受け取らないからだ。次からは、Tモンスター(Tキャラクター共通データ("ドラゴン", 400, 40, 10, []))と書かなければならないのだろうか?腱鞘炎になりそうだ。労災はおりるだろうか・・・

と、わざとらしく悩んでみたが、このくらいはもう朝飯前だろう。何と言ってもさっきコンストラクタについて学んだばかりなのだ。外部コンストラクタを作れば、今まで通りに構造体を作ることができる。(先ほど言ったように、Tモンスターを作成する際に要求されるフィールドにスキルsを追加しているので注意すること。既存の本体コード、テストコードの修正が必要になる。)

function Tプレイヤー(名前, HP, 攻撃力, 防御力, スキルs)
    return Tプレイヤー(Tキャラクター共通データ(名前, HP, 攻撃力, 防御力, スキルs))    
end

function Tモンスター(名前, HP, 攻撃力, 防御力, スキルs)
    return Tモンスター(Tキャラクター共通データ(名前, HP, 攻撃力, 防御力, スキルs))    
end

他にも困ったことがある。これまではプレイヤーのHPであれば、プレイヤー.HPとデータにアクセスできていたのだが、今後はプレイヤー._キャラクター共通データ.HPのようにしなければならない。こちらについても解消することにしよう。

getproperty

Juliaでプレイヤー.HPのようにデータを取得するときに、実際には、getproperty(プレイヤー, :HP)という関数が呼び出されている。getproperty関数は、対象の構造体の指定されたフィールドの値を呼び出す動作をする。今からやりたいのはこの仕組みに細工を加えてやり、プレイヤー.HPと呼び出すと、プレイヤー内部の_キャラクター共通データHPフィールドの呼び出しに変換するということだ。

これを実現するために、Juliaのディスパッチ機構を使う。すなわち、getproperty関数のTプレイヤー特化版を作り、その中で細工をするのだ。

#Base.getpropertyのTプレイヤー特化版
function Base.getproperty(obj::Tプレイヤー, sym::Symbol)
    if sym === :HP 
        return obj._キャラクター共通データ.HP
    end
    return Base.getfield(obj, sym)
end

こうすると、プレイヤー.HPの時だけ、プレイヤー._キャラクター共通データ.HPが呼ばれることとなる。それ以外のときはgetfieldという関数を呼んでいるが、これはフィールドの値を取得する関数で、要は普通に意識するプレイヤー.攻撃力などと同じである。a.bの形式の時に、我々は「オブジェクトaのフィールドb」の値を取得する(つまりgetfield(a, :b)が実行される)だと思っているが、実際にはgetpropertyというものを介している。このおかげで、我々は直接フィールドを取得する以外の細工をする余地が生まれるのだ。もしも、getpropertygetfieldが分かれていなければどうなるだろうか?例えば、先程の処理はこのように書くことになるだろう。

function Base.getproperty(obj::Tプレイヤー, sym::Symbol)
    if sym === :HP 
        return obj._キャラクター共通データ.HP
    end
    return Base.getproperty(obj, sym)
end

これはHP以外のケースで無限ループ(無限の再帰呼び出し)となる。

さらに先に進んでいこう。当然ながら、HP以外にも色々なフィールドについても同様に_キャラクター共通データに委譲するということを行いたい。getpropertyにelseifをたくさん作っても良いが、別のアプローチをしよう。要は、特定のフィールドが指定された時には、_キャラクター共通データgetpropertyを呼び出せば良いのである。ということで次のようになる。

function Base.getproperty(obj::Tプレイヤー, sym::Symbol)
    if sym in [:名前, :HP, :攻撃力, :防御力, :スキルs] 
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

厳密には、inでの比較は、===ではなく==になるのでやや不満ではあるが、自前で===で動くinのような関数を作るのも大変だし、パフォーマンスにわずかな違いはあるものの動作としては同じなのでこのままでいく。

setproperty!

さて、プレイヤー.HPの値を取得するときには上記のgetpropertyが呼び出されるが、プレイヤー.HPに値を代入するときにはsetproperty!というものが呼ばれる。代入もサポートしたいので次のようにしておく。

function Base.setproperty!(obj::Tプレイヤー, sym::Symbol, val)
    if sym in [:名前, :HP, :攻撃力, :防御力, :スキルs] 
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

ま、大体のところはわかるだろう。考え方は同じである。3つ目の引数valは、代入演算子の右辺にくる値である。

Tモンスター対応

さて、これでTプレイヤーは外部から以前と同様に扱えるようになった。内部の構造は変化したが、うまく覆い隠すことができている。大満足である。同様の要領でTモンスターにも同じことをやっておこう。

function Base.getproperty(obj::Tモンスター, sym::Symbol)
    if sym in [:名前, :HP, :攻撃力, :防御力, :スキルs] 
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tモンスター, sym::Symbol, val)
    if sym in [:名前, :HP, :攻撃力, :防御力, :スキルs] 
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

これでテストコード、本体コード共に問題なく動くはずだ。めでたしめでたし。

型の階層構造

動いたことは結構だが、また重複だ。Tプレイヤーと全く同じ処理がTモンスターに対して書かれている。これは大いに不満だ。私はコードの重複を許さない星人なのだ。なんとかならないだろうか?

ここで登場するのが、「型の階層構造」の概念だ。

これまでは構造体はただ宣言され、ただ存在してきた。それぞれの構造体に特に関連はなかった。しかし、ある構造体で表される概念と別の構造体で表される概念が、兄弟のような関係にあったり、親子のような関係にあったり、義兄弟の盃を交わしたりしていることはよくあるのだ。

Juliaでは型同士に親子関係を設定することができる。ある概念が別の概念の一種であるとき、親子関係を設定すると非常に便利だ。

言葉で説明するよりも、動作を見てもらった方がいいだろう。今から、Tキャラクターという型を定義する。そして、それをTプレイヤーTモンスターの親とするようにする。

まずは、Tキャラクターを定義する。

abstract type Tキャラクター end

そして、TプレイヤーTモンスターTキャラクターから継承させる。

mutable struct Tプレイヤー <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

<:というのが親子関係を表す記号である。こうすると何が嬉しいかというと、さきほどTプレイヤーTモンスターのそれぞれに特化させたgetpropertysetproperty!を共通化できるのだ。先ほど作ったTプレイヤーTモンスター版のgetpropertysetproperty!は捨ててしまって、次の関数を作ろう。

function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in [:名前, :HP, :攻撃力, :防御力, :スキルs] 
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val)
    if sym in [:名前, :HP, :攻撃力, :防御力, :スキルs] 
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

テストを動かすとうまくいっていることがわかるだろう。どのような仕組みでこれは上手くいっているのだろうか?

プレイヤー.HPという例をもとに考えよう。プレイヤー変数はTプレイヤー型の構造体である。そのためJuliaは最初にTプレイヤーで特化されたgetproperty関数を探しに行く。しかし、Tプレイヤーで特化された関数が存在しないときには、その上の階層の型Tキャラクターで特化された関数を探すという動きをするのだ。Tモンスターに関しても同様だ。

Tキャラクターをの子であるTプレイヤーは、Tプレイヤーに特化した関数が定義されているときのみ独自の振る舞いを行い、それ以外の時はその他のTキャラクターと同様に振る舞う。

さらに型の親子関係は、何段階にもわたって作ることができる。例えば、Tモンスターをの子供に、Tドラゴン族という型を作ることができる。Tドラゴン族の子供して、TグリーンドラゴンTレッドドラゴンTブルードラゴンを作ることができる。型の階層の数に上限はない。

具体型と抽象型

Juliaでは型の階層に上限はないと言ったが、許される階層構造に制約はある。一見すると厳しすぎる制約に思えるかもしれないが、使っていくうちに理にかなっていることがわかるだろう。

Juliaの型には、具体型と抽象型がある。本当はもっといろいろあるのだが、当面はこれだけ考える。

具体型はTプレイヤーのように、フィールドを持ち、コンストラクタで作成することのできる型である。具体型を継承して別の具体型を作ることはできない。

抽象型はTキャラクターのように、他の型が継承することのできる型である。抽象型を継承した別の抽象型を作ることや、抽象型を継承した具体型を作ることができる。その代わり、抽象型はフィールドを持つことができない。実体を作成して変数に代入することもできない。

つまり我々がプログラム中で直接変数に入れたり引数に渡したりと取り扱うことのできるのは、型階層の最下層に位置する具体型だけである。では抽象型の役割は何かというと、先ほど見たように共通の振る舞いを作ることができるというところにある。

もう一つ述べておくべき制約は、型は複数の子を持つことができるが、複数の親を持つことはできないということである。このため、型の階層構造はピラミッド型の構造になる。

Julia以外の言語でも、いわゆるオブジェクト指向を採用している言語では、型の階層構造と似た「継承」という機構がある。継承とJuliaの親子関係は似ている部分もあるし違う部分もある。一番違うのは、他の言語、例えばJavaでは実装クラス(Juliaでいうところの具体型)から実装クラスが継承できるというところだ。こうすると、振る舞いだけでなくデータも同時に引き継ぐのだ。これはしばしば、不適切な継承関係を生み出す。データの管理場所を共通化するために継承を使ってしまう、ということが起こるのだ。結果として、ある型がどのように振る舞うのかがわかりづらい、という問題が発生する。この誤りがあまりにも深刻なので、Javaのような言語では、しばしば「実装クラスの継承は使うな。委譲を使え。」と言われるのだ。この教えを守ると、実装クラスは継承関係の最下層に来ることになり、それ以外の階層には全て抽象クラス(Juliaでいうところの抽象型)が入ることになる。これは結局Juliaが許容する階層構造と同じなのだ。

MPの追加

そうそう、すっかり長話をしてしまったが、MPを追加するのだった。私は危うく太郎くんの次のスキルの実装を始めてしまうところだった。

まずは構造体にフィールドを追加しよう。

mutable struct Tキャラクター共通データ
    名前
    HP
    MP
    攻撃力
    防御力
    スキルs
end

function Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tプレイヤー(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

Tモンスター, Tキャラクターを作っているところは本体コードもテストコードも全てMPが要求されるので、追加してあげよう。

function main()
    モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [])
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー2 = Tプレイヤー("花子", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    ...

魔法使い型の花子や高橋先生にはもっと多くのMPを設定した方がいいのだろうが、バランス調整はあとでやろう。本体側のコードの修正ここだけでいいはずだ。テスト側のコードはたくさん変更する必要があるが、とりあえずMPは0に設定すればいいだろう。詳細は省略するが、テストが通るようにはしておこう。

次に、MPが足りないとスキル選択時にエラーとなる処理を入れよう。

function コマンド選択(行動者::Tプレイヤー)
    ...
        elseif 選択index == 2
            選択肢 = RadioMenu([s.名前 * string(s.消費MP) for s in 行動者.スキルs], pagesize=4)
            選択index = request("スキルを選択してください:", 選択肢)
            if 行動者.MP < 行動者.スキルs[選択index].消費MP 
                println("MPが足りません")
                continue
            end
            return 行動者.スキルs[選択index]
    ...
end

さらに、行動後にMPを消費する処理を加えよう。

function MP減少!(行動者, コマンド::T通常攻撃)
end

function MP減少!(行動者, コマンド::Tスキル)
    if 行動者.MP - コマンド.消費MP < 0
        行動者.MP = 0
    else
        行動者.MP = 行動者.MP - コマンド.消費MP
    end
end

function 行動実行!(行動)
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

戦況を表示する際にMPも表示するようにしよう。

function 戦況表示(プレイヤーs, モンスターs)
    結果 = []
    push!(結果, "*****プレイヤー*****")
    for p in プレイヤーs
        push!(結果, "$(p.名前) HP:$(p.HP) MP:$(p.MP)")
    end
    push!(結果, "*****モンスター*****")
    for m in モンスターs
        push!(結果, "$(m.名前) HP:$(m.HP) MP:$(m.MP)")
    end
    push!(結果, "********************")
    return join(結果, "\n")
end

ザーッと流したが、特に難しい点はなかったと思う。テストが通ることを確認し、実際にMPが足りなくなったらコマンドが選択できないことを確認しよう。

例外処理

今回の記事も終盤に近づいてきた。最後にこれまでチョロチョロと顔を出してきた「例外処理」について触れておこう。

例外処理とは、エラーハンドリングのための機構の一つである。プログラムを動かす上で、何か想定外の入力が入ってきたり、想定外の結果が出たりしたときには、適切に対処する必要がある。例外処理の説明をする前に、伝統的なエラーハンドリングについて話しておこう。

返り値によるエラーハンドリング

返り値を異常値にする

もっとも伝統的なエラーハンドリングは、返り値によるエラーである。典型的なのが下記の例である。

choice = request("行動を選択してください:", menu)
if choice == -1
   #エラー時の処理
end
#以下正常時の処理
...

requestという関数は、ユーザーからの入力を受け取り、結果を返り値として返す。このとき、choiceという変数には、通常はユーザーが選択した項目のインデックスが格納される。1とか2とかの数字だ。しかし、ユーザーが入力を中断したときには、ここに-1が入るという取り決めになっている。

このように、処理を完了させる上で異常なことが発生したという通知を返り値の異常値で表現する、というのが返り値でのエラーハンドリングだ。(ユーザーが入力を中断するというのは、必ずしも異常な入力とは言えないが、通常の入力と別の取り扱いをしなければならないのは確かだ。)

返り値によるエラーハンドリングはわかりやすい一方、明確な欠点が存在する。それは、必ずしも異常値が定義できるわけではないことだ。例えば、数値の配列を引数に受け取りその平均値を求める、という関数で、配列が空だったらどうすべきだろうか?先ほどのように、特定の値を異常値として定義することはできない。平均を取った結果が(例えば)-1になるというのはごく当たり前にあり得ることだからだ。もちろん、極端に巨大な値や極端に小さな値であれば、実際上はありえない返り値ということで成り立つかもしれないが、イマイチなことに変わりはない。

返り値でエラー判定を表現し、結果は出力引数にする

次に思いつくのは、返り値としては処理が正常に終了したかだけを返して、処理結果は関数の引数で受け取った変数に入れるというものだ。正常終了すればtrue, 異常終了すればfalseを返し、本来取得したい結果は引数で受け取り、関数内部で値を設定する。イメージは、下記のような呼び出し方になる。(実際にはJuliaで引数を書き換えるには、配列などの例外を除いてRefというキーワードを使う必要がある。)

平均値 = nothing
if !平均値計算(配列, 平均値)
    #エラー時の処理
end
#以下正常時の処理

これは概ね問題ないのだが、一見して何が入力で何が結果なのかわかりづらい。通常関数の引数は入力であり、出力用途で使うには一工夫必要な言語もある。(Juliaがそうだが、それ以外にもC言語ならポインタで渡すなど)

これの派生系というか進化系で、結果をtrue/falseではなく、整数値を返してエラーコードを表現することもある。この場合、正常値は0になり、異常値はそれ以外の数値となる。それ自体はいいのだが、true/false系と混じるとややこしい。しばしばtrueは1と、falseは0と同一視されるため、0と比較している処理が正常系なのか異常系なのかわからなくなるのだ。

返り値を使ったエラーハンドリングの手法を他の問題点としては、返り値を無視される、というものがある。多くの言語では、返り値を変数で受けなくても文法エラーにならない。そのため、返り値はいとも容易く無視することができ、問題が発生しているのに処理が継続されるということが起こりうる。

出力引数でエラー判定を表現する

別の方法として、計算結果は引数で返し、エラー判定は出力引数にするというものがある。

エラーコード = 0
平均値 = 平均値計算(配列, エラーコード)
if エラーコード > 0
    #エラー時の処理
end
#以下正常時の処理

私は関数の結果が返り値になるので割といい方法だと思うのだが、あまりメジャーではない。エラーコードが返り値の時以上に無視されやすくなるという傾向はあるので、それが原因かもしれない。

エラーが発生したとして、一体どうすればいいの?

返り値なり、出力引数なりでエラー判定をした結果、大いに困ってしまうのが、エラーが起きたときにどうすればいいの?というものがある。

例えば、配列の平均値を求める関数で、入力に空の配列が入ってきたらどうすればいいだろうか?配列の要素の1つが文字列だったらどうすればいいだろうか?

この関数がユーザーの入力を直接受け取ったのであれば、話は簡単だ。単に不正な入力である旨を伝え、再度入力するように求めれば良い。直接でなくとも、ユーザー入力から近い、比較的浅いレイヤであれば、何が悪かったかが比較的わかりやすいことが多い。

しかし、この関数がアプリケーションの奥の方で呼び出されたらどうすればいい?あなたの関数が受け取った配列が、ユーザーの入力に対して17段階にわたる複雑な変換を施された結果生まれたものだとしたら?これはユーザーの入力が悪いのか?変換に不具合があるのか?私はどうすればいいのか?

悩んでもしょうがない。結局、平均値を求める関数にできるのは、配列が不正でしたとエラーを返すことくらいである。平均値を求める関数を呼び出した関数は、エラーを受け取り、何かまずいことが起きたと判断し、エラー対処コードを実行する。その関数で適切に対処ができればいいが、できなければさらにその呼び出し元に、これこれこういう悪いことが起きましたというエラーを返すことになるだろう。それを受け取った関数は、エラー結果を受け取り、何かまずいことが起きたと判断し、、、、と続いていく。結局この連鎖は、まず間違いなく最上位の呼び出し元まで続くことになるだろう。途中の階層のどこかで、適切な対処ができるというのはほとんど幻想だ。普通は下位の関数から帰ってきた不正な結果には対処できないのだ。

なぜだろうか?もしも、ある関数Aが「配列の平均値を求める関数B」に配列を渡したとしよう。すると、Bから「不正なデータが含まれています。」ということを意味するエラーコードが返ってきたとしよう。Aがこの状況に正しく対処できるというのは、例えば「Bに渡す配列から不正なデータを抜いてやればいい」ということを知っているということである。つまりこのようなコードになる。

function A(入力)
    配列 = 何らかの変換処理(入力)
    平均値 = Nothing
    エラーコード = B(配列, 平均値)
    if エラーコード == 1 #不正な値が含まれているとき
        不正値を除いた配列 = 不正値を除く処理(配列)
        エラーコード = B(不正値を除いた配列, 平均値)
    end
    平均値を使った後続処理
    ...
end

しかし、こんなコードを書けるのであれば、普通は次のように最初から不正値を除いてからBに渡すだろう。

function A(入力)
    配列 = 何らかの変換処理(入力)
    平均値 = Nothing
    不正値を除いた配列 = 不正値を除く処理(配列)
    エラーコード = B(不正値を除いた配列, 平均値)
    ...
end

上位の関数は、普通は下位の関数が困らないように面倒を見てやるのが筋なのだ。そのため、関数は自分の処理が困ったことになったのを上位に伝えても、普通は上位側もただ困るだけなのだ。上位側が想定していないからこそ、下位側が困ったことになっているのだから。

例えば、あなたが上司から何かの資料を日本語へ翻訳することを依頼されたとしよう。あなたは外国語が得意なのだ。ところが、その資料にはヘブライ語の文献が混じっていた。あなたは7ヶ国語を操るスーパースターだが、ヘブライ語はあいにく未修得だ。それを上司に伝えたら、「ああ、その資料はやらなくていい。」と言われた。結構なことだが、それなら最初から渡すなよと思うだろう。雑に仕事を振っている証拠だ。

一方、もしそれが本当に翻訳が必要な代物だったとしたらどうなるか。ヘブライ語の資料があなたに渡ってきたのは、上司がヘブライ語の文字を見てタイ語(あなたの得意言語の1つだ)だと誤解したためだとしたら?これは上司も困ったことになる。人間の上司なら何とかして解決するだろうが、決して事前に想定していた策をとったわけではないだろう。つまり、想定外の事柄が発生してしまうと、人間ならともかく事前に定められた動きしかできないプログラムコードにはどうすることもできないのだ。

結果として、ある程度深い層でエラーが発生したら、誰もその対処ができないまま最上位の呼び出し元までエラーが遡ってくる。そこでできるのは、ユーザーに原因不明の問題が発生したことを知らせて、アプリケーションを安全に終了させることくらいだ、という事になる。あとはプログラマがログをじっくりと解析するしかないだろう。ユーザーの誤った入力を防ぐ変更を入れるなり、変換のバグを治すなりの対処をもって、問題は解決する。

もちろん、途中の階層で適切に処理できることもある。失敗した処理の重要度が低く、単にその処理を行わないという選択が可能な時だ。例えば画面を描画する処理で、どこか1ピクセルのドットが正しく描画できないとしてもさほど大きな問題ではないだろう。このようなときにはそのピクセルの描画処理を飛ばすという判断が可能だ。この場合はアプリケーション全体ではなく、特定の処理を安全に終了させることになる。しかし、一般的には失敗してもいい処理というのはそう多くはないし、その判断も簡単ではない。

返り値によるエラーハンドリングのまとめ

ここまでの議論で、次のことがわかった。エラーには何パターンかある。1つ目はユーザー入力が透けて見えるくらいに浅い層で発生する入力値に由来するエラーで、この場合はユーザーに再入力を求めて処理を継続させることができる。2つ目はアプリケーションの内部の重要度の低い処理で発生するエラーで、これは該当の処理を飛ばすということで対処可能だ。最後はアプリケーションの内部の重要度の高い処理で発生するエラーで、この場合はアプリケーションを安全に終了させるしかない。

先ほど、返り値を使ってのエラーハンドリングの問題は、返り値が無視されうることだと言った。これは重要なポイントで、適切な上位層までエラーをちゃんと伝達できれば適切な対処をすることができる。アプリケーションを終了するしかないケースであっても、原因調査に必要なログも残せるだろうし、ユーザーの入力中のデータも保存できるかもしれない。これは及第点だと言っていいだろう。

しかし、返り値を無視してしまうと、失敗した時に正しくない結果を使って後続処理が動いてしまうことになる。いずれにせよどこかでアプリケーションが動かなくなるのだろうが、この時は安全な終了というのは望むべくもない。ユーザーはアプリケーションを動かしていると突然訳のわからないダイアログが発生し、アプリケーションが終了し、入力中のデータは失われ、あなたは調査のためのログすら得られないだろう。これは全くの落第点だ。

そのようなわけで、プログラマはあらゆる関数を呼び出す都度、成功、失敗をチェックするのだが、そうするとエラーチェックのif文だらけになってしまう。仕方のないことだが、読みづらいのは確かだ。

そのような問題を解決するために発明されたのが例外処理である。

例外処理

例外処理は返り値を使ったエラーチェックの欠点を克服するものとして登場した。返り値を使ったエラーチェックの欠点の1つは、正常時の処理と異常時の処理がごっちゃになってしまうということである。

例外処理との比較のために、まずは返り値によるエラーチェック処理を改めて載せてみよう。関数A、B、Cが順番に呼び出されるという単純なコードだ。

if !A()
    #Aが失敗した時の対処
else
    if !B()
        #Bが失敗した時の処理
    else
        if !C()
            #Cが失敗した時の処理
        else
            #AもBもCも成功したあとの処理
        end
    end
end

あるいはこうだ。

if !A()
    #Aが失敗した時の対処
    return
end

if !B()
    #Bが失敗した時の処理
    return
end

if !C()
    #Cが失敗した時の処理
    return
end

#AもBもCも成功したあとの処理

A、B、Cを順番に呼び出したいだけなのに、何と読みづらいことか。これが例外処理機構を使うと次のように書けるのだ。

try
    A()
    B()
    C()
catch
    #失敗時の処理
end

#AもBもCも成功した時の処理

A、B、Cの呼び出しという正常系の処理と、失敗時の処理がきちんと分かれている。この方が読みやすいだろう。例外機構のメリットの1つは、このように正常時の処理と異常時の処理が綺麗に分かれるというところだ。

ちなみに、例外を発生させるのは次のようなコードになる。DomainErrorというのは発生させた例外の種類を意味している。例外には種類があり、例外の種類に応じたcatch処理もある。そのため、ファイル保存に失敗した時の例外のときはこうする、ネットワーク接続に失敗した時の例外はこうする、というような処理を書くことができる。

DomainErrorとはアプリケーションが取り扱っている問題領域でのエラーという意味で、自前でthrowするときはとりあえずこれでいいと思う。

function B()
    if エラー発生()
      throw(DomainError("エラーメッセージ"))
    end
end

例外処理の文法について少し説明しておこう。まず、「例外」を発生させる可能性のある処理をtry〜catchの間で囲う。もしもA、B、Cのどこか(例えばB)で例外が発生すると、Bの処理は中断し、Cの処理もすっ飛ばされ、この場合catchというところまでジャンプする。catchの中では失敗した時のどうするかの記述がある。もしもA、B、Cがtry〜catchで囲まれていない場合、呼び出し元がtry〜catchで囲まれているかどうかを確認する。もしも呼び出し元がtry〜catchで囲まれていればそのcatchの中で失敗時の動作を行う。もしも呼び出し元がtry〜catchで囲まれていなければさらに上位に、、、と続いて行って、どの上位層でも対処が不可能であれば、最終的に最上位の呼び出し元に到達する。最上位でもcatchがなければアプリケーションは終了する。

なんだか、返り値の時の流れと似ているようでもある。そう、話の流れはとてもよく似ている。違いは、返り値の時は、意図的に努力しなければこの仕組みが実現しなかったが、例外の時にはデフォルトがこの動きになるということだ。if文でちまちまと上位へエラーを通知していく必要はなく、例外機構が勝手に上位側へ通知を行ってくれる。この例外機構のもう一つのメリットを、大域脱出という。

例外処理の欠点

さて、ここまでの説明だと、例外処理は何の欠点もないように思えるだろう。しかし、例外処理は正しい取り扱いが難しいのだ。

問題は、例外が大域脱出を行うというところだ。例外が発生したら、次の処理がどこになるのかを知るのが非常に難しい。ある処理で例外が発生した時に、その対処を行うのは、直前の呼び出し元かもしれないし、3階層上の呼び出し元かもしれないし、最上位まで辿って結局存在しないと判明するかもしれない。複数の箇所から呼び出されていて、1つ目の呼び出し元では2階層上でcatchされていて、残りの呼び出し元では最上位に至るまでcatchされていないかもしれない。返り値によるエラーチェックだと、正しく対処されているかどうかはわかりやすい。エラーチェックが途切れている箇所があれば、それが正しく対処すべき処理だから途切れているであるか、あるいは単にエラーチェックを忘れているかのどちらかだ。一方、例外については、例外をcatchしなくても上位層へエラーが伝わってくれる。このため、何もcatchしていない関数が実装漏れなのか、それが正しい実装であるのか判断がつきづらい。

また、自分が書いている処理が何かの関数を呼び出す時もそうだ。もしもその関数で例外に対処しようと思ったら、自分が呼び出す関数がどのような例外を送出するのか?ということを知らなければならない。そのためには、直接呼び出している関数はもちろん、その先で呼び出されている関数全てをチェックしなければ、厳密に全てを洗い出すことはできない。返り値による制御では、常に1階層下のことだけを考えていればよかったのに、だ。Joel Spolskyはこの問題を「例外は実質的に目に見えないgotoだ」と言った。*1

この説明だけを聞いてもピンとこないかもしれない。例を出そう。あなたはあるアルバイトに応募し、指定された日時にどこかのオフィスへ到着した。オフィスはがらんとしており、何冊もの分厚いマニュアルが置いてあるだけだった。あなたの仕事はこのマニュアルの指示通りに仕事をこなすことであり、あなたの輝かしい知性を発揮する機会は残念ながら存在しない。とにかくマニュアルに従うことが求められているのだ。つまらない仕事だが仕方がない。時給が破格に良かったのだ。金に目の眩んだあなたの責任だ。

さて、マニュアルを開くと、作業指示が事細かに書いてある。その所々に注釈があり、ここがうまくいかない時はこうすること、という指示が記載されている。

返り値によるエラーチェックのイメージは、この時にうまくいかない時の対処が行間に都度書かれているような状況だ。具体的にこれこれしろ、と書いてあることもあるし、ここの処理まで戻れ、とだけ書いてあることもある。戻った先では、さらにそこに書かれている指示に従って動く。

例外によるエラーチェックのイメージは、うまくいかない時には、その節の最後にまとめて記載してあるからそこを参照するように、と書かれているような状況だ。そして、その節の最後に対処法が書かれていないことがある。その場合は、その章の最後に対処法が書かれていないかを確認する必要があり、そこにも記載がなければその巻の最後に対処法が書かれていないかを確認する必要があり、そこにも記載がなければ最終巻の最後に対処法が書かれていないかを確認する必要がある。

個人の好みもあるだろうが、どちらが対応しやすいか、となると、実は返り値の方がいいのではないかという気がしてくる。

結局どうすればいいの?

返り値によるエラーハンドリングと、例外機構によるエラーハンドリングのどちらにも利点と欠点があることがわかった。では、結局我々はどのようにエラーハンドリングを行えばいいだろうか?適当に使い分けろと言われても困る。「13日の金曜日は返り値によるエラーチェックを避ける」「木星人のあなたは例外機構と相性がバッチリ」というような明確で客観的な指針が欲しい。

どちらを使うかの判断基準は、「対処可能なエラーかどうか?」というところだ。対処可能なエラーは返り値でコントロールし、対処不可能なエラーは例外機構に頼ると良い。

少し上の方で、次のように書いた。

ここまでの議論で、次のことがわかった。エラーには何パターンかある。1つ目はユーザー入力が透けて見えるくらいに浅い層で発生する入力値に由来するエラーで、この場合はユーザーに再入力を求めて処理を継続させることができる。2つ目はアプリケーションの内部の重要度の低い処理で発生するエラーで、これは該当の処理を飛ばすということで対処可能だ。最後はアプリケーションの内部の重要度の高い処理で発生するエラーで、この場合はアプリケーションを安全に終了させるしかない。

1つ目と2つ目のケースでは、if文を使って対処する。3つ目のケースでは例外を使って対処する。

戦略はこうだ。例外を正しく取り扱うのは非常に難しいので、例外の使用は最小限に抑える。対処可能なレベルであれば全てif文で対処する。しかし、対処不可能なケースで、安全に終了させるためだけに最上位までif文でエラー情報を伝搬していくのはコードが汚れて嬉しくない。どうせ最上位でアプリケーションを終了させるしかないのであれば、例外処理で一気にジャンプしてしまおう、ということである。そのようなわけで、取り扱いの難しいcatchは最上位の呼び出しにしか存在しないのであり、悩みの種はなくなるのである。

我々のコードで言えば、例外を使わずに対処する1つ目の2つ目のケースのうち、2つ目の重要度の低い処理というのは現段階では存在しないし、おそらく最後まで登場しない。我々が書くのはアプリケーションの非常にコアの部分だからである。そのため、ユーザー入力の部分はif文で不正な入力を弾き、それ以外の困ったケースでは常に例外を発生させる、というシンプルな形になる。エラーチェックがあまりなく、困った時にはとりあえず例外を出しているいい加減なコードに見えるかもしれないが、そのような意図を持って書いているコードである。

なお、今回の解説では例外機構においてのfinallyというキーワードの話を意図的に省略した。また、例外安全性というトピックについても触れなかった。いずれも重要な内容なので、いずれ解説することになるが、今はまだその時ではない。そのうちデータをセーブしてファイルに保存する機能をつくるというあたりで話すのではないだろうか。

おまけ

ところで、if文を使ったエラーハンドリングのデメリットに、正常系のコードと異常系のコードが混在するので読みづらい、というものがあった。しかし、これは解決可能な課題に思える。

というのが、本当にアルバイトの例に出したようなマニュアルがあったとしたら、おそらく通常の手順の合間にエラー時の手順が書かれていたとしても、きっとフォントや色や段落下げを工夫するなどして通常の手順が読みづらくならないようなレイアウトで記述されると思うのだ。

これまでプログラムコードは、あらゆる箇所がフラットなテキストで記述されることが前提に見えるようになっている。しかし、別に装飾というものがあってもいいと思うのだ。現に、シンタックスハイライトは事実上の必須機能となっている。であれば、フォントを変えてはいけない理由はあるのだろうか?折りたたみ機能がもっと充実してはいけないのだろうか?

実際のコードが

if !A()
    #Aが失敗した時の対処
else
    if !B()
        #Bが失敗した時の処理
    else
        if !C()
            #Cが失敗した時の処理
        else
            #AもBもCも成功したあとの処理
        end
    end
end

であったとしても、「簡易」表示モードでは

A()
B()
C()

と表示されるようにはできないのだろうか?

私はまだまだこう言った方向への発展の余地はあるのではと思うのだ。もっと開発環境とべったり依存した言語があってもいいのではないかと思う。

第6回の終わりに

今回は、型のについて、委譲や階層構造というトピックを学んだ。また、例外処理というトピックも学んだ。これらは他の多くの言語でも頻出の機能であり、細かい文法は違えど大きな考え方に違いがあるわけではない部分だ。

正直なところ、これらの機構はかなり複雑な機構である。この記事を一読して理解するのは難しいだろう。また、特に例外をどう扱うか、というようなトピックは未だに議論の尽きないトピックである。私の意見と違う意見を見聞きしたり、あなた自身が反対の立場を表明することもあるだろう。いろいろな意見や観点を取り入れながら、自らのアプリケーションの特性を考慮しながら適切なアーキテクチャを構築することを意識して欲しい。

コードの整理

最後に、ようやく例外について話すことができたので、これまで遠慮がちに使うだけだった例外処理を適切な場所に入れていこう。また、ファイルが大きくなってきたので、構造体を中心にファイル分割する。あまり面白い作業ではないので、結果だけを提示しよう。きちんと説明を入れていくほど合理的にファイルを分けているわけではない、という事情もある。なお、全部同じフォルダに存在するようにしている。

本体側のコード

#game_exec.jl
include("game.jl")

Game.main()
#game.jl
module Game

using Random
import REPL
using REPL.TerminalMenus

include("キャラクター.jl")
include("戦闘.jl")
include("ui.jl")

function main()
    モンスター = Tモンスター("ドラゴン", 400, 80, 40, 10, [])
    プレイヤー1 = Tプレイヤー("太郎", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー2 = Tプレイヤー("花子", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 20, 10, 10, [createスキル(:大振り), createスキル(:連続攻撃)])

    println("モンスターに遭遇した!")
    println("戦闘開始!")

    ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター])

    if モンスター.HP == 0
        println("戦闘に勝利した!")
    else
        println("戦闘に敗北した・・・")
    end
end

end
#ui.jl
function コマンド選択(行動者::Tプレイヤー)
    while true
        選択肢 = RadioMenu(["攻撃", "スキル"], pagesize=4)
        選択index = request("行動を選択してください:", 選択肢)

        if 選択index == -1
            println("正しいコマンドを入力してください")
            continue
        end

        if 選択index == 1
            return T通常攻撃()
        elseif 選択index == 2
            選択肢 = RadioMenu([s.名前 * string(s.消費MP) for s in 行動者.スキルs], pagesize=4)
            選択index = request("スキルを選択してください:", 選択肢)
            if 行動者.MP < 行動者.スキルs[選択index].消費MP 
                println("MPが足りません")
                continue
            end
            return 行動者.スキルs[選択index]
        else
            throw(DomainError("行動選択でありえない選択肢が選ばれています"))
        end
    end 
end

function 戦況表示(プレイヤーs, モンスターs)
    結果 = []
    push!(結果, "*****プレイヤー*****")
    for p in プレイヤーs
        push!(結果, "$(p.名前) HP:$(p.HP) MP:$(p.MP)")
    end
    push!(結果, "*****モンスター*****")
    for m in モンスターs
        push!(結果, "$(m.名前) HP:$(m.HP) MP:$(m.MP)")
    end
    push!(結果, "********************")
    return join(結果, "\n")
end
#キャラクター.jl
include("スキル.jl")

mutable struct Tキャラクター共通データ
    名前
    HP
    MP
    攻撃力
    防御力
    スキルs
    Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs) = begin
        if HP < 0
            throw(DomainError("HPが負の値になっています"))
        end
        if MP < 0
            throw(DomainError("MPが負の値になっています"))
        end        
        if 攻撃力 < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        if 防御力 ≤ 0
            throw(DomainError("防御力が0または負の値になっています"))
        end 
        new(名前, HP, MP, 攻撃力, 防御力, スキルs)  
    end
end

abstract type Tキャラクター end

mutable struct Tプレイヤー <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

function Tプレイヤー(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tプレイヤー(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in [:名前, :HP, :MP, :攻撃力, :防御力, :スキルs] 
        return Base.getproperty(obj._キャラクター共通データ, sym)
    end
    return Base.getfield(obj, sym)
end

function Base.setproperty!(obj::Tキャラクター, sym::Symbol, val)
    if sym in [:名前, :HP, :MP, :攻撃力, :防御力, :スキルs] 
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
end

mutable struct Tモンスター <: Tキャラクター
    _キャラクター共通データ::Tキャラクター共通データ
end

function Tモンスター(名前, HP, MP, 攻撃力, 防御力, スキルs)
    return Tモンスター(Tキャラクター共通データ(名前, HP, MP, 攻撃力, 防御力, スキルs))    
end

function HP減少!(防御者, ダメージ)
    if ダメージ < 0
        throw(DomainError("ダメージがマイナスです"))
    end
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

function MP減少!(行動者, コマンド::T通常攻撃)
end

function MP減少!(行動者, コマンド::Tスキル)
    消費MP = コマンド.消費MP
    if 消費MP < 0
        throw(DomainError("ダメージがマイナスです"))
    end    
    if 行動者.MP - 消費MP < 0
        行動者.MP = 0
    else
        行動者.MP = 行動者.MP - 消費MP
    end
end

function is行動可能(キャラクター)
    if キャラクター.HP < 0
        throw(DomainError("キャラクターのHPが負です"))
    end
    return キャラクター.HP > 0
end

function 行動可能な奴ら(キャラクターs)
    return [c for c in キャラクターs if is行動可能(c)]
end
#スキル.jl
struct Tスキル
    名前
    威力
    命中率
    消費MP
    攻撃回数min
    攻撃回数max
    Tスキル(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max) = begin
        if 威力 < 0
            throw(DomainError("威力が負の値になっています"))
        end
        if !(0 ≤ 命中率 ≤ 1)
            throw(DomainError("命中率は0から1の間でなければなりません"))
        end        
        if 消費MP < 0
            throw(DomainError("消費MPが負の値になっています"))
        end 
        if 攻撃回数min < 0
            throw(DomainError("攻撃回数minが負の値になっています"))
        end 
        if 攻撃回数max < 0
            throw(DomainError("攻撃回数maxが負の値になっています"))
        end 
        if 攻撃回数max < 攻撃回数min 
            throw(DomainError("攻撃回数maxが攻撃回数minより小さくなっています"))
        end 
        new(名前, 威力, 命中率, 消費MP, 攻撃回数min, 攻撃回数max)  
    end
end

function Tスキル(名前, 威力, 命中率, 消費MP) 
    return Tスキル(名前, 威力, 命中率, 消費MP, 1, 1)
end

struct T通常攻撃 end

function createスキル(スキルシンボル)
    if スキルシンボル == :大振り
        return Tスキル("大振り", 2, 0.4, 0)
    elseif スキルシンボル == :連続攻撃
        return Tスキル("連続攻撃", 0.5, 1, 10, 2, 5)
    else
        Throw(DomainError("未定義のスキルが指定されました"))
    end
end
#戦闘.jl
struct T行動
    コマンド
    行動者
    対象者
end

function ダメージ計算(攻撃力, 防御力)
    if 攻撃力 < 0
        throw(DomainError("攻撃力が負の値になっています"))
    end
    if 防御力 ≤ 0
        throw(DomainError("防御力が0または負の値になっています"))
    end
    return round(Int, 10 * 攻撃力/防御力)
end

function 攻撃実行!(攻撃者, 防御者, コマンド::T通常攻撃)
    println("----------")
    println("$(攻撃者.名前)の攻撃!")
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ)
    println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

function 攻撃実行!(攻撃者, 防御者, スキル::Tスキル)
    println("----------")
    println("$(攻撃者.名前)$(スキル.名前)!")
    攻撃回数 = rand(スキル.攻撃回数min:スキル.攻撃回数max)
    for _ in 1:攻撃回数
        if rand() < スキル.命中率
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * スキル.威力, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
    コマンド = コマンド選択(行動者)
    return T行動(コマンド, 行動者, モンスターs[1])
end

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動(T通常攻撃(), 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

function 行動実行!(行動)
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
    MP減少!(行動.行動者, 行動.コマンド)
end

function  is全滅(キャラクターs)
    return all(p.HP == 0 for p in キャラクターs)
end

function is戦闘終了(プレイヤーs, モンスターs)
    return is全滅(プレイヤーs) || is全滅(モンスターs)
end

function 行動順決定(プレイヤーs, モンスターs)
    行動順 = vcat(プレイヤーs, モンスターs)
    return shuffle(行動順)
end

function ゲームループ(プレイヤーs, モンスターs)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

テスト側のコード

テスト側のコードは特にファイル分割はしていない。ただし、例外処理の設定に伴い一部の設定値を変更している。(防御力が0のケースなど)

#game_test.jl

include("game.jl")

using Test

function createキャラクターHP100()
    return Game.Tプレイヤー("", 100, 0, 1, 1, [])
end

function createプレイヤーHP100攻撃力10()
    return Game.Tプレイヤー("", 100, 0, 10, 10, [])
end

function createモンスターHP200攻撃力20()
    return Game.Tモンスター("", 200, 0, 20, 10, [])
end

function createプレイヤーHP0()
    return Game.Tプレイヤー("", 0, 0, 1, 1, [])
end

function createプレイヤーHP1()
    return Game.Tプレイヤー("", 1, 0, 1, 1, [])
end

function createモンスターHP0()
    return Game.Tモンスター("", 0, 0, 1, 1, [])
end

function createモンスターHP1()
    return Game.Tモンスター("", 1, 0, 1, 1, [])
end

function createプレイヤー()
    return Game.Tプレイヤー("", 0, 0, 1, 1, [])
end

function createモンスター()
    return Game.Tモンスター("", 0, 0, 1, 1, [])
end

function createプレイヤーHP(HP)
    return Game.Tプレイヤー("", HP, 0, 1, 1, [])
end

function createモンスターHP(HP)
    return Game.Tモンスター("", HP, 0, 1, 1, [])
end


@testset "HP減少" begin

    @testset "ダメージ < HP" begin
        c = createキャラクターHP100()
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
    end

    @testset "複数回ダメージ" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 94
    end   

    @testset "ダメージ > HP" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 101) #101のダメージ
        @test c.HP == 0
    end

    @testset "ダメージ = HP" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 100) #100のダメージ
        @test c.HP == 0
    end    
end

@testset "行動実行!" begin
    @testset "通常攻撃" begin
        p = createプレイヤーHP100攻撃力10()
        m = createモンスターHP200攻撃力20()

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.T通常攻撃(), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 190

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.T通常攻撃(), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 80
        @test m.HP == 190
    end    

    @testset "大振り攻撃" begin
        p = createプレイヤーHP100攻撃力10()
        m = createモンスターHP200攻撃力20()

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:大振り), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 180 || m.HP == 200

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:大振り), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 100 || p.HP == 60
        @test m.HP == 180 || m.HP == 200
    end 

    @testset "連続攻撃" begin
        p = createプレイヤーHP100攻撃力10()
        m = createモンスターHP200攻撃力20()

        プレイヤーからモンスターへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test 200 - 5 * 5 <= m.HP <= 200 - 5 * 2 

        モンスターからプレイヤーへ攻撃 = Game.T行動(Game.createスキル(:連続攻撃), m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test 100 - 10 * 5 <= p.HP <= 100 - 10 * 2 
        @test 200 - 5 * 5 <= m.HP <= 200 - 5 * 2 
    end 

end

@testset "is戦闘終了" begin
    @testset begin
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0()]) == true
        @test Game.is戦闘終了([createプレイヤーHP0()], [createモンスターHP1()]) == true
        @test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP1()], [createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP0()], [createモンスターHP1()]) == true
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP0()]) == true
    end
end

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    elseif length(配列) == 2
        return 配列[1] != 配列[2]
    else
        先頭 = 配列[1]
        残り = 配列[2:end]
        return !(先頭 in 残り) && is全て相異なる(残り) 
    end
end

@testset "is全て相異なる" begin
    #要素数1
    @test is全て相異なる([1]) == true
    #要素数2
    @test is全て相異なる([1, 2]) == true
    @test is全て相異なる([1, 1]) == false
    #要素数3
    @test is全て相異なる([1, 1, 1]) == false    
    @test is全て相異なる([1, 1, 2]) == false
    @test is全て相異なる([1, 2, 1]) == false
    @test is全て相異なる([2, 1, 1]) == false    
    @test is全て相異なる([1, 2, 3]) == true
    @test is全て相異なる([2, 1, 3]) == true
    @test is全て相異なる([3, 2, 1]) == true
end

@testset "行動順決定" begin
    p1 = createプレイヤー()
    m1 = createモンスター()

    @testset "1vs1" begin
        行動順 = Game.行動順決定([p1], [m1])
        @test length(行動順) == 2
    end

    p2 = createプレイヤー()
    @testset "2vs1" begin
        行動順 = Game.行動順決定([p1, p2], [m1])
        @test length(行動順) == 3
    end

    m2 = createモンスター()
    @testset "1vs2" begin
        行動順 = Game.行動順決定([p1], [m1, m2])
        @test length(行動順) == 3
    end

    @testset "2vs2" begin
        行動順 = Game.行動順決定([p1, p2], [m1, m2])
        @test length(行動順) == 4
    end
end

@testset "is戦闘終了" begin
    @testset "1vs1 両者生存" begin
        p = createプレイヤーHP1()
        m = createモンスターHP1()
        @test Game.is戦闘終了([p], [m]) == false
    end

    @testset "1vs1 プレイヤー死亡" begin
        p = createプレイヤーHP0()
        m = createモンスターHP1()
        @test Game.is戦闘終了([p], [m]) == true
    end
end


@testset "is行動可能" begin
    p = createプレイヤーHP1()
    @test Game.is行動可能(p) == true
    p = createプレイヤーHP0()
    @test Game.is行動可能(p) == false
    m = createモンスターHP1()
    @test Game.is行動可能(m) == true
    m = createモンスターHP0()
    @test Game.is行動可能(m) == false
end

@testset "行動可能な奴ら" begin
    p1 = createプレイヤーHP1()
    @test Game.行動可能な奴ら([p1]) == [p1]
    p2 = createプレイヤーHP0()
    @test Game.行動可能な奴ら([p1, p2]) == [p1]
    p3 = createプレイヤーHP1()
    @test Game.行動可能な奴ら([p1, p2, p3]) == [p1, p3]

    m1 = createモンスターHP1()
    @test Game.行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1]
    m2 = createモンスターHP0()
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1]
    m3 = createモンスターHP1()
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3]
end

@testset "戦況表示" begin
    モンスター = Game.Tモンスター("ドラゴン", 400, 80, 40, 10, [])
    プレイヤー1 = Game.Tプレイヤー("太郎", 100, 20, 10, 10, [])
    プレイヤー2 = Game.Tプレイヤー("花子", 100, 20, 10, 10, [])
    プレイヤー3 = Game.Tプレイヤー("遠藤君", 100, 20, 10, 10, [])
    プレイヤー4 = Game.Tプレイヤー("高橋先生", 100, 20, 10, 10, [])
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター]

    @test Game.戦況表示(プレイヤーs, モンスターs) == 
    """
    *****プレイヤー*****
    太郎 HP:100 MP:20
    花子 HP:100 MP:20
    遠藤君 HP:100 MP:20
    高橋先生 HP:100 MP:20
    *****モンスター*****
    ドラゴン HP:400 MP:80
    ********************"""
end

Julia言語で入門するプログラミング(その5)

「Julia言語で入門するプログラミング」第5回である。未読の方は第1回〜第4回を読んで欲しい。

Julia言語で入門するプログラミング(その1) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その2) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その3) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その4) - SE教育パパむううみんのブログ

当面の目標

キャラクターが複数になり、仕様が大幅に変わったので、実装を大きく変更する必要がある。今回は大変な改造になりそうだ。ふんどしを締めてかかる必要がある。

まずは味方キャラクターを4人、敵キャラクターをドラゴンにしてみよう。当面の目標は、この状態で今までのように動くことにする。つまり下記のような状態を目指す。

  • 味方キャラクターには行動の指示を与えることができる(通常攻撃か大振り)

  • 敵キャラクターは通常攻撃固定

  • 敵か味方が全滅したら戦闘終了

キャラクターごとの個性を出すのはその後だ。

ふんどしは締めただろうか?では始めよう。

改造

味方も敵も初期生成した後は配列に入れることにしよう。いったん、敵モンスターは1体だけだが、今後複数にする予定なので今のうちに配列にしている。こんなふうになる。

function main()
    モンスター = Tモンスター("ドラゴン", 400, 40, 10)
    プレイヤー1 = Tプレイヤー("太郎", 100, 10, 10)
    プレイヤー2 = Tプレイヤー("花子", 100, 10, 10)
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 10, 10)
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 10, 10)

    println("モンスターに遭遇した!")
    println("戦闘開始!")

    ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター])

    if モンスター.HP == 0
        println("戦闘に勝利した!")
    else
        println("戦闘に敗北した・・・")
    end
end

もちろん、ゲームループ関数は影響を受ける。次のように引数に配列を受け取る。複数形の意味を込めてプレイヤーsモンスターsとした。プレイヤーとモンスターが複数になったことで、行動順決定関数や行動の選択方式、戦闘終了条件防御者.HP == 0を変える必要がある。

変更前はこのような形だ。

#変更前
function ゲームループ(プレイヤー, モンスター)
    while true
        for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
            攻撃者, 防御者 = 攻防
            行動実行!(攻撃者, 防御者)
            if 防御者.HP == 0
                return
            end    
        end
    end
end

これがこのように変わる予定だ。

#変更後
function ゲームループ(プレイヤーs, モンスターs)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

まず、行動順の決定に関しては、これまでは乱数の値に従って、プレイヤー先攻とモンスター先攻が決まり、自動的に攻撃側と防御側の組み合わせも決まった。今回からキャラクターが複数になるので変更が入る。行動するキャラクターは敵味方全体を完全にシャッフルする形で決め、その後キャラクターの行動次第で攻撃対象が決まるようにしよう。

行動順決定関数

行動順決定関数は、元々はプレイヤーとモンスターをとり、乱数の結果に応じて行動順の配列を変える関数だった。今回の変更も基本的な考え方は変わらない。プレイヤーの配列とモンスターの配列を引数に取り、シャッフルして1つの配列にして返す。ただし、乱数を引数にとるのはやめにする。

これもテスト駆動で作ってみよう。最初のテストはこんな感じだ。一対一の時には、どちらが先攻かはわからないが、とにかく要素が2つの配列になることは確かだろう。

@testset "行動順決定" begin
    function createプレイヤー()
        return Game.Tプレイヤー("", 0, 0, 0)
    end

    function createモンスター()
        return Game.Tモンスター("", 0, 0, 0)
    end

    p1 = createプレイヤー()
    m1 = createモンスター()

    @testset "1vs1" begin
        行動順 = Game.行動順決定([p1], [m1])
        @test length(行動順) == 2   
    end
end

このテストを通す、最もシンプルな実装はこんなところだろう。相変わらず最初は恐ろしく適当だ。

function 行動順決定(プレイヤーs, モンスターs)
    return [0, 0]
end

テストケースを増やそう。次のケースを追加する。もちろん失敗する。

p2 = createプレイヤー()
@testset "2vs1" begin
    行動順 = Game.行動順決定([p1, p2], [m1])
    @test length(行動順) == 3
end

関数を次のように手直ししよう。append!というのは、第一引数の配列の末尾に第二引数の配列の「中身」を追加する関数だ。push!は、第一引数の配列の末尾に第二引数「そのもの」を追加するので少し違う。これでテストは通る。

function 行動順決定(プレイヤーs, モンスターs)
    行動順 = []
    append!(行動順, プレイヤーs)
    push!(行動順, 0)
    return 行動順
end

さらに次のケースを追加する。このケースも失敗する。

m2 = createモンスター()
@testset "1vs2" begin
    行動順 = Game.行動順決定([p1], [m1, m2])
    @test length(行動順) == 3
end

モンスター側もプレイヤー側と同じくappend!で配列を追加するようにしよう。テストが通るはずだ。

function 行動順決定(プレイヤーs, モンスターs)
    行動順 = []
    append!(行動順, プレイヤーs)
    append!(行動順, モンスターs) #変更
    return 行動順
end

ここまでappend!を使ってきたが、複数の配列を連結させるだけであれば、次のようにした方が意図が明確になる。

function 行動順決定(プレイヤーs, モンスターs)
    行動順 = vcat(プレイヤーs, モンスターs) #[プレイヤーs; モンスターs]と書いてもいい
    return 行動順
end

最後にダメ押しの二対二のテストケースだが、このケースは通る。

@testset "2vs2" begin
    行動順 = Game.行動順決定([p1, p2], [m1, m2])
    @test length(行動順) == 4
end

さて、ここまでは全くシャッフルをしていないので、ここからはシャッフルする処理を入れる必要がある。 Juliaには配列をランダムにシャッフルしてくれるshuffleという関数があるのでそれを使おう。

using Random
...
function 行動順決定(プレイヤーs, モンスターs)
    行動順 = vcat(プレイヤーs, モンスターs)
    return shuffle(行動順)
end

ただし、少し問題がある。シャッフルの結果は自動テストで結果を期待することはできない。渡す配列はきれいな並びにしておいて、シャッフル後にぐちゃぐちゃになっていることを期待値にする、というのもあまり良くはない。シャッフルの結果、元の配列と全く同じになる可能性も、僅かながらあるからだ。自動テストは何百回、何千回も動かされる可能性があるので、「大体うまくいくんですけどね」というのはイマイチだ。テストが失敗した都度、ただの偶然なのか追及すべき不具合なのかを切り分けるのは大変だ。だから、シャッフルされたかどうかは目視で確認するしかない。

ただ、せめてもの追加ケースとして、結果の行動順が同一の要素を含まれていないことを確認するケースは入れておきたい。何かの実装ミスで、太郎と花子とドラゴンの行動順をシャッフルした結果がドラゴン、太郎、ドラゴン、のようにはならないことを確認したい。これはちょっと重たいトピックとなるので、後ほど実装するようにしよう。

さて、行動順決定関数を変更したのでメインロジックも変えておこう。

function ゲームループ(プレイヤー, モンスター) #変更
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs) #変更
            攻撃者, 防御者 = 攻防
            行動実行!(攻撃者, 防御者)
            if 防御者.HP == 0
                return
            end    
        end
    end
end

今の時点では中途半端な修正なので動かない。行動者が行動を決定する際に、行動の内容と対象者を選べるようにしたい。これは今は行動実行関数で行っているが、これは既に決まっている防御者を受け取るインターフェースになっている。全面的に見直す必要がある。(まあ言うほど大した内容ではないが)

行動決定関数

行動者が決まったら、どんな行動を行うか決定する必要がある。そこで、行動決定という関数を作ろうと思う。行動決定関数は「行動」を返り値にする。ここで「行動」とはどのようなイメージになるだろうか。「誰か」が「誰か」に「何か」をするのが行動だ。例えば、「太郎」が「ドラゴン」に「大振り攻撃」をしたり、「高橋先生」が「花子」を「回復」したりするのだ。これを表現するためのデータ構造を作ろう。

struct T行動
    コマンド
    行動者
    対象者
end

コマンドフィールドは、画面から入力された"1"とか"2"とかになる。そのうち多彩な技が出てくるにつれてコマンド自体も複雑な構造体になっていくと思うが、今はそれだけのデータだ。

行動決定関数はT行動構造体を返す。既存の処理を切りはりして作ったもので、次のようになる。画面入力が絡むので自動テストを作れないのが残念だ。

プレイヤーの時には、コマンドは選択するがモンスターはドラゴン1体だけなので、対象はモンスターs[1]固定だ。

モンスターの時には、コマンドは"1"固定だが、対象のプレイヤーはランダムに決まるようにしている。rand関数の引数に配列を与えると、ランダムにどれかの要素を抽出してくれる。

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println("$(行動者.名前)のターン")
    コマンド = コマンド選択()
    return T行動(コマンド, 行動者, モンスターs[1])
end

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動("1", 行動者, rand(プレイヤーs))
end

決定された行動を実行する関数も作ろう。今は攻撃系の行動しかないので、攻撃実行!関数にそのまま流す。

function 行動実行!(行動)
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
end

この関数はテスト可能だ。テストコードを作っておこう。

#julia_test.jl
@testset "行動実行!" begin
    function createプレイヤーHP100攻撃力10()
        return Game.Tプレイヤー("", 100, 10, 10)
    end

    function createモンスターHP200攻撃力20()
        return Game.Tモンスター("", 200, 20, 10)
    end

    @testset "通常攻撃" begin
        p = createプレイヤーHP100攻撃力10()
        m = createモンスターHP200攻撃力20()

        プレイヤーからモンスターへ攻撃 = Game.T行動("1", p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 190

        モンスターからプレイヤーへ攻撃 = Game.T行動("1", m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 80
        @test m.HP == 190
    end    

    @testset "大振り攻撃" begin
        p = createプレイヤーHP100攻撃力10()
        m = createモンスターHP200攻撃力20()

        プレイヤーからモンスターへ攻撃 = Game.T行動("2", p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 180 || m.HP == 200

        モンスターからプレイヤーへ攻撃 = Game.T行動("2", m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 100 || p.HP == 60
        @test m.HP == 180 || m.HP == 200
    end 
end

次のようにメインロジックへ組み込もう。まだ動かないが、後一息だ。

function ゲームループ(プレイヤー, モンスター)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            行動 = 行動決定(行動者, プレイヤーs, モンスターs) #変更
            行動実行!(行動) #変更
            if 防御者.HP == 0
                return
            end    
        end
    end
end

戦闘終了条件

戦闘終了条件を変更しよう。今は防御者.HP == 0となっている部分だ。プレイヤーsモンスターsのどちらかが全滅したら終了だ。これを判定する関数is戦闘終了を書こう。

やりたいのは、プレイヤーsまたはモンスターsのいずれかの配列の全ての要素のHPが0になっていることだ。テストコードは下記のようになる。

@testset "is戦闘終了" begin
    function createプレイヤーHP0()
        return Game.Tプレイヤー("", 0, 0, 0)
    end

    function createプレイヤーHP1()
        return Game.Tプレイヤー("", 1, 0, 0)
    end

    function createモンスターHP0()
        return Game.Tモンスター("", 0, 0, 0)
    end

    function createモンスターHP1()
        return Game.Tモンスター("", 1, 0, 0)
    end

    @testset begin
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0()]) == true
        @test Game.is戦闘終了([createプレイヤーHP0()], [createモンスターHP1()]) == true
        @test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP1()], [createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP0()], [createモンスターHP1()]) == true
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP0()]) == true
    end
end

今回はそれよりも実装に興味がある。例えば、プレイヤーs配列の要素が全てHP=0である時にtrueを返す関数を作るにはどうすればいいだろうか?

例えばこのように書けるだろう。

function  is全滅(プレイヤーs)
    for p in プレイヤーs
        if p.HP != 0
            return false
        end
    end
    return true
end

悪くはない。しかし、ちょっとかっこいいテクニックがあるのだ。

内包表記

内包表記というのは最初に出会うと戸惑うかもしれないが、使い慣れると病みつきになる構文だ。超メジャーというほどではないが、HaskellPythonなどの言語にも登場する便利な機能だ。それらの言語ではリスト内包表記と呼ばれている。知っておいて損はない。内包表記とは配列から配列への変換を行う際の簡潔な表記を提供する。

発想はこうだ。「うーん、このプレイヤーs = [太郎, 花子, 遠藤君, 高橋先生]という配列から、HPが0かどうかの判定をして、結果を[true, true, false, true]みたいに取得できないかなあ」という感じだ。

具体的には次のように書く。

[p.HP == 0 for p in プレイヤーs]

これは後ろから読むと理解しやすい。すなわち、「プレイヤーsの要素をpと名付け、p.HP == 0という式を適用した結果の配列を作りなさい」と読む。

そうすると、プレイヤーsの要素のHP値に応じて、true/falseが定まる。あとは、Juliaにはallという関数があり、これは配列が全てtrueの時のみtrueとなる関数である。

julia> all([true, true])
true

julia> all([true, false])
false

結果、内包表記を使うと次のようにシンプルに表現することができる。なお、プレイヤーにもモンスターにも適用できるように、仮引数の名前はキャラクターsに変更した。

function  is全滅(キャラクターs)
    return all([p.HP == 0 for p in キャラクターs])
end

戦闘終了条件は、プレイヤーsモンスターsのどちらかが全滅することなので、次のように書ける。

function is戦闘終了(プレイヤーs, モンスターs)
    return is全滅(プレイヤーs) || is全滅(モンスターs)
end

この関数をメインロジックの戦闘終了条件として使おう。

function ゲームループ(プレイヤーs, モンスターs)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs) #変更
                    return
                end
            end
        end
    end
end

内包表記は特に新しい何かを提供するわけではない。ただ簡潔に記述できるという点だけがメリットだ。そして、簡潔に記述できるというのはとても大切なことなのだ。

仕上げ

これで動く形になった。しかし、これにはまだバグがある。動かしてみるとわかるが、HPを0にされたキャラクターが平気で動いているのだ。これは生命に対する冒涜だ。行動可能な時にだけ行動できるようにしよう。

#game_test.jl
@testset "is行動可能" begin
    p = createプレイヤーHP1()
    @test Game.is行動可能(p) == true
    p = createプレイヤーHP0()
    @test Game.is行動可能(p) == false
    m = createモンスターHP1()
    @test Game.is行動可能(m) == true
    m = createモンスターHP0()
    @test Game.is行動可能(m) == false
end
#game.jl
function is行動可能(キャラクター)
    return キャラクター.HP != 0
end

これを使って、次のように条件分岐を入れると出来上がりだ。

#game.jl
function ゲームループ(プレイヤーs, モンスターs)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者) #追加
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

もう一つ変更が必要なのが、モンスターが攻撃対象のプレイヤーを選ぶとき、HP=0のキャラクターを対象にする可能性があるところだ。

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動("1", 行動者, rand(プレイヤーs)) #全てのプレイヤーを対象にしている
end

これを避けるために、行動可能な奴らという関数を作ろう。これは受け取ったキャラクターの配列から、HPが0でないキャラクターだけを抽出する関数だ。

テストコードはこうなる。

#game_test.jl
@testset "行動可能な奴ら" begin
    p1 = createプレイヤーHP1()
    @test Game.行動可能な奴ら([p1]) == [p1]
    p2 = createプレイヤーHP0()
    @test Game.行動可能な奴ら([p1, p2]) == [p1]
    p3 = createプレイヤーHP1()
    @test Game.行動可能な奴ら([p1, p2, p3]) == [p1, p3]

    m1 = createモンスターHP1()
    @test Game.行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1]
    m2 = createモンスターHP0()
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1]
    m3 = createモンスターHP1()
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3]
end

実装はこのようになる。

#game.jl
function 行動可能な奴ら(キャラクターs)
    return [c for c in キャラクターs if is行動可能(c)]
end

ifというキーワードが内包表記中に出てきた。ifの条件を満たした要素のみを対象に抽出するという作用をする。そのため、elseなどは存在しない。

これを使うと、次のように修正される。

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動("1", 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

練習問題

配列lst = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]を考える。

  • 問題1

    • lstの各要素を2乗した配列[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]を内包表記で生成しよう。
  • 問題2

    • lstの各要素のうち、偶数の要素のみ残して2乗した配列[4, 16, 36, 64, 100]を内包表記で生成しよう。
  • 問題3

    • lstの各要素のうち、偶数の要素を2乗し、奇数の要素はそのままにした配列[1, 4, 3, 16, 5, 36, 7, 64, 9, 100]を内包表記で生成しよう。

戦況の可視化

さて、動かしてみるとバグはないのだが、いまいち困るとこがある。今の戦況がわかりづらいのだ。今誰がどのくらいの残HPなのか見えづらい。これを改善しよう。

こんな関数を作る。最終的に画面に表示する情報ではあるが、いつものprintln文ではなく文字列を返すようにした。これで自動テストが可能になるのと、println文があまり色々な場所に散らばっているとわかりづらいためだ。

function 戦況表示(プレイヤーs, モンスターs)
    結果 = []
    push!(結果, "*****プレイヤー*****")
    for p in プレイヤーs
        push!(結果, "$(p.名前) HP:$(p.HP)")
    end
    push!(結果, "*****モンスター*****")
    for m in モンスターs
        push!(結果, "$(m.名前) HP:$(m.HP)")
    end
    push!(結果, "********************")
    return join(結果, "\n")
end

まあそんなに見るべきところはないが、joinという関数は知っておいた方がいいだろう。配列に入れた文字列を、何かの区切り文字を連結したい時に使う。今回は改行コードで区切っている。Juliaに限らずいろんな言語で提供されている関数だ。これを使わずにループ処理でやろうとすると、一見簡単に思えて先頭の要素か末尾の要素でごちゃっとした処理を書く必要があり、ダサいのだ。ちなみにJuliaのjoin関数は、最後の区切り要素だけ特別に指定することもできる。これはあまり他の言語では見たことがないが、英語では例を列挙する時にA, B and Cのように、最後だけandにしたりするためらしい。

この処理は文字列を返すようにしているので、自動テスト可能だ。

@testset "戦況表示" begin
    モンスター = Game.Tモンスター("ドラゴン", 400, 40, 10)
    プレイヤー1 = Game.Tプレイヤー("太郎", 100, 10, 10)
    プレイヤー2 = Game.Tプレイヤー("花子", 100, 10, 10)
    プレイヤー3 = Game.Tプレイヤー("遠藤君", 100, 10, 10)
    プレイヤー4 = Game.Tプレイヤー("高橋先生", 100, 10, 10)
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター]

    @test Game.戦況表示(プレイヤーs, モンスターs) == 
    """
    *****プレイヤー*****
    太郎 HP:100
    花子 HP:100
    遠藤君 HP:100
    高橋先生 HP:100
    *****モンスター*****
    ドラゴン HP:400
    ********************"""
end

期待値の部分で「ヒアドキュメント」と呼ばれる特別な文字列の作り方をしている。"""とダブルクォーテーション3つで囲まれた文字列だ。ヒアドキュメントもいくつかの言語で採用されている。このように改行を含んだ文字列を取り扱いやすくするためのものだ。言語によって微妙な差異のある機能だが、インデントを賢く取り扱ってくれたりする。普通にダブルクォーテーションで囲むだけだと、先頭のインデントの空白部分も文字列に含めてしまう。ヒアドキュメントにすると、1行目のインデントに合わせて2行目以降の空白を調整してくれるのだ。

これを使って、行動決定関数で表示するようにしよう。

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
    コマンド = コマンド選択()
    return T行動(コマンド, 行動者, モンスターs[1])
end

こんなふうになる。

*****プレイヤー*****
太郎 HP:60
花子 HP:100
遠藤君 HP:100
高橋先生 HP:100
*****モンスター*****
ドラゴン HP:330
********************
太郎のターン
[1]攻撃[2]大振り: 1

プレイヤー側とモンスター側のHP表示がされて、状況がわかりやすくなった。

スキルを充実させよう

突然自己啓発本のようなフレーズだが、あなたのスキルの話ではない。ゲームのキャラクターの話だ。あなたのスキルは私の記事を読むことでバリバリに伸びているので心配する必要はない。

今は通常攻撃と大振りしかないが、もっと色々増やしたいところだ。

  • 太郎

    • 連続攻撃
      • 2〜5回の連続攻撃を行う。1回当たりのダメージは半減する。
    • かばう
      • 一定期間、指定した相手が受ける攻撃を代わりに受ける。
    • ヒール
      • HPを回復する。
    • 刃に毒を塗る
      • 攻撃がヒットしたら一定確率で毒を与えることができるようになる。
  • 花子

    • ファイア
      • 敵に炎属性の攻撃。
    • アイス
      • 敵に氷属性の攻撃。
    • ドレイン
      • 敵のHPを吸収する。
    • 集中
      • 魔法攻撃の威力を上昇させる。
  • 遠藤君

    • 大振り
      • 命中率は低いが通常の2倍の威力の攻撃を行う。
    • かばう
      • 一定期間、指定した相手が受ける攻撃を代わりに受ける。
    • 捨て身
      • 自分のHPと引き換えに相手に大ダメージを与える
  • 高橋先生

    • ヒール
      • HPを回復する。
    • バリア
      • 指定した相手が受けるダメージを軽減させる。
    • 濃霧
      • 一定期間敵味方全ての攻撃の命中率を下げる。
    • 金縛り
      • 指定した相手を一定期間行動不能にする。

これは大変そうだ。単純に攻撃の威力を上げるようなものは少なくて、ちょっとずつ実装の工夫が必要になりそうなものが多い。ドラゴンの討伐に有効そうで、さらに実装の負担の大きそうなものを頑張って考えてみたのだ。まあ、単純な実装のスキルを量産しても面白くないだろう。このように課題の設定の自由度が高いのもゲームプログラムを題材に選んだ理由の一つだ。果たしてこれらをきれいに実装できるだろうかという点は一抹の不安を覚えないでもないが、きっとなんとかなるだろう。

これらのスキルは順番に実装していくつもりだが、その前にやり残した課題をやっておこう。行動順のシャッフルをした時に、重複が存在しないことの確認のテストケースだ。

再帰

今から作るのは、「与えられた配列の要素が全て異なればtrue、そうでなければfalseを返す関数」だ。関数名はis全て相異なるにしよう。実は同じことをしてくれる関数がJuliaには標準で用意されているのだが、それとは別に実装してみよう。標準ライブラリにある関数を無視してあえて自作するというのは、批判の多い行為だ。「車輪の再発明をするな」というやつだ。ただ、これは仕事や研究で使う製品レベルのコードならその通りだが、今みたいな趣味や練習の場ではあまり気にしなくていい。どんどん再発明してしまおう。そして標準ライブラリのコードを比較してみよう。きっと、思わぬ発見があるはずだ。Juliaの標準ライブラリのコードは公式サイトの「Documentation」から確認できる。使用例のところに「source」というボタンがあるので押すとGitHubのページに飛ぶのだ。驚くほど親切だ。

今から2通りのやり方でこれを実装する。1つ目は普通のループ処理で、2つ目はこのセクションのタイトルになっている「再帰」処理である。

ループで書いてみよう

まずはループを使って書いてみよう。次の処理が、もっともシンプルなテストケースとその実装だ。要素が1つなら重複はないのでtrueになる。

@testset "is全て相異なる" begin
    @test is全て相異なる([1]) == true
end
function is全て相異なる(行動順)
    return true
end

次に、要素数が2つのケースを追加する。

@testset "is全て相異なる" begin
    ...
    @test is全て相異なる([1, 2]) == true
    @test is全て相異なる([1, 1]) == false
end

最もシンプルな実装というとこんな感じだろうか。

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    else
        return 配列[1] != 配列[2]
    end
end

素数が3つのケースはどうだろうか。テストケースはこんな感じだ。

@testset "is全て相異なる" begin
    ...
    #要素数3
    @test is全て相異なる([1, 1, 1]) == false    
    @test is全て相異なる([1, 1, 2]) == false
    @test is全て相異なる([1, 2, 1]) == false
    @test is全て相異なる([2, 1, 1]) == false    
    @test is全て相異なる([1, 2, 3]) == true
    @test is全て相異なる([2, 1, 3]) == true
    @test is全て相異なる([3, 2, 1]) == true
end

実装はどうだろうか。

  1. 1つ目の要素に対して考えると、2つ目の要素と3つ目の要素が1つ目の要素と異なる必要がある。

  2. 2つ目の要素に対して考えると、1つ目の要素と3つ目の要素が2つ目の要素と異なる必要がある。ただし、2つ目の要素と1つ目の要素の比較は1でやっているので、3つ目の要素とだけ比較すれば良い。

  3. 3つ目の要素に対して考えると、1つ目の要素と2つ目の要素が3つ目の要素と異なる必要がある。ただし、2つ目の要素と1つ目の要素の比較は1と2でやっているので、特に何もする必要はない。

そう考えて一般化すると、「i番目の要素が、i+1番目以降の要素に含まれないこと」を配列の全要素について確認できればいいことになる。こんな実装になるだろう。

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    elseif length(配列) == 2
        return 配列[1] != 配列[2]
    else
        for i in 1:length(配列)
            着目要素 = 配列[i]
            残り = 配列[i+1:end]
            if 着目要素 in 残り
                return false
            end
        end
        return true
    end
end

これでテストは通る。ところで、要素が1つのケースと2つのケースでの分岐はいるのだろうか?外してみよう。

function is全て相異なる(配列)
    for i in 1:length(配列)
        着目要素 = 配列[i]
        残り = 配列[i+1:end]
        if 着目要素 in 残り
            return false
        end
    end
    return true
end

これでもテストは通る。

結局、上のケースが最もシンプルな実装と言えそうだ。

再帰処理で書いてみよう

次は再帰処理である。再帰処理とは、自分自身を呼び出す処理のことである。これは実物を見てもらった方が速いだろう。

素数1のケースと2のケースまでの進め方は同じである。要素数が2までのケースのみ考慮した実装は次のようになる。

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    else
        return 配列[1] != 配列[2]
    end
end

素数3のケースを考える時の考え方がガラッと異なる。再帰処理を使う場合にはこう考えるのである。

配列を先頭の要素と残りの要素に分けて考えてみる。先頭の要素が残りの要素に含まれないこと、これが1つ目の条件だ。そして、残りの要素について、is全て相異なるが満たされること、これが2つ目の条件だ。この2つが満たされたら、結局すべての要素について、is全て相異なると言える。

この考え方を表現したのが次のコードだ。is全て相異なるという処理の定義の中で、自分自身を呼び出している。このような処理を再帰処理と呼ぶ。

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    elseif length(配列) == 2
        return 配列[1] != 配列[2]
    else
        先頭 = 配列[1]
        残り = 配列[2:end]
        return !(先頭 in 残り) && is全て相異なる(残り) 
    end
end

このコードは典型的な再帰処理の例になっている。問題のサイズが小さく、結果を自明に表現できるケースでは値(結果)を返し、そうでなければ問題のサイズを縮小する。数学的帰納法と似たようなイメージである。(ただし、length(配列) == 1のケースと、length(配列) == 2のケースの意味の違いには注意!前者は要素数1の配列が入ってきた時の結果を定義しているだけだ。後者は要素数2の配列の結果を定義しているだけでなく、要素数3以上のケースで再帰処理が到達する到達点になっている。要素数1以外のケースから要素数1の結果に到達することはない。)

ループと再帰の比較

ループと再帰を見比べると、何となく考え方の違いが見えてくる。実際のところ、今回出したループ処理と再帰処理はやっていることはほとんど同じなのだが、コードの字面として受ける印象が異なるだろう。

  • ループで書いた処理は、入力された配列をどうチェックすれば指定された条件が満たされるか、という処理になっている。

  • 再帰で書いた処理は、入力された配列がどのような性質を持っていれば指定された条件が満たされるか、という処理になっている。

このような性質の違いを、「手続き的」「宣言的」という言葉で表現しても良い。通常、手続き的な処理よりも宣言的な処理の方がわかりやすい。人間は、動的にグリグリと変化する状況をシミュレートするのがあまり得意ではないからだ。手続的な表現ではまさにそれが求められる。一方、宣言的な表現は、静的な関係の表明なので脳にあまり負荷をかけずに理解できる。

とはいえ、手続的な表現と宣言的な表現に何か明確な境界があるわけでもない。どんな書き方であれ、高級言語である時点でアセンブラに比べれば圧倒的に宣言的だ。x = 1と宣言するだけで、メモリ上のどこかにxというシンボルを持つ変数と、1という値を意味するビット列が確保され、それらの結び付けを行ってくれるのだ。明らかに多くの手続きを、言語処理系が請け負って代行してくれているおかげで、我々は極めて快適にプログラムを書くことができる。プログラミング言語の進化とは手続き型から宣言型への進化とも言えるのだ。

勘違いしてはいけないが、ループで書いたから手続き的、再帰で書いたから宣言的というわけではない。手続的な発想、再帰的な発想がまずあり、それを表現する上で、手続き的な発想をする時にはループ処理で実装する方が親和性が高く、宣言的な発想をする時には再帰処理で実装する方が親和性が高いという話だ。

なお、原理的には、再帰処理とループ処理は互いに書き換え可能である。そもそも関数型と呼ばれるプログラミング言語には、ループという構文自体がないこともある。必要であれば再帰処理で実現可能だからだ。

このように書くと、ループなどいらねえと捨ててしまって、何だって再帰処理で書きたくなってくるかもしれない。しかし、再帰処理には注意点があるのだ。

スタック消費と末尾再帰

再帰処理とスタックオーバーフローは切っても切り離せない関係だ。ちょっと込み入った話になるが、説明しよう。

スタックというのは、プログラムが管理する記憶領域のうちの1つだ。プログラムの実行中、最終的な結果を出す前に一時的にデータを置くための場所として利用される。ローカル変数の値であるとか、関数呼び出しの際の引数の情報であるとか、そういった情報だ。細かい話はおいておくとして、関数呼び出しするとスタックという領域が確保される(使われる)ということを理解して欲しい。ちなみにスタック領域を確保することを、スタックに積むと表現したりする。

関数呼び出しがあったらスタックが確保されるとしても通常は問題にはならない。関数を抜けるとスタックは解放され、再度利用することができるようになるからだ。そのため、通常、確保されるスタックのサイズはそう大きくはならない。しかし、再帰処理の厄介なところは、その関数の計算が完了するまでスタックを食い潰し続けるというところだ。

is全て相異なる関数の評価がどうなされるかを見てみよう。

is全て相異なる([1, 2, 3, 4, 5])
#↓
!(1 in [2, 3, 4, 5]) && is全て相異なる([2, 3, 4, 5])
#↓
true && is全て相異なる([2, 3, 4, 5]) 
#↓
true && (!(2 in [3, 4, 5]) && is全て相異なる([3, 4, 5]))
#↓
true && (true && is全て相異なる([3, 4, 5]))
#↓
true && (true && (!(3 in [4, 5]) && is全て相異なる([4, 5])))
#↓
true && (true && (true && is全て相異なる([4, 5])))
#↓
true && (true && (true && 4 != 5))
#↓
true && (true && (true && true))
#↓
true && (true && true)
#↓
true && true
#↓
true

ポイントは、再帰関数で値を返すことのできるケースに到達するまで、オリジナルの結果が確定しないことだ。そのため、言語処理系は計算の途中で現れた結果(!(1 in [2, 3, 4, 5]) = trueなど)をメモリ上のどこかに保持しておく必要がある。これは通常スタックで保持され、再帰呼び出しのたびにその数が増える。そのため要素数が大きくなると、スタックの上限を超えてしまうことがあり得るのだ。スタック上限を超えてしまうことをスタックオーバーフローと呼び、プログラムの実行は強制的に中断される。

下記のループ版のコードであればそのような心配はない。for文の繰り返し回数がどこまで大きくなろうとも、保持すべき変数の数は一定だからだ。

function is全て相異なる(配列)
    for i in 1:length(配列)
        着目要素 = 配列[i]
        残り = 配列[i+1:end]
        if 着目要素 in 残り
            return false
        end
    end
    return true
end

スタックオーバーフローは再帰関数固有の問題ではない。通常の関数呼び出しでも起こりうる。ただし、そのためには、内部で関数呼び出しを異常な深さで行う必要がある。関数Aの内部でA1を呼び出し、その次にA2を呼び出し、その次にA3を呼び出し、、、というものであれば100万回続いたってスタックオーバーフローは発生しない。それぞれの関数の終わりでスタックは解放されているからだ。単に回数が問題ではない。深さが問題なのだ。関数Aの内部で関数A1を呼び出し、A1の内部でA2を呼び出し、A2の内部でA3を呼び出し、、、という階層である必要があるのだ。これが数万程度のオーダーでなければ発生しないのだ。なかなか起こりそうにないことがわかるだろう。

末尾再帰

再帰関数にはスタックオーバーフローという弱点があることが分かったが、それではループ構文を持たない関数型言語はどう対処しているのだろうか?

ここで登場するのが「末尾再帰」というキーワードだ。

末尾再帰とは、再帰関数での再帰呼び出し処理が、その関数での最後の処理になっていることを言う。次の処理は字面的にはis相異なる(残り)が最後の処理になっているように見えるが、実際にはこの結果を元にした&&演算子の処理が残っているので、これは末尾再帰ではない。

function is全て相異なる(配列)
    ...
        return !(先頭 in 残り) && is全て相異なる(残り) 
    ...
end

末尾呼び出しとして書き換えると、次のようになる。

function is全て相異なる(配列)
    function iter(配列, 結果)
        if length(配列) == 1
            return true
        elseif length(配列) == 2
            return (配列[1] != 配列[2]) && 結果
        else
            先頭 = 配列[1]
            残り = 配列[2:end]
            return iter(残り, !(先頭 in 残り) && 結果) 
        end        
    end

    return iter(配列, true)
end

関数内関数で定義しているが、そこはどうでもよく、重要なのは次の部分である。

function iter(配列, 結果)
    ...
        return iter(残り, !(先頭 in 残り) && 結果) 
    ...        
end

iterという関数が自分自身を呼び出しており、その呼び出し結果を使った演算は存在しない。iterというのは繰り返し処理を意味するiterateの略で、そんなに深い意味があるわけでもない。末尾再帰では演算処理を保持する変数を引数にすることが多いため、関数内関数で実装することが多く、私は末尾再帰形式の時にはよくこの書き方にする。

末尾再帰はなぜスタックを食い潰さないのだろうか?次のイメージを見てほしい。

iter([1, 2, 3, 4, 5], true)
#↓
iter([2, 3, 4, 5], !(1 in [2, 3, 4, 5]) && true)
#↓
iter([2, 3, 4, 5], true && true)
#↓
iter([2, 3, 4, 5], true)
#↓
iter([3, 4, 5], !(2 in [3, 4, 5]) && true)
#↓
iter([3, 4, 5], true && true)
#↓
iter([3, 4, 5], true)
#↓
iter([4, 5], !(3 in [4, 5]) && true)
#↓
iter([4, 5], true && true)
#↓
iter([4, 5], true)
#↓
(4 != 5) && true
#↓
true && true
#↓
true

末尾呼び出し形式にすると、関数呼び出しの後に行う演算が存在しないので、そのために必要な情報をスタックに積む必要がなくなる。そのため、末尾再帰形式にすると、「原理的には」スタックオーバーフローを回避することができる。

ここであえて、原理的には、と強調したのには理由がある。それは、Juliaではこの手法を使ってもスタックオーバーフローを回避できないからだ。

なぜなら、末尾再帰にして各再帰処理の結果をスタックに保持しないようにできたとしても、関数呼び出しそのものでスタックを使ってしまうからだ。上の例で言うと、iterを呼び出すたびに、それが末尾再帰形式であっても、iterという関数呼び出しの履歴がスタックに積まれ、結局スタックを食い潰してしまうのだ。

ループを持たない言語では、この上でさらに一工夫されている。末尾再帰の関数呼び出しの時には、内部的に実行される処理を関数呼び出しからループ処理に変換してくれるのだ。ループ処理であればスタックを食い潰す心配はなくなる。この工夫のことを「末尾再帰の最適化」というように呼ぶ。Juliaは末尾再帰の最適化はサポートしていない。

結果的にJuliaでは、再帰関数を書く時にスタックオーバーフローを絶対に回避できる方法というものは存在しない。そのため、再帰関数を使う際には、事前に想定される規模の入力でスタックオーバーフローが起きないかを確認しておく必要がある。また、スタックオーバーフローが起きてしまうのであればループ処理として実装する必要がある。

再帰処理はある種の関数を非常に簡潔に記述できる強力な武器だ。是非いろいろな関数を再帰処理で書いてみて欲しい。ここで述べたようなリスクは頭に入れておく必要はあるが、使うべき時には使えるようになっておこう。

ちなみに、このis全て相異なる関数だが、Juliaではalluniqueという関数で提供されている。その実装はというと、なんとループも再帰も使っていないのだ。何を行なっているか是非確認してみよう。「Examples」の枠内にカーソルを持っていくと、右下に「source」ボタンが出てくる。

Collections and Data Structures · The Julia Language

練習問題

次の関数をループ処理、再帰処理、末尾再帰処理で実装しよう。指定されたテストケースを通過すること。なお、配列を引数の取る場合、配列は空ではなく、また、要素が全て数値であることは前提にして良い。

  • 与えられた自然数の階乗を求める関数
using Test

@testset "階乗" begin
    @test 階乗_ループ(1) == 1
    @test 階乗_再帰(1) == 1
    @test 階乗_末尾再帰(1) == 1

    @test 階乗_ループ(4) == 24
    @test 階乗_再帰(4) == 24
    @test 階乗_末尾再帰(4) == 24
end
  • 与えられた配列の和を求める関数
using Test

@testset "総和" begin
    @test 総和_ループ([1]) == 1
    @test 総和_再帰([1]) == 1
    @test 総和_末尾再帰([1]) == 1

    @test 総和_ループ([1, 2, 3, 4, 5]) == 15
    @test 総和_再帰([1, 2, 3, 4, 5]) == 15
    @test 総和_末尾再帰([1, 2, 3, 4, 5]) == 15
end
  • 与えられた配列の最大値を求める関数。2つの数の比較に、Juliaのmax関数は使用して良い。
using Test

@testset "最大" begin
    @test 最大_ループ([1]) == 1
    @test 最大_再帰([1]) == 1
    @test 最大_末尾再帰([1]) == 1

    @test 最大_ループ([2, 1]) == 2
    @test 最大_再帰([2, 1]) == 2
    @test 最大_末尾再帰([2, 1]) == 2

    @test 最大_ループ([2, 1, 3]) == 3
    @test 最大_再帰([2, 1, 3]) == 3
    @test 最大_末尾再帰([2, 1, 3]) == 3
end
  • 与えられた配列の平均値を求める関数。
using Test

@testset "平均" begin
    @test isapprox(平均_ループ([1]), 1.0)
    @test isapprox(平均_再帰([1]), 1.0)
    @test isapprox(平均_末尾再帰([1]), 1.0)

    @test isapprox(平均_ループ([2, 1]), 1.5)
    @test isapprox(平均_再帰([2, 1]), 1.5)
    @test isapprox(平均_末尾再帰([2, 1]), 1.5)

    @test isapprox(平均_ループ([2, 1, 3]), 2.0)
    @test isapprox(平均_再帰([2, 1, 3]), 2.0)
    @test isapprox(平均_末尾再帰([2, 1, 3]), 2.0)
end

第5回の終わりに

前回の終わりにモンスターごとの型をつくったり、型の階層構造について説明すると言いながら、全くその部分は着手できなった。まさかこのタイミングで再帰処理について語ることになるとは思ってもみなかったのだ。私は行き当たりばったりなのだ。本当に申し訳ない。メンゴメンゴ。

モンスターごとの型は次回ではおそらく無理だと思う。というか、しばらくはひたすら色々なスキルを実装することに注力することになりそうだ。まあスキルも型を作っていく予定なので、きっと型の階層関係については説明する機会があるだろう。

練習問題の解答

内包表記

配列lst = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]を考える。

  • 問題1

    • lstの各要素を2乗した配列[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]を内包表記で生成しよう。
  • 解答

[e^2 for e in lst]
  • 問題2

    • lstの各要素のうち、偶数の要素のみ残して2乗した配列[4, 16, 36, 64, 100]を内包表記で生成しよう。
  • 解答

[e^2 for e in lst if e%2 == 0]
  • 問題3

    • lstの各要素のうち、偶数の要素を2乗し、奇数の要素はそのままにした配列[1, 4, 3, 16, 5, 36, 7, 64, 9, 100]を内包表記で生成しよう。
  • 解答

[if e%2 == 0 e^2 else e end for e in lst]

再帰処理

次の関数をループ処理、再帰処理、末尾再帰処理で実装しよう。配列の要素が全て数値であることは前提にして良い。

次の関数をループ処理、再帰処理、末尾再帰処理で実装しよう。指定されたテストケースを通過すること。なお、配列を引数の取る場合、配列は空ではなく、また、要素が全て数値であることは前提にして良い。

  • 与えられた自然数の階乗を求める関数
function 階乗_ループ(n)
    s = 1
    while (n >= 1)
        s *= n
        n -= 1
    end
    return s
end

function 階乗_再帰(n)
    if n == 1
        return 1
    else
        return n * 階乗_再帰(n - 1)
    end
end

function 階乗_末尾再帰(n)
    function iter(n, s)
        if n == 1
            return s
        else
            return iter(n - 1, s * n)
        end
    end
    
    return iter(n, 1)
end

これは再帰処理は配列以外にも適用できることを示したいための問題だった。

  • 与えられた配列の和を求める関数
function 総和_ループ(arr)
    s = 0
    for e in arr
        s += e
    end
    return s
end

function 総和_再帰(arr)
    if length(arr) == 1
        return arr[1]
    else
        return arr[1] + 総和_再帰(arr[2:end])
    end
end

function 総和_末尾再帰(arr)
    function iter(arr, result)
        if length(arr) == 1
            return result + arr[1]
        else
            return iter(arr[2:end], result + arr[1])
        end
    end

    return iter(arr, 0)
end
  • 与えられた配列の最大値を求める関数。2つの数の比較に、Juliaのmax関数は使用して良い。
function 最大_ループ(arr)
    これまでの最大値 = arr[1]
    for e in arr[2:end]
        if e > これまでの最大値
            これまでの最大値 = max(これまでの最大値, e)
        end
    end
    return これまでの最大値
end

function 最大_再帰(arr)
    if length(arr) == 1
        return arr[1]
    elseif length(arr) == 2
        return max(arr[1], arr[2])
    else
        return max(arr[1], 最大_再帰(arr[2:end]))
    end
end

function 最大_末尾再帰(arr)
    function iter(arr, これまでの最大値)
        if length(arr) == 0
            return これまでの最大値
        elseif length(arr) == 1
            return max(arr[1], これまでの最大値)
        else
            return iter(arr[2:end], max(arr[1], これまでの最大値))
        end
    end

    return iter(arr[2:end], arr[1])
end

これなどは再帰処理で書くのが最も自然に思える問題だ。ループ処理は、いかにも手続き的な感じが出ている。

  • 与えられた配列の平均値を求める関数。
function 平均_ループ(arr)
    合計 = 0
    要素数 = 0
    for e in arr
        合計 += e
        要素数 += 1
    end
    return 合計/要素数
end

function 平均_再帰(arr)
    function iter(arr, 合計, 要素数)
        if length(arr) == 0
            return 合計/要素数
        else
            return iter(arr[2:end], 合計 + arr[1], 要素数 + 1)
        end
    end

    return iter(arr, 0, 0)
end

function 平均_末尾再帰(arr)
    function iter(arr, 合計, 要素数)
        if length(arr) == 0
            return 合計/要素数
        else
            return iter(arr[2:end], 合計 + arr[1], 要素数 + 1)
        end
    end

    return iter(arr, 0, 0)
end

これは少しいじわる問題だったかも知れない。再帰処理と末尾再帰処理が同じ答えになる。平均値を求める問題というのは、小さな問題に分割できないからだ。与えられた配列を分割してそれぞれの平均値を取る、というようなことをしても、全体の平均値を求める役には立たない。このようなケースでは再帰処理にできたとしても、ループで変化させている値を引数として渡して変化させることにしかならない。それであればループで処理するのが自然である。

ここまでの実装

最後にここまでのコードを載せておこう。game.jlとgame_test.jlがごちゃごちゃしてきている、そろそろファイルを分割するかも知れない。

#game.jl
module Game

using Random

mutable struct Tプレイヤー
    名前
    HP
    攻撃力
    防御力
end

mutable struct Tモンスター
    名前
    HP
    攻撃力
    防御力
end

struct T行動
    コマンド
    行動者
    対象者
end


function ダメージ計算(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

function HP減少!(防御者, ダメージ)
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

function 攻撃実行!(攻撃者, 防御者, コマンド)
    println("----------")
    if コマンド == "1"
        println("$(攻撃者.名前)の攻撃!")
        防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
        HP減少!(防御者, 防御者ダメージ)
        println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
        println("$(防御者.名前)の残りHP:$(防御者.HP)")
    elseif コマンド == "2"
        println("$(攻撃者.名前)の大振り!")
        if rand() < 0.4
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

function is行動可能(キャラクター)
    return キャラクター.HP != 0
end


function 行動可能な奴ら(キャラクターs)
    return [c for c in キャラクターs if is行動可能(c)]
end

function 行動順決定(プレイヤーs, モンスターs)
    行動順 = vcat(プレイヤーs, モンスターs)
    return shuffle(行動順)
end

function コマンド選択()
    function isValidコマンド(コマンド)
        return コマンド in ["1", "2"]
    end

    while true
        コマンド = Base.prompt("[1]攻撃[2]大振り")
        if isValidコマンド(コマンド)
            return コマンド            
        else
            println("正しいコマンドを入力してください")
        end
    end 
end

function 戦況表示(プレイヤーs, モンスターs)
    結果 = []
    push!(結果, "*****プレイヤー*****")
    for p in プレイヤーs
        push!(結果, "$(p.名前) HP:$(p.HP)")
    end
    push!(結果, "*****モンスター*****")
    for m in モンスターs
        push!(結果, "$(m.名前) HP:$(m.HP)")
    end
    push!(結果, "********************")
    return join(結果, "\n")
end

function 行動決定(行動者::Tプレイヤー, プレイヤーs, モンスターs)
    println(戦況表示(プレイヤーs, モンスターs))
    println("$(行動者.名前)のターン")
    コマンド = コマンド選択()
    return T行動(コマンド, 行動者, モンスターs[1])
end

function 行動決定(行動者::Tモンスター, プレイヤーs, モンスターs)
    return T行動("1", 行動者, rand(行動可能な奴ら(プレイヤーs)))
end

function 行動実行!(行動)
    攻撃実行!(行動.行動者, 行動.対象者, 行動.コマンド)
end

function  is全滅(キャラクターs)
    return all([p.HP == 0 for p in キャラクターs])
end

function is戦闘終了(プレイヤーs, モンスターs)
    return is全滅(プレイヤーs) || is全滅(モンスターs)
end

function ゲームループ(プレイヤーs, モンスターs)
    while true
        for 行動者 in 行動順決定(プレイヤーs, モンスターs)
            if is行動可能(行動者)
                行動 = 行動決定(行動者, プレイヤーs, モンスターs)
                行動実行!(行動)
                if is戦闘終了(プレイヤーs, モンスターs)
                    return
                end
            end
        end
    end
end

function main()
    モンスター = Tモンスター("ドラゴン", 400, 40, 10)
    プレイヤー1 = Tプレイヤー("太郎", 100, 10, 10)
    プレイヤー2 = Tプレイヤー("花子", 100, 10, 10)
    プレイヤー3 = Tプレイヤー("遠藤君", 100, 10, 10)
    プレイヤー4 = Tプレイヤー("高橋先生", 100, 10, 10)

    println("モンスターに遭遇した!")
    println("戦闘開始!")

    ゲームループ([プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4], [モンスター])

    if モンスター.HP == 0
        println("戦闘に勝利した!")
    else
        println("戦闘に敗北した・・・")
    end
end

end
#game_test.jl
include("game.jl")

using Test

function createキャラクターHP100()
    return Game.Tプレイヤー("", 100, 0, 0)
end

function createプレイヤーHP100攻撃力10()
    return Game.Tプレイヤー("", 100, 10, 10)
end

function createモンスターHP200攻撃力20()
    return Game.Tモンスター("", 200, 20, 10)
end

function createプレイヤーHP0()
    return Game.Tプレイヤー("", 0, 0, 0)
end

function createプレイヤーHP1()
    return Game.Tプレイヤー("", 1, 0, 0)
end

function createモンスターHP0()
    return Game.Tモンスター("", 0, 0, 0)
end

function createモンスターHP1()
    return Game.Tモンスター("", 1, 0, 0)
end

function createプレイヤー()
    return Game.Tプレイヤー("", 0, 0, 0)
end

function createモンスター()
    return Game.Tモンスター("", 0, 0, 0)
end


@testset "HP減少" begin

    @testset "ダメージ < HP" begin
        c = createキャラクターHP100()
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
    end

    @testset "複数回ダメージ" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 94
    end   

    @testset "ダメージ > HP" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 101) #101のダメージ
        @test c.HP == 0
    end

    @testset "ダメージ = HP" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 100) #100のダメージ
        @test c.HP == 0
    end    
end

@testset "行動実行!" begin
    @testset "通常攻撃" begin
        p = createプレイヤーHP100攻撃力10()
        m = createモンスターHP200攻撃力20()

        プレイヤーからモンスターへ攻撃 = Game.T行動("1", p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 190

        モンスターからプレイヤーへ攻撃 = Game.T行動("1", m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 80
        @test m.HP == 190
    end    

    @testset "大振り攻撃" begin
        p = createプレイヤーHP100攻撃力10()
        m = createモンスターHP200攻撃力20()

        プレイヤーからモンスターへ攻撃 = Game.T行動("2", p, m)
        Game.行動実行!(プレイヤーからモンスターへ攻撃)
        @test p.HP == 100
        @test m.HP == 180 || m.HP == 200

        モンスターからプレイヤーへ攻撃 = Game.T行動("2", m, p)
        Game.行動実行!(モンスターからプレイヤーへ攻撃)
        @test p.HP == 100 || p.HP == 60
        @test m.HP == 180 || m.HP == 200
    end 

end

@testset "is戦闘終了" begin
    @testset begin
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0()]) == true
        @test Game.is戦闘終了([createプレイヤーHP0()], [createモンスターHP1()]) == true
        @test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP1()], [createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP0(), createプレイヤーHP0()], [createモンスターHP1()]) == true
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP1()]) == false
        @test Game.is戦闘終了([createプレイヤーHP1()], [createモンスターHP0(), createモンスターHP0()]) == true
    end
end

function is全て相異なる(配列)
    if length(配列) == 1
        return true
    elseif length(配列) == 2
        return 配列[1] != 配列[2]
    else
        先頭 = 配列[1]
        残り = 配列[2:end]
        return !(先頭 in 残り) && is全て相異なる(残り) 
    end
end

@testset "is全て相異なる" begin
    #要素数1
    @test is全て相異なる([1]) == true
    #要素数2
    @test is全て相異なる([1, 2]) == true
    @test is全て相異なる([1, 1]) == false
    #要素数3
    @test is全て相異なる([1, 1, 1]) == false    
    @test is全て相異なる([1, 1, 2]) == false
    @test is全て相異なる([1, 2, 1]) == false
    @test is全て相異なる([2, 1, 1]) == false    
    @test is全て相異なる([1, 2, 3]) == true
    @test is全て相異なる([2, 1, 3]) == true
    @test is全て相異なる([3, 2, 1]) == true
end

@testset "行動順決定" begin
    p1 = createプレイヤー()
    m1 = createモンスター()

    @testset "1vs1" begin
        行動順 = Game.行動順決定([p1], [m1])
        @test length(行動順) == 2
    end

    p2 = createプレイヤー()
    @testset "2vs1" begin
        行動順 = Game.行動順決定([p1, p2], [m1])
        @test length(行動順) == 3
    end

    m2 = createモンスター()
    @testset "1vs2" begin
        行動順 = Game.行動順決定([p1], [m1, m2])
        @test length(行動順) == 3
    end

    @testset "2vs2" begin
        行動順 = Game.行動順決定([p1, p2], [m1, m2])
        @test length(行動順) == 4
    end
end

@testset "is戦闘終了" begin
    function createプレイヤーHP(HP)
        return Game.Tプレイヤー("", HP, 0, 0)
    end

    function createモンスターHP(HP)
        return Game.Tモンスター("", HP, 0, 0)
    end

    @testset "1vs1 両者生存" begin
        p = createプレイヤーHP(1)
        m = createモンスターHP(1)
        @test Game.is戦闘終了([p], [m]) == false
    end

    @testset "1vs1 プレイヤー死亡" begin
        p = createプレイヤーHP(0)
        m = createモンスターHP(1)
        @test Game.is戦闘終了([p], [m]) == true
    end
end


@testset "is行動可能" begin
    p = createプレイヤーHP1()
    @test Game.is行動可能(p) == true
    p = createプレイヤーHP0()
    @test Game.is行動可能(p) == false
    m = createモンスターHP1()
    @test Game.is行動可能(m) == true
    m = createモンスターHP0()
    @test Game.is行動可能(m) == false
end

@testset "行動可能な奴ら" begin
    p1 = createプレイヤーHP1()
    @test Game.行動可能な奴ら([p1]) == [p1]
    p2 = createプレイヤーHP0()
    @test Game.行動可能な奴ら([p1, p2]) == [p1]
    p3 = createプレイヤーHP1()
    @test Game.行動可能な奴ら([p1, p2, p3]) == [p1, p3]

    m1 = createモンスターHP1()
    @test Game.行動可能な奴ら([p1, p2, p3, m1]) == [p1, p3, m1]
    m2 = createモンスターHP0()
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2]) == [p1, p3, m1]
    m3 = createモンスターHP1()
    @test Game.行動可能な奴ら([p1, p2, p3, m1, m2, m3]) == [p1, p3, m1, m3]
end

@testset "戦況表示" begin
    モンスター = Game.Tモンスター("ドラゴン", 400, 40, 10)
    プレイヤー1 = Game.Tプレイヤー("太郎", 100, 10, 10)
    プレイヤー2 = Game.Tプレイヤー("花子", 100, 10, 10)
    プレイヤー3 = Game.Tプレイヤー("遠藤君", 100, 10, 10)
    プレイヤー4 = Game.Tプレイヤー("高橋先生", 100, 10, 10)
    プレイヤーs = [プレイヤー1, プレイヤー2, プレイヤー3, プレイヤー4]
    モンスターs = [モンスター]

    @test Game.戦況表示(プレイヤーs, モンスターs) == 
    """
    *****プレイヤー*****
    太郎 HP:100
    花子 HP:100
    遠藤君 HP:100
    高橋先生 HP:100
    *****モンスター*****
    ドラゴン HP:400
    ********************"""
end
game_exec.jl
include("game.jl")

Game.main()

Julia言語で入門するプログラミング(その4)

「Julia言語で入門するプログラミング」第4回である。未読の方は第1回〜第3回を読んで欲しい。

Julia言語で入門するプログラミング(その1) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その2) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その3) - SE教育パパむううみんのブログ

標準入力

前回の終わりで「さすがに次回は命令くらいは下せるようにはしたいと思っている」と言ったので早速実行しよう。自分のあまりの手際の良さに惚れ惚れするばかりだ。

まずは、プレイヤーキャラクターに命令を与えられるようにしたい。命令を与えられるからには行動に選択肢を増やそう。これまではただの攻撃しかなかったが、それに加えて「大振り」という攻撃方法を追加するようにしよう。これは、通常の2倍のダメージを与えられるのだが、大振りになるぶん命中率が下がり、60%の確率で外れてしまうという攻撃だ。期待値としては通常攻撃よりも少し悪いのだが、ちまちまと回復してくる敵だと一撃で仕留められるメリットがある。プレイヤーの行動時には、通常の攻撃か大振りかを選べるようにしたい。

そうすると、プログラムに対して何か入力をする必要が出てくる。プログラムに何かを入力する代表的な方法として、標準入力というものがある。これは、ざっくりいうとキーボードのことだ。プログラムに対する入力としては、ファイルの内容であるとか、USBケーブルからの信号であるとかも考えられるが、ひとまず最も標準的な入力はキーボードであろうということでこうなっている。

今からプログラムで勇者に命令を下すにあたっては、これに倣ってキーボードからコマンドを入力する。もちろんお好みでマイコンと物理スイッチを買ってきて、USBポートからモールス信号を流し込んめるようにしても良いが、この記事では取り扱わない。私が教えて欲しいくらいだ。

最初に準備として、前回作ったテストコードは捨ててしまおう。前回までは3ターン殴り合うだけだったので、全ての実行パターンを網羅できた。だが、今回から、敵味方共に行動のバリエーションが増える。そのため、乱数の要素を除いたとしても、動作の網羅をすることは事実上不可能になる。前回一枚岩のコードからそれぞれの役割を持つ関数などに分離できたので、それらの関数単位での自動テストは今後増やしていく予定だが、main処理を通してのテストはひとまず忘れることにする。

main関数の偽乱数列結果引数をなくし、テストコードもなくし、偽乱数列の要素を使っていたところはrand()関数に置き換えよう。結果、次のようなコードが今回のスタート地点になる。

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 ゲームループ(プレイヤー, モンスター)
    while true
        for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
            攻撃者, 防御者 = 攻防
            攻撃実行!(攻撃者, 防御者)
            if 防御者.HP == 0
                return
            end    
        end
    end
end

function main()
    モンスター = キャラクター("モンスター", 30, 10, 10)
    プレイヤー = キャラクター("勇者", 30, 10, 10)

    println("モンスターに遭遇した!")
    println("戦闘開始!")

    ゲームループ(プレイヤー, モンスター)

    if モンスター.HP == 0
        println("戦闘に勝利した!")
    else
        println("戦闘に敗北した・・・")
    end
end

main()

ではスタートだ。まずは、行動するキャラクターが勇者の時に、コマンド入力を促すようにしてみよう。そのためには、まずキャラクターがプレイヤーなのかモンスターなのかを判別できる必要がある。構造体にフィールドを一つ追加しよう。

mutable struct キャラクター
    名前
    HP
    攻撃力
    防御力
    isプレイヤー #追加
end

今回追加したのは、isプレイヤーというフィールドである。これは真偽値であり、プレイヤーの時に真に、モンスターの時に偽になる。ちょっと違和感のあるフィールド名かもしれない。「プレイヤーフラグ」などの名前にしたくなるかもしれないところだ。フラグというのは、何かの条件が成立した時にON/OFFさせる真偽値を表すために使われることが多い言葉だ。しかし、〇〇フラグという言葉は良くないことがある。名前の付け方によっては、どのような状態を指すのか分かりづらいことがあるためだ。

例えば、キャラクターが「死亡フラグ」という名称のフィールドを持っていたとして、そのキャラクターが死亡している状況なのか、そのキャラクターが死亡することが確定している状況なのか分かりづらい。「死亡済み」とか「死亡確定」とか、もっと明快な名称をつけるべきなのだ。そう言った名前にすると、真偽値であることが明確になり、フラグという名前は余計なものになる。フラグという言葉をつけなければおさまりが悪いのであれば、それは前段の言葉が不十分なのだ。逆にフラグとつけると、前段の言葉が不十分であっても、何となくそれなりの名前に見えてしまうのだ。

そのようなわけでフラグはあえて使わない。今回は「プレイヤーフラグ」と名付けてもおそらく十分明快ではあるのだが、あえてそうする。なお、通常こう言った時には普通の英語ベースのプログラムだと、「is○○」のような名前にする。「isプレイヤー」のようなフィールド名と、「プレイヤーである」のようなフィールド名のどちらが読みやすいか迷ったが、両方試した結果、「isプレイヤー」を採用することにした。英語と日本語が混ざって気持ち悪いかと思ったが意外とそうでもなく、むしろプログラミングのイディオム的でわかりやすい。

さて、キャラクターがプレイヤーだった場合、ユーザーに入力を促し、受け取った入力を保持する仕組みが必要になる。これを行ってくれるのが、Base.prompt関数である。この関数は引数に指定した文字列をユーザーに提示し、入力された文字列を返り値とする関数だ。

このように入力してみる。

julia> 入力 = Base.prompt("キー入力してエンターを押してください")

すると、下記のようになり、ユーザー入力を待つ。

julia> 入力 = Base.prompt("キー入力してエンターを押してください")
キー入力してエンターを押してください:

そのうえで、テストと入力すると、その内容が返り値の変数に保存される。

julia> 入力 = Base.prompt("キー入力してエンターを押してください")
キー入力してエンターを押してください: テスト
"テスト"

julia> 入力
"テスト"

これを使おう。次のように、攻撃実行!の前にif文を入れよう。そして、キャラクターがプレイヤーであれば、入力を促す。1を選んだら通常攻撃、2を選んだら大振りになるようにしている。受け取ったコマンドは攻撃実行!関数に渡す。モンスターのターンでは通常攻撃を示す1固定で呼び出す。

if 攻撃者.isプレイヤー
    println("勇者のターン")
    コマンド = Base.prompt("[1]攻撃[2]大振り")
    攻撃実行!(攻撃者, 防御者, コマンド)
else
    攻撃実行!(攻撃者, 防御者, "1")
end

動作の違いは攻撃実行!関数で表現する。

function 攻撃実行!(攻撃者, 防御者, コマンド)
    println("----------")
    if コマンド == "1"
        println("$(攻撃者.名前)の攻撃!")
    elseif コマンド == "2"
        println("$(攻撃者.名前)の大振り!")        
    end
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    防御者.HP = 防御者.HP - 防御者ダメージ
    println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
    println("$(防御者.名前)の残りHP:$(防御者.HP)")
end

ここではまだ、大振りの攻撃が威力2倍で命中率40%にする対応入れていない。ただ、メッセージだけ「大振り」となるようにした。これで動かしてみよう。

ただ、ここで注意がある。これまではVisual Studio Codeの上でJuliaプログラムを実行されていたと思うが、どうやら現在最新のVisual Studio CodeのJulia Extension v1.0.10にはバグがあり、Visual Studo Codeのターミナルから標準入力をうまく読み取ってくれないのだ。そのため、書き上げたコードはREPLに読み込ませるようにしよう。

Juliaでファイルに書かれたコードをREPL実行するには、includeというものが使う。これは指定されたファイルの中身をべちゃっと貼り付けたのと同じ動きをする。REPLでinclude(ファイルパス)とすると、そのファイルを実行できる。

ファイルパスを取得するには、Visual Studio Codeで、上部のファイル名が出ているタブで右クリックして「Copy Path」という選択肢を選ぶ。そうすると、クリップボードにコピーされる。macの場合はそのまま貼り付ければ良いのだが、Windowsの場合は注意点がある。Windowsのファイルパスはバックスラッシュ\記号(もしくは¥記号)で表現されるが、このままではJuliaでは上手くパスと解釈されない。スラッシュに変換するか、\記号を2つ重ねにしよう。

#悪い例
julia> include("C:\src\julia\game\rpg4.jl")

#良い例
julia> include("C:\\src\\julia\\game\\rpg4.jl")

#良い例
julia> include("C:/src/julia/game/rpg4.jl")

これで動かしてみると、REPLのプロンプトで、コマンド入力を促される。あとは好きに命令を下そう。

julia> include("あなたのファイルのパス")
モンスターに遭遇した!
戦闘開始!
[1]攻撃[2]大振り: 2
----------
勇者の大振り!
モンスターは10のダメージを受けた!
モンスターの残りHP:20

確かに動いている。

もう1つだけ作業をしよう。構造体を定義したあと、一度定義した構造体を変更した時、Juliaがエラーを出すことはなかっただろうか?「ERROR: invalid redefinition of〜」というやつだ。これまではVisual Studio Codeのターミナルでゴミ箱ボタンを押せばVisual Studo CodeのREPLが消えて、再度実行すればよかった。今後は、REPLを再起動する必要があり面倒だ。少し細工をしよう。

ファイルの先頭にmodule Game を書き、最後のmain()の直前に endを入れる。さらに、main()の呼び出しを、Game.main()に変える。こんなふうになる。

#先頭
module Game

using Test
...

#末尾
        println("戦闘に敗北した・・・")
    end
end

end

Game.main()

moduleについてはあまり深入りしない。大規模なアプリケーションでは大事になってくるのだが、今の段階ではあまり気にするものでもない。こうすると、構造体の定義を変えてもエラーにならないので使っただけで、今のところ特に本質的なものではない。

話を戻すと、これでようやくコマンドを与えて動作を変えられるようになった。ついでに、大振り攻撃を威力2倍で命中率40%にしてみよう。とりあえず安直に作ったのが次のコードだ。醜い重複にムズムズしてくるが、あとで整理する。

function 攻撃実行!(攻撃者, 防御者, コマンド)
    println("----------")
    if コマンド == "1"
        println("$(攻撃者.名前)の攻撃!")
        防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
        防御者.HP = 防御者.HP - 防御者ダメージ
        println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
        println("$(防御者.名前)の残りHP:$(防御者.HP)")
    elseif コマンド == "2"
        if rand() < 0.4 #40%の確率で攻撃成功。
            println("$(攻撃者.名前)の大振り!")
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力) #威力2倍
            防御者.HP = 防御者.HP - 防御者ダメージ
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

動かしていると、困ったことが起きたことに気づくだろう。モンスターのHPが10残っていて、大振りで20のダメージを与えた時、モンスターのHPが-10になってしまうのだ。ゲームの終了条件は防御者.HP == 0になっているので、この判定をすり抜けてしまう。HPがマイナスにならないようにしよう。

ここで、防御者のHPを計算する関数を作るのだが、ちょっとテスト駆動開発というやり方で作ってみよう。

テスト駆動開発

テスト駆動開発は一風変わった開発方法だ。普通はコードを書いてからテストをするのだが、このやり方はテストを書いてからコードを作る。妙なやり方に思えるかもしれないが、これがなかなか癖になるのだ。慣れるとむしろコードから先に書くやり方だと落ち着かなくなってくる。

テスト駆動開発のサイクルはこうだ。

  1. まず最初にテストコードを書く。
  2. 何もないところにテストコードだけ書くので必ず失敗するはずだ。失敗することを確認する。
  3. 次にテストが通る最低限のコードを書く。
  4. テストが通ることを確認する。1に戻って新たなテストを書く。

思いつく限りのテストを書いたら完了だ。何故こんなやり方をするのだろうか?理由はいくつかある。

  • 関数のインターフェースが自然になる。

    • 関数を作る時に実装から入ってしまうと、既存の部品に合わせたインターフェース(引数や返り値)になってしまうことがある。それがその関数にとって自然なインターフェースであれば良いのだが、そうならないこともある。テストから書く = 関数の呼び出し方を決める、ということなので、その関数にとって最も自然なインターフェースをまず考えることになる。
  • 不必要に複雑な実装にならない。

    • テストが通る最低限のコードを書くというところがミソだ。コードを書いていると、こんな拡張性はいるかな、あんなことは考慮しておいたほうがいいかな、と考えすぎて無駄に複雑な設計になってしまうことがある。テストが通る最低限のコードを書くことは、設計が複雑になりすぎない重要な基準になる。もちろん、テストが足りなければ単に欠陥のあるコードなので、テストは十分に用意しておく必要がある。
  • テストが後回しにされない。

    • コードは実装があれば動く。テストがなくても動く。だから、気を抜くとテストは作られなくなってしまう。
  • テスト可能な関数になる。

    • 第一回の終盤で話したが、自動テスト可能なコードというのは自然には出来上がらない。途中からテスト可能なコードに作り替えるのは大変だ。最初から作っておけば無駄な手戻りを防ぐことができる。
  • テストケースが関数の仕様となる。

    • テストコードを見ることで、どのような使い方が想定された関数かがわかるようになる。動作保証されているサンプルコードとなるのだ。

このように、メリットの多い手法なので、ぜひ身につけていきたい。

準備

テストコードを作り始める前に少しだけ準備しよう。これまでは1つのファイルの中に、全てのコードが含まれていたが、ここからは3つのファイルに分けることにする。ゲームの部品を書くメインのファイル、通常の実行で呼び出すファイル、テストの実行で呼び出すファイルだ。

ほとんどのコードは1番目のファイルに含まれる。ここまで書いた中で、Game.main()以外は全てここに入れよう。これをgame.jlというファイル名にしよう。なお、using Testは消した。テストは別のファイルに書くからだ。

#game.jl
module Game

mutable struct キャラクター
    名前
    HP
    攻撃力
    防御力
    isプレイヤー
end

function ダメージ計算(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

function 攻撃実行!(攻撃者, 防御者, コマンド)
    println("----------")
    if コマンド == "1"
        println("$(攻撃者.名前)の攻撃!")
        防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
        防御者.HP = 防御者.HP - 防御者ダメージ
        println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
        println("$(防御者.名前)の残りHP:$(防御者.HP)")
    elseif コマンド == "2"
        println("$(攻撃者.名前)の大振り!")
        if rand() < 0.4
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力)
            防御者.HP = 防御者.HP - 防御者ダメージ
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

function 行動順決定(プレイヤー, モンスター, 乱数)
    if 乱数 < 0.5
        return [[プレイヤー, モンスター], [モンスター, プレイヤー]]
    else
        return [[モンスター, プレイヤー], [プレイヤー, モンスター]]
    end
end

function ゲームループ(プレイヤー, モンスター)
    while true
        for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
            攻撃者, 防御者 = 攻防
            if 攻撃者.isプレイヤー
                println("勇者のターン")
                コマンド = Base.prompt("[1]攻撃[2]大振り")
                攻撃実行!(攻撃者, 防御者, コマンド)
            else
                攻撃実行!(攻撃者, 防御者, "1")
            end
            if 防御者.HP == 0
                return
            end    
        end
    end
end

function main()
    モンスター = キャラクター("モンスター", 30, 10, 10, false)
    プレイヤー = キャラクター("勇者", 30, 10, 10, true)

    println("モンスターに遭遇した!")
    println("戦闘開始!")

    ゲームループ(プレイヤー, モンスター)

    if モンスター.HP == 0
        println("戦闘に勝利した!")
    else
        println("戦闘に敗北した・・・")
    end
end

end

次に、同じフォルダにgame_exec.jlというファイルを作ろう。中身はたったこれだけだ。include文でgame.jlの中身をベチャッと貼り付け、main関数を呼んでいるだけだ。普通にゲームを実行する時にはこちらを呼び出す。execはexecuteという英語の略だ。

#game_exec.jl
include("game.jl")

Game.main()

最後に、同じフォルダにgame_test.jlというファイルを作ろう。こちらを呼び出すとテストが実行される。ここにテストコードを追加していこう。

#game_test.jl
include("game.jl")

using Test

これらは同じフォルダに作るようにしているが、変えたければ別のフォルダに分けても良い。その場合は、includeの中身を相対パスなり絶対パスで指定しよう。これで準備は完了だ。

失敗するテストの作成

最初にテストケースを作る。関数名はHP減少!としよう。引数の状態を変更する関数なので、!を忘れないようにしよう。忘れても動作に支障はないのだが、使う人がびっくりしてしまう。

適当なキャラクターを作成し、ダメージを与え、残HPを計算しよう。

#game_test.jl
include("game.jl")

using Test

@testset "HP減少" begin
    @testset "ダメージ < HP" begin
        c = Game.キャラクター("", 100, 0, 0, true) #HP100のキャラクター
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
    end
end

game.jlファイルの内部をmodule Gameで囲ったので、Game.キャラクターGame.HP減少!のように書く必要が出ている。moduleとは、アプリケーション内に仕切りを作る機能なのだ。moduleの外部から内部の関数や変数にアクセスするには、モジュール名.関数名のようにする必要がある。

これを実行すると、次のようなエラーになる。

ダメージ < HP: Error During Test at game_test.jl:6
  Got exception outside of a @test
  UndefVarError: HP減少! not defined

HP減少!なんて関数は無いと怒られているのだ。期待通りだ。しかし、いったいこんなことをして何の意味があるのだろうか?これは、作った自動テストが正しく動作することを確認しているのだ。たまにだが、テストの作り方を間違えて、常にOKを返すテストを作ってしまったりする。失敗することを確認するようにしていると、そんなミスを防ぐことができる。

最低限のコードの作成

次にテストケースを通過させる最低限のコードを書く。game.jlのどこかに次の関数を追加しよう。

#game.jl
function HP減少!(防御者, ダメージ)
    防御者.HP = 97
end

97固定値だって!?良識溢れるプログラマの皆様からすると卒倒するようなコードかもしれない。神をも恐れぬ蛮行である。しかし、「テストケースを通過させる最低限のコード」という要件は満たしている。最低限というよりは最低なコードという方が適切かもしれないが、ともあれ、テストは通る。気にしない気にしない。万事OKだ。

リファクタリング

次のテストケースに移る前に、ちょっとコードを読みやすくしよう。Game.キャラクター("", 100, 0, 0, true) #HP100のキャラクターという部分がダサいのだ。HP100のキャラクターを作りたいだけなのに、余計な引数が目立っており、コメントが無いと良くわからない。テストコードに使用する関数を作ろう。

#game_test.jl
@testset "HP減少" begin
    function createキャラクターHP100()
        return Game.キャラクター("", 100, 0, 0, true)
    end

こんな関数は本体側のコードに入れるようなものでは無いのでテスト側の関数とした。関数名に値がベタ書きで入っているなんて本体側のコードでは考えられないが、テストコードなら私はありだと思う。テストコードはなるべく単純明快であるべきで、そのためなら汎用性は少しくらい犠牲にしても良い。ちなみにcreateとは生成するという意味の英語で、構造体などを作る時に慣例的に使われる。

この共通関数を呼び出すように変更したら、次のようになる。

@testset "ダメージ < HP" begin
    c = createキャラクターHP100() 
    Game.HP減少!(c, 3) #3のダメージ
    @test c.HP == 97
end

もちろんテストが通ることは確認しておこう。

テストケース量産

テストケースを増やそう。要領は分かったと思うのでここからはテンポ良くいこう。次のテストケースは何度も攻撃されたら都度HPが減っていくケースにしよう。

@testset "複数回ダメージ" begin
    c = createキャラクターHP100() 
    Game.HP減少!(c, 3) #3のダメージ
    @test c.HP == 97
    Game.HP減少!(c, 3) #3のダメージ
    @test c.HP == 94
end

当たり前だがテストは通らない。ひどいコードを書いたバチが当たったのだ。

複数回ダメージ: Test Failed at game_test.jl:21
  Expression: c.HP == 94
   Evaluated: 97 == 94

修正が必要だ。テストを通す最低限の実装というと、このくらいになるだろう。

function HP減少!(防御者, ダメージ)
    防御者.HP = 防御者.HP - ダメージ
end

これでテストは通る。

Test Summary: | Pass  Total
HP減少         |    3      3

次はHPより大きいダメージを与えられた時にHPがマイナスにならず0になるテストケースを作ろう。

#game_test.jl
@testset "ダメージ > HP" begin
    c = createキャラクターHP100() 
    Game.HP減少!(c, 101) #101のダメージ
    @test c.HP == 0
end

これは期待通りエラーになる。順調だ。

ダメージ > HP: Test Failed at game_test.jl:27
  Expression: c.HP == 0
   Evaluated: -1 == 0

テストが通るように修正しよう。

#game.jl
function HP減少!(防御者, ダメージ)
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

これでテストが通る。

Test Summary: | Pass  Total
HP減少         |    4      4

こんなコードを書いたら境界値が気になってくるものだ。HPとダメージが全く同じ値の時に正しく動いてくれるだろうか?大丈夫な気もするが、テストケースを追加しよう。

#game_test.jl
@testset "ダメージ = HP" begin
    c = createキャラクターHP100() 
    Game.HP減少!(c, 100) #100のダメージ
    @test c.HP == 0
end 

これは問題なく通る。ひとまずこのくらいにしておこう。本当は、マイナスのダメージが入ってきたときのことも考えたいのだが、ちょっと後回しにする。

この関数を、HPを減らしている処理に差し替える。

if コマンド == "1"
    ...
    防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
    HP減少!(防御者, 防御者ダメージ) #差し替え
    ...
elseif コマンド == "2"
    ...
    if rand() < 0.4
        防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力)
        HP減少!(防御者, 防御者ダメージ) #差し替え
    ...
end

これでHPがマイナスになることはなくなるはずだ。

エラーハンドリング

コマンドを与えることはできるようになったが、危うい点が残っている。下記のコマンド入力箇所で、ユーザーが有効なコマンドを指定してくれたら良いが、変な入力をされたらどうなるだろうか?どうもこうも、今は完全に無視されるだけだが、できればユーザーに無効な入力をされたことを通知し、再度正しい入力をしてもらいたい。

コマンド = Base.prompt("[1]攻撃[2]大振り")
攻撃実行!(攻撃者, 防御者, コマンド)

外部から入力されたデータをチェックすることは重要だ。不正なデータを通知することはユーザーフレンドリーでもあるし、チェック処理の後続の処理はデータが汚いことを気にする必要がなくなる。

入力されたコマンドをそのまま次の処理に投げつけるのではなく、いったん内容を確認してみるようにしよう。

要は、入力されたコマンドが1、2であればOK、そうでなければNGなのだ。コマンドが1、2でない限りは再入力を求める処理なので、while文を使うのが適当だろう。次のような感じになる。inというのは関数だ。a in babに含まれることを表す。という記号を使うこともできる。これは数学などで出てくるが、ある要素が集合に含まれることを示す記号だ。ちょっと数学チックなので、この記事では使わないが、知っていたらすっきり記述できて良い。何よりも、対となるという記号があるのが良い。inにはそのようなものがなく、書くとすれば!(a in b)である。これをa ∉ bと書けるのだ。

while true
    コマンド = Base.prompt("[1]攻撃[2]大振り")
    if コマンド in ["1", "2"]
        break                        
    else
        println("正しいコマンドを入力してください")
    end
end            

どうせだからこの処理は関数化しよう。

function コマンド選択()
    while true
        コマンド = Base.prompt("[1]攻撃[2]大振り")
        if コマンド in ["1", "2"]
            return コマンド
        else
            println("正しいコマンドを入力してください")            
        end
    end 
end

この関数をREPLに貼り付けると1か2を入力しない限りは延々と正しいコマンドの入力を求められることがわかるだろう。さらに、コマンド in ["1", "2"]という処理も関数化したい。コマンドが1か2であるということは、それ即ち妥当なコマンドであるということだ。その意図を明確にした関数名をつけよう。

とはいえ、この関数は広く使われるものでもなく、コマンド選択関数の中だけで使われる補助的な関数になるので、定義も関数内にしてしまおう。Validというのは英語で妥当なという意味だ。入力値が妥当であることのチェックをバリデーションと呼んだりする。isvalidというのは、妥当性をチェックする関数の慣用句になっているのでそれに倣ってisvalidコマンドという名前にした。

function コマンド選択()
    function isvalidコマンド(コマンド)
        return コマンド in ["1", "2"]
    end

    while true
        コマンド = Base.prompt("[1]攻撃[2]大振り")
        if isvalidコマンド(コマンド)
            return コマンド            
        else
            println("正しいコマンドを入力してください")
        end
    end 
end

この関数のように、物事が上手くいかないケースの取り扱いを「エラーハンドリング」という。今回ように、上手くいったかどうかを返り値で判定し、条件分岐でエラー時の処理と正常時の処理を分けるのが最も一般的だ。これ以外の代表的な方法として、「例外処理」と呼ばれるものがある。第2回でもちょろっと触れた。ここで例外処理について語りたくなってくるのだが、またゲーム開発の進捗が止まってしまうので我慢する。第3回では細かい話をしすぎた気がして反省しているのだ。例外処理は次回か次々回くらいにとっておくことにしよう。

この関数を使うと、次のように書ける。

if 攻撃者.isプレイヤー
    println("勇者のターン")
    コマンド = コマンド選択()
    攻撃実行!(攻撃者, 防御者, コマンド)

練習問題

  • 問題1
    • コマンド選択関数はテスト駆動では作らなかった。これはなぜかわかるだろうか?

個性的なキャラクター

勇者に複数の命令を下せるようになったのはいいし、着々とプログラミング作法を学べているのも良いのだが、肝心のゲームがいまいち盛り上がらない。私が盛り上がらないだけで皆さんが盛り上がってくれていればそれでも良いのだが、あまりそんな気もしない。

理由はいくつか思いつく。まずリソース管理の概念がない。RPGの醍醐味の一つが、限られたリソースをどう配分して課題をクリアするか、というところにある。HP、MP、アイテムの制約がある中でダンジョンに潜ったり強敵を倒したりする。今は一戦限りで終わってしまうので、駆け引きというものがない。ダンジョンを作るのは大変だが、せめて雑魚敵と何連戦かした上にボス敵と戦うような構成にしたい。

キャラクターに個性がないのも問題だ。味方も敵も一人きりで、敵に至っては「モンスター」だ。もっとスライムやらドラゴンやらゾンビやら、個性豊かなキャラクターがいなくてはいけない。味方だって戦士とか魔法使いとかそういうのが欲しい。

他にも足りないものはいくらでもありそうだが、とりあえずこの辺りを解決しなければ話にならない。どちらから解決するかであるが、ここは後者から解決することにしよう。リソース管理については、一戦限りの戦闘であっても敵が強くて長期戦になればリソース管理の余地が生まれる。

そのようなわけで、これから我々のゲームのプレイヤーキャラクターとなる、主人公と愉快な仲間たちを紹介しよう。これまでの勇者君には一旦退場いただき、これからは彼らを操りモンスターと戦っていくことになる。

  • 太郎

    • 主人公格のキャラクターである。名字は山田。日本人としては極めて親近感のある名前だと思う。万能型の器用なキャラクターである。
  • 花子

    • ヒロイン的なキャラクターである。名字は田中。太郎とは幼なじみという設定にしておこう。魔法使い型のキャラクターである。
  • 遠藤君

    • 頼りになる友人役のキャラクターである。名前は哲也。戦士型のキャラクターである。ガキ大将的性格で気性が激しいので、みんなから「君」づけで呼ばれている。太郎も例外ではない。物語中盤で彼らの絆が深まるイベントを用意してあげると良いだろう。「太郎」「遠藤」と呼び合うシーンは涙無くして見ることはできない。厚手のハンカチを用意しておこう。
  • 高橋先生

    • お目付役の女の先生である。国語の先生なので特に戦闘に秀でているわけではない。支援型のキャラクターである。

こんなところだろう。枯れ木も山の賑わいとばかりに設定を盛り込んでみた。この設定を活かせるかは限りなく未知数だ。

敵も「モンスター」ではつまらないので具体的なキャラクターにしよう。かといってゴブリンやスライムのような弱そうなのと長期戦を強いられるのも格好がつかない。もっと強そうな敵にしよう。

  • ドラゴン

    • 口から炎を吐き、鋭い爪と固い鱗を持つ強力なモンスターだ。爬虫類なので体温が下がると活動が鈍くなるぞ。
  • ケルベロス

    • 頭が3つある魔犬。普段は地獄の番犬をしている。弱ると遠吠えで仲間を呼ぶので、ある程度ダメージを与えたら一気に倒してしまうべし。
  • 青銅魔人

    • 魔法の力で動く金属の巨大人形。とにかく頑丈なのでまともにダメージは与えられない。しばらくするとエネルギー切れで動かなくなるので、攻撃を受けないように時間稼ぎしよう。

どれも強そうだし、個性もあって良い感じだ。

実装の方針

さて、このような個性豊かな面々の特徴をどうコードに落とし込めば良いだろうか。

単に攻撃力が高いとか、防御力が高いという話であれば、能力値を調整すれば良い。しかし、体温低下とか、エネルギー切れになるとかはどうだろうか。そのような状態を管理するフィールドをキャラクター構造体に追加していくというのは1つの手だ。モンスターの種類を表すフィールドも必要になるだろう。

モンスターの攻撃実行!関数内で、こんな処理を書くことになるだろうか。

if モンスター.種類 == "ドラゴン" 
    if モンスター.体温低下度 > 3
        println("ドラゴンは体温が低下し動けない")
        ...
    end
elseif モンスター.種類 == "青銅魔人" 
    if モンスター.残エネルギー == 0
        println("青銅魔人はエネルギー切れとなった")
        ...
    end
else if ...
end

これでももちろん動く。しかし、どうにもif文がごちゃごちゃしている感じは拭えない。Juliaにはもっと良いやり方があるのだ。これからそれを紹介しよう。そのためにはまず、「型」という概念を理解する必要がある。

Juliaでは、というか、プログラミング言語の世界では、データは「型」という概念を持つ。「型」とは、そのデータがどのような種類のデータであるかを表す。 1というのは整数という型のデータだ。1.0というのは小数という型のデータだ。だから、11.0は違うものなのだ。

「馬鹿な!」と、あなたは叫ぶかもしれない。

「そんな無法は許されない!!」そう叫んで、私の手からキーボードをむしり取るかもしれない。

「Juliaに聞いてやろうじゃないか!!!」キーボードが壊れるほどの勢いで、REPLに入力するかもしれない。

julia> 1 == 1.0
true

「それ見たことか!同じじゃないか!!いい加減なことを言うな!!!」興奮のあまり、奪ったキーボードで私の頭をぶっ叩くのかもしれない。無法はどっちだ。

少し落ち着いて欲しい。落ち着いて聞いて欲しい。今から2つ大事なことを言う。

  1. プログラミング言語の世界では通常、整数と小数が区別されることは事実だ。しかし、それは「整数と小数が違う振る舞いをする」ということとはイコールではない。整数と小数に別の型を与えながらも、整数と小数が同じような振る舞いにすることは可能だ。

  2. Juliaでは整数と小数が違う振る舞いをすることがある。

整数と小数の違いについては、深くは立ち入らない。ここでは次の例だけ示そう。

julia> 1 === 1.0
false

第一回で言及を避けた、===演算子である。これは二つのデータが完全に同一である時にtrueとなる。どういうことだろうか?

コンピュータのデータは0と1だけで表現されると聞いたことはあるだろうか。データはコンピュータのメモリ上のどこかに保持されるわけだが、その際我々が慣れ親しんだ、5だとか0.2だとかaみたいな形では保存されない。このようなデータに変換というか、解釈できるような0と1の並べ方のルールが決まっており、コンピュータの内部ではあくまでそのルールに沿った0と1が並んでいるだけなのだ。このような0と1の数字それぞれのことをビットという。

Juliaではデータがどのようなビットの並びで表現されるかを見る関数が用意されている。bitstring関数だ。これで数字がどのようなビット列として表現されるか見てみよう。0や1が連続して並ぶ部分は非常に長いので..で省略した。

julia> bitstring(0)
"0..000000"

julia> bitstring(1)
"0..000001"

julia> bitstring(2)
"0..000010"

julia> bitstring(3)
"0..000011"

julia> bitstring(4)
"0..000100"

なんとなく理解できるだろう。普通の数字と同様に、下の桁から順に繰り上がっていく。正確に理解するには二進数というキーワードで調べてみよう。

これと比較して小数はどうなるか。

julia> bitstring(0.0)
"00000000000..0"

julia> bitstring(1.0)
"00111111111100..0"

julia> bitstring(1.1)
"0011111111110001100110011001100110011001100110011001100110011010"

julia> bitstring(1.9)
"0011111111111110011001100110011001100110011001100110011001100110"

julia> bitstring(2.0)
"010000000000..0"

小数は整数と比べて一見してルールが分かりづらい。小数も整数と同じく二進数なのだが、ルールはずっと複雑になる。

話を戻して、1 === 1.0だが、これは二つのデータが完全に同一である時にtrueとなると言った。数値のような不変オブジェクトでは、ビットの並びが同じ時に完全に同一と判断される。見ての通り、1と1.0は、コンピュータの内部では明確に違うビット列になる。そのため、1 === 1.0はfalseになるのだ。ちなみに、可変なオブジェクトの場合には、仮に全く同じメモリの並びであっても===はtrueとならず、完全に同じメモリ番地のデータの時に限りtrueとなる。

また、ここからわかるように、同じビットの並びであっても、それを整数と解釈するか小数と解釈するかで違う値を意味することになる。

このように、データは「ビットの並び」と、「ビットの並びの解釈」の2つの要素が組み合わさることで初めて正しく取り扱うことができるようになる。この「ビットの並びの解釈」の役割を担うのが「型」という存在だ。Juilaはデフォルトで整数、小数、文字、文字列、配列などの型を提供している。これらはプリミティブ型と呼ばれる最も基本的なデータ型である。

そして、それを組み合わせることで構造体などより複雑な型を定義することができるようになる。構造体を定義するというのは、新しい型を定義しているということになるのだ。

ところで、「型」は英語では"type"という単語だ。というか、順序からすると"type"に「型」という訳語が割り当てられたのだが、「型」という単語はイメージが湧きづらい。私は"type"の訳語としては「種類」の方が分かりやすかったのではないかと思う。まあ、「種類」とすると身近な単語すぎて逆に分かりづらかったり誤解を招く可能性があったのかもしれないが。

型による演算の選択

データと型は分かち難く結びついた概念だ。型はさらに、演算とも深く関わっている。

例えば、*という演算のことを考えよう。ご存知の通りこの記号は、数値であれば掛け算、文字列であれば文字列を連結させるという演算を担っている。Juliaはa * bという式がある時に、abがどのような型のデータであるかにより、実際に行う処理を変えている。

Juliaは、*という記号の演算をたくさん知っている。そして、入ってきたデータの型に応じて、たくさんある*演算のうち適切なものを選択することができる。これはJuliaが勝手に行うことであり、我々はこのことを意識しなくても良いようになっている。

そして、我々はこの仕組みをもっと積極的に使うこともできる。全く同名の関数を複数定義する。しかし、これらの関数の引数の型を変えておく。関数呼び出しの際、どちらの関数を呼び出すかを指定する必要はない。Juliaがうまくやってくるれるのだ。早速やってみよう。

今、次のように、データがプレイヤーかモンスターかで分けている処理がある。

if 攻撃者.isプレイヤー
    println("勇者のターン")
    コマンド = コマンド選択()
    攻撃実行!(攻撃者, 防御者, コマンド)
else
    攻撃実行!(攻撃者, 防御者, "1")
end

これは、次のように変形できる。これは単に関数に抽出しただけだ。

function 行動実行_プレイヤー!(攻撃者, 防御者)
    println("勇者のターン")
    コマンド = コマンド選択()
    攻撃実行!(攻撃者, 防御者, コマンド)
end

function 行動実行_モンスター!(攻撃者, 防御者)
    攻撃実行!(攻撃者, 防御者, "1")
end

if 攻撃者.isプレイヤー
    行動実行_プレイヤー!(攻撃者, 防御者)
else
    行動実行_モンスター!(攻撃者, 防御者)
end

このif分岐はもちろん我々プログラマが書いた分岐だ。データの種類に応じて実行されるべき処理を、明示的に選択している。しかし、型の仕組みをうまく使えば、適切な処理が自動で選択されるようにできるのだ。

まず、プレイヤーとモンスターの型を別々に定義する。ここで、頭に「T」をつけた。これは変数名に「プレイヤー」「モンスター」を使いたいがための苦肉の策だ。「T」にはType(型)の意味を込めた。キャラクターは不要なので消した。また、isプレイヤーも新しい型には不要だ。。

mutable struct Tプレイヤー
    名前
    HP
    攻撃力
    防御力
end

mutable struct Tモンスター
    名前
    HP
    攻撃力
    防御力
end

#=
mutable struct キャラクター
    名前
    HP
    攻撃力
    防御力
    isプレイヤー
end
=#

そして、main関数での変数の宣言を変更する。

function main()
    #モンスター = キャラクター("モンスター", 30, 10, 10, false)
    #プレイヤー = キャラクター("勇者", 30, 10, 10, true)

    モンスター = Tモンスター("モンスター", 30, 10, 10)
    プレイヤー = Tプレイヤー("勇者", 30, 10, 10)

ここからが重要だ。行動実行!という名前の関数を2つ定義する。そして、どちらの型の時に呼び出して欲しいかを、引数で指定する。(仮引数::型名)の形だ。

次のようにすることで、攻撃者変数がTプレイヤーなのかTモンスターなのかに応じて、どちらの関数が呼び出されるかが決まる。

#=
function 行動実行_プレイヤー!(攻撃者, 防御者)
    println("勇者のターン")
    コマンド = コマンド選択()
    攻撃実行!(攻撃者, 防御者, コマンド)
end
=#

function 行動実行!(攻撃者::Tプレイヤー, 防御者)
    println("勇者のターン")
    コマンド = コマンド選択()
    攻撃実行!(攻撃者, 防御者, コマンド)
end

#=
function 行動実行_モンスター!(攻撃者, 防御者)
    攻撃実行!(攻撃者, 防御者, "1")
end
=#

function 行動実行!(攻撃者::Tモンスター, 防御者)
    攻撃実行!(攻撃者, 防御者, "1")
end

呼び出し元は、ただ呼び出したい処理を指定するだけで良い。あとは引数の型の情報をもとに、Juliaがうまいことやってくれる。

#=
if 攻撃者.isプレイヤー
    行動実行_プレイヤー!(攻撃者, 防御者)
else
    行動実行_モンスター!(攻撃者, 防御者)
end
=#
行動実行!(攻撃者, 防御者)

差異を表示するためにコメントアウトでコードを残していたが、不要なコードは消してしまうと、次のようになる。isプレイヤーでのif文が消えたことがわかるだろう。

function 行動実行!(攻撃者::Tプレイヤー, 防御者)
    println("勇者のターン")
    コマンド = コマンド選択()
    攻撃実行!(攻撃者, 防御者, コマンド)
end

function 行動実行!(攻撃者::Tモンスター, 防御者)
    攻撃実行!(攻撃者, 防御者, "1")
end

function ゲームループ(プレイヤー, モンスター)
    while true
        for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
            攻撃者, 防御者 = 攻防
            行動実行!(攻撃者, 防御者)
            if 防御者.HP == 0
                return
            end    
        end
    end
end

かなりダイナミックに変更を行った。テストは通るだろうか?もちろん失敗する。キャラクターという構造体を消したからだ。createキャラクター関数の中身を修正しておこう。

function createキャラクターHP100()
    return Game.Tプレイヤー("", 100, 0, 0)
end

これで自動テストは通る。致命的な失敗はしていなさそうだ。自動テストではカバーしていない部分も多くあるので、ゲームを動かしてみて、変な動きになっていないかは、確認しておこう。

今回のところはここまでだ。最後のあたりは特に重要だ。型によって適用される関数が変わるという機能を利用することで、手動での条件分岐を減らせることがある。このように関数が型によって決定されることを、「(型による)ディスパッチ」と呼んだりする。Juliaの大きな特徴の1つに「多重ディスパッチ」と呼ばれる機能がある。これは、なかなか他の言語ではお目にかかれない強力な機能なのだ。せっかくJuliaで学んでいるのだから、この部分はしっかりと身につけたい。

第4回の終わりに

ついに、ゲームプログラミングらしい題材を入れることができた。次回以降、多彩なモンスターの特徴をJuliaのディスパッチシステムを使って表現していく。さらにプレイヤー側の技のバリーションも増やしていく予定だ。これもJuliaのディスパッチシステムを利用してうまく整理していこう。また、その中で型の階層関係についても触れることになるだろう。

練習問題の解答例

今回も練習問題が1つだけと少ない。そのうえ、あまり練習問題という感じではない。型によるディスパッチの問題を出そうかと思ったが、次回以降で嫌というほどやる予定なのでやめることにした。腹落ちしていなければいろいろ試してみて欲しい。

テスト駆動開発

  • 問題

    • コマンド選択関数はテスト駆動では作らなかった。これはなぜかわかるだろうか?
  • 解答

    • 標準入力での入力を求められるからだ。自動テストではキーボードから入力することができない。

当たり前すぎて答えに詰まったかもしれないが、これがテスト駆動開発の実践が意外と難しい部分なのだ。テスト駆動開発はメリットが大きいのだが、アプリケーション全体をテスト駆動開発で作るのは難易度が高い。最初に作る部分ほど、画面表示であったり画面からの入力であったりするためだ。そのため、テスト駆動開発を取り入れるのは、アプリケーションが多少大きくなってきて、部品に別れはじめたくらいから始めるのが良いと思う。

最終的なコード

現時点でのコードを掲載しておこう。

#game.jl
module Game

mutable struct Tプレイヤー
    名前
    HP
    攻撃力
    防御力
end

mutable struct Tモンスター
    名前
    HP
    攻撃力
    防御力
end


function ダメージ計算(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

function HP減少!(防御者, ダメージ)
    if 防御者.HP - ダメージ < 0
        防御者.HP = 0
    else
        防御者.HP = 防御者.HP - ダメージ
    end
end

function 攻撃実行!(攻撃者, 防御者, コマンド)
    println("----------")
    if コマンド == "1"
        println("$(攻撃者.名前)の攻撃!")
        防御者ダメージ = ダメージ計算(攻撃者.攻撃力, 防御者.防御力)
        HP減少!(防御者, 防御者ダメージ)
        println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
        println("$(防御者.名前)の残りHP:$(防御者.HP)")
    elseif コマンド == "2"
        println("$(攻撃者.名前)の大振り!")
        if rand() < 0.4
            防御者ダメージ = ダメージ計算(攻撃者.攻撃力 * 2, 防御者.防御力)
            HP減少!(防御者, 防御者ダメージ)
            println("$(防御者.名前)$(防御者ダメージ)のダメージを受けた!")
            println("$(防御者.名前)の残りHP:$(防御者.HP)")
        else
            println("攻撃は失敗した・・・")
        end
    end
end

function 行動順決定(プレイヤー, モンスター, 乱数)
    if 乱数 < 0.5
        return [[プレイヤー, モンスター], [モンスター, プレイヤー]]
    else
        return [[モンスター, プレイヤー], [プレイヤー, モンスター]]
    end
end

function コマンド選択()
    function isValidコマンド(コマンド)
        return コマンド in ["1", "2"]
    end

    while true
        コマンド = Base.prompt("[1]攻撃[2]大振り")
        if isValidコマンド(コマンド)
            return コマンド            
        else
            println("正しいコマンドを入力してください")
        end
    end 
end

function 行動実行!(攻撃者::Tプレイヤー, 防御者)
    println("勇者のターン")
    コマンド = コマンド選択()
    攻撃実行!(攻撃者, 防御者, コマンド)
end

function 行動実行!(攻撃者::Tモンスター, 防御者)
    攻撃実行!(攻撃者, 防御者, "1")
end

function ゲームループ(プレイヤー, モンスター)
    while true
        for 攻防 in 行動順決定(プレイヤー, モンスター, rand())
            攻撃者, 防御者 = 攻防
            行動実行!(攻撃者, 防御者)
            if 防御者.HP == 0
                return
            end    
        end
    end
end

function main()
    モンスター = Tモンスター("モンスター", 30, 10, 10)
    プレイヤー = Tプレイヤー("勇者", 30, 10, 10)


    println("モンスターに遭遇した!")
    println("戦闘開始!")

    ゲームループ(プレイヤー, モンスター)

    if モンスター.HP == 0
        println("戦闘に勝利した!")
    else
        println("戦闘に敗北した・・・")
    end
end

end
#game_exec.jl
include("game.jl")

Game.main()
#game_test.jl
include("game.jl")

using Test

@testset "HP減少" begin
    function createキャラクターHP100()
        return Game.Tプレイヤー("", 100, 0, 0)
    end

    @testset "ダメージ < HP" begin
        c = createキャラクターHP100()
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
    end

    @testset "複数回ダメージ" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 97
        Game.HP減少!(c, 3) #3のダメージ
        @test c.HP == 94
    end   

    @testset "ダメージ > HP" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 101) #101のダメージ
        @test c.HP == 0
    end

    @testset "ダメージ = HP" begin
        c = createキャラクターHP100() 
        Game.HP減少!(c, 100) #100のダメージ
        @test c.HP == 0
    end    
end

続きの記事

Julia言語で入門するプログラミング(その5) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その3)

「Julia言語で入門するプログラミング」第3回である。未読の方は第1回、第2回を読んで欲しい。

Julia言語で入門するプログラミング(その1) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その2) - SE教育パパむううみんのブログ

なお、この回から「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. Functions · The Julia Language

DeepL翻訳で翻訳した内容がこちらだ。

ジュリアの関数引数は "パスバイシェアリング "と呼ばれる慣習に従っており、関数に渡されても値はコピーされません。関数引数自体は新しい変数バインディング(値を参照できる新しい場所)として動作しますが、参照する値は渡された値と同じです。関数内で行われた(配列のような)変異可能な値への変更は、呼び出し元から見えるようになります。これは、Scheme、ほとんどのLisps、PythonRubyPerlなどの動的言語で見られる動作と同じです。

呼び出し元の変数と関数の引数は全く別の変数になるが、関数に渡された引数の値は、呼び出し元とそのまま同じものが渡されると言うのである。

であれば、なぜ数値や文字列は関数内部で変更しても呼び出し元に影響を与えないのか?これは、次のような式が、変数に設定されている値の変更ではなく、再設定だからである。

x = x + 1

もともと、x = 5となっていたとしよう。このとき変数と値はどのような関係になっているか。言語によってまちまちなのだが、Juliaでは、x5の紐付きは割とゆるい。5という値はコンピュータのメモリのどこかに保存されており、xはそのメモリを参照している、という関係である。

ここで、x = x + 1とすると、メモリ上の56に変化するのではなく、メモリ上の別の領域に6が設定され、xが参照する先がそのメモリに変更になるのである。(実際には場合により最適化の関係で元のメモリ上の56に直接変更されるかもしれないが、このように理解しても差し支えないように設計されている。)

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を参照するわけではない。yxの値を参照するだけであり、そのうえ、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]なる配列がある領域に束縛されている。配列は複数の変数を含む広い領域である。次にbaと同じ領域に束縛される。次に、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の素手で殴り合っている。我々はそれを鑑賞するだけであり、命令を下すことすらできていない。

しかし、今回はかなりの山場だったのではないかと思っている。不変性や可変性の話、関数内部での引数の書き換えの話ができたのは大きかった。このあたりは、あらゆる場面に関連する土台の部分で、土台の部分でありながらもややこしいという困ったトピックだったのだ。ゲーム部分はさておき、プログラミング知識としては大きく進展した。

とはいえ、ゲームの方が作る作る詐欺になりつつあるので、さすがに次回は命令くらいは下せるようにはしたいと思っている。乞う、ご期待!

続きの記事

Julia言語で入門するプログラミング(その4) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その5) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その2)

「Julia言語で入門するプログラミング」第2回である。まだ第1回を読まれていない方はぜひそちらから読んでいただきたい。

第1回

if文

前回の記事の終わりの方で、ダメージ計算関数の自動テストを作ると言ったが、ちょっと後に回す。先にif文を説明する必要がある。

if文とは、条件分岐のことである。プログラムで、ある条件が成り立つときにこういった処理をして欲しい、別の条件のときにはこうして欲しい、といった処理を書きたいことはよくある。そのために使われる構文がif文である。

例えば、ダメージを受けてHPを減らす処理を考えよう。残りHPが10のときに、20のダメージを受けたとして、HPがマイナスになって欲しくはないはずだ。HPは0になるのが普通のゲームだ。そのようなときは、次のような処理を書くことになる。

function 残HP計算(残HP, ダメージ)
    ダメージ計算後残HP = 0
    if 残HP - ダメージ < 0
        ダメージ計算後残HP = 0
    else
        ダメージ計算後残HP = 残HP - ダメージ
    end
    return ダメージ計算後残HP
end

中身の説明は後でするとして、まずはこのコードをJuliaのREPLに貼り付け、その後、残HP計算処理を呼び出してみよう。

julia> 残HP計算(10, 3)
7

julia> 残HP計算(10, 10)
0

julia> 残HP計算(10, 20)
0

確かに、10の残HPに対して20のダメージの時には、マイナスではなく0になっていることがわかる。

では、if文の説明に入る。まず、if文の骨格を示すと次のようになる。

if (条件式1)
  (条件式1が満たされた時に実行される処理)
elseif (条件式2)
  (条件式2が満たされた時に実行される処理)
elseif (条件式3)
  (条件式3が満たされた時に実行される処理)
else
  (条件式が全て満たされなかった時に実行される処理)
end

これらのうち、elseif とelseに関しては、なくても構わない。elseifはいくつあってもいいが、elseは一つだけだ。if文は上から順に評価される。そのため、仮に条件式1と条件式2が全く同じものであっても、「条件式1が満たされた時に実行される処理」のみが実行される。条件式2も満たされているはずだが、「条件式1が満たされた時に実行される処理」は評価されない。

条件式

条件式とはどんなものだろうか?条件式には、真偽値と呼ばれるものが入る。これは真か偽か決定できる値で、英語ではBooleanと呼ばれる。Bool値と言う呼ばれ方をすることもある。例えば、「数値xは1と等しい」は、xの値が1かどうかで結果は変わるにしろ、真か偽か決定できる。「配列aの長さは10より小さい」も、真か偽か決定できる。こう書くと、真か偽か決定できないものなどあるのか?という気がしてくるが、実際には新でも偽でもないものは多くある。例えば、10という値は真でも偽でもない。

なぜこんなことを言うかと言うと、C言語Rubyなど一部の言語では、if文の条件式に真偽値以外の値を入れることができるためである。C言語に至っては、そもそも真偽値という概念がなく、「真と評価される値」「偽と評価される値」があるだけだ。例えば、数値は0であれば偽と評価され、それ以外の値は真と評価される。そのため、C言語では、if (10) {...}という記述は完全に正当だが、Juliaでは文法エラーとなる。

Juliaには真偽値を作るための手段が下記のようになっている。しばらくこまかい文法の話が続くが、我慢して欲しい。

  • 真偽値そのもの(true、false)
  • 比較演算子 (==、!=、 ===、 !==、<、>、<=、>=、≤、≥)
  • 論理演算子 (!、&&、||)
真偽値そのもの

true/falseはそれぞれ真と偽を意味する。

julia> true
true

julia> false
false

実は真偽値は数値との比較や演算が可能である。trueは1として、falseは0として評価される。

julia> true == 1
true

julia> true == 0
false

julia> true == 2
false

julia> false == 0
true

julia> false == 1
false

julia> true + 0
1

julia> true + 1
2

julia> true - false
1

julia> true + false
1

だからといって、trueを使うべき場面で1と使えると言うわけでもない。

julia> if true
         println("trueの処理")
       else
         println("falseの処理")
       end
trueの処理

julia> if 1
         println("trueの処理")
       else
         println("falseの処理")
       end
ERROR: TypeError: non-boolean (Int64) used in boolean context

trueを数値の1として扱えて嬉しい場面は、私にはあまり思い浮かばないが、とにかくそのように動くようにはなっている。

比較演算子

比較演算子は両辺の値を比較して、結果の真偽を決める。

==は等しいと言う意味である。=記号は代入として使われているので、==が等しいと言う意味で使われたのだろう。

julia> 1 == 1
true

julia> 1 == 1.0
true

julia> 1 + 2 == 3
true

julia> x = 10
julia> x == 10
true

julia> 1 == "a"
false

julia> "a" == "a"
true

!=は等しくないと言う意味である。

julia> 1 != 2
true

julia> 1 != 1
false

julia> 1 != "a"
true

===!==の説明は難しいのでいったん省く。パッと使う限りでは、==!=との違いがわからないだろう。いずれ話すべきタイミングで話そう。

<><=>=はおなじみの不等号だ。複数つなげることもできる。

そのうえ、<=の代わりに>=を使うことできる。JuliaのREPLで\leと押してtabキーを押すととなり、\geと押してtabキーを押すととなる。(twitterで@genkurokiさんに教えていただきました。ありがとうございます。)

julia> 1 < 2
true

julia> 1 <= 2
true

julia> 12
true

julia> 2 <= 2
true

julia> 22
true

julia> 1 > 2
false

julia> 1 >= 2
false

julia> 12
false

julia> 2 >= 2
true

julia> 22
true

julia> 1 < 2 < 3
true

julia> 1 < 2 > 3
false

julia> 2 < 3 > 1 
true

数値と文字列の大小比較のようなことはできない。

julia> 1"a"
ERROR: MethodError: no method matching isless(::String, ::Int64)

一方、文字列同士の大小比較は可能だ。辞書順の評価になる。

julia> "abc" < "def"
true

julia> "ab" < "abc"
true

文字列の大小比較は明示的に行うことはあまりないと思うが、文字列を集めてソートするときなどにはどちらが大きいかを決める必要があるので、意識しておいたほうがいいことがたまにある。

論理演算子

真偽値に対する演算である。

!は否定演算子と呼ばれるもので、真と偽をひっくり返す。

julia> !true
false

julia> !false
true

julia> !(4 != 5)
false

julia> !(1 == 1)
false

&&論理積演算子と呼ばれるもので、2つの真偽値の両方が真の時に真となり、片方でも偽のときには偽となるものである。

julia> true && true
true

julia> true && false
false

&&は短絡評価という面白い動きを行う。複数の真偽値を&&でつないでいく時、1つでも偽があると、全体が偽となる。

julia> true && false && true && true
false

そのため、先頭から確認して偽があると、そこで論理積全体の評価が確定するため、後の方まで見に行かないのである。これを短絡評価という。

例えば、1 < "a"というのは、不正な比較としてエラーになる。しかし、これが含まれる式であっても短絡評価のためにエラーとならないことがある。

julia> true && (1 < "a")
ERROR: MethodError: no method matching isless(::Int64, ::String)

julia> false && (1 < "a")
false

上の方の式では、&&の1つ目がtrueのため、論理積全体の値の確定のためには、後半の1 < "a"の評価が必要であり、評価しようとしてエラーとなった。一方、下の式では、&&の1つ目がfalseのため、論理積全体の値はその時点でfalseと確定するため、1 < "a"の評価は行われないのだ。

細かい話に思われるかもしれないが、意外と重要だ。このトリックはよく使われる。

ゲームっぽい例を出そう。仮に、連続攻撃にボーナスがつく仕様になったとしよう。1撃目のダメージの下2桁がゾロ目だった時に、残りの攻撃のダメージが2倍になるのだ。この処理はどうなるだろうか。連続攻撃のダメージが連続ダメージという配列で渡されたとする。こんな感じの関数になるだろう。

function 連続ダメージ合計(連続ダメージ)
    合計ダメージ = 0
    if 下二桁がゾロ目(連続ダメージ[1])
        (連続攻撃の2撃目以降の攻撃を2倍する処理)
    else
        (普通の連続攻撃の処理)
    end
    return 合計ダメージ
end

ところが、連続攻撃が全て回避された時に、このコードは破綻する。なぜなら、連続ダメージ配列には、空振りした攻撃のダメージが入らないからだ(ということにしよう)。そのため、連続攻撃が全て外れると、連続ダメージ配列は、要素数0の配列になり、連続ダメージ[1]はエラーとなる。

これを回避するためには、連続ダメージ配列の長さを調べてやればいい。length関数は、配列の長さ(要素数)を調べる関数だ。

function 連続ダメージ合計(連続ダメージ)
    合計ダメージ = 0
    if length(連続ダメージ) ≥ 1
        if 下二桁がゾロ目(連続ダメージ[1])
            (連続攻撃の2撃目以降の攻撃を2枚する処理)
        else
            (普通の連続攻撃の処理)
        end
    end 
    return 合計ダメージ
end

ただ、こうではなく、次のように書くこともできる。

function 連続ダメージ合計(連続ダメージ)
    合計ダメージ = 0
    if length(連続ダメージ) ≥ 1 && 下二桁がゾロ目(連続ダメージ[1])
        (連続攻撃の2撃目以降の攻撃を2倍する処理)
    else
        (普通の連続攻撃の処理)
    end
    return 合計ダメージ
end

長さが1以上の時には、1つめの要素の値を元に判定するという、これはこれで自然な書き方だ。長さが0の時には、前半の条件がfalseになるので、後半の処理のことは気にしなくていい。

||論理和演算子と呼ばれるもので、2つの真偽値の片方が真の時に真となり、両方偽の時に偽となるものである。

julia> true || false
true

julia> false || false
false

||にも短絡評価がある。trueとなった瞬間に評価が確定するので、それ以降の部分は評価されない。

julia> true || (1 < "a")
true

julia> false || (1 < "a")
ERROR: MethodError: no method matching isless(::Int64, ::String)

練習問題

解答例はページの最後に記載している。ただ、プログラムの書き方に唯一の正解はない。それよりは、掲示した自動テストが正解するかどうかが重要だ。関数を作ってみたら、テストコードを実行してみて欲しい。

  • 問題1
    • 引数が負の数かどうかを判定する負の数関数を作ろう。引数が負の数ならtrue、0以上の正の数ならfalseを返す。引数は1つだけ、数値と仮定していい。
using Test
@testset "if文問題1" begin
    @test 負の数(1) == false
    @test 負の数(0) == false
    @test 負の数(-1) == true
end
  • 問題2
    • 引数の絶対値を返す関数を作ってみよう。if文の条件式の中で、問題1で作った関数を呼び出すようにしよう。引数は1つだけ、数値と仮定していい。
using Test
@testset "if文問題2" begin
    @test 絶対値(1) == 1
    @test 絶対値(0) == 0
    @test 絶対値(-1) == 1
end
  • 問題3
    • 引数が3の倍数だと"Fizz"、5の倍数だと"Buzz"、15の倍数だと"FizzBuzz"、という文字列を返すfizzbuzz関数を作ろう。それ以外は空文字を返す。これは「Fizz-Buzz問題」と呼ばれる有名な問題だ。引数は1つだけ、正の整数と仮定していい。%という演算子を使おう。n%mでnをmで割ったあまりとなる。10%3 == 1だ。
using Test
@testset "if文問題3" begin
    @test fizzbuzz(1) == ""
    @test fizzbuzz(2) == ""
    @test fizzbuzz(3) == "Fizz"
    @test fizzbuzz(4) == ""
    @test fizzbuzz(5) == "Buzz"
    @test fizzbuzz(6) == "Fizz"
    @test fizzbuzz(7) == ""
    @test fizzbuzz(8) == ""
    @test fizzbuzz(9) == "Fizz"
    @test fizzbuzz(10) == "Buzz"
    @test fizzbuzz(15) == "FizzBuzz"
    @test fizzbuzz(20) == "Buzz"
    @test fizzbuzz(30) == "FizzBuzz"
end

if文まとめ

if文というか、条件式や真偽値の説明がかなり長くなってしまった。だが、if文もfor文と同じくあらゆる言語に登場する超重要関数だ。正直なところ、for文とif文と関数が使えたら、大体のプログラムは作れる。消化不良の部分があったら、しっかりとプログラムを書いて身につけよう。練習問題3のFizz-Buzz問題が自力で書けたら文句なしに合格だ。

自動テスト

いよいよダメージ計算関数の自動テストだ。

ダメージ計算の関数は、現状、攻撃力、防御力を引数にとり、ダメージを返している。いったん、現在の実装の詳細は忘れて、ダメージ計算関数に期待する振る舞いを考えよう。この振る舞いを自動テストとして残しておきたい。

まず、関数のインターフェースはこうなっている。実装の詳細は忘れよう。

function ダメージ計算(攻撃力, 防御力)
    ...
end

ひどまず、ダメージ計算関数に期待するところを書き並べたい。

  • 攻撃力10、防御力10の時に、ダメージは10になって欲しい。
  • 攻撃力15、防御力100の時に、ダメージは2になって欲しい。
  • 攻撃力14、防御力100の時に、ダメージは1になって欲しい。
  • 攻撃力0、防御力10の時に、ダメージは0になって欲しい。

ここまでは正常系といったところだ。攻撃力を防御力で割って10倍した値を四捨五入したい。四捨五入になっているかどうかを調べるためのケースが、攻撃力15と14のケースだ。

次は、異常系について考える。「例外」という言葉が出てきているのだが、まだ説明していない。いずれ説明するが、簡単にいうと、「不正な動きと判断して処理を中断する」という意味だ。

  • 攻撃力-1、防御力10の時に、例外を発生させて欲しい。
  • 攻撃力10、防御力-1の時に、例外を発生させて欲しい。
  • 攻撃力-1、防御力-1の時に、例外を発生させて欲しい。
  • 攻撃力10、防御力0の時に、例外を発生させて欲しい。

想定していない値が入ってきたときにどうなって欲しいかというところも考えているのがここだ。

攻撃力や防御力が負の数になったときには、不正な呼び出しだと判断するという意味だ。一応考え方としては、攻撃力が負の数のとき、ダメージも負の数となりHPが回復するという考え方もあってもいい。それはプログラム開発と言うよりも、ゲームのデザインに関する範疇なので、プログラマが不用意に決定してはいけないのだが、今回は我々がゲームデザイナとプログラマを兼任しているので、決めてしまおう。私はおかしな仕様だと思うので、エラーにするようにする。

防御力0については、迷うところだ。敵の防御力を下げる魔法みたいなものがあるとして、結果として防御力0という状態があり得るのかどうか?あり得るとするのであれば、その際に正しいダメージ量が計算されるようにしなければならないし、あり得ないのであれば例外とする必要がある。これも本来プログラマが勝手には決められないことだが、私はありえないとすることにして、エラーにするようにする。防御力を下げる魔法をいくらかけても防御力は1未満にはならないということだ。

ダメージ計算に対する期待結果も揃ったので、これをチェックする自動テストを作ろう。

まずは上4つの正常系のテストだ。

@testset "ダメージ計算" begin
    @test ダメージ計算(10, 10) == 10
    @test ダメージ計算(15, 100) == 2
    @test ダメージ計算(14, 100) == 1
    @test ダメージ計算(0, 100) == 0
end

これは問題なく通るはずだ。一安心だ。

@testset "ダメージ計算" begin
    @test ダメージ計算(10, 10) == 10
    @test ダメージ計算(15, 100) == 2
    @test ダメージ計算(14, 100) == 1
    @test ダメージ計算(0, 100) == 0
    @test_throws DomainError ダメージ計算(-1, 10)
    @test_throws DomainError ダメージ計算(10, -1)
    @test_throws DomainError ダメージ計算(-1, -1)
    @test_throws DomainError ダメージ計算(10, 0)
end

次に、4つテストケースを追加した。攻撃力や防御力があり合えない値の時に、不正な処理とするテストケースだ。これを実装するには「例外」について学ぶ必要があるが、これは割と上級のトピックなので、後々解説しよう。ひとまずこれらのテストケースはコメントアウトしておこう。

@testset "ダメージ計算" begin
    @test ダメージ計算(10, 10) == 10
    @test ダメージ計算(15, 100) == 2
    @test ダメージ計算(14, 100) == 1
    @test ダメージ計算(0, 100) == 0
    #=
    @test_throws DomainError ダメージ計算(-1, 10)
    @test_throws DomainError ダメージ計算(10, -1)
    @test_throws DomainError ダメージ計算(-1, -1)
    @test_throws DomainError ダメージ計算(10, 0)
    =#
end

コメント

さらっとコメントアウトという言葉を使ったが、そもそもコメントというものについて全く触れていなかったので少しだけ解説しよう。

プログラムの中に、プログラムに関する補足説明を書きたくなることがある。そのようなコメントはプログラムの実行からは全く無視されて欲しい。でなければエラーになる。

プログラム中にコメントを書くには、#記号を使う。#記号はその部分から行の最後の部分までをコメントとして扱う。さらに、複数行に渡る長いコメントを書きたい時には、#==#で囲む。

println(1 + 2) #行コメント

#=
複数行にわたる
コメント
=#

コメントアウト」という言葉は、プログラム行をコメント記号の中に入れることで、一時的に実行の対象外とすることである。実験的に消したいときなどによく使われる。

本来、プログラムの補足説明を書くという機能と、プログラムを一時的に無効化するという機能は、全く別の意図の機能である。そのため、それぞれの文法が開発されてもよかった。杓子定規にいけば、そちらの方があるべきなような気もする。しかし、実際にはあらゆる言語が、コメントという機能だけを提供している。「これでいいじゃん」という感じである。私はこの合理性が好きだ。

モンスターにもチャンスを

前回のプログラムは、勇者がモンスターを一方的に攻撃するという不公平極まりないものだった。モンスターにもチャンスをあげよう。

前回の最後の形がこうだった。

using Test

function ダメージ計算(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

function main()
    モンスターHP = 30
    モンスター防御力 = 10
    プレイヤー攻撃力 = 10

    println("モンスターに遭遇した!")
    println("戦闘開始!")
    
    for _ in 1:3
        println("----------")
        println("勇者の攻撃!")
        モンスターダメージ = ダメージ計算(プレイヤー攻撃力, モンスター防御力)
        モンスターHP = モンスターHP - モンスターダメージ
        println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
        println("モンスターの残りHP:" * string(モンスターHP))
    end

    println("戦闘に勝利した!")
end

main()

ここから、モンスターも攻撃できるように変更しよう。

function main()
    モンスターHP = 30
    モンスター攻撃力 = 10 #追加行
    モンスター防御力 = 10
    プレイヤーHP = 30 #追加行
    プレイヤー攻撃力 = 10
    プレイヤー防御力 = 10 #追加行

    println("モンスターに遭遇した!")
    println("戦闘開始!")
    
    for _ in 1:3
        println("----------")
        println("勇者の攻撃!")
        モンスターダメージ = ダメージ計算(プレイヤー攻撃力, モンスター防御力)
        モンスターHP = モンスターHP - モンスターダメージ
        println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
        println("モンスターの残りHP:" * string(モンスターHP))

        #追加部分
        println("----------")
        println("モンスターの攻撃!")
        プレイヤーダメージ = ダメージ計算(モンスター攻撃力, プレイヤー防御力)
        プレイヤーHP = プレイヤーHP - プレイヤーダメージ
        println("勇者は" * string(プレイヤーダメージ) * "のダメージを受けた!")
        println("勇者の残りHP:" * string(プレイヤーHP))
    end

    println("戦闘に勝利した!")
end

とりあえず、モンスターが攻撃できるようにしただけである。実行してみるとわかるが、勇者が先にモンスターのHPを0にするのだが、モンスターは倒れず(あるいは倒れながら)勇者に一撃を喰らわし、両者ともにHP0で終了する。河原で夕日を背景に殴り合う高校生のような死闘を繰り広げるのだ。青春だ。最後に、戦闘に勝利したという的外れなメッセージとともにゲームは終了する。

さて、これからいくつか改良を加えたい。

  1. HPが0になっても攻撃できるのはおかしいので、一方のHPが0になったら戦闘が終了するように変更する。

  2. 常に勇者が先制攻撃をするのは不公平なので、勇者とモンスターの攻撃順はランダムになるようにする。

これで、毎回同じ流れの固定された戦闘ではなくなり、どちらが勝つかわからない手に汗握る展開になるのだ。ただ、まだ見ているだけだ。勇者に指示を出すことはできない。それは次回のお楽しみだ。

まずは、1つ目から実装しよう。早速if文の出番だ。攻撃が終了したら、ダメージを受けた側の残HPをチェックして、0だったらプログラムを終了するようにしよう。

次のようにした。勇者の攻撃後、モンスターの攻撃後、それぞれで攻撃が受けた側のHPをチェックし、0だったらbreakしている。breakというのは、ループからの脱出である。forの中でbreakに到達すると、forの終わりのendまで一気にジャンプする。

for _ in 1:3
    println("----------")
    println("勇者の攻撃!")
    (略)
    if モンスターHP == 0
        break
    end

    println("----------")
    println("モンスターの攻撃!")
    (略)
    if プレイヤーHP == 0
        break
    end
end

これを実行すると、次のような結果になる。モンスターがHP=0になってもなお攻撃してくることはなく、戦闘が終了している。

モンスターに遭遇した!
戦闘開始!
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:20
----------
モンスターの攻撃!
勇者は10のダメージを受けた!
勇者の残りHP:20
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:10
----------
モンスターの攻撃!
勇者は10のダメージを受けた!
勇者の残りHP:10
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:0
戦闘に勝利した!

何度実行しても同じだ。勇者は必ずモンスターよりも先に攻撃するので、モンスターは必ず負けることになる。

次は2を実装しよう。常に勇者が先制攻撃をするのは不公平なので、勇者とモンスターの攻撃順はランダムになるようにするのだ。これにもif文と、乱数というものを使う。

乱数とは、ランダムな数のことである。Juliaはランダムな数を生成できる関数を標準で使うことができる。rand()という関数は、0から1の間の小数をランダムに生成する。

julia> rand()
0.03587016158053946

julia> rand()
0.6696669182272514

julia> rand()
0.3851182443503387

rand() < 0.5となる確率は50%、rand() < 0.3となる確率は30%である。乱数を使うことで、プログラムの動きは予想のつかないものになる。

勇者とモンスターは対等に扱いたいので、50%で勇者が先制攻撃し、50%でモンスターが先制するようにしよう。

とりあえず、rand() < 0.5のとき、勇者が先攻、そうでない時にモンスターが先攻になるようにしよう。

もともとのプログラムは勇者が先攻のケースなので、if rand() < 0.5で、くるむ。

for _ in 1:3
    if rand() < 0.5
        #勇者が先攻
        println("----------")
        println("勇者の攻撃!")
        (略)
        if モンスターHP == 0
            break
        end


        #モンスターが後攻
        println("----------")
        println("モンスターの攻撃!")
        (略)
        if プレイヤーHP == 0
            break
        end
    end
end

そして、else がモンスター先攻のケースとなる。とりあえず、カジュアルにifの中身をコピーして上下をひっくり返そう。

for _ in 1:3
    if rand() < 0.5
        #勇者が先攻
        println("----------")
        println("勇者の攻撃!")
        (略)
        if モンスターHP == 0
            break
        end

        #モンスターが後攻
        println("----------")
        println("モンスターの攻撃!")
        (略)
        if プレイヤーHP == 0
            break
        end
    else
        #モンスターが先攻
        println("----------")
        println("モンスターの攻撃!")
        (略)
        if プレイヤーHP == 0
            break
        end

        #勇者が後攻
        println("----------")
        println("勇者の攻撃!")
        (略)
        if モンスターHP == 0
            break
        end        
    end
end

通常、ソースコード のコピぺは悪だと言われる。それはその通りだ。だが、私は最初の第一歩としてはコピペは全く問題ないと考えている。終わった時に綺麗にできていれば良い。

ついでに、戦闘が終了した時のメッセージも、次のように変えておこう。

if モンスターHP == 0
    println("戦闘に勝利した!")        
else
    println("戦闘に敗北した・・・")
end

ここまでで、次のような形になっているはずだ。

using Test

function ダメージ計算(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

function main()
    モンスターHP = 30
    モンスター攻撃力 = 10
    モンスター防御力 = 10
    プレイヤーHP = 30
    プレイヤー攻撃力 = 10
    プレイヤー防御力 = 10

    println("モンスターに遭遇した!")
    println("戦闘開始!")
    
    for _ in 1:3
        if rand() < 0.5
            #勇者が先攻
            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
        else
            #モンスターが先攻
            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
        end
    end

    if モンスターHP == 0
        println("戦闘に勝利した!")        
    else
        println("戦闘に敗北した・・・")
    end
end

main()

いろいろ粗のあるコードだが、とりあえずやりたいことはできているはずだ。これを動かしてみよう。確率次第だが、何回か実行していれば、勇者が勝ったり負けたりするところを見ることができるだろう。

モンスターに遭遇した!
戦闘開始!
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:20
----------
モンスターの攻撃!
勇者は10のダメージを受けた!
勇者の残りHP:20
----------
モンスターの攻撃!
勇者は10のダメージを受けた!
勇者の残りHP:10
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:10
----------
モンスターの攻撃!
勇者は10のダメージを受けた!
勇者の残りHP:0
戦闘に敗北した・・・

まずは動くものが作れた。これが大事だ。動くものを作って、それから改良しよう。

リファクタリング

プログラムの動作を変えずに、内部の構造を改良することをリファクタリングという。今からやるのはリファクタリングだ。コードには何の機能追加もしない。ただ、読みやすく改良するだけだ。

リファクタリングで大事なことは、動作を変えないことだ。動作を変えないことを保証するために、自動テストを作ろう。

自動テストを作るにあたって、ちょっと問題がある。プログラム中の乱数だ。乱数は毎度値を変えるので、自動テストとは相性が悪い。いろいろやり方はあるが、ひとまず今は安直に乱数を排除しよう。そのためにちょっとだけ手を加える。main関数の引数に、配列を渡せるようにして、それを内部で生成する乱数の代わりに使う。リファクタリングが終わったら元に戻す。

次のような感じだ。引数に偽乱数列をとり、プログラム中で乱数の代わりに使っている。また、for文のループの何回目かで、使う配列の要素も変わるのでfor i in 1:3に変えている。

function main(偽乱数列)
    (略)
    for i in 1:3
        if 偽乱数列[i] < 0.5
        #if rand() < 0.5

呼び出し側はこのようになる。main処理の分岐のパターンを網羅するように偽の乱数列を作って呼び出している。さらに、main中にこれから書かれるテストケースをジャッジするために、@testsetで括る。

@testset "main処理リファクタリング" begin
    main([0.1, 0.1, 0.1])
    main([0.1, 0.1, 0.9])
    main([0.1, 0.9, 0.1])
    main([0.1, 0.9, 0.9])
    main([0.9, 0.1, 0.1])
    main([0.9, 0.1, 0.9])
    main([0.9, 0.9, 0.1])
    main([0.9, 0.9, 0.9])
end

これを呼び出すと、偽乱数のパターンを変えながら8回main処理が呼ばれ、それぞれのパターンで実行される。

これで自動テストの準備は完了だ。あとはリファクタリングする箇所に合わせて、テストケースを作成して、テストが通ることを確かめながらリファクタリングを進めていく。

while文

まず最初に手をつけたいのは、for文だ。for文は3回しか実行していない。本当は3ターンの攻撃で終了することが決まっているのはおかしい。それがなんとか成り立っているのは、勇者もモンスターもHPを30に設定しており、攻撃は必ず10ダメージ固定だからだ。本来は、戦闘はターンは事前に決まらない。どちらかが倒れたら終わりで、それまでは永久に続くのが望ましい。実際、どちらかが倒れたらループを抜けるように、breakまで作っているのだ。ここらで、3回限りのforとは手を切ることにしよう。

ここでfor文のループの回数を100億回にしてもいいのだが、別の手段がある。それがwhile文だ。for文はループの回数が決まっている時に使うが、while文はループの終了条件が決まっている時に使う。

while文はこんな形をしている。if文よりも簡単だ。

while(終了条件)
  (処理)
end

例えば、次のようにREPLに打ち込んでみよう。

julia> s = 0
0

julia> while s < 100
         s = s + 30
         println(s)
       end

結果はこうなる。

30
60
90
120

while 内部の処理を、条件式が満たされる間は続けている。次の処理に入る時に、条件式をチェックして、満たされるなら内部の処理を行っている。

これを使ってみよう。まず、書き換え前はこのような処理になっている。

for i in 1:3
    if 偽乱数[i] < 0.5
        (略)
        if モンスターHP == 0
            break
        end

        (略)
        if プレイヤーHP == 0
            break
        end
    else
        (略)
    end
end

まず、書き換え前後で動作が変わっていないことを確かめる準備として、次のようなテストケースを追加しよう。ループの条件を変えても、3回攻撃したらゲームが終了することを期待している。

for i in 1:3
    if 偽乱数[i] < 0.5
        (略)
        if モンスターHP == 0
            @test i == 3
            break
        end

        (略)
        if プレイヤーHP == 0
            @test i == 3
            break
        end
    else
        (略)
    end
end

else句の中にも同様の@testが入っていると思って欲しい。これを実行すると、テストがOKになるはずだ。

次にwhile文に書き換える。今回はwhile文の条件式はtrueだ。条件式が常に真なので、中身は無限に実行される。だが、途中で戦闘終了条件を満たすと、breakしてwhileから脱出する。

while true
    if 偽乱数[i] < 0.5
        (略)
        if モンスターHP == 0
            @test i == 3
            break
        end

        (略)
        if プレイヤーHP == 0
            @test i == 3
            break
        end
    else
        (略)
    end
end

これを実行するとエラーになる。

main処理リファクタリング: Error During Test at /Users/kenji/Documents/SourceCode/rpg/rpg2.jl:199
  Got exception outside of a @test
  UndefVarError: i not defined

iというものが定義されていないと言われた。for i in 1:3iを消してしまったからだ。これを補うために、iを作ってあげよう。

    i = 0
    while true
        i = i + 1
        if 偽乱数列[i] < 0.5

これでテストが通るはずだ。テストが通るので、自信を持ってwhileに置き換えられる。

これで次のような形になっている。長くなるがほとんど答え合わせのためだけに全文載せているだけなので、飛ばしてもらって構わない。

using Test

function ダメージ計算(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

function main(偽乱数列)
    モンスターHP = 30
    モンスター攻撃力 = 10
    モンスター防御力 = 10
    プレイヤーHP = 30
    プレイヤー攻撃力 = 10
    プレイヤー防御力 = 10

    println("モンスターに遭遇した!")
    println("戦闘開始!")
    
    i = 0
    while true
        i = i + 1
        if 偽乱数列[i] < 0.5
        #if rand() < 0.5
            #勇者が先攻
            println("----------")
            println("勇者の攻撃!")
            モンスターダメージ = ダメージ計算(プレイヤー攻撃力, モンスター防御力)
            モンスターHP = モンスターHP - モンスターダメージ
            println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
            println("モンスターの残りHP:" * string(モンスターHP))
            if モンスターHP == 0
                @test i == 3
                break
            end

            #モンスターが後攻
            println("----------")
            println("モンスターの攻撃!")
            プレイヤーダメージ = ダメージ計算(モンスター攻撃力, プレイヤー防御力)
            プレイヤーHP = プレイヤーHP - プレイヤーダメージ
            println("勇者は" * string(プレイヤーダメージ) * "のダメージを受けた!")
            println("勇者の残りHP:" * string(プレイヤーHP))
            if プレイヤーHP == 0
                @test i == 3
                break
            end
        else
            #モンスターが先攻
            println("----------")
            println("モンスターの攻撃!")
            プレイヤーダメージ = ダメージ計算(モンスター攻撃力, プレイヤー防御力)
            プレイヤーHP = プレイヤーHP - プレイヤーダメージ
            println("勇者は" * string(プレイヤーダメージ) * "のダメージを受けた!")
            println("勇者の残りHP:" * string(プレイヤーHP))
            if プレイヤーHP == 0
                @test i == 3
                break
            end

            #勇者が後攻
            println("----------")
            println("勇者の攻撃!")
            モンスターダメージ = ダメージ計算(プレイヤー攻撃力, モンスター防御力)
            モンスターHP = モンスターHP - モンスターダメージ
            println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
            println("モンスターの残りHP:" * string(モンスターHP))
            if モンスターHP == 0
                @test i == 3
                break
            end
        end
    end

    if モンスターHP == 0
        println("戦闘に勝利した!")        
    else
        println("戦闘に敗北した・・・")
    end
end

@testset "main処理リファクタリング" begin
    main([0.1, 0.1, 0.1])
    main([0.1, 0.1, 0.9])
    main([0.1, 0.9, 0.1])
    main([0.1, 0.9, 0.9])
    main([0.9, 0.1, 0.1])
    main([0.9, 0.1, 0.9])
    main([0.9, 0.9, 0.1])
    main([0.9, 0.9, 0.9])
end

次に気になるのは、このような部分だ。

モンスターHP = 30
モンスター攻撃力 = 10
モンスター防御力 = 10
プレイヤーHP = 30
プレイヤー攻撃力 = 10
プレイヤー防御力 = 10

本来はモンスター/プレイヤーの2パターンと、それぞれが持つHP/攻撃力/防御力の3パターンだ。その2 * 3 = 6パターンが、並列に記述されている。もっとうまいやり方はないだろうか?

これを実現するには構造体というものを使うのだが、・・・長くなってきたし、キリもいいので次にしよう。

その2の終わりに

さて、ここまでで、for文、関数、if文、while文の説明をしてきた。これらは、プログラミングの基本構文にあたる。ここまでの知識は、ほとんどのプログラミング言語で応用できる。なんと、ここまでの知識の内容があれば、あらゆるプログラムを作ることができるという定理まであるのだ。(実際には、for文はwhile文で書き換えられるので、for文すら「原理的には」不要と言える)

構造化定理 - Wikipedia

次回からは、だんだんと応用的な内容になっていく。ここまでの知識でも、十分高度なプログラムを作ることはできる。ここから先の内容は、ある意味、不要なものかもしれない。しかし、逆に考えれば、不要であるにもかかわらず、あえて作られたことには意味があるはずだ。

今のプログラムはまだ大したことは何もしていない。にもかかわらず、既に少しごちゃごちゃし、見づらくなってきている。プログラミングは、自分が作り上げた複雑さをいかに制御するかが大きな問題となる。複雑さに飲み込まれると、機能を追加する時にどこを変更すればいいかわからず、そして変更のたびに不具合を生んでしまう、モンスタープログラムが出来上がってしまう。それはとても悲しいことだ。

Juliaには(そして他の言語にも)、そのような複雑さを制御するための素敵な機能が提供されている。少しずつ学んでいき、より良いプログラミングライフを送っていこう。

練習問題の解答例

最後に、練習問題の解答例を載せておこう。あくまで一例だ。

if文

  • 問題1

    • 引数が負の数かどうかを判定する関数を作ろう。引数が負の数ならtrue、0以上の正の数ならfalseを返す。引数は1つだけ、数値と仮定していい。
  • 回答

function 負の数(x)
    if x < 0
        return true
    else
        return false
    end
end
  • 問題2
    • 引数の絶対値を返す関数を作ってみよう。if文の条件式の中で、問題1で作った関数を呼び出すようにしよう。引数は1つだけ、数値と仮定していい。
function 絶対値(x)
    if 負の数(x)
        return -x
    else
        return x
    end
end
  • 問題3
    • 引数が3の倍数だと"Fizz"、5の倍数だと"Buzz"、15の倍数だと"FizzBuzz"、という文字列を返すfizzbuzz関数を作ろう。それ以外は空文字を返す。これは「FizzBuzz問題」と呼ばれる有名な問題だ。引数は1つだけ、正の整数と仮定していい。%という演算子を使おう。n%mでnをmで割ったあまりとなる。10%3 == 1だ。
function fizzbuzz(x)
    if x%15 == 0
      return "FizzBuzz"
    elseif x%3 == 0
      return "Fizz"
    elseif x%5 == 0
      return "Buzz"
    else
      return ""
    end
end

続きの記事

Julia言語で入門するプログラミング(その3) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その4) - SE教育パパむううみんのブログ

Julia言語で入門するプログラミング(その5) - SE教育パパむううみんのブログ