Страшилки преобразований
Это третья сказка, но вторую никак не допишу. Сегодня без ассемблера. Обещаю.
Надпись на обратной стороне. Осторожно. Неопределённое поведение. При сложении двух знаковых чисел, в случае возникновения переполнения, калькулятор может выдать ошибку или взорваться. ПРОВЕРЯЙТЕ переполнение или ИЗБЕГАЙТЕ складывания чисел.
Если вы программируете на языках C/C++ или варитесь возле этой темы, вам всегда будут попадаться люди, двух типов:
- Оставляющие за собой код рождающий неопределённое поведение и отправляющие его на ревью с припиской - да не ссы, я так сто раз делал.
- Люди рассказывающие про то, что ваша программа уничтожит весь род человеческий если вы немедленно это не исправите. А лично вы попадёте в ад, где вам вечность будут зачитывать статьи про плюсы PHP.
Что же нам, программистам на Go, когда читаем очередную статью про неопределённое поведение и сопутствующий ей холивар, она вызывает, обычно, ровно такие же эмоции, как и, например, новость про голодающих детей в Уганде1, то есть никакие.
Как же нам хорошо, ведь у нас в языке такого произойти не может.
ИЛИ МОЖЕТ?
Программисты не боятся Бабы Яги, но боятся вещественной арифметики. Ха, не верите, ну так попробуйте подойти к программисту ночью в костюме float64. Если что, я вам этого не говорил.
Да не, брось, какое ещё неопределённое поведение - слышу я от людей начиная этот рассказ. И правда в том, что чаще всего словосочетание неопределённое поведение ассоциируется у людей как неопределённое поведение (далее UB) из стандартов C/C++. И нужно заранее договориться что я не говорю про UB из C/C++. Ведь какое отношение могут иметь стандарты C/C++ к языку Go. Никакого.
Но. Вещь, про которую мы поговорим сегодня, отчасти напоминает UB, так как семантика этой вещи определена не полностью2 и мы можем интерпретировать её в данных нам границах. Чтобы писать корректный код, нужно точно знать что нам гарантирует спецификация языка.
Думаю что вы часто преобразовываете числовые типы данных, числа с плавающей точкой в целые, знаковые в беззнаковые и так далее. Отсюда и пойдём.
Представим код который преобразует знаковый тип в беззнаковый.
package main
import "fmt"
func main() {
var a int64 = int64(-1)
var b uint64 = uint64(a)
fmt.Println(b)
}
Всё ли здесь хорошо? Playground.
Что вы ожидаете получить в переменной b
? Если вы знакомы с дополнительным
кодом и, самое главное, у вас отличная память, то вы без труда ответите что
это 18 квинтиллионов 446 квадриллионов 744 триллионов 073 миллиардов 709
миллионов 551 тысяч 615. Потому как представление -1 в дополнительном коде
это единицы во всех разрядах включая знаковый разряд, а разрядов всего 64.
Рассчитываете ли вы на то, что будет именно такой результат? Это был вопрос. Думаю вы ответили —
да. Но почему да? Вы можете подтвердить это цитатой из спецификации? Какая
именно строка гарантирует вам что значение -1
типа int64
будет представлено как максимальное
беззнаковое число после конвертации в беззнаковый тип? Да, я не хочу
слышать про представления чисел в памяти, я хочу услышать гарантию.
Нашли? А что насчёт такого:
package main
import "fmt"
func main() {
var a float64 = float64(-1)
var b uint64 = uint64(a)
fmt.Println(b)
}
Если вы быстро проверяете на Go Playground, то видите что результат
точно такой же. Вы с подозрением проверяете его go vet
, но предупреждений нет.
Всё ли тут хорошо? Вы можете рассчитывать на этот результат в этом случае? На этот
результат рассчитывал программист кастомного кода хеширования
тесты на который провалились на компьютере архитектуры ARM.
Хорошо, поищем чего в спецификации связанного с конвертацией числа с плавающей точкой в число целое, вот например есть такое:
When converting a floating-point number to an integer, the fraction is discarded (truncation towards zero).
Так, отбросили дробную часть, а дальше по аналогии с uint64
, поэтому результат аналогичен?
А что если я скажу что результатом может быть 0? Да, ноль, ни одного установленного бита в результирующем uint64
. И это правильный ответ. Если у вас
компьютер на базе семейств архитектур ARM вы можете запустить пример локально и увидеть число 0.
Если такого под рукой нет, можете попробовать WebAssembly,
там вы вообще получите 9223372036854775808, само число для меня загадка, но если вы знаете как оно получилось, обязательно напишите мне.
Ответ на основной вопрос — нет. Мы не можем рассчитывать на один, конкретный результат. Потому что результат — implementation-dependent.
- Операция прошла успешно. Результат зависит от того как посмотреть.
- ЧТО?
Посмотрим внимательно на абзац (он расположен в конце блока озаглавленного Conversions between numeric types
):
In all non-constant conversions involving floating-point or complex values, if the result type cannot represent the value the conversion succeeds but the result value is implementation-dependent.
Для всех не константных преобразований (типов) затрагивающих числа с плавающей точкой, если результирующий тип не может представить значение, конверсия будет успешной, но результирующее значение зависит от реализации.
Подпадет ли это под наш кейс, очевидно — да. Наш пример затрагивает числа с плавающей точкой. И да наш
результирующий тип uint64
не может представить число -1
, потому что uint64
не может представить никакое отрицательное
число, являясь беззнаковым типом данных.
Проблема в том, что спецификация Go не определяет что такое implementation-dependent поведение. Должно ли где-то документироваться поведение для реализаций? Должно ли это поведение сохраняться для всех для одной реализации на протяжении времени? Я могу рассчитывать что на ARM всегда будет 0?
Например, в стандарте С++ есть очень похожий термин - implementation-defined behavior
:
behavior, for a well-formed program ([defns.well.formed]) construct and correct data, that depends on the implementation and that each implementation documents
Говорит нам о том, что результат зависит от реализации и каждая реализация документирует поведение. Насчёт гошного implementation-dependent я в спецификации
не нашёл, единственное, что можно сказать, программа после такой конверсии не запаникует — the value the conversion succeeds
, а результат — загадка.
Я бы посоветовал избегать implementation-dependent конструкций.
Как видите наша участь немного лучше, чем у программистов на C++, но всё же как просто совершить ошибку пропустив не самый заметный блок в спецификации. На этом всё, надеюсь вам стало интересно, какие ещё конструкции языка Go могут рождать implementation-dependent поведение. Расскажите?