最近のJavaScriptを学んでみた
JavaScriptは大昔にやったきりで最近の状況は何となくしか分かっていなかったので、以下で学び直しました。
実際、いろいろ変わってますね。と言うところで、実際のお題を元に少しまとめてみます。
お題はPaizaさんのスキルチェックにあるJavaScriptのサンプルソースです。実行環境はnodenvでnode.js 16.13.0環境を用意しました。なお、Paizaさんの実行環境はNode.js v14.17.4です。
var lines = require("fs").readFileSync("/dev/stdin", "utf8").split("\n");
var N = lines[0];
for(var i = 0; i < N; i++) {
var line = lines[i+1].split(" ");
console.log("hello = " + line[0] + ", world = " + line[1]);
}
サンプルを今風に書き換えてみる
サンプルの説明
詳細はPaizaさんの「値取得・出力サンプルコード」の説明を見ていただけばわかりますが、以下の仕様になっています。
- 1行は改行で区切る(各行に改行が必須)
- 行内の値は1個のスペースで区切る
- 1行目にこの後データが何行あるかを入力する
- 2行目から実際のデータを入力する
サンプルでは以下のデータを入力すると、
2
1 2
3 4
以下の出力をします。
hello = 1 , world = 2
hello = 3 , world = 4
見直し1:極力varでの宣言は使わない
昔のJavaScriptにはvarしかありませんでしたが、今はconstやletを使うことになっています。しかし、Paizaさんのサンプルは思いっきりvarを使っています。JavaScriptは後方互換性を重視しているため、varキーワード自体は現役ですが、それらはletやconstで置き換えがほぼほぼ可能です。
ですので、サクッと置き換えてみます。
const inputData = require("fs").readFileSync("/dev/stdin", "utf8").split("\n");
const lines = inputData[0];
for(let i = 1; i <= lines; i++) {
const line = inputData[i].split(" ");
console.log(`hello = ${line[0]}, world = ${line[1]}`);
}
1行目ですが、変数名も変えています。ここで標準入力から取得した入力データ全体を取得して改行で分割しているので、変数名をinputDataとしました。元々はlinesでしたが、後で行数を格納する変数の名前に使いたいので変えました。変数宣言はvarではなくてconstにしています。
このinputDataは一度標準入力から取り込んだら再代入しないのでconstにしています。もちろん、letでも動作はします。こんな短いプログラムであればletにしたところで弊害は少ないですが、ここで代入した後に変更がないことを示すためにもconstを使っています。
constというと定数と捉えがちですが、厳密にはJavaScriptの宣言は定数ではない、と最初に掲載したJSPrimerでは解説しています。プリミティブ型の変数であれば定数と言ってしまって問題ないのですが、オブジェクト型だと話が厄介になります。
2行目は入力データの1行目にある行数を変数に代入しています。元々の変数名はNと大文字でしたが、個人的に気になったのでlinesに変更しています。これも後から変わることはないのでvarからconstに変更しています。
大文字変数名はそれこそ定数に使うことが多いので、普通の変数名に使うのは避けたかったためです。ここで言う定数とは実行以前に値が決まっている変数という意味で使っています。
3行目はfor文のループカウンタiの宣言をvarからletに直しています。
あと、ここでは1行目に行数が入っているので、2行目から指定行数分ループします。しかし、元のソースではforループを0から行数-1行分だけ回して、実際に参照する際の配列の添字の方で+1しています。これは効率が悪いのでループの方を1から行数分にしています。
JavaScriptの実装がどうなっているか分からないですが、元のソースだと行数分添字を+1する計算が発生してしまいます。サンプルとしてわかりやすさを優先しているのかと思いますが、これも気になるので直しています。
4行目は1行取ってきてスペースで分割した配列を作っていますが、その宣言もvarからconstに直しています。ループ回数分実行されるのでconstではなくてletではないかと思うかもしれませんが、forループのブロックは毎回初期化されるためconstで問題ありません。もちろん、このブロック内で内容が変わる様なものならletにしなければなりません。
5行目は文字列を連結して出力行をつくっていましたが、せっかくなのでテンプレートリテラルを使っています。こちらの方がスッキリします。今時の言語にはたいてい用意されているものですので、積極的に使うべきかと思います。
見直し2:forEachを使う
これでも動くのですが、forループがちょっと今風じゃないです。入力データをせっかく配列で受け取っているのだから、今時の言語にはよくあるforEachを使った方がスッキリします。というわけで、直してみます。
const inputData = require("fs").readFileSync("/dev/stdin", "utf8").split("\n");
const lines = inputData.shift(); // 先頭の値を取り出す
inputData.forEach((line, i) => {
items = line.split(" "); // スペースで分割
if (items.length > 1 && lines - 1 >= i) {
console.log(`hello = ${items[0]}, world = ${items[1]}`);
}
});
2行目は入力データの1行目にある行数を取り出すところですが、shift関数を使って値を取り出しつつ、入力データの1行目は削って純粋なデータだけにしています。そうしないと、forEachで回せません。
3行目から実際にforEachを使っています。本来はforEachを使っているのですからデータの1行目の行数は不要で、実際に入力データの行数分だけ処理すれば良いのですが、Paizaさんのスキルチェックや競プロではデータ仕様が最初に説明したように決まっているので、あえてそのままにしてあります。そのため、ちょっと余計なことをしています。
入力行をスペース区切りで分割した際に2つの値があることをチェックしていますが、それに加えて指定された行数に収まっているかもチェックしています。行数として1を指定しているのに、2行以上入力した場合は無視するようにしています。
データ行数なんて余計なデータを含まないデータを前提にすれば以下のようにもっとスッキリします。特に使わないループのためだけのループカウンタ変数iなんて不要ですからね。
const inputData = require("fs").readFileSync("/dev/stdin", "utf8").split("\n");
inputData.forEach((line) => {
items = line.split(" "); // スペースで分割
if (items.length > 1) {
console.log(`hello = ${items[0]}, world = ${items[1]}`);
}
});
見直し3:someを使う
ただ、やはり行数の処理部分がいまいちです。そこでforEachの代わりにsomeを使います。someはループの中で特定の条件を満たした場合だけ処理するようなケースで使います。今回は指定した行数を超えない範囲かつスペース区切りで2項目以上ある場合だけ処理するようにします。
const inputData = require("fs").readFileSync("/dev/stdin", "utf8").split("\n");
const lines = inputData.shift(); // 先頭の値を取り出す
inputData.some(function (line, i) {
items = line.trim().split(/ +/);
if (lines - 1 >= i && items.length > 1) {
console.log(`hello = ${items[0]}, world = ${items[1]}`);
}
});
スペース区切りで分割する際に、トリム処理を追加した上で、分割部分を正規表現にしました。これで余計な空白を入れても大丈夫です。
someの中には関数を入れます。これをコールバック関数と呼びます。配列の各要素毎にこの関数を呼び出します。今回はsomeの中に直接書いていますが、別に定義してsomeに渡しても構いません。ただ、今回は一度しか使わないので直接書いています。これを関数式と呼びます。
こういう一種の使い捨て関数みたいなものも最近の言語にはよくあります。lambda式とか無名関数とか、言語や翻訳によっていろいろと呼ばれています。
例によって行数を指定しないバージョンはもっとスッキリします。スペース区切りの項目が2つ以上ない場合は処理しません。
const inputData = require("fs").readFileSync("/dev/stdin", "utf8").split("\n");
inputData.some(function (line) {
items = line.split(/ +/);
if (items.length > 1) {
console.log(`hello = ${items[0]}, world = ${items[1]}`);
}
});
配列のsomeは本来は値のチェックを行う用途に使います。
const data = [
{ "area":"東京", "sales":100 },
{ "area":"東京", "sales":200 },
{ "area":"名古屋", "sales":150 },
{ "area":"大阪", "sales":80 },
{ "area":"大阪", "sales":110 },
];
// 東京のデータはあるか?
const includeTokyo = data.some(data => {
return data.area === "東京";
});
console.log(`東京を含む:${includeTokyo}`); // => true
// 福岡のデータはあるか?
const includeFukuoka = data.some(data => {
return data.area === "福岡";
});
console.log(`福岡を含む:${includeFukuoka}`); // => false
someではコールバック関数を定義してやると、配列内の各要素にそのコールバックを適用し、trueが出てくるまで処理します。途中でコールバック関数がtrueを返せばそこで処理を中断し、trueを返しますし、見つからなければ最後まで行ってfalseを返します。上の例だと東京は最初にあるのでdata配列の最初の1行を処理した時点でtrueを返して終わりますし、福岡はないので全体を舐めた後にfalseを返します。意図的にtrueを返さなければ全体を舐めてくれるので、以下のようなこともできてしまいます。もっとも、本来の用途からは外れていますけど。
const data = [
{ "area":"東京", "sales":100 },
{ "area":"東京", "sales":200 },
{ "area":"名古屋", "sales":150 },
{ "area":"大阪", "sales":80 },
{ "area":"大阪", "sales":110 },
];
const areas = ["東京", "名古屋", "大阪"];
const salesSum = new Map();
data.some(function(salesData) {
// console.log(`area:${salesData.area}, sales:${salesData.sales}`);
// その支店の初出時は集計用変数を初期化
if (typeof salesSum[salesData.area] === "undefined") {
salesSum[salesData.area] = 0;
}
// 支店毎の売上集計を計算
salesSum[salesData.area] += salesData.sales;
});
// 支店毎の売上集計を表示
areas.forEach(area => {
console.log(`${area}: ${salesSum[area]}`);
})
上で紹介した配列のforEachでは途中で処理を中断(for文のbreakに相当)できないので、代わりにsomeを使うという使い方もある様です。
const data = ["1", "2", "3", "STOP", "4", "5"];
data.some(value => {
if (value === "STOP") {
return true;
}
console.log(value); // 3までしか表示しない
});
おかわり:letとvar
最初の見直しでvarをletやconstに置き換えましたが、もう少しvarとletを見てみます。
以下のような(あまり意味のない)プログラムがあったとします。
for (var i = 0; i < 3; i++) {
for (var j = 0; j < 3; j++) {
console.log(`i:${i}`);
}
}
これを実行するとこんな出力になります。
i:0
i:1
i:2
i:0
i:1
i:2
i:0
i:1
i:2
これをコピペをミスって内側のループもiのままにしてしまったとします。
for (var i = 0; i < 3; i++) {
for (var i = 0; i < 3; i++) {
console.log(`i:${i}`);
}
}
この場合、内側のループを実行した時点でiが3になるので、外側のループは1回しか実行されずに終わってしまいます。
しかし、letの場合は最初と同様に9行出力されます。
for (let i = 0; i < 3; i++) {
for (let i = 0; i < 3; i++) {
console.log(`i:${i}`);
}
}
なぜかというと、letとvarは有効範囲(スコープ)が違うためです。
letやconstはブロックの中でのみ有効で、ブロックの外に同名の変数があっても別扱いです。varはグローバルスコープなので、プログラム全体で有効です。つまり、プログラムのどこかにしれっと宣言されていることに気づかずに別のところで使うと、特にエラーにならずに、それでいて場合によって動作がおかしくなるという見つけにくい不具合を誘発します。上記の短いサンプルなら良いですが、1画面に収まらない範囲のコードの中でうっかり同名の変数を使ってしまってもなかなか気づかないことになります。
そんなこともあって、varは基本的に使うな、という話になります。
以下を例にすると、外側のvと内側のvは同じものなので、内側のブロックを抜けた後も値は内側で設定した1のままですが、cとlはブロックの中でのみ有効ですので、ブロックの内外で同名の変数であっても、違うものになります。
{
var v = 0;
let l = 0;
const c = 0;
console.log(`A: v:${v}, l:${l}, c:${c}`); // => A: v:0, l:0, c:0
{
var v = 1;
let l = 1;
const c = 1;
console.log(`B: v:${v}, l:${l}, c:${c}`); // => B: v:1, l:1, c:1
}
console.log(`C: v:${v}, l:${l}, c:${c}`); // => C: v:1, l:0, c:0
}
おかわり:定数とconst宣言
古い言語の定数の概念では、多くの場合、定数の宣言はヘッダに集められたり、プログラムの冒頭にまとめて定義されていたりします。それに対して、今時の言語のconst宣言は上記の古式ゆかしい定数だけでなく、最初の見直し1でやったようにプログラムの途中でもバンバン出てきます。そういう意味で、自分のようなロートルにはパラダイムシフトが必要になります。
個人的にはこれを「定数の概念が拡張された」と捉えています。
従来の古式ゆかしい定数のイメージは、プログラム中のマジックナンバーをあちこちに散らばらせないようにして、プログラムのメンテナンスを容易にするというものだと思います。例えば円周率を定義するとか、パスの長さを指定するとか。プログラム全体で常に決まった値を使うが、都合によって変更することもあるのでプログラムの頭の方にわかるようにまとめておく、という使い方です。
const PI = 3.14; // (1)
const ERAs = [ "明治", "大正", "昭和", "平成", "令和" ]; // (2)
const radius = 10; // 本来はどこかから入力される // (3)
console.log(`円周の長さ:${Math.floor(radius * 2 * PI)}`);
ERAs.forEach(era => {
console.log(era);
});
上のサンプルには(1)から(3)の3つのconst宣言が出てきます。古式ゆかしい定数はこのうち(1)と(2)になります。
一方で(3)もconst宣言された定数ですが、上の定数とは性格が違います。ロートルから見ればここは定数ではなく変数という考え方になります。これがコンパイル型の言語であれば、原則としてコーディング時点で値が決まっている(1)や(2)が定数であって、コンパイル時には定まらない(3)については定数ではないとなります。(上のサンプルでは単純に数値リテラルを代入しているのでコンパイル時に定まりますが、実際にはファイルや標準入力などから入力されるものと考えてください)
JavaScriptはインタプリタ言語なのでそもそもコンパイル時がありませんが、古式ゆかしいマジックナンバーのエイリアスとしての定数と、ブロックの最初で動的に値が決まるが以降は変更できない制限が強い変数としての定数がある、と考えています。Z世代ならぬ最初からモダンな言語をやっている人から見ればこんな区別はないのかもしれませんが、ロートルはなまじ従来の知識が邪魔するのでパラダイムシフトに対してあれこれ理由づけが必要になります。
で話はお題に戻りますが、自分がPaizaさんのサンプルで大文字の変数名が気になると言ったのがここになります。
マジックナンバーをまとめるものは大文字の変数名にすべきで、そうでない定数は小文字の変数名にすべきというのが自分の考え方です。それに従うと、明らかにプログラム実行時に決まる「入力行数」は、いわゆるマジックナンバーなので大文字にしたくはないというのが変数名を変えた理由になります。今時の若い方々がどういう考え方なのかわかりませんが、マジックナンバーをまとめる定数はそういうものであると分かる様に大文字にすべきでないかと考えています。Rubyなんかは言語仕様でそうなっていたかと思いますし、Pythonは言語仕様ではないですが慣習として定数を大文字にしていますよね。
まとめ
最初からモダンな言語を使っている人なら気にならないのかもしれませんが、かっての知識が邪魔になるロートル的には、今時のJavaScriptを学ぶにあたっては以下に注意する必要があるかと思います。
- varは基本的に使わない
- letの利用も最低限にして、constを使う(昔ながらの定数と別に、新たな定数の概念に慣れる)
ディスカッション
コメント一覧
まだ、コメントがありません