По мере роста вашего проекта Python вы можете извлечь выгоду из применения этих практик.

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

Несмотря на свою популярность, Python также подвергается резкой критике, особенно со стороны людей, хорошо разбирающихся в нескольких языках программирования. Если мы проигнорируем хулиганов C++, которые жалуются только на низкую производительность Python, наиболее обоснованная критика Python заключается в том, что его гибкость также является его слабостью, особенно для растущих проектов. Суть аргумента в том, что Python дает разработчикам слишком много свободы. Freedom отлично подходит для быстрого создания работающей программы, но является кошмаром для долгосрочного обслуживания и улучшения проекта.

По сути, Python хорошо подходит для создания «технического долга», когда много рабочего кода может быть создано быстро, но он будет хакерским, хрупким, плохо структурированным и недокументированным. В какой-то момент база кода становится настолько беспорядочной, что внесение значимых дополнений становится невозможным, не сломав что-то еще; долг должен быть погашен позже через рефакторинг.

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

Table of Contents

1. Write As Many Unit Tests As Practical

2. Use Type Annotations and Static Type Checking

3. Use Auto-Formatting Tools

4. Minimize Inheritance, Maximize Composition

5. Choose Immutability Whenever Possible

6. Choose Pure Functions Whenever Possible

7. Break Clean Code Rules Only for Good Reasons

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

1. Напишите как можно больше модульных тестов

Это, пожалуй, самое важное из всех правил. Я работал над базами кода, которые не следовали другим правилам, но имели надежные наборы тестов, которых было достаточно для продуктивной работы.

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

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

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

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

Вот мой подход к тестированию; Я не буду утверждать, что это лучший способ. Принять это или оставить, и найти стратегию, которая работает для вас:

  • Сначала напишите код. «Тестирование» на экспериментальной стадии обычно происходит ситуативно и интерактивно. С Python я мог бы использовать блокнот Jupyter для повторения отдельных частей функциональности.
  • Когда у вас есть класс или функция, которые выглядят стабильно и работают должным образом, разработайте широкий спектр стратегий того, как можно взаимодействовать с их общедоступным API. Для функции это просто размышление о комбинациях входных аргументов (см. также правило 6). Для объектов и классов это может быть сложнее из-за внутреннего состояния объектов (см. правило 6), но в идеале проверяются все общедоступные методы*.
  • Напишите модульные тесты для каждой функции и общедоступного метода примерно в соответствии со следующими шаблонами:
import pytest
from mypackage import my_function, MyClass

@pytest.mark.parametrize(
    "arg1, arg2, expected",
    [
      ("val1_1", "val1_2", "expected1"),  # different argument combinations
      ("val2_1", "val2_2", "expected2"),
    ],
)
def test_my_function(arg1, arg2, expected):
    output = my_function(arg1, arg2)
    assert output == expected  # in case of numpy arrays or pandas dataframes
                               # you may require another comparison method


# grouping tests for methods in a testclass is not strictly necessary
def TestMyClass:
    my_object = MyClass()    

    def test_method_1(self):
        expected = "expected output 1"
        assert my_object.method_1() == expected

    def test_method_2(self):
        expected = "expected output 2"
        assert my_object.method_2() == expected

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

  • Стандартизируйте pytest для тестирования Python. Это довольно хорошо, и большинство разработчиков Python знают, как интерпретировать эти наборы тестов.
  • Не увлекайтесь дебатами о модульных тестах и ​​интеграционных тестах. Сосредоточьтесь на написании тестов, которые снижают неопределенность и повышают уверенность в различных компонентах кода, будь то на высоком или низком уровне.
  • Не зацикливайтесь на 100% тестовом покрытии; это непрактично и недостаточно, чтобы доказать правильность кода. Сосредоточьтесь на разработке стратегии для разумной выборки входных комбинаций и пространств состояний в ваших тестах.

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

Сводка

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

2. Используйте аннотации типов и статическую проверку типов

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

def add(a, b):
    return a + b

может принимать целые числа, числа с плавающей запятой или любой объект, реализующий специальный метод __add__. Аргументы a и b даже не обязательно должны быть одного типа. Например, мы можем добавить константу в массив numpy; Python справляется со всеми сложностями.

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

  • Новые участники, присоединяющиеся к кодовой базе (или люди, которые какое-то время не прикасались к коду), должны потратить много времени на выяснение того, как части сочетаются друг с другом. Какие входные данные допустимы для функции? Если функция выводит пользовательский класс, что с ним делать?
  • В динамически типизированном интерпретируемом языке, таком как Python, вероятность возникновения ошибок времени выполнения увеличивается с каждой новой добавляемой строкой кода. С нетипизированным кодом нет возможности убедиться в его правильности перед запуском. Получают ли функции аргументы, которые имеют смысл? Будет ли работать любая комбинация входных аргументов? Python позволяет вам делать что угодно, пока не окажется в ситуации, когда он не знает, что делать; только тогда он выдаст исключение. Проблема с ошибками времени выполнения заключается в том, что может пройти много времени, прежде чем они появятся.

Таким образом, объявление типов служит двум важным целям: минимальному документированию и обнаружению множества распространенных ошибок до выполнения.

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

def connect_to_server(host, config):
    ...

Человек, написавший это, вероятно, имел в голове четкое представление о том, что должны означать host и config, но инопланетянин, впервые приземлившийся на этой кодовой базе, этого не знает. Теперь им нужно провести детективную работу, чтобы выяснить, как работает эта функция, либо путем интерпретации самой функции, чтобы сделать вывод о том, какие типы аргументов могут быть, либо искать места в коде, где функция вызывается, чтобы увидеть, как она предназначена. использовал. Это только одна функция. Представьте себе большую базу кода со взаимосвязанными классами и функциями, разбросанными по нескольким модулям. Задача выяснить, как все сочетается друг с другом, становится экспоненциально сложнее. Предположим, теперь сигнатура функции выглядит так:

def connect_to_server(
    host: str, 
    config: ConnectionConfig,
) -> Session:
    ...

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

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

def connect_to_server(host, config):
    """Connect to a remote server

    Parameters
    ----------
    host: str
        The host to connect to
    config: ConnectionConfig
        The configuration to pass to the session

    Returns
    -------
    session: Session
        A session
    """
    ...

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

def connect_to_server(
    host: str, 
    config: ConnectionConfig,
) -> Session:
    """Connect to a remote server

    Parameters
    ----------
    host
        The host to connect to
    config
        The configuration to pass to the session

    Returns
    -------
    session
        A session
    """
    ...

До сих пор мы обсуждали подсказки типов только как документацию для разработчиков, но разработчики всегда могут игнорировать их. Статические инструменты проверки типов, такие как mypy, стремятся применять подсказки типов как правила, определяющие правильный код. Mypy просматривает все файлы исходного кода и проверяет, выполняются ли правила типов. Например, все ли типы аргументов, передаваемых в функции, совместимы с сигнатурой функции? Действительно ли определены методы, вызываемые для объекта?

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

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

Mypy не применяет свои правила во время выполнения; в основном это инструмент, помогающий разработчикам. Иногда вам может потребоваться принудительная проверка типов во время выполнения, например, если вы хотите проверить ввод пользователя. Для этого вы можете заглянуть в Pydantic. Компромисс заключается в том, что принудительная проверка типов во время выполнения приводит к снижению производительности.

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

Краткое содержание

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

3. Используйте автоформатирование

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

В Python уже есть стандарт форматирования кода: PEP 8. Многие инструменты проверяют соответствие PEP 8, например flake8 и ruff. Есть также инструменты, которые автоматически форматируют код, наверное, самый популярный из них — B lack. Хотя черный цвет не всегда на 100 % совместим с PEP 8, он часто достаточно хорош, чтобы дать вам приличное форматирование, в котором выигрывает его удобство. Еще одна функция, о которой вы должны знать, — это isort, которая автоматически организует ваш импорт в начале каждого модуля в соответствии с правилами PEP 8.

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

def connect_to_server(
    host: str, 
    config: ConnectionConfig,
) -> Session:

вместо этого:

def connect_to_server(host: str, config: ConnectionConfig) -> Session:

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

Краткое содержание

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

4. Минимизируйте наследование, максимизируйте состав

Теперь мы подходим к несколько самоуверенным. Это может быть спорным для некоторых поклонников ООП, но, увидев (и создав) слишком много злоупотреблений наследованием, я поддерживаю это. Кроме того, не верьте мне на слово; в книге Gang of 4 Шаблоны проектирования, *the* книга по шаблонам проектирования в ООП, сказано: это сначала на стр. 20.

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

class BaseClass:
    def __init__(self, c):
        self.b = self.a
        self.initialize_c(c)

    def do_something(self, arg1):
        return self.do_something_else(arg1)

    def foo(self, arg1, arg2, arg3):
        return arg1, arg2, arg3

    def initialize_c(self, c):
        self.c = c


class ChildClass(BaseClass):
    a = "a variable"

    def __init__(self):
        super().__init__(7)
        
    def do_something_else(self, arg1):
        return self.c, self.b, arg1

    def foo(self, arg1):
        return arg1
    
    
if __name__ == "__main__":
    child = ChildClass()
    print(child.do_something(5))  # -> (7, 'a variable', 5)
    print(child.foo(5))  # -> 5
    print(child.foo(5, 4, 3))  # -> error, overridden

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

  • Ссылки в базовых классах на атрибуты и методы, определенные или реализованные только в дочерних классах.
  • Перегрузка метода в дочернем классе с другой сигнатурой.

Какие проблемы с этим подходом?

  • Неясно, что должно и не должно быть реализовано в дочерних классах. На этот вопрос можно ответить, только изучив родительский класс и некоторые примеры дочерних классов. И наоборот, может быть неясно, почему дочерний класс имеет определенные атрибуты, которые используются только в базовом классе. В чем смысл a и do_something_else в ChildClass ?
  • Из-за (частичных) реализаций в базовом классе неясно, каким может быть состояние дочернего класса в любой момент времени. Если я просто посмотрю на ChildClass, как я узнаю, что self.c существует? От куда это? В этом примере все создается в конструкторе, но я видел ситуации, когда какое-то состояние инициализируется позже каким-то событием; это может быть еще более запутанным.
  • Если я передам экземпляры подклассов BaseClass в другую функцию, как я узнаю, что они ведут себя хорошо? Предположим, что эта другая функция вызывает foo , как она должна работать с разным количеством аргументов? Часто это решается хакерским способом с использованием *args и *kwargs .

Этот пример даже не затрагивает проблемы множественного наследования, когда вам нужно искать детали реализации в нескольких родительских классах или где разные конфликтующие реализации одного и того же метода могут существовать в разных родительских классах. Даже при одиночном наследовании сложно рассуждать о коде. Вопросы типа: «какими атрибутами обладает класс, какое состояние имеет объект и как это состояние будет развиваться в будущем?» трудно ответить с несколькими уровнями наследования и «прыгучим» кодом, как в примере.

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

Так зачем вообще наследование? И почему это так распространено в Python? Большинство любителей наследования злоупотребляют этим как стратегией достижения DRY или не повторяются, поскольку всех учат этому как первому священному принципу кодирования. Мы видим, как метод повторяется в паре классов, что приводит к срабатыванию нашего детектора дублирования. Мы выделяем этот метод в базовый класс, и все готово! Но трещины начинают образовываться, когда нашему новому дочернему классу требуется другая реализация для этого метода. Нет проблем, мы просто перегружаем его! Но это также требует другого аргумента. Ну, это не имеет значения; Python позволяет нам это делать, верно? Модифицируем сигнатуру метода в дочернем классе, и все по-прежнему работает.

Я считаю, что легкость, с которой вы можете испортить наследование в Python и при этом получить работающий код, вероятно, является основной причиной, почему это так распространено; это самый простой способ выделить повторяющиеся элементы. Однако, если вы будете использовать mypy, как я предлагаю в правиле 2, в этот момент он будет жаловаться на несовместимые переопределения. Именно здесь mypy сначала заставит вас удариться головой о стену, прежде чем вы поймете, что вам вообще не следует использовать подобную архитектуру.

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

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

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

from __future__ import annotations
from typing import Protocol, Tuple, TypeVar


T = TypeVar("T")


class Interface(Protocol):
    a: str

    def do_something(self, arg1: int) -> Tuple[int, str, int]:
        ...

    def foo(self, *args: T) -> Tuple[T, ...]:
        ...

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

  • Не вдаваясь в детали реализации, все члены команды знают, что класс должен уметь делать и каков его минимальный внешний API. Это отлично подходит для изучения того, как использовать классы, и для выяснения того, как реализовать подтипы. Без статической проверки типов интерфейсы служат документацией для классов (подобно тому, как подсказки типов являются документацией для методов); со статической проверкой типов интерфейсы являются обязательными контрактами.
  • Вам не нужно наследовать от интерфейса, что означает, что класс может одновременно придерживаться нескольких интерфейсов. Это идеально вписывается в дух утиной печати, лежащей в основе идеологии Python. Например:
from __future__ import annotations
from typing import Protocol


class CanFly(Protocol):
    def fly(self) -> None:
        ...


class CanSwim(Protocol):
    def swim(self) -> None:
        ...


class Duck:
    def fly(self) -> None:
        print("The duck took to the sky!")

    def swim(self) -> None:
        print("The duck swam across the pond!")


class Whale:
    def swim(self) -> None:
        print("The whale swam across the ocean!")


def make_swim(animal: CanSwim) -> None:
    animal.swim()


def make_fly(animal: CanFly) -> None:
    animal.fly()


if __name__ == "__main__":
    duck = Duck()
    whale = Whale()
    make_swim(duck)  # ok
    make_swim(whale)  # ok
    make_fly(duck)  # ok
    make_fly(whale)  # mypy complains

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

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

from __future__ import annotations
from typing import Protocol, Tuple, TypeVar
from dataclasses import dataclass

T = TypeVar("T")


class Interface(Protocol):
    a: str

    def do_something(self, arg1: int) -> Tuple[int, str, int]:
        ...

    def foo(self, *args: T) -> Tuple[T, ...]:
        ...


class Behavior(Protocol):
    b: str
    c: int

    def do_something_else(self, arg: int) -> Tuple[int, str, int]:
        ...

    def foo(self, *args: T) -> Tuple[T, ...]:
        ...


@dataclass
class Behavior1:
    b: str
    c: int

    def do_something_else(self, arg: int) -> Tuple[int, str, int]:
        return self.c, self.b, arg

    def foo(self, *args: T) -> Tuple[T]:
        assert len(args) >= 1
        return (args[0],)


@dataclass
class Behavior2:
    b: str
    c: int
    variable: int

    def do_something_else(self, arg: int) -> Tuple[int, str, int]:
        return self.c + self.variable, self.b, arg

    def foo(self, *args: T) -> Tuple[T, T, T]:
        assert len(args) >= 3
        return (args[0], args[1], args[2])


class MyClass:
    a = "a variable"

    def __init__(self, behavior: Behavior) -> None:
        self.__behavior = behavior

    @classmethod
    def with_behavior1(cls) -> MyClass:
        return cls(Behavior1(cls.a, 7))

    @classmethod
    def with_behavior2(cls) -> MyClass:
        return cls(Behavior2(cls.a, 7, 4))

    @property
    def behavior(self) -> Behavior:
        return self.__behavior

    def do_something(self, arg1: int) -> Tuple[int, str, int]:
        return self.behavior.do_something_else(arg1)

    def foo(self, *args: T) -> Tuple[T, ...]:
        return self.behavior.foo(*args)


def make_it_do_something(obj: Interface) -> None:
    print(obj.do_something(5))


def make_it_foo(obj: Interface, *args: T) -> None:
    print(obj.foo(*args))


if __name__ == "__main__":
    obj_1 = MyClass.with_behavior1()
    obj_2 = MyClass.with_behavior2()
    make_it_do_something(obj_1)  # -> (7, 'a variable', 5)
    make_it_do_something(obj_2)  # -> (11, 'a variable', 5)
    make_it_foo(obj_1, 5)  # -> (5,)
    make_it_foo(obj_2, 5, 4, 3)  # -> (5, 4, 3)

Ключевая идея заключается в том, что мы разделяем логику реализации на Behavior класса. Для этих классов мы также определили интерфейс, который ожидает MyClass. Различные Behavior, переданные в конструктор, изменяют поведение класса, оставаясь при этом совместимыми с Interface. Для удобства мы определили два фабричных метода для создания экземпляра MyClass с различным поведением. Чего мы достигли:

  • Ни в одном из классов или объектов больше нет «скрытого» поведения или состояния.
  • Любой может создавать новые реализации поведения, если они соответствуют интерфейсу Behavior. Поскольку интерфейс явно определен в коде, то минимальные требования очевидны. Аналогично, для классов, совместимых с Interface.

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

Mypy не жалуется на эти сигнатуры методов, но делает количество аргументов, которые должны быть предоставлены foo, неоднозначными. Лучшим подходом является переосмысление метода foo и рассмотрение возможности группировки аргументов в какой-либо тип объекта коллекции.

Конечно, для такого глупого примера этот рефакторинг выглядит нелепо. Мы добавили много шаблонов, чтобы, возможно, получить очень небольшую отдачу. Ценность композиции на самом деле становится очевидной только по мере роста кодовой базы и снижения соотношения шаблонов и реализации. Композиция всегда может включать в себя немного больше шаблонного кода, поскольку мы должны явно определять методы-оболочки несколько раз. Однако обратите внимание, что мы, вероятно, можем удалить большинство дочерних классов из парадигмы, ориентированной на наследование, и объединить их в один класс; Затем индивидуальное поведение достигается путем обращения каждого экземпляра к другим объектам. Это естественным образом приводит к принципу единой ответственности.

Является ли наследство грехом? Нет, для правильной проблемы это может быть правильный ответ. Но я предлагаю вам рассмотреть все другие альтернативы, прежде чем остановиться на нем. Вам это точно не нужно: в Rust нет классов и нет наследования, но система типов по-прежнему допускает гибкий полиморфизм через границы трейтов. Однако отказ от наследования идет вразрез с инстинктами многих программистов Python, поэтому многие будут сопротивляться этому правилу. Что бы вы ни делали, вы должны придерживаться принципа замещения Лискова. Но может быть заманчиво и легко нарушить это правило при использовании наследования в Python. Статическая проверка типов поможет вам найти приемлемые архитектуры.

Краткое содержание

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

5. Выбирайте неизменность, когда это возможно

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

Языки программирования, такие как Rust, очень серьезно относятся к этой проблеме и предполагают, что все данные по умолчанию неизменяемы; вы должны явно добавить ключевые слова mutили &mut, чтобы разрешить изменение переменной или аргумента функции. К сожалению, в Python этого нет, и мы должны довольствоваться следующими вариантами:

  • Используйте подсказки типа Final[T] для переменных с типом T всякий раз, когда эта переменная больше не может быть видоизменена. Статическая проверка типов должна отсеивать нарушения. Однако кажется, что это не работает, когда T является изменяемым типом, таким как список или словарь; это не касается внутренней изменчивости.
  • Если вам нужно передавать коллекции данных, используйте типы данных, которые по умолчанию являются неизменяемыми (например, кортежи), а не изменяемые типы данных (например, списки). Аналогично, в качестве альтернативы dict может быть NamedTuple. Вы также можете посмотреть на dataclass и возможность сделать его frozen.

Краткое содержание

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

6. По возможности выбирайте чистые функции

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

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

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

Есть два способа создания чистых функций с помощью Python:

  • Определите их вне класса.
  • Определите метод класса как classmethod или staticmethod.

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

Краткое содержание

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

7. Нарушайте правила чистого кода только по уважительной причине

Советов по написанию читаемого и поддерживаемого кода намного больше, чем я мог бы включить в этот список. Коллекцию можно найти в популярной книге Роберта К. Мартина Чистый код. Книга очень ориентирована на Java, но многие уроки переносятся на любой язык ООП. Для нетерпеливых хорошее резюме правил можно найти в this GitHub Gist.

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

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

Краткое содержание

Написание хорошего кода — это нечто большее, чем то, что я могу перечислить в этом списке. Книга Чистый код — хорошая отправная точка для вдохновения, но не интерпретируйте ее как верховенство закона. Будьте прагматичны и взвешивайте все за и против.

Want to Connect?

I'm a researcher, data scientist, and scientific software developer at VITO. 

I've also worked as a data engineer and researcher in experimental physics. 

Any opinions expressed in these pieces are solely my own, and do not reflect 
those of my current or past employers. 

Check out my blog, where I occasionally write about random things 
that interest me.