Skip to content

Latest commit

 

History

History
324 lines (219 loc) · 29.7 KB

File metadata and controls

324 lines (219 loc) · 29.7 KB

Урок 6. Методы уровня класса и статические методы: когда, зачем и как применять

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

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

Вторая категория — вспомогательные вычисления, которые логически удобно держать рядом с классом, но которые вообще не нуждаются ни в объекте, ни в самом классе для своей работы.

Для первой ситуации в Python предусмотрены методы класса, объявляемые декоратором @classmethod, а для второй — статические методы, объявляемые декоратором @staticmethod.

Цель этого урока — не просто показать синтаксис обоих декораторов, а разобраться, чем они по существу отличаются друг от друга и от обычного метода, и на основе каких признаков в реальном коде выбирают один из трёх вариантов.

Отправная точка: обычный метод и его ограничение

Рассмотрим знакомую конструкцию:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def get_coord(self):
        return self.x, self.y
v = Vector(10, 20)
print(v.get_coord())   # (10, 20)

Метод get_coord полностью укладывается в схему, разобранную в третьем уроке: self — это ссылка на v, и метод обращается именно к данным этого конкретного объекта. Технически тот же вызов можно записать и через класс — Vector.get_coord(v), — но эта форма лишь подтверждает то, что уже известно: метод рассчитан на работу с объектом, и вызов через класс имеет смысл только тогда, когда объект передаётся вручную.

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

class Vector:
    MIN_COORD = 0
    MAX_COORD = 100

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

def validate(self, value):
    return self.MIN_COORD <= value <= self.MAX_COORD

Формально это будет работать: self.MIN_COORD найдёт значение в классе через уже знакомый порядок поиска атрибутов. Но по существу такой метод обманчив: он принимает self, как будто зависит от конкретного объекта, хотя на самом деле не читает и не изменяет ни одного атрибута объекта — вся используемая им информация (MIN_COORD, MAX_COORD) лежит на уровне класса. Чтобы вызвать этот метод, пришлось бы сначала создать объект, хотя по смыслу задачи объект для проверки границ вообще не требуется. Именно для подобной ситуации и существует метод класса.

Метод класса: @classmethod

Метод класса — это метод, который получает первым параметром не объект, а сам класс, и потому имеет доступ к атрибутам класса, но не имеет доступа к атрибутам конкретного объекта, поскольку никакой конкретный объект ему не передаётся.

class Vector:
    MIN_COORD = 0
    MAX_COORD = 100

    @classmethod
    def validate(cls, value):
        return cls.MIN_COORD <= value <= cls.MAX_COORD

Декоратор @classmethod, расположенный прямо над определением метода, сообщает Python, что этот метод должен вызываться иначе, чем обычный: вместо объекта первым аргументом ему нужно передавать класс. По соглашению этот параметр называют cls — то же соглашение об имени, что уже встречалось при разборе __new__ в прошлом уроке, и не случайно: там cls тоже обозначал класс, а не объект, поскольку объекта ещё не существовало. Здесь же объект в принципе не нужен — метод рассчитан на работу с классом, независимо от того, создан ли хоть один экземпляр.

Вызывается такой метод напрямую через класс, без создания объекта:

print(Vector.validate(10))   # True

Здесь заключено принципиальное отличие от обычных методов: Vector.get_coord без объекта вызвать бессмысленно (в него нечего было бы подставить в качестве self), а Vector.validate вызывается совершенно естественно, поскольку cls заполняется автоматически самим классом Vector в момент вызова, а объект для этого попросту не требуется.

Метод класса можно вызвать и через объект — Python в этом случае сам определит, какому классу принадлежит объект, и передаст в cls именно этот класс:

class Vector:
    MIN_COORD = 0
    MAX_COORD = 100

    def __init__(self, x, y):
        if self.validate(x) and self.validate(y):
            self.x = x
            self.y = y
        else:
            self.x = 0
            self.y = 0

    @classmethod
    def validate(cls, value):
        return cls.MIN_COORD <= value <= cls.MAX_COORD

Внутри __init__ вызов self.validate(x) работает корректно, хотя validate — метод класса, а не метод экземпляра: Python видит, что self принадлежит классу Vector, и автоматически подставляет в cls именно Vector, независимо от того, что для вызова использовалось имя объекта, а не имя класса. Оба варианта — self.validate(x) и Vector.validate(x) — приводят к одному и тому же результату, поскольку в конечном счёте оба передают в cls один и тот же класс.

Важное ограничение метода класса заключается в том, что внутри него недоступен self, поскольку он туда никогда и не передавался:

@classmethod
def some_method(cls):
    print(self.x)   # NameError: name 'self' is not defined

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

Метод класса как альтернативный конструктор

Одно из самых частых применений @classmethod на практике — создание объекта не обычным способом через __init__, а на основе данных другого формата.

class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email

    @classmethod
    def from_string(cls, data_str):
        username, email = data_str.split(";")
        return cls(username, email)
user = User.from_string("john;john@mail.com")
print(user.username, user.email)   # john john@mail.com

Здесь строка сначала разбирается на составные части, а затем вызывается cls(username, email). Обратите внимание, что внутри метода использован именно cls, а не прямое имя класса User — и это осознанный выбор, а не стилистическая прихоть. Если класс User впоследствии будет унаследован каким-то другим классом, cls внутри from_string, вызванного через класс-наследник, будет ссылаться именно на этот класс-наследник, а не на User, и создастся объект правильного, производного типа. Если бы вместо cls(...) было жёстко прописано User(...), метод from_string, унаследованный дочерним классом, всё равно продолжал бы создавать объекты именно User, а не наследника — что, скорее всего, было бы ошибкой. Эта деталь станет особенно значимой позже, когда в курсе будет разбираться наследование, но полезно зафиксировать её уже сейчас: использование cls(...) вместо жёстко прописанного имени класса — стандартная практика внутри методов класса именно потому, что она сохраняет корректность при наследовании.

Статический метод: @staticmethod

Существует и третий случай — функция, которая логически относится к классу по смыслу (потому и хранится внутри него), но для своей работы не нуждается ни в объекте, ни в самом классе.

class Vector:
    @staticmethod
    def norm2(x, y):
        return x * x + y * y
print(Vector.norm2(3, 4))   # 25

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

v = Vector()
print(v.norm2(3, 4))   # 25 — тот же результат, что и через класс

Здесь стоит зафиксировать разницу с двумя предыдущими типами методов предельно чётко. Обычный метод получает self — конкретный объект. Метод класса получает cls — класс. Статический метод не получает ничего, кроме тех аргументов, которые были явно переданы при вызове, — ни скрытой ссылки на объект, ни скрытой ссылки на класс.

Оба инструмента в одном классе

Рассмотрим класс, где методу класса и статическому методу находится вполне естественное применение одновременно:

class Order:
    TAX_RATE = 0.2

    def __init__(self, amount):
        self.amount = amount

    @classmethod
    def set_tax_rate(cls, new_rate):
        cls.TAX_RATE = new_rate

    @staticmethod
    def format_price(value):
        return f"{value:.2f} EUR"

set_tax_rate — метод класса, потому что он изменяет состояние, общее для всех объектов Order — ставку налога, единую для всей системы заказов, а не для одного конкретного заказа:

Order.set_tax_rate(0.25)
print(Order.TAX_RATE)   # 0.25

format_price, в свою очередь, — статический метод, поскольку форматирование числа в строку определённого вида никак не связано ни с конкретным заказом, ни даже с классом Order как таковым — это самодостаточное вычисление, которому для работы достаточно единственного переданного аргумента:

print(Order.format_price(10))   # 10.00 EUR

Три вида методов рядом друг с другом

Удобно свести различие к тому, что именно автоматически передаётся методу первым аргументом:

Обычный метод получает объект через self и благодаря этому имеет доступ и к данным объекта, и, через порядок поиска атрибутов, к данным класса. Метод класса получает сам класс через cls и имеет доступ только к данным класса — данных какого-то конкретного объекта у него просто нет. Статический метод не получает ничего автоматически — у него нет доступа ни к объекту, ни к классу, если он сам явно не обратится к ним по имени.

Типичные ошибки

Первая распространённая ошибка — использование @staticmethod там, где нужен @classmethod, особенно в задачах создания объекта:

class Vector:
    @staticmethod
    def create():
        return Vector()   # жёстко прописанное имя класса

Проблема здесь ровно та же, что была разобрана применительно к from_string: если класс Vector будет унаследован, метод create, доставшийся наследнику, всё равно продолжит создавать объекты именно Vector, а не класса-наследника, поскольку имя Vector внутри статического метода жёстко зафиксировано и не подстраивается под то, через какой класс метод был вызван. Правильный вариант — метод класса с использованием cls:

@classmethod
def create(cls):
    return cls()

Вторая ошибка — попытка обратиться к self внутри метода класса, разобранная выше: cls не содержит ссылки на объект, и такой код неизбежно завершится NameError, поскольку имя self там нигде не объявлено.

Третья ошибка, менее очевидная, — избыточное использование @staticmethod там, где функция вообще не связана с классом по смыслу. Если функция не обращается ни к атрибутам класса, ни (в силу отсутствия self) к атрибутам объекта, и её логика никак содержательно не привязана к назначению класса, помещение её внутрь класса как статического метода часто оказывается неоправданным усложнением структуры кода — такую функцию, как правило, лучше вынести за пределы класса как обычную функцию модуля. Статический метод оправдан тогда, когда вычисление, хоть и не зависящее от состояния, всё же тесно связано по смыслу именно с этим классом — как format_price связан именно с представлением цен заказа, а не является общей утилитой форматирования чисел, уместной где угодно.

Как выбирать между тремя типами методов

Практический ориентир для выбора можно сформулировать в виде последовательности вопросов. Если методу нужен доступ к данным конкретного объекта — это обычный метод с self. Если объект не нужен, но нужен доступ к данным, общим для всех объектов класса (включая создание нового объекта самого этого класса, с учётом возможного наследования) — это метод класса с cls. Если методу не нужен доступ ни к объекту, ни к классу, а требуется лишь самостоятельное вычисление, которое по смыслу естественно живёт именно рядом с этим классом, — это статический метод.

Отдельно стоит выделить наиболее частый практический сценарий использования @classmethod — создание объекта через альтернативный источник данных. Всякий раз, когда метод должен в конечном счёте вернуть новый экземпляр класса (а не просто прочитать или изменить что-то), стоит по умолчанию задуматься о cls(), а не о жёстко прописанном имени класса, — привычка, которая окупается в первую очередь при последующем наследовании.

Итоги урока

Помимо обычных методов экземпляра, работающих через self, в Python существуют методы класса и статические методы, различающиеся тем, что автоматически передаётся им первым аргументом. Метод класса, объявленный декоратором @classmethod, получает cls — сам класс — и подходит для логики, которая содержательно относится ко всему классу: работы с атрибутами класса, изменения общих настроек, а также создания объектов через альтернативные конструкторы, где использование cls() вместо жёсткого имени класса сохраняет корректное поведение при наследовании. Статический метод, объявленный декоратором @staticmethod, не получает ни self, ни cls и представляет собой самостоятельную функцию, физически размещённую внутри класса из соображений смысловой принадлежности, но не зависящую от состояния ни объекта, ни класса.

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

В следующем уроке курс переходит к отдельному, самостоятельному модулю — инкапсуляции: тому, как Python управляет доступом к атрибутам объектов через соглашения об именовании, свойства и дескрипторы. Разбор методов класса и статических методов, проведённый здесь, пригодится и там: многие технические приёмы инкапсуляции опираются на то же понимание различий между self, cls и полностью независимой функцией.


Вопросы для проверки

  1. Чем принципиально отличается первый параметр обычного метода от первого параметра метода класса?
  2. Почему метод класса можно вызвать без создания объекта, а обычный метод — как правило, нельзя?
  3. Почему при вызове метода класса через объект (self.validate(x)) в cls всё равно передаётся класс, а не объект?
  4. Почему внутри метода класса нельзя обратиться к self?
  5. Почему в альтернативных конструкторах (методах вида from_string) внутри метода класса используют cls(...), а не жёстко прописанное имя класса?
  6. Чем статический метод отличается и от обычного метода, и от метода класса?
  7. Можно ли вызвать статический метод через объект, и изменится ли при этом его поведение?
  8. Почему статический метод, создающий объект своего класса через жёстко прописанное имя, считается ошибкой проектирования?

Задачи

Задача 1.

Создайте класс NumberUtils со статическим методом is_positive(number), возвращающим True, если число больше нуля, и False в остальных случаях.


Задача 2.

Создайте класс UserCounter, считающий количество созданных объектов. Атрибут класса count должен увеличиваться при создании каждого объекта. Реализуйте метод класса get_count(), возвращающий текущее значение счётчика.


Задача 3.

Дан класс Loader с уже реализованным статическим методом parse_format:

class Loader:
    @staticmethod
    def parse_format(string, factory):
        seq = factory.build_sequence()
        for sub in string.split(","):
            item = factory.build_number(sub)
            seq.append(item)
        return seq

Реализуйте класс Factory с двумя статическими методами: build_sequence(), возвращающим пустой список, и build_number(string), преобразующим строку в целое число.


Задача 4.

Создайте класс Product с атрибутами объекта name и price, атрибутом класса MIN_PRICE = 0 и методом класса check_price(price), возвращающим True, если цена корректна (больше либо равна MIN_PRICE). В конструкторе, если переданная цена некорректна, установите price = 0.


Задача 5.

Реализуйте классы TextInput и PasswordInput, создаваемые как TextInput(name, size=10) и PasswordInput(name, size=10). У каждого должен быть метод get_html(), возвращающий соответствующую HTML-строку, а также метод класса check_name(name), проверяющий, что имя состоит из 3–50 символов и содержит только буквы, цифры и пробел (набор допустимых символов храните в атрибуте класса CHARS). Если проверка не проходит, метод должен вызывать исключение с пояснительным текстом.

Пример использования:

login = TextInput("login")
psw = PasswordInput("password")

print(login.get_html())   # <p class='login'>Имя: <input type='text' size=10 />
print(psw.get_html())     # <p class='password'>Пароль: <input type='text' size=10 />

Задача 6.

Реализуйте класс CardCheck, создаваемый как CardCheck(name, number). Атрибут класса CHARS_FOR_NAME должен содержать заглавные латинские буквы. Метод класса check_name(name) проверяет, что имя состоит из двух слов, написанных заглавными буквами и содержащих только допустимые символы. Статический метод check_card_number(number) проверяет, что номер карты соответствует формату XXXX-XXXX-XXXX-XXXX, где X — цифра. Если хотя бы одна из проверок не проходит, конструктор должен вызывать исключение ValueError с сообщением "некорректные данные карты".

Пример использования:

card1 = CardCheck("IVAN IVANOV", "1234-5678-9012-0000")
print(card1.name)     # IVAN IVANOV
print(card1.number)   # 1234-5678-9012-0000

try:
    card2 = CardCheck("Ivan Ivanov", "1234-5678-9012-0000")
except ValueError as e:
    print(e)   # некорректные данные карты

try:
    card3 = CardCheck("PETR PETROV", "1234-5678-9012")
except ValueError as e:
    print(e)   # некорректные данные карты

Предыдущий урок | Следующий урок