在Django模板中使用itertools.groupby

21 投票
2 回答
4903 浏览
提问于 2025-04-16 22:41

我遇到了一个奇怪的问题,使用 itertools.groupby 来对查询结果进行分组。我有一个模型 Resource

from django.db import models 

TYPE_CHOICES = ( 
    ('event', 'Event Room'),
    ('meet', 'Meeting Room'),
    # etc 
)   

class Resource(models.Model):
    name = models.CharField(max_length=30)
    type = models.CharField(max_length=5, choices=TYPE_CHOICES)
    # other stuff

在我的sqlite数据库里有几个资源:

>>> from myapp.models import Resource
>>> r = Resource.objects.all()
>>> len(r)
3
>>> r[0].type
u'event'
>>> r[1].type
u'meet'
>>> r[2].type
u'meet'

所以如果我按类型分组,自然会得到两个元组:

>>> from itertools import groupby
>>> g = groupby(r, lambda resource: resource.type)
>>> for type, resources in g:
...   print type
...   for resource in resources:
...     print '\t%s' % resource
event
    resourcex
meet
    resourcey
    resourcez

现在我在视图里用的是相同的逻辑:

class DayView(DayArchiveView):
    def get_context_data(self, *args, **kwargs):
        context = super(DayView, self).get_context_data(*args, **kwargs)
        types = dict(TYPE_CHOICES)
        context['resource_list'] = groupby(Resource.objects.all(), lambda r: types[r.type])
        return context

但是当我在模板中遍历这些数据时,有些资源却缺失了:

<select multiple="multiple" name="resources">
{% for type, resources in resource_list %}
    <option disabled="disabled">{{ type }}</option>
    {% for resource in resources %}
        <option value="{{ resource.id }}">{{ resource.name }}</option>
    {% endfor %}
{% endfor %}
</select>

这段代码渲染出来的效果是:

select multiple

我在想,可能是某种原因导致子迭代器已经被遍历过了,但我不太确定这怎么会发生。

(我使用的是python 2.7.1,Django 1.3)。

(补充:如果有人看到这个,我建议使用内置的 regroup 模板标签,而不是使用 groupby。)

2 个回答

27

Django的模板在使用{% for %}循环时,需要知道要循环的内容有多长,但生成器是没有长度的。

所以,Django决定在循环之前把生成器转换成一个列表,这样就能获取到列表的长度了。

这样做会导致使用itertools.groupby创建的生成器出现问题。如果你没有遍历每一组内容,就会丢失里面的内容。这里有一个来自Django核心开发者Alex Gaynor的例子,首先是正常的groupby:

>>> groups = itertools.groupby(range(10), lambda x: x < 5)
>>> print [list(items) for g, items in groups]
[[0, 1, 2, 3, 4], [5, 6, 7, 8, 9]]

接下来是Django的做法;它把生成器转换成了一个列表:

>>> groups = itertools.groupby(range(10), lambda x: x < 5)
>>> groups = list(groups)
>>> print [list(items) for g, items in groups]
[[], [9]]

解决这个问题有两种方法:在Django之前自己把它转换成列表,或者阻止Django进行这个转换。

自己转换成列表

就像上面所示:

[(grouper, list(values)) for grouper, values in my_groupby_generator]

不过,如果你这样做,就失去了使用生成器的一些好处,如果这对你来说是个问题的话。

阻止Django转换成列表

另一种解决方法是把生成器包裹在一个提供__len__方法的对象里(如果你知道长度会是多少):

class MyGroupedItems(object):
    def __iter__(self):
        return itertools.groupby(range(10), lambda x: x < 5)

    def __len__(self):
        return 2

Django就能通过len()获取长度,而不需要把你的生成器转换成列表。Django这样做确实让人感到遗憾。我很幸运能够使用这个解决方法,因为我已经在使用这样的对象,并且知道长度总是固定的。

21

我觉得你说得对。我不太明白为什么,但我感觉你的 groupby 迭代器好像被提前迭代了一遍。用代码来解释会更简单:

>>> even_odd_key = lambda x: x % 2
>>> evens_odds = sorted(range(10), key=even_odd_key)
>>> evens_odds_grouped = itertools.groupby(evens_odds, key=even_odd_key)
>>> [(k, list(g)) for k, g in evens_odds_grouped]
[(0, [0, 2, 4, 6, 8]), (1, [1, 3, 5, 7, 9])]

到目前为止,一切都很好。但是当我们尝试把迭代器的内容存储到一个列表里时,会发生什么呢?

>>> evens_odds_grouped = itertools.groupby(evens_odds, key=even_odd_key)
>>> groups = [(k, g) for k, g in evens_odds_grouped]
>>> groups
[(0, <itertools._grouper object at 0x1004d7110>), (1, <itertools._grouper object at 0x1004ccbd0>)]

我们肯定是把结果缓存起来了,迭代器应该还是可以用的,对吧?错了。

>>> [(k, list(g)) for k, g in groups]
[(0, []), (1, [9])]

在获取键的过程中,组也被迭代了一遍。所以我们实际上只是缓存了键,而把组丢掉了,除了最后一个项目。

我不知道 Django 是怎么处理迭代器的,但根据这个情况,我猜它可能是把迭代器内部缓存成列表。如果你用更多的资源来做上面的操作,至少可以部分确认这个猜测。如果显示的唯一资源是最后一个,那么你几乎可以肯定在某个地方遇到了上面提到的问题。

撰写回答