TypeScript コーディングテクニック #2 【ループ処理編】
この記事について
TypeScript のコーディングテクニックを紹介するシリーズの第 2 回です。
第 1 回の記事はこちら -> TypeScript コーディングテクニック #1 【条件分岐編】
想定する読者は、TypeScript をある程度使ったうえでさらにコード品質を高めたい方です。初心者向けではないと思います。
第 2 回は、ループ処理の書き方のテクニックです。
ループ処理の比較
以下の 6 種のループ処理について、特徴を比較しながら使いどころを検討します。
10000 字に迫る長大な記事になってしまったので、必要に応じて特定の章だけ読むことをお勧めします。
for
文-
for...of
文 (とfor...in
文とfor await...of
文) -
while
文 (とdo...while
文) forEach()
関数- 配列から値を算出する関数 (と配列を操作する関数)
- 関数の再帰呼び出し
for 文
for
文は、最も基本的なループ処理です。
while
と比較すると配列のインデックスの操作が得意で、配列内の複数の要素を使う処理や多次元配列の処理に向いています。
サンプルコード
サンプルコード
// 例: 多次元配列の処理(ボードゲームの勝利判定)
type Mark = "o" | "x";
type Cell = "-" | Mark;
type FixedLengthArray<T, L extends unknown[]['length'], A extends T[] = []> = A['length'] extends L
? A
: FixedLengthArray<T, L, [T, ...A]>;
const size = 3;
type Row = FixedLengthArray<Cell, typeof size>;
type Board = FixedLengthArray<Row, typeof size>;
// const board: Board = [
// ["x", "o", "x"],
// ["-", "o", "-"],
// ["o", "x", "-"],
// ];
const wins = (board: Board, mark: Mark): boolean => {
let topLeftDiagonal = false;
let topRightDiagonal = false;
const eq = (cell?: Cell): boolean => cell === mark;
for (let i = 0; i < size; i++) {
// 1 行そろえたら勝ち
if (board[i]?.every(eq) === true) return true;
// 1 列そろえたら勝ち
if (board.every((row) => eq(row[i]))) return true;
// 斜めにそろえたら勝ち
const j = size - 1 - i;
topLeftDiagonal &&= eq(board[i]?.[i]);
topRightDiagonal &&= eq(board[j]?.[j]);
}
return topLeftDiagonal || topRightDiagonal;
};
// range() ジェネレータ関数と for...of で代替できる
for (const i of range(size)) {
// ...
}
特徴
汎用的に使え、複雑な処理ができます。
while
文と比較して、「初回処理」と「繰り返し時処理」を指定できることが特徴です。
その他の機能は while
文と同じです。
長所、得意なこと
for...of
文と比較して、配列のインデックスを直接操作できるので、配列内の複数の要素を使う処理や多次元配列の処理が得意です。
配列のインデックスを扱うなど、「値を一定の間隔で変化させる有限回のループ処理」に適しています。
短所、苦手なこと
for...of
文などと比較してできることが多く、反面、コードが複雑になります。配列の要素を順にを処理するだけなら for...of
を使ったほうが良いです。
Python のような range()
関数を事前に定義しておけば、 for...of
で役割を代替することができます。反復に使うインデックスの値が let
でなく const
になるため、より安全になります。
個人的な意見ですが、for
文は値を一定の間隔で変化させる有限回の処理だけに使うようにしています。 break
や continue
がたくさん書いてあるような複雑なループ処理や無限ループは while
文で書いて関数に切り出してしまった方が分かりやすくなると思います。
また、配列を操作して新しい配列を算出したり、配列から値を算出したりする処理は、配列から値を算出する関数を使ったほうが良いです。
for...of 文
for...of
文は、シンプルなループ処理の文です。
配列などの反復子に対するループ処理を比較的簡潔かつ安全に書くことができます。
サンプルコード
サンプルコード
// 例: 配列の要素を順番に標準出力する処理
const list = ["zero", "one", "two", "three"] as const;
// for 文の場合
for (let i = 0; i < list.length; i += 1) {
const item = list[i];
console.log(list[i]);
}
// for...of 文の場合
for (const item of list) {
console.log(item);
}
特徴
for
と比較して、
- 必ず反復子の要素を 1 つずつ処理すること
- インデックスを意識させないこと
が特徴です。
長所、得意なこと
配列の要素を1つずつ処理することが得意です。
for
文に比べて、誤って処理が無限ループしたり、誤ったインデックスにアクセスしたりするリスクが少なくなります。
短所、苦手なこと
配列内の複数の要素を使う処理や、多次元配列を使う処理など、インデックスを直接扱う必要のある複雑な処理には向いていません。
また、配列を操作して新しい配列を算出したり、配列から値を算出したりする処理は、配列から値を算出する関数を使ったほうが良いです。
for...in 文
for...in
文は、オブジェクトの列挙可能なキーに対してループ処理をします。
ただし、オブジェクトのデータ構造(ハッシュテーブル)はループ処理に向いていません。よりループ処理に適したデータ構造である Map
や配列を使用することをお勧めします。
for await...of 文
for await...of
文は、非同期のジェネレータ関数などを処理するときに使用します。非同期処理については別の回に詳しく紹介するので、今回は説明を省略します。
while 文
while
文は、 for
文と並ぶ最も基本的なループ処理です。
主に無限ループをする処理に使います。
サンプルコード
サンプルコード
// 例: 対話型で入力を受け付ける処理(ボードゲームの入力処理)
type Player = {
readonly name: string;
readonly mark: "o" | "x";
};
const player1: Player = {
name: "player 1",
mark: "o",
};
const player2: Player = {
name: "player 2",
mark: "x",
};
let player = player1;
const nextTurn = () => {
player = player === player1 ? player2 : player1;
};
// 無限ループでプレイヤーの入力を交互に受け付け
while (true) {
// 非同期で入力を要求、受付
boardIsFull = await requireInput(board, player);
// どちらかが勝ったら終了
if (wins(board, player.mark)) {
showWinner(player);
break;
}
// ボードがいっぱいになったら引き分けで終了
if (!boardIsFull) {
showDraw();
break;
}
// ターンを交代
nextTurn();
}
// for 文の場合
for (
let player = player1;
true;
player = player === player1 ? player2 : player1
) {
// ...
}
特徴
汎用的に使える文です。
反復に使う変数をスコープ内に宣言できない点を除けば、できることは for
文と全く同じです。
無限ループするような複雑なループは while
文で書くことが多いと思います。
長所、得意なこと
無限ループや複雑な処理が得意です。
無限ループが使用されるイベント駆動の非同期処理などに適しています。
短所、苦手なこと
配列のような反復子を扱うだけならもっと適した方法があります。
do...while 文
do...while
文は、 for
や while
と異なり、ループの終了条件を処理の最後に判定します。
私は使いません(while
文の無限ループと break
で代替します)。
forEach() 関数
forEach()
関数は、配列をループ処理する関数です。
関数チェーンで配列を操作した後に続けてループ処理を記述できます。
サンプルコード
サンプルコード
// 例: 配列の要素を逆順に標準出力する処理
const list = ["zero", "one", "two", "three"] as const;
// for 文の場合
for (let i = list.length - 1; i >= 0; i -= 1) {
const item = list[i];
console.log(item);
}
// forEach() 関数の場合
list.toReversed().forEach((item) => {
console.log(item);
});
特徴
for...of
文と比較して、
-
map()
,filter()
,toSorted()
などの関数チェーンに続けて記述できること - 第 2 引数でインデックスが取得できること
- 関数なので
break
,continue
や 早期return
ができないこと - 関数なので 非同期処理を
await
で逐次処理できないこと - 空要素 (empty item) に対して処理を行わないこと
が特徴です。
長所、得意なこと
最大の特徴は 関数チェーン (method chain) です。関数名で処理内容が明確になるため、ソースコードが読みやすくなります。
短所、苦手なこと
forEach()
などの関数では、 await
を使った非同期処理の逐次実行ができないため、注意が必要です。
非同期処理の逐次実行には、 for...of
文か、非同期ジェネレータを使った for await...of
文がおすすめです。
また、標準の関数チェーンにはパフォーマンス面で懸念があるため、次の章で紹介するする外部パッケージを使用することをお勧めします。
配列から値を算出する関数
map()
や filter()
などの組み込み関数を使うと、配列から新しい配列や値を算出することができます。
これらの関数は関数チェーンでつなぐことができ、簡潔に書けます。
長所・得意なこと
関数呼び出しは式 (expression) なので、値の算出は for
文や for...of
文より得意です。
for
文や for...of
文などの文 (statement) を使うと、値を「定数」や「読み取り専用」にすることができません。
前回の記事の冒頭でも述べましたが、プログラミングで使うデータは原則として 「定数」や「読み取り専用」であるべきです。
手元を離れた変更可能な値はいつどこで変更されるか予想できないので、処理の見通しを著しく悪化させます。
また、既存の関数を使うので
- 並び替えなどのロジックを自前実装しなくてよい
- 関数名で処理内容が明確になるため、ソースコードが読みやすくなる
- 実装やレビューのコストが下がり、結果的に不具合のリスクが少なくなる
といったメリットがあります。
短所・苦手なこと
良くも悪くも、他のループ処理のような汎用性はなく、特定の処理にしか使えません。
また、組み込みの関数チェーンはループ処理を何度も繰り返してしまうので、パフォーマンスの面で for
や for...of
に劣ります。
ループ処理を最適化できる 遅延評価 (lazy evaluation) 機能を持つ npm パッケージを使うと、読みやすさとパフォーマンスを両立できます。
有名な Lodash でもよいですが、後発の Remeda が最もおすすめです(執筆時点)。
サンプルコード
サンプルコード
// 例: 配列から配列を算出する
import * as R from "remeda";
const result = R.pipe(
["zero", "ZERO", "one", "ONE", "two", "TWO", "three", "THREE"] as const,
// 大文字に変換する
R.map((value) => value.toUpperCase()),
// 重複を解消する
R.uniq,
// E を含むものに絞る
R.filter((value) => value.includes("E")),
);
console.log(result); // ['ZERO', 'ONE', 'THREE']
// for 文を使った悪い例も書こうと思いましたが、書ける気がしないので諦めます。
配列を操作する関数
組み込み関数の reverse()
, sort()
などは、新しい配列を算出するのではなく、元の配列を書き換えます。
配列が「読み取り専用」でなくなってしまうため、原則として使用しないほうがよいです。
高度なパフォーマンスが求められる場面や、状態管理が欠かせない場面など、止むをえない場合に使用します。
関数の再帰呼び出し
関数の再帰呼び出しもループ処理として使用できます。
コールスタックを保持することが特徴で、深さ優先探索などが得意です。
純粋関数型プログラミング言語では、すべての繰り返しを再帰呼び出しで書きます。
サンプルコード
サンプルコード
// 例: ツリー状に分岐するループ処理(フィボナッチ数列の生成)
const fibSeq = [1, 1];
const fib = function (n: number): number {
// 既に計算済みならその値を返す
const memo = fibSeq[n];
if (memo !== undefined) return memo;
// ツリー状に分岐する再帰処理
const value = fib(n - 1) + fib(n - 2);
// 計算結果をメモに保存
fibSeq[n] = value;
return value;
};
fibs(n);
// フィボナッチ「数」を求めるだけならもっと効率の良い方法があります
特徴
その名の通り再帰的な処理が得意です。
コールスタックを利用して、深さ優先探索などが効率的に書けます。
長所・得意なこと
深さ優先探索のような再帰的な処理を直感的に書けます。
短所・苦手なこと
パフォーマンスが悪いです。手続き処理に比べて処理速度が遅く、多くのメモリを消費します。
再帰呼び出しとループ手続きは全く等価であることが数学的に分かっています。つまり、再帰呼び出しで書ける処理はループ手続きでも書けます。
サンプルコードをループ手続きに書き直したもの
const fibSeq = [1, 1]
for (let i = 2; i <= n; i += 1)
fibSeq[i] = fibSeq[i-1] + fibSeq[i-2];
}
また、コールスタックがメモリに蓄積するため、回数の多い繰り返しには適していません。
繰り返しが多いと、かの有名な Stack Overflow を引き起こします。
ES2015 (ES6) で標準化された末尾呼び出しの最適化 (proper tail calls) についてはいまだに議論が続いており、これに対応しているJavaScript 実行環境はほんの一部です。
まとめ
長くなったので一覧でまとめます。
分類 | 使いどころ | 汎用性(複雑性) |
---|---|---|
for 文 |
配列のインデックスを扱う処理 (for...of 文と range() で代替できる) |
高 |
for...of 文 |
配列の各要素を扱う処理 | 中 (有限回) |
for...in 文 |
オブジェクトの各キーを扱う処理 | 中 (有限回) |
for await...of 文 |
非同期ジェネレータを扱う処理 | 中 (有限回) |
while 文 |
無限ループ、複雑なループ | 高 |
do...while 文 |
使用しない | 高 |
forEach() 関数 |
配列の要素を加工した後の処理 (外部パッケージの使用を推奨) | やや低 (break 不可) |
配列から値を算出する関数 | 配列から配列や値を算出する処理(外部パッケージの使用を推奨) | 低 |
配列を操作する関数 | 配列を状態管理する処理 | やや低 (break 不可) |
関数の再帰呼び出し | 再帰的に書いた方が分かりやすい処理 (パフォーマンスは悪い) | 高 |
処理の目的を明確にして、「より汎用性が低く用途が限られているもの」を選ぶとコードの複雑性が解消でき、読みやすくなると思います。
次回は
関数を使ったコードの整頓術を紹介します。
Discussion