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

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


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

一覧はこちら

「かばう」の検討

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

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

一定期間というのは、次に太郎のターンが回ってくるまでの間にしよう。その間に指定した相手(例えば花子)が受ける攻撃は全て太郎が引き受けるという男らしい技だ。消費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を使ったらそれ全部?」という疑問に対する答えだと思っておいて欲しい。

それでは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の型システムを柔軟に使いこなすための強力な武器で、よく使われる。一度理解しておくと恐るにたらないので、しっかりと理解しておこう。

仕上げ

それでは仕上げを行おう。今回はパラメトリック型などいろいろな案を提示したが、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

...

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

第7回の終わりに

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

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

コード

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

続き

第8回