Kodomo

Пользователь

Классы и наследование

Что такое self?

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

Всё становится очень просто, если сравнить, как одну и ту же роль может выполнять обычная функция (которая находится вне класса) и метод.

Давайте, в предположении, что у нас есть всё тот же класс Ball, и у объектов этого класса есть атрибуты x, y, radius, попробуем написать функцию, которая считает площадь этого кружка:

   1 ...
   2 import math
   3 
   4 ...
   5 
   6 class Ball(object):
   7     ...
   8 
   9 def area(ball):
  10     return math.pi * ball.radius ** 2 / 2
  11 
  12 ...
  13  
  14 if __name__ == "__main__":
  15     ...
  16 
  17     my_ball = Ball(...)
  18     print "Surface area of my ball is", area(my_ball)

Здесь всё просто и понятно. Функция area – это обычная питонская функция.

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

   1 ...
   2 import math
   3 
   4 ...
   5 
   6 class Ball(object):
   7     ...
   8 
   9 def area(self):
  10     return math.pi * self.radius ** 2 / 2
  11 
  12 ...
  13  
  14 if __name__ == "__main__":
  15     ...
  16 
  17     my_ball = Ball(...)
  18     print "Surface area of my ball is", area(my_ball)

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

Код выше ничем не отличается от первого примера и работает дословно так же.

Теперь сделаем одно маленькое изменение: увеличим отступ перед определением функции area (ну и, может, подвинем её на другие строки – смотря что у нас в коде): тогда area окажется методом класса Ball. В тексте самой функции area нам не требуется менять ничего, но так как вне класса Ball функции area у нас не стало, то вызывать мы её теперь можем только как метод объекта класса Ball. И вот этот объект, метод которого мы вызовем, и попадёт в self:

   1 ...
   2 import math
   3 
   4 ...
   5 
   6 class Ball(object):
   7     ...
   8 
   9     def area(self):
  10         return math.pi * self.radius ** 2 / 2
  11 
  12 ...
  13  
  14 if __name__ == "__main__":
  15     ...
  16 
  17     my_ball = Ball(...)
  18     print "Surface area of my ball is", my_ball.area()

Для обобщения: пусть у нас в классе есть метод f1 с аргументами self, a, b, c и вне класса есть функция f2 с аргументами self, a, b, c, текст f1 и f2 дословно совпадает; и пусть у нас есть объект x класса X:

   1 class X(object):
   2    ...
   3    def f1(self, a, b, c):
   4       ...
   5 
   6 def f2(self, a, b, c):
   7    ...
   8 
   9 x = X()

Тогда верно, что x.f1(1, 2, 3) делает то же самое, что и f2(x, 1, 2, 3).

То есть делать функции методами внутри класса не обязательно. Это такой способ обозначить, что эта функция нужна для этого класса (и с нашей, человеческой, точки зрения с ним как-то связана) и только объект этого класса можно давать ей в качестве первого аргумента. И, как следствие, мы её при этом чуть-чуть по-другому вызываем: не f(a, b, c), а a.f(b, c).

Технически больше никаких хитростей про self нет.

С точки зрения написания методов мыслить об этом нужно так: area of a ball is the ball's radius multiplied by pi divided by 2. self – это и есть тот самый the ball, тот шарик, с которым мы сейчас что-то хотим сделать.

Один приём программирования, для которого полезны классы: "полиморфизм"

А раз метод можно без потери чего-либо делать функцией вне класса, то в чём вообще польза от методов? Ну не в другом же способе записи?

Предположим, у нас такая задачка: есть два вида фигур – квадратики и кружочки, и при тыке в квадратик мы хотим, чтобы он перекрасился, а при тыке в кружок мы хотим, чтобы он перепрыгнул на другое место. Один из способов решить эту задачу такой: давайте сделаем класс Square и класс Ball:

   1 ...
   2 
   3 class Square(object):
   4     def __init__(self, x, y):
   5         ...
   6         coords = ...
   7         canvas.create_rectangle(coords)
   8 
   9     def change_color(self):
  10         ...
  11 
  12     def contains(self, x, y):
  13         ...
  14 
  15     def on_click(self):
  16         self.change_color()
  17 
  18 
  19 class Ball(object):
  20     def __init__(self, x, y):
  21         ...
  22         coords = ...
  23         canvas.create_oval(coords)
  24 
  25     def random_jump(self):
  26         ...
  27 
  28     def contains(self, x, y):
  29         ...
  30 
  31     def on_click(self):
  32         self.random_jump()

Здесь всё так же, как и в примерах с прошлого раза и нет абсолютно ничего нового. (Ну, кроме того, что мы написали больше строчек кода).

Нам никто не мешает объекты этих двух классов вперемешку покидать в один список:

   1 shapes = []
   2 
   3 ...
   4 
   5 def right_click(event):
   6     if random.choice(('ball', 'square')) == 'ball':
   7        shapes.append(Ball(event.x, event.y))
   8     else:
   9        shapes.append(Square(event.x, event.y))
  10 
  11 ...
  12 
  13 if __name__ == "__main__":
  14     ...
  15     canvas.bind("<3>", right_click)
  16     ...

Снова, никакой магии, ничего нового.

Упражнение (не обязательное, не стоит на нём зависать на долго). Помните, что функцию можно положить в переменную или передать другой функции, что функция – тоже объект? Можете, вспомнив это, сократить определение right_click до двух строк?

Итак, теперь у нас есть список shapes, про содержимое которого мы знаем, что в нём лежат объекты класса Ball и объекты класса Square.

Что будет, если мы возьмём какой-нибудь объект из этого списка и вызовем у него метод contains? Случится вот что: если это был объект класса Ball, то вызовется метод contains, определённый внутри класса Ball, а если это был объект класса Square, то вызовется метод contains, определённый внутри класса Square.

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

В общем-то, здесь нет никакой магии. Как и раньше было со строками или словарями: объект сам знает, какие методы у него есть, и только их мы и можем вызывать.

Та же самая история с on_click: этот метод есть у объектов обоих классов, поэтому вызовется метод того класса, которому принадлежит объект. Для Ball этот метод вызовет дальше random_jump, а для Square он вызовет change_color – то, как мы и написали, то, как требовалось по задаче.

В нашем примере, если мы попытаемся у первого попавшегося объекта из списка вызвать метод random_jump, то, если нам попался объект класса Ball, всё будет хорошо, и он перепрыгнет, а если нам попался объект класса Square, то питон нам скажет, что нету у этого объекта такого метода. (Дословно он скажет AttributeError, потому, что как питон не различает переменные с данными и функции, так же питон не различает атрибуты и методы. Питон очень глупый и делает только то, что ему сказали, и делает это очень примитивно и предсказуемо – и этим он прекрасен!).

Итак, мы можем написать реакцию на нажатие левой кнопки мыши так, как от нас требовалось в задаче:

   1 ...
   2 
   3 def left_click(event):
   4     for shape in shapes:
   5         if shape.contains(event.x, event.y):
   6             shape.on_click()
   7 
   8 ...
   9 
  10 if __name__ == "__main__":
  11     ...
  12     canvas.bind("<1>", left_click)
  13     ...

Конечно, мы могли бы добиться того же эффекта с помощью кучи ifов: сохранить в объекте название его формы, при рисовании if'ом проверять форму и выбирать create_rectangle или create_oval, внутри contains проверять if'ом, какой формы объект, и в зависимости от этого считать принадлежность координат фигуре разными формулами, внутри on_click проверять if'ом, какая у нас фигура, и выбирать либо random_jump, либо change_color. Это можно сделать вручную.

По существу, почти ровно это делает питон за нас, если мы пользуемся полиморфизмом.

У полиморфизма есть полезное отличие: когда мы пользуемся им, мы можем добавить новый тип фигур запросто: нужно только создать для них класс, обеспечить, чтобы в этом классе были методы contains и on_click и положить объектов этого класса в наш список shapes. Больше классы для разных фигур друг о друге знать ничего не должны, их могут делать разные люди, разными способами в разных местах. Без полиморфизма нам вдобавок нужно было бы в каждую проверку типа фигур добавить ещё одно правило (и нигде не забыть это сделать: а ведь такие проверки могли бы оказаться и вне нашего класса, и вообще в другом модуле, который пользуется нашим, и о котором мы знать не знаем!)

Атрибуты класса, атрибуты объекта, как оно работает и зачем оно нужно?

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

   1 ...
   2 import random
   3 ...
   4 
   5 class Ball(object):
   6     colors = ["red", "orange", "yellow", "green", "blue", "purple"]
   7 
   8     def __init__(self, x, y):
   9         ...
  10         self.color = random.choice(self.colors)
  11         canvas.create_oval(..., fill=self.color)
  12     ...
  13 ...

Полезно понимать, как это работает.

Первая часть несколько неожиданная: Ball – это переменная, в которой лежит объект, описывающий наш класс. (На самом деле, это не так уж и неожиданно: во-первых, в питоне есть только два ключевых игрока – переменные и объекты, и класс никак не может быть переменной, значит это объект; во-вторых, нас уже не пугает то, что функция – это такой объект, который мы можем переложить из одной переменной в другую; а класс ведёт себя и как функция тоже)

Соответственно, всё, что есть именно внутри самого класса Ball, доступно нам как просто его атрибуты, в нашем примере: Ball.colors, Ball.__init__. (С методами одна тонкость: при таком способе обращения мы должны первый аргумет – self – передавать явным образом. Оно и понятно, больше ему взяться неоткуда).

Вторая часть проще. Если вы вспомните, как себя ведут локальные и глобальные переменные, здесь для вас не будет нового. Напомню:

Аналогично с атрибутами и методами. Когда мы пишем a.b, то в зависимости от смысла действия получается:

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

   1 class A(object):
   2     x = 1
   3     y = []
   4 
   5 a = A()
   6 b = A()
   7 a.x += 2
   8 print a.x, b.x
   9 a.y.append(3)
  10 print a.x, b.x

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

Как избежать дублирования кода, если вам нужно сделать несколько похожих классов: наследование

Теперь мы можем вернуться к нашему примеру с кружочками и квадратиками. Если мы основательно возьмёмся делать эту программу, мы обнаружим, что очень много строк кода нам приходится тупо копировать – а ведь копирование кода со временем неизбежно приводит к глюкам, странному поведению и общему ощущению "что за каша, эта программа уже слишком большая, чтобы в ней что-нибудь понимать".

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

Для этого создано понятие наследования классов. Когда мы создаём любой класс в питоне, мы говорим питону, что этот класс является потомком другого класса – соответственно, этот другой класс станет для нашего класса родительским. До сих пор мы использовали в качестве родительского класса класс object, который создан только для того, чтобы быть всеобщим предком.

Если мы самостоятельно делаем класс A, и создаём от него потомков, то класс A и есть то место, куда мы можем сложить всё общее, что есть у потомков.

В нашем примере с квадратиками и кружочками мы могли бы написать:

   1 ...
   2 
   3 class Shape(object):
   4     def coords(self, x, y, radius):
   5       self.x = x
   6       self.y = y
   7       self.radius = radius
   8       return (x - radius, y - radius, x + radius, y + radius)
   9 
  10 class Square(Shape):
  11     def __init__(self, x, y, side):
  12       coords = self.coords(x, y, side)
  13       canvas.create_rectangle(coords)
  14     
  15     ...
  16 
  17 class Ball(Shape):
  18     def __init__(self, x, y):
  19       coords = self.coords(x, y, 50)
  20       canvas.create_oval(coords, fill="white", width=2)
  21 
  22     ...
  23 ...

С технической стороны наследование устроено как продолжение правила для поиска атрибутов. Предположим, у нас есть объект x, его класс Ball, его родитель класс Shape. Тогда если мы говорим ball.abc, то:

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

Два слова о специальных атрибутах

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

На него мы можем смотреть, если вдруг засомневались, какой объект у нас в руках:

   1 print x.__class__

Ещё благодаря нему мы можем писать конструкции вида:

self.__class__.attr = value – и это будет значить, что какая бы у нас длинная цепочка наследований ни была, мы хотим положить value в атрибут attr того класса (самого частного), которому принадлежит self.

Если вы настроены изучить и поднаготную питона тоже, то сейчас самое время пойти почитать http://docs.python.org/2/reference/datamodel.html – или скорее даже пробежаться по этой странице по диагонали и понять, что там есть, и что из этого вам понятно.

Будут ли нам нужны классы в жизни?

Короткий ответ: вероятно, да, хотя, может и не скоро. Но он очень неполный.

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

Зачем же они тогда?

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

Во-вторых (и это следствие во-первых), очень многие библиотеки полезных для питона штуковин (буде то Tkinter с графическими интерфейсами, nltk с лингвистическими инструментами, или pymorphy) предоставляют свои возможности в виде классов. Зная, что такое классы, вам безусловно будет проще читать их документацию. И, может быть, теперь вас не испугает документация, начинающаяся со слов "у нас вооооот-такая иерархия классов!" (обычно с таких слов начинается документация проектов, авторы которых поленились сделать хорошую документацию).

В-третьих, существует некоторый набор ситуаций, когда создать класс – это самое простое и естественное. Они характерны тем, что когда вы ещё даже не думали садиться программировать, вы уже говорите о задаче словами "у нас есть такой-то объект, и мы с ним делаем то-то, а ещё у нас есть сякой-то объект, и они так-то взаимодействуют". Или когда у нас программа моделирует в себе состояние чего-то, что можно пощупать руками. С примером почти такой ситуации мы, собственно, всё время и работали, когда рисовали кржочки и квадратики. Объектно-ориентированное программирование возникало из задач физики, им нужно было физические объекты с физическими свойствами отображать в программе. И это самый типичный пример. Впрочем, "лингвистические объекты" вроде токенов, предложений, текстов в корпусе, тоже вполне можно считать сущностью, которую можно пощупать, и для них описывать классы может оказаться удобным.

Ну и в-четвёртых, классы нам будут жизненно важны, когда мы будем учиться работать со сложными структурами данных: графы, деревья. Без классов говорить о них было бы в разы тяжелее. Умный читатель заметит, что вокруг этих самых структур данных вертится значительная часть лингвистики: синтаксис, всяческие иерархии, онтологии и тезаурусы, социальные сети...

Так что если сейчас вы научитесь только читать про классы, но не выделять их в своей задаче, то вы уже достигли той цели, к которой я вас подталкивал.

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

   1 class Token(object):
   2 
   3     punctuation = ...
   4 
   5     def __init__(self, word):
   6         self.raw_word = word
   7         stripped_word = word.strip(self.punctuation)
   8         self.determine_case(stripped_word)
   9         self.token = stripped_word.lower()
  10 
  11     def determine_case(self, word):
  12         if word.islower():
  13             self.case = "lower"
  14         elif word.istitle():
  15             self.case = "title"
  16         elif word.isupper():
  17             self.case = "upper"
  18         else:
  19             self.case = "mixed"
  20 
  21     def type(self):
  22         if self.token.isalpha():
  23            return "word"
  24         ...

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