Топ-5 ошибок при объявлении функций в Python

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

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

“Читаемость имеет значение”, — Дзен Python.

1. Неподходящие имена функций

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

Уникальность

Это очень прямолинейное требование. Как и в любых других объектах Python, имена используются для идентификации функций. При объявлении функций с одинаковыми именами Python IDE (интегрированная среда разработки, например, PyCharm или Visual Studio Code) пожалуется на совпадение, или последняя объявленная функция выйдет победителем. Объявим две функции say_hello, и, как видите, при вызове функции получим вторую:

>>> # определяем функцию
>>> def say_hello():
...     print('say hello, first function')
...
>>> # определяем функцию с таким же именем
>>> def say_hello():
...     print('say hello, second function')
... 
>>> say_hello()
say hello, second function

Имена функций должны быть уникальны.

Информативность

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

>>> # имя слишком общее и неконкретное
>>> def do_something():
...     print("do something")
... 
>>> # более конкретное имя
>>> def say_hi():
...     print("say hi")
... 
>>> # неточно описывает функцию
>>> def process_numbers(number1, number2):
...     result = number1 * number2
...     return result
... 
>>> # более точно описывает функцию
>>> def multiply_numbers(number1, number2):
...     result = number1 * number2
...     return result
...

Имена функций должны быть информативны.

Единообразие

Программирование на Python стимулирует модальность, что подразумевает группировку связанных классов и функций в модули. Внутри модулей и между ними стоит именовать функции единообразно. То есть использовать одни и те же условные обозначения для определённых видов объектов и операций. Рассмотрим следующие простые примеры. Первые три функции выполняют схожие операции, поэтому я использовал одинаковый формат: глагол + подчёркивание + числа. В пользовательском классе Mask структура имён двух функций promotion_price и sales_price одинакова: в первой части определена характеристика цены, а во второй суть возвращаемого значения (то есть цена, выраженная в виде числа с плавающей точкой).

>>> # функции, выполняющие схожие операции
>>> def multiply_numbers(number1, number2):
...     return number1 * number2
... 
>>> def add_numbers(number1, number2):
...     return number1 + number2
... 
>>> def divide_numbers(number1, number2):
...     return number1 / number2
... 
>>> # определяем пользовательский класс
>>> class Mask:
...     def __init__(self, price):
...         self.price = price
...
...     # две функции, возвращающие два вида цен
...     def promotion_price(self):
...         return self.price * 0.9
...
...     def sales_price(self):
...         return self.price * 0.75
...

Имена функций должны быть единообразны.

2. Разнородные обязанности и чрезмерная длина

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

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

Сравним два гипотетических примера. Используем популярную библиотеку pandas для обработки нормальных данных, полученных в ходе эксперимента. Для каждого субъекта у нас есть четыре сессии данных в формате CSV. Мы можем написать функцию process_physio_data, включающую в себя все три этапа обработки данных. Но из-за сложности данных функция будет содержать более 100 строк кода! 

>>> import pandas as pd
>>> 
>>> def process_physio_data(subject_id):
...     # первый этап - чтение связанных файлов
...     df0 = pd.read_csv(f'{subject_id}_v1.csv')
...     df1 = pd.read_csv(f'{subject_id}_v2.csv')
...     df2 = pd.read_csv(f'{subject_id}_v3.csv')
...     df3 = pd.read_csv(f'{subject_id}_v4.csv')
...     # завершение первого этапа
...
...     # второй этап - процедуры очистки
...     # 
...     # обработка четырёх DataFrames
...     # здесь 50 строк кода
...     # генерация большого DataFrame
...     #
...     # завершение второго этапа
...     big_df = pd.DataFrame()
...
...     # третий этап - некоторые сложные вычисления
...     #
...     # обработка большого DataFrames
...     # здесь 50 строк кода
...     # генерация маленького DataFrame
...     #
...     # завершение третьего этапа
...     small_df = pd.DataFrame()
...
...     return small_df
...

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

>>> import pandas as pd
>>> 
>>> # вспомогательная функция, читающая данные
>>> def read_physio_data(subject_id):
...     df0 = pd.read_csv(f'{subject_id}_v1.csv')
...     df1 = pd.read_csv(f'{subject_id}_v2.csv')
...     df2 = pd.read_csv(f'{subject_id}_v3.csv')
...     df3 = pd.read_csv(f'{subject_id}_v4.csv')
...     return [df0, df1, df2, df3]
... 
>>> # вспомогательная функция, очищающая данные
>>> def clean_physio_data(dfs):
...     # all the 50 lines of code for data clean up
...     big_df = pd.DataFrame()
...     return big_df
... 
>>> # вспомогательная функция, вычисляющая данные
>>> def calculate_physio_data(df):
...     # all the 50 lines of code for data calculation
...     small_df = pd.DataFrame()
...     return small_df
...
>>> # обновлённая функция
>>> def process_physio_data(subject_id):
...     # первый этап - чтение
...     dfs = read_physio_data(subject_id)
...     # второй этап - очистка
...     big_df = clean_physio_data(dfs)
...     # третий этап - вычисление
...     small_df = calculate_physio_data(big_df)
...     
...     return small_df
...

3. Отсутствие документации

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

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

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

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

4. Некорректное использование значений по умолчанию

При написании функций Python позволяет задавать значения по умолчанию для конкретных аргументов, эта фича также есть во многих встроенных функциях. Рассмотрим пример ниже. Создадим список объектов, используя функцию range(), синтаксис которой выглядит так: range(start, stop, step). По умолчанию аргумент step использует 1. Однако мы можем задать значение этому аргументу (скажем, 2):

>>> # функция range, использующая значение step по умолчанию
>>> list(range(5, 15))
[5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
>>> # зададим step значение 2
>>> list(range(5, 15, 2))
[5, 7, 9, 11, 13]

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

>>> # определяем функцию, включающую значение по умолчанию для 
>>> # списка
>>> def append_score(score, scores=[]):
...     scores.append(score)
...     print(scores)
... 
>>> append_score(98)
[98]
>>> append_score(92, [100, 95])
[100, 95, 92]
>>> append_score(94)
[98, 94]

Когда мы пытаемся добавить значение 98 в конец списка, видим ожидаемый результат, потому что не указан аргумент scores и используется пустой список. Когда мы добавляем 92 к списку [100, 95], результат также соответствует ожиданиям: [100, 95, 92]. Однако при попытке добавить значение 94, некоторые ожидают увидеть [94], но результат иной. Почему так происходит?

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

>>> # обновленная функция для отображения id аргумента scores
>>> def append_score(score, scores=[]):
...     scores.append(score)
...     print(f'scores: {scores} & id: {id(scores)}')
... 
>>> append_score.__defaults__
([],)
>>> id(append_score.__defaults__[0])
4650019968
>>> append_score(95)
scores: [95] & id: 4650019968
>>> append_score(98)
scores: [95, 98] & id: 4650019968

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

Как же правильно задавать значения по умолчанию? Необходимо использовать None в качестве значения по умолчанию для изменяемых типов данных, чтобы при объявлении функции не создавался изменяемый объект; его можно создать уже при вызове функции. Подробнее это видно во фрагменте ниже, теперь всё работает как ожидалось:

>>> # используем None в качестве значения по умолчанию
>>> def append_score(score, scores=None):
...     if not scores:
...         scores = []
...     scores.append(score)
...     print(scores)
... 
>>> append_score(98)
[98]
>>> append_score(92, [100, 95])
[100, 95, 92]
>>> append_score(94)
[94]

5. Злоупотребление *args и **kargs

Python позволяет писать гибкие функции, поддерживая переменное количество аргументов. Вам наверняка встречались *args и **kargs в документации некоторых библиотек. По сути *args относится к неопределённому количеству позиционных аргументов, а **kargs — к неопределённому количеству именованных аргументов.

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

>>> # функция, включающая и позиционные, и именованные аргументы
>>> def add_numbers(num0, num1, num2=2, num3=3):
...     outcome = num0 + num1 + num2 + num3
...     print(f"num0={num0}, num1={num1}, num2={num2}, num3={num3}")
...     return outcome
... 
>>> add_numbers(0, 1)
num0=0, num1=1, num2=2, num3=3
6
>>> add_numbers(0, 1, num3=4, num2=5)
num0=0, num1=1, num2=5, num3=4
10

В функции add_numbers, num0 и num1 — позиционные аргументы, а num2 и num3 — именованные. Стоит помнить, что вы можете менять порядок именованных аргументов, но не позиционных и именованных. Сделаем ещё шаг и посмотрим, как работают *args и **kargs. Лучше всего, конечно, изучить на примере. Обратите внимание на две вещи:

  1. Переменное число позиционных аргументов обрабатывается как кортеж, следовательно, его можно распаковать, используя одну звёздочку — *.
  2. Переменное число именованных аргументов обрабатывается как словарь, следовательно, его можно распаковать, используя две звёздочки —  **.
>>> # функция с *args
>>> def show_numbers(*numbers):
...     print(f'type: {type(numbers)}')
...     print(f'list from *args: {numbers}')
... 
>>> show_numbers(1, 2, 3)
type: <class 'tuple'>
list from *args: (1, 2, 3)
>>> 
>>> # функция с **kargs
>>> def show_scores(**scores):
...     print(f'type: {type(scores)}')
...     print(f'list from **kargs: {scores}')
... 
>>> show_scores(a=1, b=2, c=3)
type: <class 'dict'>
list from **kargs: {'a': 1, 'b': 2, 'c': 3}

Доступность *args и **kargs позволяет писать более гибкие функции, однако злоупотребление ими может привести к путанице. Выше я упоминал об использовании библиотеки pandas для манипуляций с данными и о функции read_csv, читающей CSV-файлы. А знаете, сколько аргументов может принимать эта функция? Заглянем в документацию:

pandas.read_csv(filepath_or_buffer: Union[str, pathlib.Path, IO[~AnyStr]], sep=',', delimiter=None, header='infer', names=None, index_col=None, usecols=None, squeeze=False, prefix=None, mangle_dupe_cols=True, dtype=None, engine=None, converters=None, true_values=None, false_values=None, skipinitialspace=False, skiprows=None, skipfooter=0, nrows=None, na_values=None, keep_default_na=True, na_filter=True, verbose=False, skip_blank_lines=True, parse_dates=False, infer_datetime_format=False, keep_date_col=False, date_parser=None, dayfirst=False, cache_dates=True, iterator=False, chunksize=None, compression='infer', thousands=None, decimal: str = '.', lineterminator=None, quotechar='"', quoting=0, doublequote=True, escapechar=None, comment=None, encoding=None, dialect=None, error_bad_lines=True, warn_bad_lines=True, delim_whitespace=False, low_memory=True, memory_map=False, float_precision=None)

Их 49: 1 позиционный и 48 именованных аргументов. Теоретически, список можно сократить:

pandas.read_csv(filepath_or_buffer: Union[str, pathlib.Path, IO[~AnyStr]], **kargs)

Однако при реальной разработке всё равно придётся распаковывать **kargs и выяснять, как правильно читать CSV-файл. Почему же опытные разработчики Python согласились перечислять все эти именованные аргументы? Потому что они прекрасно понимают следующий принцип:

Явное лучше, чем неявное”, — Дзен Python.

Использование **kargs может сэкономить нам немного времени при написании первой строки объявления функции, однако код становится менее явным. То же справедливо и для *args. Как упоминалось выше, при работе в среде совместного использования кода всегда стоит стремиться к ясному и понятному коду. Поэтому по возможности стоит избегать использования *args и **kargs для написания более явно заданного кода.

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

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


Перевод статьи Yong Cui, Ph.D.: Top 5 Mistakes You Make When Declaring Functions in Python