floatのビットパターンを表示する可搬なC++プログラムとは
この記事を読むと…
- バイト (byte)、ビット(bit)、
unsigned char
との関係 - オブジェクト表現 (object representation)
- 値表現 (value representation)
- トリビアルコピー可能な型 (trivially copyable type)
-
std::memcpy
は何をやっているのか - パディングビット
- strict aliasing rule
- エンディアン (endian) / バイトオーダー (byte order)
- 国際標準規格と慣習とのギャップ
これらの事柄について、理解できるようになります。
はじめに
IEEE 754 binary32[1]で実装されたfloat
のビット表現を出力するプログラムを作成したい。論理&演算によってビットを出力するが、今回は話の都合上unsigned int
がfloat
と同じバイト数であり、これを使用すると仮定する。
解答の一つとして、下記のようなものが考えられる。
#include <iostream>
#include <cstring>
#include <cstdint>
#include <climits>
#include <limits>
static_assert(sizeof(float) * CHAR_BIT == 32);
static_assert(std::numeric_limits<float>::is_iec559);
static_assert(std::numeric_limits<float>::digits == 24);
static_assert(std::numeric_limits<float>::radix == 2);
static_assert(std::numeric_limits<float>::max_exponent == 128);
int main() {
float x = 1.0f;
std::uint32_t buf;
std::memcpy(&buf, &x, sizeof(float));
for (int i = 31; i >= 0; i--) {
std::cout << ((buf & (1U << i)) ? '1' : '0');
if (i == 31 || i == 23) {
std::cout << ' ';
}
}
std::cout << "\n";
}
float
と同じ大きさのuint32_t
に内容をmemcpy
でコピーして、あとはビット演算を施すことでビットパターンを表示するというものであり、実行結果は下記のようになるはずである。
0 01111111 00000000000000000000000
このようなプログラムによって、float
(binary32)のビット表現を得ることができたが、これはたまたま上手くいっただけなのだろうか。なぜstd::memcpy
をすれば、float
のビットパターンがそのままuint32_t
に転写できたのだろうか。float
やuint32_t
ではなくて、他の型でも同じことができるのだろうか。
この手のメモリ周りを直接操作する話は色々と落とし穴がある。このあたりの理解があやふやになりがちだが大事なC/C++プログラミングの基礎的な部分を、国際標準規格に書かれた内容から丁寧に確認していく。このような一見シンプルなプログラムであっても正確に理解するには多少の分量が必要となる。
参照先として、今回はC++14の国際標準規格の最終ドラフトn4140を使用する。またC++はCに基づくことから、C++14の"normative reference"であるC99の最終ドラフトn1256を使用する。
C++の言語規格において固有の意味合いを持つ用語については、初出時にはイタリック体と日本語訳で「object(オブジェクト)」のように表現し、日常用語ではないことを分かるように記載する。二回目以降は、日本語訳をそのまま書くため注意されたい。また、今回の解説に必要な内容に絞って引用し、細かい例外などについては記述が煩雑となるため省略する。脚注についても、あわせて読んでもらうと理解が深まると考える。
メモリとは何か
まず、変数を格納しているメモリとはC/C++ではどのように位置づけられているのか、ここから確認する必要がある。
[intro.memory] にはこのような記載がある。
- ストレージの基本単位はbyte(バイト)と呼ぶ。
- バイトは、少なくともUTF-8の8ビットコードユニットを格納できる大きさでなければならない。
- バイトは、連続したビットによって構成され、そのビット数は処理系定義[2]である。
- バイトは、それぞれ固有のアドレスを持つ。
メモリはそれぞれがアドレスで特定できるバイトから構成されて、1バイトはいくつかのビットによって構成されているようである。そして1バイトは8ビットコードユニットを格納できるので、少なくとも8bitであることが分かる。
オブジェクトとは何か
次に、C/C++における変数がそのバイトというものにどのように格納されるのかを確認する。
[intro.object] にはこのような記載がある。
- object(オブジェクト)[3]とはストレージの領域のことである。
- オブジェクトはdefinition(定義)によって構築される。
- オブジェクトはname(名前)を持つことができる[4]。
- オブジェクトはtype(型)を持つ。
- object type(オブジェクトの型)とは、そのオブジェクトが構築された型を意味する。
- オブジェクトのアドレスとは、オブジェクトが占有する最初のバイトのアドレスのことである。
オブジェクトとはいわゆる変数、名前というのはいわゆる変数名のことである。前節と合わせると、オブジェクトは基本単位をバイトとしてストレージの一部を占有しているということになる。
オブジェクト表現、値表現、パディングビットとは何か
さらに読み進めると[basic.types] にはこのような記載がある。
-
トリビアルコピー可能な型を持つオブジェクトは、それを構成するバイト列を
char
またはunsigned char
[5]の配列にコピーすることができる。char
またはunsigned char
の配列から、コピーされた内容をオブジェクトにコピーし戻した場合、オブジェクトは元の値を復元することができる。- コピーの方法の例として、
std::memcpy
やstd::memmove
が使用できる[6]。
- コピーの方法の例として、
- オブジェクトのobject representation(オブジェクト表現)とは、オブジェクトが占有しているN個の連続した
unsigned char
型のオブジェクトのシーケンスのことである。Nは、オブジェクトの型をT
とするとsizeof(T)
である。-
sizeof
とはオブジェクト表現が占有するバイト数を返す演算子であり、sizeof(unsigned char)
は1である[7]。
-
- オブジェクトのvalue representation(値表現)とは、オブジェクトの型の値を保持するビットの集合のことである。
- トリビアルコピー可能な型においては、値表現はオブジェクト表現に含まれる。
- value(値)とは、実装定義である値集合の中の1つの離散的な要素である。
つまり、オブジェクト(表現)が占有しているストレージというのは、1つあたり1バイトのunsigned char
型のオブジェクトの配列として見なすことができるようである。オブジェクト(表現)がNバイトなのであれば、それはunsigned char [N]
と見なすことができる[8]。
そしてトリビアルコピー可能な型であれば、オブジェクトのバイト列はstd::memcpy
などによってunsigned char
型の配列に、元の値を復元できる形で可逆的にコピーが可能である。なぜなら、トリビアルコピー可能な型T
の値を決定するビット集合(=値表現)は、オブジェクトが占有するバイト列(=オブジェクト表現)に含まれるからである。float
やuint32_t
がトリビアルコピー可能な型であるかどうかは重要な情報であり、後のセクションで確認する。
さて、オブジェクト表現と値表現という二種類の用語が登場したのでその違いを明確にしよう。例えば、1バイトが8bitのシステムにおいて、unsigned short
が(厭らしいことに)12bitの精度を持つ型であった仮定しよう。先ほどの説明から、あらゆるオブジェクトは整数バイトのストレージを占有するのであった(1.5バイトのみの占有は許容されない)。従って、このunsigned short
を格納するために必要なストレージは少なくとも2バイトであり、実際に2バイトを占有するものとする。その場合、sizeof(unsigned short)
は2となる。
この場合、オブジェクト表現とはunsigned short
がストレージ上を占有している2バイトのことであり、値表現とはunsigned short
がその整数値を決定するために必要な12bitのことである。このようにオブジェクト表現と値表現とでは扱っているビット数に差が生じることがあり、この差をパディングビットと言う。
unsigned short x = 0xFFF;
┌─────────┐┌─────────┐
│ byte[1] ││ byte[0] │ <--- オブジェクト表現は2byte=16bit
└─────────┘└─────────┘
**** 1111 1111 1111 <--- 値表現は12bit (*を除く)
8192 <--- xの値は値表現から決定される
分かりやすいように右側に最下位バイトを記載する。
****がパディングビットである。
トリビアルコピーというのは、本当に必要な情報(値表現に関わるビット)がどこにあるのかには全く関知せずに、オブジェクト表現であるunsigned char
のシーケンス単位で無心でコピーするということである。パディングビットがあろうがなかろうが関係はない。トリビアル(trivial)は「取るに足らない」「些細な」を意味し[9]、この性質をよく表している。
実際に、そのトリビアルコピーをしてくれるstd::memcpy
は何をやっているのだろうか。その宣言はこの通りである。
void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
このように、受け取るときの型はvoid*
であって元の型が何であったのかの情報は落ちている。std::memcpy
からすれば、元々が何の型だったのか一切知ることなく、単にオブジェクト表現であるバイト列を全コピーするだけである。
std::memcpy
の疑似実装は下記のようなものとなっており、受け取ったポインタをunsigned char*
[10]と見なし、unsigned char
型の配列のコピーをひたすら行うというものとなっている。実際には、処理系ごとに最適化が施されることによって高速化が捗られている。
void* memcpy(void* dest, const void* src, size_t n) {
unsigned char* d = static_cast<unsigned char*>(dest);
const unsigned char* s = static_cast<const unsigned char*>(src);
for (size_t i = 0; i < n; ++i) {
d[i] = s[i];
}
return dest;
}
さて、これには注意が必要である。なぜなら、今回は単にfloat
からuint32_t
にバイトをコピーしたいのではなく、binary32の表示に必要なビットの位置を特定して論理&で演算をする必要があるからだ。もしfloat
にパディングビットがある場合、表示に不要なビットもコピーされることからコピー先のuint32_t
においてどのビットがパディングビットであるかを特定する必要があり、計算が煩雑になる。またuint32_t
にパディングビットがある場合、そもそも必要なビット位置で論理&演算ができない可能性がでてくる。従って、両者ともにパディングビットがないことを確認する必要がある。
まずfloat
については、float
のバイトサイズ(sizeof(float)
)とバイトあたりビット数(CHAR_BIT
)の積からfloat
に使用されているビット数を計算し、それが32と一致することを確認することで、binary32で実装されていることを前提としてパディングビットが存在しないことを保証できる。
// binary32で実装されている場合、floatにはパディングビットがないことが保証される
static_assert(sizeof(float) * CHAR_BIT == 32);
つぎにuint32_t
であるが、Cの言語規格「7.18.1.1 Exact-width integer types」において、uint32_t
はパディングビットがない32bit符号無し整数型であることが規定されている[11]。従って、uint32_t
についてはこれ以上に確認すべき内容はない。
float
、uin32_t
はトリビアルコピー可能なのか
それでは、float
やuint32
がトリビアルコピー可能な型であることを確認する。
[basic.fundamentals] にはこのような記載がある。
-
signed char
、short int
、int
、long int
、long long int
はstandard signed integer(標準符号付き整数)型である。これ以外に、処理系定義のextended signed integer(拡張符号付き整数)型があってもよい。これら両者を合わせて、signed interger(符号付き整数)型と呼ぶ。 -
unsigned char
、unsigned short int
、unsigned int
、unsigned long int
、unsigned long long int
はstandard unsigned integer(標準符号無し整数)型である。これ以外に、処理系定義のextended unsigned integer(拡張符号無し整数)型があってもよい。これら両者を合わせて、unsigned integer(符号無し整数)型と呼ぶ。 - 符号無し整数型は、その値表現に関わるビット数をnとしたときに
を法とした算術法則に従う。2^n -
bool
、char
、char16_t
、char32_t
、wchar_t
、符号付き整数型、符号無し整数型を合わせてintegral[12](整数)型と呼ぶ。 - 整数型は、"pure binary numeration system"(純粋な2進法)として値を定義しなければならない。すなわち、2の補数表現 ("2's complement")、1の補数表現 ("1's complement")、符号付き絶対値表現 ("signed magnitude representation")のいずれかでなければならない。
-
float
、double
、long double
は、floating point(浮動小数点)型である。 - 浮動小数点型の値表現は、処理系定義[13]である。
- 整数型と浮動小数点型は、arithmetic(算術)型である。
float
は浮動小数点型であり、従って算術型であるということのようだ。また、uint32_t
については先ほど言及したように符号無し整数型であり、従って同様に算術型ということになる。
ここでもう一度、[basic.types] に戻ってみると、更にこのような記載がある。
- 算術型、列挙型、ポインター型、メンバへのポインタ型、
std::nullptr_t
、そしてこれらのcv修飾は、あわせてscalar type(スカラー型)と呼ぶ。 - CV非修飾のスカラー型、トリビアルコピー可能なクラス、これらの配列、これらの非volatile[14]・const修飾型は、あわせてtrivially copyable types(トリビアルコピー可能な型)と呼ぶ
従って、float
とuint32_t
はいずれもスカラー型であり、よってトリビアルコピー可能な型であるということが確定する。だから、float
とuint32_t
を構成するバイト列はunsigned char
型の配列との間で可逆的にコピーすることができる。
// floatはトリビアルコピー可能な型である
float x = 1.0f;
// 従って、floatはunsigned charの配列にコピーすることができる。
unsigned char buf[sizeof(float)];
std::memcpy(&buf, &x, sizeof(float));
さらに、重要なことの一つとして整数型についてそれを構成するビット列との対応が規定されている。整数型が2進法に従っていることは重要である。なぜなら、この記述によって整数型のビットパターンとそれが表す数字との間に一対一の関係が定義されるからである[15]。今回の例では、1U << i
のように1という数字を左論理シフトすることで所望の位置のビットを論理&で抽出するためのビットマスクを生成しているが、これは1が'0000 0001'のように最下位ビットだけ1であるようなビットパターンになることを前提としているため、このルールが必要となってくる。
まとめ
以上をまとめると、もともとのコードが上手く動く理由は下記のようにコメントをした通りになる。
#include <iostream>
#include <cstring>
#include <cstdint>
#include <climits>
#include <limits>
// binary32に従う場合にfloatのパディングビットがないことを確認する
static_assert(sizeof(float) * CHAR_BIT == 32);
// floatがIEEE 754規格に適合することことを確認する
static_assert(std::numeric_limits<float>::is_iec559);
// 以下の3つによってfloatがIEEE 754の中でも
// 特にbinary32として実装されていることを確認する
static_assert(std::numeric_limits<float>::digits == 24);
static_assert(std::numeric_limits<float>::radix == 2);
static_assert(std::numeric_limits<float>::max_exponent == 128);
int main() {
float x = 1.0f;
std::uint32_t buf;
// &bufと&xによってそれぞれの最初のバイトアドレスを取得する
// floatとstd::uint32_tはトリビアルコピー可能な型であるため
// std::memcpyによってバイト表現をそのままお互いにコピー可能
std::memcpy(&buf, &x, sizeof(float));
for (int i = 31; i >= 0; i--) {
// 転写されたbufの型であるstd::uint32_tは二進法を採用しているため、
// ビット演算によって所望の位置のビットを抽出できる。
std::cout << ((buf & (1U << i)) ? '1' : '0');
if (i == 31 || i == 23) {
std::cout << ' ';
}
}
std::cout << "\n";
}
めでたしめでたし。
何かひっかからないだろうか…?
よくよく考えてみると、なぜfloat
からuint32_t
にバイトコピーができたのだろうか。先ほどまでの説明で納得していればそれでもよいが、ここまで言及した国際標準規格の内容を厳密に解釈すれば、トリビアルコピー可能な型とunsigned char
の配列との間で可逆的にオブジェクト表現のコピーができると言っているだけで、任意の異なるトリビアルコピー可能な型の間で可逆的にオブジェクト表現のコピーができるとまでは言っていないように見える。
float x = 1.0f;
std::uint32_t buf;
static_assert(sizeof(float) == sizeof(std::uint32_t));
std::memcpy(&buf, &x, sizeof(float)); // 本当にOK?
どうやら、自分と同じような疑問を持っている人もいるようである。
これについて、自分が調べた限りでは国際標準規格はストレートに答えを述べていないように見える。先ほどのstd::memcpy
の仕様に踏み込むことで初めてこのような操作も認められると解釈するということだろうか。また、そもそもこれが認められなければ低レイヤーのプログラミングが不可能になるであろうから、これまでの慣習も含めて暗黙的に認められているということなのだろうと解釈することにする。
その他の方法について
上記で紹介したもの以外に、たとえばunsigned int
やunsigned char []
へのコピー、もしくはコピーではなくポインタのキャストによってfloat
のビットパターンを別の整数型として直接参照する方法などが考えられる。これらの手法を採用する際に、注意すべきことを以下に挙げる。
unsigned int
をコピー先とする場合
まずunsigned int
を構成するビット数が32ビットであれば[14:1]、float
が余分なスペース無しでコピーできることが保証される。
static_assert(sizeof(unsigned int) * CHAR_BIT == 32);
float x = 1.0f;
unsigned int buf;
std::memcpy(&buf, &float, sizeof(x));
しかしながら、これではパディングビットを含んでいる可能性を排除できておらず、後のビット演算時に精度不足によって期待しない結果をもたらす可能性がある。厳密にはunsigned int
が32ビットの数値をすべて表現できることを別途確かめる必要がある。
一例として、unsigned intの最大値が2^32-1=4294967295であることをUINT_MAX
などで確かめれば大抵の場合はよいであろう。
static_assert(UINT_MAX == 4294967295); // これで本当に良い?
しかしながら、これは処理系が4294967295
を表現できる整数型を持っている場合に限られるため(たとえばunsigned long long
でさえも16bitなシステムがあるかもしれない)、厳密さを期すのであればUINT_MAX
を1右シフト演算していって1が何ビット立っているのか計測するプログラムを組むのがよいであろう。詳細はCERTのINT35-Cを参照されたい。
unsigned char
の配列をコピー先とする場合
[basic.fundamental]に記載があるが、unsigned char
はナロー文字型であり、全てのビットが値表現に関わっていることが定められている。従って、追加で確認する内容はない。ただし、後述のエンディアンの問題については対応する必要がある。
std::memcpy
ではなくポインタキャストによる場合
この手の処理をするときに、std::memcpy
によるコピーを介さずに、float
のアドレスをuint32_t
へのアドレスと見なして逆参照をすることで、直接的に変換する方法が思いつく。
static_assert(sizeof(float) * CHAR_BIT == 32);
float x = 1.0f;
uint32_t y = *((uint32_t *)(&x)); // Undefined behavior!
しかしながら、これは未定義動作である。なぜなら、C++の規格によれば「プログラムがオブジェクトが持つ値を、以下の型を除くglvalue[16]を通してアクセスする場合の動作は未定義である」[17]としているからである。
「以下の型」とは、
である。今回の例では、float
の動的型はfloat
であり[18:1]、float
のアドレスは他の型のアドレスと再解釈して逆参照することはダメということである。これは一般に"strict aliasing rule"と呼ばれるもので、CERTのEXP39-Cにも記載がある。詳しくは、[20][21]などで詳しく解説されているが、平たくいえば「そんな変なことはしないことを仮定することでコンパイラを最適化したい」というのが理由である。
しかしながら、上述のようにunsigned char
は例外である。従って、unsigned char *
と見なしてそれを配列アクセスするように手法を変更することにより問題を解消できる。(そもそもこれは、std::memcpy
がやっていることと同じである。)
ただしこの場合も、エンディアンの問題があるため後述の対応が必要となる。
static_assert(sizeof(float) * CHAR_BIT == 32);
float x = 1.0f;
unsigned char * buf = (unsigned char *)(&x);
for (int i = 0; i < sizeof(x); i++) {
// buf[i]にアクセス
}
補足
…と書いておいてあれだが、C++17ではこれは未定義動作になるような変更をしてしまったとかなんとかで、改善提案が行われているとのこと。少なくともC++29以降になりそうで、マジで言っているのかという感じ。
エンディアンについて
unsigned char
の配列をコピー先に用いてビットパターンを表示する方法を取る場合、元のfloat
の最上位バイトが配列の最初に来るのか、最後に来るのかを決定する必要がある。これはエンディアン、ないしはバイトオーダーと呼ばれる類の問題である[22]。x86-64ではリトルエンディアンであるが、他の処理系ではビッグエンディアンを採用しているものもある。
これに対応するためには、例えば2バイト以上の整数型で1を格納した変数を定義し、そのアドレスをchar *
ないしはunsigned char *
に変換した際に、最初のバイトが1と解釈されるかどうかで決定できる。最初のバイトが1であればリトルエンディアン、最後のバイトが1であればビッグエンディアンである[23]。従って、下記のような関数を用意すればよい。なお、C++20以降であればstd::endianによって決定できる。
bool is_little_endian() {
static_assert(sizeof(int) > 1);
int x = 1;
return *reinterpret_cast<char *>(&x) == 1;
}
あとは、エンディアンに従ってアクセスするべき配列位置を切り替えることで問題に対処できる。(実行結果)
#include <iostream>
#include <cstring>
#include <cstdint>
#include <climits>
#include <limits>
static_assert(sizeof(float) * CHAR_BIT == 32);
static_assert(std::numeric_limits<float>::is_iec559);
static_assert(std::numeric_limits<float>::digits == 24);
static_assert(std::numeric_limits<float>::radix == 2);
static_assert(std::numeric_limits<float>::max_exponent == 128);
bool is_little_endian() {
static_assert(sizeof(int) > 1);
int x = 1;
return *reinterpret_cast<char*>(&x) == 1;
}
int main() {
float x = 1.0f;
unsigned char * bytes = reinterpret_cast<unsigned char *>(&x);
static_assert(sizeof(float) == 4);
static_assert(CHAR_BIT == 8);
bool little_endian = is_little_endian();
for (int i = 31; i >= 0; i--) {
unsigned char byte = little_endian ? bytes[i / 8] : bytes[3 - i / 8];
std::cout << (byte & (1U << (i % 8)) ? '1' : '0');
if (i == 31 || i == 23) {
std::cout << ' ';
}
}
std::cout << '\n';
}
エンディアン問題に正しく対処できているかどうかは、異なるエンディアンを持つシステムないしはそのエミュレータでプログラムをビルド・実行する必要がある。Linux (Ubuntu)であれば、下記のようにQEMUを使用することで、x86-64 (リトルエンディアン)とMIPS(ビッグエンディアン)の両方をエミュレートすることができる。
# x86-64での実行
x86_64-linux-gnu-g++ test.cpp && qemu-x86_64 ./a.out
# MIPSでの実行
mips-linux-gnu-g++ -static test.cpp && qemu-mips ./a.out
両方のエンディアンに対応したプログラムであれば、実行結果は同じとなる。
0 01111111 00000000000000000000000
0 01111111 00000000000000000000000
C++20であれば…
std::bit_castをstd::memcpy
の代わりに使用可能である。バイトサイズが一致することをテンプレートパラメータ制約で保証してくれるため、使い勝手が少しいい。入出力の型がトリビアルコピー可能な型でなければいけないという事情は同じであるため、std::memcpy
を簡略化したものだと捉えるとよい。
float x = 1.0f;
// before C++20
std::uint32_t buf;
std::memcpy(&buf, &x, sizeof(float));
// From C++20
const std::uint32_t buf = bit_cast<std::uint32_t>(x);
-
詳しくは以前の記事参照: https://zenn.dev/misokatsu6/articles/e753e1a072c049 ↩︎
-
従って、バイトは8bitではなく、例えば9bitであってもよい。 ↩︎
-
オブジェクト指向プログラミングでいうところのオブジェクトではない ↩︎
-
関数の戻り値などのように名前を持たないオブジェクトも存在する ↩︎
-
従って、
signed char
が同等にコピーできるかどうかは不明である。 ↩︎ -
C++17からは、
std::byte
がunsigned char
の強い型付けとして定義された。unsigned char
とバイトは本質的に同じなのである。 ↩︎ -
余談だが、この単語をみると「トリビアの泉」を思い出すのは私だけだろうか。 ↩︎
-
unsigned char*
に変換してよい理由は後述の「ポインタキャストではダメなのか」で説明している ↩︎ -
ちなみにパディングビットがない
uint32_t
が存在するということは、1バイトが少なくとも32bitの約数であることが決まる。従って、1バイトが8bit、16bit、32bitのいずれかとなるが、そのいずれであろうとも今回のプログラムでは問題にはならない。 ↩︎ -
integralはintegerの同義語である ↩︎
-
今回は
float
がbinary32で実装されているものとして話を進めているが、全然違う実装はあり得る。 ↩︎ -
これは理論的な話ではなく、実際の話として
int
は32bitとは限らず、処理系によって16bitだったり64bitであることは十分ある話である。 ↩︎ ↩︎ -
ただし符号付き整数型については、上述のように負の整数の表現方法に関していくつかのオプションがあるため、今回のようにビット操作をする場合には符号無し整数型を使うほうが混乱が避けられてよい。 ↩︎
-
オブジェクトを参照する式(expression)のこと。ここでは、
*((uint32_t *)(&x))
がx
というオブジェクトを参照するuint32_t
型のglvalueであり、これがx
の型であるfloat
と適合しない型であるため、strict type aliasingに違反するということである ↩︎ -
話がややこしいが、C++の規格による定義はこの通りであり、glvalueが参照しているmost derived objectの型のことである。most dervived objectとはここに定義されているが、
class
/struct
/union
でない型は全てmost derived objectである。従って、float
は動的型である。 ↩︎ ↩︎ -
本稿とは関係はないが、この例外によってクラスにおいてはポリモーフィズムが実現できる ↩︎
-
論理的にはリトルエンディアンでもビッグエンディアンでもない環境が存在し得るが、現状ではほとんど見かけないため本稿では無視する。 ↩︎
Discussion