2017/07/11
今更だけど、Dockerで開発環境を作ってみた
私は、コンピューターのセットアップが大好きです。だって、プログレス・バーがグングン伸びていって、ログがジャカジャカ表示されて、何もしないで待っているだけなのに「俺は仕事しているぜぇ」って感じることができますからね。
だから、世の中にはびこる仮想化技術には目もくれず、プロジェクトのたびにOSから環境を再セットアップしてきました。でもね、最近はね、Android開発に必要なAndroid Studioさんが「お前の64bitのUbuntuに32bitライブラリをセットアップしろ」と言ってきたり、画像解析に使おうと考えたOpenCVさんが「☓☓ライブラリがあればもっと速くなるからセットアップしようぜ」と言ってきたりして、必要最小限な完璧環境を作るというOSセットアップ職人である私に歯向かってきやがるんですよ。
というわけで、流行りのDockerで開発環境を作って、私のかわいいOSを汚さないようにしてみました。
Dockerとは?
Dockerとは仮想的なハード・ディスクの中でプログラムを動かす技術である、と考えれば、だいたい合ってます。プログラムや共有ライブラリ(Linuxなら*.so
、Windowsなら*.DLL
)はハード・ディスクから読み込まれるわけで、ハード・ディスクをまるごと偽装しちゃえばほぼ別のコンピューターになるというわけ。これなら、仮想ハードウェアの上でOSの起動を待たなければならない一般的な仮想化より、速くて軽そうでしょ? 実際にDockerは、普通にプログラムを起動するのとほとんど変わらない速度で動作します。
もっともこのハード・ディスクを偽装するってのは別に難しくも新しくもない話で、UNIXに大昔から存在しているchroot
というコマンドでも実現できます。chroot
は別に特別なコマンドではなくて、たとえばArch Linuxのインストールで、インストール先のファイル・システムをchroot
してインストール後の環境を偽装する際に使われたりしています。
じゃあDockerを使わずにchroot
を使えばよいのかというとそんなことはなくて、Dockerは、ディスクに加えてネットワークやコンピューター名、ユーザーやグループ等も偽装してくれて、chroot
より高機能で便利です。あと、仮想的なハードディスクを作るための言語があったり、仮想的なハードディスクの階層化(あなたが作った仮想的なハードディスクに私がファイルを追加したときに、「あなたのハードディスク」と「私が追加したファイルだけが入った差分」という形で管理できる)ができたりします。
Dockerでのイメージ(仮想ハードディスク)の作り方
Dockerを起動して、手動でファイルを作成したりソフトウェアをセットアップしたりして、その結果をイメージ(Dockerでは仮想ハードディスクをイメージと呼びます)として保存することも可能です。しかし、この方式だと、ソフトウェアのバージョンアップの時とかに同じ作業を何回も実施しなければなりません。その時に作業を間違えないようにするには、手順書を作らなければならなくなってあまりに面倒くさい。
だから、Dockerではイメージを作成するためのスクリプトを提供していて、このスクリプトをDockerfileというファイル(makeコマンドのMakefileからヒントを得た名前だと思う)に書いて、自動でイメージを生成できるようになっています。以下に、Dockerfileの具体例を挙げましょう。このDockerfileは、最新のNode.jsの実行環境を作ってくれます。
FROM ubuntu:16.04
RUN apt-get update && \
apt-get install -y \
nodejs \
npm && \
npm install -g n && \ # UbuntuのNode.jsはバージョンが古いので、Node.jsのパッケージのnをインストールして、
n stable && \ # nに最新版をセットアップさせます。
apt-get purge -y \ # 以降はnがセットアップした環境を使いますから、UbuntuのNode.jsパッケージは消しておきます。
nodejs \
npm && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
これなら確実に同じイメージが作成されますな。手順書も不要ですし。実に素晴らしい!
ただね、Dockerfileって、あまり機能がなくて基本的にシェルに丸投げなんですよ……。上の例で言うと、RUN
のところはすべてシェルに丸投げです。まぁ、シェル・スクリプトを知っていればすぐに使えるということでもあるわけですけど、落とし穴が多いですから、「Dockerfileを書くベスト・プラクティス」を斜め読みしておいたほうがよいでしょう。
Dockerで開発環境を作る際に遭遇した課題
で、今回はDockerで開発環境を作ったのですけれど、その際には、以下の3点が課題となりました。
- Dockerのコンテナから、GUIを使う
- Dockerのコンテナから、KVMを使う
- Dockerのコンテナから、OpenGLを使う
すべてをCUI(ターミナル)でやるという男前な方法も考えたのですけど、Dockerやろうとした理由の一つであるAndroid Studioは、GUIじゃないと動かないもんね。なのでGUIは必須となります。
あと、Android開発で使用するAndoidのエミュレーターは、Linuxカーネルの仮想化技術であるKVMを使用します。コンテナ型の仮想化であるDockerの上で、従来の仮想化技術であるKVMを使用しなければならないわけで、とても面倒くさそうです。
さらに、Androidのエミュレーターは、画面の描画にOpenGLを使いやがるんですよ。OpenGLはグラフィック・ドライバの機能ですから、Dockerのコンテナの内側にグラフィック・ドライバ相当の環境を用意しなければならないわけで、もうAndroid開発は辞めようかと思うくらいに面倒……。
以下に、この3つの課題の解決方法を述べていきます。
Dockerのコンテナから、GUIを使う
幸いなことに、私が使用しているLinuxはX Window Systemというクライアント・サーバー型のGUIシステムを使用しています。Xクライアント(ごく普通のGUIプログラム)がXサーバー(Ubuntu16.04の場合はX.Orgファウンデーションが提供するソフトウェア)に描画を依頼したり、イベントを受け取ったりしながら動くシステムですな。というわけで、Dockerのコンテナの中から、外側にあるX Window Systemと通信できればOKというわけ。
で、X Window Systemとの通信手段はいろいろあるんですけど(sshを使ってネットワーク越しなんてものあります。遅いけど)、今回はUNIXドメイン・ソケットがよさそうです。X Window Systemと通信するためのUNIXドメイン・ソケットは/tmp/.X11-unix/
ディレクトリの下にファイルとして存在していますから、/tmp/.X11-unix
ディレクトリをDockerコンテナと共有すれば大丈夫そう。Dockerにはコンテナ外のディレクトリやファイルをマウントする機能がありますから、これを使いましょう。
以上、方針が定まりましたので、Dockerfileを作成します。以下のDockerfileの中でユーザーを作成しているのは、X Window Systemはセキュリティ上の理由でroot(WindowsでのAdministrator)ユーザーからの実行を認めていないためです。
FROM ubuntu:16.04
RUN apt-get update && \
apt-cache search x11 && \
apt-get install -y x11-apps && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN groupadd --gid 1000 developer && \
useradd --uid 1000 --gid 1000 --groups sudo --create-home --shell /bin/bash developer && \
echo 'developer:P@ssw0rd' | chpasswd
USER developer
WORKDIR /home/developer
CMD xeyes
Dockerfileを元にdocker build
してイメージを作成し、docker run
してコンテナを実行します。docker run
のオプションの--rm
は、起動したプログラムが終了したらコンテナを削除するようにとの指示で、-e DISPLAY=${DISPLAY}
は環境変数を通じて使用するX Window Systemのサーバーの指定で、-v /tmp/.X11-unix:/tmp/.X11-unix
はファイル(X Windows SystemのUNIXドメイン・ソケット)の共有です。
$ sudo docker build -t try-gui .
$ sudo docker run --rm -e DISPLAY=${DISPLAY} -v /tmp/.X11-unix:/tmp/.X11-unix try-gui
はい、目玉が表示されました。これでDockerでGUIはオッケー。
ちなみに、xeyesにはGUIから終了させる手段がありません……。が、実はDockerは環境を偽装して普通にプロセスを動かしているだけですから、外側からps
で見えますし、普通にkill
で終了できるのでご安心を。
$ ps -afe | grep xeyes
ryo 8727 8710 0 11:35 ? 00:00:00 /bin/sh -c xeyes
ryo 8776 8727 0 11:35 ? 00:00:00 xeyes
ryo 8802 22749 0 11:35 pts/19 00:00:00 grep --color=auto xeyes
$ kill -9 8776
Dockerのユーザーは名前を偽装しているだけで、コンテナの外側ではuidが同じ外側のユーザー(上の例ではryo)になっています。この程度の偽装でよいのですから、そりゃ、Dockerは軽いわけですな。
Dockerのコンテナから、KVMを使う
KVMをセットアップするのは、Dockerの外側、ホストOSの側です。Dockerのことは忘れて、まずは普通にKVMをセットアップしました。
Dockerfileの中では、KVMを使えるようにするために、ユーザーをkvmグループに追加しておきます(Dockerの外側ではkvmグループでなくてもACLで触れるけど、コンテナの内側ではACLが使えないみたい)。その上で今回は、AndroidのSDK Toolsをダウンロードして、emulator -accel-check
で本当にKVMを使えるか確認することにしました。
FROM try-gui
USER root
WORKDIR /home/root
RUN apt-get update && \
apt-get install -y \
unzip \
wget && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN groupadd --gid 130 kvm && \
gpasswd --add developer kvm
USER developer
WORKDIR /home/developer
RUN wget -O sdk-tools.zip https://dl.google.com/android/repository/sdk-tools-linux-3859397.zip && \
unzip sdk-tools.zip && \
rm sdk-tools.zip
CMD /home/developer/tools/emulator -accel-check
はい完成。Dockerを動かして確認しましょう。docker run
で指定している--privileged
は、コンテナの外側のすべてのデバイス(/dev/*
)へのアクセス可能にするオプションです。KVMを使用するには/dev/kvm
にアクセスできなければなりませんし、Android開発ではUSB等の他のデバイスも使いますから、--privileged
で全てのアクセスを許すようにしてみました。
$ sudo docker build -t try-kvm .
$ sudo docker run --privileged --rm -e DISPLAY=${DISPLAY} -v /tmp/.X11-unix:/tmp/.X11-unix try-kvm
accel:
0
KVM (version 12) is installed and usable.
accel
ターミナルに’KVM (version 12) is installed and usable’と表示されましたから、これでKVMはオッケー。
Dockerのコンテナから、OpenGLを使う
残るはOpenGLです。DockerとOpenGLでGoogle検索してみたら、/dev/dri/*
にアクセスできればOpenGLできる(だから--privileged
をつけて実現した)という話と、NVIDIAのドライバーを入れたらOpenGLできたという話の2つが見つかりました。
で、いろいろ試してみたのですけど、どうやら「/dev/dri/*
へのアクセス」と「コンテナの外側と同じグラフィック・ドライバ」の両方が必要になるみたい。Intel CPUのグラフィックの場合はX関連を入れた時に一緒に入るみたいで問題なかったけど、NVIDIAの場合は自動では入らないから入れないとダメという話みたいですね。
ここで問題になるのは、私はNVIDIA環境のデスクトップPCとIntel環境のラップトップPCの両方を持っているということ。だからといって、NVIDIAのドライバをセットアップするDockerfileと、セットアップしないDockerfileを2つ作るなんてのは、地球が砕け散っても嫌。
だから、nvidia-dockerというNVIDIA公式のツールを使用しました。nvidia-docker
経由でDockerを起動すれば、NVIDIAのデバイスやドライバーを追加してくれるというスグレモノ。これならDockerfileは1つで済みます。ただし、PCにあわせてコマンドを変えるというのは、加齢で衰えた私の脳には負荷が大きすぎるので、Docker Composeというツールも使用しました。Docker Composeは、Dockerのbuildやrunを自動化するツールです(名前が’compose’となっているのはアプリケーションとデータベース等の複数のコンテナを協調的に管理できるためで、本来はこの機能が主なのですけど、今回は使わないので省略で)。
まずは、OpenGLのことは何も考えずにDockerfile
を書きます。mesa-utils
の中に含まれるglxgears
で、OpenGLの動作確認をすることにしました。
FROM try-kvm
USER root
WORKDIR /home/root
RUN apt-get update && \
apt-get install -y \
mesa-utils && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
USER developer
WORKDIR /home/developer
本当は、DockerfileにLABEL com.nvidia.volumes.needed="nvidia_driver"
と書いてNVIDIAのドライバが必要だということを知らせるようにすべき(ラベルが設定されていない場合、nvidia-dockerはドライバを読み込んでくれません)で、PATH
やLD_LIBRARY_PATH
のような環境変数にNVIDIAのドライバを含めてあげるべきなのですけど、今回はやりません。ドライバの読み込みもデバイスのマウントも環境変数の設定も、Docker Composeで実施するためです。
さて、nvidia-dockerがインストール済で、一度実行した後なら、docker volume ls
すると以下のように表示されるはずです(381.22の部分はみなさまがセットアップしたドライバのバージョンになります)。
$ docker volume ls
DRIVER VOLUME NAME
nvidia-docker nvidia_driver_381.22
このボリュームをコンテナにマウントしてあげれば良いわけ。あと、環境変数の設定も。というわけで、Docker Composeの定義ファイルであるdocker-compose.yml
は、以下のようになります。
version: '3'
services:
app:
build: .
privileged: true
command: glxgears
environment:
- DISPLAY=${DISPLAY}
- PATH=/usr/local/nvidia/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- LD_LIBRARY_PATH=/usr/local/nvidia/lib64
volumes:
- /tmp/.X11-unix:/tmp/.X11-unix
- nvidia_driver_381.22:/usr/local/nvidia
volumes:
nvidia_driver_381.22:
external: true
Docker Composeでビルドしたり実行したりする方法は以下の通り。
$ docker-compose build
$ docker-compose up
これで、NVIDIA環境で、OpenGLを使うglxgears
が表示されました。
Intel環境の場合は、以下のようなdocker-compose.yml
にすれば大丈夫。
version: '3'
services:
app:
build: .
privileged: true
command: glxgears
environment:
- DISPLAY=${DISPLAY}
volumes:
- /tmp/.X11-unix:/tmp/.X11-unix
結果としてdocker-compose.yml
が複数存在してしまうことになりますけど、ソースコード管理システム上ではdocker-compose-nvidia.yml
とdocker-compose-intel.yml
の2つを作成して、実際の環境上ではln -s docker-compose-nvidia.yml docker-compose.yml
かln -s docker-compoe-intel.yml docker-compose.yml
することで対応すればオッケー。
それはそれとして、Android Studioは……
よっしゃこれでAndroid開発できる……はずだったのですけど、Android StudioってGUIで初期設定しなければならないので、Dockerfileだけではセットアップできないんですよね。なので、長い時間をかけて手動でセットアップして、docker commit
しましたよ。なんだよ、Docker使う意味無いじゃん……。コマンド・ラインからセットアップできないソフトウェアは禁止っていう法律ができないかなぁ。
というわけで
私が使用しているプログラミング言語はClojureとNode.jsとPythonなので、それぞれのプログラミング用のDockerfileとdocker-composeを作成しました。MatLabとCaffe関連以外のすべての機能をコンパイルするOpenCVとコンパイル・オプション関連の警告が出ないTensorFlowを組み込んだpython-cudaは、とても大変で面白かったです。もしよろしければ、皆様がDockerするときの参考にしてみてください。
2017/06/19
辛すぎるAndroidのApp開発を、Kotlinとコルーチンで楽にする
今年48歳になる年男な私ですが、2週間程前から、生まれて始めてのAndroidのApp開発をやっています。
で、その感想。Androidって、APIがダサすぎませんか? ダサいAPIを使うと生産性が落ちてしまいますから、特にダサくて涙が出ちゃった実行時のパーミッション・リクエストを、Kotlinとコルーチンで素敵にラップしてみました。Kotlinとコルーチン、かなり良いですよ。
なお、本稿は、Android Mのパーミッション制御をKotlinのasync/awaitで簡単にしたを参考にしています。この人のコルーチンの解説、すげー分かりやすいです。
Androidにおける、実行時のパーミッション・リクエスト
まずは、その実行時のパーミッション・リクエストがどれだけ面倒なのかを、Androidの公式トレーニング文書を参考に作成したコードで確認してみます。いきなり長いコードでごめんなさい。言語はKotlinです。
import ...
class MyActivity: AppCompatActivity() {
...
// カメラを使いたいなら……
fun usingCamera() {
// パーミッションをすでに持っているか確認します。
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
// パーミッションを持っているなら、実際にカメラを使う処理のuseCameraを呼び出します。
useCamera()
return
}
// 過去にパーミッションのリクエストを蹴られていて、「今後表示しない」が選択されていない場合は……
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
// パーミッションが必要な理由を訴えた上で、パーミッションをリクエストします。
AlertDialog.Builder(this)
.setMessage("インスタで意識高い系をやるにはカメラが必要なの")
.setPositiveButton(android.R.string.ok, { dialog, which ->
// [OK]ボタンがタップされたら、パーミッションをリクエストします。
// 1234はonRequestPermissionsResultでリクエストを判別するための数値。なんでもいいみたい。
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 1234)
})
.show()
return
}
// パーミッションをリクエストします。
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 1234)
}
// パーミッションをリクエストした結果は、onRequestPermissionResultで受け取らなければなりません……。
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
// 自分のコード以外がrequestPermissionsしているかもしれないので、requestCodeをチェックします。
if (requestCode != 1234) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
return
}
// リクエスト結果をチェック。
if (grantResults.any { it != PackageManager.PERMISSION_GRANTED }) {
// リクエストを蹴られた場合はどうしましょ? 今回は、単純にActivityを終了させます。
finish()
return
}
// 実際の処理をここに書くわけには行かない(だって、ここに来るかはcheckSelfPermission次第だから)ので、useCameraに移譲します。
useCamera()
}
// やっとここで実際の処理。これより前はAndroidがやれっていうから書かなければならないだけの処理……
fun useCamera() {
// カメラを使って実際にやりたい処理……。
}
}
……分かりづれーよ、Android。requestPermissions()
からonRequestPermissionsResult()
に処理が流れるなんて、知識がなければ絶対に分かんねーじゃん。
あるべき形を考える
パーミッションを貰えたかどうかだけが重要なわけですから、以下のようなコードが、あるべき姿でしょう。
fun useCamera() {
if (!requestPermission(Manifest.permission.CAMERA, "インスタで意識高い系をやるにはカメラが必要なの")) {
finish()
return
}
// カメラを使って実際にやりたい処理。
}
面倒な処理はrequestPermission()
の中に閉じ込めてライブラリ化しちゃうわけ。で、このあるべき形、Kotlinのコルーチンを使うと、ほぼ可能なんですよ。
ライブラリ化の課題
あるべき形にするのを阻んでいるのは、成功か失敗かが判明する場所がusingCamera()
とonRequestPermissionResult()
に分散していることです。で、従来の方式ではこんな場合にはコールバックを渡して、コールバックの中にuseCamera()
相当の処理を書くことになっていました。でもこの方式、いわゆるコールバック地獄になっちゃうんですよね。いくつものコールバックに処理が分断されてしまうので、処理の流れを追えなくなって生産性が落ちちゃう。
だから、今時のプログラミング言語ではコルーチンと呼ばれる処理を途中で止めたり再開できる機能を持っていて、コールバックの代わりに処理の再開を使用するようになっています。コルーチンを使えば、先程のあるべき姿のような上から下に流れる(ように見える)コードで、複雑な処理フローを実現することができるんですよ。
といっても、コルーチンというのは別に新しいものではなくて、実はC#やJavaScript(ECMAScript 2015)のyield
でお馴染みのアレです。Lispでは何十年も前から当たり前に使われていますし、2012年の.NET Framework 4.5でC#に組み込まれたasync/await
なんてのもコルーチンの応用ですな。Kotlinは、2017年3月のバージョン1.1でコルーチンに対応したらしい。
さて、先程のパーミッションのリクエスト処理を思い返してみると、useCamera()
とfinish()
の所で、ユーザー・コードに処理を引き継げればよいわけ。リターンする直前で止めておいたコルーチンに、成功か失敗かを渡して継続させればOKです。
ライブラリ化
というわけで、ライブラリ化しました。以下がそのコード。
package ...
import android.app.Activity
import android.app.AlertDialog
import android.app.Fragment
import android.content.pm.PackageManager
import android.support.v13.app.FragmentCompat
import android.support.v4.app.ActivityCompat
import android.support.v4.content.ContextCompat
import kotlinx.coroutines.experimental.android.UI
import kotlinx.coroutines.experimental.launch
import kotlin.coroutines.experimental.Continuation
import kotlin.coroutines.experimental.suspendCoroutine
suspend fun requestPermission(activity: Activity, permission: String, rationale: String): Unit? { // エルビス演算子を使いたいので、戻り値はBooleanじゃなくてUnit?で。
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
return Unit
}
if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) {
suspendCoroutine<Unit?> { cont -> // suspendCoroutineでコルーチンの実行を中断して……
AlertDialog.Builder(activity)
.setMessage(rationale)
.setOnCancelListener { cont.resume(null) } // ここか……
.setPositiveButton(android.R.string.ok, { _, _ -> cont.resume(Unit) }) // ここで、再開します。
.show()
} ?: return null
}
class RequestPermissionFragment : Fragment() { // onRequestPermissionsResultが必要なので、Fragmentを継承したクラスを用意します。
lateinit var cont: Continuation<Unit?>
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
cont.resume(Unit.takeIf { grantResults.all { it == PackageManager.PERMISSION_GRANTED } })
// 処理を閉じ込めているので、requestCodeのチェックはたぶん不要じゃないかなぁと。
}
}
return RequestPermissionFragment().let { fragment -> // Kotlinのスコープ関数は、スコープを小さくできるのでとても便利。
try {
activity.fragmentManager.beginTransaction().add(0, fragment).commit() // Fragmentを追加します。
suspendCoroutine<Unit?> { cont ->
launch(UI) { // Fragmentの追加が終わった後に実行させたい(launchしないとエラーになっちゃう)ので、launchします。
fragment.cont = cont
FragmentCompat.requestPermissions(fragment, arrayOf(permission), 0) // パーミッションをリクエストします。
// onRequestPermissionsResultでresumeされるまで、コルーチンは中断されます。
}
}
} finally {
activity.fragmentManager.beginTransaction().remove(fragment).commit() // Fragmentを削除します。
}
}
}
呼び出し側のコードは、以下の通り。
fun useCamera() = launch(UI) {
// パーミッションをリクエスト。
if (!requestPermission(Manifest.permission.CAMERA, "インスタで意識高い系をやるにはカメラが必要なの")) {
// パーミッションを貰えなかった場合の処理。
finish()
return
}
// カメラを使って実際にやりたい処理。
}
launch(UI)
の部分がちょっと見苦しいけど、それ以外は理想的なコードでしょ? コルーチンは実に便利ですな。
2017/04/27
浮動小数点の誤差にご用心!
ゲームを作っていたら(←SIerで働いているとは思えない書き出し)、浮動小数点の誤差でひどい目にあいました……。同じ穴にハマる方がいらっしゃるかもしれないので、注意喚起を。
そのゲームはキャラクターが重なってはならない仕様だったので、移動する2つの球の衝突判定を使いました。移動する2つの球の衝突判定は2次方程式を解く形になっているので、以下のような関数になります。
def solve_quadratic_equation(a, b, c):
d = b ** 2 - 4 * a * c
if d >= 0:
return ((-b + math.sqrt(d)) / (2 * a),
(-b - math.sqrt(d)) / (2 * a))
return ()
昔学校で習ってその後完全に忘れた、「まいなすびーぷらすまいまするーとびーにじょうまいなすよんえーしーわるにえー」って奴ですな。で、この関数を使って衝突判定してみたところ、なんだか時々おかしな動作をします。具体的には、キャラクターが少しだけ食い込んじゃう時がある(キャラクターが重なった状態でもう一度衝突判定すると、キャラクターが離れる瞬間を衝突と間違えて堂々巡りするというバグになっちゃう)。
これは多分浮動小数点の誤差でしょうとあたりをつけてインターネットを検索してみたら、どうやら、2次方程式の解の公式をそのままプログラミングするのは素人らしい。30年以上プログラムを組んでいるのに、いまだ素人でしたか、私は……。
なんでも、同じ値の浮動小数点の引き算は精度が落ちるので、-b +/- math.sqrt(d)
の部分がダメなんだって。math.sqrt()
の結果は正の数なので、b
がどんな値であっても必ず引き算が発生しちゃうというわけ。ではどうするかというと、2次方程式の解の係数の関係を使うんだそうです。2次方程式は2乗しているので解が2つあるわけで、それをα
とβ
とした場合、α + β == -b / a
とα * β == c / a
という関係が成り立つんだって。だから、引き算がない方を使ってとりあえず解を一つ見つけて、解の係数の関係を使ってもう一つの解を求めればよい。コードにすると、こんな感じ。
def solve_quadratic_equation(a, b, c):
d = b ** 2 - 4 * a * c
if d >= 0:
if b >= 0:
x = (-b - math.sqrt(d)) / (2 * a)
else:
x = (-b + math.sqrt(d)) / (2 * a)
return (x, c / a / x) if x != 0 else (x, x)
return ()
さて、このコードの効果は? 誤差がどのくらい減るのか、可視化してみましょう。-b + math.sqrt(b ** 2 - 4 * a * c)
が問題なのだから、b
の絶対値が大きくてa
とc
が小さい場合に誤差がでるはず。だからbの値は大きくしたい。あと、正解を簡単に計算できるようにもしておきたい。というわけで、a
とc
を1に固定して、x ** 2 - (10 ** n + 10 ** -n) * x + 1
の場合でやりましょう。この場合の正解は10 ** n
と10 ** -n
になるはず。あとは誤差を計算してグラフを描くだけ。というわけで、可視化のコードは以下の通り。
import math
import matplotlib.pyplot as plot
import numpy as np
from matplotlib import cm
from mpl_toolkits.mplot3d import Axes3D
def solve_quadratic_equation_1(a, b, c):
d = b ** 2 - 4 * a * c
if d >= 0:
return ((-b + math.sqrt(d)) / (2 * a),
(-b - math.sqrt(d)) / (2 * a))
return ()
def solve_quadratic_equation_2(a, b, c):
d = b ** 2 - 4 * a * c
if d >= 0:
if b >= 0:
x = (-b - math.sqrt(d)) / (2 * a)
else:
x = (-b + math.sqrt(d)) / (2 * a)
return (x, c / a / x) if x != 0 else (x, x)
return ()
def visualize(f):
def error(n):
a, b = sorted((10 ** n, 10 ** -n))
a_, b_ = sorted(f(1, -(10 ** n + 10 ** -n), 1))
return (n, max(math.fabs(a - a_), math.fabs(b - b_)))
def errors():
return tuple(map(error, np.linspace(0, 10, 100)))
x, y = zip(*errors())
plot.plot(x, y)
plot.show()
if __name__ == '__main__':
visualize(solve_quadratic_equation_1)
visualize(solve_quadratic_equation_2)
可視化結果は、こんな感じ。
……確かに誤差が出るケースは少なくなったけど、大きな誤差がでる場合の、誤差の量そのものは変わらないじゃん! というわけで、このコードでもやっぱり誤差は出ちゃいます。だから、今回は、誤差の許容量であるEPSを変えて対応しました。今回私がハマった穴というのは、浮動小数点と二次方程式じゃなくて、可視化してみないと効果はわからないという部分です。皆様気をつけて! やっぱり、可視化は大事ですよ。
そもそも、どうして私は浮動小数点の誤差を押さえ込めると考えちゃったんだろ?
if float(2 ** 53) == float(2 ** 53) + 1:
print('xとx+1は等しい')
だって、上のコードを実行すると「xとx+1は等しい」と表示されちゃうくらい、浮動小数点は不完全なのに……。
追記
ごめんなさい。可視化プログラムにバグがありました。上のコードの-(10 ** n + 10 ** -n)
では、n
が大きくなると、本文の最後に挙げた大きな数値に小さな数値を足しても結果が変わらないのと同じ浮動小数点の誤差が発生します。具体的にはグラフの右の端、8を超えたあたりからは、この誤差によって意味がないグラフになっています。だから、ごめんなさい、グラフの右端は見ないようにしてください。
あと、コード・レビューしてくれた親切な人が教えてくれた、バグがあるのに正しい答えが出ちゃっているように見える理由も。上のコードのsolve_quadratic_equation_2()
のd = b ** 2 - 4 * a * c
のところ、b ** 2
に比べて4 * a * c
がとても小さい場合は-b - math.sqrt(b ** 2) / (2 * a)
と同じコードになり、a
は1なので-b - b / 2
になって、だから、正しい答えの一つが手に入っちゃうというわけ。情けないことに、レビューしてもらうまで全く気がついていませんでした。
結論は、可視化だけでは不十分、コード・レビューも必要ということですね……。