最も妥当な実装を選択せよ

こんにちは。ソーシャル経済メディア「NewsPicks」プリンシパルエンジニアのむとうです。

システムを作っていると、動いた時に「楽しい!」と感じることでしょう。しかし、動かすことで満足してしまってとりあえず動くだけの実装を行ったことが後で問題となった経験、ありますよね。

AI時代だからこそ、動くだけのコードやガチャを回して終わりではなく深く理解した上での妥当な実装を選択することが必要です。JavaScriptで配列の比較を行うという小さな例を題材に、どうすればいいかを計測とコードで見ていきましょう。

一つ一つの決断の質を高めることが、あなたのエンジニアとしての評価、ひいてはあなたが関わるプロダクトの強さを作るのです。

JavaScriptで配列の比較を行う

JavaScriptには配列同士の値の比較をする関数がありません。*1しかし、値の比較をしたいケースはしばしば発生します。

それでは今このような配列を比較したくなったとしましょう。

// 比較したい対象
const ids = [1, 2, 3, 4, 5];
// 入力。実際には外部から渡される。
const input = [1, 2, 3, 4, 5];

ids と外から渡された配列 input とを比較したいです。どうすればいいでしょうか?あなたはJavaScriptでは比較は ===演算子を使うことを知っています。しかし試したところ必ず false*2になってしまいました。

ids === input // falseになる

なぜだかわからないけどなってしまったものは仕方がない*3ので「JavaScript 配列 比較」などとググると、いろいろ解説したブログが出てきます。いくつかやり方が見つかりました!

a.toString() === b.toString()
JSON.stringify(a) === JSON.stringify(b)
function isSameArray(a, b) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
        if (a[i] !== b[i]) return false;
    }
    return true;
}
a.every((v, i) => v === b[i])

色々あって難しそうなので、深く考えなくても正しく比較できそうなJSONでの比較を試すことにしました。

やってみましょう。

JSON.stringify(ids) === JSON.stringify(input) // true

やった!上手く行きました。

配列を比較するための処理は今後も使いそうです。後から再利用しやすいように関数化しましょう。

/**
 * 配列の中身が等しいか確認する
 */
export function isSameArray(a, b) {
    return JSON.stringify(a) === JSON.stringify(b);
}

ではこれで配列の比較は完了です。(?)

遅いので修正する

あるとき、配列の比較を利用したロジックがすごく遅いことが報告されました。調べてみると、片方の配列の要素数が大きいケースで isSameArray()が頻繁に使われていて、それが遅かったのです。

const ids = []; // 10000要素の配列
const input = []; // 小さな配列
isSameArray(ids, input); // inputが色々入れ替わりたくさん呼ばれる

isSameArray()を詳しくみてみると、次のような処理になっています。(再掲)

/**
 * 配列の中身が等しいか確認する
 */
export function isSameArray(a, b) {
    return JSON.stringify(a) === JSON.stringify(b);
}
  1. aをJSON文字列化する
  2. bをJSON文字列化する
  3. 結果を比較する

配列の長さが違う場合は明らかに値として等しいとは言えないにも関わらず、長い配列を引数として渡されると必ずJSON化してしまいます。これは無駄な処理に思えます。

以前見つけた配列を比較する処理の中で、長さを先に比較しているものがあったので、これを採用してみましょう。

/**
 * 配列の中身が等しいか確認する
 * a, b: number[]
 */
function isSameArray(a, b) {
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
        if (a[i] !== b[i]) return false;
    }
    return true;
}

すると高速に処理が行えるようになり、問題は解決しました!

なぜ遅いのか

実際に配列を作成し、二通りの方法で比較して時間を計測してみましょう。*4

/**
 * forループで配列を比較する関数
 */
function isSameArrayForLoop(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) {
    if (a[i] !== b[i]) return false;
  }
  return true;
}

/**
 * JSON文字列で配列を比較する関数
 */
function isSameArrayJSON(a, b) {
  return JSON.stringify(a) === JSON.stringify(b);
}

// テストデータ生成
function generateArray(size) {
  return Array.from({ length: size }, () => Math.floor(Math.random() * 10000));
}

// ウォームアップ
for (let i = 0; i < 1000; i++) {
  const a = generateArray(1000);
  const b = generateArray(1000);
  isSameArrayForLoop(a, b);
  isSameArrayJSON(a, b);
}

const testArray1 = generateArray(1000);
const testArray2 = [1, 2, 3];

// ベンチマーク実行
const times = 10000;

const startForLoop = performance.now();
for (let i = 0; i < times; i++) {
  isSameArrayForLoop(testArray1, testArray2);
}
const endForLoop = performance.now();
const forLoopTime = endForLoop - startForLoop;

const startJSON = performance.now();
for (let i = 0; i < times; i++) {
  isSameArrayJSON(testArray1, testArray2);
}
const endJSON = performance.now();
const jsonTime = endJSON - startJSON;

console.log(`ForLoop: ${forLoopTime.toFixed(3)}ms`);
console.log(`JSON: ${jsonTime.toFixed(3)}ms`);
console.log(`JSON は ForLoop の ${(jsonTime / forLoopTime).toFixed(2)} 倍遅い`);

私の手元では以下のような結果になりました。(何度か実行して目で中央値をとりました)

ForLoop: 0.063ms
JSON: 60.265ms
JSON は ForLoop の 950.92 倍遅い

なんとJSON文字列の比較は1000倍近くも遅いようです!なぜでしょうか?

配列をJSON文字列にするには、簡単にいうと次のような処理を行います。

  • 各要素の数値を文字列に変換する
    • 文字列オブジェクトを作成し、数値に対応する文字を書き込む*5
  • 全要素の文字列をカンマ区切りで繋ぎ合わせる
    • 文字列オブジェクトを作成し、要素の文字列をコピーする

プログラムで遅くなる要因は主にIO(通信・ファイル読み書き)ですが、それを除いて次に遅いのは

  • オブジェクトの生成(=メモリの確保)
  • データのコピー

です。*6

JSON文字列を作成するということは、まさにオブジェクトの生成とデータのコピーです。今回やりたいことに対して過剰な計算と言えます。またJSON文字列を作る際には実際には数値以外のケースも考慮するロジックが挟まれています。今回のようにID列を前提とする場合は影響がないでしょうが、数値としては表現可能なNaNや無限大をJSONでは表現できないという制約もあります。これらの理由から、安直にオブジェクトの比較に使うのは適切ではないのです。

約1000倍の差は恣意的な例ですが、forループ版が最も遅くなるようなケースで比較してみましょう。

/**
 * forループで配列を比較する関数
 */
function isSameArrayForLoop(a, b) {
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) {
    if (a[i] !== b[i]) return false;
  }
  return true;
}

/**
 * JSON文字列で配列を比較する関数
 */
function isSameArrayJSON(a, b) {
  return JSON.stringify(a) === JSON.stringify(b);
}

// テストデータ生成
function generateArray(size) {
  return Array.from({ length: size }, () => Math.floor(Math.random() * 1000));
}

const testArray1 = generateArray(100);
const testArray2 = [...testArray1];

// ウォームアップ
for (let i = 0; i < 1000; i++) {
  const a = generateArray(1000);
  const b = generateArray(1000);
  isSameArrayForLoop(a, b);
  isSameArrayJSON(a, b);
}

// ベンチマーク実行
const times = 100000;

const startForLoop = performance.now();
for (let i = 0; i < times; i++) {
  isSameArrayForLoop(testArray1, testArray2);
}
const endForLoop = performance.now();
const forLoopTime = endForLoop - startForLoop;

const startJSON = performance.now();
for (let i = 0; i < times; i++) {
  isSameArrayJSON(testArray1, testArray2);
}
const endJSON = performance.now();
const jsonTime = endJSON - startJSON;

console.log(`ForLoop: ${forLoopTime.toFixed(3)}ms`);
console.log(`JSON: ${jsonTime.toFixed(3)}ms`);
console.log(`JSON は ForLoop の ${(jsonTime / forLoopTime).toFixed(2)} 倍遅い`);

私の手元では以下のような結果になりました。(何度か実行して目で中央値をとりました)

ForLoop: 5.319ms
JSON: 106.229ms
JSON は ForLoop の 19.97 倍遅い

forループが最も遅くなるケースで比べても、JSON版はforループ版に比べて約20倍遅いようです。やはり今回の要件ではforループが妥当と言えます。

前述したように、数値の配列を比較するためにJSONに変換することは適切ではないですが、それ以外のオブジェクトではどうなのでしょうか?やはり、一度メモリに文字列を確保するのは比較という操作に対して過剰なので、避けた方が良いでしょう。JSONに変換できるのであれば、オブジェクトの構造を再起的に辿ることができ、最終的に数値や文字列といったプリミティブの比較に還元することが可能です。*7

今回のケースでは、数値の配列だけを比較すれば十分だったので、高速で単純なforループを使った比較が適切でしょう。実装は十分シンプルで、メンテナンスコストを増大させるようなものではありません。*8

適切な方法を選ぶことに責任を持つ

無駄な計算が増えれば増えるほどユーザ体験は低下しますし、適切でない方法に依存するシステムの規模が増えるほど改修コストは増大します。ユーザー体験の低下や改修コストの増加はサービスの競争力の低下や機会損失につながります。

今回は計測にフォーカスして話をしましたが、良い方法を選ぶためには実践を通じた複合的な学びが必要です。

  • 過去の経験や他者に学ぶ
    • 社内のドキュメント、障害対応履歴などを読む
    • コードレビューを受ける
  • 体系的に学ぶ
    • 技術書・教科書で学ぶ
  • 仮説を立てて計測する

これらを通じて小さな決断の質を高めていくことが大切です。

あなたの書いたコードは、目的に適っているでしょうか?ただ動くだけではなく、十分高速に動作し、メンテナンス性も高いものになっているでしょうか?今までも、これからのAI時代でも、プログラマであればあなたは書いたコードに責任を持つ必要があるのです。


*1:そもそもJavaScriptには言語標準ではプリミティブの他にオブジェクトしか存在せず、複合的な値を表現する方法がないので仕方がないと言えます。

*2:falseはあえてカタカナで書くと、フォールスと読みます。fall, always, haltと同じです。フォールスと読んでください☺️

*3:JavaScriptでは数値・文字列・真偽値のようなメソッドを持たないデータをプリミティブと呼び、値の比較ができるのはプリミティブだけです。プリミティブについてはPrimitive (プリミティブ) - MDN Web Docs 用語集 | MDNをご覧ください。それ以外のデータはオブジェクトと呼ばれていて、===では同一のオブジェクトかどうかが比較されます。そのため、配列の中身が同じだったとしても必ずfalseになるのです。演算子については厳密等価演算子 (===) - JavaScript | MDNをご覧ください。

*4:途中に挟まれているウォームアップは、計測結果を安定させるためのものです。nodeの処理系は実行時の最適化が行われるので、ある程度の回数実行した後で計測する方がより実際の性能を反映する結果になります。ベンチマークはどれもApple M3 Pro、Node.js v24.6.0 で実行しました。

*5:もし自分で数値を文字列にするプログラムを書いたことがなければ、ぜひ書いてみてください。数値を比較するためにわざわざこんな計算をさせる必要はないと感じることでしょう。

*6:実は、メモリの確保やデータのコピーというのはCPUとメインメモリの通信ということになります。ざっくり言うと、CPU内で完結する処理が一番早く、次にCPUとメインメモリの通信を伴う処理、その次にネットワーク通信やファイル書き込みという順で時間がかかるようになります。

*7:このようなオブジェクト同士の比較を一般化したライブラリもいくつか存在します。しかし外部ラリブラリへの依存性を追加することはメンテナンス性の低下やセキュリティ上の問題を増やします。本論と同様に、前提条件を様々に考慮した上での妥当な選択を行う必要があります。

*8:JavaScriptに慣れた方であればeveryを使った実装はどうなのか気になるところでしょう。ぜひ本論に照らし合わせて検討してみてください。しかしながら、あまり本質的でないことを議論しすぎないことも時には必要です☺️

Page top