Пользовательские классы
Contents
Пользовательские классы#
Объявление класса#
Сравним базовый синтаксис объявления классов в C/C++
и в python
. Пока оставим наследование на потом, но заметим, что пользовательские классы наследуют от самого общего класса object
, если не указанно иначе. Так или иначе, любой тип является производным от object
.
C++
Объявление класса в C++
выглядит примерно следующим образом.
class MyClass
{
// все
// детали
// класса
// т.е. тело класса
};
объявление класса начинается с ключевого слова
class
;за ним следует имя класса (
MyClass
в нашем случае);далее следует тело класса которое помещается в фигурные скобки
{}
;завершается объявление класса точкой с запятой “
;
”.
python
В python
класс объявляется очень похоже, если вспомнить разницу в объявлении функций в C++
и python
.
class MyClass: # заголовок класса
# все
# детали
# класса
# т.е. тело класса
объявление класса начинается с ключевого слова
class
;за ним следует имя класса (
MyClass
в нашем случае) и символ двоеточия;далее следует тело класса (состоящее хотя бы из одной инструкции), которое обозначается постоянным отступом в право;
завершается объявление класса тогда, когда отступ возвращается к прежнему уровню;
опционально, тело класса может начинаться с документирующей строки.
Note
В отличие от имен переменных, которые принято задавать в стиле snake_case, имена классов принято давать в стиле CamelCase.
Инструкции внутри тела класса выполняются сразу после того, как поток программы доберется до объявления класса и интерпретатор прочитает заголовок класса. В результате создаётся объект объявления класса (не экземпляр класса), который имеет тип type
, т.е. этот объект задает новый тип данных. Этот объект будет использоваться в качестве фабрики для создания экземпляров (смотри Создание экземпляра).
Этого знания уже хватает, чтобы объявить примитивный класс с пустым телом. Объявим класс EmptyClass
с только документирующей строкой и убедимся, что создался объект объявления этого класса и связался с именем EmptyClass
.
class EmptyClass:
"""Пустой класс без атрибутов и методов."""
print(EmptyClass)
print(type(EmptyClass))
print(EmptyClass.__doc__)
print(type(int))
<class '__main__.EmptyClass'>
<class 'type'>
Пустой класс без атрибутов и методов.
<class 'type'>
Важно понимать, что все инструкции внутри тела класса выполняются в тот момент, когда интерпретатор читает объявление класса, а не когда создаются экземпляры этого класса. В примере ниже объявляется класс, но не создаётся ни один его экземпляр. Инструкция print
все равно выполняется.
print(f"До создания класса MyClass")
class MyClass:
print(f"Создаётся класс MyClass")
print(f"Класс {MyClass.__name__} создан")
До создания класса MyClass
Создаётся класс MyClass
Класс MyClass создан
Note
Объект объявления класса создаётся после того, как завершится исполнение всех инструкций в теле класса. Т.е. если попытаться в примере выше изменить инструкцию в теле класса на print(f"Создаётся класс {MyClass.__name__}")
, то возникнет ошибка NameError, т.к. объект объявления класса ещё не создан и имя MyClass
ни с чем не связанно.
Создание экземпляра#
Несмотря на то, что EmptyClass
не содержит описания того, какими должны быть его экземпляры (кроме документирующей строки), уже можно создавать экземпляры. Чтобы создать экземпляр класса, необходимо вызвать имя класса, как если бы оно было функцией. В данном случае при вызове имени класса передавать аргументов не надо, но в более содержательных случаях это может потребоваться (смотри Инициализация объекта).
e = EmptyClass()
isinstance(e, EmptyClass)
True
Итак, инструкция EmptyClass()
создаётся экземпляр класса EmptyClass
, который связывается с именем e
.
Команда isinstance(e, EmptyClass)
демонстрирует, что созданный объект — экземпляр искомого класса. Это возможно сделать в runtime, т.к. несмотря на то, что тело класса EmptyClass
по сути дела пустое, у созданного объекта всегда создаётся специальный атрибут __class__, который ссылается на класс, к которому относится экземпляр.
e.__class__
__main__.EmptyClass
__class__
— не единственный такой атрибут. Изучим как создавать атрибуты самостоятельно.
Note
Атрибуты и методы, имена которых начинаются и завершается двумя нижними подчеркиваниями, считаются специальными. Часто их называются магическими. Они автоматически генерируются python
для внутренних целей. За исключением ряда ситуаций, не принято давать имена в таком стиле своим атрибутам. Хотя их переопределение в производном классе — очень распространенная практика.
Атрибуты класса и атрибуты объекта#
Если при объявлении аттрибутов в python
действовать по аналогии с С++
, то сразу возникнут трудности. Рассмотрим объявление класса C++
с одним атрибутом.
class A
{
public:
int x;
};
Здесь объявляется атрибут x
целочисленного типа, который инициализируется при создании объекта. Но в python
нельзя объявить переменную и не инициализировать её: каждое имя должно ссылаться на какой-то объект. Попробуем построить аналогичное объявление, инициализировав атрибут числом 0
.
class A:
x = 0
Теперь вспомним, что инструкции в теле класса выполняются не в момент создания экземпляра, а в момент чтения объявления класса интерпретатором. Т.е. атрибут x
должен уже существовать, несмотря на то, что не было создано ни одного экземпляра. Чтобы убедиться в этом, проверим наличие атрибута x
у объекта объявления класса A
, используя точечную нотацию.
print(A.x)
0
Итак, такое объявление создаёт атрибут самого класса (атрибут объекта объявления класса). В терминах C++
это статический атрибут. Иными словами, этот атрибут разделяется всеми экземплярами этого класса.
Чтобы продемонстрировать это, создадим два экземпляра этого класса.
a1 = A()
a2 = A()
print("До изменения атрибута")
print(A.x, a1.x, a2.x)
A.x = 1
print("После изменения атрибута")
print(A.x, a1.x, a2.x)
До изменения атрибута
0 0 0
После изменения атрибута
1 1 1
Изменение значения атрибута класса привело к его изменению и у всех экземпляров. Таким образом, следующие два блока кода схожи между собой в C++
и python
.
C++
class A
{
public:
static int x = 0;
};
Класс A
с целочисленным статическим атрибутом x
.
python
class A:
x = 0
Класс A
с атрибутом класса x
.
На самом деле x
принадлежит самому классу A
, но к нему удается получить доступ через экземпляры этого класса a1
и a2
из-за механизма поиска атрибута в python: если не удается найти атрибут у самого экземпляра, то интерпретатор ищет его у класса этого объекта.
Note
Доступ к атрибуту осуществляется через точку (obj.attr
), как и в C++
. Встроенная функция getattr предоставляет альтернативу: getattr(obj, "attr")
то же самое, что и obj.attr
. В обоих случаях, если атрибут найти не удается, то бросается исключение AttributeError. Проверить, есть ли такой атрибут у объекта можно функцией hasattr.
Приведенные альтернативы могут быть полезны, если у вас уже есть имя атрибута, значение которого вы хотите получить, в виде строки.
Чтобы создать атрибут у самого объекта, достаточно просто присвоить к желаемому атрибуту какое-нибудь значение.
a1.y = 42
В большинстве случаев, объекты пользовательских классов хранят свои атрибуты в словаре, доступ к которым можно получить по полю __dict__. Встроенная функция vars возвращает этот словарь. Воспользуемся этой функцией, чтобы проверить, какие атрибуты есть у экземпляров a1
и a2
.
print(vars(a1))
print(vars(a2))
{'y': 42}
{}
Видно, что у объекта a1
появился атрибут y
, а у объекта s2
нет, т.е. атрибут y
принадлежит экземпляру, а не классу целиком.
Note
__class__
, __dict__
и ряд других специальных атрибутов хранятся особым образом и командой vars
не выводятся.
Возможно сделать так, чтобы все или часть атрибутов хранилась не в словаре (например, объявив атрибут __slots__). В таких случаях специального атрибута __dict__
у объекта может не быть вообще и вызов функции vars
бросит ошибку TypeError.
Встроенная функция dir не полагается на наличия атрибута __dict__
у объекта и пытается вывести все значимые атрибуты объекта.
Создание атрибутов объекта обычно происходит когда объект уже создан. Однако, стараются избегать ситуации, когда атрибуты (и методы) экземпляра создаются вне тела класса, т.к. это может привести к ситуации, когда у объектов одного типа есть разные атрибуты и методы (интерфейс). Если так случилось, то стоит задаться вопросом, а действительно ли эти сущности должны быть одного типа (класса), так уж ли много у них общего, чтобы считать их объектами одного типа?
Чтобы проконтролировать, что у объектов одного типа одинаковый набор атрибутов, определяют специальный метод-“конструктор” класса. Но прежде разберем, как объявляются и вызываются методы класса вообще.
Методы класса#
Методы класса объявляются в теле класса как обычные функции в модуле.
class A:
def f():
print("Метод f класса A")
A.f()
Метод f класса A
Видим, что вызвать метод класса можно используя точечную нотацию через объект объявления этого класса.
Попробуем создать экземпляр этого класса и вызвать эту функцию через него.
a = A()
try:
a.f()
except TypeError as msg:
print(msg)
f() takes 0 positional arguments but 1 was given
При вызове функции через экземпляр класса возникла ошибка TypeError, которая сообщает, что при попытке вызвать функцию f()
был передан 1 аргумент, а ожидалось 0 аргументов. Это объясняется тем, что инструкция
a.f()
на самом деле неявно преобразуется к инструкции
A.f(a)
Мы объявили функцию f
в теле класса A
без параметров, а python
передал в неё ссылку на экземпляр класса, что и привело к ошибке.
Note
Такое поведение присуще всем методам, объявленным в теле класса обычным образом. Смотри Статические методы, чтобы узнать, как объявить метод, не принимающий экземпляр первым параметром.
Т.е. при вызове метода через экземпляр класса первым аргументом неявно передаётся ссылка на этот самый экземпляр. Это позволяет таким методам получать доступ к вызвавшему экземпляру, его атрибутам, методам и т.п. Однако программистам приходится явно указывать дополнительный параметр в объявлении метода класса на первой позиции. Имя этого параметра может быть произвольным, но общепринято называть его self
. Этот параметр очень похож на this
в C++
, но в python
приходится явно указывать этот параметр.
Приведем пример, того как обычно объявляются методы класса.
class A:
def f(self):
print("Метод f класса A")
def g(self, x):
print(f"Метод g класса A вызван с параметром {x}")
a = A()
a.f()
a.g(42)
Метод f класса A
Метод g класса A вызван с параметром 42
Т.е. методы класса объявляются как обычные функции, но с одним дополнительным параметром self
на первом месте. При вызове этого метода через экземпляр класса этот параметр указывать уже не надо.
Под капотом.
Методы класса — можно считать обычными атрибутами класса. Они объявляются в теле класса, а значит их объекты создаются во время создания объекта объявления класса, а не во время создания экземпляра. Однако, есть интересная деталь, которая объясняет почему первым параметром передаётся ссылка на вызвавший экземпляр: все функции являются дескрипторами, т.е. функции — объекты, у которых специализирован метод __get__. Если атрибут класса является дескриптором, то при получении доступа к нему через экземпляр класса возвращается не сам дескриптор, а результат вызова его метода __get__
.
У всех функций по умолчанию, метод __get__
принимает на вход экземпляр и тип этого экземпляра, а возвращает обертку над этой самой функцией, которая подставляет экземпляр класса в качестве первого аргумента при вызове функции. Такая обертка над функцией называется связанным методом (bound method
): метод привязан к экземпляру.
Ссылка на исходную функцию хранится в атрибуте __func__
связанного метода.
Продемонстрируем факт того, что A.f
и a.f
в предыдущем примере — разные сущности.
print(A.f) # <function A.f at 0x...>
print(a.f) # <bound method A.f of <__main__.A object at 0x...>>
print(A.f is a.f) # False
print(A.f is a.f.__func__) # True
Статические методы#
Итак, вызов метода через объект объявления класса и через экземпляр класса приводит к разным эффектам. В первом случае метод вызывается в точности, как он объявлен, а во втором случае в качестве первого параметра передаётся ссылка на вызвавший экземпляр. Но что, если мы хотим объявить метод внутри класса, который никак не взаимодействует с экземпляром класса, но хотим иметь возможность вызывать его одинакового через экземпляр и через класс?
Для таких целей есть встроенная декорирующая функция staticmethod.
class A:
def my_instance_method():
print("Обычный метод.")
@staticmethod
def my_static_method():
print("Статический метод.")
a = A()
print(a.my_instance_method)
A.my_instance_method()
try:
a.my_instance_method()
except TypeError as msg:
print(msg)
<bound method A.my_instance_method of <__main__.A object at 0x0000024C3DB71760>>
Обычный метод.
my_instance_method() takes 0 positional arguments but 1 was given
print(a.my_static_method)
A.my_static_method()
a.my_static_method()
<function A.my_static_method at 0x0000024C3DBE4DC0>
Статический метод.
Статический метод.
Из примера выше видно, что
Статический метод не отображается в качестве связанного
bound
с экземпляром;Статический метод с одинаковым успехом вызывается и через класс и через экземпляр без дополнительных параметров.
Note
Часто возникают сомнения, должен ли метод быть объявлен статическим внутри класса или снаружи в виде обычной функции, если они никак не взаимодействуют с экземпляром. Общего правила на это счет нет. Иногда удобно объявить метод статическим, чтобы поместить его в пространство имен класса и тем самым не только освободить глобальное пространство имен модуля, но и явно указать, что этот метод как-то связан с этим типом данных. Ещё часто статические методы применяют для реализации альтернативных конструкторов: действительно, конструктор класса, по определению не должен принимать на вход экземпляр класса, а должен его возвращать.
Инициализация объекта#
Теперь, когда мы знаем, как объявляются методы, разберем, как модифицировать создание экземпляров, чтобы гарантировать наличие у них уникальных атрибутов.
При создании экземпляра класса (вызов объекта объявления класса) сначала вызывается специальный метод __new__ соответствующего класса, который именно создаёт объект и возвращает его. Затем у этого уже созданного объекта вызывается специальный метод __init__, который его инициализирует. По умолчанию эти методы наследуются от базового класса (object
во всех предыдущих примерах). Чтобы модифицировать создание объекта, необходимо переопределить один (или оба) из методов __new__
и __init__
.
Метод __new__
переопределяется относительно редко и раскрываться здесь не будет. При инициализации объекта методом __init__
обычно создаются атрибуты объекта. Ниже приводятся примеры объявления классов с одним не статическим атрибутом в C++
и python
.
C++
class A
{
public:
int x = 0;
A(int x): x(x) {};
};
В конструкторе A::A(int)
инициализируется целочисленная переменная x
значением, переданным в конструктор.
python
class A:
def __init__(self, x):
self.x = x
В инициализирующем методе A.__init__
у объекта self
создаётся атрибут x
и связывается со значением, переданным в качестве второго параметра.
Метод __init__
является обычным методом класса, которому неявно передаётся ссылка self
на созданный методом __new__
экземпляр. Раз уж этот объект уже существует, то у него можно создавать атрибуты (смотри Атрибуты класса и атрибуты объекта), что и происходит в инструкции self.x = x
.
Note
Метод __init__
обязательно должен ничего не возвращать (иными словами возвращать None
). Если это нарушится, то при создании экземпляра возникнет ошибка TypeError.
Объявим такой класс A
с дополнительной инструкцией print
в методе __init__
, чтобы явно видеть, когда и с какими параметрами он вызывается.
class A:
def __init__(self, x):
print(f"Инициализируется экземпляр класса A с атрибутом x = {x}")
self.x = x
Теперь создадим два экземпляра этого класса с разными значениями и убедимся, что атрибут x
уникален для каждого экземпляра.
Т.к. метод __init__
теперь принимает дополнительный параметр x
, то необходимо передать какое-нибудь значение при вызове объекта объявления класса для создания экземпляра.
a1 = A(3)
a2 = A(42)
print(f"a1.x = {a1.x}\na2.x = {a2.x}")
Инициализируется экземпляр класса A с атрибутом x = 3
Инициализируется экземпляр класса A с атрибутом x = 42
a1.x = 3
a2.x = 42
Из примера видно, что переопределенный метод __init__
вызывается дважды: первый раз с параметром 3, а второй раз с параметром 42. В результате создаётся два объекта, у которых атрибут x
ссылается на уникальные значения.
Ещё раз явно подметим, что метод __init__
вызывается, когда объект уже создан метод __new__
. Вызовем метод __new__
у типа object
, указав какого типа объект необходимо создать.
a3 = A.__new__(A) # то же самое, что и object.__new__(A)
print(isinstance(a3, A))
print(vars(a3))
True
{}
Видим, что объект a3
ссылается на экземпляр класса A
, но инициализации объекта методом __init__
не произошло.
Инициализируем a3
явно вызвав метод __init__
у экземпляра.
a3.__init__(13)
print(a3.x)
Инициализируется экземпляр класса A с атрибутом x = 13
13
Инициализация объекта a3
произошла успешно. Но ничто не мешает вызвать метод __init__
повторно, но в данном случае атрибут x
не создаётся, а перезаписывается, т.к. он уже существует после первой инициализации.
a3.__init__("new value")
print(a3.x)
Инициализируется экземпляр класса A с атрибутом x = new value
new value
Публичные и не публичные поля. Справка по классу#
В python нет поистине приватных атрибутов: какие бы меры вы не приняли, будет возможно получить доступ ко всем атрибутам класса. От части в связи с этим, в python
не принято принимать больших усилий для скрытия атрибутов. Вместо этого существует общепринятое правило, которое отличает публичные имена в классе от не публичных.
Все атрибуты и методы, имена которых начинаются с одинарного нижнего подчеркивания, не считаются публичными. Разработчик класса не рассчитывает, что к ним будет осуществляться доступ напрямую вне самого класса.
Все атрибуты и методы, имена которых не начинаются с нижнего подчеркивания, считаются публичными.
Так, метод public_method
в примере ниже считается публичным, а _private_method
нет.
class A:
"docstring of class"
def public_method(self):
"docstring of method"
def _private_method(self):
pass
Набор публичных атрибутов методов часто называют интерфейсом класса. Разработчик класса (модуля, библиотеки) берет на себя обязательство поддерживать интерфейс в ближайших обновлениях. Публичные методы документируются. Пользователь может смело получать доступ к публичным атрибутам и вызывать публичные методы.
Остальные атрибуты и методы не входят интерфейс. Разработчик вправе не составлять исчерпывающую документацию для непубличных методов или не документировать их вовсе. Разработчик может в любой момент убрать или изменить роль любого таких атрибутов и методов по своему усмотрению (например, при рефакторинге). Пользователь обращается к ним на свой страх и риск.
В частности, встроенная функция справки help
не перечисляет непубличные методы.
help(A)
Help on class A in module __main__:
class A(builtins.object)
| docstring of class
|
| Methods defined here:
|
| public_method(self)
| docstring of method
|
| ----------------------------------------------------------------------
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables (if defined)
|
| __weakref__
| list of weak references to the object (if defined)
Note
Имена, начинающиеся с двух нижних подчеркиваний (но не заканчивающиеся двумя подчеркиваниями), тоже имеют особую роль, которая проявляется при наследовании.
Пример c треугольниками#
Предположим, вы пишите программу, которая должна работать с треугольниками, вычислять их площадь и периметр. В результате у вас может получиться что-то следующее.
from math import asin, sqrt, cos, sin, pi
def degree_to_radian(angle):
return angle * pi / 180
class Triangle:
"Класс для работы с треугольниками. "
def __init__(self, AB, BC, CA):
"Конструктор. В качестве параметров принимает длины 3 сторон."
if not Triangle.is_valid(AB, BC, CA):
raise ValueError("Нарушено неравенство треугольника.")
self._AB = AB
self._BC = BC
self._CA = CA
@staticmethod
def is_valid(AB, BC, CA):
"Проверяет выполнение неравенства треугольника."
if AB + BC < CA:
return False
if BC + CA < AB:
return False
if CA + AB < BC:
return False
return True
@staticmethod
def _is_valid_angle(angle):
if angle <= 0:
return False
if angle >= pi:
return False
return True
@staticmethod
def from_2_edges_and_1_angle(AB, CA, CAB, degree=False):
"Конструктор. В качестве параметров принимает 2 стороны и угол между ними."
if degree:
CAB = degree_to_radian(CAB)
if not Triangle._is_valid_angle(CAB):
raise ValueError("Угол должен быть в интервале от 0 до Pi.")
BC = sqrt(CA*CA + AB*AB - 2*AB*CA*cos(CAB))
return Triangle(AB, BC, CA)
@staticmethod
def from_1_edge_and_2_angles(AB, ABC, CAB, degree=False):
"Конструктор. В качестве параметров принимает сторону и прилежащие углы."
if degree:
ABC = degree_to_radian(ABC)
CAB = degree_to_radian(CAB)
if not Triangle._is_valid_angle(ABC):
raise ValueError("Угол должен быть в интервале от 0 до Pi.")
if not Triangle._is_valid_angle(CAB):
raise ValueError("Угол должен быть в интервале от 0 до Pi.")
BCA = pi - ABC - CAB
if not Triangle._is_valid_angle(BCA):
raise ValueError("Сумма углов должна быть меньше Pi.")
CA = AB / sin(BCA) * sin(ABC)
BC = AB / sin(BCA) * sin(CAB)
return Triangle(AB, BC, CA)
def perimeter(self):
"Возвращает периметр треугольника."
return self._AB + self._BC + self._CA
def area(self):
"Возвращает площадь треугольника."
p = self.perimeter() / 2.
return sqrt(p*(p - self._AB)*(p - self._BC)*(p - self._CA))
Обсудим реализованную структуру объекта.
Треугольник однозначно задаётся длинами его сторон, а значит разумно задать эти длины в качестве атрибутов объекта треугольника.
Далее встаёт вопрос, считать ли эти атрибуты публичными или нет? Не из любых отрезков можно сложить треугольник. Чтобы треугольник с заданным набором длин сторон существовал, необходимо, чтобы выполнялось неравенство треугольника. Таким образом, если сделать атрибуты публичными, то пользователь сможет по неосторожности сделать из возможного треугольника невозможный. Сделаем эти атрибуты непубличными.
Треугольник с заданной комбинацией сторон может не существовать, а значит логично будет выделить отдельную процедуру для проверки неравенства треугольника. Проверка на существование заданного треугольника по логике должна осуществляться до создания объекта. Иначе, в какой-то момент будет существовать невозможный треугольник. По этой причине логично считать, что эта процедура должна в качестве параметров принимать длинны сторон, а не экземпляр класса. Выше принято решение сделать такой метод статическим (
Triangle.is_valid
). В качестве альтернативы можно было рассмотреть реализацию этого метода в виде функции снаружи класса. При этом, так как такой метод может быть полезен и в других ситуациях, то можно сделать его публичным.Стандартный конструктор принимает в качестве параметров длины отрезков. Если отрезки не удовлетворяют неравенству треугольника, то возбуждается исключение ValueError. В обратной ситуации создаётся объект с непубличными атрибуты.
Два альтернативных конструктора, позволяющие задавать треугольники в виде комбинации сторон и углов, реализованны в качестве статически методов.
Разумно ограничить все углы в отрезке \((0, \pi)\). Для этого реализован непубличный статический метод
_is_valid_angle
.Публичные методы
perimeter
иarea
вычисляют периметр и площадь треугольника соответственно.
t1 = Triangle(3, 4, 5)
t2 = Triangle.from_2_edges_and_1_angle(3, 4, 90, degree=True)
alpha = asin(3 / 5)
beta = asin(4 / 5)
t3 = Triangle.from_1_edge_and_2_angles(5, alpha, beta)
print(t1.area(), t2.area(), t3.area())
6.0 6.0 6.0
help(Triangle)
Help on class Triangle in module __main__:
class Triangle(builtins.object)
| Triangle(AB, BC, CA)
|
| Класс для работы с треугольниками.
|
| Methods defined here:
|
| __init__(self, AB, BC, CA)
| Конструктор. В качестве параметров принимает 3 длины сторон.
|
| area(self)
| Возвращает площадь треугольника.
|
| perimeter(self)
| Возвращает периметр треугольника.
|
| ----------------------------------------------------------------------
| Static methods defined here:
|
| from_1_edge_and_2_angles(AB, ABC, CAB, degree=False)
| Конструктор. В качестве параметров принимает сторону и прилежащие углы.
|
| from_2_edges_and_1_angle(AB, CA, CAB, degree=False)
| Конструктор. В качестве параметров принимает 2 стороны и угол между ними.
|
| is_valid(AB, BC, CA)
| Проверяет выполнение неравенства треугольника.
|
| ----------------------------------------------------------------------
| Data descriptors defined here:
|
| __dict__
| dictionary for instance variables (if defined)
|
| __weakref__
| list of weak references to the object (if defined)