jsonで記載したクラス関係から一発でオブジェクトを生成しようとした話


未経験からIT業界に入って2年目、まだまだ知らないことだらけですがなんやかんやで頑張っています。

さて、今回私はこういう勉強をしました。

- `json.load`を使ってjson→pythonへデコードしよう
- `dict`を挟まず一発でオブジェクトを作成しよう

順を追って話しましょう。
jsonでこんな設定ファイルを作っていたとします。
~~~cfg.json
{
    "class": "Hoge",
    "name": "hoge1",
    "hugas": [
        {
            "class": "Huga",
            "name": "huga1"
        },
        {
            "class": "Huga",
            "name": "huga2"
        }
    ]
}
~~~
`Hoge`が`Huga`を複数持つ設定です。
このファイルに書かれたクラス情報をそのまま、一発でオブジェクトを生成したいです。

## 1.準備
クラスを実装しておきます。
~~~python
class Huga:
    def __init__(self, name: str):
        self.name = name


class Hoge:
    def __init__(self, name:str, huga: Huga):
        self.name = name
        self.hugas = [huga]

~~~
可読性のために引数の型を明示しています。

## 2.試行錯誤
### 2.1 勉強前だったらこうしてる
一番素直にするなら、`json.load`でpythonの辞書形式でデコードしてから、それらの値を逐次的に入れていく方法でしょうか。

~~~python
import json

############
# 上記のクラス
############

def main():
    f = open("path/to/cfg.json", "r")
    data = json.load(f)

    # data = {'class': 'Hoge', 'name': 'hoge1',
    #'hugas': [{'class': 'huga', 'name': 'huga1'},
    #          {'class': 'huga', 'name': 'huga2'}]}

    huga_1 = Huga(name=data['hugas'][0]['name'])
    huga_2 = Huga(name=data['hugas'][1]['name'])
    
    print(huga_1, type(huga_1), huga_1.name)
    # <__main__.Huga object at 0x7fe7244f7850> <class '__main__.Huga'> huga1
    print(huga_2, type(huga_2), huga_2.name)
    # <__main__.Huga object at 0x7fe7244f7700> <class '__main__.Huga'> huga2

    hoge = Hoge(name=data['name']) 
    
    print(type(hoge), hoge.name)
    # <class '__main__.Hoge'> hoge1
    
    hoge.add_huga(huga_1)
    hoge.add_huga(huga_2)

    print(hoge.hugas)
    # [<__main__.Huga object at 0x7fe7244f7850>, 
    #  <__main__.Huga object at 0x7fe7244f7700>]
~~~
順番としてはこんな感じです。
1. Hugaオブジェクトを作成 (huga_1, huga_2)
1. Hogeオブジェクトを作成(hoge)
1. hogeにhuga_1, huga_2を持たせる

(もっとこう、パパッとできないだろうか...)

### 2.2 デコード関数を自作する
調べたら、`json.load`に`object_hook`という引数があり、ここに"こんな感じでデコードしてほしい"という関数を入れるとその通りにしてくれるらしい。

というわけで早速、作ってみました。
まずは各クラスに自分の型のオブジェクトを返す関数を作成します。
~~~ python
class Huga:
    def __init__(self, name: str):
        self.name = name

    # new
    @staticmethod
    def hook(obj):
        return Huga(name=obj["name"])


class Hoge:
    def __init__(self, name: str, huga: Huga):
        self.name = name
        self.hugas = [huga]

    def add_huga(self, huga: Huga):
        self.hugas.append(huga)

    # new
    @staticmethod
    def hook(obj):
        return Hoge(name=obj["name"], huga=obj["hugas"])
~~~
どちらも引数として与えられた辞書をもとに、新しいオブジェクトを返す関数です。

`object_hook`に渡す関数はこれらを組み合わせて以下のように作りました。
~~~ python
def custom_decode(obj):
    if isinstance(obj, dict):
        if obj["class"] == "Hoge":
            obj = Hoge.hook(obj)
            return obj
        elif obj["class"] == "Huga":
            obj = Huga.hook(obj)
            return obj
        else:
            return obj
    else:
        return obj
~~~
(設定ファイルにありながら使われてこなかった`class`キー、ここで初登場です)
`class`キーの値に対応する関数を呼び出してオブジェクトを生成するか、何もせず返す関数です。
実行してみましょう。

~~~ python
def main():
    cfg = "./cfg.json"
    f = open(cfg, "r")
    ent = json.load(f, object_hook=custom_decode)

    print(ent) 
    # <__main__.Hoge object at 0x7f55bdfc6940>
    
    print(ent.name) 
    # hoge1
    
    print(ent.hugas) 
    # [[<__main__.Huga object at 0x7f55bdfeeb50>, 
    #   <__main__.Huga object at 0x7f55bdfc6640>]]

    for hu in ent.hugas[0]:
        print(hu.name)
    # huga1
    # huga2
~~~
設定ファイルの通りにオブジェクトの作成ができました!
### 2.3 あれ？
よく見たら`ent.hugas`がリストの中にリストが入っています。これはして欲しい挙動と少し違います。
デバッグしてみると、以下のような挙動をしてました。
~~~ python
# cfg.json読み込み時
{'class': 'Hoge', 'name': 'hoge', 'hugas': [{'class': 'Huga', 'name': 'huga1'},{'class': 'Huga', 'name': 'huga2'}]}
# custom_decode:1回目
{'class': 'Hoge', 'name': 'hoge', 'hugas': [<__main__.Huga object at 0x7f55bdfeeb50>,{'class': 'Huga', 'name': 'huga2'}]}
# custom_decode:2回目
{'class': 'Hoge', 'name': 'hoge', 'hugas': [<__main__.Huga object at 0x7f55bdfeeb50>,<__main__.Huga object at 0x7f55bdfc6640>]}
# custom_decode:3回目
<__main__.Hoge object at 0x7f55bdfc6940>
~~~
分かったことは2つ。

- 呼び出される辞書は末端から。
- リストは保持される。

2つ目に関しては[公式のドキュメント](https://docs.python.org/3/library/json.html#json.JSONDecoder)に書いてありましたね。厳密には`[ ]`はjsonではarrayであり、pythonオブジェクトに変換する際はlistに置き換えられます。

### 2.4 じゃあどうするか
hook()関数は直しません。`__init__()`を直します。
`Huga`オブジェクト単体を受け取っていたのを、`Huga`オブジェクトが格納されたリストを受け取るように変更しました。
~~~ python
from typing import List


class Hoge:
    # def __init__(self, name: str, huga: Huga):
    def __init__(self, name: str, hugas: List[Huga]):
        self.name = name
        # self.hugas = [huga]
        self.hugas = hugas

    def add_huga(self, huga):
        self.hugas.append(huga)

    @staticmethod
    def hook(obj):
        return Hoge(name=obj["name"], huga=obj["hugas"])
~~~

もちろん`hook()`を直す考え方もあるのでしょうが、よく考えたら`Hoge`は`Huga`のリストを持つことが`cfg.json`で決められているのでこのようにしたほうが設定に忠実です。
ちなみに`hugas`の型については単に`list`でもいいのですが、個人的にはこちらのほうが可読性が上がるかと思います。

このようにして、コードの全体はこんな形にまとまりました。
~~~ python
import json
from typing import List


class Huga:
    def __init__(self, name: str):
        self.name = name

    @staticmethod
    def hook(obj: dict):
        return Huga(name=obj["name"])


class Hoge:
    def __init__(self, name: str, hugas: List[Huga]):
        self.name = name
        self.hugas = hugas

    def add_huga(self, huga: Huga):
        self.hugas.append(huga)

    @staticmethod
    def hook(obj: dict):
        return Hoge(name=obj["name"], hugas=obj["hugas"])


def custom_decode(obj: dict):
    if isinstance(obj, dict):
        if obj["class"] == "Hoge":
            obj = Hoge.hook(obj)
            return obj
        elif obj["class"] == "Huga":
            obj = Huga.hook(obj)
            return obj
        else:
            return obj
    else:
        return obj


def main():
    cfg = "./cfg.json"
    f = open(cfg, "r")
    ent = json.load(f, object_hook=custom_decode)

    print(ent)
    # <__main__.Hoge object at 0x7f7913fe9490>
    
    print(ent.name)
    # hoge1
    
    print(ent.hugas)
    # [<__main__.Huga object at 0x7f7913fe9250>, 
    #  <__main__.Huga object at 0x7f7913fe92b0>]
    
    for hu in ent.hugas:
        print(hu.name)
        # huga1
        # huga2


if __name__ == "__main__":
    main()
~~~
### 2.4 結論
今回やりたかったことは「jsonファイルの内容から一発でオブジェクトを生成すること」で、そのためには
1. 自作クラスの型のオブジェクトを生成する関数を作り、
1. またそれらを使い分ける関数を作り、
1. `json.load`の`object_hook`引数に渡す。

ことをすればよい、という話でした。
自分でいちいちオブジェクトを作らなくてよくなりました～

## 3　感想
やってみたら簡単な話でした。調べてもなかなか出てこなかったのは簡単で発信する程のことでもなかったから...？
でもいいんです、私は確かに学んだのだ。
あと型ヒントは単に想定した型を明示してるだけで、それと異なる型が渡されてもスルーされるっぽいことが分かりました。なんとなくで使ってるとよくないので、ここら辺はまた別でちゃんと調べてみます。