Top > Programmingとか > VB / VB.NET > 泥Tips

(3) VB5:Single型・Double型の変数で計算すると微妙に数値が狂う

旧々猿頁から収録。日付を管理していなかったので、正確な記述日時がわかりません。ので、サイト「猿頁」開設日としました。御了承ください。

Q. Single型・Double型の変数で計算を繰り返すと、微妙に数値が狂ってしまって使い物にならない。
A. データ型の特長をきちんと理解しよう。

コンピュータの内部では数値は16進(もっと正確に言えば2進)で記憶されている、ということは 皆さんご存知だと思います。
値を表すのに10進であろうと16進であろうと27進であろうと同じ値は同じ値なのですが、それを変数に割り当てた たかだか数バイトのメモリの中に正確に書きこめない、というのが値の狂ってくる原因です。
で、数値範囲・精度・演算速度のどれかを犠牲にしながら、何種類かの変数の型が用意されていますので、 適材適所で使っていきましょう。

種類 精度 小数 数値範囲 演算速度 データ型
整数 × Integer、Long
浮動小数点数 × Single、Double
固定小数点数 Currency(Decimal)

わざわざ精度の悪い型の変数を使っておいて精度の悪さを嘆く、ってのもおかしな話です。

 「小数を扱うなら整数では無理だから浮動小数点数型」「DoubleはSingleよりも精度がいい」なんて悪常識を持っているなら、捨てましょう。「浮動」ってのは「精度が悪い」という意味、「倍精度」ってのは「単精度の2倍しか精度が よくない」と理解しておかないと。


「整数型」と「浮動小数点数型」しか使わない人って、けっこういるんではないでしょうか。 初期のパソコン(とかマイコンとか)に搭載されていた昔のBASICって、この2種類くらいしか数値変数が なかったんですよ。
そのころにBASICを覚えた人、あるいはそーいう人に教えてもらった、そーいう人の 書いた書籍で学んだ、なんて人にこの傾向があるような気もしないではないのですが。

浮動小数点数型なんて、むかーしの大型コンピュータがすごく壊れやすかった頃に、「なんとかマシンが 壊れる前に計算を終了させねばならん」って編み出された、速度重視・精度無視の型なんですね。
なんせその頃のコンピュータってば、真空管(!)。真空管ってのはトランジスタ(LSIの一部、みたいなもん)と同等の 働きをする電気部品なんですが、基本的な仕組みは電球と同じ。要は、長い間使ってると切れるわけです。1万時間の 耐久性を持った真空管を1万本使ったコンピュータがあったとすると、平均1時間に1本は切れちゃう。専任の部隊が予備の 真空管抱えて控えていて、ポン!と切れると、「どのタマが切れたんだーっ」と一斉に走って探し始めたんだそうで。 コンピュータっていっても体育館くらいの大きさですから、体育館にたくさんの本棚詰め込んだ中で宝捜しゲームを やっているようなもんです。

てゅうわけで、浮動小数点数型ってのは大まかな値をすばやく(なのか?)算出するために考案された わけですね。でないとミサイルをどう飛ばしていいのかわからん(現在のコンピュータの元祖は、 米軍が大陸間弾道弾の軌跡を算出するために開発したんです、確か)。
FORTRANの数値の表現形式は基本的にこの浮動小数点数型です。そもそも元になるデータが 実験や計測から得られているわけですから、それ自体に誤差が含まれていますので、 計算誤差だけに目くじら立ててもしょうがないんです。
でも、有効桁数には割とシビアですので、確かとんでもない有効桁数を取れる変数宣言なんかも あったはずです(なんせ10年以上前にいじったのが最後で、しかもその後確か規格変わったはずなんで、 今どうなっているか厳密なところは、私、知りません)。

これとは別に、お金の計算をコンピュータにさせたい、という話もあったわけなんですが。 こっちは話がお金ですから、大まかに計算するわけにはいかない。
利息計算したら無限小数になっちゃうなんてこともよくあるんですが、浮動小数点数型で計算して四捨五入しようと 思ったら、「0.5」になるはずの計算結果が「0.49999999999」とかになっちゃって、切り上げてほしかったのに 切り捨てられちゃったなんてこともよくある話で。

で、ひとつは、整数値で計算しておいて、その整数値の左から何桁目に小数点があるんだ、とみなしちゃうという方法が 考案されました。これなら有効桁数の範囲内なら全部整数演算ですので、誤差は生じません。 これが、固定小数点数型。
もうひとつ。16進演算のせいで小数部が10進に変換しきれないんだから、16進演算をあきらめてしまえ、という考え方。 実際には16進1桁に10進1桁を記録してしまうんですね。これだと記録としては正しいですが、実際の数値としては むちゃくちゃになりますので、CPUの演算機能を全部あきらめて1桁ずつの演算で繰り上がり/繰り下がりやなにかは全部 プログラミングで演算(エミュレート)していくんですね。これは10進での演算ルールをそのまんまエミュレート しますので誤差は生じませんし、有効桁数も思いのまま。ただしCPUの演算機能を直接使えるわけではないので、 バカ遅です。これが10進型。

これらはどちらも元はCOBOLから来ています。で、COBOL自体は米国防省がその莫大な予算を 計算するために、特にお金の計算に特化したプログラミング言語として開発しました。 (ちなみに、このときこのプロジェクトに関わった事務機屋さんがBig Blue=IBMになったって、ホント?)

なんか現在のコンピュータの基礎技術の多くが、軍隊とゆーか戦争の副産物だっちゅうのも 胸くそ悪い話ではありますが。事実は事実、電子計算機は血塗られた生い立ちだったりもするわけです。

VBでは固定小数点数型は「通貨型(Currency)」と名付けられています。その生い立ちから言えば確かに通貨の計算が主な 目的の型なんではありますが、その名前のせいで、お金の計算以外には使おうとしないとか、「僕はゲームを 作りたいんで通貨型なんて関係ないね」って最初っから眼中にない人もいますね。これは間違い。単に「浮動小数点数型」に対する「固定小数点数型」なんですから、用途に応じて もっと積極的に使い込むべきです。

10進型は、VBでもそのまんま「10進型(Decimal)」として存在するんですが、惜しむらくはそーいう型宣言がありません 。Variant型の変数にCDec関数で数値を 突っ込んでやらないと10進型としては使えません。しかもうっかりどこかで生の数値をその変数に突っ込んだりすると、 Vaiantの内部処理形式自体がDecimalではなくなってしまうかもしれないので、安心して使えません。
また固定なのは有効桁数なので小数部の精度を上げようとすると整数部の値を大きくしないようにプログラミング上で 気をつけなければならないなど、ほかの数値変数とは違う制限事項があります。通貨型の小数部有効桁数が4桁しかないことに比べると、10進型の小数部有効桁数は最大で27桁になりますので、 上手に使えば小数部に一番信頼のおけるデータ型ではあるのですが。


さて。
先ほど「(数値を)変数に割り当てたたかだか数バイトのメモリの中に正確に書きこめない」と述べたわけですが、 じゃあ実際にはどう記録されているのか?という話になりますね。

これは本当は「2の補数」「IEEE 754」とか「パック10進」とかが索引に載っているような書籍をきちんと 読んだほうがいいのですが。でもまぁ、わかる範囲でこつことつと。

「変数の中にどのように数値データが格納されているか?」という話なわけですから、変数に割り当たっているメモリの 中身(メモリイメージ)を直接引きずり出す必要があります。「256で割って…」などと考えても、それはメモリの内容とは 無関係なただの演算結果ですから、あらかじめ記録のロジックを知った上でないとどうどうめぐりになっちゃって ちっとも正解にたどり着けません。

で、今回は変数の内容をメモリイメージとして引きずり出すのに、「LSet」を使いましょう 。ユーザ定義型の変数同士をLSetで代入すると、単純・純粋にメモリイメージのコピーになるんですね。 VBのヘルプ[ランゲージリファレンス]-[ステートメント]-[L]-[LSetステートメント]には、

警告
LSetステートメントを使って、あるユーザー定義型の変数を別のユーザー定義型の変数に コピーすることは、できる限りしないでください。あるデータ型のデータを別のデータ型で予約されている 領域にコピーすると、予期しない結果が生じる可能性があります。あるユーザー定義型から別のユーザー定義型に変数をコピーすると、その領域の要素に対して 指定されているデータ型に関係なく、一方の変数のバイナリ データだけが他方のメモリ領域にコピーされます。

なんてことが書いてあるんですが、ここで「予期しない結果が生じる可能性があります。」という言葉に 惑わされないでください。これは変数内のメモリイメージをきちんと把握していない人が安易に LSetステートメントを使うことに対しての牽制の意味合いが強いと思います。
片っ方のユーザ定義型にLong、もう片っ方にIntegerを宣言しておいて、「数値がきちんと代入されない」なんて いう人もいますので、こう言われちゃってもまぁしょうがないことかと思います。
基本的には、両方のユーザ定型に含まれる変数の総バイト数が同じであれば、内容はむちゃくちゃになるにせよ、 そんなに弊害はありません。逆に、バイト数だけ合わせておいて代入される側のユーザ定義型の変数をすべて Byte型にしてしまえば、LSet後のByte型変数の中身は元のユーザ定義型のメモリイメージになっているんです。


で、まず初歩の初歩。Integer(整数型)から行きましょうか。

VBのヘルプ[補足情報]-[データ型の概要]-[データ型の概要]を読むと、Integer(整数型)は2バイトで、 表現できる範囲は-32768~32767ということになっています。
ためしに「0」をIntegerに突っ込むと、そのメモリイメージは[00][00](16進)となりました。
「1」を代入すると、[01][00]になります。ということは、メモリの若い方に 数値を16進で見たときの下2桁が格納される、ということになります。
「-1」を代入するとどうなるか?→[FF][FF]となります。意外ですか?そんなことはないか?

実はこれ、「2の補数」という考え方でして、「1+(-1)」がストレートに計算できちゃうんですね。つまり、

1 + (-1) = &H0001 + &HFFFF
     = &H10000 (でも最上位ビットは3バイト目に当たるので切り捨てられちゃう)
     = &H0000 (厳密には「=」じゃないけど)
     = 0

ということで、CPUの演算機能一発、ということになります。記録方式をきちんと考えると、こーいう高速化ができちゃう というお手本みたいなロジックです。Integerにおいては&HFFFF=65535ではない、ということですね。 話は進みます。最大値、つまり32767ならどうなるか?[FF][7F]です。最小値-32768なら?[00][80]ですね。 これも、

32767 + (-32768) = &H7FFF + &H8000
          = &HFFFF
          = -1

…おぉ、帳尻合うじゃん。やるな2の補数!ってとこでしょうか。


次、Long(長整数型)。

 これは4バイト使用、範囲は-2,147,483,648 ~ 2,147,483,647ですね。基本的にはInteger型と同じわけですから、さらっと行きます。

     0      [00][00][00][00]
    -1      [FF][FF][FF][FF]
2147483647(最大値) [FF][FF][FF][7F]
-2147483648(最小値) [00][00][00][80]

です。まぁInteger型とやっていることは同じですね。


次、Single(単精度浮動小数点数型)。

これも4バイト使用で、範囲は-3.402823E38 ~ -1.401298E-45 (負の値)、1.401298E-45 ~ 3.402823E38 (正の値) ということになっています。

これはちょっとややこしいです。

  • 最上位バイトの最上位ビットは符号を表す(0=「+」、1=「-」)
  • その次のビットから8ビット分が、指数部を表す。ただし2nの、n+127が格納される。
  • 残り23ビットは仮数部の小数部を表す。ただしこの23のさらに上位ビットに常に整数部「1」があるとみなし、24ビット分の仮数表記となる。

…ということなんですが…おーい、ついてきてますか?

たとえば、「2」を格納することを考えてみましょう。

「2」は2進表記では、「10(2)」となります。で、これを仮数部に当てはめることができるように、「1.xxxxxxxxxxxxxxxxxxxxxxx(2)」の形に 直します。そうすると、「1.00000000000000000000000(2)×21」となります。
さて仮数部はかならず「1.xxxxxxxxxxxxxxxxxxxxxxx(2)」となりますので、頭の「1」は あってもなくても同じです。ですから、メモリには記録しないことにします。
また、指数部のnはマイナスになる可能性もあるのですが、そうそう符号ビットばかりは立てていられないので、 単純に最大値の半分(8ビットの符号なし最大値は256ですから、その半分は128)を「1」とみなすことにしましょう。 つまり21ならn=128、20ならn=127、…にしちゃうわけです。 そうすると、

小数部 00000000000000000000000(2)
指数部 128 = 10000000(2)
符号  0(+)

となります。これを符号、指数部、小数部の順に並べて

[0100 0000][0000 0000][0000 0000][0000 0000](2)
= [40][00][00][00](16)

で、整数型と同じように下位バイトをメモリの若い方に並べると、

[00][00][00][40]

となります。これがSingle型の中のメモリイメージですね。

さて、この表現方法で表現できる最大値っていくつだ?と考えると、当然 「1.11111111111111111111111(2)×2128」ですね。 …と思いきや、IEEE規格754(VBのヘルプでは単に「IEEE」と呼んでいますね)には、例外がいくつかあります。正確にはちょっと違うんですが、ここではわかりやすく言いきっちゃいます。

  • 指数部=0、かつ小数部=0のときは「±0」とみなす(符号は有効)。
  • 指数部=255、かつ小数部=0のときは「±∞」とみなす(符号は有効)。
  • 指数部=255、かつ小数部≠0のときは数値ではないとみなす。

つまり、指数部を255(2128)にしちゃうと、無限大になるか(もっともVBのSingle型に無限大の概念は ありませんが)エラーになっちゃうわけです。
てゅうことで、「1.11111111111111111111111(2)×2127」が最大ということになります。これを 律儀に計算すると、340,282,346,538,428,459,811,704,183,484,516,925,440(あーしんど…合ってる?)です。

ところが、VBのSingle型では、小数点以下6桁までしか表示されません。しかも小数第7位で四捨五入されちゃったりもします。ですから、せっかく最大値を代入しても、表示するときにはただの「3.402823E38」に なっちゃうんですね。しかもこれって[FD][FF][7F][7F]で、小数部がいつのまにか目減りしちゃうわけです。

じゃあ最小値はどうなるかというと、1.401298E-45(つまり[01][00][00][00])です。

これはこれでいいんですが、 そうすると「最小値の次に小さな数値はいくつだ?」というこも気になるわけです。
メモリイメージで考えれば、当然 [02][00][00][00]ということになります。ところがこれは2.802597E-45です。ではこの間の値はどうなるんでしょう?

たとえば、「2.000000E-45」は?実際に調べてみたところ、答えは「やっぱり[01][00][00][00]」でした。 「2.500000E-45」は[02][00][00][00]になりますので、どうも近いほうのメモリイメージに 丸められちゃうようです。

とまぁ、えんえんと述べてきたわけですが、Single型の内部構造を知ると、おのずからその数値格納の限界が おわかりいただけるんではないでしょうか(あるいは途中でうんざりいただけたんではないでしょうか)。
一応結論としては、「どんな値をつっこんでも近似値にしかならないので、そのつもりで使え!」です 。


次、Double(単精度浮動小数点数型)。

これは8バイト使用、範囲は-1.79769313486232E308~-4.94065645841247E-324(負の値)、 4.94065645841247E-324~1.79769313486232E308(正の値)ですね。基本的にはSingle型と同じわけですから、これもさらっと行きましょう。 Single型との相違点は、

  • 小数部52ビット、指数部11ビット。
  • 21の時の指数部は1024。
  • 無限大、または数値ではないと処理するときの指数部は2047。

というところでしょうか。


次、Currency(通貨型)。

これは8バイト使用、範囲は-922,337,203,685,477.5808~922,337,203,685,477.5807です。
基本的には整数型と同じなんですが、「表示するときに1/10000にされる」というのが特長です。
つまり、「0.0001」をCurrency型に格納したとき、メモリイメージとしては整数型の「1」として 記録されるということです。
小数点以下4位までの精度しかありませんが、この範囲内であれば浮動小数点型に見られた「数値と数値の間の隙間」 がなく(したがって誤差もなく)演算できるということになります。
また、演算ルーチンが事実上整数演算なので、浮動小数点型よりも簡単(したがって高速)であるという 利点も持っています。

わざと一万分の1の値を格納するようにプログラミングすれば、Long型の倍のビット数を持っていますので、 ダブルLong型の代わりに使うという反則技も使えます。


さて、最後です。Decimal(10進型)。

…と言おうと思ったんですが、Decimalという型はなく、Variant型にCDec()関数で10進化した数値をつっこむと Variant型がDecimal型として動作するという、不完全な型です。

理由はよくわからないんですが、実はVariant型を含むユーザ定義型は、LSetが効かないんですね。LSetかけようとすると 「型が違います」エラーが発生してしまう。ということで、この型については検証していません。
固定28桁の精度を持ち、内部的にも10進表記のままで記録されますので、うまく使えばCurrency型よりも 広い範囲の値を誤差なく使えるはずなんですが…「サポートしていません」ってヘルプで繰り返し言っている意味も よくわからないんですよね。だったら言わなきゃいいのに。
ちなみにウォッチではきちんと「Variant/Decimal」として表示されますので、Variant型が内部的にDecimal型で 動作しているかどうかだけはかろうじてわかります。

Variant/Decimal型を左辺に持つ代入式を記述する場合は、右辺の各定数/変数に、それぞれCDec()関数を かぶせておいたほうが無難でしょう。いつほかの型にシフトするかわからないんですから。

まぁなぞの多い型ではあります。

検証に使ったプログラムはこちら→DimBits.lzh

さて。
精度の限界や格納できる数値の範囲について「そーいぅもんだ」と割り切っちゃうのが効率よく VBでプログラムを作っていく時の正しい姿勢だと私は思っているんですが、それでは済まされない 開発目的だっていっぱいありますよね。

「ゲームを作っているが、キャラクタの表示座標を誤差なく算出したい」…こんな話にはおつきあいできません。こんなことするのに誤差の可能性のあるロジックを組む方がおかしいです。 どぅせ画面に表示するときのドット位置は整数で指定するんですから(エラーが出ないように実数値のtwips系がVBでの 座標のデフォルトになっていますが、誤差なく高速にAPIとの整合性も考えるべきプログラムの時には当然pixel系に すべきですよ)、演算だって整数演算にすればいぃんです。小数の精度をいくらガンバって計算したって、 丸められるだけでちょっとも役に立ちませんね。

「円周率を算出したい」…VBには「π」定数が用意されていませんので、 普通円周率定数を利用するときには

Public Const PAI = 3.14159

とか最初に用意しちゃうんですけど。

でも、たとえば円周率の算出そのものを目的としているときに、最初っから定数で与えてどーするんだ? って、そのとーりですね。
こーいう場合はVBが標準で用意したデータ型や演算子をそのまま使うのではなく、自分で必要とするだけの 有効桁数や精度を実現できるロジックを独自に組む必要があります。てゅうか、コンピュータってもともと そーいぅもの。ビットシフトと論理演算しかできないんですから、誰がそれを駆使して高度な演算を行う ロジックをこしらえて提供するのか?だけの違いです。CPUの回路パターンに埋め込まれているか、 OSの中か、VBのランタイムモジュールの中か、自分で作ったプログラムの中か。

トラックバック

このエントリーのトラックバックURL:
http://salv.miscnotes.com/mt/mt-tb.cgi/316

コメント

Currencyの情報、すごく助かりました。

> すごく助かりました。

お役に立てて何よりです。

ちなみに、本エントリはVB5~6で有効です。
VB.NET(2002)以降をお使いの場合は、Decimal型を使われた方が幸せになれるかもしれません(^^)。

コメントを投稿