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

От шагов решения к функциям

В день 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.go
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)
}

Запустите:

▶ Выполните
cd ~/gocourse/day13/scratch
go run .

Запомните три строки вывода.

2. Замените содержимое файла второй программой — той, что из четырёх функций, — и запустите снова:

✎ Наберите в файл scratch/main.go
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), "руб.")
}
▶ Выполните
go run .

Готово, когда: обе программы напечатали одни и те же три строки, и вы можете показать в тексте второй программы, куда уехал каждый из трёх комментариев первой.


Одна функция — одно дело

Главное правило разбиения. У функции одна работа, и она вся названа в имени.

Признаки, что дело одно:

Обратите внимание на третий признак: 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