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

Рефакторинг: меняем устройство, не трогаем поведение

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


Правило дня

Пока идёт рефакторинг, поведение не меняется ни на букву.

Не «почти то же самое», не «а заодно я поправил округление». Заодно — нельзя. Если по ходу вы увидели ошибку в расчёте, у вас теперь две задачи: сначала до конца разложить на функции, ничего не меняя, а потом отдельно починить расчёт. Иначе, когда вывод разойдётся, вы не будете знать, из-за чего: из-за переноса куска или из-за «заодно».

Отсюда весь порядок работы:

Шаг Что делаете Зачем
1 снимок поведения: прогнали программу на примерах, вывод сохранили в файл появилось, с чем сравнивать
2 вынесли один кусок в функцию одна правка — одна причина расхождения
3 прогнали те же примеры, сравнили со снимком убедились, что ничего не сломали
4 закоммитили этот шаг есть точка, к которой можно вернуться
5 обратно к шагу 2, пока куски не кончатся

Шаг 1: снимок поведения

Вывод программы можно не разглядывать глазами, а сохранить в файл. Знак > после команды отправляет её вывод в файл вместо экрана. Вы знаете его с дня 07 (страница 2): go run . < input.txt > out.txt; сегодня он работает по делу. Это пара к знаку <, который подаёт программе ввод из файла.

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day13/scratch$ go run . > baseline.txt
stagiaire@lab:~/gocourse/day13/scratch$ cat baseline.txt
Штук: 50
Коробок: 5
Упаковка: 4.05 руб.

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

Если программа читает ввод, оба перенаправления ставят в одну строку:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day13/split$ go run . < example.txt > baseline.txt

Читается слева направо: запустить программу, ввод взять из example.txt, вывод положить в baseline.txt.

Имя baseline — общепринятое название такого снимка: «базовая линия», то, с чем сравнивают. Снимок делается один раз, до правок, и больше не переснимается: в этом весь смысл.


Шаг 2: вынести один кусок

Как выносить, вы знаете с дня 11 (страница 6): обвести кусок, понять, что входит и что выходит, дать имя, перенести, заменить вызовом, проверить. Сегодня к этим шагам одно ограничение: за один заход — один кусок. Какой кусок выносить и как его назвать — страницы 1 и 2. А последний шаг, «проверить», делается уже не глазами, а сверкой со снимком — это шаг 3.


Шаг 3: сверка через diff

diff сравнивает два файла построчно. Команду вы видели в дне 08, когда смотрели на свою починку; здесь у неё другая работа — сравнить вывод до и после.

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day13/scratch$ go run . > after.txt
stagiaire@lab:~/gocourse/day13/scratch$ diff baseline.txt after.txt
stagiaire@lab:~/gocourse/day13/scratch$

diff промолчал — поведение не изменилось. Это единственный ответ, который годится.

А вот как выглядит расхождение: в вынесенной функции скидка потерялась, и третья строка стала другой.

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

Как это читать:

Бывают ещё 2d1 (строка удалилась) и 2a3 (строка добавилась). Что бы ни было напечатано, вывод на шаге 3 — это сигнал «откатить последний вынос и сделать его заново, аккуратнее».


Попробуйте сейчас: снимок поведения.

Цель: в split/baseline.txt лежит вывод программы до всяких правок.

1. Сделайте снимок и посмотрите на него:

▶ Выполните
cd ~/gocourse/day13/split
go run . < example.txt > baseline.txt
cat baseline.txt

2. Сразу проверьте, что сверка работает: прогоните ещё раз в другой файл и сравните.

▶ Выполните
go run . < example.txt > after.txt
diff baseline.txt after.txt

3. В example.txt три заказа, последний нулевой, а программа умеет ещё две вещи: пустой ввод и строку не с числом. Снимите и их, иначе «поведение не изменилось» будет сказано только про удобный пример:

▶ Выполните
go run . < /dev/null > base-empty.txt
printf '50\n12шт\n' > bad.txt
go run . < bad.txt > base-bad.txt 2>&1
cat base-empty.txt base-bad.txt

Знак 2>&1 отправляет поток ошибок (номер 2) туда же, куда уже идёт обычный вывод (номер 1), — в файл; номера потоков — из дня 07, страница 5, а & значит, что 1 — номер потока, а не файл с таким именем. На плохом вводе программа печатает именно в поток ошибок, и без 2>&1 в файле осталась бы только первая строка. Дальше все три снимка сверяются одним и тем же diff после каждого выноса, а переснимать их нельзя.

Последней строкой в base-bad.txt будет ещё и exit status 1. Её пишет не ваша программа, а сам go run: так он сообщает, каким кодом завершилось то, что он запустил. В снимке она не мешает — проверка эту строку не считает, — и после выносов она останется на месте.

Готово, когда: в baseline.txt четыре строки — три заказа и итог, в base-empty.txt одна строка «Заказов не было», в base-bad.txt — строка первого заказа, сообщение об ошибке и exit status 1 от go run, а diff ничего не напечатал (пункты t_base, t_diff, base).


Шаг 4: коммит на каждый вынесенный кусок

Git вы завели в дне 11. Сегодня он нужен по делу: каждый вынесенный кусок — отдельный коммит. Тогда, если через три выноса вывод разошёлся, видно, какой именно шаг его сломал, и можно вернуться к последнему рабочему состоянию.

В папке дня репозитория ещё нет — заведите его. Называться (user.name, user.email) и задавать имя первой ветки не нужно: это сделано один раз в дне 11, настройки лежат в домашнем каталоге и действуют на все папки.

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day13$ git init
Initialized empty Git repository in /home/stagiaire/gocourse/day13/.git/
stagiaire@lab:~/gocourse/day13$ git add .
stagiaire@lab:~/gocourse/day13$ git commit -m "день 13: стенд как есть"
[main (root-commit) ea412df] день 13: стенд как есть
 11 files changed, 232 insertions(+)
 create mode 100644 fixcall/main.go
 create mode 100644 go.mod
 create mode 100644 read/program.go
 create mode 100644 report/TASK.txt
 create mode 100644 report/main.go
 create mode 100644 scratch/main.go
 create mode 100644 split/TASK.txt
 create mode 100644 split/after.txt
 create mode 100644 split/baseline.txt
 create mode 100644 split/example.txt
 create mode 100644 split/main.go

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

Если git init вместо одной строки напечатал десяток строк со словом hint про имя ветки master, значит, настройка init.defaultBranch из дня 11 не сделана. Ветка в квадратных скобках тогда будет называться master; на работу дня это не влияет.

Дальше — вынесли функцию, сверили diff, закоммитили:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day13$ git add .
stagiaire@lab:~/gocourse/day13$ git commit -m "split: вынес расчёт коробок"
[main d89cab9] split: вынес расчёт коробок
 1 file changed, 5 insertions(+), 2 deletions(-)

Одна строка вместо списка файлов — изменился только split/main.go: три строки новой функции плюс её вызов вместо двух прежних строк расчёта. Числа у вас будут другие, а форма та же.

История коммитов читается одной командой:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day13$ git log --oneline
d89cab9 split: вынес расчёт коробок
ea412df день 13: стенд как есть

Сообщение коммита пишут о том, что сделано: «вынес расчёт коробок», «вынес печать строки заказа». Не «правки», не «фикс», не «работа». Через неделю такую историю читаете вы сами.

Совет, который экономит время: коммитьте только когда diff промолчал. Коммит с уже сломанным поведением сохраняет поломку, и откатываться будет некуда.


Попробуйте сейчас: разложить split на функции.

Цель: split разложен на функции, вывод не изменился, каждый шаг закоммичен.

1. Прочитайте условие целиком:

▶ Выполните
cd ~/gocourse/day13/split
cat TASK.txt

2. Заведите репозиторий в папке дня и сделайте первый коммит — состояние «как было»:

▶ Выполните
cd ~/gocourse/day13
git init
git add .
git commit -m "день 13: стенд как есть"

Если git отвечает, что не знает, кто вы, — подпись автора задаётся так же, как в дне 11: git config --global user.name "…" и git config --global user.email "…".

3. Вынесите первый кусок — тот, что подписан комментарием «сколько коробок». Сверьте и закоммитьте:

▶ Выполните
cd ~/gocourse/day13/split
go run . < example.txt > after.txt
diff baseline.txt after.txt
cd ~/gocourse/day13
git add .
git commit -m "split: вынес расчёт коробок"

4. Так же — второй кусок и третий. Каждый раз: вынести, прогнать, diff, коммит. Всего должно получиться не меньше четырёх коммитов. После каждого выноса сверяйте и границы — это те же две команды и два diff:

▶ Выполните
go run . < /dev/null > after-empty.txt
diff base-empty.txt after-empty.txt
go run . < bad.txt > after-bad.txt 2>&1
diff base-bad.txt after-bad.txt

5. В конце проверьте формат, go vet и историю:

▶ Выполните
cd ~/gocourse/day13/split
gofmt -l .
go vet .
cd ~/gocourse/day13
git log --oneline

Готово, когда: в split не меньше трёх своих функций, каждая вызывается, в main от расчётов остались только вызовы, тело main не длиннее 34 строк (это порог «вынос сделан», а не правило про длину функции: в заготовке тело main — 38 строк, страница 1), diff молчит на всех трёх снимках, git log --oneline показывает четыре и больше строк — пункты split, funcs, base, vet, code_fmt, t_log, git.

Порядок тут жёсткий: funcs — про само разбиение, а split, vet и code_fmt — про то, что при разбиении ничего не сломалось. Пока расчёты не уехали из main, эти три пункта красные и пишут «сначала пункт funcs»: на нетронутой простыне вывод не менялся, go vet молчал и формат был в порядке сам собой, а «ничего не сломал» — это про работу, а не про то, что работы не было.


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

Что видите Что это значит Что делать
diff печатает 3c3 и две строки последний вынос изменил поведение смотреть только последний кусок: потерянное условие, перепутанный порядок, забытый +=
diff: after.txt: No such file or directory прогон после правки не сделан или ушёл на экран повторить с > after.txt
baseline.txt совпал, а проверка ругается на скрытые случаи пример не покрывает границы: ноль, пустой ввод, строку с ошибкой прогнать руками go run . < /dev/null и ввод с не-числом
вывод «почти тот же», отличается пробел для сверки нет «почти» вернуть прежний формат строки до буквы
закоммитить нечего: nothing to commit git add не сделан или правок не было git status, потом git add .
Author identity unknown не задана подпись автора git config --global user.name и user.email, день 11
после трёх выносов всё сломалось, непонятно где коммитов не было, шаги слились в один дальше коммитить каждый шаг; сейчас — выносить обратно по одному

Дальше: написать функцию по чужому описанию — course next