こんにちは!SWEの高橋(@thehighhigh)です。 この記事は ANDPAD Advent Calendar 2024の 10日目の記事です。
私は入社して以降「ANDPAD図面」のサーバーサイドの開発に携わっており、最近は新規機能の開発を進めています。
そんな「ANDPAD図面」のサーバーサイドは、主にRuby on Railsで構築されています。本記事では、Ruby on Railsで浮動小数点数を扱う際にハマった問題についてお話ししようと思います。
※今回の動作環境は以下のとおりです
Rails 7.2.2 MySQL: 8.0.28
ハマった問題:浮動小数点数によって、意図せずテストが失敗
例示のため、以下のようなexperiment_results
テーブルと、ExperimentResultsController
を定義します。
何かの実験を行ってその結果(浮動小数点数)をmeasured_value
(測定値)に格納するイメージです。
このとき、measured_value
の型はFLOAT
で定義しています。
ActiveRecord::Schema[7.2].define(version: 0) do ... create_table "experiment_results", force: :cascade do |t| t.float "measured_value", comment: "実験での測定値" t.datetime "created_at", null: false t.datetime "updated_at", null: false end ... end
class ExperimentResultsController < ApplicationController ... def create @experiment_result = ExperimentResult.new(create_experiments_params) if @experiment_result.save render json: { id: @experiment_result.id, measured_value: @experiment_result.measured_value }, status: 201 else render json: { errors: @experiment_result.errors.full_messages }, status: :unprocessable_entity end end ... private def create_experiments_params params.require(:experiment_result).permit(:measured_value) end end
これに対して、RSpecで以下のようなテストコードを書きます
require 'rails_helper' RSpec.describe ExperimentResultsController do describe 'POST #experiment_results' do subject { post experiment_results_url, params: params, as: :json } context 'with valid parameters' do let(:params) { { experiment_result: { measured_value: 9998.999 } } } it 'creates a new experiment result' do expect { subject }.to change { ExperimentResult.count }.by(1) expect(response).to have_http_status(200) # 保存された値が正しいか確認 expect(ExperimentResult.find(response.parsed_body['id']).measured_value).to eq(9998.999) end end end end
一見すると成功しそうですが、実際にはこのテストは失敗してしまいます。
Failures: 1) ExperimentResultsController POST #experiment_results with valid parameters creates a new experiment result Failure/Error: expect(ExperimentResult.find(response.parsed_body['id']).measured_value).to eq(9998.999) expected: 9998.999 got: 9999.0 (compared using ==)
measured_value
の精度が保たれず、意図せずに丸められてしまっていることが原因のようです。
では一体なぜこのテストが落ちてしまうのか、詳しく調べてみることにしました。
原因はMySQL?
この問題のコードで色々と試してみると、以下のことがわかります。
- 桁数を減らして、
9998.999
ではなく、998.999
に変更すると、テストが成功する - 桁数を増やすと、小数点を含んだどの数値でもテストが失敗する
created
と共に送られるレスポンスは9998.999
のまま精度を保っている- つまり、DBを介さない場合、精度は保たれている
これらのことから、Ruby on Rails側ではなく、データを保存した後のMySQL側の浮動小数点数の精度に原因がある可能性が高いと考えました。
MySQLのFLOAT型とRubyのFloatクラスの違い
原因が絞れたので、MySQLのFLOAT型と、RubyのFloatクラスについてそれぞれ調べてみました。
MySQLのFLOAT型
MySQLの公式ドキュメントには以下のように書かれています。
FLOAT および DOUBLE 型は概数値データ値を表します。 MySQL は、単精度値には 4 バイトを、倍精度値には 8 バイトを使用します。
つまり、FLOATでは、文字通りC言語のfloatと同様の4バイト=32ビットの単精度浮動小数点数を使用していることがわかります。
この32ビットの単精度浮動小数点数の精度がどれくらいかというと、一般には7桁の精度といわれています。※1
つまり9998.999
のような7桁の値は、保存時に丸められることがある、ということがわかります。
RubyのFloatクラス
一方で、Rubyの公式ドキュメントを見てみると、Floatクラスについて以下のように書かれていました。
浮動小数点数のクラス。Float の実装は C 言語の double で、その精度は環境に依存します。 一般にはせいぜい15桁です
つまり、一見するとC言語のfloatを連想させる命名ですが、実際にはdoubleとして扱うべきということです。
原因はこの精度の差
これらのことから、同じ名がついているものの、MySQLのFLOAT型とRubyのFloatクラスの精度が異なっていることが原因で、データの意図しない誤差が発生したということがわかりました。
対応方法
Ruby側がC言語のdoubleに対応しているので、MySQLで同様にDouble型カラムに変更すれば精度は保たれるため、この問題が解消しそうということがわかります。
ここでも少しハマったポイントがあったので、対応方法についてもご紹介します。
Ruby on Railsでは、MySQLのDOUBLE型を直接定義できない
実はRuby on RailsのActiveRecord::Migration
では、カラムにDOUBLE型を直接定義できません。
これは、Ruby on Railsの公式ドキュメントのActiveRecord::ConnectionAdapters::SchemaStatements
のadd_column
の記述から確認できます。
The type parameter is normally one of the migrations native types, which is one of the following: :primary_key, :string, :text, :integer, :bigint, :float, :decimal, :numeric, :datetime, :time, :date, :binary, :blob, :boolean.
ただし、以下のようにfloat
に対して24~53のlimitを指定することで、DOUBLE型として定義されます。
ActiveRecord::Schema[7.2].define(version: 0) do ... create_table "experiment_results", force: :cascade do |t| t.float "measured_value", limit: 53, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false end ... end
mysqlを直接見に行くと、きちんとDOUBLE型になっていることがわかります。
mysql> DESCRIBE experiment_results; +----------------+-------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +----------------+-------------+------+-----+---------+----------------+ | id | int | NO | PRI | NULL | auto_increment | | measured_value | double | NO | | NULL | | | created_at | datetime(6) | NO | | NULL | | | updated_at | datetime(6) | NO | | NULL | | +----------------+-------------+------+-----+---------+----------------+ 4 rows in set (0.02 sec)
この状態で、先のテストを再度実行してみると、、、
Finished in 13.07 seconds (files took 16.29 seconds to load) 1 example, 0 failures
見事にパスしました!これで一件落着です。
結論
今回の問題から、以下のことがわかりました
- Ruby on Rails と MySQLの組み合わせで浮動小数点を扱うときは、精度の違いに注意
- MySQLのFLOAT型の精度は、C言語のfloatと同等の精度
- RubyのFloatの精度は、C言語のdoubleと同等の精度
- Ruby on Railsで、MySQLのDOUBLE型を扱うには、
float
にlimit
を指定する
今後Ruby on Rails上で浮動小数点数を扱う際には、こういった仕様の差異を意識したいと思いました。
最後に
アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を募集しています。
ご興味を持たれた方は、下記のサイトをぜひご覧ください。
※1: IEEE754の32ビットの単精度浮動小数点数では、仮数部23ビットに暗黙的な1ビットを加えた24ビットの精度を持ち、これを10進数に直すとおよそ7.22になることから