Python

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

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

1. Записывайте назначение функций 

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

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

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

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

func getCurrentTime() -> Date { 
    // тело функции 
}

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

А вот и пример из реальной жизни. Как-то я работал над проектом анализа данных с помощью pandas, популярной библиотеки Python для различных операций с данными. На одном из этапов необходимо было объединить отдельные файлы CSV в единый датафрейм (табличную структуру данных в pandas). 

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

Функция Python (от CSV к датафрейму)

# Эта функция объединит отдельные файлы CSV в указанной папке и сформирует единый датафрейм 
def CSVs_to_df(source_folder):
    # тело функции 
    return processed_df

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

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

2. Изучайте существующие решения 

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

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

В чем же проблема? И о какой ошибке идет речь? 

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

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

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

# Эта функция прочитает файл CSV, обработает и сохранит данные в виде словаря для последующего анализа 
def read_data_from_csv(file_path):
    output_data = {}
    with open(file_path, "r") as file:
        for line_data in file:
            # выполнение действий с каждой строкой 
            # для получения subject_id и subject_data 
            output_data[subject_id] = subject_data
    return output_data

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

# Эта функция прочитает файл CSV и сохранит данные в формате датафрейма pandas для последующего анализа 
import pandas as pd
def read_data_from_csv(file_path):
    df = pd.read_csv(file_path, header=None)
    # действия по очистке любых предваряющих данных 
    return df

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

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

3. Создание прототипа функции 

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

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

struct User {
    let username: String
    let profileImageURL: String
    let biostatement: String
    let followerNumber: Int
    let followingNumber: Int
}

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

import UIKit

// Эта функция создает представление профиля для данного пользователя 
func createProfileView(user: User) -> UIView {
    let profileView = UIView()
    
    let userImageView = UIImageView()
    // Асинхронно получает данные изображения пользователя с сервера и загружает их 
    
    let usernameLabel = UILabel()
    usernameLabel.text = user.username
    // Дополнительная настройка текстовой подписи (шрифт, цвет и т.д.) 
    
    let biostatementLabel = UILabel()
    biostatementLabel.text = user.biostatement
    // Дополнительная настройка текстовой метки(шрифт, цвет и т.д.) 
    
    let followerNumberLabel = UILabel()
    followerNumberLabel.text = "\(user.followerNumber) Followers"
    // Дополнительная настройка текстовой метки (шрифт, цвет и т.д.)
    
    let followingNumberLabel = UILabel()
    followingNumberLabel.text = "\(user.followingNumber) Following"
    // Дополнительная настройка текстовой метки(шрифт, цвет и т.д.) 
    
    let subviews = [userImageView, usernameLabel, biostatementLabel, followerNumberLabel, followingNumberLabel]
    for subview in subviews {
        profileView.addSubview(subview)
    }
    
    // Настройка макета для подмножеств представлений элементов согласно ситуации 
    
    return profileView
}

Как видно из примера, речь идет о функции createProfileView. Процесс создания прототипа функции главным образом нацелен на написание ее тела. Выделим 4 ключевых момента, которые необходимо иметь в виду на этапе прототипирования: 

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

4. Рефакторинг функции 

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

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

Суть единственного принципа рефакторинга функции сводится к тому, чтобы придать ей компактный вид. 

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

  • Получение необходимых данных. Это действие больше относится к структуре данных User. Таким образом, теперь мы получаем данные изображения профиля в обновленной структуре User. Несомненно, это должен быть асинхронный запрос, но в рамках данной статьи мы не будем затрагивать эту тему. 
  • Повторное использование определенных компонентов UI. Например, допуская, что текстовые метки требуют специального форматирования для использования в представлении профиля, мы могли бы создать их как расширение класса UILabel и избавили бы себя от необходимости повторяться, штампуя многочисленные текстовые метки с одинаковым форматированием. 
  • Форматирование данных. В следующем примере кода мы реализовали функцию, отвечающую за отображение чисел, как расширение класса String. Суть в том, что форматирование строки (или форматирование данных в целом) можно представить независимо от функции createProfileView. Пример вспомогательных методов: 
struct User {
    // Располагает теми же свойствами, что и раньше 
    // Выполняет асинхронную задачу для получения данных 
    func getProfileImageDataInBackground() -> Data? {
        let imageData = Data()
        return imageData
    }
}

extension UILabel {
    // Настройка текстовой метки для использования в профиле 
    static func formattedLabelForProfile() -> UILabel {
        return UILabel()
    }
    // Настройка текстовой метки для отображения чисел 
    static func formattedLabelForNumbers() -> UILabel {
        return UILabel()
    }
}

extension String {
    // Форматирует числа для отображения, отталкиваясь от возможных чисел 0, 1, 1000 и т. д. 
    static func formattedForNumber(_ number: Int, unit: String) -> String {
        var formattedString = ""
        return formattedString
    }
}

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

// Эта функция создает представление профиля определенного пользователя 
func createProfileView(user: User) -> UIView {
    let profileView = UIView()
    
    let userImageView = UIImageView()
    if let data = user.getProfileImageDataInBackground() {
        profileImageView.image = UIImage(data: data)
    }

    let usernameLabel = UILabel.formattedLabelForProfile()
    usernameLabel.text = user.username

    let biostatementLabel = UILabel.formattedLabelForProfile()
    biostatementLabel.text = user.biostatement

    let followerNumberLabel = UILabel.formattedLabelForNumbers()
    followerNumberLabel.text = String.formattedForNumber(user.followerNumber, unit: "Follower")

    let followingNumberLabel = UILabel.formattedLabelForNumbers()
    followingNumberLabel.text = String.formattedForNumber(user.followingNumber, unit: "Following")
    
    let subviews = [userImageView, usernameLabel, biostatementLabel, followerNumberLabel, followingNumberLabel]
    for subview in subviews {
        profileView.addSubview(subview)
    }

    // Настройка макета для подмножеств представлений элементов согласно ситуации 
    
    return profileView
}

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

Фактически мы и дальше можем осуществлять рефакторинг createProfileView. Зачем? Затем, что есть возможность еще больше уменьшить размеры функций. В нашем примере каждый компонент UI принимает для настройки две строки кода, но есть вероятность, что каждая настройка может принимать гораздо больше кода. Так почему бы нам не сделать следующее: 

Функция после рефакторинга (альтернатива):

func createProfileView(user: User) -> ProfileView {
    let profileView = ProfileView()
    //...
    
    setupUserImageView()
    setupUsernameLabel()
    setupBiostatementLabel()
    setupFollowerNumberLabel()
    setupFollowingNumberLabel()

    //...
    
    return profileView
}

func setupUserImageView() {}
func setupUsernameLabel() {}
func setupBiostatementLabel() {}
func setupFollowerNumberLabel() {}
func setupFollowingNumberLabel() {}

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

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

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

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

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

Заключение 

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

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

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


Перевод статьи Yong Cui, Ph.D.: 4 Steps to Write Readable and Maintainable Functions