Help us understand the problem. What is going on with this article?

JavaScriptで簡潔な、複数のキーでのオブジェクトの並び替え

JavaScriptで複数のキーでオブジェクトを並び替えるプログラムです。

例えば、

const fruitsArr = [
  {name: 'ぶどう',   price: 5000, weight: 1000},
  {name: 'もも',    price: 3000, weight: 2000},
  {name: 'りんご',   price: 5000, weight: 5000},
  {name: 'バナナ',  price: 1500, weight: 1000},
  {name: 'メロン',   price: 5000, weight: 1200},
  {name: 'マンゴー', price: 10000, weight: 900},
  {name: 'みかん',  price: 1500, weight: 5000},
]

このような配列を、priceの昇順、同じpriceであればweightの昇順で並び変えます。
compareByAttr関数を作成し、このように書くとシンプルです。compareByAttrは下で実装します。

fruitsArr.sort((o1, o2) => 
  compareByAttr(o1, o2, ['price', 'weight'])
)

console.log(fruitsArr)
//=> [
//      {name: "バナナ", price: 1500, weight: 1000},

//      {name: "みかん", price: 1500, weight: 5000},

//      {name: "もも",   price: 3000, weight: 2000},

//      {name: "ぶどう",  price: 5000, weight: 1000},

//      {name: "メロン",  price: 5000, weight: 1200},
//      {name: "りんご",  price: 5000, weight: 5000},
//      {name: "マンゴー", price: 10000, weight: 900}
// ]

compareByAttrと関連する関数の作成

上記コードのcompareByAttr関数と関連する関数の定義と実装です。

const zipLongest = (...arrays) => {
  const length = Math.max(...(arrays.map(arr => arr.length)))
  return new Array(length).fill().map((_, i) => arrays.map(arr => arr[i]))
}

const compareArr = (arr1, arr2) => {
  const difference = zipLongest(arr1, arr2).find(([v1, v2]) => v1 !== v2)
  if (!difference) {
    return 0
  }
  if (difference[0] === undefined) return -1
  if (difference[1] === undefined) return 1
  if (difference[0] > difference[1]) {
    return 1
  }
  return -1
}

const compareByAttr = (o1, o2, attrs) => {
  const o1Values = attrs.map(attr => o1[attr])
  const o2Values = attrs.map(attr => o2[attr])
  return compareArr(o1Values, o2Values)
}

コードの解説

compareByAttr

compareByAttr関数は、2つのオブジェクト(o1o2)と、1つの配列(attrs)を受け取ります。配列には、比較する対象のキーが、優先度の高い順番に並べられています。

まず、各オブジェクトからattrsで指定されたキーを抜き出して配列にします。
以下のように動作します。

const main = () => {
  const o1 = {name: 'ぶどう', price: 5000, weight: 1000}
  const o2 = {name: 'みかん', price: 1500, weight: 5000}

  compareByAttr(o1, o2, ['price', 'weight'])
}

const compareByAttr = (o1, o2, attrs) => {
  const o1Values = attrs.map(attr => o1[attr])
  //=> [5000, 1000]
  const o2Values = attrs.map(attr => o2[attr])
  //=> [1500, 5000]
}

main()

このようにして得られた結果をcompareArr関数に渡してその結果を返します。

const main = () => {
  const o1 = {name: 'ぶどう', price: 5000, weight: 1000}
  const o2 = {name: 'みかん', price: 1500, weight: 5000}

  compareByAttr(o1, o2, ['price', 'weight'])
}

const compareByAttr = (o1, o2, attrs) => {
  const o1Values = attrs.map(attr => o1[attr])
  //=> [5000, 1000]
  const o2Values = attrs.map(attr => o2[attr])
  //=> [1500, 5000]
  return compareArr(o1Values, o2Values)
  //=> return compareArr([5000, 1000], [1500, 5000])
}

main()

zipLongest

compareArr関数の解説をする前に、zipLongest関数を解説します。この関数は、各配列の同じインデックスの要素をまとめます。各配列の長さが異なる場合には、一番長い配列の長さになります。未定義値はundefinedになります。

const a1 = [1, 2, 3]
const a2 = ['Jan', 'Feb', 'Mar']

zipLongest(a1, a2)
//=> [[1, "Jan"], [2, "Feb"], [3, "Mar"]]

const a3 = [1, 2, 3]
const a4 = ['Jan', 'Feb', 'Mar', 'Apr', 'May']

zipLongest(a3, a4)
//=> [[1, "Jan"], [2, "Feb"], [3, "Mar"], [undefined, "Apr"], [undefined, "May"]]

zipLongest関数では、レスト構文を使用して引数全てをarraysに格納しています。
最終的に作成する配列の長さは、各配列の最大値なので、Math.max関数を使用して長さを求めます。
最後に、各配列の同じインデックスの値をまとめます。

const zipLongest = (...arrays) => {
  const length = Math.max(...(arrays.map(arr => arr.length)))
  return new Array(length).fill().map((_, i) => arrays.map(arr => arr[i]))
}

もっと詳細なコードの解説は参考リンクを参照してください。

compareArr

compareArr関数は、2つの配列(arr1,arr2)を受け取り、配列を最初から値を比べていき、arr1の値の方が大きければ1arr2の値の方が大きければ-1を返します。同じ値であれば次の値を見ます。全て同じ値であれば0を返します。

最初の値をみて、違うならばその値同士で判定、同じならば次の値を見る、という操作は、"最初に違う値が出たところで、大小を判定する"と言い換えることができます。そこで、配列を最初から見ていって、違う値が出たところを取得します。

まず、zipLongest関数で2つの配列の同じインデックスをまとめましょう。

const main = () => {
  const arr1 = [100, 500]
  const arr2 = [100, 300]

  compareArr(arr1, arr2)
}

const compareArr = (arr1, arr2) => {
  zipLongest(arr1, arr2)
  //=> [[100, 100], [500, 300]]
}

main()

次に、最初に違う値になる時のarr1arr2の値を取得します。Array.prototype.find()メソッドを使用します。このメソッドは、渡された関数を満たす配列内の最初の要素の値を返します。
find()メソッドに([v1, v2]) => v1 !== v2を渡し、値が等しくない最初の値を取得します。

const main = () => {
  const arr1 = [100, 500]
  const arr2 = [100, 300]

  compareArr(arr1, arr2)
}

const compareArr = (arr1, arr2) => {
  const difference = zipLongest(arr1, arr2).find(([v1, v2]) => v1 !== v2)
  //=> [[100, 100], [500, 300]].find(([v1, v2]) => v1 !== v2)
  //=> [500, 300]
}

main()

違う値が見つからない場合(全て同じ値)のときは、undefinedになります。

const main = () => {
  const arr1 = [100, 500]
  const arr2 = [100, 500]

  compareArr(arr1, arr2)
}

const compareArr = (arr1, arr2) => {
  const difference = zipLongest(arr1, arr2).find(([v1, v2]) => v1 !== v2)
  //=> undefined
}

main()

differenceundefinedの場合は等しいので0を返します。
それ以外の場合はarr1の値(difference[0])の値が大きければ1、そうでなければ-1を返します。
ただし、片方がundefinedの場合は大小比較ができないので、先に判定しておきます。undefindは並び替えで先に来るようにします。(参照: JavaScriptでundefinedの大小比較はつねにfalse。その理由を、仕様を引用して解説する - Qiita)

const main = () => {
  const arr1 = [100, 500]
  const arr2 = [100, 300]

  compareArr(arr1, arr2)
}

const compareArr = (arr1, arr2) => {
  const difference = zipLongest(arr1, arr2).find(([v1, v2]) => v1 !== v2)
  //=> [500, 300]
  if (!difference) {
    return 0
  }
  if (difference[0] === undefined) return -1
  if (difference[1] === undefined) return 1
  if (difference[0] > difference[1]) {
    // この例では1がreturnされる
    return 1
  }
  return -1
}

main()

まとめ

もう一度全てのコードをコメント付きで書いておきます。コード量は多いですが、呼び出し側はすっきりします。
また、個々の関数はそれ自体で独立性が高く、他の用途でも有用です。

/**
 * 各配列の同じインデックスの要素をまとめます。
 * 各配列の長さが異なる場合には、一番長い配列の長さになります。
 * 未定義値はundefinedになります。
 * 
 * @param  {...Array} arrays 
 */
const zipLongest = (...arrays) => {
  const length = Math.max(...(arrays.map(arr => arr.length)))
  return new Array(length).fill().map((_, i) => arrays.map(arr => arr[i]))
}

/**
 * 2つの配列を比較します。
 * 配列の要素をインデックスの小さい順に比較し、arr1の要素の方が大きければ1を返し、arr2の要素の方が大きければ-1を返します。
 * 全ての値が等しいときは、0を返します。
 * 
 * @param {Array} arr1 比較する配列1個目
 * @param {Array} arr2 比較する配列2個目
 */
const compareArr = (arr1, arr2) => {
  const difference = zipLongest(arr1, arr2).find(([v1, v2]) => v1 !== v2)
    if (!difference) {
      return 0
    }
    if (difference[0] === undefined) return -1
    if (difference[1] === undefined) return 1

    if (difference[0] > difference[1]) {
      return 1
    }
    return -1
}

/**
 * 与えられたキーによってオブジェクトの大小を判定します。
 * 与えられたキーに対応する値が、o1の方が大きい場合には1、o2の方が大きい場合には-1を返します。
 * 等しい場合には0を返します。 
 * 
 * @param {Object} o1 比較対象の1個目のオブジェクト
 * @param {Object} o2 比較対象の2個目のオブジェクト
 * @param {Array} attrs 比較する対象のキー。優先度の高い順番に並べる
 */
const compareByAttr = (o1, o2, attrs) => {
  const o1Values = attrs.map(attr => o1[attr])
  const o2Values = attrs.map(attr => o2[attr])
  return compareArr(o1Values, o2Values)
}

// --- 以下、使用方法と実行結果 ---

const fruitsArr = [
  {name: 'ぶどう',   price: 5000, weight: 1000},
  {name: 'もも',     price: 3000, weight: 2000},
  {name: 'りんご',   price: 5000, weight: 5000},
  {name: 'バナナ',   price: 1500, weight: 1000},
  {name: 'メロン',   price: 5000, weight: 1200},
  {name: 'マンゴー', price: 10000, weight: 900},
  {name: 'みかん',  price: 1500, weight: 5000},
]

// priceの昇順、weightの昇順の優先順で並び替え
fruitsArr.sort((o1, o2) => 
  compareByAttr(o1, o2, ['price', 'weight'])
)

console.log(fruitsArr)
//=>[
//      {name: "バナナ", price: 1500, weight: 1000},

//      {name: "みかん", price: 1500, weight: 5000},

//      {name: "もも",   price: 3000, weight: 2000},

//      {name: "ぶどう",  price: 5000, weight: 1000},

//      {name: "メロン",  price: 5000, weight: 1200},
//      {name: "りんご",  price: 5000, weight: 5000},
//      {name: "マンゴー", price: 10000, weight: 900}
// ]

// priceの降順、weightの降順の優先順で並び替え
fruitsArr.sort((o1, o2) => 
  -1 * compareByAttr(o1, o2, ['price', 'weight'])
)
//=> [
// {name: "マンゴー", price: 10000, weight: 900},
// {name: "りんご", price: 5000, weight: 5000},
// {name: "メロン", price: 5000, weight: 1200},
// {name: "ぶどう", price: 5000, weight: 1000},
// {name: "もも", price: 3000, weight: 2000},
// {name: "みかん", price: 1500, weight: 5000},
// {name: "バナナ", price: 1500, weight: 1000}

まとめ その2

"javascript ソート 複数キー"で検索すると、以下のようなコードが出てきます。

fruitsArr.sort((o1,o2) => {
    if(o1.price > o2.price) return 1;
    if(o1.price < o2.price) return -1;
    if(o1.weight > o2.weight) return 1;
    if(o1.weight < o2.weight) return -1;
    return 0;
});

もちろん、これでも動作します。ただ、priceだけで4回も書かなければいけないことが面倒ですし、変更時に漏れも発生します。
また、この程度であればバグの入り込む余地も少なく、レビューもそれほど大変ではありません。しかし、数が大きくなってきたときに、今回のような実装も検討してはいかがでしょうか。

あとがき

Pythonだと、2つ(やそれ以上)のキーで並び替える時には、以下のようにシンプルに書けます。

list1 = [
    {'name': 'ぶどう',   'price': 5000, 'weight': 1000},
    # 中略
    {'name': 'みかん',  'price': 1500, 'weight': 5000},
]

sorted(list1, key=itemgetter('price', 'weight'))

JavaScriptでも似たように書けないか試行錯誤したところ、上記のコードにたどりつきました。

参考リンク

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした