День 12 · Несколько результатов и ошибка как результат · страница 5 из 7

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

Это главный дефект сегодняшнего дня и одна из самых дорогих ошибок в Go вообще. Программа собирается, gofmt доволен, go vet молчит, все строки на месте — а результат неверный, и в коде глазами не за что зацепиться. Разница между правильной и сломанной программой — одно двоеточие перед знаком равенства.


Что делает := внутри блока

Ситуация. На складе 100 штук. Если их больше 50, нужно отгрузить 40 и списать ещё 10, а потом напечатать остаток.

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

import "fmt"

func main() {
	stock := 100
	if stock > 50 {
		stock := stock - 40
		stock = stock - 10
		fmt.Println("внутри if:", stock)
	}
	fmt.Println("после if:", stock)
}

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

Цель: предсказать две строки вывода программы с затенением и сдать числа.

1. Программа выше печатает два числа. Не запуская, выпишите на бумаге, что будет в каждой строке. Разбор — в разделе «Что увидите» сразу под этим блоком: читайте его после того, как отправите ответ.

2. Сдайте два числа через пробел — сначала то, что напечатано внутри if, потом то, что после:

▶ Выполните · выделенное замените своим
course answer day12.q4 ЧИСЛО ЧИСЛО

3. Проверьте запуском — наберите ту же программу в scratch/main.go:

✎ Наберите в файл scratch/main.go
package main

import "fmt"

func main() {
	stock := 100
	if stock > 50 {
		stock := stock - 40
		stock = stock - 10
		fmt.Println("внутри if:", stock)
	}
	fmt.Println("после if:", stock)
}
▶ Выполните
cd ~/gocourse/day12/scratch
go run .

4. Потом замените stock := stock - 40 на stock = stock - 40 и запустите ещё раз.

Готово, когда: пункт q4 в course check 12 зелёный, а после замены := на = вторая строка стала после if: 50.


Что увидите.

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day12/scratch$ go run .
внутри if: 50
после if: 100

Как это читать. Строка 8 — stock := stock - 40 — читается так: «объявить новую переменную stock в этом блоке и положить в неё значение выражения stock - 40». Справа от := стоит ещё старая, внешняя stock — её значение 100, поэтому в новой оказывается 60. Дальше строка 9 работает уже с новой: 60 − 10 = 50. Печать внутри блока видит новую, печать после блока — старую, которой никто не менял.

Такая новая переменная затеняет внешнюю: пока блок не кончился, имя stock означает её. Как только блок закрылся, имя снова означает внешнюю.

Строка Какая stock Что в ней
6 stock := 100 внешняя, создана 100
7 if stock > 50 внешняя, читается 100
8 stock := stock - 40 внутренняя, создана 60
9 stock = stock - 10 внутренняя, изменена 50
10 печать внутренняя 50
12 печать после } внешняя 100

Чтобы менялась внешняя переменная, нужен =, а не :=:

Код для чтения · разбираем, набирать не нужно
	stock := 100
	if stock > 50 {
		stock = stock - 40
		stock = stock - 10
	}
	fmt.Println("после if:", stock)

Тогда после блока напечатается 50.

Короткое правило. := — «создать», = — «положить в существующую». Внутри блока := всегда создаёт новое имя, даже если такое имя снаружи уже есть.


Самый опасный случай: затенённая err

Числа ещё заметны глазом. Хуже, когда затеняется err: тогда программа молча принимает плохие данные за хорошие.

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

import (
	"fmt"
	"strconv"
)

func main() {
	qty := 0
	var err error
	if true {
		qty, err := strconv.Atoi("12шт")
		fmt.Println("внутри:", qty, err)
	}
	fmt.Println("снаружи:", qty, err)
}
Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day12/scratch$ go run .
внутри: 0 strconv.Atoi: parsing "12шт": invalid syntax
снаружи: 0 <nil>

Строка var err error — это знакомое с дня 02 объявление «переменная такого-то типа, значение пока по умолчанию», как var sold int. У типа error значение по умолчанию — nil, «ошибки нет»; поэтому в последней строке и напечаталось <nil>. Здесь она нужна только для опыта: в настоящих программах err появляется сразу из вызова.

Внутри блока ошибка есть. Снаружи — nil, «всё хорошо». Если после блока стоит привычное if err != nil, оно ничего не заметит: снаружи ошибки не было. Разбор строки не удался, а программа считает, что удался.

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

Сравните с днём 07. Там рядом стояли price, err := … и qty, err := … в одном блоке: err переиспользовалась, потому что слева появлялось новое имя qty, — переменная err оставалась одной и той же. Третье такое price, err := … в том же блоке уже не собиралось: no new variables on left side of :=, ни одного нового имени слева. Здесь имена слева тоже не новые, но блок другой — и потому обе переменные создаются заново, а сообщения об ошибке нет.

Часто такую программу выдаёт компилятор — если внутреннюю переменную не прочитали:

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

import (
	"fmt"
	"strconv"
)

func main() {
	qty := 0
	if true {
		qty, err := strconv.Atoi("12")
		if err != nil {
			fmt.Println("ошибка")
		}
	}
	fmt.Println(qty)
}
Вывод · это печатает программа, набирать не нужно
# day12/scratch
./main.go:11:3: declared and not used: qty

err внутри блока прочитана, а qty — нет: внешнюю qty печатают после блока, но это другая ячейка. Сообщение declared and not used на строке с := — верный признак затенения.


То же самое в цикле

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

import "fmt"

func main() {
	total := 0
	for day := 1; day <= 3; day++ {
		total := day * 10
		fmt.Println("день", day, "начислено", total)
	}
	fmt.Println("итого:", total)
}
Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day12/scratch$ go run .
день 1 начислено 10
день 2 начислено 20
день 3 начислено 30
итого: 0

В дне 08 это разбиралось как «накопитель объявлен внутри цикла». Теперь видно, что это тот же самый механизм: тело цикла — блок, := создаёт в нём новую переменную на каждом проходе, а внешний total так и остаётся нулём.


Почему это не ловится инструментами

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day12/scratch$ go vet .
stagiaire@lab:~/gocourse/day12/scratch$

go vet молчит — и это честно: с точки зрения языка программа правильная. Затенение — законный приём, его иногда пишут нарочно. Отличить «нарочно» от «случайно» может только человек, который знает, что программа должна делать.

Чем ловится на самом деле:

  1. Чтением вслух. На каждой строке с := спросите себя: «я создаю здесь новую переменную?» Если ответ «нет, я меняю ту, что снаружи», — нужен =.
  2. Печатью значений. Метод дня 09: печать до блока, внутри блока и после него. Если «внутри» и «после» разошлись — переменных две.
  3. Сообщением declared and not used на строке с := — почти всегда затенение.
  4. Отладчиком. В dlv команда locals внутри блока покажет две переменные с одним именем (опыт в course extra 12).

И профилактика: не заводите внутри блока переменную с тем же именем, что снаружи. Нужна пересчитанная величина — дайте ей своё имя: stockAfter, partLeft. Тогда := не соврёт.


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

Что видите Что это значит Что делать
после блока у переменной старое значение внутри блока := создал новую переменную = вместо :=
declared and not used: qty на строке с := внутри блока переменная создана заново и в блоке не прочитана = вместо :=, если хотели менять внешнюю
if err != nil после блока ничего не находит err внутри блока была своя забирать результат в те же переменные через = или проверять внутри блока
счётчик всё время равен единице счётчик := счётчик + 1 внутри блока =
итог после цикла нулевой накопитель объявлен в теле цикла объявление — до цикла, в теле = или +=
no new variables on left side of := все переменные слева уже объявлены в этом же блоке =
go vet и gofmt молчат, а программа считает не то затенение они не видят печать значений до блока, внутри и после

Последняя строка таблицы — про сегодняшнюю починку.


Попробуйте сейчас: что скажет go vet, и починка fix1.

Цель: увидеть, что go vet на затенение молчит, и починить счётчик в fix1.

1. Оставьте в scratch/main.go программу с stock := stock - 40 внутри if и запустите проверку:

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

go vet ничего не печатает — дефект он не видит.

2. Теперь fix1. Программа считает плохие строки и печатает нарастающий счётчик, а сейчас счётчик всё время равен единице:

▶ Выполните
cd ~/gocourse/day12/fix1
go run . < example.txt

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

▶ Выполните
go run . < example.txt
diff ~/.course/orig/day12/fix1/main.go main.go

4. Починка закончена — закоммитьте:

▶ Выполните
cd ~/gocourse/day12
git add .
git commit -m "fix1: счётчик плохих строк"

Готово, когда: go run . < example.txt печатает плохих строк: 1, плохих строк: 2 и Принято: 17 шт., плохих строк: 2, а в diff одна пара строк — < со старым := и > с новым =; пункты fix1, fixsmall и t_vetdiff в course check 12 зелёные.


Дальше: трассировка программы с двумя одноимёнными переменными — course next