19 декабря 2020

⛱️ Как правильно ничего не делать в Python: инструкция pass

Пишу, перевожу и иллюстрирую IT-статьи. На proglib написал 140 материалов. Увлекаюсь Python, вебом и Data Science. Открыт к диалогу – ссылки на соцсети и мессенджеры: https://matyushkin.github.io/links/ Если понравился стиль изложения, упорядоченный список публикаций — https://github.com/matyushkin/lessons
Говорят, что в Python нет ничего проще ключевого слова pass – всего лишь инструкция «ничего не делать», чтобы соблюсти синтаксис языка. Однако не всегда pass служит заглушкой – есть и более интересные применения.
⛱️ Как правильно ничего не делать в Python: инструкция pass

Публикация представляет собой сокращенный перевод статьи Моше Задка The pass Statement: How to Do Nothing in Python.

***

В Python ключевое слово pass – самостоятельная инструкция, которая буквально ничего не делает. Она даже отбрасывается на этапе компиляции байт-кода. В чем же толк от такого оператора-бездельника?

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

В этом туториале мы изучим:

  • что собой представляет pass и чем полезна эта инструкция;
  • как использовать pass в продакшене;
  • как применять pass в разработке;
  • какие есть альтернативы этой инструкции и когда их следует использовать.

Python и синтаксис pass

Синтаксис Python предполагает, что в некоторых случаях после двоеточия новые блоки кода идут с отступом. Например, после объявления цикла for или условия if:

        >>> for x in [1, 2, 3]:
...     y = x + 1
...     print(x, y)
...
1 2
2 3
3 4
    

Тело условия или цикла не может быть пустым:

        >>> if 1 + 1 == 3:
...
  File "<stdin>", line 2

    ^
IndentationError: expected an indented block

    

Чтобы структура кода осталась корректной, нужно использовать инструкцию pass:

        >>> if 1 + 1 == 3:
...     pass
...

    

В первом случае из-за невалидного синтаксиса вызывается исключение, во втором – pass позволяет соблюсти требования Python.

🐍 Библиотека питониста
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека питониста»
🐍🎓 Библиотека собеса по Python
Подтянуть свои знания по Python вы можете на нашем телеграм-канале «Библиотека собеса по Python»
🐍🧩 Библиотека задач по Python
Интересные задачи по Python для практики можно найти на нашем телеграм-канале «Библиотека задач по Python»

Временное использование pass

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

Будущий код

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

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

        def get_and_save_middle(data, fname):
    middle = data[len(data)//3:2*len(data)//3]
    save_to_file(middle, fname)
    return middle

    

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

Можно закомментировать вызов save_to_file(), но тогда придется держать в уме: это не просто комментарий – соответствующую функцию когда-то придется реализовать. Лучше сделать заготовку сразу же:

        def save_to_file(data, fname):
    pass # TODO: заполнить позже

    

Теперь функцию get_and_save_middle() можно тестировать.

Другой вариант использования pass – когда мы пишем сложную структуру управления потоком и нужен заполнитель для будущего кода. Например, для реализации fizz-buzz полезно сначала набросать структуру кода:

        if idx % 15 == 0:
    pass # Fizz-Buzz
elif idx % 3 == 0:
    pass # Fizz
elif idx % 5 == 0:
    pass # Buzz
else:
    pass # Idx

    

Такие структурные скелеты выстраивают логику и порядок ветвления. В приведенном примере первый оператор if должен проверять делимость на 15, потому что любое число, которое делится на 15, также делится на 5 и 3. Предварительное понимание общей структуры полезно независимо от реализации конкретного вывода.

После того как вы прониклись логикой задачи, можно решить, будет ли использоваться print() прямо в коде:

        def fizz_buzz(idx):
    if idx % 15 == 0:
        print("fizz-buzz")
    elif idx % 3 == 0:
        print("fizz")
    elif idx % 5 == 0:
        print("buzz")
    else:
        print(idx)

    

Функция напрямую печатает строки, однако из-за этого ее будет неудобно тестировать. Разумная альтернатива – написать функцию, возвращающую строковое значение:

        def fizz_buzz(idx):
    if idx % 15 == 0:
        return "fizz-buzz"
    elif idx % 3 == 0:
        return "fizz"
    elif idx % 5 == 0:
        return "buzz"
    else:
        return str(idx)

    

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

Подход полезен и при написании классов. Если вы пока не до конца понимаете предметную область, используйте pass, чтобы сначала набросать макет и представить архитектуру.

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

        class Candy:
    pass

    

Такой код даже позволит создавать экземпляры класса.

Закомментированный код

Если у вас есть условие if… else, бывает полезно закомментировать одну из ветвей. В следующем примере expensive_computation() запускает длительно выполняющийся код, например, перемножение больших массивов чисел. В процессе отладки может потребоваться временно закомментировать вызов expensive_computation().

        def process(context, input_value):
    if input_value is not None:
        expensive_computation(context, input_value)
    else:
        logging.info("skipping expensive: %s", input_value)

    

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

        def process(context, input_value):
    if input_value is not None:
        # Временно невыполняемые длительные расчеты
        # expensive_computation(context, input_value)
        # Добавляем pass, чтобы сделать код валидным
        pass
    else:
        logging.info("skipping expensive: %s", input_value)

    

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

Маркеры для отладчиков

Запуская код в отладчике, можно установить маркер на позиции, где отладчик остановится и позволит проверить состояние программы. Многие отладчики допускают выставить точку останова, которая срабатывает только при выполнении условия. Например, можно установить точку останова в цикле for так, чтобы отладчик срабатывал, только если переменная имеет значение None. Так можно увидеть, почему этот случай обрабатывается неправильно. Например, в следующей строке отладчик срабатывает, если строка является палиндромом.

        for line in filep:
    if line == line[::-1]:
        pass  # Устанавливаем здесь breakpoint
    process(line)

    

Хотя инструкция pass ничего не делает, она позволяет установить здесь маркер. Теперь код можно запустить в отладчике и отлавливать строки-палиндромы.

Пустые функции и методы

Распространенная ситуация – код определяет класс, наследуемый от класса, требуется переопределение метода. Вполне вероятно, что новый метод не должен делать или ему даже нужно запретить что-то делать:

        >>> def ignore_arguments(record, status):
...     pass
...

    
        class DiscardingIO:
    def write(self, data):
        pass

    

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

Пустые классы на примере исключений

Python поддерживает концепцию наследования исключений. Например, встроенное исключение LookupError является родительским для KeyError:

        >>> empty={}
>>> try:
...     empty["some key"]
... except LookupError as exc:
...     print("got exception", repr(exc))
...
got exception KeyError('some key')
>>> issubclass(KeyError, LookupError)
True

    

При поиске несуществующего ключа в словаре возникает исключение KeyError. Исключение KeyError перехватывается, хотя в инструкции except указано LookupError. Так происходит потому, что KeyError является подклассом LookupError.

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

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

  1. Не менее 8 символов.
  2. По крайней мере один символ – цифра.
  3. По крайней мере один специальный символ (?!. и др.).
Примечание
Этот пример предназначен исключительно для иллюстрации семантики и методов Python. Для получения дополнительной информации изучите рекомендации Национального института стандартов и технологий (NIST) и исследования, на которых они основаны.

Каждая из соответствующих ошибок должна вызывает собственное исключение. Следующий код реализует указанные правила:

password_checker.py
        class InvalidPasswordError(ValueError):
    pass

class ShortPasswordError(InvalidPasswordError):
    pass

class NoNumbersInPasswordError(InvalidPasswordError):
    pass

class NoSpecialInPasswordError(InvalidPasswordError):
    pass

def check_password(password):
    if len(password) < 8:
        raise ShortPasswordError(password)
    for n in "0123456789":
        if n in password:
            break
    else:
        raise NoNumbersInPasswordError(password)
    for s in "?!.":
        if s in password:
            break
    else:
        raise NoSpecialInPasswordError(password)

    

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

        >>> from password_checker import check_password
>>> def friendly_check(password):
...     try:
...         check_password(password)
...     except InvalidPasswordError as exc:
...         print("Invalid password", repr(exc))
...
>>> friendly_check("hello")
Invalid password ShortPasswordError('hello')
>>> friendly_check("helloworld")
Invalid password NoNumbersInPasswordError('helloworld')
>>> friendly_check("helloworld1")
Invalid password NoSpecialInPasswordError('helloworld1')

    

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

Маркирующие методы

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

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

        import abc

class Origin(abc.ABC):
    @abc.abstractmethod
    def description(self):
        # Этот метод никогда не будет вызван
        pass

class NotLoggedIn(Origin):
    def description(self):
        return "unauthenticated connection"

class LoggedIn(Origin):
    def description(self):
        return "authenticated connection"

    
Примечание
Название модуля стандартной библиотеки Python abc соответствует сокращению от abstract base classes. Модуль помогает определять классы, которые не предназначены для создания экземпляров, а служат базой для других классов.

Хотя реалистичный класс Origin выглядел бы сложнее, в этом примере показана его основа. Метод Origin.description() никогда не будет вызван – все подклассы его переопределяют.

        >>> Origin()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Origin with abstract...
>>> logged_in.description()
'authenticated connection'
>>> not_logged_in.description()
'unauthenticated connection'

    

Классы с декораторами методов abstractmethod не могут быть созданы. Любой объект, имеющий Origin в качестве суперкласса, будет экземпляром класса, который переопределяет description(). Из-за этого тело в Origin.description() не имеет значения и его можно заменить инструкцией pass.

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

Альтернативы pass

Инструкция pass – не единственный способ «ничего не делать». Любое выражение в Python это валидная инструкция, как и любая константа. Фактически следующие инструкции тоже сами по себе ничего не делают:

  • None
  • True
  • 0
  • "hello I do nothing"

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

Docstrings

Есть одно важное исключение из идиомы использования pass в качестве инструкции бездействия. В классах, функциях и методах использование константного строкового выражения приведет к тому, что выражение будет использоваться как атрибут объекта .__ doc__. Этот атрибут используется функцией интерпретатора help(), различными генераторами документации и многими IDE.

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

        class StringReader(Protocol):
      def read(self, length: int) -> str:
          """
          Считывает строку.
          """

class Origin(abc.ABC):
    @abc.abstractmethod
    def description(self):
        """
        Человекочитаемое описание источника.
        """

class TooManyOpenParens(ParseError):
    """
    Не все круглые скобки закрыты.
    """

class DiscardingIO:
    def write(self, data):
        """
        Игнорируем данные.
        """

    
Примечание
Строки документации кода обычно описывают код более тщательно, чем приведенные примеры.

Во всех приведенных случаях строка документации делает код понятнее, а работу с ним – более удобной.

Ellipsis

В pyi-файлах рекомендуется использовать в качестве выражения многоточие (...). Эта константа определяется с помощью синглтона Ellipsis:

        >>> ...
Ellipsis
>>> x = ...
>>> type(x), x
(<class 'ellipsis'>, Ellipsis)

    

Первоначально объект Ellipsis использовался для создания многомерных срезов. Однако теперь это также рекомендуемый синтаксис для заполнения блоков в stub-файлах c расширением .pyi:

        # В `.pyi` файле:
def add(a: int, b: int)-> int:
    ...

    

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

Вызов исключения

В тех случаях, когда функции или методы пусты, потому что они никогда не выполняются, иногда лучшим телом будет вызов исключения raise NotImplementedError("this should never happen"). Вызов исключения в этом случае даст дополнительную информацию.

Перманентное использование pass

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

Применение pass в try ... except

При использовании try ... except для определенных исключений нет необходимости как-либо его обрабатывать – главное, чтобы программа продолжала работу. В такой ситуации pass как нельзя кстати.

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

        import os

def ensure_nonexistence(fname):
    try:
        os.remove(fname)
    except FileNotFoundError:
        pass

    

При вызове исключения FileNotFoundError используем pass, чтобы не блокировать остальной код.

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

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

Вы также можете использовать диспетчер контекста contextlib.suppress() для подавления исключения. Если нужно обрабатывать одни исключения, игнорируя другие, то проще использовать инструкцию pass.

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

        import os
import shutil

def ensure_nonexistence(fname):
    try:
       os.remove(fname)
    except FileNotFoundError:
       pass
    except IsADirectoryError:
       shutil.rmtree(fname)

    

Здесь происходит игнорирование исключения FileNotFoundError и обработка исключения IsADirectoryError.

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

Использование pass в цепочках if … elif

При обработке длинных цепочек if… elif в каком-то из вариантов бывает просто ничего не нужно делать, но вы не можете пропустить этот elif, потому что без него результат станет некорректным.

Представьте, что рекрутер попросил вас написать fizz-buzz с такимиусловиями:

  1. Если число делится на 20, программа должна печать "twist".
  2. В остальных случаях, если число делится на 15, ничего не печатать.
  3. В остальных случаях, если число делится на 5, печатать "fizz".
  4. В остальных случаях, если число делится на 3, печатать "buzz".
  5. Во всех остальных случаях печатать само число.

Как и во всех вопросах по кодингу, есть много способов решить эту проблему. Один из них – использовать цикл for с цепочкой, которая имитирует само описание:

        for x in range(100):
    if x % 20 == 0:
       print("twist")
    elif x % 15 == 0:
       pass
    elif x % 5 == 0:
       print("fizz")
    elif x % 3 == 0:
       print("buzz")
    else:
       print(x)

    

Цепочка if elif отражает логику перехода между ограничениями задачи. В этом примере, если мы полностью удалим выражение if x % 15, мы изменим поведение программы. То есть такой вариант использования оператора pass позволяет не только решить задачу, но и сохранить логику кода в соответствии с логикой задачи.

Заключение

Теперь вы знаете, что собой представляет инструкция pass. Вы готовы использовать ее для повышения скорости разработки и отладки кода на Python.

Если вам понравился материал этой статьи, обратите внимание на следующие публикации:

Источники

МЕРОПРИЯТИЯ

Комментарии

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ