Обеспечение чистого кода: взгляд на Python, параметризованный
Опубликовано: 2022-03-11В этом посте я собираюсь рассказать о том, что я считаю наиболее важной техникой или шаблоном для создания чистого Pythonic-кода, а именно о параметризации. Этот пост для вас, если:
- Вы относительно плохо знакомы со всеми шаблонами проектирования и, возможно, немного сбиты с толку длинными списками имен шаблонов и диаграмм классов. Хорошая новость заключается в том, что на самом деле существует только один шаблон проектирования Python, который вам обязательно нужно знать. Более того, вы, вероятно, уже знаете это, но, возможно, не все способы его применения.
- Вы пришли к Python с другого языка ООП, такого как Java или C#, и хотите знать, как перевести свои знания о шаблонах проектирования с этого языка на Python. В Python и других динамически типизированных языках многие шаблоны, распространенные в статически типизированных языках ООП, «невидимы или проще», как выразился автор Питер Норвиг.
В этой статье мы рассмотрим применение «параметризации» и то, как она может быть связана с основными шаблонами проектирования, известными как внедрение зависимостей , стратегия , метод шаблона , абстрактная фабрика , фабричный метод и декоратор . В Python многие из них оказываются простыми или становятся ненужными из-за того, что параметры в Python могут быть вызываемыми объектами или классами.
Параметризация — это процесс получения значений или объектов, определенных в функции или методе, и превращения их в параметры этой функции или метода с целью обобщения кода. Этот процесс также известен как рефакторинг «извлечение параметра». В некотором смысле эта статья посвящена шаблонам проектирования и рефакторингу.
Простейший случай параметризованного Python
Для большинства наших примеров мы будем использовать модуль черепахи из стандартной библиотеки для обучения графике.
Вот некоторый код, который будет рисовать квадрат 100x100, используя turtle
:
from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)
Предположим, теперь мы хотим нарисовать квадрат другого размера. У начинающего программиста на этом этапе возникло бы искушение скопировать и вставить этот блок и изменить его. Очевидно, гораздо лучшим методом было бы сначала извлечь код рисования квадрата в функцию, а затем сделать размер квадрата параметром этой функции:
def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)
Итак, теперь мы можем рисовать квадраты любого размера с помощью draw_square
. Вот и все, что касается основной техники параметризации, и мы только что увидели первое ее основное применение — исключение программирования с копированием и вставкой.
Непосредственная проблема с приведенным выше кодом заключается в том, что draw_square
зависит от глобальной переменной. Это имеет много плохих последствий, и есть два простых способа исправить это. Во-первых, для draw_square
будет создан сам экземпляр Turtle
(о чем я расскажу позже). Это может быть нежелательно, если мы хотим использовать одну Turtle
для всего нашего рисунка. Итак, сейчас мы снова воспользуемся параметризацией, чтобы сделать turtle
параметром для draw_square
:
from turtle import Turtle def draw_square(turtle, size): for i in range(0, 4): turtle.forward(size) turtle.left(90) turtle = Turtle() draw_square(turtle, 100)
Это имеет причудливое название — внедрение зависимостей. Это просто означает, что если функции нужен какой-то объект для выполнения своей работы, например, draw_square
требуется Turtle
, вызывающая сторона несет ответственность за передачу этого объекта в качестве параметра. Нет, правда, если вам когда-нибудь было интересно узнать о внедрении зависимостей Python, то вот оно.
До сих пор мы имели дело с двумя очень простыми способами использования. Ключевым наблюдением для остальной части этой статьи является то, что в Python существует широкий спектр вещей, которые могут стать параметрами — больше, чем в некоторых других языках, — и это делает его очень мощной техникой.
Все, что является объектом
В Python вы можете использовать эту технику для параметризации всего, что является объектом, а в Python большинство вещей, с которыми вы сталкиваетесь, на самом деле являются объектами. Это включает в себя:
- Экземпляры встроенных типов, таких как строка
"I'm a string"
и целое число42
или словарь. - Экземпляры других типов и классов, например, объект
datetime.datetime
- Функции и методы
- Встроенные типы и пользовательские классы
Последние два вызывают наибольшее удивление, особенно если вы работаете с других языков, и их нужно обсудить подробнее.
Функции как параметры
Оператор функции в Python делает две вещи:
- Он создает функциональный объект.
- Он создает в локальной области имя, указывающее на этот объект.
Мы можем поиграть с этими объектами в REPL:
> >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'
И, как и все объекты, мы можем назначать функции другим переменным:
> >> bar = foo > >> bar() 'Hello from foo'
Обратите внимание, что bar
— это другое имя для того же объекта, поэтому у него то же самое внутреннее свойство __name__
, что и раньше:
> >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>
Но решающим моментом является то, что, поскольку функции — это просто объекты, везде, где вы видите функцию, она может быть параметром.
Итак, предположим, мы расширили нашу функцию рисования квадратов выше, и теперь иногда, когда мы рисуем квадраты, мы хотим делать паузы в каждом углу — вызов time.sleep()
.
Но предположим, иногда мы не хотим делать паузу. Самый простой способ добиться этого — добавить параметр pause
, возможно, с нулевым значением по умолчанию, чтобы по умолчанию мы не делали паузу.
Однако позже мы обнаруживаем, что иногда нам действительно хочется сделать что-то совершенно другое в углах. Возможно, мы хотим нарисовать другую фигуру в каждом углу, изменить цвет пера и т. д. У нас может возникнуть соблазн добавить еще много параметров, по одному для каждой вещи, которую нам нужно сделать. Однако гораздо более приятным решением было бы разрешить передачу любой функции в качестве действия, которое необходимо выполнить. По умолчанию мы сделаем функцию, которая ничего не делает. Мы также заставим эту функцию принимать локальные параметры turtle
и size
, если они потребуются:
def do_nothing(turtle, size): pass def draw_square(turtle, size, at_corner=do_nothing): for i in range(0, 4): turtle.forward(size) at_corner(turtle, size) turtle.left(90) def pause(turtle, size): time.sleep(5) turtle = Turtle() draw_square(turtle, 100, at_corner=pause)
Или мы могли бы сделать что-то более крутое, например, рекурсивно рисовать квадраты меньшего размера в каждом углу:
def smaller_square(turtle, size): if size < 10: return draw_square(turtle, size / 2, at_corner=smaller_square) draw_square(turtle, 128, at_corner=smaller_square)
Есть, конечно, вариации этого. Во многих примерах будет использоваться возвращаемое значение функции. Здесь у нас более императивный стиль программирования, и функция вызывается только из-за ее побочных эффектов.
На других языках…
Наличие первоклассных функций в Python делает это очень простым. В языках, в которых их нет, или в некоторых языках со статической типизацией, требующих сигнатур типов для параметров, это может быть сложнее. Как бы мы это сделали, если бы у нас не было функций первого класса?
Одним из решений было бы превратить draw_square
в класс SquareDrawer
:
class SquareDrawer: def __init__(self, size): self.size = size def draw(self, t): for i in range(0, 4): t.forward(self.size) self.at_corner(t, size) t.left(90) def at_corner(self, t, size): pass
Теперь мы можем создать подкласс SquareDrawer
и добавить метод at_corner
, который делает то, что нам нужно. Этот шаблон Python известен как шаблон метода шаблона — базовый класс определяет форму всей операции или алгоритма, а различные части операции помещаются в методы, которые должны быть реализованы подклассами.
Хотя это иногда может быть полезно в Python, извлечение вариантного кода в функцию, которая просто передается в качестве параметра, часто оказывается намного проще.
Второй способ, которым мы могли бы решить эту проблему в языках без функций первого класса, состоит в том, чтобы обернуть наши функции как методы внутри классов, например:
class DoNothing: def run(self, turtle, size): pass def draw_square(turtle, size, at_corner=DoNothing()): for i in range(0, 4): turtle.forward(size) at_corner.run(turtle, size) t.left(90) class Pauser: def run(self, turtle, size): time.sleep(5) draw_square(turtle, 100, at_corner=Pauser())
Это известно как шаблон стратегии. Опять же, это, безусловно, допустимый шаблон для использования в Python, особенно если класс стратегии фактически содержит набор связанных функций, а не одну. Однако часто все, что нам действительно нужно, — это функция, и мы можем перестать писать классы.
Другие вызываемые объекты
В приведенных выше примерах я говорил о передаче функций в другие функции в качестве параметров. Однако все, что я написал, на самом деле верно для любого вызываемого объекта. Функции — самый простой пример, но мы также можем рассмотреть методы.
Предположим, у нас есть список foo
:
foo = [1, 2, 3]
Теперь к foo
привязана целая куча методов, таких как .append()
и .count()
. Эти «связанные методы» можно передавать и использовать как функции:
> >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]
В дополнение к этим методам экземпляра существуют другие типы вызываемых объектов — статические методы и classmethods
, экземпляры классов, которые реализуют staticmethods
, и __call__
классы/типы.

Классы как параметры
В Python классы относятся к «первому классу» — они являются объектами времени выполнения точно так же, как словари, строки и т. д. Это может показаться даже более странным, чем функции, являющиеся объектами, но, к счастью, на самом деле это легче продемонстрировать, чем для функций.
Знакомый вам оператор class — хороший способ создания классов, но не единственный — мы также можем использовать версию типа с тремя аргументами. Следующие два оператора делают одно и то же:
class Foo: pass Foo = type('Foo', (), {})
Во второй версии обратите внимание на две вещи, которые мы только что сделали (которые удобнее делать с помощью оператора class):
- Справа от знака равенства мы создали новый класс с внутренним именем
Foo
. Это имя, которое вы получите, если сделаетеFoo.__name__
. - С помощью присваивания мы создали в текущей области имя Foo, которое ссылается на только что созданный объект класса.
Мы сделали те же наблюдения относительно того, что делает инструкция function.
Ключевым моментом здесь является то, что классы — это объекты, которым можно присвоить имена (т. е. их можно поместить в переменную). Везде, где вы видите используемый класс, вы на самом деле просто видите используемую переменную. И если это переменная, она может быть параметром.
Мы можем разбить это на несколько вариантов использования:
Классы как фабрики
Класс — это вызываемый объект, который создает экземпляр самого себя:
> >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>
И как объект его можно присвоить другим переменным:
> >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>
Возвращаясь к нашему примеру с черепахой выше, одна из проблем с использованием черепах для рисования заключается в том, что положение и ориентация рисунка зависят от текущего положения и ориентации черепахи, и это также может оставить ее в другом состоянии, что может быть бесполезным для звонящий. Чтобы решить эту проблему, наша функция draw_square
могла бы создать собственную черепашку, переместить ее в нужное положение, а затем нарисовать квадрат:
def draw_square(x, y, size): turtle = Turtle() turtle.penup() # Don't draw while moving to the start position turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)
Однако теперь у нас есть проблема с настройкой. Предположим, вызывающая сторона хочет установить некоторые атрибуты черепахи или использовать другую черепаху с таким же интерфейсом, но с особым поведением?
Мы могли бы решить эту проблему с помощью внедрения зависимостей, как делали это раньше — вызывающая сторона отвечала бы за настройку объекта Turtle
. Но что, если нашей функции иногда нужно создать много черепах для разных целей рисования или, возможно, она хочет запустить четыре потока, каждый со своей собственной черепахой, чтобы нарисовать одну сторону квадрата? Ответ прост: сделать класс Turtle параметром функции. Мы можем использовать аргумент ключевого слова со значением по умолчанию, чтобы упростить задачу для вызывающих абонентов, которым все равно:
def draw_square(x, y, size, make_turtle=Turtle): turtle = make_turtle() turtle.penup() turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)
Чтобы использовать это, мы могли бы написать функцию make_turtle
, которая создает черепаху и изменяет ее. Предположим, мы хотим скрыть черепаху при рисовании квадратов:
def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)
Или мы могли бы создать подкласс Turtle
, чтобы сделать это поведение встроенным, и передать подкласс в качестве параметра:
class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)
На других языках…
В некоторых других языках ООП, таких как Java и C#, отсутствуют первоклассные классы. Чтобы создать экземпляр класса, вы должны использовать ключевое слово new
, за которым следует фактическое имя класса.
Это ограничение является причиной таких шаблонов, как абстрактная фабрика (которая требует создания набора классов, единственной задачей которых является создание экземпляров других классов) и шаблона Factory Method. Как видите, в Python это просто вопрос извлечения класса в качестве параметра, потому что класс — это собственная фабрика.
Классы как базовые классы
Предположим, мы создаем подклассы, чтобы добавить одну и ту же функцию к разным классам. Например, нам нужен подкласс Turtle
, который будет записывать в журнал при его создании:
import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")
Но затем мы обнаруживаем, что делаем то же самое с другим классом:
class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")
Единственное, что различается между этими двумя, это:
- Базовый класс
- Имя подкласса — но нас это не особо волнует, и мы можем сгенерировать его автоматически из атрибута
__name__
базового класса. - Имя, используемое внутри вызова
debug
, но опять же, мы могли бы сгенерировать его из имени базового класса.
Столкнувшись с двумя очень похожими фрагментами кода только с одним вариантом, что мы можем сделать? Как и в нашем самом первом примере, мы создаем функцию и вытаскиваем вариантную часть в качестве параметра:
def make_logging_class(cls): class LoggingThing(cls): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("{0} got created".format(cls.__name__)) LoggingThing.__name__ = "Logging{0}".format(cls.__name__) return LoggingThing LoggingTurtle = make_logging_class(Turtle) LoggingHippo = make_logging_class(Hippo)
Здесь у нас есть демонстрация классов первого класса:
- Мы передали класс в функцию, дав параметру обычное имя
cls
, чтобы избежать конфликта с ключевым словомclass
(вы также увидите, чтоclass_
иklass
используются для этой цели). - Внутри функции мы создали класс — обратите внимание, что каждый вызов этой функции создает новый класс.
- Мы вернули этот класс в качестве возвращаемого значения функции.
Мы также устанавливаем LoggingThing.__name__
, что является совершенно необязательным, но может помочь при отладке.
Другое применение этой техники — когда у нас есть целая куча функций, которые мы иногда хотим добавить к классу, и мы можем захотеть добавить различные комбинации этих функций. Создание всех необходимых комбинаций вручную может стать очень громоздким.
В языках, где классы создаются во время компиляции, а не во время выполнения, это невозможно. Вместо этого вы должны использовать шаблон декоратора. Этот шаблон иногда может быть полезен в Python, но в основном вы можете просто использовать технику, описанную выше.
Обычно я избегаю создания большого количества подклассов для настройки. Обычно существуют более простые и более питонические методы, которые вообще не используют классы. Но эта техника доступна, если вам это нужно. См. также полное описание шаблона декоратора в Python от Брэндона Роудса.
Классы как исключения
Еще одно место, где вы видите, как используются классы, находится в предложении exclude инструкции try/ except
/finally. Неудивительно, что мы можем параметризовать и эти классы.
Например, следующий код реализует очень общую стратегию попытки выполнить действие, которое может завершиться ошибкой, и повторной попытки с экспоненциальной отсрочкой до тех пор, пока не будет достигнуто максимальное количество попыток:
import time def retry_with_backoff(action, exceptions_to_catch, max_attempts=10, attempts_so_far=0): try: return action() except exceptions_to_catch: attempts_so_far += 1 if attempts_so_far >= max_attempts: raise else: time.sleep(attempts_so_far ** 2) return retry_with_backoff(action, exceptions_to_catch, attempts_so_far=attempts_so_far, max_attempts=max_attempts)
Мы вытащили как действие, которое нужно выполнить, так и исключения, которые нужно перехватить, в качестве параметров. Параметр exceptions_to_catch
может быть либо одним классом, таким как IOError
или httplib.client.HTTPConnectionError
, либо кортежем таких классов. (Мы хотим избежать предложений «голые исключения» или даже except Exception
, потому что известно, что это скрывает другие ошибки программирования).
Предупреждения и заключение
Параметризация — это мощный метод повторного использования кода и уменьшения его дублирования. Он не лишен некоторых недостатков. В погоне за повторным использованием кода часто возникает несколько проблем:
- Чрезмерно общий или абстрактный код, который становится очень трудно понять.
- Код с большим количеством параметров, которые затемняют общую картину или вносят ошибки, потому что в действительности должным образом тестируются только определенные комбинации параметров.
- Бесполезное соединение различных частей кодовой базы, потому что их «общий код» был вынесен в одно место. Иногда код в двух местах похож только случайно, и эти два места должны быть независимы друг от друга, потому что они могут нуждаться в независимом изменении.
Иногда немного «дублированного» кода намного лучше, чем эти проблемы, поэтому используйте эту технику с осторожностью.
В этом посте мы рассмотрели шаблоны проектирования, известные как внедрение зависимостей , стратегия , метод шаблона , абстрактная фабрика , метод фабрики и декоратор . В Python многие из них действительно оказываются простым применением параметризации или определенно становятся ненужными из-за того факта, что параметры в Python могут быть вызываемыми объектами или классами. Надеюсь, это поможет облегчить концептуальную нагрузку «вещей, которые вы должны знать как настоящий разработчик Python» и позволит вам писать лаконичный код на языке Python!
Дальнейшее чтение:
- Шаблоны дизайна Python: для гладкого и модного кода
- Шаблоны Python: для шаблонов проектирования Python
- Ведение журнала Python: подробное руководство