現在地: ホーム Dive Into Python 3

難易度: ♦♦♦♦♢

Pythonオブジェクトをシリアライズする

このアパートに来てから毎週土曜は、6時15分に起きて、低脂肪牛乳を1/4カップ入れたシリアルを食べながら、
このカウチの、この端に座って、BBCアメリカでドクター・フーを観てるんだよ。
— Sheldon, The Big Bang Theory

 

飛び込む

シリアライズの概念は、表面上はシンプルだ。保存したり、再利用したり、他の人に送信したいデータをメモリ上に持っているとしよう。あなたならそれをどのように行うだろうか? もちろんこれは、そのデータをどのように保存したいのか、どのように再利用したいのか、だれに送りたいのかによって違うだろう。多くのゲームは終了時に「セーブ」を行い、ゲームを再び起動したときに以前の場所から再開できるようになっている(実際には、ゲーム以外の多くのアプリケーションも似たようなことを行っている)。この場合、ゲームの終了時にどこまで進んだかを記録するデータ構造がディスクに保存され、再び起動するときにディスクから読み込まれる必要がある。このデータは、作成したのと同じアプリケーションによって読み込まれるだけで、ネットワーク越しに転送されたり、作成したアプリケーション以外によって読み込まれることは無いという想定で作られている。したがって、相互運用性に関して考慮すべきことは、古いバージョンのプログラムが作成したデータを、新しいバージョンのプログラムが読み込めることを保証することに限られる。

このような場合にはpickleモジュールがうってつけだ。これはPython標準ライブラリに含まれているのでいつでも利用できるし、Pythonインタプリタと同様に大部分がC言語で書かれているので処理も速い。そして、どんなに複雑なPythonのデータ構造でも保存することができる。

pickleモジュールは何を保存できるのか?

これでも不十分な場合は、pickleモジュールを拡張することもできる。この拡張性に興味がある人は、この章の最後にあるもっと知りたい人のためにのリンクを確認してほしい。

この章の例についての手短な注意

この章では、2つのPythonシェルを使って話をする。この章では、すべての例示によって1つの話が構成されている。私がpicklejsonモジュールの実演を行うあいだ、皆さんには2つのPythonシェルを行ったり来たりしてもらうことになる。

混乱を防ぐために、Pythonシェルを開いて次の変数を定義しよう。

>>> shell = 1

このウインドウは開いたままにしておこう。今度は、もう一つの新しいPythonシェルを開いて次の変数を定義しよう。

>>> shell = 2

この章では、各々の例でどちらのPythonシェルが使われているのかをshell変数を使って示すことにする。

データをPickleファイルに保存する

pickleモジュールはデータ構造を扱う。まずは1つ作ってみよう。

>>> shell                                                                                              
1
>>> entry = {}                                                                                         
>>> entry['title'] = 'Dive into history, 2009 edition'
>>> entry['article_link'] = 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'
>>> entry['comments_link'] = None
>>> entry['internal_id'] = b'\xDE\xD5\xB4\xF8'
>>> entry['tags'] = ('diveintopython', 'docbook', 'html')
>>> entry['published'] = True
>>> import time
>>> entry['published_date'] = time.strptime('Fri Mar 27 22:20:42 2009')                                
>>> entry['published_date']
time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1)
  1. Pythonシェル #1 で追っていこう。
  2. ここでやりたいことは、Atomフィードのエントリのような何か意味のあるデータを収めたPythonの辞書を作ることだ。しかし、pickleモジュールの能力を見せびらかすために、この辞書が様々なデータ型を格納していることも強調しておきたい。これらの値の内容については気にしなくて良い。
  3. timeモジュールは、時刻上の1点(ミリ秒までの精度を持つ)を表現するためのデータ構造 (time_struct) と、そのデータ構造を操作するための関数を含んでいる。strptime()関数は、フォーマットされた文字列を受け取ってtime_structに変換する。この文字列はデフォルトの形式で表現されているが、フォーマットコードによって制御することもできる。詳しくはtimeモジュールを見てほしい。

カッコイイPythonの辞書ができた。これをファイルに保存しよう。

>>> shell                                    
1
>>> import pickle
>>> with open('entry.pickle', 'wb') as f:    
...     pickle.dump(entry, f)                
... 
  1. まだPythonシェル #1 にいる。
  2. open()関数を使ってファイルを開く。ファイルをバイナリモードで開くために、ファイルモードは'wb'に設定しよう。これをwithで包むことで、保存が終わったときに自動的にファイルが閉じられるようにしておく。
  3. pickleモジュールのdump()関数は、シリアライズ可能なPythonのデータ構造を1つ受け取り、それを最新のバージョンのPickleプロトコルを用いてPython固有のバイナリ形式にシリアライズした上で、開いておいたファイルにそれを保存する。

最後の文はとても重要だ。

データをPickleファイルから読み込む

今度は2つ目のPythonシェル — つまりentry辞書を作ったシェルではない方のシェルに切り替えよう。

>>> shell                                    
2
>>> entry                                    
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'entry' is not defined
>>> import pickle
>>> with open('entry.pickle', 'rb') as f:    
...     entry = pickle.load(f)               
... 
>>> entry                                    
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ('diveintopython', 'docbook', 'html'),
 'article_link':
 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}
  1. これはPythonシェル #2 だ。
  2. ここではentry変数が定義されていない。Pythonシェル #1 においてentry変数を定義したが、それは独自の状態を持つ完全に異なる環境だ。
  3. Pythonシェル #1 で作ったentry.pickleファイルを開こう。pickleモジュールはバイナリ形式を使うので、Pickleファイルは常にバイナリモードで開かなければならない。
  4. pickle.load()関数は、ストリームオブジェクトを受け取り、ストリームからシリアライズされたデータを読み込み、新しいPythonオブジェクトを作り、シリアライズされたデータを新しいPythonオブジェクトの中に再構成し、その新しいPythonオブジェクトを返す。
  5. さて、entry変数は、見覚えのあるキーと値を持つ辞書になった。

pickle.dump() / pickle.load()のサイクルによって、元のデータ構造と等しい内容をもつ新たなデータ構造がもたらされる。

>>> shell                                    
1
>>> with open('entry.pickle', 'rb') as f:    
...     entry2 = pickle.load(f)              
... 
>>> entry2 == entry                          
True
>>> entry2 is entry                          
False
>>> entry2['tags']                           
('diveintopython', 'docbook', 'html')
>>> entry2['internal_id']
b'\xDE\xD5\xB4\xF8'
  1. Pythonシェル #1 へ戻ろう。
  2. entry.pickleファイルを開こう。
  3. シリアライズされたデータを新しい変数entry2に読み込もう。
  4. 2つの辞書entryentry2が等しいことをPythonが認めている。このシェルでは、空の辞書からスタートし、特定のキーに手作業で値を設定することでentryを組み上げた。そして、この辞書をシリアライズし、entry.pickleファイルに保存した。さらに、そのファイルからシリアライズされたデータを読み込んで、元のデータ構造の完全な複製を作成した。
  5. 等しいことと同一であることは違う。私は、元のデータ構造の完全な複製を作ったと言った。これは正しい。しかし、これはコピーに過ぎないのだ。
  6. ここで、'tags'キーの値がタプルであることと、'internal_id'キーの値がbytesオブジェクトであることを指摘しておきたい。指摘した理由は後で分かる。

ファイルを使わずにPickle化する

前節の例は、Pythonオブジェクトをディスク上のファイルに直接シリアライズする方法を示していた。しかし、ファイルが必要ない場合や、ファイルを使わずにやりたい場合はどうすればいいのだろうか? pickleは、メモリ上のbytesオブジェクトにシリアライズすることもできるのだ。

>>> shell
1
>>> b = pickle.dumps(entry)     
>>> type(b)                     
<class 'bytes'>
>>> entry3 = pickle.loads(b)    
>>> entry3 == entry             
True
  1. pickle.dumps()関数(関数名の末尾に's'が付いていることに注意)は、pickle.dump()関数と同様のシリアライズを行う。ただし、ストリームオブジェクトを受け取って、ディスク上のファイルにシリアライズしたデータを書き込む代わりに、ただ単にシリアライズしたデータを返すだけだ。
  2. Pickleプロトコルはバイナリ形式を用いるので、pickle.dumps()関数はbytesオブジェクトを返す。
  3. pickle.loads()関数(再び、関数名の末尾に's'が付いていることに注意)は、pickle.load()関数と同様のデシリアライズを行う。ただし、ストリームオブジェクトを受け取って、ファイルからシリアライズされたデータを読み込む代わりに、pickle.dumps()関数が生成するような、シリアライズされたデータを含んだbytesオブジェクトを受け取る。
  4. 結果は同じだ。つまり、元の辞書の完全な複製だ。

バイト列と文字列が再び不快な姿を現す

Pickleプロトコルが最初に考案されたのは何年も前のことであり、Pythonが言語として成熟するのにあわせて、このプロトコルも成長してきた。現在は4つの異なるバージョンのPickleプロトコルが存在する。

ああ、見てほしい。バイト列と文字列の違いが再び不快な姿を表した(ここで驚いたのなら、注意を払っていなかったのだろう)。これが実際問題として示す意味はこうだ。つまり、Python 3はプロトコルバージョン2でPickle化されたデータを読み込むことができるが、Python 2はプロトコルバージョン3でPickle化されたデータを読み込むことができないのだ。

Pickleファイルをデバッグする

Pickleプロトコルはどのような姿をしているのだろうか? Pythonシェルから少しのあいだ飛び出して、さきほど作ったentry.pickleを見てみよう。

you@localhost:~/diveintopython3/examples$ ls -l entry.pickle
-rw-r--r-- 1 you  you  358 Aug  3 13:34 entry.pickle
you@localhost:~/diveintopython3/examples$ cat entry.pickle
comments_linkqNXtagsqXdiveintopythonqXdocbookqXhtmlq?qX publishedq?
XlinkXJhttp://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition
q   Xpublished_dateq
ctime
struct_time
?qRqXtitleqXDive into history, 2009 editionqu.

これでは何の参考にもならない。文字列は判読できるものの、他のデータ型は非印字文字(もしくは、少なくとも読めない文字)になってしまっているし、フィールドがタブやスペースで区切られているわけでもないようだ。この形式を自分でデバッグしようとは思わないだろう。

>>> shell
1
>>> import pickletools
>>> with open('entry.pickle', 'rb') as f:
...     pickletools.dis(f)
    0: \x80 PROTO      3
    2: }    EMPTY_DICT
    3: q    BINPUT     0
    5: (    MARK
    6: X        BINUNICODE 'published_date'
   25: q        BINPUT     1
   27: c        GLOBAL     'time struct_time'
   45: q        BINPUT     2
   47: (        MARK
   48: M            BININT2    2009
   51: K            BININT1    3
   53: K            BININT1    27
   55: K            BININT1    22
   57: K            BININT1    20
   59: K            BININT1    42
   61: K            BININT1    4
   63: K            BININT1    86
   65: J            BININT     -1
   70: t            TUPLE      (MARK at 47)
   71: q        BINPUT     3
   73: }        EMPTY_DICT
   74: q        BINPUT     4
   76: \x86     TUPLE2
   77: q        BINPUT     5
   79: R        REDUCE
   80: q        BINPUT     6
   82: X        BINUNICODE 'comments_link'
  100: q        BINPUT     7
  102: N        NONE
  103: X        BINUNICODE 'internal_id'
  119: q        BINPUT     8
  121: C        SHORT_BINBYTES 'ÞÕ´ø'
  127: q        BINPUT     9
  129: X        BINUNICODE 'tags'
  138: q        BINPUT     10
  140: X        BINUNICODE 'diveintopython'
  159: q        BINPUT     11
  161: X        BINUNICODE 'docbook'
  173: q        BINPUT     12
  175: X        BINUNICODE 'html'
  184: q        BINPUT     13
  186: \x87     TUPLE3
  187: q        BINPUT     14
  189: X        BINUNICODE 'title'
  199: q        BINPUT     15
  201: X        BINUNICODE 'Dive into history, 2009 edition'
  237: q        BINPUT     16
  239: X        BINUNICODE 'article_link'
  256: q        BINPUT     17
  258: X        BINUNICODE 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'
  337: q        BINPUT     18
  339: X        BINUNICODE 'published'
  353: q        BINPUT     19
  355: \x88     NEWTRUE
  356: u        SETITEMS   (MARK at 5)
  357: .    STOP
highest protocol among opcodes = 3

これはファイルを逆アセンブルしたものだが、最も興味深い情報は最後の行に含まれている。というのも、この行にはファイルを保存したときに使ったPickleプロトコルのバージョンが記されているからだ。Pickleプロトコルには、バージョンを示すための明示的なマーカが存在しない。したがって、ファイルの保存にどのバージョンのプロトコルが用いられたのかを知るためには、Pickle化されたデータの中にある目印(“opcodes”)に目を向けて、「どのopcodeがどのバージョンのPickleプロトコルで導入されたのか」というハードコードされた知識をもとに推測を行うしかない。pickletools.dis()関数もこの方法でバージョンを識別しており、その結果を逆アセンブリ出力の最後の行に表示してくれる。次に示すのは、何も表示することなく、単にバージョン番号のみを返す関数だ。

[pickleversion.pyをダウンロードする]

import pickletools

def protocol_version(file_object):
    maxproto = -1
    for opcode, arg, pos in pickletools.genops(file_object):
        maxproto = max(maxproto, opcode.proto)
    return maxproto

これは次のように使う:

>>> import pickleversion
>>> with open('entry.pickle', 'rb') as f:
...     v = pickleversion.protocol_version(f)
>>> v
3

Pythonオブジェクトを他の言語で読むためにシリアライズする

pickleモジュールが使っているデータ形式はPython固有のものであり、他のプログラミング言語との互換性をとる努力は行われていない。言語間の互換性が必要な場合は、他のシリアライズ形式に目を向ける必要がある。そのような形式の1つとしてJSONがある。“JSON” は “JavaScript Object Notation” の略だが、この名前に騙されてはいけない。JSONは、様々な言語間で使えるように明確に設計されているのだ。

Python 3の標準ライブラリの中にはjsonモジュールが含まれている。jsonモジュールは、pickleモジュールと同様に、データ構造をシリアライズする関数、シリアライズされたデータをディスクに保存する関数、シリアライズされたデータをディスクから読み込む関数、データをデシリアライズして新しいPythonオブジェクトに戻す関数を持っている。しかし、重要な違いもいくつか存在する。まず第一に、JSONのデータ形式はバイナリ形式ではなくテキスト形式だ。RFC 4627は、JSONの形式と、個々の異なる型がどのようにテキストとしてエンコードされるのかを定義している。例えばブール値は、5文字の文字列'false'、もしくは4文字の文字列'true'として保存される。JSONのすべての値は、大文字と小文字が区別される。

第二に、テキストベースの形式ではいつも問題となることだが、ホワイトスペースの扱いがある。JSONでは、値のあいだに任意個のホワイトスペース(空白・タブ・キャリッジリターン・ラインフィード) を置くことが許されている。このホワイトスペースは「意味を持たない」とされている。すなわち、JSONのエンコーダは好きなようにホワイトスペースを加えることができるし、JSONのデコーダはホワイトスペースを無視するよう仕様で定められているのだ。これによって、JSONのデータを “pretty-print”、つまり、異なるレベルのインデントを加えて、値を値の中にきれいにネストさせて出力できるので、標準的なブラウザやテキストエディタで容易にこのデータを読むことができる。Pythonのjsonモジュールは、エンコード時に “pretty-print” を行うオプションを持っている。

第三に、これまで何度も見てきた文字コードの問題がある。JSONはプレーンテキストとして値を保存するが、知っての通り「プレーンテキスト」などというものは存在しないJSONは、Unicodeエンコーディング(UTF-32・UTF-16・UTF-8。デフォルトはUTF-8)で保存されなければならない。RFC 4627の第3節は、どのエンコーディングが使われているのかを見分ける方法を定義している。

データをJSONファイルに保存する

JSONは、JavaScriptで手作業で定義するようなデータ構造と非常によく似ている。これは偶然ではなく、実際に、JSONでシリアライズされたデータをJavaScriptのeval()関数を使って「デコード」することができる(通例の信頼されていない入力に関する警告は適用されるが、ここで言いたいのは、JSONはJavaScriptの値として有効だということだ)。そんなわけで、JSON はあなたにとって見覚えのあるものかもしれない。

>>> shell
1
>>> basic_entry = {}                                           
>>> basic_entry['id'] = 256
>>> basic_entry['title'] = 'Dive into history, 2009 edition'
>>> basic_entry['tags'] = ('diveintopython', 'docbook', 'html')
>>> basic_entry['published'] = True
>>> basic_entry['comments_link'] = None
>>> import json
>>> with open('basic.json', mode='w', encoding='utf-8') as f:  
...     json.dump(basic_entry, f)                              
  1. すでに存在するデータ構造entryを使う代わりに、新しいデータ構造を定義しよう。この章では後に、もっと複雑なデータ構造をJSONでエンコードしようとしたときに何が起きるのかを見ることになる。
  2. JSONはテキストベースの形式だ。つまり、このファイルはテキスト形式で開かなければならず、文字コードも指定する必要がある。UTF-8を使っておけば問題は決して起きない。
  3. pickleモジュールと同様に、jsonモジュールは、Pythonのデータ構造と書き込み可能なストリームオブジェクトを受け取るdump()関数を定義している。dump()関数はPythonのデータ構造をシリアライズして、それをストリームオブジェクトに書き込む。これをwith文の中で行うことによって、処理が終わったときにファイルが適切に閉じられることを保証している。

では、JSONのシリアライズ結果はどうなっているだろうか?

you@localhost:~/diveintopython3/examples$ cat basic.json
{"published": true, "tags": ["diveintopython", "docbook", "html"], "comments_link": null,
"id": 256, "title": "Dive into history, 2009 edition"}

これは明らかにPickleファイルよりも読みやすい。しかし、JSONは、値のあいだに任意個のホワイトスペースを入れることができるので、jsonモジュールはこの長所を生かして、さらに読みやすいJSONファイルを手軽に作る方法を提供している。

>>> shell
1
>>> with open('basic-pretty.json', mode='w', encoding='utf-8') as f:
...     json.dump(basic_entry, f, indent=2)                            
  1. indentパラメータをjson.dump()関数に渡すと、JSONファイルをより読みやすい形に整形して出力してくれる(その代わりに、ファイルサイズは増えるが)。indentパラメータは整数だ。これを0にすると「各々の値ごとに1行を使う」という意味になる。0より大きい数を与えた場合は、「各々の値ごとに1行を使い、さらに指定した数の空白を使ってデータ構造をインデントする」という意味になる。

そして、これがその結果だ:

you@localhost:~/diveintopython3/examples$ cat basic-pretty.json
{
  "published": true,
  "tags": [
    "diveintopython",
    "docbook",
    "html"
  ],
  "comments_link": null,
  "id": 256,
  "title": "Dive into history, 2009 edition"
}

Pythonのデータ型をJSONにマッピングする

JSONはPython固有の形式ではないので、Pythonのデータ型とのミスマッチがいくつか存在する。いくつかは単に名前が違うだけであるが、Pythonの重要なデータ型のうちの2つは完全に欠けてしまっている。その2つを以下の表から見つけることができるだろうか:

JSON Python 3
オブジェクト 辞書
配列 リスト
文字列 文字列
整数 整数
実数 浮動小数点数
* true True
* false False
* null None
* すべてのJSONの値は大文字と小文字が区別される。

何が欠けているか気付いただろうか? タプルとバイト列だ。JSONは配列型を持っており、jsonモジュールはこれをPythonのリストにマッピングする。しかし、JSONは「凍結された配列」(タプル)を持たない。また、JSONは、文字列を非常に上手くサポートする一方で、bytesオブジェクトやバイト配列はサポートしない。

JSONがサポートしていないデータ型をシリアライズする

JSONが組み込みでバイト列をサポートしていないと言えども、bytesオブジェクトのシリアライズが不可能なわけではない。jsonモジュールは、未知のデータ型をエンコード/デコードするためのフックを仕掛けることができるようになっている(「未知の」という言葉は「JSONで定義されていない」という意味だ。jsonモジュールはもちろんバイト列の存在を認識しているが、このモジュールはJSONの仕様によって制約されているのだ)。バイト列やJSONがネイティブでサポートしない他のデータ型をエンコードしたい場合には、それらの型のための自作のエンコーダとデコーダを用意する必要がある。

>>> shell
1
>>> entry                                                 
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ('diveintopython', 'docbook', 'html'),
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}
>>> import json
>>> with open('entry.json', 'w', encoding='utf-8') as f:  
...     json.dump(entry, f)                               
... 
Traceback (most recent call last):
  File "<stdin>", line 5, in <module>
  File "C:\Python31\lib\json\__init__.py", line 178, in dump
    for chunk in iterable:
  File "C:\Python31\lib\json\encoder.py", line 408, in _iterencode
    for chunk in _iterencode_dict(o, _current_indent_level):
  File "C:\Python31\lib\json\encoder.py", line 382, in _iterencode_dict
    for chunk in chunks:
  File "C:\Python31\lib\json\encoder.py", line 416, in _iterencode
    o = _default(o)
  File "C:\Python31\lib\json\encoder.py", line 170, in default
    raise TypeError(repr(o) + " is not JSON serializable")
TypeError: b'\xDE\xD5\xB4\xF8' is not JSON serializable
  1. よし、entryデータ構造を再び使う時が来たようだ。これにはあらゆるものが含まれる: ブール値、None、文字列、文字列のタプル、bytesオブジェクト、そしてtimeデータ構造だ。
  2. 前にも言ったことだが、これは繰り返し言っておく価値がある: JSONはテキスト形式だ。JSONファイルは、文字コードをUTF-8にしてテキストモードで開こう。
  3. これはマズい。何が起きたのだろうか?

ここで起きたのは次のようなことだ: json.dump()関数がbytesオブジェクトb'\xDE\xD5\xB4\xF8'をシリアライズしようとしたが、JSONbytesオブジェクトをサポートしていないので失敗した。しかし、どうしてもバイト列を保存したい場合には、独自の「小さなシリアライズ形式」を定義することができる。

[customserializer.pyをダウンロードする]


def to_json(python_object):                                             
    if isinstance(python_object, bytes):                                
        return {'__class__': 'bytes',
                '__value__': list(python_object)}                       
    raise TypeError(repr(python_object) + ' is not JSON serializable')  
  1. JSONがネイティブにサポートしないデータ型のための独自の「小さなシリアライズ形式」を定義するには、パラメータとして1つのPythonオブジェクトを受け取る関数を定義すればいい。json.dump()関数が自分自身ではシリアライズできない実際のオブジェクトが、このPythonオブジェクトとして渡されることになる。この例では、それはbytesオブジェクトのb'\xDE\xD5\xB4\xF8'だ。
  2. 自作のシリアライズ関数は、json.dump()関数が渡してきたPythonオブジェクトの型をチェックすべきだ。厳密には、その関数が1つのデータ型だけをシリアライズするのであれば必要ないのだが、このチェックは関数がどの型を対象にしているのかを非常に明白にしてくれるし、あとになって他のデータ型をシリアライズする必要に迫られたときの拡張も容易になる。
  3. 今回はbytesオブジェクトを辞書に変換することにした。__class__キーが元のデータ型を(文字列'bytesとして)格納し、__value__キーが実際の値を格納する。もちろん、この値としてbytesオブジェクトを使うことはできないので、これをJSONでシリアライズできる何らかの値に変換することが必要だ。bytesは整数のシーケンスなので、各々の整数は0–255の範囲のいずれかになる。私たちは、list()関数を使ってbytesオブジェクトを整数のリストに変換することができる。つまり、b'\xDE\xD5\xB4\xF8'[222, 213, 180, 248]になる(計算すれば分かる! ちゃんと動いているよ! 16進数で\xDEのバイトは10進数では222。\xD5は213。以下同様)。
  4. この行は重要だ。シリアライズしようとしているデータは、組み込みのJSONシリアライザでも、自作のシリアライザでも扱えない型を含んでいるかもしれない。その場合は、自作のシリアライザがTypeErrorを発生させることによって、自作のシリアライザがその型を認識できないことをjson.dump()関数に知らせなければならない。

これですべてだ。他のことはしなくていい。具体的に言うと、この自作のシリアライズ関数は、文字列を返さずに、Pythonの辞書を返している。自分自身ではJSONへの完全なシリアライズを行っていない。やっているのはサポートされているデータ型に変換する部分だけだ。残りの仕事はjson.dump()関数がやってくれる。

>>> shell
1
>>> import customserializer                                                             
>>> with open('entry.json', 'w', encoding='utf-8') as f:                                
...     json.dump(entry, f, default=customserializer.to_json)                           
... 
Traceback (most recent call last):
  File "<stdin>", line 9, in <module>
    json.dump(entry, f, default=customserializer.to_json)
  File "C:\Python31\lib\json\__init__.py", line 178, in dump
    for chunk in iterable:
  File "C:\Python31\lib\json\encoder.py", line 408, in _iterencode
    for chunk in _iterencode_dict(o, _current_indent_level):
  File "C:\Python31\lib\json\encoder.py", line 382, in _iterencode_dict
    for chunk in chunks:
  File "C:\Python31\lib\json\encoder.py", line 416, in _iterencode
    o = _default(o)
  File "/Users/pilgrim/diveintopython3/examples/customserializer.py", line 12, in to_json
    raise TypeError(repr(python_object) + ' is not JSON serializable')                     
TypeError: time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1) is not JSON serializable
  1. customserializerモジュールは、前節でto_json()関数を定義したファイルだ。
  2. テキストモード、UTF-8エンコーディング、とかなんとかを指定する(きっと忘れるぞ! 私もときどき忘れてしまう! しかも、動かなくなるその瞬間までは正しく動いてしまうのだが、時が来ると、本当に壮大に動かなくなる)。
  3. これは重要な点だ: 自作の変換用関数をjson.dump()関数にフックするために、その関数をjson.dump()関数のdefaultパラメータで渡している(やったね、Pythonではあらゆるものがオブジェクトだ!)。
  4. OK、これでもまだ動かないようだ。でも例外を見てみよう。もはやjson.dump()関数は、bytesオブジェクトをシリアライズできない、という文句を言っていない。今度は、違うオブジェクトについて文句を言っている。time.struct_timeオブジェクトだ。

別の例外が発生したので、前進したようには見えないかもしれないが、ちゃんと解決には近づいている! あともう一手間加えれば、この問題を乗り越えることができる。


import time

def to_json(python_object):
    if isinstance(python_object, time.struct_time):          
        return {'__class__': 'time.asctime',
                '__value__': time.asctime(python_object)}    
    if isinstance(python_object, bytes):
        return {'__class__': 'bytes',
                '__value__': list(python_object)}
    raise TypeError(repr(python_object) + ' is not JSON serializable')
  1. 先ほどのcustomserializer.to_json()関数にコードを加え、(json.dump()関数が扱いに困っている)Pythonオブジェクトがtime.struct_timeであるかどうかをチェックする必要がある。
  2. もしそうであれば、bytesオブジェクトに対して行った変換と似たようなことを行う: time.struct_timeオブジェクトを、JSONがシリアライズできる値だけで構成された辞書に変換するのだ。この場合、日時をJSONがシリアライズできる値に最も簡単に変換するには、time.asctime()関数を使って文字列に変換すると良いだろう。time.asctime()関数は、見た目の悪いtime.struct_time'Fri Mar 27 22:20:42 2009'という文字列に変換する。

これら2つのカスタムな変換をすれば、これ以上の問題を起こすことなくentryデータ構造の全体がJSONにシリアライズされるはずだ。

>>> shell
1
>>> with open('entry.json', 'w', encoding='utf-8') as f:
...     json.dump(entry, f, default=customserializer.to_json)
... 
you@localhost:~/diveintopython3/examples$ ls -l example.json
-rw-r--r-- 1 you  you  391 Aug  3 13:34 entry.json
you@localhost:~/diveintopython3/examples$ cat example.json
{"published_date": {"__class__": "time.asctime", "__value__": "Fri Mar 27 22:20:42 2009"},
"comments_link": null, "internal_id": {"__class__": "bytes", "__value__": [222, 213, 180, 248]},
"tags": ["diveintopython", "docbook", "html"], "title": "Dive into history, 2009 edition",
"article_link": "http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition",
"published": true}

JSONファイルからデータを読み込む

jsonモジュールは、pickleモジュールのようにload()関数を持っている。この関数は、ストリームオブジェクトを受け取り、そのオブジェクトからJSONでエンコードされたデータを読み込み、JSONのデータ構造を写しとった新しいPythonのオブジェクトを作成する。

>>> shell
2
>>> del entry                                             
>>> entry
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'entry' is not defined
>>> import json
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry = json.load(f)                              
... 
>>> entry                                                 
{'comments_link': None,
 'internal_id': {'__class__': 'bytes', '__value__': [222, 213, 180, 248]},
 'title': 'Dive into history, 2009 edition',
 'tags': ['diveintopython', 'docbook', 'html'],
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': {'__class__': 'time.asctime', '__value__': 'Fri Mar 27 22:20:42 2009'},
 'published': True}
  1. デモンストレーションを行うために、Pythonシェル #2 に切り替えて、この章の初めの方でpickleモジュールを使って定義した entry データ構造を削除する。
  2. この簡単な場合には、json.load()関数はpickle.load()関数と同様にうまく機能する。この関数は、オブジェクトを渡すと新しいPythonオブジェクトを返す。
  3. 良い知らせと悪い知らせがある。まずは良い知らせ: json.load()関数は、Pythonシェル #1 で作成したentry.jsonを正常に読み込んで、そのデータを格納する新しいPythonオブジェクトを作る。次に悪い知らせ: この関数は entry データ構造を再構成しない。2つの値'internal_id''published_date'は辞書だ。具体的に言うと、これらは、変換関数to_json()で作ったJSON互換の値を持った辞書になっている。

json.load()は、先にjson.dump()関数に渡された変換関数については何も知らない。ここで必要なのはto_json()関数とは反対のことをする関数だ。つまり、カスタムな変換を施されたJSONオブジェクトを受け取って、それを元のPythonのデータ型に変換する関数だ。

# add this to customserializer.py
def from_json(json_object):                                   
    if '__class__' in json_object:                            
        if json_object['__class__'] == 'time.asctime':
            return time.strptime(json_object['__value__'])    
        if json_object['__class__'] == 'bytes':
            return bytes(json_object['__value__'])            
    return json_object
  1. この変換関数も1つのパラメータを受け取って1つの値を返す。しかし、受け取るパラメータは文字列ではなく、JSONでエンコードされた文字列をデシリアライズすることで得られたPythonオブジェクトだ。
  2. やらなければならないことは、このオブジェクトがto_json()によって作られた'__class__'キーを持っているかどうかをチェックすることだけだ。もし、オブジェクトがこのキーを持っているのであれば、元のPythonのデータ型に値をデコードする方法を'__class__'キーが教えてくれる。
  3. time.asctime()関数によって返された文字列をデコードするには、time.strptime()関数を使えばよい。この関数はフォーマットされた日時を表す文字列を受け取り(形式はカスタマイズできるが、標準ではtime.asctime()がデフォルトで返す文字列と同じ形式をとる)time.struct_timeを返す。
  4. 整数のリストをbytesオブジェクトに変換するには、bytes()関数を使うことができる。

これだけだ。to_json()関数で特別に処理したデータ型は2つだけで、これら2つのデータ型はfrom_json()関数でも処理されるようになった。以下がその結果だ:

>>> shell
2
>>> import customserializer
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry = json.load(f, object_hook=customserializer.from_json)  
... 
>>> entry                                                             
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ['diveintopython', 'docbook', 'html'],
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}
  1. デシリアライズの処理の中にfrom_json()関数をフックするために、この関数をobject_hookパラメータとしてjson.load()関数に渡す。関数を受け取る関数というのはとても便利だね!
  2. entryデータ構造はキー'internal_id'を持ち、その値はbytesオブジェクトだ。キー'published_date'も持っており、その値はtime.struct_timeオブジェクトだ。

しかし、問題があと1つ存在する。

>>> shell
1
>>> import customserializer
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry2 = json.load(f, object_hook=customserializer.from_json)
... 
>>> entry2 == entry                                                    
False
>>> entry['tags']                                                      
('diveintopython', 'docbook', 'html')
>>> entry2['tags']                                                     
['diveintopython', 'docbook', 'html']
  1. シリアライズの処理にto_json()関数をフックし、デシリアライズ処理にfrom_json()関数をフックし終えたにもかかわらず、私たちは元のデータの完全な複製を再構成することができない。なぜだろう?
  2. 元のentryデータ構造では、'tags'キーの値は3つの文字列からなるタプルだった。
  3. しかし、JSONファイルから復元したentry2データ構造では、'tags'キーの値は3つの文字列からなるリストになっている。JSONはタプルとリストを区別することができない。JSONにはリストに似たデータ型である配列しか存在しないので、jsonモジュールはシリアライズの過程でタプルとリストの両方を黙ってJSONの配列に変換する。ほとんどの場合、タプルとリストの違いは無視することができるが、jsonモジュールを使うときには、このことを心に留めておいたほうが良い。

もっと知りたい人のために

pickleモジュールに関する多くの記事はcPickleについて言及している。Python 2にはpickleモジュールの実装が2つあった。1つは純粋にPythonだけで書かれたもので、もう片方はC言語で書かれたものだ(でもPythonから呼び出せる)。Python 3では、これらの2つのモジュールは統合されたので、常にimport pickleとしてインポートしなければならない。これらの記事が役に立つこともあるだろうけれど、cPickleについての時代遅れな情報は無視しよう。

pickleモジュールを使ったPickle化について:

JSONjsonモジュールについて:

Pickleの拡張性について:

© 2001– Mark Pilgrim
© Fukada, Fujimoto(日本語版)