День 13 · Разбить задачу на функции · страница 7 из 7

Когда разбиение мешает, памятка и словарик

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


Случай 1: функция, которая считает и печатает

Ситуация. Кусок «посчитать коробки и напечатать» вынесли целиком, вместе с печатью.

Код для чтения · разбираем, набирать не нужно
package main

import "fmt"

func printBoxes(items, perBox int) {
	fmt.Println("Коробок:", (items+perBox-1)/perBox)
}

func main() {
	printBoxes(50, 12)
	printBoxes(7, 12)
}
Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day13/scratch$ go run .
Коробок: 5
Коробок: 1

Работает. И всё же так делать не стоит, и вот почему.

Её нельзя проверить, не глядя на экран. printBoxes ничего не возвращает: чтобы узнать, верно ли она считает, надо запустить программу и прочитать вывод глазами. Сравнить «то, что вернула функция», с «тем, что посчитано на бумаге» здесь не с чем.

Её нельзя использовать второй раз. Понадобилось то же число, но в другой строке — например, в итоге смены: Заказов: 3, коробок: 7. С printBoxes придётся писать формулу заново.

Правило: считает одна функция, печатает другая (или сам main).

Код для чтения · разбираем, набирать не нужно
func boxesFor(items, perBox int) int {
	return (items + perBox - 1) / perBox
}

Теперь число можно и напечатать, и сложить с другим, и сравнить с посчитанным на бумаге. Завтра, в день 14, вы напишете к такой функции тест — программу, которая сравнивает результат с ожидаемым сама. К функции вроде printBoxes теста не напишешь.

Печатающие функции нужны и полезны — printOrder, printTotal, — но у них одно дело: печать. Расчёт в них не живёт.


Случай 2: обёртка, которая ничего не объясняет

Код для чтения · разбираем, набирать не нужно
func plusOne(n int) int {
	return n + 1
}

count = plusOne(count) вместо count++ — на один прыжок при чтении больше, понимания ноль. Функция оправдана, когда её имя объясняет формулу, которую иначе пришлось бы разбирать: boxesFor объясняет, что значит (items + perBox - 1) / perBox.

Проверка простая: можете ли вы назвать функцию по смыслу, а не по действиям? Если единственное честное имя — пересказ тела, функция не нужна.


Случай 3: функция, которой нужно полпрограммы

Признак — длинный список параметров:

Код для чтения · разбираем, набирать не нужно
func handle(items, perBox, boxPrice, from, orders, boxesTotal, costTotal int) int {

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

Что делать: резать мельче. Обычно внутри такого куска есть два-три самостоятельных: расчёт коробок, расчёт стоимости, печать. Каждому нужно два-три числа.

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


Случай 4: функция, которая помнит

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


Случай 5: разбиение без снимка поведения

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

Ситуация. Программа приёмки со страницы 1 работала. За один заход вынесли три куска и заодно поправили формат первой строки. Запустили — и получили вот что:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day13/scratch$ diff baseline.txt after.txt
1c1
< Штук: 50
---
> Штук : 50
3c3
< Упаковка: 4.05 руб.
---
> Упаковка: 4.50 руб.

Расхождения два, правок четыре, и какая правка дала какое расхождение — из этого вывода не видно. Первое, Штук : вместо Штук:, нашлось бы глазами. Второе, 4.50 вместо 4.05, — это потерянная скидка, и она может прятаться в любом из трёх выносов.

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

Как делали Сколько кандидатов на ошибку Как искать
три выноса и правка формата, один прогон четыре читать всё, сравнивать с оригиналом
один вынос, один прогон, коммит один смотреть только его

Отсюда и порядок со страницы 5 — вынести, прогнать, diff, коммит, — и его цена: без него у расхождения нет адреса. И вторая причина поломки здесь та же, что в правиле дня: «заодно поправил формат». Заодно — нельзя; при выносе по одному куску это стало бы видно на первом же diff.


Пять вопросов к готовому разбиению

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

  1. Читается ли main как оглавление? Пройдите его глазами сверху вниз и скажите вслух, что делает программа. Если для ответа пришлось заходить внутрь функции, у неё врёт имя.
  2. Есть ли функция, которую нельзя объяснить одной фразой без «потом»? Такая делает больше одного дела.
  3. Есть ли функция, которую нельзя позвать с числами и сверить с бумагой? Значит, внутри печать или чтение ввода вперемешку с расчётом.
  4. Есть ли функция с пятью и больше параметрами? Граница проведена не по подцели.
  5. Осталась ли в программе формула, записанная дважды? Найдите глазами: если одно и то же выражение встречается в двух местах, вынос ещё не закончен.

Четыре «нет» и одно «да» на первый вопрос — разбиение готово. Любой другой ответ говорит, какую именно страницу перечитать: 1, 2, 4 или 7.


Разбиение в чужом коде: три предосторожности

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

1. Сначала посмотрите, кто зовёт. Прежде чем менять функцию, нажмите на её имени Shift+F12: редактор покажет все места вызова. Меняете то, что зовут из пяти мест, — проверять придётся все пять. В своей однофайловой программе этот список всегда пуст или короток, в сервисе — нет.

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

3. Поведение сверяют на границах, а не на удобном примере. Ваш baseline.txt снят на example.txt — три заказа, последний нулевой. Программа умеет ещё две вещи: пустой ввод и строку не с числом. Обе вы на странице 5 тоже сняли в файлы — base-empty.txt и base-bad.txt, их сверяет тот же diff. Проверка курса гоняет все эти границы ещё и скрытыми случаями. Прогоняйте такие вводы руками после каждого выноса:

Ввод Что должно остаться прежним
go run . < /dev/null одна строка «Заказов не было»
ноль штук в заказе 0 коробок и 0.00 руб., а не пропуск строки
строка не с числом сообщение в поток ошибок и код возврата 1
пустые строки вперемешку с числами пустые пропускаются и не сдвигают номера заказов

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


Памятка дня

Вопрос Ответ
Когда выносить кусок повтор; длинное условие; кусок, подписанный комментарием-заголовком
Когда не выносить обёртка ради одной строки; кусок, которому нужны почти все переменные main; три строки печати подряд в конце
Сколько дел у функции одно; имя без «и», объяснение без «потом»
Как назвать результат — существительным (packCost), действие — глаголом (printOrder), «да или нет» — утверждением (bulkDelivery)
Как передать данные аргументами; вернуть результатом; пакетных переменных не заводить
Как не спутать единицы единица в имени: priceKop, priceRub, weightG
Как читать чужую программу main сверху вниз; вглубь по вызову, когда нужен ответ; F12 и Alt+←
Как вести таблицу с вызовами правила 16–22 дней 11 и 12 плюс сегодняшние 23 и 24: колонка на переменную каждой функции, вложенный вызов — отдельной таблицей
Где искать неверное число по дереву вызовов сверху вниз, проверяя листья отдельными вызовами
Как менять работающую программу снимок вывода, один кусок, diff, коммит — и снова
Что значит «поведение не изменилось» diff молчит на всех трёх снимках: пример, пустой ввод, строка не с числом
Когда коммитить после каждого вынесенного куска, у которого diff промолчал

Что может пойти не так

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

Словарик ошибок

За сегодня в словарик ~/errors.md добавляются сообщения, которые встречаются при работе с функциями. Строка — «текст ошибки — что значило», между ними тире с пробелами:

Образец · набирать не нужно
undefined: boxesFro - вызвал функцию с опечаткой в имени, такого имени в пакете нет
missing return - в функции есть путь, на котором return не встретился
not enough arguments in call to packCost - передал меньше аргументов, чем параметров в заголовке
boxes redeclared in this block - объявил две функции с одинаковым именем
syntax error: unexpected name boxesFor, expected ( - объявил функцию внутри main

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

Попробуйте сейчас: словарик до двадцати шести строк.

Цель: в ~/errors.md не меньше двадцати шести разборов за весь курс.

Откройте словарик и посчитайте разборы — так же, как их считает проверка: строка длиннее двадцати символов и с тире-разделителем.

▶ Выполните
code ~/errors.md
grep -E '.{20,}' ~/errors.md | grep -cE ' (-|—|–) '

Обычный grep -c . ~/errors.md покажет больше: он считает все строки подряд, в том числе заголовки и заметки без разбора.

Допишите то, что встретили сегодня: undefined при опечатке в имени, missing return, сообщения про число аргументов, redeclared. Записывайте настоящие строки — те, что видели на своём экране. Разделитель между сообщением и разбором — тире с пробелами вокруг, двоеточие не считается: оно есть в каждом сообщении компилятора.

Готово, когда: пункт errors в course check 13 зелёный — в файле не меньше двадцати шести разборов.


Итог дня

Что уносите:

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


Материал дня закончен. Задания — course lab 13, дополнительное чтение — course extra 13