Python для сбора и анализа данных

Объекты и классы

Алла Тамбовцева, НИУ ВШЭ

Введение в классы

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

Класс часто называют проектом объекта определенного типа, то есть шаблоном, который описывает атрибуты (характеристики) объекта и методы, которые можно применять к этому объекту. Например, в Python есть списки, объекты типа list, на которых определены методы .append(), .extend(), .pop() и другие. Это означает, что при разработке языка написали класс, который зафиксировал, что такое список и как с ним работать. Или, что еще более интересно, в Python есть объект NumPy array, массив из библиотеки NumPy, у которого есть набор атрибутов и набор методов. Вспомним, как он выглядит:

In [1]:
import numpy as np
In [2]:
# создаем массив
A = np.array([8, 2, 8])
A
Out[2]:
array([8, 2, 8])

Посмотрим на атрибуты .dtype и .ndim:

In [3]:
A.dtype  # одна характеристика массива
Out[3]:
dtype('int64')
In [4]:
A.ndim  # другая характеристика массива
Out[4]:
1

А теперь применим метод .sort(), который изменит нам исходный массив, отсортировав его по возрастанию (тут полезно вспомнить, что метод – это функция, определенная на конкретном типе, в данном случае – на массиве):

In [5]:
A.sort()  # функция sort(), которая работает на массивах
A
Out[5]:
array([2, 8, 8])

Написание собственного класса

Теперь давайте создадим свой класс. Чтобы пример был запоминающимся и более-менее осязаемым, давайте продолжим волшебную тему с предыдущего занятия и создадим класс Cauldron, который будет описывать котёл. А точнее, этот класс будет определять характеристики котла (атрибуты класса «котёл») и действия, которые мы можем к нему применять (методы класса «котёл»).

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

Создание класса начинается с оператора class, после которого мы указываем его название. После названия мы ставим двоеточие и приступаем к описанию класса. Описание класса всегда начинается с его инициализации – мы должны сообщить Python, что у нас создается новый объект с некоторыми атрибутами.

In [ ]:
class Cauldron:

Для инициализации объекта нам нужно написать специальную функцию __init__ (от initialize, обратите внимание на двойной _ вокруг init) и описать, какие атрибуты у него будут:

In [6]:
# self – указание на сам объект
# атрибуты size и material

class Cauldron:
    def __init__(self, size, material):
        self.size = size
        self.material = material

Что делает (и не делает) код выше? Во-первых, он не создает объект класса Cauldron, а только описывает его. Во-вторых, он сообщает Python, что для создания объекта класса Cauldron пользователь должен обязательно указать размер котла и его материал.

Попробуем создать медный котёл среднего размера:

In [7]:
caul = Cauldron('medium', 'copper')

Посмотрим на него:

In [8]:
caul
Out[8]:
<__main__.Cauldron at 0x108185f50>

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

In [9]:
caul.material
Out[9]:
'copper'

А теперь размер:

In [23]:
caul.size
Out[23]:
'medium'

А теперь изменим наш класс – добавим в него атрибут contents, который будет представлять собой список с содержимым котла. Плюс, допустим, что изначально котёл пустой, то есть по умолчанию в contents сохранен пустой список.

In [10]:
class Cauldron:
    def __init__(self, size, material, contents=[]):
        self.size = size
        self.material = material
        self.contents = contents

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

Создадим обновленный объект caul:

In [13]:
# не задали contents, но Python не ругается

caul = Cauldron('medium', 'copper') 
In [14]:
# потому что по умолчанию там пустой список

caul.contents
Out[14]:
[]

Теперь давайте допишем метод .add(), который будет позволять добавлять ингредиенты в котёл. Несложно догадаться, что внутри этого метода будет использоваться обычный .append(), так как contents – это список:

In [15]:
class Cauldron:
    def __init__(self, size, material, contents=[]):
        self.size = size
        self.material = material
        self.contents = contents
    
    # обновляем contents
    def add(self, x):
        self.contents.append(x)

Создадим новый котёл и добавим туда имбирь и змеиные клыки:

In [16]:
caul = Cauldron('medium', 'copper') 
caul.add('ginger')
caul.add('snake fangs')  # do not write snape fangs, please

Проверим содержимое котла и по совместимости атрибута .contents:

In [17]:
caul.contents
Out[17]:
['ginger', 'snake fangs']

Все добавилось! Теперь напишем метод .engorgio(), который будет изменять размер котла на huge и делать все буквы в ингредиентах в contents заглавными.

In [20]:
class Cauldron:
    def __init__(self, size, material, contents=[]):
        self.size = size
        self.material = material
        self.contents = contents
    
    def add(self, x):
        self.contents.append(x)
        
    def engorgio(self):
        self.size = 'huge'
        self.contents = [s.upper() for s in self.contents]
In [21]:
caul = Cauldron('medium', 'copper') 
caul.add('ginger')
caul.add('snake fangs')
caul.engorgio()
In [22]:
caul.contents # сработало
Out[22]:
['GINGER', 'SNAKE FANGS']
In [23]:
caul.size # тоже сработало
Out[23]:
'huge'

Теперь пойдем еще дальше. Напишем метод .expulso(), который будет «взрывать» котёл, то есть разбивать все элементы в .contents на буквы.

In [24]:
class Cauldron:
    def __init__(self, size, material, contents=[]):
        self.size = size
        self.material = material
        self.contents = contents
    
    def add(self, x):
        self.contents.append(x)
        
    def engorgio(self):
        self.size = 'huge'
        self.contents = [s.upper() for s in self.contents]
        
    def expulso(self):
        self.contents = [list(s) for s in self.contents]

Применим сначала .engorgio(), а потом .expulso():

In [25]:
caul = Cauldron('medium', 'copper', 
                ['dittany', 'ginger']) # сразу с contents
In [27]:
caul.engorgio()
caul.expulso()
In [29]:
caul.contents
Out[29]:
[['D', 'I', 'T', 'T', 'A', 'N', 'Y'], ['G', 'I', 'N', 'G', 'E', 'R']]

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

In [30]:
class Cauldron:
    def __init__(self, size, material, contents=[]):
        self.size = size
        self.material = material
        self.contents = contents
    
    def add(self, x):
        self.contents.append(x)
        
    def engorgio(self):
        self.size = 'huge'
        self.contents = [s.upper() for s in self.contents]
        print("Engorgio!")
        
    def expulso(self):
        self.contents = [list(s) for s in self.contents]
In [31]:
caul = Cauldron('medium', 'copper', 
                ['dittany', 'ginger']) # сразу с contents
caul.engorgio()
Engorgio!
In [ ]:
caul.contents  # изменились, плюс, выше получили еще подтверждение 

Атрибут .contents изменился, но теперь у нас еще одно подтверждение (сообщение Engorgio!), что метод сработал.

Давайте напишем еще метод .reparo(), который будет устранять последствия разрушения – склеивать буквы обратно в слова. Этот метод будет более интересным по сравнению с предыдущими, потому что он должен что-то делать только в случае, если мы раньше применили метод .expulso() и все сломали.

Что это означает технически? Нам нужно проверить, какого типа элементы содержит атрибут .contents. Если строки, то .expulso() мы не применяли, и все в порядке, если списки, то .expulso() был запущен, и необходимо элементы каждого списка внутри contents склеить.

In [32]:
class Cauldron:
    def __init__(self, size, material, contents=[]):
        self.size = size
        self.material = material
        self.contents = contents
    
    def add(self, x):
        self.contents.append(x)
        
    def engorgio(self):
        self.size = 'huge'
        self.contents = [s.upper() for s in self.contents]
        print("Engorgio!")
        
    def expulso(self):
        self.contents = [list(s) for s in self.contents]
        
    def reparo(self):
        if len(self.contents) != 0:
            if type(self.contents[0]) is list:
                self.contents = ["".join(s) for s in self.contents]

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

В коде выше мы дополнительно проверили длину списка в .contents, чтобы в случае применения метода .reparo() к пустому котлу, мы не получили ошибку (если список пустой, то Python не сможет найти в нем элемент с индексом 0).

Применяем и проверяем:

In [33]:
# разбиваем полный котел

caul = Cauldron('medium', 'copper', 
                ['dittany', 'ginger'])
caul.expulso()
In [34]:
# восстанавливаем

caul.reparo()
caul.contents
Out[34]:
['dittany', 'ginger']
In [35]:
# разбиваем пустой котел

caul = Cauldron('medium', 'copper')
caul.expulso()
In [36]:
# восстанавливаем

caul.reparo()
caul.contents  # reparo() ничего не делает, но и ошибки нет
Out[36]:
[]

Теперь давайте добавим в Cauldron() методы другого типа – методы, которые не просто изменяют имеющиеся атрибуты, а добавляют новые или удаляют старые.

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

In [37]:
class Cauldron:
    # изначально среди атрибутов нет color
    def __init__(self, size, material, contents=[]):
        self.size = size
        self.material = material
        self.contents = contents
    
    def add(self, x):
        self.contents.append(x)
        
    def engorgio(self):
        self.size = 'huge'
        self.contents = [s.upper() for s in self.contents]
        print("Engorgio!")
        
    def expulso(self):
        self.contents = [list(s) for s in self.contents]
        
    def reparo(self):
        if len(self.contents) != 0:
            if type(self.contents[0]) is list:
                self.contents = ["".join(s) for s in self.contents]
    
    # а здесь создаем атрибут color и записываем туда color
    def colovaria(self, color):
        self.color = color
    
In [38]:
caul = Cauldron('medium', 'copper', ['dittany', 'ginger'])

Если сейчас напишем caul. и нажмем Tab, среди перечисленных методов и атрибутов color мы не увидим. Если вызовем его явно, получим ошибку:

In [39]:
caul.color
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-39-7bddfc7855c2> in <module>
----> 1 caul.color

AttributeError: 'Cauldron' object has no attribute 'color'

Теперь применим метод .colovaria():

In [40]:
caul.colovaria('red')
caul.color  # появился! 
Out[40]:
'red'

Давайте прекратим эксперименты над котлом и напишем последний метод .evanesco(), который будет не просто присваивать атрибуту .contents значение None, а будет удалять этот атрибут совсем. Если его удалим, то дальше ничего интересного сделать с котлом не сможем – все методы у нас работают с содержимым котла в contents.

In [41]:
class Cauldron:
    
    def __init__(self, size, material, contents=[]):
        self.size = size
        self.material = material
        self.contents = contents
    
    def add(self, x):
        self.contents.append(x)
        
    def engorgio(self):
        self.size = 'huge'
        self.contents = [s.upper() for s in self.contents]
        print("Engorgio!")
        
    def expulso(self):
        self.contents = [list(s) for s in self.contents]
        
    def reparo(self):
        if len(self.contents) != 0:
            if type(self.contents[0]) is list:
                self.contents = ["".join(s) for s in self.contents]
    def colovaria(self, color):
        self.color = color
    
    def evanesco(self):
        del self.contents

Проверяем:

In [42]:
caul = Cauldron('medium', 'copper', ['dittany', 'ginger'])
caul.evanesco()
In [43]:
caul.contents  # нет contents
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-43-71c0e9761fc7> in <module>
----> 1 caul.contents  # нет contents

AttributeError: 'Cauldron' object has no attribute 'contents'
In [44]:
caul.expulso() # разбивать нечего
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-44-b2e7ade75814> in <module>
----> 1 caul.expulso() # разбивать нечего

<ipython-input-41-48da0ff0e812> in expulso(self)
     15 
     16     def expulso(self):
---> 17         self.contents = [list(s) for s in self.contents]
     18 
     19     def reparo(self):

AttributeError: 'Cauldron' object has no attribute 'contents'

Наследование в классах

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

Наследование, кстати, объясняет, почему у объектов типа pandas DataFrame, pandas Series и NumPy array есть одинаковые методы: датафрейм pandas наследует некоторые методы от Series, а Series, в свою очередь, наследует часть атрибутов и методов у array.

Давайте создадим класс Cauldron_Pers, который будет описывать не просто котёл, а чей-то личный котёл. Это класс унаследует у Cauldron все методы и атрибуты, а потом к ним мы добавим новые, например, имя владельца котла и его оценку за зелье.

In [45]:
class Cauldron_Pers(Cauldron):
    def __init__(self, size, material, contents=[]):
        super().__init__(size, material, contents)

Чтобы Python понимал, от какого класса мы наследуем атрибуты и методы, мы поместили название класса-родителя Cauldron (parent class, от которого наследуем) в скобки после названия дочернего класса Cauldron_Pers (child class). После этого мы записали функцию __init__ для инициализации класса, а потом воспользовались функцией super(). Эта функция наследует все атрибуты и методы класса-родителя. Посмотрим, что получилось:

In [46]:
my = Cauldron_Pers('medium', 'cooper')
my.size # унаследовалось
Out[46]:
'medium'

Теперь добавим новые атрибуты, как и задумывали. Имя владельца owner будет у нас обязательным аргументом, а для оценки mark выставим значение по умолчанию, равное None.

In [47]:
class Cauldron_Pers(Cauldron):
    def __init__(self, size, material, owner, contents=[], mark=None):
        super().__init__(size, material, contents)
        self.owner = owner
        self.mark = mark
In [49]:
my = Cauldron_Pers('standard', 'copper', 'Alla')
In [50]:
my.owner  # работает
Out[50]:
'Alla'
In [51]:
my.mark  # ничего, так как None
In [55]:
# если прогоним ячейку два раза, каждый ингредиент
# добавится дважды

my.add('dittany')
my.add('lavender')
In [56]:
my.expulso()  # даже взрывается
In [57]:
my.contents
Out[57]:
[['d', 'i', 't', 't', 'a', 'n', 'y'],
 ['l', 'a', 'v', 'e', 'n', 'd', 'e', 'r'],
 ['d', 'i', 't', 't', 'a', 'n', 'y'],
 ['l', 'a', 'v', 'e', 'n', 'd', 'e', 'r']]

Все методы из Cauldron работают. Добавим какой-нибудь характерный только для личного котла метод и закончим. Пусть это будет метод .set_mark() для выставления оценки.

In [58]:
class Cauldron_Pers(Cauldron):
    def __init__(self, size, material, owner, contents=[], mark=None):
        super().__init__(size, material, contents)
        self.owner = owner
        self.mark = mark
        
    def set_mark(self, m):
        self.mark = m
In [59]:
my = Cauldron_Pers('standard', 'copper', 'Alla')

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

In [60]:
my.set_mark(0)
In [61]:
my.mark
Out[61]:
0

Важно: если мы создадим метод с таким же названием, что и унаследованный, то новый метод заменит старый. Например, если бы мы определили в Cauldron_Pers метод .expulso(), который бы записывал в contents значение None, этот метод бы вытеснил унаследованный .expulso(), разбивающий названия ингредиентов на буквы:

In [62]:
class Cauldron_Pers(Cauldron):
    def __init__(self, size, material, owner, contents=[], mark=None):
        super().__init__(size, material, contents)
        self.owner = owner
        self.mark = mark
        
    def set_mark(self, m):
        self.mark = m
        
    def expulso(self):
        self.contents = None
In [65]:
my = Cauldron_Pers('standard', 'copper', 'Alla')
my.add('fangs')
my.expulso()
print(my.contents)  # ничего - None
None

Документация к классам и методам

Документация для классов пишется так же, как и документация к функциям, с помощью так называемой docstring:

In [66]:
class Cauldron_Pers(Cauldron):
    
    def __init__(self, size, material, owner, contents=[], mark=None):
        """
        Creates a personal cauldron with 
        the following attributes:
        size – a string;
        material – a string;
        owner – a string;
        contents – a list (contents = [] by default);
        mark – an integer (mark = None by default).
        """
        super().__init__(size, material, contents)
        self.owner = owner
        self.mark = mark
        
    def set_mark(self, m):
        """
        Parameters: m is an integer
        Changes attribute mark to m.
        """
        self.mark = m
        
    def expulso(self):
        self.contents = None

Обратите внимание, общее описание внутри __init__.

Запросим help по созданному нами классу и по методам внутри него.

In [71]:
help(Cauldron_Pers)
Help on class Cauldron_Pers in module __main__:

class Cauldron_Pers(Cauldron)
 |  Cauldron_Pers(size, material, owner, contents=[], mark=None)
 |  
 |  Creates a personal cauldron with 
 |  the following attributes:
 |  size – a string;
 |  material – a string;
 |  owner – a string;
 |  contents – a list (contents = [] by default);
 |  mark – an integer (mark = None by default).
 |  
 |  Method resolution order:
 |      Cauldron_Pers
 |      Cauldron
 |      builtins.object
 |  
 |  Methods defined here:
 |  
 |  __init__(self, size, material, owner, contents=[], mark=None)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |  
 |  expulso(self)
 |  
 |  set_mark(self, m)
 |      Parameters: m is an integer.
 |      Changes attribute mark to m.
 |  
 |  ----------------------------------------------------------------------
 |  Methods inherited from Cauldron:
 |  
 |  add(self, x)
 |  
 |  colovaria(self, color)
 |  
 |  engorgio(self)
 |  
 |  evanesco(self)
 |  
 |  reparo(self)
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from Cauldron:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)

В документации есть наше описание, перечень методов, перечень методов, которые унаследованы у другого класса.

При этом мы можем создать объект класса Cauldron_Pers и запросить помощь по отдельному методу:

In [72]:
my = Cauldron_Pers('standard', 'copper', 'Alla')
help(my.set_mark)
Help on method set_mark in module __main__:

set_mark(m) method of __main__.Cauldron_Pers instance
    Parameters: m is an integer.
    Changes attribute mark to m.