По своей сути, Python является объектно-ориентированным языком программирования, вследствие чего он работает с данными и функциональностями, поддерживая различные объектно-ориентированные возможности. Например, все структуры данных — это объекты, в том числе и примитивные типы, такие как целые числа и строки, которые в других языках к таковыми не относятся. Функции также являются объектами и представляют из себя просто атрибуты других объектов, в которых они определены (например, класса или модуля). 

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

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

В данной статье представлен обзор 9 актуальных и наилучших практик, которые стоит рассмотреть применительно к пользовательским классам.

1. Правильные имена 

Определяя собственный класс, вы добавляете новое “дитя” в вашу базу кода, так что следует присвоить ему правильное и понятное имя. Несмотря на то, что в Python единственным ограничением при именовании являются правила допустимых имен переменных (например, они не могут начинаться с цифры), существуют ряд предпочтительных способов назвать класс.

  • Используйте легко произносимые существительные. Эта рекомендация особенно актуальна при условии совместной работы над проектом. Вряд ли вам захочется оказаться на месте человека, которому во время презентации придется сказать: “В этом случае мы создаем экземпляр класса Zgnehst”. Кроме того, из правила о легко произносимом имени вытекает еще одно, согласно которому оно не должно быть длинным. Сложно представить случаи, когда бы вам потребовалось больше трех слов для определения имени класса. Одно слово — наилучший вариант, два — приемлемый, а три — предельно допустимое количество.
  • Отражайте в имени суть содержащихся данных и предполагаемые функциональности. Все как в реальной жизни — мальчиков нарекают мужскими именами. Когда мы слышим имя Максим, то понимаем, что оно принадлежит мальчику. Этот принцип также применим к именам класса (или в целом любой другой переменной). Правило простое — не вводите людей в замешательство! Если вы работаете с информацией о студентах, то классу следует дать соответствующее имя— Student, а не KiddosAtCampus (Парни из универа), которое не несет должной смысловой нагрузки. 
  • Соблюдайте соглашения об именах. Для именования классов рекомендуется использовать верблюжий стиль (горбатый регистр), например так: GoodName. Далее приводится неполный список неприемлемых имен класса: goodName, Good_Name, good_name и GOodnAme. Следование общепринятым правилам написания имен позволит прояснить ваши намерения. В итоге при чтении кода ни у кого не возникнет сомнения, что объект с именем GoodName является классом.

Существуют также правила и соглашения об именовании атрибутов и функций. Далее я кратко расскажу о них по мере возможности, но в основном принципы те же самые. Просто запомните главное правило: Не вводите людей в замешательство!

2. Явные атрибуты экземпляров 

В большинстве случаев наша цель — определить собственный метод инициализации экземпляра, т. е. __init__. В нем мы устанавливаем начальное состояние создаваемых экземпляров класса. Однако Python не накладывает ограничений относительно того, где в пользовательском классе мы можем определить атрибуты экземпляра. Иначе говоря, вы можете определить дополнительные атрибуты в операциях, следующих за созданием экземпляра. Рассмотрим возможный сценарий в следующем примере кода. 

class Student:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    def verify_registration_status(self):
        status = self.get_status()
        self.status_verified = status == "registered"

    def get_guardian_name(self):
        self.guardian = "Goodman"

    def get_status(self):
        # получает статус регистрации из базы данных 
        status = query_database(self.first_name, self.last_name)
        return status

Как показано в примере, можно создать экземпляр класса Student, указав имя и фамилию студента. Далее при вызове метода экземпляра, а именно verify_registration_status, будет установлен атрибут статуса Student. Однако применение такого шаблона нежелательно, поскольку, распространяя различные атрибуты экземпляра по всему классу, вы не даете классу понять, какие данные содержит объект экземпляра. Поэтому будет лучше разместить атрибуты в метод __init__, чтобы при чтении кода была возможность в одном месте ознакомиться со структурой данных вашего класса. Обратимся к примеру улучшенного метода инициализации. 

class Student:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name
        self.status_verified = None
        self.guardian = None

Если какие-то атрибуты экземпляров нельзя установить изначально, то можно это сделать с помощью значений плейсхолдера, например None. Хотя это и не столь важно, но подобное изменение также помогает предотвратить возможную ошибку, когда вы забываете вызвать методы экземпляра для установки требуемых атрибутов, следствием чего является AttributeError (‘Student’ object has no attribute ‘status_verified’). 

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

3. Используем свойства, но не увлекаемся 

Некоторые учатся программированию на Pyhton, уже владея другими объектно-ориентированными языками, например Java, и привыкли создавать геттеры и сеттеры для атрибутов экземпляров. В Python этот шаблон можно воспроизвести с помощью декоратора property, и в следующем примере мы рассмотрим основной способ его использования для реализации геттеров и сеттеров. 

class Student:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name
    
    @property
    def name(self):
        print("Getter for the name")
        return f"{self.first_name} {self.last_name}"
    
    @name.setter
    def name(self, name):
        print("Setter for the name")
        self.first_name, self.last_name = name.split()

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

>>> student = Student("John", "Smith")
... print("Student Name:", student.name)
... student.name = "Johnny Smith"
... print("After setting:", student.name)
... 
Getter for the name
Student Name: John Smith
Setter for the name
Getter for the name
After setting: Johnny Smith

Скорее всего, вам известно, что в число преимуществ реализации свойств входят проверка правильной установки значений (например, используется ли строка, а не целое число) и доступ только для чтения (без реализации метода setter). Однако использовать их следует в разумных пределах. Такое чрезмерное количество свойств в пользовательском классе, как в нижеприведенном примере, собьет с толку кого угодно! 

class Student:
    def __init__(self, first_name, last_name):
        self._first_name = first_name
        self._last_name = last_name
    
    @property
    def first_name(self):
        return self._first_name
    
    @property
    def last_name(self):
        return self._last_name
    
    @property
    def name(self):
        return f"{self._first_name} {self._last_name}"

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

4. Определение содержательных строковых представлений 

Функции, содержащие двойные символы нижнего подчеркивания до и после имени, относятся к особым или магическим методам Python. Они используются в специальных случаях для основных операций интерпретатора, включая и ранее рассмотренный метод __init__. Для создания правильных строковых представлений пользовательского класса необходимы два метода, __repr__ и __str__, благодаря которым читающие ваш код получат более интуитивно понятную информацию о классах.

Главное их отличие в том, что метод __repr__ определяет строку, с помощью которой вы можете пересоздать объект, вызвав eval(repr(“the repr”)), тогда как строка, определяемая методом __str__, является более описательной и предоставляет больше возможностей для кастомизации. Иначе говоря, строка, используемая в методе __repr__, предназначена для просмотра разработчиками, а строка в методе __str__ — для обычных пользователей. Рассмотрим следующий пример реализации строковых представлений. 

class Student:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    def __repr__(self):
        return f"Student({self.first_name!r}, {self.last_name!r})"

    def __str__(self):
        return f"Student: {self.first_name} {self.last_name}"

Обратите внимание, что при реализации метода __repr__ f-строка использует !r, в результате чего эти строки отображаются в кавычках, поскольку они необходимы для создания экземпляра с правильно отформатированными строками. Без !r-форматирования строка будет выглядеть как Student(John, Smith), что будет неверным способом создания экземпляра Student. Посмотрим, в каком виде предстают строки в этих реализациях. Если быть более конкретным, то метод __repr__ вызывается при обращении к объекту в интерактивном режиме интерпретатора, а метод __str__ — по умолчанию при выводе объекта.

>>> student = Student("David", "Johnson")
>>> student
Student('David', 'Johnson')
>>> print(student)
Student: David Johnson

5.Статические методы, методы экземпляра и класса 

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

В случаях, когда речь идет об отдельных объектах экземпляра, например при необходимости обновления его конкретных атрибутов или обращения к ним, следует задействовать методы экземпляра. Их сигнатура выглядит следующим образом: def do_something(self):, где аргумент self относится к объекту экземпляра, которые вызывает метод.

В других же случаях следует применять статические методы или методы класса. И тот, и другой можно легко определить с помощью соответствующих декораторов: staticmethod и classmethod. Отличаются эти методы тем, что метод классов позволяет вам обратиться к атрибутам класса или обновить их, а статические методы не зависят от экземпляров или самого класса. Типичный пример метода класса — обеспечение удобного способа создания экземпляра, тогда как статический метод может быть просто вспомогательной функцией. В следующем коде приводятся примеры разных видов методов. 

class Student:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    def begin_study(self):
        print(f"{self.first_name} {self.last_name} begins studying.")
        
    @classmethod
    def from_dict(cls, name_info):
        first_name = name_info['first_name']
        last_name = name_info['last_name']
        return cls(first_name, last_name)
    
    @staticmethod
    def show_duties():
        return "Study, Play, Sleep"

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

6. Инкапсуляция при помощи приватных атрибутов 

Создавая пользовательские классы для своих проектов, вы должны принимать во внимание инкапсуляцию, особенно если планируете, что их будут использовать и другие. Когда функциональности класса разрастаются, некоторые функции или атрибуты применимы лишь для обработки данных внутри класса. Иначе говоря, эти функции не будут вызываться за пределами класса, и другие его пользователи не проявят ни малейшего интереса к деталям их реализации. В подобных сценариях вам следует прибегнуть к инкапсуляции.

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

По сути, называя атрибуты и функции подобным образом, вы говорите IDE, например PyCharm, что к ним нельзя получить доступ вне класса, хотя в Python настоящих приватных атрибутов не существует, т. е. при желании можно к ним обратиться.

class Student:
    def get_mean_gpa(self):
        grades = self._get_grades()
        gpa_list = Student._converted_gpa_from_grades(grades)
        return sum(gpa_list) / len(gpa_list)
    
    def _get_grades(self):
        # получает баллы из базы данных 
        grades = [99, 100, 94, 88]
        return grades
    
    @staticmethod
    def _converted_gpa_from_grades(grades):
        # преобразует баллы в GPA (средний балл) 
        gpa_list = [4.0, 4.0, 3.7, 3.4]
        return gpa_list

Данный фрагмент кода содержит простой пример инкапсуляции. Допустим, мы хотим узнать средний балл студента (GPA), что становится возможным с помощью метода get_mean_gpa. Пользователю не нужно знать, как он вычисляется, так что мы можем защитить сопутствующие методы, поставив нижнее подчеркивание перед именами функций. 

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

7. Разделение задач и уменьшение зацепления 

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

class Student:
    def __init__(self, first_name, last_name, student_id):
        self.first_name = first_name
        self.last_name = last_name
        self.student_id = student_id

    def check_account_balance(self):
        account_number = get_account_number(self.student_id)
        balance = get_balance(account_number)
        return balance

    def load_money(self, amount):
        account_number = get_account_number(self.student_id)
        balance = get_balance(account_number)
        balance += amount
        update_balance(account_number, balance)

Данный пример иллюстрирует псевдокод по проверке баланса счета и его пополнения, при этом обе эти операции реализованы в классе Student. А теперь представьте, что число операций со счетом может увеличиться. Например, добавятся приостановка обслуживания потерянной карты, объединение счетов, и реализация всех из них повлечет за собой всё большее разрастание класса Student, так что со временем обслуживать его станет сложнее. Вместо этого вам следует изолировать эти задачи и снять с класса Student ответственность за связанные со счетом функциональности — в этом суть шаблона проектирования decoupling (уменьшения зацепления). 

class Student:
    def __init__(self, first_name, last_name, student_id):
        self.first_name = first_name
        self.last_name = last_name
        self.student_id = student_id
        self.account = Account(self.student_id)

    def check_account_balance(self):
        return self.account.get_balance()

    def load_money(self, amount):
        self.account.load_money(amount)


class Account:
    def __init__(self, student_id):
        self.student_id = student_id
        # получает дополнительную информацию из базы данных 
        self.balance = 400

    def get_balance(self):
        # Теоретически student.account.balance сработает, но  на всякий случай 
        # нам необходимо добавить шаги для проверки, такие как запрос базы данных, 
        # и еще раз убедиться, что база актуальна 
        return self.balance

    def load_money(self, amount):
        # получает баланс из базы данных 
        self.balance += amount
        self.save_to_database()

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

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

8. __slots__ для оптимизации 

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

class StudentRegular:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name


class StudentSlot:
    __slots__ = ['first_name', 'last_name']

    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

Данный код показывает простой пример реализации __slots__ в классе. Точнее говоря, вы составляете последовательность из всех атрибутов, которая произведет в хранилище данных сопоставление один-к-одному, ускорив доступ и снизив затраты памяти. Как мы недавно отметили, обычные классы для доступа к атрибутам используют словари, что не касается случаев с реализацией __slots__. Это демонстрирует следующий пример кода, где в классе со __slots__ отсутствует __dict__:

>>> student_r = StudentRegular('John', 'Smith')
>>> student_r.__dict__
{'first_name': 'John', 'last_name': 'Smith'}
>>> student_s = StudentSlot('John', 'Smith')
>>> student_s.__dict__
Traceback (most recent call last):
  File "<input>", line 1, in <module>
AttributeError: 'StudentSlot' object has no attribute '__dict__'

Однако применение __slots__ сопровождается одним побочным эффектом — оно препятствует динамическому созданию дополнительных атрибутов. Некоторые предлагают использовать его в качестве механизма контроля наличия атрибутов в классе, но это не отвечает его назначению. 

9. Документация 

Последний, но не менее значимый раздел мы посвятим документации класса. Важно осознать, что факт ее написания не заменит вам код. Тонны документации не повысят его производительность и не улучшат читаемость. Если вам приходится полагаться на строки документации для пояснения кода, то, скорее всего, у него проблемы. Я уверен, что код должен говорить сам за себя. Следующий пример демонстрирует ошибку, которую могут допустить некоторые программисты — ненужные комментарии, компенсирующие плохой код. В данном случае речь идет о бессмысленных именах переменных.

# количество расчетных часов 
a = 6
# почасовая оплата 
b = 100
# общая сумма
c = a * b

# Альтернатива приведенной выше версии без комментариев

billable_hours = 6
hourly_rate = 100
total_charge = billable_hours * hourly_rate

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

Заключение 

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

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Yong Cui, Ph.D.: Advanced Python: 9 Best Practices to Apply When You Define Classes

Предыдущая статьяДва метода безопасного программирования
Следующая статьяПоиск с возвратом в решении типичных задач на собеседовании