一次跑偏之旅!
对于一个惯用C++的人来说,使用Python这种语言的一大障碍就是许多集合类型的操作效率并不如传统的经典数据结构那样直观可见,以及许多实际上涉及到内存分配、对象复制之类的耗时操作被隐藏在看似简单的接口之中。加上Python的文档只强调如何使用,大部分时候都对实现的细节和效率语焉不详。这使我在使用Python时,会有一种比用C++更加小心翼翼的心态。当有许多个方式来加工一个数据集时,我不得不仔细考虑哪一种方式才是效率最高的,因为无法从文档中获得相关的信息,所以只能靠经验推测或是阅读源码来判断,这经常比用C++更加费时和困难。
虽然Python的优势在于其开发效率、统一的类库和简洁的语法,但对于所有从事企业级开发的人来说,显然任何语言的效率都是值得重视的。
所以,在假装不关心效率地写了几天之后,我今天花了些时间来尝试判断一个小问题:
当需要渲染一个dict的所有value时,究竟应该向RenderContext里塞一个怎样的数据集对象才是最高效的?
从最直观的写法开始:
data = {'a' : '1', 'b' : '2'}
ctx = Context({'data' : data.values()})
这里使用到的dict.values()函数会新建一个列表,把dict的所有value复制到其中 —— 显然效率不够高。一个改进的写法是使用迭代器:
data = {'a' : '1', 'b' : '2'}
ctx = Context({'data' : data.itervalues()})
这是一个效率很高的实现,但是很遗憾的是,它存在一个问题。正是这个问题导致我下决心来探寻其中的实现细节:当使用iterator来构造Context时,只有第一个使用该数据集的for Tag能够正确的渲染出数据,如果在一个模板中存在多个地方需要渲染同一个数据集,后续的for Tag全部只能输出空列表。
产生这个问题的原因是迭代器指针在第一次遍历完之后,指针位置到达了列表末尾,在下一次遍历时,迭代器并没有重置,所以自然无法取到数据。
坦白说,我认为这是一个语义范畴的Bug,渲染引擎应该考虑这种情况并确保多次渲染所取到的数据是一致的。所以接下来我想看看有没有什么办法来解决这个Bug,说不定还能成为我对开源项目的第一个commit...
首先,最简单的办法是在使用完迭代器之后reset一下,然而iterator并没有reset接口。
或者,在每次使用都使用原始迭代器的拷贝,然而iterator同样没有clone接口。找到个itertools.tee函数,号称可以复制迭代器,但是其实只能接收iterable参数,传递iterator参数给它同样会导致上面的问题。
d= {'a':'1','d':'2'}
import itertools
di = d.itervalues()
di1 = itertools.tee(di,1)
list(di1[0])
>>> ['1', '2']
list(di)
>>> []
不得不吐槽一句,各种翻译真害人不浅,搞得我还以为这个tee是元数据语言的黑科技,闹半天发现只不过一个从iterable批量产生iterator的util。
既不能reset,又不能clone的话,那么就只剩两个选择了,一个是从Django渲染引擎的实现中看有没有办法,二是不用iterator来构造Context。
对于后者,一个简单的办法是自己实现一个iterable,用来代理dict.itervalues,然后用这个iterable对象来构造Context,这样不需要拷贝数据集,还可以保证每次使用的迭代器都是新的:
class iterable4dictval():
def __init__(self, dict_obj):
self.dict_obj = dict_obj
def __iter__(self):
if self.dict_obj is None or not isinstance(self.dict_obj, dict):
return None
return self.dict_obj.itervalues()
data = {'a' : '1', 'b' : '2'}
ctx = Context({'data' : iterable4dictval(data)})
这是一个能工作的实现,调用方的开销也很小,但是效率是否真的高呢?嘿!Django的实现告诉你然并卵...
那么,来看看Django渲染引擎的实现,对于安装好的Django,for Tag的实现代码在Lib\site-packages\django\template\defaulttags.py文件中,它对数据集的处理过程大概是这样的:
class ForNode(Node):
def render(self, context):
...
try:
values = self.sequence.resolve(context, True)
except VariableDoesNotExist:
values = []
if values is None:
values = []
if not hasattr(values, '__len__'):
values = list(values)
len_values = len(values)
if len_values < 1:
context.pop()
return self.nodelist_empty.render(context)
for i, item in enumerate(values):
...
这个实现会先判断Data Object是否有__len__属性,没有的话就会先转换成一个list。什么样的对象支持或应该支持__len__属性呢?Python小白的我还特意先百度了一番:
简单来说呢,__len__基本上和len()的支持是对应的,而文档里说len函数支持所有的sequence和collection类型,也就是string, bytes, tuple, list, range, dictionary, set这些。
显然,iterable和iterator是不支持len的,也就是说,如果使用iterable或iterator来构造Context,那么Django在渲染前,还是会把所有数据都转存到一个新建的list里去...得!调用方省下的效率,全都在实现中还回去了!
Django的开发者显然不至于脑残到不知道iterator,那么为什么要这样实现?ForNode.render实现的其它部分揭示了答案,代码就不列了。我们看看for Tag支持的一些变量:
forloop.counter
forloop.counter0
forloop.revcounter
forloop.revcounter0
forloop.first
forloop.last
forloop.parentloop
其它的都好说,唯独revcounter,如果不知道数据集的长度,要支持这个变量就难了。对于iterable,或许可以做两次遍历,一次计算长度,一次渲染;但对iterator,除了转换为列表,还真没有什么好的办法,更何况还可能有形状提到的多次渲染需求问题。所以,Django干脆直接把把iterable和iterator都转成list。
最后,回到正题,如果要渲染dict的所有value,到底怎样构造Context才是最高效的?如果没看过Django的实现,或许我们会认为使用迭代器是最高效的方法之一,但是看过之后,最高效的办法只有一个,没有之一。那就是直接用dict来构造Context,然后这样写模板:
{% for key, value in data.items %}
{{ key }}: {{ value }}
{% endfor %}
注意data变量就是字典,而不是data.items。
后话:
Andorid的文件枚举接口,也存在一个类似的效率问题,当初也是搞得我很无语。java.io.File.dir()有一个重载是带一个过滤器参数,返回一个经过过滤的文件列表,看起来这比返回所有子文件列表的开销要小一点。然而,大家看看这个实现:
public String[] list(FilenameFilter filter) {
String[] filenames = list();
if (filter == null || filenames == null) {
return filenames;
}
List<String> result = new ArrayList<String>(filenames.length);
for (String filename : filenames) {
if (filter.accept(this, filename)) {
result.add(filename);
}
}
return result.toArray(new String[result.size()]);
}
先获取一个所有子文件的列表,再用for循环处理一遍,把符合条件的项再放到一新列表中去。也就是说,这货其实是创建两个列表的开销,效率比调用方直接用list()再手工迭代差远了。盒盒~