Erlangの型システムとタグ
この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-TypeSystem
翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22)
ERTSを理解するうえで最も重要な側面の1つが、ERTSがデータをどう保存しているか、すなわちErlangの項がメモリ上にどう格納されているかである。 これはガベージコレクションの仕組みやメッセージパッシングの仕組みを理解する土台になり、どれだけのメモリが必要になるかについての見通しも与えてくれる。
本章では、Erlangの基本的なデータ型と、それらがERTSでどう実装されているかを学ぶ。 この知識は、メモリ割り当てとガベージコレクションを扱う章(メモリ割り当て)を理解するうえで欠かせないものになる。
Erlangの型システム
Erlangは強く型付けされている。
つまり、ある型を別の型へ強制変換する方法は無く、ある型から別の型へ変換できるだけである。
たとえばCではcharをintに、あるいは任意のポインタ型をvoid *に強制変換できるが、これと比較するとよい。
Erlangの型の束はかなり平坦であり、それは図1からもわかる。 実際の部分型はごくわずかしかない。 数値には整数と浮動小数点数という部分型があり、リストにはnilとconsという部分型がある。 (タプルにはサイズごとに部分型があり、マップについても同様だという見方もできるだろう。)

図1: Erlangの型束。矢印の無い実線は、左側の型が右側の型より小さいという半順序を表す。
Erlangのすべての項の間には半順序(<、>)があり、型は上の型束で左から右へと順序付けられている。
この順序が全順序ではなく半順序であるのは、整数と浮動小数点数が比較の前に変換されるためである。
1 < 1.0も1.0 < 1もどちらも偽であり、一方で1 =< 1.0、1 >= 1.0、1 == 1.0はいずれも真になる。
精度の低い方の数値が、精度の高い方の数値に変換される。
通常は整数が浮動小数点数に変換される。
非常に大きい、あるいは非常に小さい浮動小数点数については、逆に浮動小数点数の方が整数に変換される。
これは、有効桁がすべて小数点より左側にある場合に起こる。
==(不等価の場合は/=)であり、A =< BかつA >= BであればA == Bとなる。
2つ目は厳密等価性=:=(厳密不等価の場合は=/=)であり、こちらでは1 =:= 1.0は偽になる。
重要なのは、パターンマッチングで使われるのは厳密等価性の方であり、値の変換は一切行われないという点である。タプルやリストのような複合構造については、順序は再帰的に拡張される。
たとえば{1, 2} < {1, 3}だが{2, 1} > {1, 3}となる。
これはつまり、Erlangの項の任意の集合は順序付けてソートできるということであり、この項の順序は言語仕様として定義されている。
2つのマップを順序について比較する場合は次のように行われる。 一方のマップの要素数がもう一方より少なければ、タプルの場合と同様、要素数が少ない方が小さいとみなされる。 そうでなければキーがキー順序で比較される。 キー順序では、すべての整数はすべての浮動小数点数より小さいとみなされる。 すべてのキーが同じであれば、各値のペア(キー順に並べたもの)が算術的に比較される。 すなわち、まず同じ精度に変換したうえで比較される。
等価性の比較についても同様であり、したがって#{1 => 1.0} == #{1 => 1}は真になるが#{1.0 => 1} /= #{1 => 1}は真にならない。
Erlangは動的型付け言語である。 つまり型は実行時にチェックされ、型エラーが起きると例外が投げられる。 コンパイラはコンパイル時に型をチェックしない。 CやJavaのような静的型付け言語であればコンパイル時に型エラーを得られるが、Erlangはそうではない。
型に順序を持つ、強く動的に型付けされているというErlangの型システムのこれらの側面は、言語の実装にいくつかの制約を課す。 実行時に型をチェックし比較できるようにするためには、それぞれのErlangの項が自分の型を携えていなければならない。
これを解決しているのが、項をタグ付けするという方法である。
タグ付けの方式
Erlangの項のメモリ表現では、型タグのために数ビットが確保されている。 性能上の理由から、項はイミディエイトとボックス化された項に分けられる。 イミディエイト項はマシンワード、つまりレジスタやスタックスロット1つに収まる。 ボックス化された項は、タグ付きポインタとプロセスヒープ上に格納された複数のワードという2つの部分から成る。 ヒープに格納されるボックスにはヘッダとボディがあるが、リストだけは例外でヘッダを持たない。
現在のERTSは段階的なタグ方式を採用しており、この方式に至った経緯と理由はHiPEグループによる技術レポートで説明されている(http://www.it.uu.se/research/publications/reports/2000-029/を参照)。 タグ方式はerl_term.hに実装されている。
基本的な考え方は、最下位ビットをタグに使うというものである。 現代のほとんどのCPUアーキテクチャは32ビット語と64ビット語をアラインメントするため、ポインタにとって「未使用」なビットが少なくとも2ビットはある。 これらのビットをタグとして使えばよい。 残念ながら、この2ビットだけではErlangのすべての型を表すには足りないため、必要に応じてより多くのビットが使われる。
プライマリタグ
先頭の2ビットはプライマリタグと呼ばれ、次のように使われる。
00 ヘッダ(ヒープ上) / CP(スタック上)
01 リスト(cons)
10 ボックス化
11 イミディエイト
イミディエイト(タグ11)は、小さい整数のようにそれ自体で完結した1ワードである。
追加のワードは一切必要ない。
ボックス化タグ10は、リストを除くヒープ上のオブジェクトへのポインタに使われる。
ヘッダタグ00は、ヒープ上ではヘッダ語にのみ使われる。これについてはボックス化された項で説明する。
ヒープ上のすべてのオブジェクトは、通常ヘッダ語から始まる、整数個分のマシンワードを使う。
スタック上では、00は代わりにリターンアドレス、すなわち継続ポインタを示す。
これは実用上都合のよい選択であり、リターンアドレスは元々必ずワード境界に揃っているため、そうしたポインタに対してタグ付け・タグ外しの処理を一切行わずに済む。
これは、イミディエイトやリスト、ボックス化された値のために00を使うよりも良いトレードオフになっている。
独立したリストタグ(01)は、リストが非常に頻繁に使われることに由来する最適化である。
これによって各cons cellは、ヒープ上でヘッダ語無しに格納できるようになり、リストに本来必要な容量の1/3を節約できる。
これはリストと文字列で見ていく。
イミディエイト
イミディエイトタグはさらに次のように分割される。
00 11 Pid
01 11 Port
10 11 イミディエイト2
11 11 小さい整数
PidとPortはイミディエイトであり、等価性を効率的に比較できる。 どちらも実際にはただの参照であり、Pidはプロセス識別子であってプロセスを指す。 プロセスはどのプロセスのヒープ上にも存在せず、PCB(プロセス制御ブロック)によって管理される。 Portもほぼ同じように動作する。
Pidのイミディエイトは1つのマシンワードに詰め込まれる。
下位2ビットがプライマリタグ11、続く2ビットがサブタイプ00、残りの上位ビットがPidの値を保持する。
32ビットシステムでは、その28ビットはさらに15ビットのプロセスインデックス(プロセステーブルへのオフセット)、13ビットのシリアル(インデックスの再利用を区別するため)、そして小さなクリエーションフィールドに分割される。
シェルでPidを表示すると<Node.Index.Serial>のように見える。ここでのクリエーション(Node)は、再起動をまたいだ場合に区別するためのものである。
PortもPidと同じレイアウトを使う(サブタイプ01)が、上位ビットはポート番号とクリエーションに分割される。
参照全体が1ワードに収まっているため、Pidやポートの等価性(および不等価性)の判定は、単一の整数比較に帰着する。
ERTSには小さい整数とビグナムという2種類の整数がある。 小さい整数は、1マシンワードからタグ用の4ビットを引いた大きさ、すなわち32ビットシステムでは28ビット、64ビットシステムでは60ビットに収まる。 一方でビグナムは(ヒープの空き容量が許す限り)必要なだけ大きくなることができ、ボックス化された項にあるように、ボックス化されたオブジェクトとしてヒープ上に格納される。
小さい整数はタグの4ビットすべてが1になっているため、エミュレータは整数演算を行う際に両方の引数がイミディエイトかどうかを効率的にテストできる。
(is_both_small(x,y)は(x & y & 1111) == 1111として定義される。)
イミディエイト2タグはさらに次のように分割される。
00 10 11 アトム
01 10 11 Catch
10 10 11 [未使用]
11 10 11 Nil
アトムはアトムテーブル内のインデックスとアトムタグから構成される。 2つのアトムのイミディエイトは、その表現同士を比較するだけで等価性を判定できる。
アトムテーブルでは、アトムは次のようなC構造体として格納される。
typedef struct atom {
IndexSlot slot; /* MUST BE LOCATED AT TOP OF STRUCT!!! */
int len; /* length of atom name */
int ord0; /* ordinal value of first 3 bytes + 7 bits */
byte* name; /* name of atom */
} Atom;
lenとord0のフィールドのおかげで、先頭の4文字が同じでない限り、2つのアトムの順序は効率的に比較できる。
何らかの理由で、名前の後ろに数字を付けたようなパターンでアトムを大量に生成し、それを順序付きリストや順序木に格納すると、それらがすべて同じ先頭の文字を持つ場合、アトムの比較はより高コストになる(たとえばfoo_1、foo_2など)。
アトムテーブルには上限があるので、アトム名を生成することなど本来あってはならない。 これはあくまで、ここに潜んでいる意地の悪いマイクロ最適化についての話である。
もちろん自分でこれをやることは無いだろうが、もし数字の後にポストフィックス名が続くアトムを生成するコードを見つけたら、そのコードを書いた人が何を考えていたのか、これで見当が付くはずである。
Catchのイミディエイトはスタック上でのみ使われる。 これは、例外の後に実行を継続すべきコード上の地点への間接ポインタを含む。 詳しくは呼び出しを参照。
Nilタグは、Erlangで[]と書かれる空リストのために使われ、CのコードではNILと呼ばれる。
ワードの残りの部分はすべて1で埋められる。
リスト
リストはcons cellから構成される。
cons cellはヒープ上の連続した2ワード、すなわちheadとtail(LISPでの呼び方に倣い、ERTSのコードベースの一部でもcar、cdrと呼ばれる)にすぎない。
前述の通り、空リストはイミディエイトである。
cons cellは、単方向連結リストを組み立てるための最小限のC構造体として、次のように記述できる。
struct cons_cell {
Eterm *head; /* the value */
Eterm *tail; /* the next element */
};
tail要素は通常、次のcons cellを指すもう1つのListタグ付きポインタか、リストの終端を示すNILイミディエイトのいずれかである。
これらは正格リストと呼ばれる。
しかしErlangは動的型付け言語であり、[1|2]のように、tailには任意の項を許している。
これらは非正格リストと呼ばれ、正格リストを期待する関数にとっては、末尾要素として[]が見つからないため驚きの種になる。
それぞれのcons cellは、セルへのポインタ内のタグによって識別されるため、別途ヘッダ語を必要としない。
したがってcons cell[A|B]は、ボックス化された2要素タプル{A,B}よりも33%少ないメモリしか使わない。
これは対応する速度の向上にも反映される。初期化が必要なヒープワードが3つではなく2つで済むからである。
したがって、要素の並びを一時的に保持するデータ構造としてはリストが好まれる。
ただし、大量のデータを長期にわたって表現する場合、リストは大量のメモリを消費しうることに注意してほしい。
仮に、それぞれ2バイトで表現できる小さな整数を100万個持っているとする。
64ビットのErlangシステムでは、これを[1234, 2001, …]のようなリストとして表現すると、100万個のcons cellそれぞれが64ビットのイミディエイト整数と、リスト内の次のセルへの64ビットポインタを保持することになり、つまり1セルあたり16バイトになる。
これは2メガバイトのはずのものが16メガバイトになるということである。
このデータをバイナリに詰め込む方がメモリ効率は良いが、それも結局はこれらの数値にどうアクセスする必要があるか次第である。
文字列
Erlangにおける文字列("…"と書く)は、Unicodeのコードポイントを表す整数のリストにすぎない。
したがって、"XYZ"は[88,89,90]のシンタックスシュガーである。
Erlangの対話型シェルは、こうした表示可能な文字のリストを検出しようとし、それを数値のリストの代わりに文字列として表示する。
たとえば次のようになる。
Eshell V15.2.2 (press Ctrl+G to abort, type help(). for help)
1> [88,89,90].
"XYZ"
Unicodeの全範囲を「表示可能な文字」として許してしまうと、雑多な整数のリストの多くが文字列に見えかねないため、シェルは文字列を検出する際にはLatin-1の範囲を既定とする。 たとえば次のようになる。
2> [955, 960, 963].
[955,960,963]
955、960、963はそれぞれギリシャ文字のラムダ、パイ、シグマだが、これは単なる数値のリストとして表示される。
しかし、Erlangを+pc unicodeオプション付きで起動すると、シェルは何を表示可能とみなすかについて、ずっと寛容になる。
$ erl +pc unicode
Erlang/OTP 27 [erts-15.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]
Eshell V15.2.2 (press Ctrl+G to abort, type help(). for help)
1> [955, 960, 963].
"λπσ"
ただし、それらの文字を実際に表示できるかどうかは、コンソールがどのエンコーディングを想定しているか、そして利用可能なフォントに依存することに注意してほしい。
文字列"hello"は、メモリ上ではおおむね次のように見える(図2)。

図2: 32ビットマシン上での文字列"hello"の表現。破線ではなく実線の矢印は、各ワードが保持する値の指す先を表す。
これは基本的に、UTF-32でエンコードされた文字の連結リストであり、見ての通り、UTF-8でエンコードされたバイト列として同じ文字列を持つ場合と比べるとかなりのメモリを浪費する。特に64ビットマシンでは1文字あたり16バイトを使うことになる。 したがって文字列は、マッチング、走査、先頭への追加を行うための一時的な「テキストバッファ」として使い、用が済んだらUTF-8エンコードされたバイナリに変換して保存・送信・出力するのが最善である。(バイナリについては後で詳しく述べる。)
term_to_binary()を通して行われる。
これは文字列に対してはるかにコンパクトなバイトエンコーディングを使う。
そうした場面では文字列について心配する必要は基本的に無く、避けるべきなのはヒープ上の表現を持ち続けることである。IOリスト
IOリストは、バイナリ、入れ子になったリスト、文字列、整数をフラット化せずに連結できるようにすることで、Erlangの文字列を拡張したものである。 それぞれの要素はバイナリのチャンクかリストノードのいずれかであり、ドライバはこれらを最小限のコピーで順番に出力する。
ボックス化された項
ヒープ上の他のすべての項はヘッダ語から始まる。 ヘッダ語は4ビットのサブタグとプライマリタグ00を使う。 残りのビットはアリティ、すなわちそのボックス化された項が使う追加ワード数を符号化する。
aaaaaaaa...aaaaaaaaaaaaaaaa tttt 00
サブタグは次の通りである。
0000 00 ARITYVAL (タプル)
0001 00 BINARY_AGGREGATE |
001s 00 符号ビット付きBIGNUM |
0100 00 REF |
0101 00 FUN | THINGS
0110 00 FLONUM |
0111 00 EXPORT |
1000 00 REFC_BINARY | |
1001 00 HEAP_BINARY | BINARIES |
1010 00 SUB_BINARY | |
1011 00 [未使用]
1100 00 EXTERNAL_PID | |
1101 00 EXTERNAL_PORT | EXTERNAL THINGS |
1110 00 EXTERNAL_REF | |
1111 00 MAP
タプル
タプル(ERTSのコードベースではARITYVALと呼ばれる)は単純に、タグと要素数を格納したヘッダの後ろに、タプルの要素(あれば)が続く形で格納される。
たとえば5要素タプル{$h, $e, $l, $l, $o}は、メモリ上では次のように見える(図3)。

図3: 32ビットマシン上での5要素タプルの表現。
これはつまり、空タプル{}はヒープ上でビットがすべて0の1ワード(アリティ0、サブタグ0000、プライマリタグ00)として表現されるということである。
とはいえ、ヒープ上のそのワードを指すボックス化ポインタも必要になるため、レジスタに保持したり追加のヒープワード無しにリストのtailとして格納したりできるNILほど安上がりではない。
バイナリ
バイナリはバイト列の不変な配列である。
内部的には、バイナリの表現には4種類ある。
ヒープバイナリとrefcバイナリという2つの種類は、実際のバイナリデータを含む。
残る2つの種類、サブバイナリとマッチコンテキスト(BINARY_AGGREGATEタグ)は、他の2種類のうちどちらかへの、より小さな参照である。
64バイト以下のバイナリは、ヒープバイナリとしてプロセスヒープ上に直接格納できる。 それより大きいバイナリは参照カウント方式になり、ペイロードはプロセスヒープの外に格納され、プロセスヒープ上にはProcBinと呼ばれるオブジェクトの中にそのペイロードへの参照が格納される。

図4: バイナリのレイアウトとタグ付け。
- プライマリタグ
00はボックス化された項のヘッダであることを示す。 - サブタグ
1001はヒープバイナリ(ペイロードがヒープ上にある)を示す。 - サブタグ
1000は参照カウント方式のバイナリ(ProcBin)を示す。
ヘッダに続いて次のようになる。
- ヒープバイナリの場合、バイナリのペイロードのバイトがヘッダの直後に続く。
- refcバイナリの場合、ヘッダの後にProcBin構造体が続く。
サブバイナリ(サブタグ1010)は、既存のバイナリへのビューである。
長さとオフセットのフィールドを持つヘッダ語に続けて、さらに2マシンワードを格納する。
これにより、バイナリの一部を切り出したりマッチングしたりする際にペイロードデータのコピーを避けられる。
マッチコンテキスト(サブタグ0001、BINARY_AGGREGATE)は、バイナリのパターンマッチングエンジンが使う。
これは新たなペイロード領域を確保することなく、1つ以上の外側のバイナリのセグメントを参照する。
- セグメントの総数を示すヘッダ語を持つ。
- 元のバイナリを指す
{ポインタ, 長さ}のタプルの配列を含む。
これにより、フラット化やコピーを行うことなく効率的な反復処理が可能になる。
バイナリについてはメモリ割り当てで詳しく述べる。
ビグナム
小さいイミディエイト(ワードサイズからタグ用の4ビットを引いた大きさ)に収まらない整数はビグナムとして表現される。
これはボックス化された項であり、そのヘッダ語は符号と、後に続く「リム」ワードの数の両方を符号化する。
ヘッダのプライマリタグ(00)はそれがヒープに割り当てられたヘッダであることを示し、4ビットのサブタグ(001)はそれがビグナムであることを識別し、そのサブタグの最上位ビットが符号を運ぶ(0が正、1が負)。
続く各リムは完全な1マシンワードであり、整数の一部を基数2^D_EXP(32ビットビルドでは通常28ビット、64ビットビルドでは60ビット)で保持し、リトルエンディアン(最下位のリムが先頭)で並べられる。
多倍長演算は、これらのリム配列に対して直接行われる。
加算と減算は、繰り上がりまたは繰り下がりを伝播させながらリムをワード単位で走査することで実装されており(I_add/I_sub)、最後に繰り上がりがあれば長さが1ワード分伸びる結果のリム配列を出力する。
乗算は、リム数が少ない場合は素朴な筆算アルゴリズム(I_mul)を、オペランドの長さが閾値を超える場合はKaratsuba法による分割統治(I_mul_karatsuba)を使い、中間の倍精度積を上位・下位に分割して扱う。
除算と剰余算は、KnuthのアルゴリズムD(I_div/I_rem)を使って商と剰余のリム配列を計算し、オペランドの符号に応じて結果の符号を調整する。
内部的には、それぞれのリムは**ErtsDigit**であり、ビグナムのリム配列にはBIG_V(xp)マクロを通してアクセスする。このマクロは最下位ワードを指すErtsDigit*ポインタを返す。
個々のリムはBIG_DIGIT(xp, i)(0始まりのインデックス、リトルエンディアン)で読み書きする。
big.c内のコアルーチン、たとえばI_add、I_sub、I_mul、I_mul_karatsuba、I_div、I_remはいずれも、これらのErtsDigit*配列に対して直接動作し、繰り上がり・繰り下がり、多倍長の乗算、長除算のステップを処理する。
結果の桁が計算されると、big_normがボックス化されたヘッダ(タグ、アリティ、符号)を再計算し、結果がタグのビット幅に収まる場合には小さいイミディエイトへと再統合することさえある。
参照
参照は「一意な」項であり、プロセスのメールボックス上にチャネルを実装するような形で、メッセージにタグを付けるためによく使われる。
参照は82ビットのカウンタとして実装されている。
make_ref/0を9671406556917033397649407回呼び出すと、カウンタは一周して再びref 0から始まる。
生きている間にそれだけの回数make_refを呼び出すには相当に高速なマシンが要る。ノードを再起動した場合は別で、その場合もまた0から始まるが、古いローカルな参照はすべて失われる。
参照を別ノードへ送ると、それは外部参照になる(後述)。
32ビットシステムでは、ローカルな参照はヒープ上で32ビットのワードを4つ使う。 64ビットシステムでは、参照はヒープ上で64ビットのワードを3つ使う。
32ビット(またはハーフワード)システムにおける参照の表現。
|00000000 00000000 00000000 11010000| アリティ3 + 参照タグ
|00000000 000000rr rrrrrrrr rrrrrrrr| Data0
|rrrrrrrr rrrrrrrr rrrrrrrr rrrrrrrr| Data1
|rrrrrrrr rrrrrrrr rrrrrrrr rrrrrrrr| Data2
参照の番号は(Data2 bsl 50) + (Data1 bsl 18) + Data0である。
浮動小数点数
Erlangの浮動小数点数は、FLONUMサブタグでタグ付けされたボックス化ヒープ項である。
それぞれの浮動小数点数は2マシンワードを占める。ヘッダ語(プライマリタグ00、サブタグ0110)と、64ビットのIEEE 754ペイロード語である。
エミュレータは、ガベージコレクションと比較においては浮動小数点数を不透明な「ボックス化」された値として扱い、可能であればネイティブCPU命令を、そうでなければソフトウェアルーチンを使って演算を行う。
レコード
レコードはタプル上に構築されたコンパイル時の便宜であり、アリティNのレコードは単に、先頭の要素がレコード名を表すアトムであるN要素タプルである。
アクセスと更新のマクロは、固定インデックスのタプル参照やsetelement/3呼び出しへと変換される。
タプルのヘッダ(プライマリタグ00、サブタグ0000)がアリティを符号化しているため、レコードのサイズとフィールドのオフセットは、追加のメタデータ無しに実行時にわかる。
まとめ
本章では、Erlangの動的型システムがERTSでどう実装されているかを見てきた。効率的な実行時の型チェックと比較のためにイミディエイトとボックス化された項を統一するタグ方式を中心に扱った。 タグのビットとヒープのレイアウトが、Erlangの強く動的な型付けモデルをどう反映しているかも見た。 これらのエンコーディングを理解しておくことで、後のメモリ割り当てとガベージコレクションの章、すなわちボックス化された項がプロセスヒープ上でどう管理されるかを見ていく章が、より把握しやすくなるはずである。