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

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``は下で実装します。

```javascript
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``関数と関連する関数の定義と実装です。

```javascript
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つのオブジェクト(``o1``、``o2``)と、1つの配列(``attrs``)を受け取ります。配列には、比較する対象のキーが、優先度の高い順番に並べられています。

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

```javascript
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``関数に渡してその結果を返します。

```javascript
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``になります。

```javascript
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``関数を使用して長さを求めます。
最後に、各配列の同じインデックスの値をまとめます。

```javascript
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``の値の方が大きければ``1``、``arr2``の値の方が大きければ``-1``を返します。同じ値であれば次の値を見ます。全て同じ値であれば``0``を返します。

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

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

```javascript
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()
```

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


```javascript
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``になります。

```javascript
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()
```

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

```javascript
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()
```

# まとめ

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

```javascript
/**
 * 各配列の同じインデックスの要素をまとめます。
 * 各配列の長さが異なる場合には、一番長い配列の長さになります。
 * 未定義値は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 ソート 複数キー"で検索すると、以下のようなコードが出てきます。

```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つ(やそれ以上)のキーで並び替える時には、以下のようにシンプルに書けます。

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

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

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

# 参考リンク

* [JavaScriptでPython風のzip_longest関数を実装する - Javaエンジニア、React+Redux+Firebaseでアプリを作る](http://yucatio.hatenablog.com/entry/2020/02/07/080926)
* [JavaScript つい忘れてしまう配列のソート方法 - Qiita](https://qiita.com/PianoScoreJP/items/f0ff7345229871039672)<br/>(プログラムの例で、同じようにフルーツの名前、価格、重さを使用していますが全くの偶然です。不快に思いましたら申し訳ございません)
* [(Tips)オブジェクトのソート。複数の数値プロパティ - Qiita](https://qiita.com/cocottejs/items/511f6be58efe00339498)