Itertools.takewhile в функции генератора - почему он оценивается только один раз?

У меня есть текстовый файл:

11
2
3
4

11

111

Используя Python 2.7, я хочу превратить его в список списков строк, где разрывы строк делят элементы во внутреннем списке, а пустые строки делят элементы во внешнем списке. Например:

[["11","2","3","4"],["11"],["111"]]

И для этой цели я написал генераторную функцию, которая давала бы внутренние списки по одному за один раз, когда передал открытый файловый объект:

def readParag(fileObj):
    currentParag = []
    for line in fileObj:
        stripped = line.rstrip()
    if len(stripped) > 0: currentParag.append(stripped)
    elif len(currentParag) > 0:
        yield currentParag
        currentParag = []

Это прекрасно работает, и я могу назвать это из понимания списка, создавая желаемый результат. Однако впоследствии мне пришло в голову, что я смогу сделать то же самое более кратко с помощью itertools.takewhile (с целью переписать генераторную функцию как выражение генератора, но мы оставим это на данный момент). Это то, что я пробовал:

from itertools import takewhile    
def readParag(fileObj):
    yield [ln.rstrip() for ln in takewhile(lambda line: line != "\n", fileObj)]

В этом случае полученный генератор дает только один результат (ожидаемый первый, т.е. ["11","2","3","4"]). Я надеялся, что вызов метода next снова заставит его снова оценить takewhile(lambda line: line != "\n", fileObj) в остальной части файла, что приведет к тому, что он даст другой список. Но нет: вместо этого я получил StopIteration. Поэтому я предположил, что выражение take while оценивалось один раз только в то время, когда был создан объект-генератор, а не каждый раз, когда я вызывал метод результирующего объекта-генератора next.

Это предположение заставило меня задаться вопросом, что произойдет, если я снова позвоню функции генератора. В результате он создал новый объект-генератор, который также дал один результат (ожидаемый второй, т.е. ["11"]), прежде чем набросить StopIteration на меня. Таким образом, на самом деле написать это как функцию-генератор дает тот же результат, что и если бы я написал его как обычную функцию, а return вместо списка yield.

Я думаю, я мог бы решить эту проблему, создав свой собственный класс вместо генератора (как в ответ Джона Милликина на этот вопрос). Но дело в том, что я надеялся написать что-то более сжатое, чем моя исходная функция генератора (возможно, даже выражение генератора). Может кто-нибудь сказать мне, что я делаю неправильно, и как правильно это сделать?

Ответ 1

То, что вы пытаетесь сделать, - отличная работа для groupby:

from itertools import groupby

def read_parag(filename):
    with open(filename) as f:
        for k,g in groupby((line.strip() for line in f), bool):
            if k:
                yield list(g)

который даст:

>>> list(read_parag('myfile.txt')
[['11', '2', '3', '4'], ['11'], ['111']]

Или в одной строке:

[list(g) for k,g in groupby((line.strip() for line in open('myfile.txt')), bool) if k]

Ответ 2

Другие ответы хорошо объясняют, что здесь происходит, вам нужно называть takewhile несколько раз, что ваш текущий генератор не делает. Вот довольно краткий способ получить нужное поведение с помощью встроенной функции iter() с аргументом дозорного:

from itertools import takewhile

def readParag(fileObj):
    cond = lambda line: line != "\n"
    return iter(lambda: [ln.rstrip() for ln in takewhile(cond, fileObj)], [])

Ответ 3

Именно так должно вести себя .takewhile(). Пока условие истинно, оно вернет элементы из базового итеративного файла, и как только оно станет ложным, он будет автоматически переключиться на этап, выполняемый итерацией.

Обратите внимание, что так итераторы должны вести себя; воскрешение StopIteration означает именно это, перестаньте перебирать меня, я закончил.

Из глоссария python на "итераторе" :

Объект, представляющий поток данных. Повторные вызовы метода итераторов next() возвращают последовательные элементы в потоке. Когда больше нет данных, вместо этого возникает исключение StopIteration. На этом этапе объект итератора исчерпан, и любые дальнейшие вызовы его методу next() снова поднимают StopIteration.

Вы можете объединить takewhile с tee, чтобы узнать, есть ли в следующей партии больше результатов:

import itertools

def readParag(filename):
    with open(filename) as f:
        while True:
            paras = itertools.takewhile(lambda l: l.strip(), f)
            test, paras = itertools.tee(paras)
            test.next()  # raises StopIteration when the file is done
            yield (l.strip() for l in paras)

Это дает генераторы, поэтому каждый полученный элемент сам по себе является генератором. Вы должны потреблять все элементы в этих генераторах, чтобы это продолжало работать; то же самое верно для метода groupby, указанного в другом ответе.

Ответ 4

Если содержимое файла вписывается в память, гораздо проще получить группы, разделенные пустыми строками:

with open("filename") as f:
    groups = [group.split() for group in f.read().split("\n\n")]

Этот подход можно сделать более надежным, используя re.split() вместо str.split() и путем фильтрации потенциальных пустых групп в результате четырех или более последовательных разрывов строк.

Ответ 5

Это документированное поведение takewhile. Это условие выполняется, пока условие истинно. Он не запускается снова, если условие позже становится истинным снова.

Простое исправление заключается в том, чтобы ваша функция просто вызывала takewhile в цикле, останавливаясь, когда takewhile больше ничего не возвращает (т.е. в конце файла):

def readParag(fileObj):
    while True:      
        nextList = [ln.rstrip() for ln in takewhile(lambda line: line != "\n", fileObj)]
        if not nextList:
            break
        yield nextList

Ответ 6

Вы можете вызвать takewhile несколько раз:

>>> def readParagGenerator(fileObj):
...     group = [ln.rstrip() for ln in takewhile(lambda line: line != "\n", fileObj)]
...     while len(group) > 0:
...         yield group
...         group = [ln.rstrip() for ln in takewhile(lambda line: line != "\n", fileObj)]
... 
>>> list(readParagGenerator(StringIO(F)))
[['11', '2', '3', '4'], ['11'], ['111']]