От шагов решения к функциям
В день 11 появилась своя функция, в день 12 — функция с двумя результатами и с ошибкой. Сегодня речь не о том, как написать одну функцию, а о том, как разрезать целую задачу: какие куски вынести, как их назвать и как при этом ничего не сломать.
Ситуация: приёмка в одном куске
Программа считает упаковку заказа: сколько коробок, сколько стоит упаковка, есть ли скидка. Написана она так, как обычно выходит с первого раза, — всё в main, подряд.
package main
import "fmt"
func main() {
items := 50
perBox := 12
boxPrice := 90
discountFrom := 5
// сколько коробок
boxes := (items + perBox - 1) / perBox
// стоимость упаковки, со скидкой от discountFrom коробок
cost := boxes * boxPrice
if boxes >= discountFrom {
cost = cost * 9 / 10
}
// печать
fmt.Println("Штук:", items)
fmt.Println("Коробок:", boxes)
fmt.Printf("Упаковка: %d.%02d руб.\n", cost/100, cost%100)
}Что увидите.
stagiaire@lab:~/gocourse/day13/scratch$ go run .
Штук: 50
Коробок: 5
Упаковка: 4.05 руб.Как это читать. Программа работает и умещается на экране. Проблема появляется, когда её нужно прочитать: чтобы ответить на вопрос «почему упаковка 4 рубля 5 копеек, а не 4.50», приходится держать в голове все восемнадцать строк сразу. И ещё: расчёт коробок здесь нельзя проверить отдельно от печати — они склеены в одном куске.
Заметьте комментарии // сколько коробок, // стоимость упаковки, // печать. Это следы шага 3 из дня 05: автор разбил задачу на подцели и подписал куски. Комментарий-заголовок — почти всегда имя будущей функции.
Подцель становится функцией
Те же шаги, но каждый кусок с комментарием-заголовком переехал в свою функцию, а комментарий исчез — его работу теперь делает имя:
package main
import "fmt"
func boxesFor(items, perBox int) int {
return (items + perBox - 1) / perBox
}
func packCost(boxes, boxPrice, discountFrom int) int {
cost := boxes * boxPrice
if boxes >= discountFrom {
cost = cost * 9 / 10
}
return cost
}
func rubles(kopecks int) string {
return fmt.Sprintf("%d.%02d", kopecks/100, kopecks%100)
}
func main() {
items := 50
boxes := boxesFor(items, 12)
cost := packCost(boxes, 90, 5)
fmt.Println("Штук:", items)
fmt.Println("Коробок:", boxes)
fmt.Println("Упаковка:", rubles(cost), "руб.")
}Что увидите.
stagiaire@lab:~/gocourse/day13/scratch$ go run .
Штук: 50
Коробок: 5
Упаковка: 4.05 руб.Вывод тот же, буква в букву. Это главное свойство сегодняшней работы: разбиение на функции ничего не меняет для того, кто смотрит на экран. Меняется только то, как программа устроена внутри.
fmt.Sprintf вы знаете с дня 04: он не печатает, а возвращает готовую строку. Поэтому последняя строка main печатает 4.05 точно так же, как Printf с %d.%02d в первой программе.
Что дало разбиение
| Вопрос | В одном куске | По функциям |
|---|---|---|
| «сколько коробок на 50 штук?» | прочитать main до нужной строки |
прочитать три строки boxesFor |
| «откуда скидка?» | найти if среди других строк |
открыть packCost |
| «что делает программа?» | прочитать всё | прочитать main: шесть строк |
| «этот расчёт верен?» | проверить можно только всю программу целиком | проверить можно одну функцию — этим займёмся в день 14 |
main из шести строк читается как оглавление: посчитать коробки, посчитать стоимость, напечатать. Порядок шагов виден сразу, подробности каждого шага — по требованию, если они вам сейчас нужны.
Попробуйте сейчас: две программы, один вывод.
Цель: увидеть своими глазами, что разбиение на функции не меняет вывод.
1. Наберите в
scratch/main.goпервую программу — ту, что в одном куске:✎ Наберите в файлscratch/main.gopackage main import "fmt" func main() { items := 50 perBox := 12 boxPrice := 90 discountFrom := 5 // сколько коробок boxes := (items + perBox - 1) / perBox // стоимость упаковки, со скидкой от discountFrom коробок cost := boxes * boxPrice if boxes >= discountFrom { cost = cost * 9 / 10 } // печать fmt.Println("Штук:", items) fmt.Println("Коробок:", boxes) fmt.Printf("Упаковка: %d.%02d руб.\n", cost/100, cost%100) }Запустите:
▶ Выполнитеcd ~/gocourse/day13/scratch go run .Запомните три строки вывода.
2. Замените содержимое файла второй программой — той, что из четырёх функций, — и запустите снова:
✎ Наберите в файлscratch/main.gopackage main import "fmt" func boxesFor(items, perBox int) int { return (items + perBox - 1) / perBox } func packCost(boxes, boxPrice, discountFrom int) int { cost := boxes * boxPrice if boxes >= discountFrom { cost = cost * 9 / 10 } return cost } func rubles(kopecks int) string { return fmt.Sprintf("%d.%02d", kopecks/100, kopecks%100) } func main() { items := 50 boxes := boxesFor(items, 12) cost := packCost(boxes, 90, 5) fmt.Println("Штук:", items) fmt.Println("Коробок:", boxes) fmt.Println("Упаковка:", rubles(cost), "руб.") }▶ Выполнитеgo run .Готово, когда: обе программы напечатали одни и те же три строки, и вы можете показать в тексте второй программы, куда уехал каждый из трёх комментариев первой.
Одна функция — одно дело
Главное правило разбиения. У функции одна работа, и она вся названа в имени.
Признаки, что дело одно:
- Имя обходится без «и».
boxesFor— «сколько коробок». А если имя хочется назватьcountBoxesAndPrint, дел два, и это две функции. - Работу можно объяснить одной фразой без «потом». «Возвращает стоимость упаковки со скидкой» — одно дело. «Читает строку, разбирает число, считает коробки и печатает» — четыре.
- Функцию можно проверить отдельно. Дал числа — получил число, сравнил с посчитанным на бумаге. Если для проверки нужно запустить всю программу и посмотреть на экран, дел внутри больше одного.
Обратите внимание на третий признак: boxesFor, packCost и rubles ничего не печатают и ничего не читают. Они получают числа аргументами и возвращают результат. Печатает только main. Это не случайность, а сознательный порядок: посчитать и показать — разные дела.
Сколько строк должно быть в функции? Правила «не больше N строк» нет. Есть правило про одно дело. Обычно у функции получается от одной до двадцати строк, но длинная функция, которая делает ровно одно дело (например, разбирает строку заказа по формату), — нормальная функция, а короткая, которая делает два, — нет.
Числа в проверках при этом есть, и врать про них не будем. В задании split встретится «тело main — не больше 34 строк», а в задаче 3 контрольной точки дня 15 — «тело main не больше 32 строк, и ни одна своя функция не длиннее 20». Это не правила про длину функции и не оценка качества кода. Это пороги, которыми проверка отличает разобранную программу от нетронутой: в заготовке split тело main — 38 строк, а после честного выноса трёх названных в условии кусков там остаётся 33; функция длиннее двадцати строк на контрольной значит, что простыню переложили под другим именем, а не разрезали. Внутри этих порогов длина не оценивается: функция на две строки и функция на восемнадцать засчитываются одинаково.
Порядок функций в файле ни на что не влияет
Во второй программе boxesFor, packCost и rubles стоят выше main. Можно переставить их как угодно — поднять main наверх, разбросать остальные вокруг, — программа соберётся и напечатает то же самое. В Go функцию можно вызывать до того места, где она объявлена: компилятор сначала читает весь файл целиком и только потом проверяет вызовы.
Отсюда два порядка, которые встречаются в жизни, — оба законные:
| Порядок | Как выглядит | Кому удобно |
|---|---|---|
| сверху вниз | main первым, под ним вызываемые функции |
читающему: сначала оглавление, потом подробности |
| снизу вверх | мелкие функции сверху, main последним |
автору: пишешь кусок — сразу видишь его целиком |
В курсе используется второй: main в конце файла, как во всех заготовках заданий. Так сложилось в примерах Go, и так же устроены программы, которые вы читали до сегодня.
Важно другое: читать программу по порядку файла не надо ни в одном из двух случаев. Порядок чтения задаёт main, а не расположение на экране — об этом страница 3. Единственное, на что порядок в файле влияет, — сколько придётся мотать колесом мыши.
Одно исключение из «порядок не важен» есть, и оно про переменные, не про функции: переменная, объявленная через var на верхнем уровне файла, тоже видна всему файлу независимо от места объявления. Такие переменные мы не заводим, и почему — на странице 4.
Порядок работы на сегодня
Пять шагов дня 05 никуда не делись, разбиение на функции — это шаг 3, доведённый до кода:
| Шаг дня 05 | Что меняется сегодня |
|---|---|
| 1. Прочитать условие | без изменений |
| 2. Выписать примеры | без изменений; примеры пригодятся, чтобы сверить поведение до и после |
| 3. Разбить на подцели | каждая подцель — кандидат в функцию, её имя — имя функции |
| 4. Писать кусками | кусок = функция; написали — проверили на примере |
| 5. Сверить | прогнать те же примеры, что были до разбиения |
Для программы, которой ещё нет, функции придумывают на шаге 3. Для программы, которая уже написана и работает, куски выносят по одному и после каждого проверяют, что вывод не изменился, — об этом страница 5.
Своя функция и чужая устроены одинаково
В день 04 вы вызывали strings.ToUpper, strconv.Atoi, math.Ceil — и ни разу не заглянули внутрь. Хватало трёх вещей: как называется, что принимает, что возвращает. Устройство было не нужно.
Ровно это и происходит с вашими функциями, как только они написаны. boxesFor в main читается так же, как strings.ToUpper: имя, аргументы, результат. Разница только в том, что до объявления boxesFor можно дойти по F12 (страница 3), а до ToUpper — тоже можно, но там сотни строк чужого кода.
| Что нужно знать о функции, чтобы её вызвать | strings.ToUpper |
boxesFor |
|---|---|---|
| имя | ToUpper |
boxesFor |
| что принимает | строку | штуки и вместимость коробки, целые |
| что возвращает | новую строку | число коробок, целое |
| где узнать | go doc strings.ToUpper |
заголовок функции, F12 |
| нужно ли читать тело | нет | нет |
Отсюда простое следствие: функция получилась удачной, если её можно вызывать, не читая. Если для правильного вызова приходится открывать тело и выяснять, «а что она делает с нулём» или «в чём у неё цена», значит, либо имя врёт, либо дел внутри больше одного, либо в заголовке не видно единиц измерения. Все три — темы страниц 2 и 4.
Что может пойти не так
| Что видите | Что это значит | Что делать |
|---|---|---|
| после разбиения вывод изменился | при переносе куска что-то потерялось или порядок строк сдвинулся | сравнить вывод до и после (страница 5), вернуть прежний порядок |
функций много, а main всё равно не читается |
функции названы по тому, как они устроены, а не что дают | переименовать по смыслу результата (страница 2) |
| функция считает и сразу печатает | дел два | разделить: считает одна, печатает другая или сам main |
| в функцию пришлось передать шесть чисел | вынесен не кусок работы, а случайные строки | резать по границам подцелей, а не по строкам |
./main.go:7:1: missing return |
в функции есть путь, на котором return не встретился |
return на каждом пути; день 11 |
Попробуйте сейчас: разметить простыню на куски.
Цель: увидеть в задании
splitготовые границы будущих функций.Откройте программу главного задания дня — она в одном куске, как первая программа этой страницы, только длиннее:
▶ Выполнитеcd ~/gocourse/day13/split cat main.goНайдите в
mainстроки-комментарии — их четыре. Каждый подписывает кусок работы; функциями к концу дня станут не меньше трёх из них — какие именно просятся, сказано вTASK.txt. Выпишите на бумаге четыре пары «комментарий — что этот кусок делает».Запускать и менять программу сейчас не нужно: сначала страницы 2–5.
Готово, когда: на бумаге четыре строки, и для каждой вы можете сказать, какие числа этому куску нужны на входе и что он отдаёт наружу.
Дальше: какой кусок выносить и как его назвать — course next