Страшилки преобразований

Это третья сказка, но вторую никак не допишу. Сегодня без ассемблера. Обещаю.

Надпись на обратной стороне. Осторожно. Неопределённое поведение. При сложении двух знаковых чисел, в случае возникновения переполнения, калькулятор может выдать ошибку или взорваться. ПРОВЕРЯЙТЕ переполнение или ИЗБЕГАЙТЕ складывания чисел.

Если вы программируете на языках C/C++ или варитесь возле этой темы, вам всегда будут попадаться люди двух типов:

  1. Оставляющие за собой код, рождающий неопределённое поведение, и отправляющие его на ревью с припиской — да не ссы, я так сто раз делал.
  2. Люди, рассказывающие про то, что ваша программа уничтожит весь род человеческий, если вы немедленно это не исправите. А лично вы попадёте в ад, где вам вечность будут зачитывать статьи про плюсы 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 триллиона 73 миллиарда 709 миллионов 551 тысяча 615. Потому как представление -1 в дополнительном коде — это единицы во всех разрядах, включая знаковый разряд, а разрядов всего 64.

Рассчитываете ли вы на то, что будет именно такой результат? Это был вопрос. Думаю, вы ответили — да. Но почему да? Вы можете подтвердить это цитатой из спецификации? Какая именно строка гарантирует вам, что значение -1 типа int64 будет представлено как максимальное беззнаковое число после конвертации в беззнаковый тип? Да, я не хочу слышать про представления чисел в памяти, я хочу услышать гарантию.

Нашли? Она есть, в разделе Conversions between numeric types:

When converting between integer types, if the value is a signed integer, it is sign extended to implicit infinite precision; otherwise it is zero extended. It is then truncated to fit in the result type’s size.

Знаковое -1 расширяется знаком до «бесконечной» точности — получается бесконечная цепочка единиц, — а потом обрезается до 64 бит. Остаются 64 единицы, то есть максимальный uint64. Гарантия есть. А что насчёт такого:

package main

import "fmt"

func main() {
	var a float64 = float64(-1)
	var b uint64 = uint64(a)
	fmt.Println(b)
}

Если вы быстро проверяете на Go Playground, то видите, что результат точно такой же. Вы с подозрением проверяете его go vet, но предупреждений нет. Всё ли тут хорошо? Вы можете рассчитывать на этот результат в этом случае? На этот результат рассчитывал программист кастомного кода хеширования, тесты на который провалились на компьютере архитектуры arm64.

Хорошо, поищем чего в спецификации связанного с конвертацией числа с плавающей точкой в число целое. Вот, например, есть такое:

When converting a floating-point number to an integer, the fraction is discarded (truncation towards zero).

Так, отбросили дробную часть, а дальше по аналогии с uint64, поэтому результат аналогичен?

А что, если я скажу, что результатом может быть 0? Да, ноль, ни одного установленного бита в результирующем uint64. И это правильный ответ. Если у вас компьютер на arm64 (например, Mac на Apple Silicon), вы можете запустить пример локально и увидеть число 0. Если такого под рукой нет, можете попробовать WebAssembly, там вы вообще получите 9223372036854775808, само число для меня загадка, но если вы знаете, как оно получилось, обязательно напишите мне.

Обновление. Разгадка: 9223372036854775808 — это 0x8000000000000000. В Go до 1.25 включительно на WebAssembly такая конверсия вызывает функцию runtime.wasmTruncU, а она для NaN, слишком больших и отрицательных чисел возвращает именно 0x8000000000000000. Начиная с Go 1.26 компилятор использует инструкцию i64.trunc_sat_f64_u, и на WebAssembly тоже получится 0 — ещё один пример того, что implementation-dependent результат может меняться от версии к версии.

Ответ на основной вопрос — нет. Мы не можем рассчитывать на один, конкретный результат. Потому что результат — 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 поведение. Должно ли где-то документироваться поведение для реализаций? Должно ли это поведение сохраняться от версии к версии одной реализации? Я могу рассчитывать, что на arm64 всегда будет 0? (Спойлер: на WebAssembly оно уже поменялось — см. обновление выше.)

Например, в стандарте C++ есть очень похожий термин — 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 conversion succeeds, а результат — загадка. Я бы посоветовал избегать implementation-dependent конструкций.

Как видите, наша участь немного лучше, чем у программистов на C++: у них такая же конверсия отрицательного double в беззнаковый тип — это не implementation-defined, а полноценное undefined behavior (conv.fpint: «The behavior is undefined if the truncated value cannot be represented in the destination type»). Но всё же как просто совершить ошибку, пропустив не самый заметный блок в спецификации. На этом всё, надеюсь, вам стало интересно, какие ещё конструкции языка Go могут рождать implementation-dependent поведение. Расскажите?


  1. Глубочайшие извинения всем угандцам, если в их стране дети не голодают, мне просто нужна была случайная африканская страна с названием, намекающим на то, что там может кто-то голодать. ↩︎

  2. Если она впоследствии будет определена, я обязательно поправлю статью. Сейчас на дворе лето 2025 и Go 1.24. ↩︎