複天一流:どんな手を使ってでも問題解決を図るブログ

宮本武蔵の五輪書の教えに従い、どんな手を使ってでも問題解決を図るブログです(特に、科学、数学、工学の問題についてですが)

東大数学2024問題6 (part 4): ポインタを使って整数表現を調べる

前回のあらすじ

素数を生成する「エラトステネスの篩」アルゴリズムに従って、自分でコードを書いてみようと思ったわけだが、どの程度まで大きな整数が数値的に扱えるのかを確認する必要があると感じ、C言語FORTRANについて調べてみたのが前回である。

C言語についてはlimits.hというヘッダーファイルがシステムに置かれていて、それを参照するだけでよかったのだが、Fortran95に関してはそのようなファイルはなく、計算を用いて確認することになった。128bit表現までが可能で、垓の桁を遥かに凌駕するまで計算することができることがわかった。

一方、Pythonについては「制限がない」という噂をつかみ、今回はその確認を行う予定である。

しかし、その前に、ポインタを使って整数表現が何ビットで表されているか調べる方法を思いついたので、まずはそちらについてメモっておきたい。

C言語におけるポインタの利用

C言語はOS基部やコンピュータシステムそのものにアクセスできる言語であり、本来は科学技術計算というよりは、システム構築のために利用されてきた。以前USB接続機器の制御プログラムを書いたことがあるが、もちろんCで書いた。FORTRANでは不可能だろう(しかし、PythonならRaspberry PIでハードウェア制御することができる!)。

そのため、メモリ管理についてはどんな素人プログラマーでも(理論上は)制御することができる。しかし、その代償として、素人が迂闊なプログラムを書くことで、システム全体の脆弱性を高めてしまったりする危険性もあるから注意が必要だ。

今回は、メモリリークやネットワークハックなどの問題とは無縁のコード内容であるため、それほど緊張する必要はないが、メモリ管理の基本に触れる内容であり、ある意味C言語の「一番危ないところ」の初歩を理解する上では重要な内容である。

まず普通の整数型変数を定義する。この時、配列として定義すると、メモリ上に連続して配置してくれるので計算し易くなる。

limits.hを利用した前回の分析で、普通の整数型は32bitで表現されることがわかった。情報関係でよく利用される単位byteは、8ビット)で表される最大整数28=256の情報量を指しているようである。 \begin{equation} \text{1 byte} \leftrightarrow \text{8 bit} \end{equation}

したがって、C言語の32ビット整数表現は4byteの情報量をもつことになるが、これは28*4=232=4294967296種類の数を取り扱えるという意味になる。

整数の32bit表現をFORTRANでは"integer*4"と宣言するが、これは「4バイトの整数表現という」意味でもあったのだ。いまさらながら、納得である。

さて、では実際に確かめてみよう。

#include <stdio.h>

int main(){
  int qint[5];
  unsigned int uint[5];

  printf("%p %p\n",&qint, &qint[1]);
  printf("%p %p\n",&uint, &uint[1]);
}

qintは4byteつまり32ビット表現の整数である。配列とはメモリ中の連続した情報のことである。したがって、整数型の配列であるqintの先頭アドレスと2つ目のアドレスの間には32ビット(4バイト)の間隔がメモリ中にできていると予想される。実行してみると、

0x16f86f6e4 0x16f86f6e8
0x16f86f6d0 0x16f86f6d4

となった。すべての数は16進数表現である(数字の先頭に0xがつくと16進数という意味になる)。最初の列は配列先頭のアドレスで、2つ目の列が配列2つ目のアドレスである。したがって、その差が整数表現の大きさに対応する。ちなみに、1行目は32ビット表現の場合、2行目が符号なしの32ビット表現の場合である。

最初の行の数字を使って、アドレス間の間隔を計算してみる。数字が長いので下2桁のみを引用する。最初のアドレスから2つ目のアドレスまでを「指折り数えると」、e4,e5,e6,e7,e8となるから、2つのアドレスの感覚は「4」だけ空いている。これは4byteの差、つまり4x8=32ビットの間隔が空いていることに相当する。言い換えれば、一つの整数を格納するのに32ビット必要であるという意味である。

次に2行目のアドレスも見てみる。d0, d1, d2, d3, d4であるからやっぱり4byteの間隔である。これは「符号なし」の場合であるが、符号ありとなしの場合は、先頭ビットを符号に当てるかどうかの違いであるから、一つの数字を表現するビット数(バイト数)は共通になるのは当然である。

16進数の計算は慣れてないと面倒だし、不要に難しく見える。じゃあ10進数でやってみたらいいじゃないか、という意見が飛んできそうであるが、これは不可能ではない。printfの識別子%pを%dに変えるだけの話である。10進数の方が読みやすいと思う人はこれでいいと思う。ただ、gccコンパイルしようとするとwarningが出てしまう。どうもコンピュータ関係の人は、10進数が嫌いのようである(もしかすると、理論上では明確な違いがあるのかもしれないが、素人に近い私にとってはその機微については予想すらもできないのである)。

1840707300 1840707304
0x16db6f6e4 0x16db6f6e8

(同じ内容を上の行では10進数で、下の行では16進数で表したもの。どちらも4バイトの間隔がある。)

配列のアドレスは、厳密には「ポインタ」ではない。ポインタの宣言の仕方は

int *pntr;

である。ポインタというのは「メモリの塊」の先頭部分のアドレス情報のことであるから、実質、配列の先頭のアドレスだと解釈しても(一般のプログラマにとっては)実害はないだろう。OSやカーネルの開発をやっているプログラマからしてみれば、ポインタの実装については配列と違うかもしれないから、区別して理解するのは重要だろう。実際、ポインタが示すアドレス情報自体は、配列とは無関係のアドレスに格納されている。上のプログラムでポインタを使って、整数の情報を引き出す場合には、たぶん次のようにやるのだと思う。

#include <stdio.h>

int main(){
  int qint[5];
  int *pntr;
  
  printf("%p %p\n",&qint, &qint[1]);

  printf("%p\n",pntr);
  ++pntr;
  printf("%p\n",pntr);

  pntr = qint;
  printf("%p %p \n", pntr,qint);

  ++pntr;
  printf("%p %p \n", pntr,qint);

}

実行結果は次のようになる。

0x16ef4f6e4 0x16ef4f6e8
0x100ebc060
0x100ebc064
0x16ef4f6e4 0x16ef4f6e4 
0x16ef4f6e8 0x16ef4f6e4 

最初の行は、これまでのプログラムと同じ内容である。整数型の配列の先頭と2つめのアドレスである。その間隔は4バイト、つまり32ビットである。

次に、ポインタ $*pntr$が定義され、そのアドレスをそんまま印字させている。結果は0x100ebc060となり、整数型の配列とは無関係の、新しいアドレスが表示されている。これが宣言当初にポインタが割り当てられたアドレスであるが、どこも向いてない無意味なアドレスである。ただし、この無意味なポインタを一つ「横に」ずらすと($++pntr$)、整数一つ分だけ「ずれる」ことが確認でき、そのずれ幅は4バイト(32ビット)になっていることがわかる。

最後に、ポインタを配列の先頭にセットする。ポインタが示す「方向」は配列qintの先頭であり、「意味のある方向」を向いたことになる。ポインタを印字すると、配列の先頭アドレスに一致していることがわかる(0x16ef4f6e4)。

ポインタを一つ「横にずらす」と、qint[1]のアドレスにポインタの位置はずれる(0x16ef4f6e8)。そのずれ幅は、またもや4バイト(32ビット)である。

Fortran95における「ポインタ」の利用

C言語と同じようなことをFortran95でやってみたいと思っても、なかなか難しい。というのは、Fortranの本質は、システムの詳細にあまりこだわらなくても、簡単に数値計算が実施できることだからである。したがって、意図的にメモリ構造やそのアドレス情報といったものにアクセスできないように言語が設計されている。

しかし、時にはメモリ構造を念頭に置きながらコーディングした方が、効率的なプログラムを書ける場合がある。その場合、Fortran95でも、どのように変数がメモリ空間に配置されているか知りたいケースも出てくるだろう。

いろいろ調べてみると、「非正規な」関数ではあるが、loc()という関数が利用できることがわかった。C言語のようにポインタ操作ができるわけではないが、配列や変数のアドレスを印字する関数だというから、C言語の&に相当するものである。

Fortranの場合は、4byte表現, 8byte表現, 16byte表現を一気に確認してみたい。使ったコンパイラgnu gfortranであるが、非正規の内部関数loc()を含んでいた。

program pointer_address
  implicit none
  integer :: ad1, ad2
  integer, dimension(5) :: quadInt
  integer*8, dimension(5) :: octInt
  integer*16, dimension(5) :: hexInt

  ad1 = loc(quadInt(1))
  ad2 = loc(quadInt(2))
  print '(z10 z10 i4)',ad1, ad2, ad2-ad1
  print *

  ad1 = loc(octInt(1))
  ad2 = loc(octInt(2))
  print '(z10 z10 i4)',ad1, ad2, ad2-ad1
  print *

  ad1 = loc(hexInt(1))
  ad2 = loc(hexInt(2))
  print '(z10 z10 i4)',ad1, ad2, ad2-ad1
  print *
end program pointer_address

Fortran95で16進数表現の表示をする場合は、フォーマットで指定する(記号zをprint文で利用)。

計算結果は次のような感じになる。

  6DCFF6C0  6DCFF6C4   4

  6DCFF6D8  6DCFF6E0   8

  6DCFF700  6DCFF710  16

配列の先頭と2つ目の間が、メモリ空間中でどのくらい空いているか計算したものである。予想通り、4byte, 8byte, 16byteとなり、整数の表現が32bit, 64bit, 128bitになっていることが確認できた。

Pythonの場合

Pythonにもloc()に似た関数が存在し、id()という。同じようなことをやってみよう。ただし、Pythonでは整数はデフォルトで無制限に扱えるため、64bit表現とか32bit表現とか、配列の宣言で規定する必要がない。これがPythonが人気があり、他の言語に比べて優れている点であろう。とにかく、変数型の宣言をしないとプログラムというのは、こんなにも簡単になるのである!

i1=[1,2]
ad1=id(i1[0])
ad2=id(i1[1])
print(hex(ad1),hex(ad2),int(ad2)-int(ad1))

16進数表示するための関数hex()を利用して印字しているので、結果は次のようになった。

0x101738e48 0x101738e68 32

アドレスの引き算(差)は0x20であるから、これを10進数に変換すると$2\cdot 16^1+0\cdot 16^0=32$となる。

つまり、Pythonでは最初から32byte表現、つまり256 bit表現を採用しているらしいのである。

ある意味、これはメモリ容量をかなり逼迫するような設計である。たった一つの整数を保存するだけでも256ビットを取られてしまうのである。明らかに、pythonは現代の計算機環境に根ざした「資産が潤沢なプログラマ向け」の言語である。