Практика: Pygame, шарики и ООП



Общая информация

При процедурном программировании программа разбивается на части в соответствии с алгоритмом: каждая функция является составной частью алгоритма. При объектно-ориентированном программировании программа строится как совокупность взаимодействующих объектов. Что же такое объект? С точки зрения объектно-ориентированного подхода, объект - это нечто, обладающее состоянием и поведением. По сути, состояние - это данные, соответствующие объекту, например рост, вес и громкость гавканья собаки. Поведением называется набор операций, которые можно производить с объектом. Например, в случае с собакой: покормить, погладить, погулять. Эти операции, которые можно выполнять над объектом обычно называют методами (по крайней мере в контексте языка Python).

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

В языке Python для определения класса используется оператор class. Рассмотрим следующий пример:

class Dog:
    def say_gaw(self): # имя self для первого аргумента метода это общепринятое но не обязательное правило
        print('Gaw-gaw')

my_dog = Dog()
another_dog = Dog()
my_dog.say_gaw()      # вызовется функция Dog.say_gaw с параметром self = my_dog
another_dog.say_gaw()

Здесь мы описали класс Dog, который задает один метод. При описании методов класса первый аргумент есть ссылка на экземепляр, для которого этот метод вызывается. Далее, мы создали пару собак и позвали для каждой метод say_gaw. Для создания объектов используется имя класса со скобками. Методы вызываются через точку после имени объекта. Заметьте, что первый аргумент метода - self - при вызове указывать не нужно, т.к. им становится сам объект (тот для которого зовем метод, его имя перед точкой).

Для хранения данных в объектах используются атрибуты. Это те самые "свойства" объекта - рост, вес и т.п. Атрибуты могут иметь любой тип данных. Так же как и с обычными переменными в Python, объявлять атрибуты неким специальным образом не нужно, они появляются автоматически, при первом присваивании, следующим образом:

class Dog:
    def say_gaw(self):
        if self.angry:
            print('GAW-GAW')
        else:
            print('Gaw-gaw')

    def ping(self):
        self.angry = True

    def feed(self, food_count):
        if food_count > 10:
            self.angry = False

my_dog = Dog()
my_dog.feed(20)
my_dog.say_gaw()      # напечатает Gaw-gaw
my_dog.ping()
my_dog.say_gaw()      # напечатает GAW-GAW

Часто для атрибутов хочется иметь некоторое начальное значение. В предыдущем примере есть проблема - если собака попытается гавкнуть до того как ее пнули или покормили, она навернется с ошибкой "AttributeError: 'Dog' object has no attribute 'angry'". Для решения этой проблемы используется метод со специальным именем - __init__, который вызывается автоматически при создании объекта:

class Dog:
    def __init__(self):
        self.angry = False

    def say_gaw(self):
        if self.angry:
            print('GAW-GAW')
        else:
            print('Gaw-gaw')

my_dog = Dog()
my_dog.say_gaw()      # ошибки нет, напечатает Gaw-gaw

Метод __init__ называется конструктором. Собственно, конструктор зовется при выполнении конструкции вида ИмяКласса(), в нашем случае - Dog(). Аргументом self для конструктора становится вновь созданный объект. Конструктор, также как и обычные методы, может иметь дополнительные аргументы кроме self. Эти аргументы передаются при создании объекта, следующим образом:

class Dog:
    def __init__(self, angry, count):
        self.angry = angry
        self.count = count

    def say_gaw(self):
        if self.angry:
            print('GAW-' * self.count)
        else:
            print('gaw-' * self.count)

my_dog = Dog(True, 3)
my_dog.say_gaw()      # ошибки нет, напечатает Gaw-gaw

Класс в Python также является объектом. Объект этот создается с помощью ключевого слова class, как в примерах выше. Таким образом, в предыдущем примере вызов my_dog.say_gaw() эквивалентен вызову Dog.say_gaw(my_dog). Разобраться, какой объект какому классу принадлежит помогут встроенные функции type и isinstance:

>>> class A:
...     pass
...
>>> a = A()
>>> type(a)
<class '__main__.A'>
>>> type(A)
<class 'type'>
>>> type(type)
<class 'type'>
>>> type(1)
<class 'int'>
>>> type(int)
<class 'type'>
>>>
>>> isinstance(1, int)
True
>>> isinstance(1, A)
False
>>> isinstance(a, A)
True
>>> isinstance(type, type)
True
>>> isinstance(A, type)
True

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

Нужно заметить также, что методы, которые класс определяет, не будут методами для него (как для объекта), а будут просто атрибутами типа function. Действительно, при вызове Dog.say_gaw(my_dog) никакой дополнительный self уже не передается, функция запускается в том виде, в котором мы ее написали. Это также можно показать следующим образом:

>>> class A:
...     def f(self):
...         print('hello')
...
>>>
>>> type(A.f)
<class 'function'>
>>> a = A()
>>> type(a.f)
<class 'method'>

То есть, A.f - это функция, а a.f - метод. Метод здесь это объект, который содержит в себе ссылку на объект, за которым этот метод закреплен (в нашем случае это объект a) и ссылку на функцию, которую надо вызывать. Соответственно при вызове метод зовет эту функцию, передавая ссылку на свой объект как первый аргумент и прокидывая остальные аргументы.

>>> m = a.f
>>> m is A.f
False
>>> m.__func__ is A.f
True
>>> m.__self__ is a
True
>>> m.__func__(m.__self__)
hello
>>> m()
hello
>>> a.f()
hello
>>> A.f(a)
hello

Стандартные методы

Кроме __init__ есть и другие стандартные методы, которые можно определить в описании класса.

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

class Dog
    def __str__(self):
        return self.name + ' ' + str(self.score)

Метод __str__ будет вызываться, когда вызывается функция str от данного объекта, например, str(Vasya). То есть создавая метод __str__ вы даете указание Питону, как преобразовывать данный объект к типу str.

Поскольку функция print использует именно функцию str для вывода объекта на экран, то определение метода __str__ позволит выводить объекты на экран удобным способом: при помощи print.

Переопределение стандартных операций

Реализуем класс Vector, используемый для представления радиус-векторов на координатной плоскости, и определим в нем поля-координаты: x и y. Также очень хотелось бы определить для векторов операцию +, чтобы их можно было складывать столь же удобно, как и числа или строки. Например, чтобы можно было записать так:

a = Vector(1, 2)
b = Vector(3, 4)
c = a + b

Для этого необходимо перегрузить операцию +: определить функцию, которая будет использоваться, если операция + будет вызвана для объекта класса Vector. Для этого нужно определить метод __add__ класса Vector, у которого два параметра: неявная ссылка self на экземпляр класса, для которого она будет вызвана (это левый операнд операции +) и явная ссылка other на правый операнд:

class Vector:
    def __init__(self, x = 0, y = 0):
        self.x = x
        self.y = y
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

Теперь при вызове оператора a + b Питон вызовет метод a.__add__(b), то есть вызовет указанный метод, где self = a, other = b.

Аналогично можно определить и оставшиеся операции. Полезной для переопределения является операция <. Она должна возвращать логическое значение True, если левый операнд меньше правого или False в противном случае (также в том случае, если объекты равны). Для переопределения этого операнда нужно определить метод __lt__ (less than):

class Vector:
    def __lt__(self, other):
        return self.x < other.x or self.x == other.x and self.y < other.y

В этом примере оператор вернет True, если у левого операнда поле x меньше, чем у правого операнда, а также если поля x у них равны, а поле y меньше у левого операнда.

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

Упражнения - класс Vector

Упражнение №1

Создайте класс Vector с полями x и y, определите для него конструктор, метод __str__, необходимые арифметические операции:

  • сложение (__add__)
  • вычитание (__sub__)
  • умножение на число справа (__mul__) и слева (__rmul__)
  • отрицание (унарный минус __neg__)

Упражнение №2

Используя класс Vector выведите координаты центра масс данного множества точек.

Упражнение №3

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

Что понадобится для этого (подсказки):

  • класс Ball, с методами update(self, dt) (для обновления координат), render(self, canvas) (для рисования, canvas - это наш объект screen из Pygame).
  • кончно, все координаты и скорости представим объектами класса Vector
  • в классе Vector нам пригодится дополнительный метод intpair(self), который вернет координаты округленные и в виде кортежа (т.е. в виде, в котором удобно скормить их Pygame-у)

Упражнение №4

При клике мышью по шарику меняем цвет этого шарика.

Упражнение №5

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

Упражнение №6

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

\begin{equation*} \mathbf{\Delta p_1} = ((\frac{-2m_2}{m_1+m_2}\mathbf{p_1} + \frac{2m_1}{m_1+m_2}\mathbf{p_2}) * \mathbf{n}) * \mathbf{n} \end{equation*}

где:

\(p_1\) - импульс нашего шара

\(m_1\) - масса нашего шара

\(p_2\) - импульс второго шара

\(m_2\) - масса второго шара

\(n\) - единичный вектор нормали контакта (в нашем случае, вектор направленный от центра одного шара к центру другого), первая звездочка - скалярное умножение на \(n\), вторая - умножение вектора \(n\) на число.