Python object serialization and deserialization is an important aspect of any non-trivial program. If in Python you save something to a file, if you read a configuration file, or if you respond to an HTTP request, you do object serialization and deserialization.
In one sense, serialization and deserialization are the most boring things in the world. Who cares about all the formats and protocols? You just want to persist or stream some Python objects and get them back later intact.
This is a very healthy way to look at the world at the conceptual level. But, at the pragmatic level, which serialization scheme, format or protocol you choose may determine how fast your program runs, how secure it is, how much freedom you have to maintain your state, and how well you're going to interoperate with other systems.
The reason there are so many options is that different circumstances call for different solutions. There is no "one size fits all". In this two-part tutorial I'll go over the pros and cons of the most successful serialization and deserialization schemes, show how to use them, and provide guidelines for choosing between them when faced with a specific use case.
Running Example
In the following sections I'll serialize and deserialize the same Python object graphs using different serializers. To avoid repetition, I'll define these object graphs here.
Simple Object Graph
The simple object graph is a dictionary that contains a list of integers, a string, a float, a boolean, and a None.
simple = dict(int_list=[1, 2, 3], text='string', number=3.44, boolean=True, none=None)
Complex Object Graph
The complex object graph is also a dictionary, but it contains a datetime
object and user-defined class instance that has a self.simple
attribute, which is set to the simple object graph.
from datetime import datetime class A(object): def __init__(self, simple): self.simple = simple def __eq__(self, other): if not hasattr(other, 'simple'): return False return self.simple == other.simple def __ne__(self, other): if not hasattr(other, 'simple'): return True return self.simple != other.simple complex = dict(a=A(simple), when=datetime(2016, 3, 7))
Pickle
Pickle is a staple. It is a native Python object serialization format. The pickle interface provides four methods: dump, dumps, load, and loads. The dump()
method serializes to an open file (file-like object). The dumps()
method serializes to a string. The load()
method deserializes from an open file-like object. The loads()
method deserializes from a string.
Pickle supports by default a textual protocol, but has also a binary protocol, which is more efficient, but not human-readable (helpful when debugging).
Here is how you pickle a Python object graph to a string and to a file using both protocols.
import cPickle as pickle pickle.dumps(simple) "(dp1\nS'text'\np2\nS'string'\np3\nsS'none'\np4\nNsS'boolean'\np5\nI01\nsS'number'\np6\nF3.4399999999999999\nsS'int_list'\np7\n(lp8\nI1\naI2\naI3\nas." pickle.dumps(simple, protocol=pickle.HIGHEST_PROTOCOL) '\x80\x02}q\x01(U\x04textq\x02U\x06stringq\x03U\x04noneq\x04NU\x07boolean\x88U\x06numberq\x05G@\x0b\x85\x1e\xb8Q\xeb\x85U\x08int_list]q\x06(K\x01K\x02K\x03eu.'
The binary representation may seem larger, but this is an illusion due to its presentation. When dumping to a file, the textual protocol is 130 bytes, while the binary protocol is only 85 bytes.
pickle.dump(simple, open('simple1.pkl', 'w')) pickle.dump(simple, open('simple2.pkl', 'wb'), protocol=pickle.HIGHEST_PROTOCOL) ls -la sim*.* -rw-r--r-- 1 gigi staff 130 Mar 9 02:42 simple1.pkl -rw-r--r-- 1 gigi staff 85 Mar 9 02:43 simple2.pkl
Unpickling from a string is as simple as:
x = pickle.loads("(dp1\nS'text'\np2\nS'string'\np3\nsS'none'\np4\nNsS'boolean'\np5\nI01\nsS'number'\np6\nF3.4399999999999999\nsS'int_list'\np7\n(lp8\nI1\naI2\naI3\nas.") assert x == simple x = pickle.loads('\x80\x02}q\x01(U\x04textq\x02U\x06stringq\x03U\x04noneq\x04NU\x07boolean\x88U\x06numberq\x05G@\x0b\x85\x1e\xb8Q\xeb\x85U\x08int_list]q\x06(K\x01K\x02K\x03eu.') assert x == simple
Note that pickle can figure out the protocol automatically. There is no need to specify a protocol even for the binary one.
Unpickling from a file is just as easy. You just need to provide an open file.
x = pickle.load(open('simple1.pkl')) assert x == simple x = pickle.load(open('simple2.pkl')) assert x == simple x = pickle.load(open('simple2.pkl', 'rb')) assert x == simple
According to the documentation, you're supposed to open binary pickles using the 'rb' mode, but as you can see it works either way.
Let's see how pickle deals with the complex object graph.
pickle.dumps(complex) "(dp1\nS'a'\nccopy_reg\n_reconstructor\np2\n(c__main__\nA\np3\nc__builtin__\nobject\np4\nNtRp5\n(dp6\nS'simple'\np7\n(dp8\nS'text'\np9\nS'string'\np10\nsS'none'\np11\nNsS'boolean'\np12\nI01\nsS'number'\np13\nF3.4399999999999999\nsS'int_list'\np14\n(lp15\nI1\naI2\naI3\nassbsS'when'\np16\ncdatetime\ndatetime\np17\n(S'\\x07\\xe0\\x03\\x07\\x00\\x00\\x00\\x00\\x00\\x00'\ntRp18\ns." pickle.dumps(complex, protocol=pickle.HIGHEST_PROTOCOL) '\x80\x02}q\x01(U\x01ac__main__\nA\nq\x02)\x81q\x03}q\x04U\x06simpleq\x05}q\x06(U\x04textq\x07U\x06stringq\x08U\x04noneq\tNU\x07boolean\x88U\x06numberq\nG@\x0b\x85\x1e\xb8Q\xeb\x85U\x08int_list]q\x0b(K\x01K\x02K\x03eusbU\x04whenq\x0ccdatetime\ndatetime\nq\rU\n\x07\xe0\x03\x07\x00\x00\x00\x00\x00\x00\x85Rq\x0eu.' pickle.dump(complex, open('complex1.pkl', 'w')) pickle.dump(complex, open('complex2.pkl', 'wb'), protocol=pickle.HIGHEST_PROTOCOL) ls -la comp*.* -rw-r--r-- 1 gigi staff 327 Mar 9 02:58 complex1.pkl -rw-r--r-- 1 gigi staff 171 Mar 9 02:58 complex2.pkl
The efficiency of the binary protocol is even greater with complex object graphs.
JSON
JSON (JavaScript Object Notation) has been part of the Python standard library since Python 2.5. I'll consider it a native format at this point. It is a text-based format and is the unofficial king of the web as far as object serialization goes. Its type system naturally models JavaScript, so it is pretty limited.
Let's serialize and deserialize the simple and complex objects graphs and see what happens. The interface is almost identical to the pickle interface. You have dump()
, dumps()
, load()
, and loads()
functions. But, there are no protocols to select, and there are many optional arguments to control the process. Let's start simple by dumping the simple object graph without any special arguments:
import json print json.dumps(simple) {"text": "string", "none": null, "boolean": true, "number": 3.44, "int_list": [1, 2, 3]}
The output looks pretty readable, but there is no indentation. For a larger object graph, this can be a problem. Let's indent the output:
print json.dumps(simple, indent=4) { "text": "string", "none": null, "boolean": true, "number": 3.44, "int_list": [ 1, 2, 3 ] }
That looks much better. Let's move on to the complex object graph.
json.dumps(complex) --------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-19-1be2d89d5d0d> in <module>() ----> 1 json.dumps(complex) /usr/local/Cellar/python/2.7.10/Frameworks/Python.framework/Versions/2.7/lib/python2.7/json/__init__.pyc in dumps(obj, skipkeys, ensure_ascii, check_circular, allow_nan, cls, indent, separators, encoding, default, sort_keys, **kw) 241 cls is None and indent is None and separators is None and 242 encoding == 'utf-8' and default is None and not sort_keys and not kw): --> 243 return _default_encoder.encode(obj) 244 if cls is None: 245 cls = JSONEncoder /usr/local/Cellar/python/2.7.10/Frameworks/Python.framework/Versions/2.7/lib/python2.7/json/encoder.pyc in encode(self, o) 205 # exceptions aren't as detailed. The list call should be roughly 206 # equivalent to the PySequence_Fast that ''.join() would do. --> 207 chunks = self.iterencode(o, _one_shot=True) 208 if not isinstance(chunks, (list, tuple)): 209 chunks = list(chunks) /usr/local/Cellar/python/2.7.10/Frameworks/Python.framework/Versions/2.7/lib/python2.7/json/encoder.pyc in iterencode(self, o, _one_shot) 268 self.key_separator, self.item_separator, self.sort_keys, 269 self.skipkeys, _one_shot) --> 270 return _iterencode(o, 0) 271 272 def _make_iterencode(markers, _default, _encoder, _indent, _floatstr, /usr/local/Cellar/python/2.7.10/Frameworks/Python.framework/Versions/2.7/lib/python2.7/json/encoder.pyc in default(self, o) 182 183 """ --> 184 raise TypeError(repr(o) + " is not JSON serializable") 185 186 def encode(self, o): TypeError: <__main__.A object at 0x10f367cd0> is not JSON serializable
Whoa! That doesn't look good at all. What happened? The error message is that the A object is not JSON serializable. Remember that JSON has a very limited type system and it can't serialize user defined classes automatically. The way to address it is to subclass the JSONEncoder class used by the json module and implement the default()
that is called whenever the JSON encoder runs into an object it can't serialize.
The job of the custom encoder is to convert it to a Python object graph that the JSON encoder is able to encode. In this case we have two objects that require special encoding: the datetime
object and the A class. The following encoder does the job. Each special object is converted to a dict
where the key is the name of the type surrounded by dunders (double underscores). This will be important for decoding.
from datetime import datetime import json class CustomEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, datetime): return {'__datetime__': o.replace(microsecond=0).isoformat()} return {'__{}__'.format(o.__class__.__name__): o.__dict__}
Let's try again with our custom encoder:
serialized = json.dumps(complex, indent=4, cls=CustomEncoder) print serialized { "a": { "__A__": { "simple": { "text": "string", "none": null, "boolean": true, "number": 3.44, "int_list": [ 1, 2, 3 ] } } }, "when": { "__datetime__": "2016-03-07T00:00:00" } }
This is beautiful. The complex object graph was serialized properly, and the original type information of the components was retained via the keys: "__A__" and "__datetime__". If you use dunders for your names, then you need to come up with a different convention to denote special types.
Let's decode the complex object graph.
> deserialized = json.loads(serialized) > deserialized == complex False
Hmmm, the deserialization worked (no errors), but it is different than the original complex object graph we serialized. Something is wrong. Let's take a look at the deserialized object graph. I'll use the pprint
function of the pprint
module for pretty printing.
> from pprint import pprint > pprint(deserialized) {u'a': {u'__A__': {u'simple': {u'boolean': True, u'int_list': [1, 2, 3], u'none': None, u'number': 3.44, u'text': u'string'}}}, u'when': {u'__datetime__': u'2016-03-07T00:00:00'}}
Ok. The problem is that the json module doesn't know anything about the A class or even the standard datetime object. It just deserializes everything by default to the Python object that matches its type system. In order to get back to a rich Python object graph, you need custom decoding.
There is no need for a custom decoder subclass. The load()
and loads()
functions provide the "object_hook" parameter that lets you provide a custom function that converts dicts to objects.
def decode_object(o): if '__A__' in o: a = A() a.__dict__.update(o['__A__']) return a elif '__datetime__' in o: return datetime.strptime(o['__datetime__'], '%Y-%m-%dT%H:%M:%S') return o
Let's decode using the decode_object()
function as a parameter to the loads()
object_hook parameter.
> deserialized = json.loads(serialized, object_hook=decode_object) > print deserialized {u'a': <__main__.A object at 0x10d984790>, u'when': datetime.datetime(2016, 3, 7, 0, 0)} > deserialized == complex True
Conclusion
In part one of this tutorial, you've learned about the general concept of serialization and deserialization of Python objects and explored the ins and out of serializing Python objects using Pickle and JSON.
In part two, you'll learn about YAML, performance and security concerns, and a quick review of additional serialization schemes.
No comments:
Post a Comment