Elixirではダブルクオートで囲んだ文字列がそのままUTF-8のバイナリです。この一点を押さえないと、byte_size/1 と String.length/1 が別の数を返す理由も、バイナリパターンマッチでプロトコルを分解できる理由も腑に落ちません。この記事ではバイナリとビット文字列の境界、生成時の型修飾子、連結の性能差、可変長データの分解までを、Elixir 1.20.3(Erlang/OTP 29)で実際に実行した出力とともに整理します。2026年6月公開の1.20で書き方が変わった点も扱います。
まとめ
- バイナリはビット数が8の倍数のビット文字列。
<<3::4>>はビット文字列だがバイナリではない <<>>の1セグメントは既定で8ビット。256以上の整数は下位8ビットに切り捨てられ、<<257>>は<<1>>になるbyte_size/1は入力長に関係なく一定時間、String.length/1は長さに比例(100万文字で0.8秒台)- 連結は末尾追記の
acc <> chunkが速く、先頭付加のchunk <> accは同じ回数で約100倍遅い - パターンマッチでサイズを省略できるバイナリは末尾の1つだけ。それ以外はコンパイルエラーになる
- Elixir 1.20から、マッチの外で束縛した変数を
size(...)に渡すにはピン演算子が必要(ハード非推奨として警告)
バイナリとビット文字列を分ける8の倍数という境界
Elixirの公式ドキュメントは「バイナリとは、ビット数が8で割り切れるビット文字列である」と定義しています。つまり両者は別物ではなく包含関係で、ビット文字列が広く、その中でバイト境界に揃ったものだけがバイナリです。
iex> is_binary(<<0, 255, 42>>)
true
iex> is_bitstring(<<3::4>>)
true
iex> is_binary(<<3::4>>)
false
iex> bit_size(<<3::4>>)
4
<<3::4>> は4ビットしかないため is_binary/1 が偽を返します。この区別は関数のガード節で効いてきます。バイト単位のデータだけを受け取りたい関数には when is_binary(data) を書き、ビット単位の端数を許すなら is_bitstring/1 を使う、という判断です。ファイル入出力やネットワーク送信は8ビット単位でしか扱えないため、外部とやり取りする境界の関数は is_binary/1 で受けるのが安全でしょう。
リテラル記法によるバイナリ生成と型修飾子
既定サイズは8ビットで256以上は切り捨て
公式ドキュメントは「ビット文字列の各数値の格納には既定で8ビット(1バイト)が使われる」と述べています。ビット数は ::n または冗長な ::size(n) で明示できます。
iex> <<42>> == <<42::8>>
true
iex> <<257>>
<<1>>
257は2進数で100000001ですが、8ビットに収まらない最上位ビットが落ち、00000001だけが残ります。エラーにはなりません。外部から受け取った整数をそのままバイナリに詰めると、値が黙って化けます。0〜255の範囲検査は自前で書くか、::16 のように十分な幅を指定してください。
符号付き整数とエンディアンの指定
セグメントには型(integer、float、binary、bitstring、utf8、utf16、utf32 ほか)に加えて、符号(signed / 既定は unsigned)とバイト順(big(既定)/ little / native)を指定できます。integer の既定サイズは8ビット、float は64ビットです。
iex> <<1000::16-big>>
<<3, 232>>
iex> <<1000::16-little>>
<<232, 3>>
iex> <<v::8-signed>> = <<255>>
<<255>>
iex> v
-1
同じ1バイトでも signed を付けるだけで255が-1に変わります。バイナリプロトコルの仕様書に「符号付き16ビット、リトルエンディアン」と書かれていたら、修飾子をそのまま ::16-little-signed と写す。この対応の素直さが、Elixirでプロトコル実装を書く実利です。
UTF-8バイナリとしての文字列の測り方と検証
byte_sizeとString.lengthが返す値のずれ
Elixirの文字列はUTF-8でエンコードされたバイナリそのものです。したがって日本語のようなマルチバイト文字では、バイト数と文字数が一致しません。
iex> byte_size("日本語")
9
iex> String.length("日本語")
3
iex> <<230, 151, 165>>
"日"
3文字がそれぞれ3バイトなので合計9バイトです。3行目が示すとおり、有効なUTF-8のバイト列を <<>> で書くとiexは文字列として表示します。バイナリと文字列の表示が同じになるのは、両者が同じ値だからです。
String.lengthの計算量と100万文字での実測
byte_size/1 はバイナリが自身の長さを保持しているため入力長に依存しません。対する String.length/1 は先頭から書記素を数え直します。「あ」を100万個並べた文字列で :timer.tc を使い、同一プロセス内で5回続けて測った結果が次です(Elixir 1.20.3 / OTP 29、Intel x86_64のmacOS)。
big = String.duplicate("あ", 1_000_000)
for _ <- 1..5 do
{t1, _} = :timer.tc(fn -> byte_size(big) end)
{t2, _} = :timer.tc(fn -> String.length(big) end)
IO.puts("byte_size #{t1}us String.length #{t2}us")
end
byte_size 1us String.length 864350us
byte_size 0us String.length 799957us
byte_size 2us String.length 916233us
byte_size 1us String.length 904378us
byte_size 1us String.length 894480us
byte_size/1 は5回とも0〜2マイクロ秒、つまり :timer.tc の分解能に埋もれる速さでした。単一の数値として書けないほど速い、と読むのが正しい読み方です。一方の String.length/1 は0.80〜0.92秒を要しています。実行時の負荷でばらつくため秒単位で幅を持って捉えるべき値ですが、両者の性質の差は明確で、前者は入力長に依存せず一定、後者は長さに比例します。
この差は書き方に直結します。入力サイズの上限チェックやバッファ管理の判定に String.length/1 をループ内で呼ぶのは避けてください。「何バイトまで受け付けるか」の検査なら byte_size/1 で足ります。逆に、ユーザーに「30文字まで」と提示する入力欄の検証をバイト数で代用すると、日本語では3分の1しか入りません。仕様がバイトなのか文字なのかを先に決めるのが順序です。
不正なバイト列を検出するString.valid?
すべてのバイナリが有効なUTF-8文字列とは限りません。外部から受け取ったバイト列を文字列として扱う前に検査します。
iex> String.valid?(<<239, 191, 19>>)
false
iex> String.valid?("日本語")
true
この検査を通さずに String モジュールの関数へ渡すと、途中で例外が飛ぶか、意図しない結果になります。ファイル読み込みやソケット受信の直後に一度だけ通す位置づけが扱いやすいでしょう。
バイナリ連結の書き方で100倍変わる実行時間
バイナリの連結は <> 演算子で書きます。ここで見落とされがちなのが、「既存のバイナリの後ろに足す」のか「前に足す」のかで実行時間が桁違いになる点です。同じ200,000回の連結を3通りで測りました。
n = 200_000
:timer.tc(fn -> Enum.reduce(1..n, <<>>, fn _, acc -> acc <> "ab" end) end)
:timer.tc(fn -> 1..n |> Enum.map(fn _ -> "ab" end) |> IO.iodata_to_binary() end)
:timer.tc(fn -> Enum.reduce(1..n, <<>>, fn _, acc -> "ab" <> acc end) end)
acc <> chunk : 33704us size=400000
iodata : 43454us size=400000
chunk <> acc : 3445418us size=400000
末尾に追記する acc <> chunk が33.7ミリ秒、先頭に付ける chunk <> acc が3.45秒。結果のバイナリは同じ400,000バイトなのに、約100倍の開きが出ました。BEAMが末尾追記に対して確保済み領域を伸ばす最適化を持つ一方、先頭付加は毎回コピーが発生するためです。
したがって、ループでバイナリを組み立てるなら追記の向きを末尾に固定してください。逆順に組み上がってしまう設計なら、リストへ積んでから IO.iodata_to_binary/1 で一度に平坦化する書き方(43.5ミリ秒)に寄せます。3行目の書き方を選ぶ理由は、この測定の範囲では見当たりません。
バイナリパターンマッチによる可変長データの分解
先頭バイトと文字単位の取り出し
バイナリはパターンマッチで直接分解できます。取り出したい分だけセグメントを書き、残りを rest::binary で受けるのが基本形です。
iex> <<a, b, rest::binary>> = "hello"
"hello"
iex> {a, b, rest}
{104, 101, "llo"}
iex> <<first::utf8, tail::binary>> = "日本語"
"日本語"
iex> {first, tail}
{26085, "本語"}
修飾子なしの a は8ビット整数として取り出されるので、”h” ではなく104というバイト値が束縛されます。ASCIIの範囲ではバイト値とコードポイントがたまたま一致しますが、日本語のように1文字が複数バイトになる領域では別物です。1文字ずつ処理したいなら ::utf8 を付けてください。::utf8 は1〜4バイトの可変長を正しく読み進めるため、先頭1バイトだけ取ってしまう事故を防げます。
長さヘッダ付きデータの分解
バイナリパターンマッチが最も効くのは、先頭に長さを持つ形式です。同じマッチ式の中で先に束縛した変数を、後続セグメントのサイズとして使えます。
iex> packet = <<3, "abc", "REST">>
<<3, 97, 98, 99, 82, 69, 83, 84>>
iex> <<len, chunk::binary-size(len), rest::binary>> = packet
<<3, 97, 98, 99, 82, 69, 83, 84>>
iex> {len, chunk, rest}
{3, "abc", "REST"}
1行目でパケットが数値の並びとして表示されているのは、先頭バイトの3が印字可能文字ではないためです。値としては文字列を含んだ1本のバイナリで、len に3が入り、続く3バイトが chunk に、残りが rest に分かれます。長さ・型・値の並びで定義されたプロトコルなら、この1行がパーサーの中核になるでしょう。バイト位置を数えて binary_part/3 を呼ぶ手続き的なコードは不要です。
逆に、この形が使えない条件もはっきりしています。可変長フィールドが長さ情報なしに2つ並ぶ形式、そしてフィールド境界が8の倍数ビットに揃わない形式です。前者は次のh3で見るとおりコンパイルが通りません。後者はビット単位のセグメントで受けられますが、途中経過がビット文字列になるため、そのままファイルへ書き出せない点に注意してください。
サイズ未指定のバイナリを末尾以外に置けない理由
公式ドキュメントは「マッチにおいて既定サイズを使えるバイナリは最後の1つだけ。他はマッチが一意であってもサイズを明示しなければならない」と定めています。破ると実行前に落ちます。iexでの実際の出力が次です。
iex> <<a::binary, b::binary>> = "hello"
error: a binary field without size is only allowed at the end of a binary
pattern, at the right side of binary concatenation and never allowed in
binary generators. The following examples are invalid:
rest <> "foo"
<<rest::binary, "foo">>
They are invalid because there is a bits/bitstring component not at the
end. However, the "reverse" would work:
"foo" <> rest
<<"foo", rest::binary>>
(中略)
** (CompileError) cannot compile code (errors have been logged)
診断の本体は error: の行にあり、** (CompileError) の行は「エラーが記録されたのでコンパイルできない」という結びだけです。境界が決まらない以上コンパイラには分割位置を選べません。実行時エラーではなくコンパイルエラーなので、テストを書く前に気づける種類の間違いです。2つの可変長フィールドを並べたい形式なら、長さヘッダを持たせるか区切りバイトを挟む設計に改めます。
Elixir 1.20で必須になったピン演算子
外部変数をsize指定に使うと警告になる条件
1.19以前のコードをそのまま1.20でコンパイルしたとき、バイナリ処理でまず出会うのがこの警告です。2026年6月3日公開のElixir v1.20は、チェンジログのHard deprecationsに「ビットパターン内のサイズに対するマッチは、一貫性のためピン演算子を必要とするようになった(例:<<x::size(^existing_var)>>)」と記載しています。マッチの外で束縛済みの変数をそのまま size(...) に書いた既存コードが対象です。実際にElixir 1.20.3で実行した出力を挙げます。
size = 3
<<chunk::binary-size(size), rest::binary>> = "abcdef"
warning: the variable "size" is accessed inside size(...) of a bitstring
but it was defined outside of the match. You must precede it with the pin
operator
ピン演算子を付ければ警告は消えます。
iex> size = 3
3
iex> <<chunk::binary-size(^size), rest::binary>> = "abcdef"
"abcdef"
iex> {chunk, rest}
{"abc", "def"}
前節の長さヘッダの例(<<len, chunk::binary-size(len), ...>>)は警告になりません。len が同じマッチ式の中で束縛されているからです。区別の基準は「変数がマッチの内側で決まるか外側で決まるか」の一点にあります。
既存コードの移行判断
ハード非推奨は現時点では警告どまりで、動作は変わりません。前掲のとおり警告付きでもマッチは成功します。Elixirの互換性方針では、ハード非推奨は「使用時に警告を出すことで実質的に非推奨とする」段階と定義され、機能の削除はメジャーリリースでしか行われません。加えて同方針は「現時点でメジャーなv2リリースの計画はない」と明記しており、v1.x系の非推奨がすぐ削除に進む状況ではありません。非推奨化そのものにも「代替が最低3マイナー版のあいだ存在していること」という条件が課されています。
つまり緊急性は低い。それでも直しておく理由は、警告が出続けるとコンパイル出力の中で本当に見るべき警告が埋もれるからです。移行作業としては、チャンク分割や固定長レコード読み込みのように外部変数をサイズに渡すコードを size( で全文検索し、該当箇所に ^ を足すだけで済みます。外部変数をサイズに渡している箇所の数だけ警告が出るので、コンパイル出力の警告をゼロにするまで潰せば漏れはありません。
Erlang呼び出しに限る文字リストとバイナリの使い分け
文字リスト(charlist)はコードポイントの整数リストで、バイナリとは内部構造がまったく違います。Elixirではシングルクオート記法ではなく ~c シギルで書きます。
iex> ~c"hello"
~c"hello"
iex> ~c"hełło"
[104, 101, 322, 322, 111]
iex> List.to_string(~c"hello")
"hello"
iex> String.to_charlist("hello")
~c"hello"
2行目の表示に注意してください。すべてのコードポイントがASCII(0〜127)に収まる場合だけ ~c シギル形式で表示され、範囲外の文字が混じると整数リストのまま表示されます。表示が変わっても値の性質は同じです。
使い分けの基準は、Erlangのライブラリを呼ぶかどうかの一点に尽きます。Elixir側のコードでは文字列(バイナリ)を使い、Erlang関数へ渡すときだけ String.to_charlist/1 で変換し、戻り値は List.to_string/1 でバイナリに戻す。Erlangの多くの関数は文字リストを期待するため、Erlang資産を再利用する場面では避けて通れません。同じBEAM上の言語がこの相互運用をどう扱っているかは、Erlang VM上で動く型安全な言語Gleamの設計と比べると輪郭がつかめます。
逆に、Elixir内部の文字列処理を文字リストで書く理由はありません。メモリ消費が実際どれだけ違うかは :erts_debug.size/1 で測れます。11文字の ~c"hello world" が22ワード、同じ内容の文字列 "hello world" が4ワードでした。ワードサイズは8バイトなので、176バイト対32バイトです。文字リストは1要素あたり2ワード(16バイト)を消費するため、文字数に比例して差が開きます。
よくある質問
ファイルやソケットから読み込んだデータはそのままバイナリとして扱えますか?
扱えます。File.read!/1 はバイナリを返すため、そのままパターンマッチにかけられます。実際に <<0, 1, 250, 255>> を書き出して読み戻すと、同じバイナリが返り is_binary/1 は真になりました。ただし中身がUTF-8として妥当とは限らないので、String モジュールの関数へ渡す前に String.valid?/1 を通してください。
バイナリの一部を取り出すにはbinary_partとパターンマッチのどちらを使いますか?
取り出し位置と長さが固定の定数なら binary_part/3、データ構造に沿って分けるならパターンマッチです。binary_part("hello world", 6, 5) は "world" を返します。部分文字列の位置を探すなら :binary.match/2 を使ってください。同じ入力では開始位置と長さのタプル {6, 3} が返りました。プロトコル解析のように「先頭から順に意味のある単位へ分ける」用途では、位置計算の要らないパターンマッチのほうが読みやすくなります。
バイナリのパターンマッチでサイズを変数にできますか?
可能です。同じマッチ式の中で先に束縛した変数は、そのまま binary-size(len) と書けます。マッチの外で束縛した変数を使う場合は、Elixir 1.20以降ピン演算子が必要で、binary-size(^size) と書いてください。付け忘れると1.20系では警告が出るものの、マッチ自体は従来どおり成功します。
ビット文字列をそのままファイルへ書き出せますか?
書き出せません。File.write!("/tmp/y.bin", <<3::4>>) を実行すると File.Error が発生し、メッセージは bad argument でした。ファイル入出力は8ビット単位のため、<<3::4, 0::4>> のようにパディングしてバイト境界へ揃える必要があります。パディング後は :ok が返り、読み戻すと "0"(16進の30)になりました。ビット単位で組み立てた値を永続化する場合は、埋めたビット数を別途記録しておかないと復元できません。
Elixir 1.20の警告は既存コードを壊しますか?
壊しません。ハード非推奨は警告を出す段階であり、コードは従来どおり動きます。Elixirの互換性方針では削除はメジャーリリースでのみ行われ、しかも「現時点でメジャーなv2リリースの計画はない」と明記されています。1.21で急に動かなくなる想定は不要ですが、警告を残すとコンパイル出力が読みにくくなるため、size( の検索で該当箇所を洗い出して ^ を足しておくのが実務的です。