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

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


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

一覧はこちら

スキルの実装

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

大振りの実装

大振りとはこんな技だ。

  • 大振り
    • 命中率は低いが通常の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

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

また、この時点でテストが壊れるため修正をしている。

#game_test.jl
...
#プレイヤーからモンスターへ攻撃 = Game.T行動("1", p, m)
プレイヤーからモンスターへ攻撃 = Game.T行動(Game.T通常攻撃(), p, m)
...
#プレイヤーからモンスターへ攻撃 = Game.T行動("2", p, m)
プレイヤーからモンスターへ攻撃 = Game.T行動(Game.Tスキル("大振り", 2, 0.4), p, m)
...

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

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.名前 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

getpropertysetproperty にも手を入れる必要がある。

function Base.getproperty(obj::Tキャラクター, sym::Symbol)
    if sym in [:名前, :HP, :MP, :攻撃力, :防御力, :スキルs] #MPの追加
        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] #MPの追加
        return Base.setproperty!(obj._キャラクター共通データ, sym, val)
    end
    return Base.setfield!(obj, sym, val)
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

ザーッと流したが、特に難しい点はなかったと思う。いくつかのテストにはエラーが出るが、仕様変更を理解していれば簡単に修正できる内容のはずだ。テストだけでなく、game_exec.jlを起動し、実際に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段階にわたる複雑な変換を施された結果生まれたものだとしたら?これはユーザーの入力が悪いのか?変換に不具合があるのか?私はどうすればいいのか?[1] … Continue reading

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

例外処理

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

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

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だ」と言った。[2]「間違ったコードは間違って見えるようにする」 https://www.joelonsoftware.com/2005/05/11/making-wrong-code-look-wrong/

この説明だけを聞いてもピンとこないかもしれない。例を出そう。あなたはあるアルバイトに応募し、指定された日時にどこかのオフィスへ到着した。オフィスはがらんとしており、何冊もの分厚いマニュアルが置いてあるだけだった。あなたの仕事はこのマニュアルの指示通りに仕事をこなすことであり、あなたの輝かしい知性を発揮する機会は残念ながら存在しない。とにかくマニュアルに従うことが求められているのだ。つまらない仕事だが仕方がない。時給が破格に良かったのだ。金に目の眩んだあなたの責任だ。

さて、マニュアルを開くと、作業指示が事細かに書いてある。その所々に注釈があり、ここがうまくいかない時はこうすること、という指示が記載されている。

返り値によるエラーチェックのイメージは、この時にうまくいかない時の対処が行間に都度書かれているような状況だ。具体的にこれこれしろ、と書いてあることもあるし、ここの処理まで戻れ、とだけ書いてあることもある。戻った先では、さらにそこに書かれている指示に従って動く。

例外によるエラーチェックのイメージは、うまくいかない時には、その節の最後にまとめて記載してあるからそこを参照するように、と書かれているような状況だ。そして、その節の最後に対処法が書かれていないことがある。その場合は、その章の最後に対処法が書かれていないかを確認する必要があり、そこにも記載がなければその巻の最後に対処法が書かれていないかを確認する必要があり、そこにも記載がなければ最終巻の最後に対処法が書かれていないかを確認する必要がある。

個人の好みもあるだろうが、どちらが対応しやすいか、となると、実は返り値の方がいいのではないかという気がしてくる。

結局どうすればいいの?

返り値によるエラーハンドリングと、例外機構によるエラーハンドリングのどちらにも利点と欠点があることがわかった。では、結局我々はどのようにエラーハンドリングを行えばいいだろうか?適当に使い分けろと言われても困る。「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回の終わりに

今回は、型のについて、委譲や階層構造というトピックを学んだ。また、例外処理というトピックも学んだ。これらは他の多くの言語でも頻出の機能であり、細かい文法は違えど大きな考え方に違いがあるわけではない部分だ。

正直なところ、これらの機構はかなり複雑な機構である。この記事を一読して理解するのは難しいだろう。また、特に例外をどう扱うか、というようなトピックは未だに議論の尽きないトピックである。私の意見と違う意見を見聞きしたり、あなた自身が反対の立場を表明することもあるだろう。いろいろな意見や観点を取り入れながら、自らのアプリケーションの特性を考慮しながら適切なアーキテクチャを構築することを意識して欲しい。

続き

第7回

コードの整理

最後に、ようやく例外について話すことができたので、これまで遠慮がちに使うだけだった例外処理を適切な場所に入れていこう。また、ファイルが大きくなってきたので、構造体を中心にファイル分割する。あまり面白い作業ではないので、結果だけを提示しよう。きちんと説明を入れていくほど合理的にファイルを分けているわけではない、という事情もある。なお、全部同じフォルダに存在するようにしている。

GitHub

https://github.com/muuumin-soft/julia-intro-prog/tree/main/rpg06

本体側のコード

#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

References

References
1 コンパイラについて解説した文章を読んでいると、しばしば「ユーザーに適切なエラーメッセージを伝えることは、構文解析そのものと同等かそれ以上に難しい」というような説明に出会うことがあるのは、おそらくこういった部分の問題なのだろう。ソースコードから字句解析、構文解析を経て検出したエラーを元のソースコードに対応させるのは、想像するだけでもややこしそうだ。
2 「間違ったコードは間違って見えるようにする」 https://www.joelonsoftware.com/2005/05/11/making-wrong-code-look-wrong/