Затенение: две переменные с одним именем
Это главный дефект сегодняшнего дня и одна из самых дорогих ошибок в 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.gopackage 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: qtyerr внутри блока прочитана, а 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 молчит — и это честно: с точки зрения языка программа правильная. Затенение — законный приём, его иногда пишут нарочно. Отличить «нарочно» от «случайно» может только человек, который знает, что программа должна делать.
Чем ловится на самом деле:
- Чтением вслух. На каждой строке с
:=спросите себя: «я создаю здесь новую переменную?» Если ответ «нет, я меняю ту, что снаружи», — нужен=. - Печатью значений. Метод дня 09: печать до блока, внутри блока и после него. Если «внутри» и «после» разошлись — переменных две.
- Сообщением
declared and not usedна строке с:=— почти всегда затенение. - Отладчиком. В
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.txt3. Найдите строку, где счётчик создаётся заново, и почините — правка одна-две строки, больше двух проверка не примет. Что должно получиться, написано в комментарии в начале файла. После правки:
▶ Выполнитеgo run . < example.txt diff ~/.course/orig/day12/fix1/main.go main.go4. Починка закончена — закоммитьте:
▶ Выполните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