Собственные исключения и итерабельные объекты

На этом занятии я решил привести пример класса для хранения клип-арт изображений, то есть, изображений составленных из графических примитивов. Их характерная особенность – однородный фон и небольшая цветовая гамма, например, деловые графики:

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

self.__width = width
self.__height = height

цвет фона:

self.__background = background

и две коллекции: для хранения уникальных цветов (палитра) и для хранения пикселей, чей цвет отличается от цвета фона:

self.__colors = { self.__background}
self.__pixels = {}

Причем, коллекция __pixels – это словарь, в котором ключами будут кортежи (x,y) с координаты точек, а значениями – цвета:

self.__pixels[(x,y)] = color

Палитру будем хранить в виде множества __colors, которое гарантирует нам уникальность значений.

В результате, получаем такой конструктор класса Image:

class Image:
    def __init__(self, width, height, background="_"):
        self.__background = background
        self.__pixels = {}
        self.__width = width
        self.__height = height
        self.__colors = {self.__background}

Далее, создадим два простых свойства в этом классе для получения или изменения высоты и ширины изображения:

@property
def width(self):
    return self.__width
 
@width.setter
def width(self, width):
    self.__width = width
 
@property
def height(self):
    return self.__height
 
@height.setter
def height(self, height):
    self.__height = height

И, затем, переопределим методы __setitem__ и __getitem__, чтобы мы могли обращаться к отдельным пикселам по синтаксису:

img[1,1] = "*"color = img[5,5]

Метод __setitem__ запишем так:

def __setitem__(self, coord, color):
    self.__checkCoord(coord)
 
    if color == self.__background:
        self.__pixels.pop(coord, None)
    else:
        self.__pixels[coord] = color
        self.__colors.add(color)

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

def __checkCoord(self, coord):
    if not isinstance(coord, tuple) or len(coord) != 2:
        raise KeyError("Координаты точки должны быть двумерным кортежем")
 
    if not (0 <= coord[0] < self.__width) or not (0 <= coord[1] < self.__height):
        raise KeyError("Значение координаты выходит за пределы изображения")

который генерирует исключения, если координаты не удовлетворяют нашим условиям.

Если проверка проходит, то для пикселей с цветом, равным цвету фона удаляем их из нашего словаря __pixels, т.к. там должны находиться только пиксели с отличающимися от фона цветами. Удаление делаем с помощью метода pop, в котором первым параметром указываем удаляемый ключ, а вторым – возвращаемое значение, если такой ключ не будет найден. При таком вызове метода pop он не будет генерировать исключение в случае отсутствия ключа, что нам и нужно.

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

По аналогии пропишем метод

def __getitem__(self, coord):
    self.__checkCoord(coord)
    return self.__pixels.get(coord, self.__background)

Он просто берет значение из словаря, если оно там есть. Иначе, будет возвращено значение фона.

Все, в первом приближении наш класс Image готов. Давайте посмотрим на его работу, запишем такие строчки:

img = Image(20, 4)
img[1,1] = "*"; img[2,1] = "*"; img[3,1] = "*"
for y in range(img.height):
    for x in range(img.width):
        print(img[x,y], sep=" ", end="")
    print()

После запуска в консоли видим импровизированное изображение. Кстати, если в качестве координат мы укажем неверный формат, например, так:

img[1,1,1] = "*"

то возникнет исключение KeyError. Давайте вместо этого исключения создадим свое, новое в виде класса CoorError. В Python это делается элементарно. Достаточно унаследовать этот класс от базового класса Exception и мы автоматически получим полный функционал для отслеживания нашего нового исключения:

class CoordError(Exception):
    pass

И, далее в методе __checkCoord() пропишем его:

def __checkCoord(self, coord):
    if not isinstance(coord, tuple) or len(coord) != 2:
        raise CoordError("Координаты точки должны быть двумерным кортежем")
 
    if not (0 <= coord[0] < self.__width) or not (0 <= coord[1] < self.__height):
        raise CoordError("Значение координаты выходит за пределы изображения")

Теперь при запуске программы мы видим свой класс исключения.

Итерабельные классы

Продолжим улучшать функционал нашего класса Image и сделаем его итерабельным, то есть таким, чтобы можно было напрямую перебирать пиксели в циклах for вот так:

for row in img:
    for pixel in row:
        print(pixel, sep=" ", end="")
    print()

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

__iter__(self)
__next__(self)

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

class MyIter:
    def __init__(self, limit):
        self.__num = 0
        self.__limit = limit
 
    def __iter__(self):
        return self
 
    def __next__(self):
        if self.__num >= self.__limit:
            raise StopIteration
        
        self.__num += 1
        return self.__num

И, затем, перебирать с помощью цикла for:

it = MyIter(10)
for i in it:
    print(i)

То есть, цикл for вызывает метод __next__ пока не возникнет исключение StopIteration. Вот общий принцип построения собственных итераторов.

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

for row in img:
    for pixel in row:
        print(pixel, sep=" ", end="")
    print()

То есть, первый цикл перебирал бы строки, а второй – столбцы. Здесь нам понадобятся два итератора для перебора строк и столбцов соответственно.

Значит, когда мы обращаемся просто к объекту img в цикле for:

for row in img

он должен вернуть экземпляр итерабельного объекта для перехода по строкам, то есть, ImageYIterator. Поэтому, в самом классе Image переопределим метод __iter__:

def __iter__(self):
    return ImageYIterator(self)

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

class ImageYIterator:
    def __init__(self, img):
        self.__limit = img.height
        self.__img = img
        self.__y = 0
 
    def __iter__(self):
        return self
 
    def __next__(self):
        if self.__y >= self.__limit:
            raise StopIteration
 
        self.__y += 1
        return ImageXIterator(self.__img, self.__y-1)

Смотрите, мы здесь изменяем приватное свойство __y, указывающее текущую строку, а переопределенный метод __next__ возвращает экземпляр объекта для итераций по столбцам изображения. То есть, переменная row в цикле for будет ссылаться на этот экземпляр класса ImageXIterator. Поэтому вложенный цикл может спокойно его перебирать:

for pixel in row

Класс для второго итерационного объекта выглядит так:

class ImageXIterator:
    def __init__(self, img, y:int):
        self.__limit = img.width
        self.__y = y
        self.__img = img
        self.__x = 0
 
    def __iter__(self):
        return self
 
    def __next__(self):
        if self.__x >= self.__limit:
            raise StopIteration
 
        self.__x += 1
        return self.__img[self.__x-1, self.__y]

Мы здесь уже переходим по координате x, в методе __next__ она увеличивается и возвращается текущее значение пиксела, используя ссылку на класс Image. Поэтому pixel уже принимает конкретное значение цвета точки изображения.

Здесь может возникнуть вопрос: почему бы нам не сделать сам класс Image в виде итерабельного объекта и не создавать дополнительно класс ImageYIterator? Однако, тут есть один нюанс: перебор данных в таких объектах происходит только один раз. И при таком подходе мы могли бы только один раз выводить изображение. При повторной попытке, сразу возникло бы исключение StopIteration и никакого вывода не было бы. Вот для этого и были созданы эти два дополнительных итератора.

Задания для самоподготовки

1. Измените класс Image так, чтобы в нем появился метод resize(width, height). Если новая ширина или высота меньше текущего значения, все цвета, оказавшиеся за пределами новых границ изображения, должны удаляться. Если в качестве нового значения ширины или высоты передается None, соответствующее значение ширины или высоты должно оставаться без изменений.

2. Реализуйте класс ListInt для хранения списка целых чисел и сделайте его итерируемым так, чтобы значения возвращались с конца в начало.

3. Создайте класс Persons для хранения списка уникальных посетителей клуба. Сделайте возможность перебора гостей итератором(ми) следующим образом:

  • с выводом только их имени;
  • с выводом только их возраста;
  • с выводом только их фамилии.