ウォーターソートパズルを題材に
もう6年以上前の話ですがKotlin
が流行り始めた当時、将棋関連アプリのAI部分の高速化を考えていた私はCoffeeScript(Javascript)
のソースコードをKotlin
に書き換えてみたことがあります。でも文法だけKotlin
に書き換えてもわずか3手読み
ですぐにスタックオーバーフローが発生して、末尾再帰の形になるようにソースコードを書き換えなければならないことが分かって挫折しました。
でも、前回の記事で作ったプログラムなら、将棋関連アプリに較べればかなり単純な構造の再帰関数なので、これなら出来るんじゃないか?と興味が湧いて、これを題材にして末尾再帰に書き換える作業を体験することにしました。
※Kotlin
製の5五将棋プログラムは今でもソースが残ってますが、Null Safety
なんて全く気にしていないCoffeeScript
のソースコードをそのままKotlin
に移行して、とにかくコンパイルを通すためにnullable
な変数を無理やりnon-null
に強制的に変換する演算子!!
(ビックリマーク2つ)だらけの、Kotlin使い
に言わせれば汚いコードでした。
Grokに聞いてみる
プログラミングの際に本やGoogle検索を使うよりもリファレンスとしてGrok
は有用だなと思っていたので、まずはKotlin
に慣れていない私はRuby
のソースコードを貼り付けて「このコードをKotlinに書き換えて!」と丸投げしてみました。コンパイルエラーが出ても実行時エラーが出ても「エラー出てるよ!」と返して、修正されたコードをまたコピペして実行するだけでこちらはあくまでも受け身の姿勢です。
そしてエラーは無くなったけど期待した結果が返ってこない状態になって、Ruby
で配列A == 配列B
の比較をしている部分は「Kotlinでは無理だろうな」と当たりをつけてequalsメソッド
を改変したら(ここもGrok
に丸投げ)すぐにBFS版
は完成しました。Grok
が吐き出すコードを碌に確認もせずデバッグ作業も全くしてません。そして肝心のNodeクラス内の再帰関数メソッドに較べて再帰がやや複雑になっているDFS版
も「dfs関数も末尾再帰に書き換えて!」とGrok
に丸投げしただけで完成しました
「どうせどこかでトラブってデバッグ作業させられるんだろうなぁ」と期待せずにダラダラと始めた作業なのですが、正味の作業時間は1時間もかかっていません。Grok
とのやり取りも数回だけです、公開するのは少し恥ずかしいですが、Grokとのやり取りの一部始終を公開しますので興味ある方は確認してみてください。
生成AIの実力を認めざるを得ない
この記事は「自作の再帰関数を末尾再帰に書き換えるためにはこんな作業が必要でした」という内容になる予定だったのですが、Grok
に丸投げするだけで済んでしまったので書くことが無くなりました。もしそういう内容を期待してた方はGrokの履歴を見てもらえば各再帰関数の変更前と変更後のソースも見れますし、解説もされているのでそちらを見ていただきたいと思います。dfs関数
の末尾再帰化された後のコードは自分自身もよく理解できていません。
生成AI
はプログラミング時のリファレンスには使えるぐらいの認識だったのですが、完全に自分が教えて貰ってる状態です。未だにChatGPT
もほとんど使ってないしCopilot
もよく知りません(何を使っても中身は同じ?)が、積極的に使ったほうが開発効率は上がりそうですね。
とは言っても今回のケースはRuby
の正解がある状態、アルゴリズムに問題が無いことが分かっている状態から始めたので、一からKotlin
でプログラミングしていたら例えGrok
に手伝ってもらってもこんなに簡単には完成しなかったとは思います。しかしRuby
のようなLightweight Languages
でサクッとプロトタイプを作ってから生成AI
を使ってC++
のようなコンパイラ言語に置き換えるのも一つの使い方としてアリかもしれません。将棋関連アプリのC++(STL使用)
への変換も試してみようと思ってます。
ちょっと怪しい…
vscode
上で実行していた時は気づかなかったのですが、コマンドラインでコンパイルするとwarning
が出てました
$ kotlinc -include-runtime -d ./game.jar waterBottles
waterBottles/game.kt:72:21: warning: recursive call is not a tail call.
val subResult = dfs(node, temp, depth - 1, 0, 0, false, newResult)
^^^
waterBottles/node.kt:52:22: warning: recursive call is not a tail call.
} else if (c.add(nd, target, 0)) {
^^^
waterBottles/node.kt:77:22: warning: recursive call is not a tail call.
} else if (c.replace(nd, target, 0)) {
^^^^^^^
waterBottles/node.kt:94:22: warning: recursive call is not a tail call.
} else if (c.search(target, 0)) {
^^^^^^
コンパイラは「末尾再帰になってませんよ」と言ってます
どうやら末尾再帰化が中途半端になっているようですが、dfs関数
に関してはGrok
による末尾再帰化を施さないと期待通り動かなかったので、間違った変更を加えたわけではないようですし、内部の処理がRuby
とどのような違いがあるのかは分かりません。それより驚いたのは、完成したKotlin
のプログラムでRuby
のプログラムと同じ問題を解かせてみるとDFS版
もBFS版
もKotlin版
の方がRuby版
よりもかなり遅かったことです。Ruby
に合わせて敢えて不要な部分で可変長の配列を使ってるところとか、確かに実行速度を上げることを目指したコードではないですが、まさか同じデータ構造とアルゴリズムでインタプリタ言語のRuby
より大幅に実行速度が遅くなるとは思いませんでした。
Kotlin(Java)
って再帰関数が苦手なの?
前回の記事の冒頭の問題を解かせて見たところDFS版
で0.151秒
、BFS版
で5530.022秒(約1時間半!!)
でした、Ruby
では前回の記事でも書いた通り0.016248494秒
と279.599430481秒(約4分半)
です。再帰関数ではないはずのBFS版
の遅さが目立ちますが、BFS版
でも内部では再帰関数であるNodeクラスのaddメソッド
を頻繁に呼び出すので、念の為Node.addメソッドを末尾再帰ではない元々のコードに戻して試してみましたが実行時間に違いはありませんし、スタックオーバーフローも発生しません。末尾再帰化はスタックオーバーフローを避けるためのものなので、実行速度は変わらない(あるいは若干遅くなる)はずで、まぁこうなるところでしょう。それよりなによりRuby
より遅いのならそもそもKotlin
に書き換える意味がありません。Java
は昔に較べて速くなってると聞いていたし、原因はいろいろあると思いますが、もしかしてKotlin(Java)
は末尾再帰化されているかどうかに関わらす再帰関数を使うと遅くなるのでしょうか?とにかくモチベーションが一気に下がりました。
結局、末尾再帰化は中途半端になりましたが、速度アップには繋がらないし、スタックオーバーフローが発生しているわけでもないのでここで止めておきます。昔、GOTO文も駆使しながらスタックの消費を抑えているC言語
のソースを見たことがありますが、そこまでする必要はないでしょう、というかそこまでやる人に敬意を表しますが私には出来そうにないです。
もうこのKotlin版の高速化に時間を掛けようとは思いませんが、その昔将棋関連アプリをKotlin
に書き換えることを早々に諦めたことは私にとって良かったのかもしれません。生成AI
が存在しなかった当時、スタックオーバーフローを避けるために苦労して末尾再帰に書き換えても実行速度が遅くなったのではさぞ虚しかったことでしょう。
やはり高速化ならC++
将棋関連アプリの高速化のために将棋AI部分をC++
で書き直したとしても、GUIを何で作るかという問題が残りますのでこれについては今後も模索中です。Kotlinで高速なものが作れれば、AndroidアプリのGUIもKotlinで作れるのでよかったのですが、残念です。
現在販売中の3三将棋アプリは評価値でソートして候補手を絞る関数
と局面を先読みする関数
の2つの関数を再帰呼出ししている面白い処理(「反復深化?を工夫してより深く読む」参照)をしているので、GUI付きのアプリとして販売するかどうかに関わらず一度C++
で実装してみたいとは思っています。