Pythonで辞書(もしくはインスタンス)のリストをうまく整形する方法について

Webアプリを開発していると、SQLクエリを実行した結果をAPIのレスポンスに合うように整形するという処理を実装する事が良くあります。

SQLクエリを実行した結果得られるデータは、DB接続に利用しているドライバで定義されているインスタンスのリストになっている事がほとんどだと思いますが、このデータを整形する処理は意外と面倒くさい場合が多いのではないでしょうか。(処理自体は簡単なものが大半なので、余計にそう感じてしまう事が多いですよね。)

この記事では、上記のようなデータの整形処理を効率良く実装する方法を検討してみました。

---

例えば、下記のようなデータがDBから取得出来たとして
このデータを整形する事を考えてみましょう。

```python

data_list = [
  {
    'user_id': 1,
    'group_name': 'GroupA',
    'user_name': 'user1',
    'email': 'user1@xxx.co.jp'
  },
  {
    'user_id': 2,
    'group_name': 'GroupB',
    'user_name': 'user2',
    'email': 'user2@xxx.co.jp'
  },
  {
    'user_id': 3,
    'group_name': 'GroupB',
    'user_name': 'user3',
    'email': 'user3@xxx.co.jp'
  },
  {
    'user_id': 4,
    'group_name': 'GroupA',
    'user_name': 'user4',
    'email': 'user4@xxx.co.jp'
  },
  {
    'user_id': 5,
    'group_name': 'GroupA',
    'user_name': 'user5',
    'email': 'user5@xxx.co.jp'
  }
]
```

期待する結果は、下記のようにgroup_name毎にグループ化された
形式のデータに変換される事とします。

```json
{
  "GroupA": [
    {
      "user_id": 1,
      "user_name": "user1",
      "email": "user1@xxx.co.jp"
    },
    {
      "user_id": 4,
      "user_name": "user4",
      "email": "user4@xxx.co.jp"
    },
    {
      "user_id": 5,
      "user_name": "user5",
      "email": "user5@xxx.co.jp"
    }
  ],
  "GroupB": [
    {
      "user_id": 2,
      "user_name": "user2",
      "email": "user2@xxx.co.jp"
    },
    {
      "user_id": 3,
      "user_name": "user3",
      "email": "user3@xxx.co.jp"
    }
  ]
}
```

---

整形処理の実装方法として、以下の2パターンを検討してみました。
※以降のコードの動作検証には、Pythonのバージョン3.7.3を使用しています。

#####パターン1
以下のように、for文の中で1個ずつデータを詰めていく書き方が一番単純なやり方だと思います。

```python
# 引数はDBから取得したデータ
def sample1(data_list):
    
    result_dict = {}
    
    for data in data_list:
        
        group_name = data.get('group_name')
        
        # group_name が未登録の時の考慮
        if group_name not in result_dict:
            result_dict[group_name] = []
            
        # group_name を除いた辞書を生成してリストに追加
        result_dict[group_name].append({key:value for key, value in data.items() if key != 'group_name'})
        
    return result_dict
```

#####パターン2
reduceを利用したやり方でも、今回の整形処理は実装する事が出来ます。

```python
from functools import reduce

# 引数はDBから取得したデータ
def sample2(data_list):
    
    def accumulate(total, target):
        
        group_name = target.get('group_name')
        
        # group_name が未登録の時の考慮
        if group_name not in total:
            total[group_name] = []
            
        # group_name を除いた辞書を生成してリストに追加
        total[group_name].append({key:value for key, value in target.items() if key != 'group_name'})
        
        return total
        
    return reduce(accumulate, data_list, {})
```

この実装を簡単に説明すると、reduceは第1引数に関数、第2引数にデータ、第3引数にオプションで初期値を渡す事が出来るので、データ整形用の関数(accumulate)、DBから取得したデータ(data_list)、初期値として空の辞書を渡しています。
そうすると、初回でaccumulateが呼ばれた時はtotalには空の辞書が、targetにはdata_listの1件目のデータが渡され、2回目以降のtotalには前回のリターン値が設定されるようになります。

---

パターン1の書き方は、どのような整形処理でも実装出来る点はメリットとして挙げられますが、今回のような整形処理が必要になる度に実装する必要がある(再利用性が低い)点がデメリットだと思います。

一方で、パターン2の書き方は複雑な処理を実装する場合は可読性が下がる可能性がありますが、データ整形用の関数が参照するカラム名を動的に変更するなどをして、整形処理を共通化する事が出来る点をメリットとして挙げられます。

また、reduceを使うと速度面で問題が出るのではないか？という懸念があったので
念の為、それぞれのパターンに対して10000000レコードのデータを整形した時にかかる時間を計測してみました。※jupyter notebookにて実施

```cronsole
%timeit sample1(data_list)
11.6 s ± 211 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit sample2(data_list)
12.3 s ± 290 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
```

上記の実行結果から、reduceを利用した実装の方がやや遅いという事が分かりましたが、それでも10000000レコードのデータで1秒ほどの差しか無いので、速度面はほとんど気にしなくても良いと思います。

以上から、結論としては
極力reduceを利用して処理を共通化するのを基本方針としつつ、複雑な処理については諦めてfor文を使うというのが現実的かと思います。