prompt を Deno に実装した話


Deno (ディノ) Advent Calendar 2020、18日目の記事です。

今日は、prompt 関数を Deno に実装した話をします。

TL;DR

  • ブラウザの window.prompt() に相当する関数を Deno の API として実装しました
  • https://github.com/denoland/deno/pull/7507
  • 実装そのものは比較的簡単。なぜこの API を入れる必要があるのか、その意義を理解してもらうことに苦労しました。

モチベーションは本の執筆

以前 Denobook 2 を執筆したときに、完全にプログラムの初心者の人に向けて Deno・JavaScript の機能を1から解説していくという記事を書いたのですが、その時不便に感じたのがプログラムへの入力が非常に煩雑であるということでした。

従来の API だと標準入力から1行受け取る呼び出しは以下のようになります。

read_line.ts
import { BufReader } from "https://deno.land/std/io/mod.ts";

const reader = new BufReader(Deno.stdin);
const { line } = await reader.readLine();
const input = new TextDecoder().decode(line);

やりたいことがシンプルな割に、かなり煩雑です。

単に煩雑であるという以上に、初心者向けの本を執筆する上で、上の例は「先の章で出てくる概念を先に使ってしまっている」という大きな問題があります。たとえば、new が何かを説明するには、クラスを説明しなければならず、await を説明するには非同期処理とプロミスをまず説明しなければなりません。import を説明するにはモジュールの説明が必要になります。「1行入力する関数」はそれらの概念よりもっとずっと初歩的な if 文の解説などで使いたい機能なので、この状況はかなり不都合です。「呪文です。覚えてください」で、ごまかす事も出来ますが、そこで多くの初心者が脱落してしまいそうです。

上のような事情があり、結局 Denobook 2 の執筆時はユーザーからの入力を受け取る例を出すことは諦めました。

他のスクリプト言語と比較しても、Python であれば、input()、Ruby であれば、gets という非常にシンプルな関数呼び出しで、標準入力から1行読むことが出来ます。

issue を発見する

そこで「1行読む関数」を Deno に導入するための検討を始めました。当初は Deno.stdin.readLineSync() のような API を主に検討していました。似たようなトピックの issue が無いか検索すると、気になる issue が目に付きました。

この issue はブラウザが持っているダイアログ系の API である、alert、confirm、prompt を Deno に実装しよう、という提案です。作者 (ライアン・ダール) は「Sounds reasonable」という一言を残したあと、議論からは姿を消しています。その後、メインコントリビュータの一人の Kitson とイシューを立てた人の @KSXGitHub さんの間で (割と意見の強い) 議論が行われたあと、コンセンサスが得られずに議論が終了しています。簡単にまとめると、Kitson はそういうものは必要無いと言っていて、KSXGitHub さんは便利だからあった方が良いと言っています。

上の issue を読んでいる際に、alert、confirm に関しては実際のところあまり興味はありませんでした。一方で、prompt の挙動を考えてみた時に、これはまさに python 3 の input() と同じであって、自分が入れたい「1行読む関数」そのものであるという事に気づきました。しかも、prompt()Deno.stdin.readLineSync() よりも大幅に短く書けて、まさに初心者向けという感じがします。

これだと思い、issue に次のようにコメントしました。

I think this kind of feature, especially prompt, is very useful for new learners to learn the very first interactive programs in console. This kind of feature is also necessary for Deno to be considered as a "batteries included" runtime. If it needs 3rd party libraries like x/ask or x/prompt for performing such simple input, is it really called "batteries included"?

何らかの反応があることを期待していましたが、特に反応がないため、とりあえず実装を開始しました。

prompt を実装する

実装の大枠はシンプルで、1文字づつ Deno.stdin.readSync(c) で読み込んで、改行 (LF) を見つけたら読むのをやめるという単純なものです1。また、stdin が pipe されている場合は、念の為入力を受け付けずに null を返すようにしました (これはレビュワーの Nayeem さんが調べてくれた Headless Firefox の挙動をベースにしました)。

実装の本筋以外で若干苦戦したのが、Deno の現在のビルドの仕組みです。Deno は以前は TypeScript で書かれたソースコードを TypeScript Compiler API を使って AMD に変換しながら V8 に読み込ませてスナップショットを取るという方法でバンドルしていて、通常の TypeScript の記述方法で API を書くことが出来ました。現在は諸事情により Vanilla.js で書いたソースコードをファイルの名前順で concat するという原始的なバンドルの仕方になっており、自分が追加したいファイルの読み込み順はどこが良いのかなどの検討が必要でした。結局 prompt で使っている API は割と後ろのほう (040番台) で定義されていると分かり、それより1段階後ろという意味で 041 番台という prefix をする必要があり、041_prompt.js というファイル名でチェックインすることになりました。

PR を出すと Nayeem さんがすぐにレビューし始めてくれたり、👍 がたくさんついたりとポジティブなフィードバックが多く集まりました。事前の議論では特に結論が出ていなかったため、ここまでポジティブな反応だけが沢山来ることはすこし驚きがありました。

マージ後の議論とリバート阻止

レビュー開始から1ヶ月ぐらいたった10月13日に、1.5 のリリース準備作業の中でついにメンテナの Bartek によってマージされましたが、その直後に Bartek 自身から、そのマージをリバートするという PR が提出されました。

Revert "feat: add alert, confirm, and prompt (#7507)"
https://github.com/denoland/deno/pull/7959

理由は、Bartek は元 issue の Kitson の意見を見逃しており、その懸念が解消されない限りはマージできないというものでした。

自分にとってはこの API は上記のような、初心者・初学者向け機能として必要という確信があり、上記の Kitson の主張にはその観点に対する反論が含まれていないと思われました (あくまで、他の機能で代替出来るので必要ないという立場)。そこで、思い切って PR 上で自分の意見をもう一度述べ、反対意見以上にこの機能を入れるメリットが大きいことを説明しました。程なくして Kitson から返信があり、「そこまで好きな機能ではないが、既存機能を壊しているわけではないため、強く反対するわけではない」という意見が述べられ、結果その Revert PR は閉じられ、1.5 のリリースに含まれることが確定しました。

自分はこれまで Deno の API デザインに対して強く意見を言ったことはあまり無く、あくまでやると決まった issue をただ解決するという関わり方を主にしていたので、上の議論の中で初めてコアメンバーの主張に対して反対意見を述べて、自分の方針側にコンセンサスを得る経験が出来た事が大きな成果と感じました。

まとめ

今日は Deno に prompt を実装した際のモチベーションや、経緯を紹介しました。

明日は @uki00a さんの2020年のDenoの変更点の話です。


  1. ここで、windows の改行コード CR のケアを怠っていたため、windows で盛大にバグってしまいました。リリース後に issue で指摘されて修正しました。