今回は Tips というより、教育的なメモです。コンピュータという名前の通り、PC も計算機です。ですから、なんらかの数値を処理することにより、ゲームが動いたり、ワープロソフトが動作しているわけです。そこで、ここではもっとも基本的な「整数値」が PC の中でどのように表現されているか実際に見てみましょう。
ご存じのように、現在の計算機では、メモリやファイルの内容の特定の場所が、1あるいは0の数値で表される二進法により動作しています。どんな内容のファイルやメモリでも、1と0だけでなりたっているわけです。それを人間が読みやすいように、テキストの文字にあてはめたり、10進数や16進数や浮動小数に当てはめているのは、もっぱらソフトウェアがそのように解釈しているからです。メモリやファイルは、1と0で埋め尽くされているだけで、それ自体は、なんのデータであるか知らないのです。テキストエディタは、ファイルを読み込むとき、1と0の連なりを8ビットまたは16ビットごとに区切られた文字コードであると解釈して、それに対応するフォントを使って表示します。テキストファイルは、ファイルの中に「テキスト」が入っているのではなく、テキストを作っている文字列のなかの各々の文字の文字コードを1と0で表したものが入っているのです。こう書いてしまうといかにも当然なのですが、これを理解していない方が大勢いることに気がついて驚くことがあります。ここでは、整数値がどのようにメモリやファイルに記録され、また変数にどのように格納されているのかを実際に見てみましょう。
10進数の表記では、0から9までの10種類の数字をつかって、0から一回ずつ使って大きい数値を対応させ、最も大きな数値に対応する数字である9の次に大きな数値は、10と表記されます。十種類の数字を使い切って桁があがるのですね。抽象的な数値の概念からすると、8と9の差と、9と10の差は同じですから、桁があがっても数値的な意味が9から10になるときに変化するわけではありません。ですから、9から10に桁があがることには何ら普遍的な意味は無いのです。たんに、たまたま十種類の数字をつかって表現すると、9の次は10と表記するといろいろ便利であるという便宜的な約束ごとです。同様にして、0と1の2つだけの数字では、1の次に大きい数値は10となります。これが二進法の桁上がりです。16進法では、0から9までの数字に加えて A から F までの16種類の「数字」を使って、F の次に大きい数値を10と表記します。対応表は以下のようになります。
10進 2進 16進 0 0000 0 1 0001 1 2 0010 2 3 0011 3 4 0100 4 5 0101 5 6 0110 6 7 0111 7 8 1000 8 9 1001 9 10 1010 A 11 1011 B 12 1100 C 13 1101 D 14 1110 E 15 1111 F 16 1 0000 10 17 1 0001 11 18 1 0010 12 ....
二進数で4ビットごとに区切るのは、たんにそうすると16進数の一桁に対応していて見やすいからです。
それでは、実際に変数に数値を代入して、上記のようになっているかどうか見てみましょう。といっても、Delphi では、整数値を十進表記で表したときの文字列に変換するには IntToStr()、十六進表記の文字列に変換するには IntToHex() を使えますが、2進表記の文字列に変換する関数がありません。それをまずつくることから始めましょう。最初は、2進数で4桁、つまり十進数の 15 までの数値を2進数表記の文字列に変換することからです。
ある変数に今格納されている数値が、二進数表記で下四桁が WXYZ になっていたとします。この4ビットの W から Z までの数値はゼロまたは1です。Z がゼロか1かは、1と論理積をとって、その結果がゼロか1かで分かります。1は 0001 だからです。同様に、Y は 0010 = 2 と、X は 0100 = 4 と、W は 1000 = 8 と論理積をとって、その結果がゼロでなければ1、ゼロならばゼロであることが分かります。この論理を関数にしてみましょう。
List 1
function NibbleToBinaryStr(value:Byte):string; begin SetLength(result,4); if (value and 8) = 0 then result[1] := '0' else result[1] := '1'; if (value and 4) = 0 then result[2] := '0' else result[2] := '1'; if (value and 2) = 0 then result[3] := '0' else result[3] := '1'; if (value and 1) = 0 then result[4] := '0' else result[4] := '1'; end; procedure TForm1.Button1Click(Sender: TObject); var i:Byte; begin Memo1.Clear; for i := $0 to $F do Memo1.Lines.Add(Format(' %.2d = %s = $%.1x',[i,NibbleToBinaryStr(i),i])); end;
結果は右図のようになります。これは、上記の対応表と同じです。したがって、4ビットまでは人間が考えたとおりに、変数が数値を保持していることが分かりました。
上記の NibbleToBinaryStr 関数を使って、1バイト=8ビットの数値を二進数表記に変換する関数を作りましょう。
List 2
procedure TForm1.FormCreate(Sender: TObject); begin Memo1.ScrollBars := ssVertical; end; function NibbleToBinaryStr(value:Byte):string; begin SetLength(result,4); if (value and 8) = 0 then result[1] := '0' else result[1] := '1'; if (value and 4) = 0 then result[2] := '0' else result[2] := '1'; if (value and 2) = 0 then result[3] := '0' else result[3] := '1'; if (value and 1) = 0 then result[4] := '0' else result[4] := '1'; end; function ByteToBinaryStr(value:Byte):string; begin result := NibbleToBinaryStr(value div 16)+' '+NibbleToBinaryStr(value mod 16); end; procedure TForm1.Button1Click(Sender: TObject); var i:integer; begin Memo1.Lines.BeginUpdate; Memo1.Clear; for i := $00 to $FF do Memo1.Lines.Add(Format(' %.3d = %s = $%.2x',[i,ByteToBinaryStr(i),i])); Memo1.Lines.EndUpdate; end;
実行して、Button1 を押すと右図のようになります。ByteToBinaryStr 関数は、1バイトの上位4ビットを取得するために、渡された数値を16で割り、その答えを NibbleToBinaryStr 関数に渡します。さらに、下位4ビットを16で割った余りで取得して、やはり NibbleToBinaryStr 関数に渡します。それらを文字列を空白を挟んで繋げると、求める文字列が得られます。結果をみると、やはり、数値を表すビットの組み合わせは、1バイトでも期待通りになっていることが分かったでしょう。
ここで、ちょっと視点を変えて、Delphi のビット演算子のうち、shr と shl について例を示しましょう。D5 のヘルプによると
x shl y と x shr y の各演算は,x の値を左または右に y ビットだけシフトする。 これは x に対する 2^y の乗算または除算に相当し,結果は x と同じ型になる。
とあります。実際に1バイトの数値で試してみましょう。前のコードをすこし変更して以下のようにします。
List 3
procedure TForm1.Button1Click(Sender: TObject); var i:integer; b1,b2:Byte; begin b1 := $FF; Memo1.Lines.BeginUpdate; Memo1.Clear; for i := 0 to 8 do begin b2 := b1 shl i; Memo1.Lines.Add(Format(' %.3d = %s = $%.2x',[b2,ByteToBinaryStr(b2),b2])); end; Memo1.Lines.EndUpdate; end;
結果は右図のようになります。まんなかの二進数表記のところを注目してください。ここでは shl を試しています。はじめに 1111 1111 だったのが、左に一つずつシフトして、その代わり右からゼロが補充されているのが分かるでしょう。
同様にして shr を試してみましょう。
List 4
procedure TForm1.Button1Click(Sender: TObject); var i:integer; b1,b2:Byte; begin b1 := $FF; Memo1.Lines.BeginUpdate; Memo1.Clear; for i := 0 to 8 do begin b2 := b1 shr i; Memo1.Lines.Add(Format(' %.3d = %s = $%.2x',[b2,ByteToBinaryStr(b2),b2])); end; Memo1.Lines.EndUpdate; end;
結果は右図のようになります。shr では、ビットごとに右にシフトして、左からゼロが補充されていますね。このようなビットシフトの演算は CPU のレジスタを使って直接実行されるので、非常に高速であることが期待できます。これらを使うと、整数値の任意のビットがゼロであるか1であるかを確かめたり、上位のビットを下位にシフトさせたり、その逆のことが簡単にできます。もとに戻って、NibbleToBinaryStr 関数と ByteToBinaryStr 関数をビットシフトを使ってより高速動作を期待できるように書き直してみましょう。前のところを書き直して以下のようにします。
List 5
function NibbleToBinaryStr(value:Byte):string; const Bit:array[0..1] of Char = ('0','1'); var i:integer; begin SetLength(result,4); for i := 0 to 3 do begin result[4-i] := Bit[value and 1]; value := value shr 1; end; end; function ByteToBinaryStr(value:Byte):string; begin result := NibbleToBinaryStr(value shr 4)+' '+NibbleToBinaryStr(value and $F); end; procedure TForm1.Button1Click(Sender: TObject); var i:integer; begin Memo1.Lines.BeginUpdate; Memo1.Clear; for i := $00 to $FF do Memo1.Lines.Add(Format(' %.3d = %s = $%.2x',[i,ByteToBinaryStr(i),i])); Memo1.Lines.EndUpdate; end;
結果は右図のようになり、うまくいっています。NibbleToBinaryStr 関数では、shr を使って最下位ビットに追い込み、1との論理積がゼロか1かで文字列を割り当てています。また、ByteToBinaryStr 関数では、16で割るかわりに shr で4ビット右にずらしています。また、下位4ビットは、$F との論理積で求めています。同様にして16ビットと32ビットの数値に対しても二進数表記の文字列に変換する関数をつくりましょう。ByteToBinaryStr 関数の下に以下の関数を書き足して、テストします。
List 6
function WordToBinaryStr(value:Word):string; begin result := ByteToBinaryStr(value shr 8)+' '+ByteToBinaryStr(value and $FF); end; function DWordToBinaryStr(value:DWord):string; begin result := WordToBinaryStr(value shr 16)+' '+WordToBinaryStr(value and $FFFF); end; procedure TForm1.Button1Click(Sender: TObject); var i:integer; begin Memo1.Lines.BeginUpdate; Memo1.Clear; for i := $12345678 to $12345700 do Memo1.Lines.Add(Format(' %.3d = %s = $%.2x',[i,DWordToBinaryStr(i),i])); Memo1.Lines.EndUpdate; end;
実行すると右図のようになります。ここでは、32ビットの数値をいくつか変換して確認しています。ここまでで、整数値は、期待通りのビット表現になっている事が確認できたでしょう。
さて、これまでに数値を二進数表記するための関数を作成してきました。この逆の関数もつくってみましょう。まず、4ビットの二進数表記を変換する関数をつくりましょう。これは、例えば '1101' の文字列を与えると、13 を返すような関数です。これは、もっとも素朴に考えると、文字列の1番目が '1' だったら、8を足し、2番目が '1' だったら4を足し、.... というふうにするとできるでしょう。やってみましょう。
List 7
function NibbleStrToNibble(s:string):Byte; begin result := 0; if Length(s) <> 4 then exit; if s[1] = '1' then result := result + 8; if s[2] = '1' then result := result + 4; if s[3] = '1' then result := result + 2; if s[4] = '1' then result := result + 1; end; procedure TForm1.Button1Click(Sender: TObject); var i:integer; s:string; begin Memo1.Lines.BeginUpdate; Memo1.Clear; for i := $0 to $F do begin s := NibbleToBinaryStr(i); Memo1.Lines.Add(Format(' %.2d = %s = $%.1x',[i,s,NibbleStrToNibble(s)])); end; Memo1.Lines.EndUpdate; end;
実行すると右図のようになります。どうやらうまくいっているようです。もう少し、かっこうよくしてみましょう。
List 8
function NibbleStrToNibble(s:string):Byte; var i:integer; begin result := 0; if Length(s) <> 4 then exit; for i := 1 to 4 do begin result := result shl 1; if s[i] = '1' then Inc(result); end; end;
これは、上位ビットから '1' であればインクリメントし、それから次のサイクルで shl して左に1つずらしています。これを4回繰り返しています。前と同じようにして、うまくいくことが確かめられるでしょう。同様にして、8ビット、16ビット、32ビットについても関数を作成しましょう。
List 9
function ByteStrToByte(s:string):Byte; begin result := 0; if Length(s) <> 9 then exit; result := NibbleStrToNibble(Copy(s,1,4)) shl 4 + NibbleStrToNibble(Copy(s,6,4)); end; function WordStrToWord(s:string):Word; begin result := 0; if Length(s) <> 19 then exit; result := ByteStrToByte(Copy(s,1,9)) shl 8 + ByteStrToByte(Copy(s,11,9)); end; function DWordStrToDWord(s:string):DWord; begin result := 0; if Length(s) <> 39 then exit; result := WordStrToWord(Copy(s,1,19)) shl 16 + WordStrToWord(Copy(s,21,19)); end; procedure TForm1.Button1Click(Sender: TObject); var i:integer; s:string; begin Memo1.Lines.BeginUpdate; Memo1.Clear; for i := $12345678 to $12345700 do begin s := DWordToBinaryStr(i); Memo1.Lines.Add(Format(' %.d = %s = $%.8x',[i,s,DWordStrToDWord(s)])); end; Memo1.Lines.EndUpdate; end;
実行すると右図のようになります。
以上のように、変数に格納された数値は、我々が期待するような二進数表記の形で格納されていることが分かったでしょう。つぎに、ファイルやメモリにはどのような形で記録・記憶されているのか調べましょう。現在の PC は32ビットの CPU を使って動作していますが、メモリの位置を示すアドレス(番地)は、最初の PC の規格であった8ビットのものをそのまま使っており、1バイトごとにアドレスが割り振られています。ですから、メモリの記憶の最小単位は1バイトです。最初にメモリに1バイトの数値を記憶させ、それを取り出してみましょう。
List 10
procedure TForm1.Button1Click(Sender: TObject); var b1,b2:Byte; p:pointer; begin GetMem(p,SizeOf(Byte)); // メモリを確保 Memo1.Lines.BeginUpdate; Memo1.Clear; for b1 := $00 to $FF do begin Move(b1,p^,SizeOf(Byte)); // 書き込み Move(p^,b2,SizeOf(Byte)); // 読み出し Memo1.Lines.Add(Format(' %.3d = %s = $%.2x',[b1,ByteToBinaryStr(b2),b2])); end; Memo1.Lines.EndUpdate; FreeMem(p); // メモリを破棄 end;
実行すると右図のようになります。メモリへの書き込みと、メモリから変数への読み出しは Move() 関数を使っています。ヘルプから引用しておきます。
procedure Move(const Source; var Dest; Count: Integer); 説明 Move は Count バイトを Source から Dest にコピーします。範囲はチェックされません。 Move は,ソースとターゲットの領域が重なり合っている場合でも正しくコピーできます。 Count には,できる限り SizeOf 関数を使った値を指定してください。
第一、第二パラメータはポインタではなく、型なしの変数ですから、上記の例ではポインタの逆参照 p^ を使っています。うーむ、ポインタでは分かりづらいかも知れませんので、メモリストリームをつかって同じ事をしてみましょう。
List 11
procedure TForm1.Button1Click(Sender: TObject); var b1,b2:Byte; MS:TMemoryStream; begin MS := TMemoryStream.Create; // メモリストリームオブジェクトの作成 Memo1.Lines.BeginUpdate; Memo1.Clear; for b1 := $00 to $FF do begin MS.Position := 0; // 書き込み位置を指定 MS.Write(b1,SizeOf(Byte)); // 書き込み MS.Position := 0; // 読み込み位置を指定 MS.Read(b2,SizeOf(Byte)); // 読み出し Memo1.Lines.Add(Format(' %.3d = %s = $%.2x',[b1,ByteToBinaryStr(b2),b2])); end; Memo1.Lines.EndUpdate; MS.Free; // メモリストリームオブジェクトを破棄 end;
実行結果は前と同じになります。この方が分かりやすいかも知れませんね。それでは、2バイト = Word 型の数値について、同じ事をやってみましょう。ただし、メモリにどのように記憶されているかを知るためですから、書き込みは Word 型の変数を使い、読み込みは、記憶の最小単位であるバイトごとに行います。
List 12
procedure TForm1.Button1Click(Sender: TObject); var w:Word; i:integer; b1,b2:Byte; MS:TMemoryStream; begin MS := TMemoryStream.Create; // メモリストリームオブジェクトの作成 Randomize; Memo1.Lines.BeginUpdate; Memo1.Clear; for i := 1 to 10 do begin w := Random($10000); // ランダムな数値をセット MS.Position := 0; // 書き込み位置を指定 MS.Write(w,SizeOf(Word)); // 書き込み MS.Position := 0; // 読み込み位置を指定 MS.Read(b1,SizeOf(Byte)); // 1バイト目の読み出し MS.Read(b2,SizeOf(Byte)); // 2バイト目の読み出し Memo1.Lines.Add(Format(' $%.4x = $%.2x + $%.2x',[w,b1,b2])); end; Memo1.Lines.EndUpdate; MS.Free; // メモリストリームオブジェクトを破棄 end;
実行すると右図のようになります。驚いたことに、1バイト目と2バイト目の順序が反対になっていますね。メモリに記憶されている Word 型の値は、バイトごとに順序が入れ替わっています。Delphi が動作する Win32 の CPU は、インテル製あるいはその互換です。最初の8ビット CPU が、動作速度に有利なようにこのように複数バイトの数値を逆の順でメモリに記憶させていました。それをずっと継承して、32ビットになっても、やはり、この方式が続けられています。このような記憶方式は、リトルエンディアン( little endian )と言います。反対に、大きな位のバイトから順序よく記憶させる方式をビッグエンディアン( big endian )といいます。これはあくまでもメモリに記憶させる方式のことですから、変数などに読み込んだ結果は、どちらも上でやったように普通のビットイメージで格納されます。ですから、普通のプログラムを作成するときにはこの事を知らなくても何も問題はありませんが、メモリの内容や、後で示すようにファイルの内容をプログラムで調べるときには重要な知識となります。是非、頭の片隅にでも記憶をとどめておいてください。さて、32ビットの場合はどうなるのでしょうか。
List 13
procedure TForm1.Button1Click(Sender: TObject); var dw:DWord; i:integer; b:array[0..3] of Byte; MS:TMemoryStream; begin MS := TMemoryStream.Create; // メモリストリームオブジェクトの作成 Randomize; Memo1.Lines.BeginUpdate; Memo1.Clear; for i := 1 to 10 do begin dw := Random($FFFFFFFF); // ランダムな数値をセット MS.Position := 0; // 書き込み位置を指定 MS.Write(dw,SizeOf(DWord)); // 書き込み MS.Position := 0; // 読み込み位置を指定 MS.Read(b,SizeOf(Byte)*4); // 読み出し Memo1.Lines.Add(Format(' $%.8x = $%.2x + $%.2x + $%.2x + $%.2x', [dw,b[0],b[1],b[2],b[3]])); end; Memo1.Lines.EndUpdate; MS.Free; // メモリストリームオブジェクトを破棄 end;
実行結果は右図のようになります。このように、完全に順序が逆になっています。この結果から、DWord 型が、4バイト数値であるとして、バイトごとに取り出すときもリトルエンディアンであると見なすことができることに加えて、Word 型が2つだと解釈しても、Word 型のリトルエンディアンになっていると見なすことができます。つぎに、ディスクファイルにはどのように格納されているか確認しましょう。
List 14
procedure TForm1.Button1Click(Sender: TObject); var dw:array[1..10] of DWord; i:integer; b:array[0..3] of Byte; MS:TMemoryStream; FS:TFileStream; begin MS := TMemoryStream.Create; Randomize; MS.Position := 0; for i := 1 to 10 do begin dw[i] := Random($FFFFFFFF); MS.Write(dw[i],SizeOf(DWord)); end; MS.SaveToFile('c:\Test.abc'); MS.Free; FS := TFileStream.Create('c:\Test.abc',fmOpenRead); Memo1.Lines.BeginUpdate; Memo1.Clear; FS.Position := 0; for i := 1 to 10 do begin FS.Read(b,SizeOf(Byte)*4); Memo1.Lines.Add(Format(' $%.8x = $%.2x + $%.2x + $%.2x + $%.2x', [dw[i],b[0],b[1],b[2],b[3]])); end; Memo1.Lines.EndUpdate; FS.Free; end;
ここでは、10個のランダムな DWord 型の数値をメモリストリームに書き込み、ファイルに保存しています。そのファイルをファイルストリームを使って、4バイトごとに読み込んで表示することを10回繰り返しています。結果は右図の通りです。メモリの場合と同じですね。メモリイメージとファイルイメージが違っていると、読み書きするときに相互に変換するための演算が必要になり、処理が低速になります。ですから、メモリイメージとファイルイメージが同じなのは当然ですね。
最後に、ビット演算子としてビットごとの演算を試してみましょう。ヘルプには not or and xor の四つがあげられています。ビットごとの「真理値表」は右図のようになります。
not はビットの反転、or は論理和(どちらかが1なら1)、and は論理積(どちらも1なら1)、xor は排他的論理和(違っていれば1)ですね。ここで作った二進数表記をする関数を使ってこれらを試してみましょう。
List 15
procedure TForm1.Button1Click(Sender: TObject); var bA,bB,bC:Byte; begin Randomize; Memo1.Clear; bA := Random($100); bC := not bA; Memo1.Lines.Add(ByteToBinaryStr(bA)+' ( A )'); Memo1.Lines.Add(ByteToBinaryStr(bC)+' ( not A )'); Memo1.Lines.Add(''); bA := Random($100); bB := Random($100); bC := bA or bB; Memo1.Lines.Add(ByteToBinaryStr(bA)+' ( A )'); Memo1.Lines.Add(ByteToBinaryStr(bB)+' ( B )'); Memo1.Lines.Add(ByteToBinaryStr(bC)+' ( A or B )'); Memo1.Lines.Add(''); bA := Random($100); bB := Random($100); bC := bA and bB; Memo1.Lines.Add(ByteToBinaryStr(bA)+' ( A )'); Memo1.Lines.Add(ByteToBinaryStr(bB)+' ( B )'); Memo1.Lines.Add(ByteToBinaryStr(bC)+' ( A and B )'); Memo1.Lines.Add(''); bA := Random($100); bB := Random($100); bC := bA xor bB; Memo1.Lines.Add(ByteToBinaryStr(bA)+' ( A )'); Memo1.Lines.Add(ByteToBinaryStr(bB)+' ( B )'); Memo1.Lines.Add(ByteToBinaryStr(bC)+' ( A xor B )'); end;
結果は右図のようになります。Button1 を何度も押していろいろな値に対して試してみてください。ここでは8ビットの値を試しましたが、4ビット、16ビット、32ビットでも同じ論理が成り立ちます。
以上、ここでは、ビットの演算について、また、その格納方式についていろいろ調べ実際に試しました。