今回の本編に入る前に、まず前回の到達点が整理されました。ここでは Date と String 周辺で前回見た内容を短く振り返っています。
前回扱ったポイントは主に次のとおりです。
Date.prototype.toUTCString() は RFC 7231 の HTTP-date 形式をベースにした UTC 文字列表現を返すGMT で終わるvalueOf() は内部スロットに入っている値を返すToPrimitive の挙動を少し実験し、Symbol.toPrimitive の上書きや defineProperty での変更も確認したString() と new String() は返り値が違うString.fromCharCode() と String.fromCodePoint() は似ているが、Unicode の扱いがかなり違う特に String() と new String() の違いは今回の本編にも繋がるため、再確認されました。
const s1 = String(1);
const s2 = String(1);
const s3 = new String(1);
const s4 = new String(1);
console.log(s1 === s2); // true
console.log(s3 === s4); // false
console.log(typeof s1); // "string"
console.log(typeof s3); // "object"
ここでの確認事項は単純で、String(x) はプリミティブ文字列を返し、new String(x) は String オブジェクトを返す、ということです。同じ "1" を表していても、後者はオブジェクトなので参照比較では一致しません。
また、fromCharCode() と fromCodePoint() の違いも前回内容として再確認されました。fromCharCode() は UTF-16 のコードユニット寄りの古い API で、fromCodePoint() は Unicode コードポイントを直接扱いやすい API です。BMP 外の文字、つまりサロゲートペアを必要とする文字では差がはっきり出ます。
今回最初の本題は String.raw でした。参加者の感覚としても「普段かなり使わない API」ですが、タグ付きテンプレートリテラルを理解するには良い題材、という位置づけで読み進められました。
String.raw は、タグ関数として使うことを前提に設計された関数です。普通のテンプレートリテラルでは、\n のようなエスケープシーケンスは改行として解釈されます。しかし String.raw をタグとして使うと、バックスラッシュを“生のまま”保持した文字列を作れます。
輪読会では Windows パスの例が非常にわかりやすいとして取り上げられました。
const filePath = String.raw`C:\Development\profile\new.html`;
console.log(`The file was uploaded from: ${filePath}`);
出力は次のようになります。
The file was uploaded from: C:\Development\profile\new.html
一方、普通のテンプレートリテラルだとこうなります。
const filePath = `C:\Development\profile\new.html`;
console.log(`The file was uploaded from: ${filePath}`);
The file was uploaded from: C:Developmentprofile
ew.html
ここで起きているのは、\n が改行として解釈され、\D や \p のような部分も「そのまま見た目通り残ってくれる」とは限らない、という問題です。Windows パスや正規表現のようにバックスラッシュを大量に含む場面では、String.raw はかなり実用的です。
${} の補間はどうなるのかraw という名前から「全部そのままになるのでは」と思いがちですが、補間そのものは普通に行われます。生になるのは主にエスケープシーケンスの扱いです。
const name = "Bob";
console.log(String.raw`Hi\n${name}!`);
console.log(String.raw`Hi \${name}!`);
Hi\nBob!
Hi \${name}!
この確認から、会では次のように整理されました。
String.raw でも ${...} の補間は行われる\n は改行にならず、文字列 \ と n の並びとして残るtemplate.raw の中身を見るその後、String.raw の仕組みを理解するために、独自タグ関数 myRaw を作って、テンプレートに渡されるオブジェクトを直接観察しました。
function myRaw(template, ...subs) {
console.log(JSON.stringify({ template, subs }, null, 2));
console.log(JSON.stringify({ "template.raw": template.raw }, null, 2));
return String.raw(template, ...subs);
}
console.log(myRaw`a${1}b${2}c${3}\n`);
観察結果は次のとおりです。
{
"template": [
"a",
"b",
"c",
"\n"
],
"subs": [
1,
2,
3
]
}
{
"template.raw": [
"a",
"b",
"c",
"\\n"
]
}
a1b2c3\n
ここで重要なのは、template と template.raw が別物だという点です。
template 側は「調理済み(cooked)」で、\n は実際の改行として解釈済みtemplate.raw 側は「生(raw)」で、\\n のように元の記述に近い形を保持する会では cooked / raw という呼び方そのものにも触れられ、「raw に対して cooked という語を使っているのが仕様っぽくて面白い」といった反応がありました。
.raw はどこから来るのか「なぜテンプレート配列に .raw プロパティがあるのか」も話題になり、タグ付きテンプレートリテラルの仕様側まで遡って確認されました。結論としては、テンプレートオブジェクトが生成されるときに raw 用の別オブジェクトが作られ、それが .raw として結び付けられる、という仕組みです。
さらに、実験の中で次の点も見えました。
.raw 側も専用の固定的な値として扱われるつまり、タグ関数に渡ってくるテンプレートは「ただの配列」ではなく、仕様がかなり強く形を決めた特別な値です。
次に読まれたのは String.prototype 自体の説明でした。ここはメソッド個別の話というより、「String.prototype はどういうオブジェクトなのか」という土台の確認です。
会で押さえられたポイントは次のとおりです。
String.prototype は組み込みの特別なオブジェクト[[StringData]] という内部スロットを持つlength は 0Object.prototype特に面白がられていたのは、「String.prototype 自体が [[StringData]] を持っている」という点です。そのため、String.prototype.valueOf() を直接呼ぶと空文字列が返ります。
const s1 = String(1);
const s3 = new String(1);
console.log(s1.valueOf()); // "1"
console.log(s3.valueOf()); // "1"
console.log(String.prototype.valueOf()); // ""
ここでの理解はこうです。
valueOf() はその文字列を返すnew String(1) のような文字列オブジェクトに対する valueOf() も内部スロット内の "1" を返すString.prototype 自体の [[StringData]] は空なので、空文字列が返るString.prototype は「string exotic object」だと書かれており、この言い方も少し掘られました。ここでいう exotic object は、普通のオブジェクトとは異なる内部メソッドを持つオブジェクトです。
会では、文字列に対して次のような振る舞いがあることと結びつけて理解されました。
"abc"[0] のように添字アクセスできるつまり、見た目は配列っぽくインデックスで読めるが、実体は文字列専用ルールを持ったオブジェクトだ、という理解です。
休憩前後で、次の中心テーマが String.prototype.at でした。
読み始めてすぐ、「この節だけ This method performs the following steps when called: という定型句が抜けている」という指摘が出ました。これは内容上の大問題ではないものの、編集上の抜け漏れに見える、という扱いでした。
Scrapbox にも次のメモが残されています。
22.1.3.1 String.prototype.atに、This method performs the following steps when called:が無い
会の空気としては「読解に支障は少ないが、仕様書の体裁としてはミスっぽい」という感じです。
at() の動きat() は最近の API で、配列の at() と似た使い方ができます。要点は次のとおりです。
undefined実験はこうでした。
console.log("𩸽".at(0));
console.log("鱧".at(0));
const s = "あいうえお";
console.log(s.at(0));
console.log(s.at(4));
console.log(s.at(4.5));
console.log(s.at(-5));
console.log(s.at(-5.5));
console.log(s.at(-6));
console.log(s.at(5));
String.prototype.at.call(null);
出力は次のようになります。
�
鱧
あ
お
お
あ
あ
undefined
undefined
TypeError: String.prototype.at called on null or undefined
ここで整理されたポイントはかなり多いです。
"あいうえお".at(4.5) が "お" になるのは、小数部が切り捨てられるから-5.5 も -5 として扱われる-6 は範囲外なので undefinednull や undefined をレシーバーにすると TypeError𩸽 のような BMP 外文字は UTF-16 上では 2 つのコードユニットなので、at(0) は“文字全体”ではなく前半だけ返してしまうつまり at() は「文字っぽく見える単位」ではなく、「UTF-16 コードユニット」で動く API です。ここが直感を裏切るところでした。
at() は実質ジェネリックではないか仕様本文には charAt() などと違って at() に “intentionally generic” という注記がありません。しかし、実際の定義を見ると this を文字列化して使っているため、null / undefined 以外にはかなり広く使えます。
そのため会では、「注記がないだけで、定義上は実質ジェネリックに見える」という話になりました。これは終盤の concat() の議論にも繋がります。
ここからは、似て非なる三兄弟の比較が続きました。
charAt() は指定位置の 1 文字を返しますが、ここでいう 1 文字もコードユニット単位です。範囲外なら空文字列を返します。
console.log("𩸽".charAt(0));
console.log("𩸽".charAt(1));
console.log("𩸽".charAt(-1));
console.log("𩸽".charAt(2));
�
�
BMP 外文字では前半・後半どちらも単独では壊れた見え方になります。また、範囲外は undefined ではなく空文字列です。
charCodeAt() は同じ位置のコードユニット値を数値で返します。範囲外は NaN です。
console.log("𩸽".charCodeAt(0));
console.log("𩸽".charCodeAt(1));
console.log("𩸽".charCodeAt(-1));
console.log("𩸽".charCodeAt(2));
55399
56893
NaN
NaN
ここでも返っているのは Unicode コードポイントではなく、UTF-16 の各コードユニットです。
codePointAt() は名前の通りコードポイント寄りの API ですが、これも完全に“文字単位”ではありません。開始位置がサロゲートペアの先頭なら全体を読めますが、後半側を指すと後半コードユニット単体の値が返ります。
console.log("𩸽".codePointAt(0));
console.log("𩸽".codePointAt(1));
console.log("𩸽".codePointAt(-1));
console.log("𩸽".codePointAt(2));
console.log(String.fromCodePoint("𩸽".codePointAt(0)));
console.log(String.fromCodePoint("𩸽".codePointAt(1)));
171581
56893
undefined
undefined
𩸽
�
この比較から会で強調されていたのは次の点です。
codePointAt(0) は 𩸽 全体のコードポイントを返せるcodePointAt(1) は“後半コードユニット単体”を返すcodePointAt() があるからといって、for (let i = 0; i < str.length; i++) のようなコードユニット基準のループが安全になるわけではないこれは実務的にも重要で、「文字列長が 2 なのに見た目は 1 文字」という UTF-16 固有のややこしさが、かなり丁寧に確認されました。
この会では charAt() と at() の違いも自然に比較されました。挙動の違いをまとめるとこうです。
charAt() は範囲外で空文字列at() は範囲外で undefinedcharAt() は負インデックスに特別対応していないat() は負インデックスを後ろから数える形で扱える見た目は似ていますが、API 設計の思想はかなり違う、という話でした。
charAt() には仕様注記として「intentionally generic」とあります。つまり、this が本物の文字列オブジェクトでなくても、文字列化できるなら使えるということです。
これを実際に試した例がこちらです。
const obj = {
charAt: String.prototype.charAt,
};
console.log(obj.charAt(0));
console.log(obj.charAt(1));
console.log(obj.charAt(2));
[
o
b
obj 自体が文字列ではないのに動くのは、内部で "[object Object]" に文字列化されて、その先頭から読んでいるからです。
さらに、at() についても実質同じように使えることが確認されました。
const fakeStr = { length: 1, toString: () => "a" };
console.log(String.prototype.charAt.call(fakeStr, 0)); // a
console.log(String.prototype.at.call(fakeStr, 0)); // a
会ではこの結果を受けて、
charAt() は明示的にジェネリックat() も定義を見る限り事実上ジェネリックという見方が共有されました。
最後に読まれたのは String.prototype.concat です。ここはかなり素直な API で、「レシーバーを文字列化し、各引数も文字列化して順に連結するだけ」という理解でほぼ足ります。
concat() の本質はコードユニット列の連結です。複雑な Unicode 解釈をしてくれるわけではなく、単に並べるだけです。
そのため、逆に言えばサロゲートペアの前半と後半を別々に取り出して連結すれば、元の文字に戻せます。
const parts = [
"𩸽".charAt(0),
"𩸽".charAt(1),
"𩸽".charAt(0),
"𩸽".charAt(1),
];
console.log("".concat(...parts));
𩸽𩸽
これは「壊れた文字を直してくれる」のではなく、「前半と後半を元通りの順で並べたので結果的に復元された」というだけです。concat() はあくまで連結しかしていません。
さらに難しい例として、家族絵文字 👨👩👧👦 でも実験されました。
{
const parts = "👨👩👧👦".split("");
console.log(parts);
console.log("".concat(...parts));
}
{
const parts = [..."👨👩👧👦"];
console.log(parts);
console.log("".concat(...parts));
}
出力は次のようになります。
�,�,,�,�,,�,�,,�,�
👨👩👧👦
👨,,👩,,👧,,👦
👨👩👧👦
ここで比較されたのは次の2通りです。
split("") はコードユニット単位で分解するので、サロゲートペアが壊れる[...str] は文字列イテレータを使うので、コードポイント単位で分解される👨, , 👩, , 👧, , 👦 に分かれるconcat() で元の並びに戻せば、見た目上は元の絵文字に復元されるこのあたりから、会話は「コードユニット」「コードポイント」「書記素クラスタ」が全部別物である、という Unicode 文字列処理の難しさに広がっていきました。
最後の方では、String.prototype.at や concat の記述に関して、仕様書の注記が揃っていないのではないか、という話になりました。
主な指摘は次の2つです。
String.prototype.at にだけ定型句 This method performs the following steps when called: が欠けているat や concat は実質ジェネリックに見えるのに、その旨の注記が省かれている箇所がある会の雰囲気としては、「読めなくなるほどの欠陥ではないが、ECMAScript 仕様書はこういう小さな編集ミスも実際にある」という確認でした。過去に参加者が仕様書へ小さな修正をコントリビュートした話も出ており、今回の件も気が向けば直せそう、という締めでした。
この回の主題を一言でまとめると、String 周りの API は見た目以上に「UTF-16 のコードユニット」と強く結びついている、ということでした。
特に確認された理解は次のとおりです。
String.raw はタグ付きテンプレートリテラルの raw/cooked の差を露出させる APIString.prototype 自体が [[StringData]] を持つ特別なオブジェクトat(), charAt(), charCodeAt(), codePointAt() は似ているが、返り値と Unicode の扱いがかなり違うcodePointAt() ですら、コードユニット基準の添字操作と組み合わせると安全ではないconcat() は賢い Unicode API ではなく、あくまで連結 API全体として、今回の輪読会は String.raw と各種文字列アクセス API を通じて、JavaScript の文字列処理が「見た目の文字」ではなく「UTF-16 の内部表現」に強く支配されていることを、実例つきで丁寧に確認する回になっていました。