Упражнения и задачи по Python для изучения языка на практике

Упражнения на Python, которые предлагаются раз в неделю в рассылке Python Morsels известным коучем Треем Ханнером (Trey Hunner). Рассматриваются задачи (а также дополнительные бонусные задачи) и их решения из бесплатной пробной версии рассылки, которую получает каждый подписавшийся. Плюс в том, что задача излагается в первом коротком письме, и дается пара дней на свое решение, которое можно проверить с помощью тестов на сайте. Затем на почту приходит очень длинный и подробный разбор возможных решений.

Источник: Python Morsels

Содержание

  • 1. Класс для создания круга с атрибутами радиуса, диаметра и площади (и три бонусных задачи).
  • 2. Исправление CSV-файла (и две бонусных задачи).
  • 3. Функция, которая принимает последовательность и возвращает итерируемый объект, предварительно удалив дубликаты (и две бонусных задачи).
  • 4. Функция для возврата указанного количества элементов последовательности с конца (и две бонусных задачи).

1. Класс для создания круга с атрибутами радиуса, диаметра и площади (и три бонусных задачи).

Упражнение по Python на этой неделе: сделать класс Circle (круг) с радиусом, диаметром и площадью. Еще можно добавить красивое представление в строке (string representation). Как это должно работать:

>>> c = Circle(5)
>>> c
Circle(5)
>>> c.radius
5
>>> c.diameter
10
>>> c.area
78.53981633974483

Для радиуса нужно поставить значение 1 по умолчанию, если при создании круга никакого радиуса не указывается:

>>> c = Circle()
>>> c.radius
1
>>> c.diameter
2

В этом упражнении по Python три бонусных задачи.

Первая. При изменении радиуса также должны меняться диаметр и площадь:

>>> c = Circle(2)
>>> c.radius = 1
>>> c.diameter
2
>>> c.area
3.141592653589793
>>> c
Circle(1)

Вторая: должна быть возможность менять атрибут диаметра (diameter attribute) в классе Circle, при этом значение радиуса должно обновляться, а площадь задавать нельзя (присваивание площади должно вызывать ошибку AttributeError):

>>> c = Circle(1)
>>> c.diameter = 4
>>> c.radius
2.0

Третья: отрицательные значения радиуса не допустимы:

>>> c = Circle(5)
>>> c.radius = 3
>>> c.radius = -2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "circle.py", line 27, in radius
    raise ValueError("Radius cannot be negative")
ValueError: Radius cannot be negative

Автоматизированные тесты для этого упражнения по Python можно найти здесь (нужно зарегистрироваться). Написанную функцию нужно вставить в файл circle.py рядом с файлом тестов. Для запуска тестов используется команда python test_circle.py. Возможно, вы увидите одну из предусмотренных неудач (или даже один из неожиданных успехов).

Для тестирования бонусных задач нужно раскомментировать соответствующие строки кода в тестах.

Подсказка по этому упражнению: почитайте про методы getter и setter в Python.

Решение

Задача по Python на этой неделе заключалась в необходимости создать класс для представления круга. Он должен включать в себя радиус, диаметр и площадь, при этом диаметр и площадь должны обновляться автоматически в зависимости от переменной радиуса.

Начнем с варианта ответа, который работает, но не совсем.

import math

class Circle:
    """Круг с радиусом, площадью и диаметром."""
    def __init__(self, radius):
        self.radius = radius
        self.area = math.pi * self.radius ** 2
        self.diameter = self.radius * 2

На этом примере все атрибуты заданы в методе инициализации, который запускается при каждом создании нового экземпляра нашего класса (class instance). В этом ответе нет двух нужных нам элементов: нет значения по умолчанию для радиуса и полезного представления в строке.

При использовании значения по умолчанию представление нашего класса выглядит примерно так:

>>> c = Circle(1)
>>> c
<circle.Circle object at 0x7f75816c48d0>

Это не сильно помогает программистам, которые будут пользоваться нашим классом. Можно исправить это, добавив метод __repr__. Давайте так и сделаем, а затем также добавим радиусу значение по умолчанию (default value) в методе инициализации:

import math

class Circle:

    """Круг с радиусом, площадью и диаметром."""

    def __init__(self, radius=1):
        self.radius = radius
        self.area = math.pi * self.radius ** 2
        self.diameter = self.radius * 2

    def __repr__(self):
        return f'Circle({self.radius})'

Здесь мы контролируем представление класса в строке. Оно возвратит результат, похожий на код для воссоздания эквивалентного класса.

Отметим, что для форматирования строки используются строки f-string. Данная возможность появилась в Python 3.6 и служит альтернативой методу format и оператору %.

Теперь представление в строке выглядит гораздо симпатичнее:

>>> c = Circle()
>>> c
Circle(1)

Также не нужно указывать радиус (ему задано значение по умолчанию 1).

Обратите внимание, что не нужно внедрять метод __str__, который тоже помогает создавать представление в строке. Функционал __str__ по умолчанию реализован на основе __repr__, поэтому если они совпадают, нам нужно определить только __repr__.

Данный вариант кода пройдет все базовые тесты.

Перейдем к бонусным задачам.

Для решения первой бонусной задачи требовалось реализовать автоматическое изменение диаметра и площади в случае изменения радиуса. Она решается через добавление декоратора @property к атрибутам диаметра и площади:

class Circle:

    """Круг с радиусом, площадью и диаметром."""

    def __init__(self, radius=1):
        self.radius = radius

    def __repr__(self):
        return f'Circle({self.radius})'

    @property
    def area(self):
        return math.pi * self.radius ** 2

    @property
    def diameter(self):
        return self.radius * 2

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

Если вы никогда не видели свойства (properties), обязательно нужно ознакомиться с ними. Они являются предпочтительным эквивалентом Python перед методами getter и setter.

Перейдем ко второй бонусной задаче. Согласно ее условиям, должна быть возможность определить какое-нибудь значение для свойства диаметра, в результате чего радиус будет автоматически изменяться в зависимости от заданного значения.

Для этого нам нужно создать setter для свойства диаметра. На данном примере показаны только методы свойства для диаметра нашего класса:

    @property
    def diameter(self):
        return self.radius * 2

    @diameter.setter
    def diameter(self, diameter):
        self.radius = diameter / 2

Синтакс для сеттеров свойства немного странный. Если вам непонятно, зачем мы используем символ @, именно он создает декоратор в Python. Встроенный класс property в Python можно использовать в качестве декоратора. Но с ним возможна и другая реализация:

    def get_diameter(self):
        return self.radius * 2

    def set_diameter(self, diameter):
        self.radius = diameter / 2

    diameter = property(get_diameter, set_diameter)

Осталась последняя третья бонусная задача. В этом бонусе нам нужно сделать так, чтобы атрибуту радиуса нельзя было присваивать отрицательное значение. Тут придется подумать подольше, даже если есть опыт со свойствами.

Ответ кроется в понимании того, что атрибут радиуса тоже можно дополнить свойством:

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, radius):
        if radius < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = radius

Все, что нам нужно для реализации бонуса — это добавить getter и setter к свойству радиуса. Каждый раз, когда мы создаем радиус (например, в методе __init__ или в методе setter для диаметра) либо используем радиус (например, в методе getter для диаметра и площади), то вызываются эти методы getter и setter.

Некоторые могут подумать, что следует обновить метод __init__, чтобы осуществлять присваивание еще и на self._radius для отработки этого исключительного случая. Но это не нужно, потому что к моменту вызова __init__ наш экземпляр класса уже создан, и присваивание на self.radius вызовет метод radius setter автоматически! Мы можем считать атрибут _radius запрятанным в свойстве getter/setters. Изменить _radius можно извне, но для этого нет достаточно сильной причины.

Свойства в Python и правда очень полезны, они позволяют использовать API существующего класса с атрибутами и поддерживать постоянную обратную совместимость и одновременно с этим добавлять новый функционал во время поиска атрибута или присваивания на него.

2. Исправление CSV-файла (и две бонусных задачи).

Упражнения по Python на этой неделе дает возможность попрактиковаться в нормализации CSV-файла. Нужно написать программу fix_csv.py, которая конвертирует файл, в котором данные разделены вертикальной строкой, в CSV-файл.

Как это должно работать. См. пример исходного файла:

Reading|Make|Model|Type|Value
Reading 0|Toyota|Previa|distance|19.83942
Reading 1|Dodge|Intrepid|distance|31.28257

Запускаем созданный скрипт следующей командой:

$ python fix_csv.py cars-original.csv cars.csv

Исправленный файл должен выглядеть так:

Reading,Make,Model,Type,Value
Reading 0,Toyota,Previa,distance,19.83942
Reading 1,Dodge,Intrepid,distance,31.28257

Подсказка: скорее всего, придется использовать модуль csv из стандартной библиотеки Python.

Первая бонусная задача: должна быть опция для указания разделителя между вводимыми данными и символа для обозначения кавычек. То есть, следующие команды должны давать результат:

$ python fix_csv.py --in-delimiter="|" cars.csv cars-fixed.csv
$ python fix_csv.py cars.csv cars-fixed.csv --in-delimiter="|"
$ python fix_csv.py --in-delimiter="|" --in-quote="'" cars.csv cars-fixed.csv
$ python fix_csv.py --in-quote="'" --in-delimiter="|" cars.csv cars-fixed.csv

Чтобы реализовать данный бонус, потребуется изучить парсинг аргументов командной строки в Python. Эту задачу можно решить с помощью нескольких стандартных модулей. На самом деле, есть целых три разных решения в рамках стандартной библиотеки, но по-настоящему рекомендовать можно только один.

Вторая бонусная задача: автоматическое определение разделителя, если никакого значения для внутреннего разделителя не указано (неправильно изначально предполагать, что это вертикальная черта и знак кавычек, это нужно вычислить).

Вторая бонусная задача немного хитрее, и она, возможно, не будет работать со всеми файлами. Скорее всего, для ее решения придется еще раз прочитать документацию для модуля csv.

Автоматизированные тесты для этого упражнения по Python можно найти здесь (нужно зарегистрироваться). Написанную функцию нужно вставить в файл fix_csv.py рядом с файлом тестов. Для запуска тестов используется команда python test_fix_csv.py. Возможно, вы увидите одну из предусмотренных неудач (или даже один из неожиданных успехов).

Решение

Данная программа fix_csv.py должна принимать название существующего файла с вертикальной чертой в качестве разделителя и название нового файла с запятой в качестве разделителя.

Возможный вариант решения:

import sys


old_filename = sys.argv[1]
new_filename = sys.argv[2]

old_file = open(old_filename)
rows = [
    line.split('|')
    for line in old_file.read().splitlines()
]

new_file = open(new_filename, mode='wt', newline='\r\n')
print("\n".join(
    ",".join(row)
    for row in rows
), file=new_file)
old_file.close()
new_file.close()

Но это решение не проходит наши тесты. Одной из причин такого результата является то, что оно не обрабатывает кавычки, если разделители присутствуют в выходных данных. Чтобы исправить это, можно воспользоваться модулем csv.

import csv
import sys


old_filename = sys.argv[1]
new_filename = sys.argv[2]

with open(old_filename, newline='') as old_file:
    reader = csv.reader(old_file, delimiter='|')
    rows = [line for line in reader]

with open(new_filename, mode='wt', newline='') as new_file:
    writer = csv.writer(new_file)
    writer.writerows(rows)

Обратите внимание на то, что мы внесли изменения еще в паре мест.

Первым большим изменением является то, что мы используем объекты csv.reader и csv.writer при парсинге файлов с разделенными данными. Обратите внимание на то, что модуль csv применим ко всем файлам с разделенными данными, а не только к файлам с запятой в качестве разделителя.

Еще одно изменение заключается в том, что мы используем блоки с оператором with для автоматического закрытия файлов после завершения работы с ними.

Но этот вариант тоже не проходит тесты. Причина: неожиданно оказалось, что наш скрипт принимает больше двух аргументов и попросту игнорирует все аргументы после первых двух:

$ python fix_csv.py in_file.psv out_file.csv another_arg and_another

Мы можем исправить это, разделив аргументы слайсом и распаковав (unpack) их ровно на две переменные.

Вместо этого:

old_filename = sys.argv[1]
new_filename = sys.argv[2]

Мы делаем это:

old_filename, new_filename = sys.argv[1:]

Это ленивый способ обеспечить вызов исключения (exception) (хотя не особенно симпатичный), исключая случаи, когда передаются ровно два аргумента. В данном случае мы используем множественное присваивание (multiple assignment).

Еще можно улучшить эту строку:

    rows = [line for line in reader]

Каждый раз, когда вам встречается генератор списка (list comprehension), осуществляющий итерацию по объекту, который ничего не отфильтровывает и не меняет никакие элементы, а просто создает список, его можно заменить конструктором списка (list constructor):

    rows = list(reader)

Конструктор списка пробегает по любому объекту, который вы ему дадите, и создает новый список.

При желании, мы также можем немного сократить эту часть кода, написав одну строку с методами reader и writer:

import csv
import sys


old_filename, new_filename = sys.argv[1:]

with open(old_filename, newline='') as old_file:
    rows = list(csv.reader(old_file, delimiter='|'))

with open(new_filename, mode='wt', newline='') as new_file:
    csv.writer(new_file).writerows(rows)

Обратите внимание на то, что в обоих вызовах open мы указываем пустую строку (string) в аргументе newline. Причина: модуль CSV автоматически добавляет \r\n в конце строки файла, но Python в системах Windows автоматически берет все концы LF (\n) и преобразовывает их в концы CRLF (\r\n). Поэтому при записи в файл CSV в Python 3 рекомендуется использовать аргумент newline=''. В Python 2 по той же причине лучше использовать режим 'wb'.

При чтении в функции open аргумент newline='' не обязателен, потому что модуль CSV обрабатывает \r\n и \n как конец строки, но мне кажется, что лучше быть последовательным при открытии файла, в которых должны быть данные с разделителем и CRLF на концах.

По поводу объектов reader и writer в модуле csv также хотелось бы отметить, что метод writerows из объекта writer принимает итерируемый объект (iterable), а объект reader представляет собой итерируемый объект. Поэтому, если мы будем использовать вложенные операторы with, но мы будем осуществлять прямую запись во время чтения:

import csv
import sys


old_filename, new_filename = sys.argv[1:]

with open(old_filename, newline='') as old_file:
    reader = csv.reader(old_file, delimiter='|')
    with open(new_filename, mode='wt', newline='') as new_file:
        csv.writer(new_file).writerows(reader)

Такой вариант имеет большое преимущество, но и недостаток тоже.

Большое преимущество заключается в том, что он позволяем нам эффективно обрабатывать очень большие файлы с разделителями, потому что здесь мы обрабатываем файлы построчно. Каждая прочитанная строка сразу же дает одну записанную строку (потому что reader читает построчно ленивым способом, а writerows осуществляет ленивую итерацию по итерируемому объекту, который передается ему по мере записи).

Недостаток заключается в том, что если мы передадим одно и то же название файла на вход и на выход, то получим немного странный результат, потому что мы будем записывать в файл по мере чтения из него. Часто это плохая идея.

Поэтому я буду и дальше использовать вышеприведенный метод с записью в список и двумя блоками с оператором with. Если вам предпочтительно записывать по мере чтения, можно передавать reader прямо в метод writerows объекта writer.

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

from argparse import ArgumentParser
import csv


parser = ArgumentParser()
parser.add_argument('old_filename')
parser.add_argument('new_filename')
args = parser.parse_args()

with open(args.old_filename, newline='') as old_file:
    rows = list(csv.reader(old_file, delimiter='|'))

with open(args.new_filename, mode='wt', newline='') as new_file:
    csv.writer(new_file).writerows(rows)

Библиотека argparse дает нашему скрипту командной строки приятный интерфейс, которому нужно правильное количество аргументов и который показывает информацию об использовании, если добавить флаг -h или --help, а также показывает полезные сообщения об ошибках, если даются некорректные аргументы командной строки.

Также она облегчает первую бонусную задачу. Следует отметить, что в стандартной библиотеке есть еще два инструмента для парсинга командной строки. Возможно, вы о них слышали, но они, в основном, сосредоточены, на парсинге опций (речь о флагах -i и --in-quote), и ни один из них не обрабатывает позиционные аргументы (positional arguments) из командной строки. см. PEP 389, почему getopt и optparse не достаточны.

Бонусная задача 1. В этом бонусе мы должны были принимать не обязательные аргументы по разделителю и символу кавычек для файла с вводными данными. Вот так можно изменить программу, чтобы принимать эти аргументы:

from argparse import ArgumentParser
import csv


parser = ArgumentParser()
parser.add_argument('old_filename')
parser.add_argument('new_filename')
parser.add_argument('--in-delimiter', dest='delim')
parser.add_argument('--in-quote', dest='quote')
args = parser.parse_args()

with open(args.old_filename, newline='') as old_file:
    quotechar = '"'
    delimiter = '|'
    if args.delim:
        delimiter = args.delim
    if args.quote:
        quotechar = args.quote
    reader = csv.reader(old_file, delimiter=delimiter, quotechar=quotechar)
    rows = list(reader)

with open(args.new_filename, mode='wt', newline='') as new_file:
    writer = csv.writer(new_file)
    writer.writerows(rows)

Мы добавили еще два аргумента к парсеру аргументов, и они оба не обязательные (значение аргументов по умолчанию приведено внути --).

Обратите внимание на то, что мы добавили довольно много кода, чтобы обрабатывать новые аргументы при чтении файла с вводными данными. По умолчанию значения этих аргументов (если они не указаны) установлены на None. То есть, мы предусмотрели значения по умолчанию | и ", и изменяем эти значения по умолчанию только в случае, если они имеют содержание, т.е. True (None относится к не содержательным переменным (falsey), поэтому в данном условии с оператором if оно определяется как False).

Чтобы было легче понять, можно установить значения для этих аргументов по умолчанию при добавлении аргументов:

from argparse import ArgumentParser
import csv


parser = ArgumentParser()
parser.add_argument('old_filename')
parser.add_argument('new_filename')
parser.add_argument('--in-delimiter', dest='delim', default='|')
parser.add_argument('--in-quote', dest='quote', default='"')
args = parser.parse_args()

with open(args.old_filename, newline='') as old_file:
    reader = csv.reader(old_file, delimiter=args.delim, quotechar=args.quote)
    rows = list(reader)

with open(args.new_filename, mode='wt', newline='') as new_file:
    writer = csv.writer(new_file)
    writer.writerows(rows)

Таким образом, устраняется всякая проверка условий, и в нашем коде больше нет исключительных случаев.

Вторая бонусная задача. В ней есть небольшой подвох. Цель задачи: автоматически определять (или попытаться определить) формат файла с вводными данными. Для этого можно воспользоваться классом Sniffer из модуля csv:

from argparse import ArgumentParser
import csv


parser = ArgumentParser()
parser.add_argument('old_filename')
parser.add_argument('new_filename')
parser.add_argument('--in-delimiter', dest='delim')
parser.add_argument('--in-quote', dest='quote')
args = parser.parse_args()

with open(args.old_filename, newline='') as old_file:
    arguments = {}
    if args.delim:
        arguments['delimiter'] = args.delim
    if args.quote:
        arguments['quotechar'] = args.quote
    if not args.delim and not args.quote:
        arguments['dialect'] = csv.Sniffer().sniff(old_file.read())
        old_file.seek(0)
    reader = csv.reader(old_file, **arguments)
    rows = list(reader)

with open(args.new_filename, mode='wt', newline='') as new_file:
    writer = csv.writer(new_file)
    writer.writerows(rows)

Здесь наш код стал сложнее. Если указывается разделитель и/или символ кавычек, мы даем поименованные аргументы (keyword arguments) для объекта csv reader. Если ни один из них не указан, то мы используем объект csv Sniffer, чтобы найти их.

Обратите внимание на то, что мы проверяем весь файл csv, а затем в режиме поиска возвращаемся к началу файла, чтобы начать его чтение заново. Файлы являются итераторами, то есть могут фиксировать текущее состояние, запоминая наше положение по мере итерации по их содержанию.

3. Функция, которая принимает последовательность и возвращает итерируемый объект, предварительно удалив дубликаты (и две бонусных задачи).

В этом упражнении по Python написанная нами функция должна принимать любую последовательность (например, список) и возвращать любой итерируемый объект (то есть, любой объект, который позволяет осуществить итерацию по своим элементам). Например:

>>> compact([1, 1, 1])
[1]
>>> compact([1, 1, 2, 2, 3, 2])
[1, 2, 3, 2]
>>> compact([])
[]

К данному упражнению прилагаются две бонусные задачи. Рекомендуется сначала решить само упражнение без учета бонусов, а затем решить каждый бонус отдельно.

Первый бонус: необходимо убедиться, что в качестве аргумента принимается только итерируемый объект (iterable), а не просто какая-то последовательность. Это означает, что во время поиска нельзя использовать поиск индексов.

Вот пример с выражением-генератором (generator expression), который представляет собой ленивый итерируемый объект (lazy iterable):

>>> compact(n**2 for n in [1, 2, 2])
[1, 4]

Второй бонус: нужно проверить, что функция возвращает итератор (например, генератор), а не список.

>>> c = compact(n**2 for n in [1, 2, 2])
>>> iter(c) is c
True

Также это должно позволять функции принимать бесконечно длинные итерируемые объекты (или другие ленивые итерируемые объекты).

Автоматизированные тесты для этого упражнения по Python можно найти здесь (нужно зарегистрироваться). Написанную функцию нужно вставить в файл compact.py рядом с файлом тестов. Для запуска тестов используется команда python test_compact.py. Возможно, вы увидите одну из предусмотренных неудач (или даже один из неожиданных успехов). Чтобы проверить бонус, нужно раскомментировать помеченные строки кода в файле тестов.

Решение

Если предположить, что функции передается список (list), строка (string) или другая последовательность (с индексами от нуля и далее), можно использовать индексы для проверки текущего элемента на совпадение с предыдущим:

def compact(sequence):
    """Возвращаем новый итерируемый объект после удаления дублирующихся значений."""
    deduped = []
    for i, item in enumerate(sequence):
        if i == 0 or item != sequence[i-1]:
            deduped.append(item)
    return deduped

Мы всегда добавляем элемент 0, а затем добавляем только те из последующих элементов, которые не равны элементу, предшествующему их индексу.

Еще один способ выполнить данное упражнение: склеить (zip) исходную последовательность с точно такой же, но со сдвигом в один элемент, чтобы каждый элемент мог сравнить себя с предыдущим:

def compact(sequence):
    """Возвращаем новый итерируемый объект после удаления дублирующихся значений."""
    deduped = []
    for item, previous in zip(sequence, [object(), *sequence]):
        if item != previous:
            deduped.append(item)
    return deduped

Следует отметить, что мы используем распаковку с оператором * внутри списка. Данная функция появилась в Python 3.5. В данном случае оператор * как бы распаковывает каждый элемент нашей последовательности в новый список, указанный после object().

Первым элементом во втором списке будет object(), потому что новый объект не будет сравниваться на эквивалентность с каким-либо элементом последовательности (по умолчанию каждый объект равен только самому себе).

Это странное решение и, возможно, чрезмерно заумное. Думаю, что можно было бы предпочесть решение на основе индексов, хотя функция zip — одна из любимых.

Перейдем к первой бонусной задаче. Для ее решения нужно, чтобы функция принимала любой итерируемый объект, а не просто последовательности.

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

Мы можем попробовать конвертацию входного итерируемого объекта в список с последующей итерацией аналогично вышеприведенному:

def compact(iterable):
    """Возвращаем новый итерируемый объект после удаления дублирующихся значений."""
    sequence = list(iterable)
    deduped = []
    for i, item in enumerate(sequence):
        if i == 0 or item != sequence[i-1]:
            deduped.append(item)
    return deduped

Но это не особо эффективно, потому что мы создаем новый список только для того, чтобы пройтись по нему и отмести его.

Если мы хотим, чтобы наше решение эффективно работало с любыми итерируемыми объектами, нам нужен один цикл по итерируемому объекту (чего нет в решении с функцией zip), не опираясь при этом на индексы (что невозможно в вышеприведенном решении):

def compact(iterable):
    """Возвращаем новый итерируемый объект после удаления дублирующихся значений."""
    deduped = []
    previous = None
    for item in iterable:
        if item != previous:
            deduped.append(item)
            previous = item
    return deduped

Но это решение тоже не пройдет тесты! Оно не работает потому, что если наш список начинается с None, то возникнет проблема, ведь получается, что предыдущий элемент начинается с None, и поэтому будет выглядеть так, будто первая переменная итерируемого объекта должна быть удалена.

Мы можем исправить это, определив какую-нибудь переменную, которая будет отслеживать, не находимся ли мы в начале итерируемого объекта:

def compact(iterable):
    """Возвращаем новый итерируемый объект после удаления дублирующихся значений."""
    deduped = []
    first = True
    for item in iterable:
        if first or item != previous:
            deduped.append(item)
            previous = item
            first = False
    return deduped

Эта первая переменная будет содержательной (True) только в самом начале; с ее помощью гарантируется, что мы всегда добавляем первый элемент вместо проверки предыдущей переменной.

Также мы можем использовать подход с присваиванием каждой предыдущей переменной такого значения, которое будет равно только себе самому (наподобие объекта object(), которым мы пользовались выше):

def compact(iterable):
    """Возвращаем новый итерируемый объект после удаления дублирующихся значений."""
    deduped = []
    previous = object()
    for item in iterable:
        if item != previous:
            deduped.append(item)
            previous = item
    return deduped

Это изящное решение, потому что нам не нужно одновременно проверять первый элемент и сравнивать каждый элемент с предыдущим всякий раз, когда мы проверяем все элементы.

Я только что упоминал объект object(), но хотелось бы коротко показать, почему мы его используем.

Каждой из предыдущих переменных нужно присваивать какое-нибудь значение перед ее прочтением. Если мы поставим previous = None (для примера), то случится следующее:

>>> compact([None, None, 1, 2, 2, 3])
[1, 2, 3]

Так происходит в случае, когда предыдущее значение получает что-то, что может считаться эквивалентным первому значению списка. Тогда эти первые значения будут удалены. Это плохо!

Если мы поставим previous = object(), то предыдущее никогда не будет оцениваться как эквивалентное чему-либо помимо себя самого. Так происходит потому, что классы в Python по умолчанию определяют объект как индивидуальный объект/индивидуальность. Поэтому мы используем объект object() для присваивания предыдущему совершенно уникального значения.

Перейдем к бонусной задаче 2. Она предполагает возвращение итератора. Генератор-функция (generator function) — один из способов создать итератор.

Мы можем создать генератор-функцию, изменив все вызовы методы добавления к списку (append) на оператор yield, чтобы не возвращать список:

def compact(iterable):
    """Возвращаем новый итерируемый объект после удаления дублирующихся значений."""
    previous = object()
    for item in iterable:
        if item != previous:
            yield item
            previous = item

В этом случае мы используем функцию-генератора. Они не похожи на обычные функции и возвращают объект-генератор (generator object), который возвращает элементы каждый раз, когда в нашей функции-генераторе вызывается оператор yield.

Следует обратить внимание на то, что с этим примером тесты не будут выполнены, если пройтись по всему итерируемому объекту, который мы получаем на входе, перед тем как начать возврат значений. Возьмем следующий пример:

def compact(iterable):
    """Возвращаем новый итерируемый объект после удаления дублирующихся значений."""
    sequence = list(iterable)
    for i, item in enumerate(sequence):
        if i == 0 or item != sequence[i-1]:
            yield item

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

Если итерируемый объект, который передается нашей функции, является итератором, этот итератор будет ленивым и одноразовым (потому что таковы все итераторы). Генератор, который возвращается нашей функцией, также будет ленивым и одноразовым (потому что генераторы являются итераторами). Мы хотим, чтобы ленивое свойство нашей функции учитывало ленивое свойство передаваемого нам итератора, поэтому мы должны пройтись по предоставленному итерируемому объекту поэлементно.

Порядок работы функций-генераторов на фундаментальном уровне отличается от обычных функций в Python. Если вы никогда о них не слышали, возможно, стоит ознакомиться.

Давайте рассмотрим последнее решение для этого упражнения. В нем используется одна утилита из стандартной библиотеки:

from itertools import groupby

def compact(iterable):
    return (
        item
        for item, group in groupby(iterable)
    )

Функция itertools.groupby хорошо подходит для того, чтобы помочь нам написать нашу функцию. Очень редко можно найти оправдание для того, чтобы воспользоваться функцией groupby, но данное упражнение хорошо подходит. Функция groupby последовательно группирует эквивалентные элементы итерируемого объекта (данное поведенеие можно немного изменить, если указать функцию-ключ, но это не наш случай). Мы объединяем последовательно расположенные дубликаты элемента в один, поэтому ключи, которые возвращает функция groupby — это все, что нам по-настоящему нужно для создания нового итерирумого объекта.

Выше мы создали выражение-генератора (generator expression), чтобы убедиться в том, что наша функция возвращает итератор. Выражения-генераторы представляют собой для функций-генераторов примерно то же самое, что генераторы списков (list comprehensions) для самих списков.

Также следует отметить, что мы распаковываем значения, которые нам возвращает groupby, и передаем их переменным элементов и групп с помощью множественного присваивания (multiple assignment).

4. Функция для возврата указанного количества элементов последовательности с конца (и две бонусных задачи).

На этой неделе наше упражнение по Python будет посвящено функции, которая принимает любую последовательность (sequence) (например, список, строку или кортеж) и любое число n, а после этого возвращает соответствующее этому числу количество элементов последовательности с конца в виде списка. Например, вот так:

>>> tail([1, 2, 3, 4, 5], 3)
[3, 4, 5]
>>> tail('hello', 2)
['l', 'o']
>>> tail('hello', 0)
[]

Бонусная задача: функция должна возвращать пустой список, если задается отрицательное число n. Пример:

>>> tail('hello', -2)
[]

Вторая бонусная задача: функция должна работать с любым итерируемым объектом, а не только с последовательностями. Например:

>>> squares = (n**2 for n in range(10))
>>> tail(squares, 3)
[49, 64, 81]

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

Автоматизированные тесты для этого упражнения по Python можно найти здесь (нужно зарегистрироваться). Написанную функцию нужно вставить в файл tail.py рядом с файлом тестов. Для запуска тестов используется команда python test_tail.py. Возможно, вы увидите одну из предусмотренных неудач (или даже один из неожиданных успехов).

Для тестирования бонусных задач нужно раскомментировать соответствующие строки кода в тестах.

Решение

На этой неделе нужно было сделать функцию, которая принимает последовательность и число n, а затем возвращает количество элементов последовательности, соответствующее числу n.

Давайте попробуем довольно простой подход. Можно сделать слайс (slice) последовательности, чтобы получить нужное количество элементов с конца:

def tail(sequence, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    return sequence[-n:]

Однако оно не пройдет все наши тесты. Во-первых, возвращаемое значение (return value) будет совпадать по своему типу с заданной последовательностью. Потому, если функции передается строка, то при возврате мы получим строку, а если это будет кортеж, то мы получим кортеж.

Мы хотим, чтобы возвращаемым значением всегда был список, поэтому давайте преобразуем выделенную слайсом последовательность в список:

def tail(sequence, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    return list(sequence[-n:])

Данный вариант работает с объектами, которые не являются списками, но не работает, если числом n является ноль. Так происходит потому, что sequence[-0:] совпадает с sequence[0:], то есть вернет всю последовательность (sequence) (которая, скорее всего, содержит не ноль элементов). Мы можем исправить это через добавление проверки на такую ситуацию:

def tail(sequence, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    if n == 0:
        return []
    return list(sequence[-n:])

Теперь давайте перейдем к первой бонусной задаче. Для ее решения нужно, чтобы функция возвращала пустой список в случае, если ей передается отрицательное число n. Это сделать просто, достаточно поменять оператор == на <=:

def tail(sequence, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    if n <= 0:
        return []
    return list(sequence[-n:])

Далее перейдем ко второй бонусной задаче. Она требует добавления к функции возможности принимать итерируемые объекты любого рода. Мы можем просто преобразовать передаваемый на входе итерируемый объект в список:

def tail(iterable, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    sequence = list(iterable)
    if n <= 0:
        return []
    return sequence[-n:]

Но, возможно, это не лучшая идея. Если кто-то вызовет нашу функцию с очень большим итерируемым объектом, мы создадим копию этого объекта, а затем вернем только нужное количество элементов с конца. Скорее всего, не возникнет больших проблем с копированием нескольких тысяч элементов, но представьте себе, что мы сделаем круг по файлу логов на 20 Гб и будем хранить каждую строку в памяти. С точки зрения использования памяти это не очень эффективно.

Вместо того, чтобы хранить каждый элемент заданного итерируемого объекта в новой последовательности, мы можем хранить только то количество элементов с конца, которое соответствует числу n. Вот один из вариантов:

def tail(iterable, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    items = []
    if n <= 0:
        return []
    for item in iterable:
        items = [*items[-(n-1):], item]
    return items

Здесь мы используем оператор * для распаковки (n-1) элементов в новый список, при этом текущий элемент будет в конце. После этого мы осуществляем повторное присваивание элементов. Такой способ использования выражений с оператором * работает, начиная с Python 3.5.

Следует отметить, что данный вариант не работает, если в качестве n передается число 1. Когда n равно 1, то (n-1) равно нулю. Это означает, что наш список с элементами будет непрерывно расти вместо того, чтобы хранить последний элемент.

Мы можем исправить это, предусматривая отдельное решение в цикле для случаев, когда n равно 1:

def tail(iterable, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    items = []
    if n <= 0:
        return []
    for item in iterable:
        if n == 1:
            items = [item]
        else:
            items = [*items[-n+1:], item]
    return items

Возможно, этот код выглядит немного глупым или дублирующим, но он работает. Мы переносим выражение if-else и ставим его перед циклом for. То есть, цикл for оказывается внутри этого выражения.

def tail(iterable, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    items = []
    if n == 1:
        for item in iterable:
            items = [item]
    elif n > 0:
        for item in iterable:
            items = [*items[-n+1:], item]
    return items

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

На самом деле, мы можем передвинуть данное выражение if-else за пределы цикла for, не повторяя цикл в случае, если мы передаем полученные в результате слайс-операции объекты в цикл for:

def tail(iterable, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    items = []
    if n <= 0:
        return []
    elif n == 1:
        index = slice(0, 0)
    else:
        index = slice(-(n-1), None)
    for item in iterable:
        items = [*items[index], item]
    return items

Нечасто можно встретить, чтобы в Python слайс-объекты использовались очень активно. Python создает слайс-объект в случае, когда используется обозначение для процедуры слайса (slicing notation). Когда мы пишем sequence[-n:], Python, по сути, преобразовывает этот код в sequence[slice(-n, None)].

Хотелось бы рассмотреть еще одно решение. В нем используется двухсторонний класс deque из модуля collections:

from collections import deque

def tail(iterable, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    if n <= 0:
        return []
    items = deque(maxlen=n)
    for item in iterable:
        items.append(item)
    return list(items)

В экземпляре класса deque мы устанавливаем максимальную длину на n. Когда вы добавляете объекты к deque, и он достигает максимальной длины, он эффективно удалит самый близкий к началу элемент перед добавлением нового элемента. Поэтому данный вариант эффективно помогает отслеживать самое новые элементы в количестве, соответствующем числу n.

На самом деле, есть еще одно решение с использованием deque. Поскольку объектам deque можно передавать итерируемый объект, состоящий из элементов, с помощью которых такие объекты можно инициализировать, мы можем создать deque и сразу же возвратить его:

from collections import deque

def tail(iterable, n):
    """Возвращаем соответствующее числу n количество элементов последовательности с конца."""
    if n <= 0:
        return []
    return list(deque(iterable, maxlen=n))

Таким образом, мы заполняем объект deque с помощью итерируемого объекта и определяем максимальную длину (мы могли бы использовать позиционный аргумент, но предпочли поставить поименованный), а затем преобразовываем этот объект deque в список. Этот вариант пройдет тесты (которые ожидают получить список).

Если вам еще ни разу не попадались примеры с deque, можно найти их на известном сайте Python Module of the Week.