Получите полное представление об основах классов данных в Python

Если вам нравится работать с Medium, поддержите меня и тысячи других авторов, подписавшись на членство. Это стоит всего 5 долларов в месяц, это очень поддерживает нас, писателей, и вы получаете доступ ко всем удивительным историям на Medium.

Что такое классы данных?

Классы данных — это новая функция, доступная во встроенных модулях Python, начиная с версии 3.7. Они предоставляют декоратор и функции для создания более простых, удобных и безопасных классов, которые в основном используются для обработки данных, отсюда и название.

Одним из основных преимуществ классов данных является то, что они автоматически генерируют для вас несколько специальных методов, таких как __init__, __repr__ и __eq__. Это может сэкономить вам много времени и лишнего кода при определении классов, которые в основном используются для обработки данных.

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

Как использовать классы данных

Чтобы использовать классы данных, вам сначала нужно импортировать декоратор класса данных из модуля dataclasses. Этот декоратор изначально включен в Python 3.7 и выше.

Использование классов данных очень просто. Просто украсьте определение класса декоратором @dataclass, чтобы определить класс данных.

Вот пример простого класса данных с параметрами по умолчанию:

from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
print(p)  # Output: Point(x=1.0, y=2.0)

Мы определили класс данных Point с двумя полями, x и y, оба из которых являются числами с плавающей запятой. Когда вы создаете экземпляр класса Point, вы можете указать значения для x и y в качестве аргументов конструктора.

По умолчанию классы данных сгенерируют для вас метод __init__, который принимает поля класса в качестве аргументов. Они также сгенерируют метод __repr__, возвращающий строковое представление объекта, которое выводится при вызове print(p) в приведенном выше примере.

Вы можете указать, какие методы генерируются декоратором, передав дополнительные аргументы, такие как repr=False, чтобы деактивировать метод __repr__.

Функция поля

Вы также можете указать значения по умолчанию для полей класса данных, используя аргументы default и default_factory для функции field().

Например:

@dataclass
class Point:
    x: float = 0.0
    y: float = field(default=0.0)

p1 = Point(x=1.0, y=2.0)
print(p1)  # Output: Point(x=1.0, y=2.0)

p2 = Point(1.0)
print(p2)  # Output: Point(x=1.0, y=0.0)

В этом примере мы определили класс Point с двумя полями, x и y. Поле x имеет значение по умолчанию 0,0, указанное непосредственно в определении поля, а поле y имеет значение по умолчанию 0,0, указанное с помощью функции field().

Когда мы создаем экземпляр класса Point, мы можем указать значения для x и y в качестве аргументов конструктора. Если мы не укажем значение для y, будет использоваться значение по умолчанию 0,0.

Внутри класса данных два способа указания значений по умолчанию эквивалентны для атрибутов immutable.

Однако функция field() обеспечивает большую гибкость и дополнительные возможности для определения атрибутов.

Представление поля и аргументы инициализации

Действительно, аргументы __repr__ и __init__ по умолчанию для функции field() имеют значение True. Мы можем установить для них значение False, чтобы изменить их поведение.

@dataclass
class Point:
    x: float = field(default=0.0, repr=False)
    y: float = field(default=0.0, init=False)

p1 = Point(3.0) 
print(p1) # Output: Point(y=0.0)
p2 = Point(3.0, 2.0) # TypeError: Point.__init__() takes from 1 to 2 positional arguments but 3 were given

В первом примере экземпляр p1 не показывает значение x в строковом представлении, что полезно для сокрытия временных или конфиденциальных переменных (по крайней мере, из строкового представления).

Во втором примере instancep2 не может быть создан, потому что аргумент инициализации имеет значение False, и мы пытаемся инициализировать переменную y. Это полезно для переменных, которые должны возвращаться только методом. Давайте добавим в наш класс данных метод, который использует только y в качестве результата вычисления в функции compute_y_with_x:

@dataclass
class Point:
    x: float = field(default=0.0, repr=False)
    y: float = field(default=0.0, init=False)
    def compute_y_with_x(self):
        self.y = self.x ** 2

p2 = Point(x=2.0)
p2.compute_y_with_x()
print(p2) # Output: Point(y=4.0)

Здесь мы замечаем, что p2 использовал y в качестве неинициализированной переменной (init=False), которая является результатом преобразования нашей входной переменной x. Нас не интересует x после инициализации, поэтому мы удалили его строковое представление (repr=False).

поле default_factory

Вы помните, когда мы обсуждали, что использование аргумента класса по умолчанию или установка поля с аргументом по умолчанию имеет тот же эффект для неизменяемых объектов, но не для изменяемых?

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

@dataclass
class Points:
    coord: list = field(default=[])
    def add_a_dimension(self, element):
        self.coord.append(element)
# Output: ValueError

Этот класс невозможно создать, потому что, как указано, «использование изменяемого значения по умолчанию (например, списка) для поля ‹coord› не разрешено». Это мера безопасности, добавленная классами данных, которая очень полезна для предотвращения ошибок, с которыми мы можем столкнуться в обычном классе.

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

class Points:
    coord = []
    def __init__(self, coord=coord):
        self.coord = coord
    def add_a_dimension(self, element):
        self.coord.append(element)

И у нас не будет ошибок при определении этого класса! Однако, если бы мы определили класс таким образом, используя обычный класс, мы бы увидели непреднамеренное поведение:

p1 = Points()
p2 = Points()
p1.coord, p2.coord # Output: ([],[])
p1.add_a_dimension(3)

Хорошо, мы создали пустой список и добавили 3 к экземпляру p1. Как вы думаете, какие теперь будут значения p1.coord и p2.coord?

p1.coord, p2.coord # Output: ([3], [3])

Невероятно, на экземпляр p2 также повлияло добавление 3 к списку!

Это связано с тем, что в Python при создании экземпляра класса этот экземпляр будет использовать ту же копию атрибутов класса. Поскольку списки являются изменяемыми, и p1, и p2 будут использовать одну и ту же копию списка coord.

Чтобы правильно реализовать этот класс и избежать этого непреднамеренного результата, классы данных предоставляют аргумент для field() с именем default_factory.

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

@dataclass
class Points:
    coord: list = field(default_factory=lambda: [])
    def add_a_dimension(self, element):
        self.coord.append(element)
p1 = Points()
p2 = Points()
p1.coord, p2.coord # Output ([], [])

Стоит отметить, что ValueError не возникает, даже если мы определяем изменяемый список как значение по умолчанию для coord.

Давайте проверим выходные данные после вызова метода add_a_dimension только для одного экземпляра:

p1.add_a_dimension(3)
p1.coord, p2.coord # Output ([3], [])

О, наконец-то мы получили желаемый результат! Экземпляр p2 не был изменен методом, вызванным экземпляром p1. Именно потому, что у каждого экземпляра есть своя уникальная копия поля coord .

Как мы видим, классы данных обеспечивают безопасность, правильно обрабатывая изменяемые и неизменяемые объекты.

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

Последний момент, который следует рассмотреть, — это то, как классы данных работают с наследованием. По умолчанию классы данных не имеют метода __init__, поэтому должен быть метод, позволяющий перезаписывать унаследованные атрибуты. Этот метод называется __post_init__ и вызывается после инициализации экземпляра.

Вот пример, иллюстрирующий эту концепцию:

@dataclass
class Point:
    x: float = field(default=0.0)
    y: float = field(default=0.0)
    
    def __post_init__(self):
        self.x = self.x ** 2
        self.y = self.y ** 2

@dataclass
class ColoredPoint(Point):
    color: str = field(default='black')
    
    def __post_init__(self):
        self.color = self.color.upper()

В этом примере класс Point имеет метод __post_init__, который возводит в квадрат значения x и y. Класс ColoredPoint наследуется от Point, а также имеет собственный метод __post_init__, который переводит значение атрибута color в верхний регистр.

Давайте создадим экземпляр Point:

p0 = Point(2.0,2.0)
print(p0) # Output: Point(x=4.0, 4.0)

Из вывода мы видим, что был вызван метод __post_init__, и оба значения x и y были возведены в квадрат.

Теперь давайте создадим экземпляр ColoredPoint:

p1 = ColoredPoint(2.0, 2.0, 'red')
print(p1) # Output: ColoredPoint(x=2.0, y=2.0, color='RED')

При создании экземпляра ColoredPoint вызывается метод __post_init__, и значение color указывается в верхнем регистре, но значения x и y не возводятся в квадрат, знаете почему?

Это потому, что метод __post_init__ из Point не был вызван в нашем методе __post_init__ из ColoredPoint!

Чтобы вызвать метод __post_init__ базового класса, вы можете использовать функцию super(), которая возвращает ссылку на базовый класс. Вот исправленная версия ColoredPoint:

@dataclass
class ColoredPoint(Point):
    color: str = field(default='red')

    def __post_init__(self):
        super().__post_init__()
        self.color = self.color.upper()
p2 = ColoredPoint(2.0, 2.0, 'red')
print(p2) # Output: ColoredPoint(x=4.0, y=4.0, color='RED')

С этим изменением метод __post_init__ класса Point будет вызываться при создании экземпляра ColoredPoint, а значения x и y будут возведены в квадрат, как и ожидалось.

Я рад, что смог помочь вам узнать о классах данных. Как вы видели, классы данных предоставляют расширенную версию обычных классов, которые более безопасны и подчеркивают строгую типизацию. Надеюсь, вы почувствуете себя более уверенно в своей способности использовать классы данных в своих будущих проектах.

Бесплатно вы можете подписаться на Medium по моей реферальной ссылке.



Или вы можете получать все мои сообщения в свой почтовый ящик.Сделайте это здесь!