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

Спецификации и соглашения

Будем говорить о спецификации и соглашениях (конвенциях). И те, и другие — правила, которым следуют, когда пишут код. Разница в том, что спецификации как законы физики, их нельзя нарушить, а соглашения нарушать можно. Пример соглашения - стиль кода PEP8.

Общепринятые соглашения

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

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

Например, соглашение, которое я выучил через сообщение линтера: Не используйте f-strings в конструкторе исключения, так как строка с исходным кодом пишется в логи рядом с самим сообщением и получается дублирование. Это может ухудшить чтение логов (https://docs.astral.sh/ruff/rules/f-string-in-exception/). Код стал чуть лучше и вы на это ничего не потратили. Порог вхождения нулевой, вам не надо знать это правило и инструменты об этом расскажут. На этапе ревью, я никогда не буду просить это исправить, слишком незначительно. Положительный эффект от некоторых соглашений будет только при полной автоматизации.

У меня был положительный опыт использования автомтизации, когда я обучал людей программированию на Питон с нуля. Когда студенты делают первые шаги в программировании, на них сваливается большой объём информации. Чтобы не грузить правилами оформления, я создал шаблон проекта на GitHub, в котором уже подключены и настроены линтеры и форматеры. Домашние задачи сдавались как пул-реквест, на котором автоматом запускались проверки. В результате, я никогда не видел плохо отформатированного кода или обычных ошибок новичков у студентов. При ревью я тратил время на концепции Питона, алгоритмы и особенности логики программы. Я использовал упрощенную версию личного шаблона для новых проектов project_template.

Имена в Питоне

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

Примеры атрибутов:

module_scope_variable_name = "Hello"


def function_name(argument_name):
    local_variable_name = argument_name

    
class ClassName(ParentName):
    def method_name(self, argument_name):
        self.attribute_name = argument_name

Подчёркивание в именах

Подчёркивание посреди имени

def underscore_helps_you_to_read_names_composed_from_many_words():
    this_is_a_good_practice = True
    it_is_called_snake_case = True
    comparetothisname = False
    cameCaseIsAnAlternative = True

    
IS_SCREAMING_SNAKE_CASE = True

Спецификация

Подчёркивание — один из символов, которые можно использовать в именах.

Соглашение

Подчёркивание упрощает чтение словосочетаний в именах за счет визуального разделения между словам, например: сказочное_бали. Если убрать подчёркивание, то будет возможны разные прочтения. Такой стиль называется змеиный_регистр (snake_case), потому что он похож на змею, которая ползёт. Соглашение о стиле для внутренних библиотек в Питоне (PEP8) рекомендует использовать змеиный регистр для названий переменных, функций и методов. Змеиный_регистр — не единственный способ улучшать читаемость имен. Кроме него существует верблюжийРегистр, сamelCase и РегистрКакВПаскале PascalCase. PascalCase используется в Питоне, но для имен классов.

Подчёркивание в начале имён (атрибуты)

Атрибутами являются элементы доступные снаружи. Функции, классы и переменные являются атрибутами модуля. Методы и переменные класса — атрибуты класса. Локальные переменные не являются атрибутами, так как не видны снаружи. Простое правило: если вы можете получить доступ через точку - это атрибут. например math.log, где функция log - атрибут модуля math

def _function():
    ...

    
class Foo():
    def _foo(self):
        ...
    def foo():
        self._foo()

Спецификация

При импорте через * имена с нижним подчёркиванием не импортируются. Сразу оговорюсь, что импроты со звёздочкой не рекомендуется использовать, так как они захламляют пространство имён.

Соглашение

Имена начинающиеся с подчёркивания — для внутреннего (internal) использования. Имя функции _function() говорит нам, что не стоит использовать её вне модуля. В документации можно встретить еще один синоним «непубличные».

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

Инкапсуляция

Публичные функции сложно менять

Как говорил Лис: «Люди забыли эту истину, но вы не забывайте: вы навсегда в ответе за код, который вы опубликовали.» То, что доступно другим, будет использовано не так, как вы ожидали.

Чтобы поменять что-то публичное придётся учесть все использования. Это требует времени разработчиков и пользователей. Давайте рассмотрим пример из стандартной библиотеки: публичный модуль asynchat был удалён за три шага:

  • Python3.6 released on 23 Dec 2016: The asynchat has been deprecated in favor of asyncio.

  • Python3.10 released on 4 Oct 2021: asynchat, asyncore, smtpd These modules have been marked as deprecated in their module documentation since Python 3.6. An import-time DeprecationWarning has now been added to all three of these modules.

  • Python3.12 released on 2 Oct 2023: The asynchat, asyncore, and imp modules have been removed

Процесс занял шесть лет и десяток разработчиков принимали в нем участие.

Публичные функции сложнее писать

Функция, будь она публичная или внутренняя, должна работать и решать задачу.

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

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

def do_something(**kwargs):
    valid_data = _validate_data(**kwargs)
    _do_something_internal(valid_data)
  • Сигнатура: для публичных функций нужно чтобы было комфортно использовать, например, добавить аргументы по умолчанию и использовать абстрактные типы. Внутренние функции должны работать. Абстракции не нужны, вы знаете и контролируете весь код, который вызывает вашу функцию.

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

Ревью

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

Использование инкапсуляции

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

Когда еще вам понадобятся внутрение функции

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

# Плохо
def print_stats(base):
    response = requests.get(base + "/accounts/")
    response.raise_for_status()
    data = json.loads(response.text[5:])
    for a in data:
        print(f"{a.username}: {a.email}")

        
# Хорошо
def print_stats(base):
    accounts =_get_accounts(base)
    _print_account_stats(accounts)

С точки зрения бизнеса код в приведённых примерах делает 2 вещи: читает данные и выводит результат. В плохом примере мы видим и бизнес логику и низкоуровневые операции в одном месте. Вы начинаете читать про скачивание и парсинг данных и потому переходите к итерации по строкам. Этот код не разговаривает с вами на языке бизнеса. Где-то по середине, вы скорее всего отвлечётесь посмотреть, почему мы не получаем JSON прямо из response. И только прочитав весь код, вы сможете построить в голове, что именно он делает. 

В хорошем примере вы можете отдельно сфокусироваться на логике бизнеса и отдельно на то, как она реализована. Внешняя функция позволяет понять общую структуру очень быстро. Ревью можно разделить на три этапа: логика, получение данных, вывод данных. Каждую функцию удобно тестировать отдельно. Этот принцип сложно объяснить на маленьких примерах. Вы просто можете загрузить весь код в голову и обработать. Когда у вас 1000 строк, то тут могут уйти дни, чтобы понять логику. Вот пример сложного кода, в котором смешанны бизнес логика (поведение компьютерного игрока) и технический код (получение данных, отправка команд: https://github.com/freeorion/freeorion/blob/3255213f8025002be445b5e29e2572b90353d4e5/default/python/AI/ProductionAI.py#L158C1-L158C48

Поддержка инструментами

Инструменты знают и уважают внутренние атрибуты и предупредят вас об ошибках.

  • Использование внутренних атрибутов снаружи контекста вызовет предупреждение.

  • Отсутствие документации для внутренних методов не создаст предупреждение.

Подчёркивание в начале имён (переменные)

class TestConnector:
    def connect(param, _url, _username, _password):
        return

Спецификация

Для Питона это обычное имя, ничего особенного.

Соглашение

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

Класс из сторонней библиотеки:

class Connector:
    def connect(param, url, usarname, password):
        ...

Заглушка для тестов, сигнатура такая же, но аргументы нам не нужны.

class TestConnector:
    def connect(param, _url, _username, _password):
        return

Поддержка инструментами

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

Подчёркивание и есть имя

_, second = function_that_return_pair()

Спецификация

Для Питона это обычное имя, ничего особенного.

Соглашение

Если переменная не используется, то её можно назвать _. Часто так получается, когда надо распаковать последовательность.

pair = 1, 2
_, second = pair

В питоновской консоли (REPL) эта переменная имеет специальный смысл. Она содержит результаты последнего вычисления. Если вы забыли присвоить, то это можно сделать потом.

>>> "com" + "putation"
'computation' 
>>> a = _
>>> print(a)
computation

Подчёркивание в конце

list_ = [1, 2, 3]

Спецификация

Для Питона это обычное имя, ничего особенного.

Соглашение

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

def print_(list_):
    print(", ".join(list_))

    
def distance(from_, to):
    ...

Питон использует много простых имён, таких как print, list, file и json. Некоторые имена зарезервированы для самого языка, например, from. Добавив подчёркивание, вы получаете читаемые и уникальные имена.

В стандартной библиотеке этот подход тоже используется: operator.and_.

Два подчёркивания в начале в методах

Спецификация

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

class Foo:
    def _get(self):
        return self.__class__.__name__
    def get_name(self):
        return self._get()

      
class Boo(Foo):
    def _get(self):
        return datetime.now()
    def get_time(self):
        return self._get()


assert Boo().get_name() == "Boo"  # AssertionError

Код в этом примере сломан. При вызове boo.get_name(), ожидается, что будет вызван метод Foo._get. Но он перезаписан в классе Boo и будет вызван Boo._get.

Каждый раз, когда вы меняете Foo или Boo вы можете оказаться в такой ситуации. Для безопасности как минимум с одним классом нужно что-то сделать, чтобы избежать конфликта имён. Например, добавить имя класса к внутренним методам _get -> _Foo__get.

class Foo:
    def _Foo__get(self):
        return self.__class__.__name__
    def get_name(self):
        return self._Foo__get()

      
class Boo(Foo):
    def _get(self):
        return datetime.now()
    def get_time(self):
        return self._get()

      
assert Boo().get_name() == "Boo"  # работает

Теперь Foo в безопасности, шансы того, что кто-то решить назвать свой метод _Foo__getблизки к нулю. Правда поддерживать такое решение будет неприятно. Поэтому Питон предоставляет простое и удобное решение с ипользованием двух нижних подчёркиваний в начале _get -> __get

class Foo:
    def __get(self):
        return self.__class__.__name__
    def get_name(self):
        return self.__get()


class Boo(Foo):
    def __get(self):
        return datetime.now()
    def get_time(self):
        return self._get()


assert Boo().get_name() == "Boo"

Теперь Boo.__getне перезаписываетFoo.__get, это два разных метода. Чтобы понять как они оба уживаются в __dict__, давайте в него заглянем.

>>> boo = Boo()
>>> print(dir(boo))
['_Boo__get', '_Foo__get', ...]

Методы с двойным нижним подчёркиванем сушествуют только в текстовом файле. Когда Питон загружает класс, то все методы с двойным нижним подчеркиванием будут заменены на методы с именем класса. При этом использование __getвне объявления класса, не будет изменено.

Такие атрибуты называются приватными, но это не сможет удержать вас от их использования. Вы всё еще можете вызвать их по имени.

>>> boo._Boo__get()
datetime.datetime(2024, 3, 31, 9, 42, 47, 141187)
>>> boo._Foo__get()
'Boo'

Такой подход называется name mangling и используется для избегания конфликта имён.

Соглашение

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

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

Два подчёркивания в начале, два в конце

Спецификация

Два подчёркивания с обоих концов — это часть спецификации, так обозначаются методы и атрибуты со специальным значением в Питоне. Они описаны в модели данных (Data model).

Посмотрим на две большие группы.

Первая группа — переменные, которые устанавливаются интерпретатором:

"""Sample module."""

if __name__ == "__main__":
    print(f"Code from {file}")
    print(f"Docstring {doc}")

Вторая группа — магические или Дандр методы (Double UNDeRscore), с их помощью создают пользовательские классы, которые интегрируются в экосистему Питона.

class Foo:
    def __init__(self, number: int):
        self._number = number

        
    def __add__(self, other):
        if isinstance(other, Foo):
            return Foo(self._number + other._number)
        return NotImplemented
  
    def __hash__(self):
        return hash(self._number)
      
    def __repr__(self):
        return f"Foo({self._number})"

Двайте выполним немного кода в терминале.

>>> foo = Foo(1)

__init__ вызывается когда мы создаём объект. 

>>> one = Foo(1)
>>> two = one + Foo(1)
>>> print(one, two)
Foo(1) Foo(2)

__add__ Вызывается когда вы используете оператор +. 

>>> hash(one)
1

__hash__ описывает как ваш класс будет себя вести, когда вызывается функцияhash.

__repr__ участвует в вызовах метода str и repr.

Встроенные функции (len, bool, iter), математические операторы (+, -), контекстные менеджеры, работают с объектами, у которых реализованы соответствующие дандр методы. Будьте внимательны, эти методы не вызываются напрямую, там есть дополнительная логика. Например, bool если не найдёт реализацию bool , воспользуется len, а если и этого метода нет, то вернёт True.

Есть одно исключение, когда можно и нужно вызвать такой метод в вашем коде: когда вы создаете магический метод и вызываете аналогичный метод родителя.

def __init__(self, my_arg, parent_args):
    super().__init__(parent_args)

Вы всегда можете найти описание как работает тот или иной метод в Data model.

Соглашение

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

Заключение

Давайте подведём итоги.

Знания о применении подчёркивания в коде помогут вам писать более качественный код и лучше передавать ваши мысли другим.

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

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

Спецификации

Двойное подчёркивание с обоих концов — это Python Data Model:

def __init__(): , __file__

Двойное подчёркивание в начале методов — избегание конфликтов при наследовании:

class Parent: def __get(): ...

Соглашения

В середине — это змеиный_регистр , популярный стиль именования:

i_use_underscore = True

В конце — возможность использовать хорошие имена:

list_ = ...

Всё имяподчёркивание — показывает умышленное неиспользование переменной:

_, last = get_first_and_last_names()

Лидирующее подчёркивание -, методах, классах — инкапсуляция:

def _foo(): ...

Лидирующее подчёркивание в именах аргументов — умышленное неиспользование:

def mock_connection(_url, _username, _password): ...

Комментарии (11)


  1. AndyLem
    12.09.2024 23:46

    if name == "main_":

    Что то тут не так


    1. Andrey_Solomatin Автор
      12.09.2024 23:46
      +1

      Спасибо, поправил. Интерфейс редактирования хабра съел.

      PS. Если читаете с компьютера, то лучше отправлять опечатки через CTRL+ENTER, чтобы не захламлять обсуждения.


      1. AndyLem
        12.09.2024 23:46
        +2

        Не читаю с компьютера почти никогда, так что уж извините.

        Поправьте ещё раз, должно быть

        if __name__ == "__main__":


  1. zorn-v100500
    12.09.2024 23:46
    +5

    В плохом примере мы видим и бизнес логику и низкоуровневые операции в одном месте

    А в хорошем мы видим не работающий код ;)


    1. Andrey_Solomatin Автор
      12.09.2024 23:46

      Спасибо поправил.


  1. arsvirid
    12.09.2024 23:46
    +1

    Есть еще одно использование нижнего подчёркивания. Не могу сказать, соглашение ли это, но при использовании библиотеки gettext для локализации популярным алиасом на функцию gettext.gettext является именно _ https://docs.python.org/3/library/gettext.html#localizing-your-module


    1. Andrey_Solomatin Автор
      12.09.2024 23:46

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


  1. YeeaaahMan
    12.09.2024 23:46
    +3

    В статье PascalCase называется верблюжьим. Правильные примеры будут выглядеть вот так: snake_case, camelCase, PascalCase, kebab-case.


    1. Andrey_Solomatin Автор
      12.09.2024 23:46

      Спасибо поправил.


  1. fobo
    12.09.2024 23:46
    +2

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


    1. Andrey_Solomatin Автор
      12.09.2024 23:46

      Про врехнее и среднее может в начале апреля.

      Автор только начав писать эту статью понял что "нижнее подчеркивание" это неправильно.