JavaScriptでRAII (for-ofを使って)
for-ofを用いて広義のRAIIを実現する方法を紹介し、JavaScriptでリソース管理を行うパターン3種を比較します。
タイトルはorumin氏の記事のリスペクトです。
RAIIについて
RAII (リソース確保は初期化である) はC++におけるリソース管理方式を表す言葉で、デストラクタにより暗黙的にリソース解放処理を行うことでプログラマの注意力に頼らずにリソース管理を行うことができます。
C++やRustのRAIIの特徴は、専用の制御構文を持たずに、変数のスコープに基づいてリソース管理が行われることです。
// C++ RAII
// スコープから抜けるときに自動的に解放される
std::fstream fs;
fs.open("/proc/cpuinfo", std::fstream::in);
// Rust RAII
// スコープから抜けるときに自動的に解放される
let fs = File::open("/proc/cpuinfo");
広義のRAIIについて
一方、PythonやJavaなどは専用の制御構文を用いて、リソース解放を確実に行わせることができます。
# Python: with文
with open("/proc/cpuinfo") as f:
# ...
// Java: try-with-resources
try (FileInputStream f = new FileInputStream("/proc/cpuinfo")) {
// ...
}
C言語でRAIIではこれらの機能もRAIIと呼んでいるため、本稿でもこれらを 広義のRAII と呼ぶことにします。本稿はこの広義のRAIIをJavaScriptで行う方法について論じます。
ファイナライザによるリソース解放
GCを持つ多くの言語では、未解放のリソースに対するハンドルがGCによって回収されたときはその時点でリソースを解放するようになっています。
RAIIや広義のRAIIと異なり、この方法ではリソースが実際に解放されるタイミングを予測・制御することができません。ファイナライザによるリソース解放はあくまで救済措置と考え、この機能にはできるだけ依存しないようにするのがよいでしょう。
JavaScriptにおける標準的なリソース管理 (1)
JavaScriptにおいてリソースの解放処理を確実に行うための標準的な方法はtry-finallyを使うことです。 (これはtry-with-resources導入前のJavaと同様です)
const f = await fs.promises.open("/proc/cpuinfo", "r");
try {
// ...
} finally {
await f.close();
}
この方法はリソース確保とリソース解放処理が分割されてしまうこと、リソース解放処理を書かせる強制力に欠けることなどが欠点として考えられます。
JavaScriptにおける標準的なリソース管理 (2)
別の方法として、上記の処理をラップした高階関数を提供するという手があります。
{
open("/proc/cpuinfo", "r", (f) => {
// ...
});
}
async function open(path, flags, callback) {
let result;
const f = await fs.promises.open(path, flags);
try {
result = await callback(f);
} finally {
await f.close();
}
return result;
}
この方法ではtry-finallyの問題は解消されていますが、return/break/continueがコールバックを貫通できなくなるという意味で理想的ではありません。
for-ofによる広義RAIIの実現
実はJavaScriptには脱出処理にフックできる構文がもう1つあります。それがfor-ofです。
for-ofはいわゆるforeach系のループのJavaScriptにおける名前です。制御がfor-ofから抜けるとき、イテレーターのreturnコールバックが必ず呼ばれます (for-await-ofでPromiseがsettleせずにストールした場合などを除く)。
イテレーターを作るにはジェネレーター関数を使うのが手軽です。 (詳しくは過去記事 JavaScriptのIterator / Generatorの整理 を参照)
ジェネレーター関数を使うと、先ほどの処理は以下のように書き直すことができます。
{
for await (const f of open("/proc/cpuinfo", "r")) {
// ...
}
}
async function* open(path, flags) {
let result;
const f = await fs.promises.open(path, flags);
try {
yield callback(f);
} finally {
await f.close();
}
}
先ほどの高階関数による方式と違い、 for-of内で発行したbreak/continue/returnはfor-ofを貫通します (もちろんその途中でリソース解放処理が必ず行われます)。
ではこの方法の欠点は何でしょうか。ひとつは、この方法はループ構文を流用しているため、ツールによる静的解析では for-ofの中がちょうど1回実行されるという仮定を置いてくれない ことです。実際、TypeScriptでは期待される実行回数によって型検査の結果が変わることがあります。
// try-finallyの場合: ちょうど1回実行されるという前提で解析してくれる
let x;
try {
x = 42;
} finally {}
console.log(x.toString()); // OK。型検査に通る
// for-ofの場合: ちょうど1回実行されるという前提で解析してくれる
let x;
for (const y of [null]) {
x = 42;
}
console.log(x.toString()); // 型検査に通らない
この観点からは高階関数よりもfor-ofのほうがマシです (for-ofではfor-ofの中身がそれ以降の処理と逆転して実行されることがないことは保証できる) が、try-finallyの完全な上位互換として使えるわけではないことがここからわかります。
また、これはあくまでfor-ofの目的外流用による弊害のひとつにすぎず、他にも同様の問題があるかもしれないという広い意味でのリスクもあるかもしれませんし、目的外流用ではプログラムの実装意図が正しく理解されにくく、可読性が落ちるリスクもあります。
関連するproposal
Explicit Resource Managementでは、まさしくこのイテレーターのAPIを使って適切なRAII相当の枠組みを整備することを目指しているようです。
まとめ
JavaScriptではfor-ofによって広義のRAIIと同等の目的が実現できることを説明しました。また、for-ofを含む、リソース管理をするためのパターン3種を比較しました。
try-finally | 高階関数 | for-of | |
---|---|---|---|
目的外利用ではない | ✅ | ✅ | × |
確保と解放がセット | × | ✅ | ✅ |
return/continue/breakが貫通できる | ✅ | × | ✅ |
静的解析上の保証: 1回だけ実行される | ✅ | × | × |
静的解析上の保証: 実行が前後しない | ✅ | × | ✅ |
Discussion