Функции – это первый уровень структурирования кода в программе, независимо от того, пишете вы на Java, C++, Python или Kotlin. Их грамотная запись неоценимо облегчает понимание, разработку и доработку программы. Благодаря красивому написанию функций вы сможете подходить к написанию кода как к искусству, а не разбирать непроходимые дебри. Приступим!
Размер имеет значение
Рассмотрим одну функцию из моего старого проекта. Спустя какое-то время я нашла достаточно большую и запутанную функцию. Проблемы этой функции не только в длине. Попробуйте разобраться в том, что в ней творится.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public void writeTo(XWPFParagraph paragraph, HashMap<String, AFieldValue> fieldValues) { List<VariableLocation> varLocations = pattern.findAndSortVariableLocations(textFields); int currentStrIndex = 0; for (VariableLocation varLocation : varLocations) { if (currentStrIndex < varLocation.getIndexStart()) { int simpleTextEndIndex = varLocation.getIndexStart(); XWPFRun run = paragraph.createRun(); String simpleText = pattern.getText().substring(currentStrIndex, simpleTextEndIndex); run.setText(simpleText); applyStylesByDefault(run); } currentStrIndex = varLocation.getIndexEnd() + 1; TaskTextString textField = findTextFieldByVarName(varLocation.getVarName()); if (textField == null) continue; XWPFRun run = paragraph.createRun(); String styledText = fieldValues.get(textField.getName()).asStringFieldValue().getValue(); run.setText(styledText); applyStyles(run, textField); } } |
Даже пробовать не хочется, правда? Вроде как происходит написание чего-то в какой-то параграф. А ещё есть словарь со значениями неких полей. Остальное – загадка. Хотя функция, казалось бы, умещается в один экран. Типа – не громадная. Однако в функции много непонятного кода, есть отдельные сегменты, которые отвечают сразу за несколько операций.
Как насчёт выделения нескольких методов, переименований и небольшой реструктуризации, чтобы повысить читаемость этого кода? Посмотрим, насколько проще вам будет разобраться в коде после рефакторинга.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public void writeFieldsToParagraph(HashMap<String, AFieldValue> fieldValues, XWPFParagraph paragraph) { List<VariableLocation> varLocations = findVariableLocations(); int currentStrIndex = 0; for (VariableLocation varLocation : varLocations) { String simpleText = findSimpleTextOrNull(varLocation, currentStrIndex); if (simpleText != null) { addSimpleTextToParagraph(paragraph, simpleText); } TaskTextString textField = findTextFieldByVarNameOrNull(varLocation.getVarName()); if (textField != null) { AFieldValue fieldValue = fieldValues.get(textField.getName()); addStyledTextToParagraph(paragraph, textField, fieldValue); } currentStrIndex = getNextIndex(varLocation); } } |
Уже лучше, но можно сделать ещё меньше.
1 2 3 4 5 6 7 8 9 |
public void writeFieldsToParagraph(HashMap<String, AFieldValue> fieldValues, XWPFParagraph paragraph) { List<VariableLocation> varLocations = findVariableLocations(); int currentStrIndex = 0; for (VariableLocation varLocation : varLocations) { findAndAddSimpleTextToParagraph(paragraph, varLocation, currentStrIndex); findAndAddStyledTextToParagraph(paragraph, varLocation, fieldValues); currentStrIndex = getNextIndex(varLocation); } } |
Отлично, теперь становится понятно, что функция writeFieldsToParagraph() получает список расположений переменных (список VariableLocation), а затем в цикле добавляет обычный и стилизованный текст к параграфу, в конце каждой итерации обновляя текущий индекс в строке (currentStrIndex).
Да, эта функция и класс, в которой она лежит, далеко не идеальны. В коде имеется достаточно недочётов, таких, как передача множества параметров в функции, а также то, что повсюду передаётся параграф (как будто пора бы уже создать отдельную сущность для этого). Однако в этих условиях, в рамках архитектуры этого приложения (какой бы она ни была кривой) рефакторинг функции дал свои плоды. Чтение функции позволяет быстро понять, что там происходит. Нам необязательно разбираться в деталях, как всё это работает. Но что делает функция, алгоритм её действий – мы видим.
Запомните: функции должны быть маленькими. Их должно быть можно быстро и безболезненно прочесть. Чтение исходного варианта функции у меня вызвало чуть ли не физическую боль, а мозги вовсю сопротивлялись. Чтение полученного варианта после рефакторинга – прошло легко.
Соответственно, блоки в командах if, else, for, while и т.д. должны состоять не более, чем из парочки строк. Это делает более понятным, что именно происходит в условии или цикле, поскольку не нужно читать 10, 20 строк, входящих в блок. Плюс ко всему – за счёт названий методов можно без комментариев понять намерения программиста.
Одна функция – одна операция
Не поспорить, что изначальный вариант функции writeTo() выполняет множество операций. Она в цикле проверяет текущий индекс относительно индекса в VariableLocation, создаёт отрывок текста в параграфе для обычного текста, создаёт и стилизует обычный текст, изменяет текущий индекс в соответствии с VariableLocation, получает TextField по имени, создаёт отрывок текста в параграфе для стилизованного текста, создаёт и стилизует стилизованный текст…
С другой стороны, в итоговом варианте функция writeFieldsToParagraph() в соответствии с полученными расположениями переменных в цикле добавляет в параграф обычный и стилизованный текст.
Функция должна выполнять только одну операцию. Она должна выполнять её хорошо. И ничего другого она делать не должна.
На словах просто, но на деле бывает трудно определить, что же считать “одной операцией”. В итоговом варианте функции writeFieldsToParagraph() выполняется одна операция? Можно возразить, что это не так, и операций на самом деле больше:
- Происходит формирование списка VariableLocation.
- Цикл for:
- Нахождение и добавление в параграф обычного текста.
- Нахождение и добавление в параграф стилизованного текста.
Так сколько операций выполняет функция? Одну или больше? Прошу обратить внимание, что все пункты списка, все этапы работы функции находятся на одном уровне абстракции под объявленным именем функции. То есть они все логичным образом соотносятся с названием функции – writeFieldsToParagraph(). К примеру, findAndAddSimpleTextToParagraph(). “Найти и добавить обычный текст в параграф” вполне соотносится с “записать в параграф”. А вот какая-нибудь операция из исходного кода функции writeTo() с её названием бы не сумела соотнестись. Например:
1 |
if (currentStrIndex < varLocation.getIndexStart()) |
Исходя из таких мелочей реализации, как сверка текущего индекса с каким-то индексом в местоположении переменной, становится понятно, что функция выполняет больше одной операции. Она контролирует слишком много деталей. В конце концов, функции пишутся прежде всего для разложения более крупной концепции на последовательность действий на следующем уровне абстракции.
Получается, чтобы понять, что функция слишком много на себя берёт и выполняет больше одной операции, попробуйте извлечь из неё другую функцию. Причём чтобы извлечённая функция не была просто переформулированной реализацией.
Ещё один хороший показатель того, что функция выполняет больше одной операции – это если она разделена на отдельные участки, секции. Если функция выполняет только одну операцию, её не получится адекватно разбить на отдельные составляющие. Посмотрите на пример:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
private List<TaskBlock> assignTasksToBlocks() { //создание отсортированного по цене списка заданий List<Task> tasksSortedByPrice = new ArrayList<>(tasks); Collections.sort(tasksSortedByPrice); //расчёт общей цены за все задания double priceAllTasks = 0; for (Task task : tasksSortedByPrice) { priceAllTasks += task.getPrice(); } //расчёт количества блоков в книге double pricePerBlock = SettingsManager.getInstance().getApproximatePriceForBlock(); double blockCountDouble = priceAllTasks / pricePerBlock; int blockCount = 0; if (blockCountDouble > 0 && blockCountDouble < 1) blockCount = 1; else if (blockCountDouble >= 1) blockCount = (int) Math.ceil(blockCountDouble); //создание и добавление блоков в список List<TaskBlock> blocks = new ArrayList<>(); while (tasksSortedByPrice.size() > 0 && blocks.size() < blockCount) { TaskBlock block = createNewTaskBlock(tasksSortedByPrice, pricePerBlock); blocks.add(block); } return blocks; } |
Чётко видно 4 секции в функции:
- Создание списка заданий.
- Расчёт цены за все задания
- Расчёт количества блоков.
- Создание списка блоков.
Очевидно, функция выполняет эти самые 4 операции. А не одну, как следует.
Один уровень абстракции
Уровень абстракции показывает, является ли операция в функции важной концепцией или же второстепенной подробностью. Уровень абстракции может быть высоким, средним, низким. Это примерные уровни.
Пример высокого уровня абстракции:
1 |
findVariableLocations(); |
Средний уровень абстракции:
1 |
fieldValues.get(textField.getName()); |
Низкий уровень абстракции:
1 |
pattern.getText().substring(currentStrIndex, simpleTextEndIndex); |
Чем ниже уровень абстракции, тем больше конкретных деталей и подробностей.
В итоге, в одной функции не следует смешивать разные уровни абстракции, поскольку это создаёт путаницу и код читается гораздо сложнее. Читателю трудно определить, является ли выражение важным или второстепенным. В итоговом варианте функции writeFieldsToParagraph() абстракция выдерживается примерно на одном уровне. Но если вернуть определение индекса по конкретной формуле, оно будет выбиваться:
1 2 3 4 5 6 7 8 9 |
public void writeFieldsToParagraph(HashMap<String, AFieldValue> fieldValues, XWPFParagraph paragraph) { List<VariableLocation> varLocations = findVariableLocations(); int currentStrIndex = 0; for (VariableLocation varLocation : varLocations) { findAndAddSimpleTextToParagraph(paragraph, varLocation, currentStrIndex); findAndAddStyledTextToParagraph(paragraph, varLocation, fieldValues); currentStrIndex = varLocation.getIndexEnd() + 1; } } |
Выражения findVariableLocations(), findAndAddSimpleTextToParagraph(), findAndAddStyledTextToParagraph() – это абстрактные выражения без детализации. В свою очередь, varLocation.getIndexEnd() + 1 – это уже детали вычисления индекса.
Содержательные имена
Изначальную функцию writeTo() я переименовала в writeFieldsToParagraph(), поскольку это название немного более точно описывает, что она делает. Плюс проще отследить порядок двух параметров функции. К тому же, все приватные методы были наделены содержательными именами – findVariableLocations(), findSimpleTextOrNull(), addSimpleTextToParagraph() и так далее. Причём, если функция маленькая и выполняет одну операцию, для неё проще подобрать удобное и содержательное имя.
Не осторожничайте с длинными именами. Лучше длинное и понятное имя, чем короткое и непонятное. Также не осторожничайте с временем, которое вы затрачиваете на придумывание хорошего имени. Это время окупится с лихвой. Можете попробовать несколько разных вариантов и проверить, насколько легко читается код с каждым из вариантов.
Дублирование
Принцип DRY – Don’t Repeat Yourself – один из основополагающих принципов в написании кода. Как циклы позволяют не прописывать 10 итераций друг за другом, так и функции позволяют выполнять однотипные операции с разными настройками с помощью передачи разных параметров.
Посмотрим на часть метода, который из строки конфигурации создаёт объект задачи (TaskTextString). Из массива строковых параметров он достаёт значения курсива, жирности и подчёркнутости текста.
1 2 3 4 5 6 7 8 9 10 |
public static TaskTextString createFromConfig(String config) { String[] params = ...; TaskTextString task = ...; ... task.italics = params[3].equals("1"); task.bold = params[4].equals("1"); task.underline = params[5].equals("1"); return task; } |
Здесь аж в трёх местах производится операция equals(“1”). Вынести это в отдельный метод нужно даже не для приведения функции createFromConfig() к маленькому размеру. Здесь нужно исключить нарушение принципа DRY. Потому что если вы захотите изменить логику проверки параметра на истинность, вам придётся менять её в 3 местах. Если же вы вынесете эту проверку в отдельный метод, то в 1 месте. Бонусом будет то, что название метода сразу сообщит о том, зачем проверять на равенство единице:
1 2 3 4 5 6 7 8 9 10 |
public static TaskTextString createFromConfig(String config) { String[] params = ...; TaskTextString task = ...; ... task.italics = isParameterOn(params[3]); task.bold = isParameterOn(params[4]); task.underline = isParameterOn(params[5]); return task; } |
Метод isParameterOn() выносит логику проверки параметра в другое место, абстрагируясь от конкретных деталей. Более того, благодаря названию этого метода сразу становится ясно, для чего осуществляется проверка.
Однако повторение часто приобретает масштабы побольше, чем повторяющийся однострочный equals(). Не всегда оно настолько очевидно, но бывает и режущее глаз повторение, которое просится в отдельную функцию, как здесь:
1 2 3 4 5 6 7 8 9 10 11 12 |
public String getHexRgbString() { String hexRed = Integer.toHexString(red); hexRed = (hexRed.length() == 1) ? "0" : "" + hexRed; String hexGreen = Integer.toHexString(green); hexGreen = (hexGreen.length() == 1) ? "0" : "" + hexGreen; String hexBlue = Integer.toHexString(blue); hexBlue = (hexBlue.length() == 1) ? "0" : "" + hexBlue; return hexRed + hexGreen + hexBlue; } |
Функция getHexRgbString() возвращает строку из 6 символов, которая значения 0-255 по каналам RGB конвертирует в строку вроде “FF07A1”. Итак, как же убрать повторения? Вот так:
1 2 3 4 5 6 7 |
public String getHexRgbString() { String hexRed = getHexColorChannelString(red); String hexGreen = getHexColorChannelString(green); String hexBlue = getHexColorChannelString(blue); return hexRed + hexGreen + hexBlue; } |
Аргументы функции
Аргументов функции должно быть как можно меньше. Аргументы значительно усложняют функции. Они лишают их мощи концепций и идей. Функцию с 3 и более аргументов понимать сильно сложнее. Когда вы пишете 3 и более аргументов у функции, это с большой вероятностью означает, что где-то подвела структура вашего класса.
В идеале – у функции должно быть 0 аргументов. “Как так?” – скажете вы. “Мы же получаем возможность настраивать работу функции при помощи параметров”. А вы вспомните, что в Java нет функций, которые гуляют сами по себе. Все функции являются заключёнными в классы методами. Находясь внутри класса, функция имеет доступ ко всем его переменным (если она не статическая, конечно). Получается, вызов функции f() у объекта o вы можете интерпретировать и как o.f(), и как f(o). Идея минимизации количества аргументов как раз может быть реализована за счёт сведения f(o) к o.f():
1 2 3 4 5 6 7 8 9 10 |
public class Main { public static void main(String[] args) { ... List<Book> books = collectAllBooks(bookCase); } private static List<Book> collectAllBooks(BookCase bookCase) { ... } } |
1 2 3 4 5 6 |
public class Main { public static void main(String[] args) { ... List<Book> books = bookCase.collectAllBooks(); } } |
Вот такая логика: зачем передавать управление сбором книг из BookCase кому-то другому, если можно организовать это внутри самого BookCase?
В общем говоря, 0 аргументов – идеальный случай. 1 – хороший. 2 – терпимый. 3 – подозрительный. Больше – точно что-то не так.
Если вы каждый раз передаёте одни и те же аргументы вместе в методы, стоит задуматься об объединении их в одну концепцию с собственным именем – в один класс. Например, вместо передачи x и y в методы работы с плоскими фигурами, можно объединить их в класс Point:
1 2 3 4 5 6 7 |
Rectangle createRectangle(double x1, double y1, double x2, double y2) { ... } Rectangle createRectangle(Point firstPoint, Point secondPoint) { ... } |
Вместо 4 аргументов мы имеем 2. Воспринимается лучше и работать удобнее.
Не передавайте boolean как аргумент в функцию. Логический аргумент громко кричит о том, что функция выполняет больше одной задачи. Почему? Потому что при true она делает одно, а при false – другое. Например, в классе Author есть метод, формирующий его описание:
1 2 3 |
public String getDescription(boolean includeBooks) { ... } |
Вместо этого можно создать 2 метода, которые будут выполнять по одной операции – формировать описание без книг и с книгами:
1 2 3 4 5 6 7 |
public String getAuthorDescription() { ... } public String getAuthorDescriptionWithBooks() { ... } |
Конечно, бывает, что вы пользуетесь сторонним API, куда нужно передавать аргумент типа boolean. Поэтому вы решили его получать как аргумент в свою функцию. Тут уже дело ваше. Но аргументы-флаги некрасивы и сбивают с толку.
Кстати, порой становится полезно кодировать названия аргументов в названии самой функции. Да, можно навести мышку на метод и увидеть порядок аргументов и что они значат. Но это время и это силы. Если операция проверки аргументов частая, то можно много на ней сэкономить. Например, как это было с функцией writeTo(), когда ключевые слова аргументов fields и paragraph проникли в название функции:
1 2 |
void writeTo(XWPFParagraph paragraph, HashMap<String, AFieldValue> fieldValues); void writeFieldsToParagraph(HashMap<String, AFieldValue> fieldValues, XWPFParagraph paragraph) |
Или вот примеры попроще для восприятия:
1 2 |
void updateValues(int newSize); void updateValuesAccordingToSize(int newSize); |
1 2 |
void print(int[][] matrix); void printMatrix(int[][] matrix); |
1 2 |
double[] calcTwoRoots(double discriminant); double[] calcTwoRootsByDiscriminant(double discriminant); |
1 2 |
Sandwich cook(LocalDateTime cookingTime); Sandwich cookAtTime(LocalDateTime cookingTime); |
Побочные эффекты
Если ваша функция пообещала сделать что-то одно, она не должна делать чего-то другого, скрытого от разработчика. Она не должна вносить внезапные поправки в переменных своего класса или в глобальных переменных. Она не должна неожиданным образом выполнять какие-либо другие операции, которые не подразумеваются её названием и назначением. Ведь это приводит к побочным эффектам, которые приводят к повышению сложности разработки и к ошибкам.
Посмотрите на следующую функцию. Она обещает только получить переменную по ключу.
1 2 3 4 5 6 7 8 9 10 11 12 |
public Variable getVariable(String key) { if (global.containsKey(key)) { return global.get(key); } if (story.containsKey(key)) { return story.get(key); } //если в словарях нет такой переменной, создадим её в переменных истории Variable newVar = new Variable(0); story.put(key, newVar); return newVar; } |
Да, но не тут-то было! В комментарии сказано, что если ключа нету ни в global, ни в story, то он создаётся в story. Но разве по названию функции getVariable() можно такое предположить? Разработчик может ожидать, что после вызова getVariable() при отсутствии такого ключа он выполнит свои, другие действия. Он не подозревает о том, что функция подпольно создаёт такой ключ. И причём именно в story!
Выходные аргументы
Аргументы – входные данные функции. То, что вы передаёте в скобках, не должно быть возвращаемым значением. Оно не должно ждать изменений, которые вы потом будете использовать как результат работы функции.
Если вы столкнётесь в коде с этим:
1 |
addDescription(s); |
Вы станете думать: эта функция присоединяет s к чему-то? Или же что-то присоединяется к s? То есть s – это входной или выходной аргумент? Вы можете проверить объявление функции и увидеть следующее:
1 |
public void addDescription(StringBuilder message); |
И тогда всё станет ясно. Но опять же – когда вы идёте проверять в среде разработки, какова сигнатура метода – вы тратите время. Последовательное и эффективное чтение кода нарушается.
Раньше, когда объектно-ориентированные языки ещё были только во снах, порой без выходных аргументов обойтись было непросто. Но теперь вы лучше просто сделайте так:
1 |
message.addDescription(); |
Пускай уж функция будет изменять состояние своего объекта, чем состояние аргумента.
Исключения вместо кодов ошибок
Коды ошибок неудобны по той причине, что создают большие уровни вложенности. Везде нужно добавлять проверки и бизнес-логика смешивается с обработкой ошибки. Ведь после возникновения ошибки нужно её обработать. Особенно если есть разбиение функций на подфункции (то есть хороший тон). В итоге мы имеем эти джунгли:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
if (checkConnection() == State.OKAY) { if (sendRequest(request) == State.OKAY) { if (acceptRequest(request.getId()) == State.OKAY) { logger.log("request accepted"); } else { logger.log("request not accepted"); } } else { logger.log("request not sent"); } } else { logger.log("connection failed"); return State.ERROR; } |
В свою очередь, исключения с блоками try-catch позволяют изолировать логику нормального выполнения от логики обработки ошибок. Код упрощается:
1 2 3 4 5 6 7 |
try { checkConnection(); sendRequest(request); acceptRequest(request.getId()); } catch (Exception e) { logger.log(e.getMessage()); } |
В целом, обработка ошибок уже является одной операцией. Поэтому следует три функции из блока try поместить в один метод. А в этом оставить только операцию обработки ошибок, если в методе pingClient() что-то пойдёт не так:
1 2 3 4 5 |
try { pingClient(); } catch (Exception e) { logger.log(e.getMessage()); } |
Написание красивых программ похоже на написание статьи или книги. Когда вы излагаете свои мысли, вы их структурируете, разбиваете на логические части, а также причёсываете, пока они не будут хорошо читаться. Первая наработка может быть черновой, однако с неё вы можете начать “причёсывание”. Постепенно вы доведёте ваш текст до ума.
Так же и с функциями и кодом в целом. Программа – система, которая имеет свою историю. Разбивая эту систему на классы, а классы – на функции, вы идёте от общего к частному. Вы рассказываете историю и идеи этой системы. Пишите код так, чтобы он читался как литературное произведение.