JavaScript MapとSetの業務実装パターン|実務で使える活用事例とコード

未分類

JavaScript MapとSetの業務実装パターン|実務で使える活用事例とコード

JavaScriptを使った開発で、配列やオブジェクトだけでは対応しきれない場面が増えてきました。そこで活躍するのがMapSetです。これらはES6から導入されたコレクション型で、適切に使い分けることで、コードの可読性と性能が劇的に向上します。本記事では、実際の業務で遭遇する問題をMapとSetでどう解決するのか、具体的なコード例を交えて解説します。

MapとSetの基本的な違い

まず、MapとSetがどのような構造であるかを簡潔に説明します。

Setは値の集合を管理します。重複がなく、順序は挿入順です。値の存在確認が高速です。

const mySet = new Set([1, 2, 3, 3, 4]);
console.log(mySet); // Set(4) { 1, 2, 3, 4 }
console.log(mySet.has(2)); // true

Mapはキーと値のペアを管理します。キーには任意の型が使用でき、オブジェクトをキーにすることもできます。

const myMap = new Map();
myMap.set('name', '太郎');
myMap.set(1, 'one');
console.log(myMap.get('name')); // 太郎
console.log(myMap.size); // 2

業務でのユースケース

ユースケース1:重複排除と存在確認

ユーザーIDのリストから重複を除き、特定のIDが含まれているか高速に確認する場面は非常に多いです。配列のfilterやincludesを使うと計算量がO(n)になってしまいますが、Setなら検索がO(1)です。

例えば、ECサイトで購入履歴のあるユーザーIDを管理する場合:

// 購入履歴から一度でも購入したユーザーを取得
const purchaseHistory = [
  { userId: 101, productId: 'A' },
  { userId: 102, productId: 'B' },
  { userId: 101, productId: 'C' },
  { userId: 103, productId: 'A' }
];

// SetとMapを組み合わせた実装
const userPurchaseMap = new Map();

purchaseHistory.forEach(({ userId, productId }) => {
  if (!userPurchaseMap.has(userId)) {
    userPurchaseMap.set(userId, new Set());
  }
  userPurchaseMap.get(userId).add(productId);
});

// ユーザー101が商品Aを購入したか確認
const hasPurchased = userPurchaseMap.has(101) && 
                     userPurchaseMap.get(101).has('A');
console.log(hasPurchased); // true

ユースケース2:複合キーでの高速検索

オブジェクトをキーとして使用することは、一見複雑に思えますが、業務では頻出です。例えば、座標ペア(x, y)をキーにして、そこに存在するオブジェクトを管理する場合です。

// ゲームのマップ上で、座標ごとにキャラクターを管理する
const characterMap = new Map();

function setCharacterAtPosition(x, y, character) {
  const key = `${x},${y}`; // 複合キーを文字列で作成
  characterMap.set(key, character);
}

function getCharacterAtPosition(x, y) {
  const key = `${x},${y}`;
  return characterMap.get(key);
}

setCharacterAtPosition(10, 20, { name: 'Hero', hp: 100 });
setCharacterAtPosition(10, 21, { name: 'Enemy', hp: 50 });

console.log(getCharacterAtPosition(10, 20)); // { name: 'Hero', hp: 100 }
console.log(getCharacterAtPosition(10, 22)); // undefined

ユースケース3:キャッシュの実装

API呼び出しの結果をMapでキャッシュし、同じリクエストには保存された結果を返す。容量制限付きのキャッシュは、LRU(Least Recently Used)アルゴリズムで実装できます。

class SimpleLRUCache {
  constructor(maxSize = 10) {
    this.cache = new Map();
    this.maxSize = maxSize;
  }

  get(key) {
    if (!this.cache.has(key)) {
      return null;
    }
    // アクセスされた順序を最新にする
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.maxSize) {
      // 最も古いキー(最初のキー)を削除
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

// 使用例
const cache = new SimpleLRUCache(3);
cache.set('user:1', { id: 1, name: '太郎' });
cache.set('user:2', { id: 2, name: '次郎' });
cache.set('user:3', { id: 3, name: '三郎' });
cache.set('user:4', { id: 4, name: '四郎' }); // user:1が削除される

console.log(cache.get('user:1')); // null
console.log(cache.get('user:2')); // { id: 2, name: '次郎' }

TypeScriptでの型安全な実装

実務では、JavaScriptだけでなくTypeScriptで開発することも多いです。MapとSetをTypeScriptで使用する場合は、ジェネリクス型を活用して型安全にします。

interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  productId: string;
  title: string;
  price: number;
}

class UserProductManager {
  private userPurchases: Map<number, Set<string>> = new Map();
  private productCache: Map<string, Product> = new Map();
  private activeUserIds: Set<number> = new Set();

  addPurchase(userId: number, productId: string): void {
    if (!this.userPurchases.has(userId)) {
      this.userPurchases.set(userId, new Set());
    }
    this.userPurchases.get(userId)!.add(productId);
    this.activeUserIds.add(userId);
  }

  getPurchasedProducts(userId: number): string[] {
    return Array.from(this.userPurchases.get(userId) || new Set());
  }

  cacheProduct(product: Product): void {
    this.productCache.set(product.productId, product);
  }

  getProduct(productId: string): Product | undefined {
    return this.productCache.get(productId);
  }

  getActiveUsers(): number[] {
    return Array.from(this.activeUserIds);
  }
}

// 使用例
const manager = new UserProductManager();
manager.addPurchase(1, 'PROD001');
manager.addPurchase(1, 'PROD002');
manager.addPurchase(2, 'PROD001');

manager.cacheProduct({
  productId: 'PROD001',
  title: 'ノートパソコン',
  price: 150000
});

console.log(manager.getPurchasedProducts(1));
console.log(manager.getProduct('PROD001'));
console.log(manager.getActiveUsers());

Pythonでの同等実装

JavaScriptだけでなく、バックエンド言語との連携を考えることも重要です。同じロジックをPythonで実装する場合を見てみましょう。

from typing import Dict, Set, List, Optional
from collections import defaultdict

class UserProductManager:
    def __init__(self):
        self.user_purchases: Dict[int, Set[str]] = defaultdict(set)
        self.product_cache: Dict[str, Dict] = {}
        self.active_user_ids: Set[int] = set()

    def add_purchase(self, user_id: int, product_id: str) -> None:
        self.user_purchases[user_id].add(product_id)
        self.active_user_ids.add(user_id)

    def get_purchased_products(self, user_id: int) -> List[str]:
        return list(self.user_purchases.get(user_id, set()))

    def cache_product(self, product: Dict) -> None:
        self.product_cache[product['productId']] = product

    def get_product(self, product_id: str) -> Optional[Dict]:
        return self.product_cache.get(product_id)

    def get_active_users(self) -> List[int]:
        return list(self.active_user_ids)

# 使用例
manager = UserProductManager()
manager.add_purchase(1, 'PROD001')
manager.add_purchase(1, 'PROD002')
manager.add_purchase(2, 'PROD001')

manager.cache_product({
    'productId': 'PROD001',
    'title': 'ノートパソコン',
    'price': 150000
})

print(manager.get_purchased_products(1))
print(manager.get_product('PROD001'))
print(manager.get_active_users())

よくある応用パターン

パターン1:グループ化とフィルタリング

複数の条件でデータをグループ化し、後で素早くアクセスする場面です。

// 注文データをステータスごとにグループ化
const orders = [
  { id: 1, status: 'pending', amount: 10000 },
  { id: 2, status: 'completed', amount: 20000 },
  { id: 3, status: 'pending', amount: 15000 },
  { id: 4, status: 'cancelled', amount: 5000 },
  { id: 5, status: 'completed', amount: 30000 }
];

const ordersByStatus = new Map();

orders.forEach(order => {
  if (!ordersByStatus.has(order.status)) {
    ordersByStatus.set(order.status, []);
  }
  ordersByStatus.get(order.status).push(order);
});

// 完了した注文の合計金額を計算
const completedOrders = ordersByStatus.get('completed') || [];
const totalCompleted = completedOrders.reduce((sum, order) => sum + order.amount, 0);
console.log(totalCompleted); // 50000

パターン2:リアルタイムデータの監視

WebSocketやイベントリスナーで受け取るデータを、Mapで追跡し、変更があった場合のみ処理する。

class DataTracker {
  constructor() {
    this.currentData = new Map();
    this.listeners = new Set();
  }

  update(id, data) {
    const previousData = this.currentData.get(id);
    
    // 実際のデータ変更があったかチェック
    if (JSON.stringify(previousData) !== JSON.stringify(data)) {
      this.currentData.set(id, data);
      this.notifyListeners(id, previousData, data);
    }
  }

  addListener(callback) {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }

  notifyListeners(id, oldData, newData) {
    this.listeners.forEach(callback => {
      callback(id, oldData, newData);
    });
  }

  getData(id) {
    return this.currentData.get(id);
  }
}

// 使用例
const tracker = new DataTracker();

tracker.addListener((id, oldData, newData) => {
  console.log(`ID ${id} が更新されました:`, newData);
});

tracker.update(1, { name: '太郎', status: 'online' });
tracker.update(1, { name: '太郎', status: 'online' }); // 変更がないため通知されない
tracker.update(1, { name: '太郎', status: 'offline' }); // 通知される

パターン3:逆マッピングの管理

キーから値への対応と、値からキーへの対応の両方が必要な場合。

class BiDirectionalMap {
  constructor() {
    this.keyToValue = new Map();
    this.valueToKey = new Map();
  }

  set(key, value) {
    // 既存の関連付けを削除
    if (this.keyToValue.has(key)) {
      this.valueToKey.delete(this.keyToValue.get(key));
    }
    if (this.valueToKey.has(value)) {
      this.keyToValue.delete(this.valueToKey.get(value));
    }

    this.keyToValue.set(key, value);
    this.valueToKey.set(value, key);
  }

  getByKey(key) {
    return this.keyToValue.get(key);
  }

  getByValue(value) {
    return this.valueToKey.get(value);
  }
}

// 使用例:言語コードと言語名の相互参照
const languageMap = new BiDirectionalMap();
languageMap.set('ja', '日本語');
languageMap.set('en', '英語');
languageMap.set('zh', '中国語');

console.log(languageMap.getByKey('ja')); // 日本語
console.log(languageMap.getByValue('英語')); // en

注意点と落とし穴

注意点1:オブジェクトキーの参照に注意

Mapでオブジェクトをキーとして使用する場合、同じ内容でも異なるオブジェクト参照なら別のキーとして扱われます。

const map = new Map();
const obj1 = { x: 1, y: 2 };
const obj2 = { x: 1, y: 2 };

map.set(obj1, 'value1');
map.set(obj2, 'value2');

console.log(map.size); // 2(同じ内容でも異なるキーとして扱われる)
console.log(map.get(obj1)); // value1
console.log(map.get(obj2)); // value2

複合キーを使う場合は、文字列キーに統一するか、JSON.stringifyを使ってハッシュ化することをお勧めします。

注意点2:Setでのオブジェクト比較

Setでもオブジェクトの参照比較が行われます。

const set = new Set();
const user1 = { id: 1, name: '太郎' };
const user2 = { id: 1, name: '太郎' };

set.add(user1);
set.add(user2);

console.log(set.size); // 2
console.log(set.has(user1)); // true
console.log(set.has({ id: 1, name: '太郎' })); // false

注意点3:WeakMapとWeakSetの使用場面

メモリ管理が重要な場合、WeakMapやWeakSetを使用することで、参照がなくなると自動的に削除されます。ただし、WeakMapはキーのみが弱参照、WeakSetは値が弱参照です。

// WeakMapを使用したプライベート属性の実装
const privateData = new WeakMap();

class User {
  constructor(name, password) {
    this.name = name;
    privateData.set(this, { password });
  }

  verifyPassword(inputPassword) {
    return privateData.get(this).password === inputPassword;
  }
}

const user = new User('太郎', 'secret123');
console.log(user.verifyPassword('secret123')); // true
console.log(user.password); // undefined(privateDataからはアクセス不可)

注意点4:パフォーマンスの測定

大規模なデータセットでMapやSetを使用する場合は、パフォーマンスを測定することが大切です。

// 配列とSetでの検索速度比較
const largeArray = Array.from({ length: 100000 }, (_, i) => i);
const largeSet = new Set(largeArray);
const searchValue = 99999;

console.time('配列の検索');
for (let i = 0; i < 1000; i++) {
  largeArray.includes(searchValue);
}
console.timeEnd('配列の検索');

console.time('Setの検索');
for (let i = 0; i < 1000; i++) {
  largeSet.has(searchValue);
}
console.timeEnd('Setの検索');
// 結果:Setの方が圧倒的に高速

まとめ

JavaScriptのMapとSetは、単なる便利なデータ構造ではなく、実務での複雑な問題を効率的に解決するツールです。本記事で紹介した業務パターンは、実際のプロジェクトで頻出する事例ばかりです。

重要なポイントをまとめると:

  • Setは重複排除と高速な存在確認に最適
  • Map
  • TypeScriptを使う場合はジェネリクス型で型安全にする
  • 複合キーは文字列に統一してシンプルに保つ
  • オブジェクトをキーにする場合は参照管理に注意
  • 大規模データではパフォーマンス測定を忘れずに
  • WeakMapやWeakSetはメモリ管理が必要な場面で活躍

これらの実装パターンを適切に組み合わせることで、保守性が高く、パフォーマンスに優れたコードが実現できます。業務で一度使い始めると、その利便性に気づくはずです。今後のプロジェクトで、ぜひこれらのコレクション型を活用してみてください。

タイトルとURLをコピーしました