вторник, 29 октября 2019 г.

Константы в Golang

Go - это статически типизированный язык, который не разрешает операции, которые смешивают числовые типы. Вы не можете добавить float64 к int или даже int32 к int. Тем не менее, допустимо писать 1e6*time.Second или math.Exp(1) или даже 1<<('\t'+2.0). В Go константы, в отличие от переменных, ведут себя почти как обычные числа. Этот пост объясняет, почему это так и что это значит.

Background: C

В первые дни размышления о Go говорили о ряде проблем, вызванных тем, как C и его потомки позволяют смешивать и сопоставлять числовые типы. Многие загадочные ошибки, сбои и проблемы с переносимостью вызваны выражениями, которые объединяют целые числа разных размеров и "знаковость". Хотя для опытного программиста C результат вычисления, как

unsigned int u = 1e9;
long signed int i = -1;
... i + u ...

может быть знаком, но это априори не очевидно. Насколько велик результат? Каково его значение? Результат со знаком или без?

Здесь прячутся неприятные ошибки.

C имеет набор правил, называемых "обычными арифметическими преобразованиями", и это показатель их тонкости, что они изменились за годы (вводя еще больше ошибок, задним числом).

При разработке Go было решено избежать этого минного поля, обязав не смешивать числовые типы. Если вы хотите добавить i и u, вы должны четко указать, каким должен быть результат. Указав

var u uint
var i int

вы можете написать либо uint(i)+u, либо i+int(u), с четко выраженным значением и типом сложения, но в отличие от C вы не можете написать i+u. Вы даже не можете смешивать int и int32, даже если int 32-битный тип.

Эта строгость устраняет общую причину ошибок и других сбоев. Это жизненно важное свойство Go. Но это имеет свою цену: иногда программистам приходится украшать свой код неуклюжими числовыми преобразованиями, чтобы ясно выразить свое значение.

А как насчет констант? Учитывая приведенные выше заявления, что может быть законным для записи i = 0 или u = 0? Какой тип 0? Было бы неразумно требовать, чтобы константы имели преобразования типов в простых контекстах, таких как i = int(0).

Вскоре стало ясно, что ответ заключается в том, чтобы заставить числовые константы работать не так, как они ведут себя в других C-подобных языках. После долгих размышлений и экспериментов, был придуман дизайн, который, почти всегда прав, освобождая программиста от постоянного преобразования констант, но при этом он может писать такие вещи, как math.Sqrt(2), без упреков от компилятора.

Кратко, константы в Go просто работают, по крайней мере в большинстве случаев. Посмотрим, как это происходит.

Терминология

Сначала быстрое определение. В Go const - это ключевое слово, вводящее имя для скалярного значения, такого как 2 или 3.14159 или "scrumptious". Такие значения, именованные или нет, называются константами в Go. Константы также могут быть созданы выражениями, построенными из констант, таких как 2+3 или 2+3i или math.Pi/2 или ("go"+"pher").

Некоторые языки не имеют констант, а другие имеют более общее определение константы или применение слова const. Например, в C и C++ const является классификатором типа (type qualifier), который может кодировать более сложные свойства более сложных значений.

Но в Go константа - это просто неизменное значение, и с этого момента мы говорим только о Go.

Строковые константы

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

Строковая константа заключает некоторый текст в двойные кавычки. (Go также имеет необработанные строковые литералы, заключенные в обратные кавычки ``, но для целей этого обсуждения они имеют все те же свойства.) Вот строковая константа:

"Hello, 世界"

Какой тип у этой строковой константы? Очевидный ответ - строка, но это неправильно.

Это нетипизированная строковая константа, то есть постоянная текстовая величина, которая еще не имеет фиксированного типа. Да, это строка, но это не значение Go типа string. Она остается нетипизированной строковой константой, даже если ей присвоено имя:

const hello = "Hello, 世界"

После этого объявления hello также является нетипизированной строковой константой. Нетипизированная константа - это просто значение, тип которого еще не определен, что заставило бы его подчиняться строгим правилам, предотвращающим объединение значений различных типов.

Именно это понятие нетипизированной константы позволяет использовать константы в Go с большой свободой.

Так что же тогда является типизированной строковой константой? Это та, которая получила тип, как следующая:

const typedHello string = "Hello, 世界"

Обратите внимание, что объявление typedHello имеет явный строковый тип перед знаком равенства. Это означает, что typedHello имеет строку типа Go и не может быть назначен переменной Go другого типа. То есть этот код работает:

var s string
s = typedHello
fmt.Println(s)

но этот код не работает

type MyString string
var m MyString
m = typedHello // Type error
fmt.Println(m)

Переменная m имеет тип MyString, и ей нельзя присвоить значение другого типа. Ему могут быть назначены только значения типа MyString, например:

const myStringHello MyString = "Hello, 世界"
m = myStringHello // OK
fmt.Println(m)

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

m = MyString(typedHello)
fmt.Println(m)

Возвращаясь к нашей нетипизированной строковой константе, у нее есть полезное свойство, заключающееся в том, что, поскольку у нее нет типа, ее присвоение типизированной переменной не вызывает ошибку типа. То есть мы можем написать

m = "Hello, 世界"

или

m = hello

потому что, в отличие от типизированных констант typedHello и myStringHello, нетипизированные константы "Hello, 世界" и hello не имеют типа. Присвоение их переменной любого типа, совместимой со строками, работает без ошибок.

Конечно, эти нетипизированные строковые константы являются строками, поэтому их можно использовать только там, где разрешена строка, но у них нет типа string.

Тип по умолчанию

Как программист на Go, вы наверняка видели много объявлений вроде

str := "Hello, 世界"

и теперь вы можете спросить: "Если константа нетипизирована, как str получает тип в этом объявлении переменной?" Ответ заключается в том, что нетипизированная константа имеет тип по умолчанию, неявный тип, который она передает значению, если нужен тип, в котором ничего не указано. Для нетипизированных строковых констант этот тип по умолчанию, очевидно, является string, поэтому

str := "Hello, 世界"

или

var str = "Hello, 世界"

означает точно то же, что и

var str string = "Hello, 世界"

Один из способов думать о нетипизированных константах заключается в том, что они живут в неком идеальном пространстве значений, пространстве, менее ограничивающем, чем система полного типа Go. Но чтобы что-то с ними сделать, нам нужно присвоить их переменным, и когда это произойдет, переменная (а не сама константа) нуждается в типе, и эта константа может сообщить переменной, какой у нее тип. В этом примере str становится значением типа string, потому что нетипизированная строковая константа дает объявлению тип по умолчанию, string.

В таком объявлении переменная объявляется с типом и начальным значением. Однако иногда, когда мы используем константу, назначение значения не так ясно. Например, рассмотрим это утверждение:

fmt.Printf("%s", "Hello, 世界")

Сигнатура fmt.Printf

func Printf(format string, a ...interface{}) (n int, err error)

то есть его аргументы (после format string) являются значениями интерфейса. Что происходит, когда fmt.Printf вызывается с нетипизированной константой, так это то, что значение интерфейса создается для передачи в качестве аргумента, а конкретный тип, хранимый для этого аргумента, является типом константы по умолчанию. Этот процесс аналогичен тому, что мы видели ранее, когда объявляли инициализированное значение, используя нетипизированную строковую константу.

Вы можете увидеть результат в этом примере, который использует формат %v для печати значения и %T для печати типа значения, передаваемого в fmt.Printf:

fmt.Printf("%T: %v\n", "Hello, 世界", "Hello, 世界")
// string: Hello, 世界

fmt.Printf("%T: %v\n", hello, hello)
// string: Hello, 世界

Если константа имеет тип, который входит в интерфейс, как показано в следующем примере:

fmt.Printf("%T: %v\n", myStringHello, myStringHello)
// main.MyString: Hello, 世界

Таким образом, типизированная константа подчиняется всем правилам типизированных значений в Go. С другой стороны, нетипизированная константа не несет тип Go таким же образом и может смешиваться и сопоставляться более свободно. Однако у нее есть тип по умолчанию, который предоставляется, когда никакая другая информация о типе не доступна.

Тип по умолчанию определенный синтаксисом

Тип по умолчанию для нетипизированной константы определяется ее синтаксисом. Для строковых констант единственным возможным неявным типом является строка. Для числовых констант неявный тип имеет большее разнообразие. Целочисленные константы по умолчанию равны int, константы с плавающей точкой - float64, константы рун - rune (псевдоним для int32), мнимые константы - complex128. Вот канонический оператор print, который неоднократно использовался для отображения типов по умолчанию в действии:

fmt.Printf("%T %v\n", 0, 0)
fmt.Printf("%T %v\n", 0.0, 0.0)
fmt.Printf("%T %v\n", 'x', 'x')
fmt.Printf("%T %v\n", 0i, 0i)

int 0
float64 0
int32 120
complex128 (0+0i)

Булевы константы (Booleans)

Все, что мы говорили о нетипизированных строковых константах, можно сказать и о нетипизированных булевых константах. Значения true и false являются нетипизированными логическими константами, которые могут быть назначены любой логической переменной, но как только задан тип, логические переменные не могут быть смешаны:

type MyBool bool
const True = true
const TypedTrue bool = true
var mb MyBool
mb = true      // OK
mb = True      // OK
mb = TypedTrue // Ошибка
fmt.Println(mb)

Константы с плавающей точкой

Константы с плавающей точкой во многом похожи на логические константы.

type MyFloat64 float64
const Zero = 0.0
const TypedZero float64 = 0.0
var mf MyFloat64
mf = 0.0       // OK
mf = Zero      // OK
mf = TypedZero // Ошибка
fmt.Println(mf)

Есть недостаток в том, что в Go есть два типа с плавающей точкой: float32 и float64. Тип по умолчанию для константы с плавающей точкой - float64, хотя нетипизированная константа с плавающей точкой может быть назначена значению float32:

var f32 float32
f32 = 0.0
f32 = Zero      // OK: Zero нетипизированна
f32 = TypedZero // Ошибка: TypedZero это float64, 
                // а не float32.
fmt.Println(f32)

Значения с плавающей точкой являются хорошим местом для представления концепции переполнения или диапазона значений.

Числовые константы живут в числовом пространстве произвольной точности; они просто обычные числа. Но когда они назначены переменной, значение должно соответствовать цели. Мы можем объявить константу с очень большим значением:

const Huge = 1e1000

В конце концов, это просто число, но мы не можем присвоить его или даже напечатать. Это утверждение даже не скомпилируется:

fmt.Println(Huge)

Ошибка: constant 1e+1000 overflows float64 (константа 1e+1000 переполняет float64), что верно. Но Huge может быть полезен: мы можем использовать его в выражениях с другими константами и использовать значение этих выражений, если результат может быть представлен в диапазоне float64. Утверждение,

fmt.Println(Huge / 1e999)

печатает 10, как и следовало ожидать.

Аналогичным образом, константы с плавающей точкой могут иметь очень высокую точность, так что арифметика с ними более точна. Константы, определенные в пакете math, даны с гораздо большим количеством цифр, чем доступно в float64. Вот определение math.Pi:

Pi = 3.14159265358979323846264338327950288419716939937510582097494459

Когда это значение присваивается переменной, часть точности будет потеряна; присваивание создаст значение float64 (или float32), наиболее близкое к значению высокой точности. Этот фрагмент

pi := math.Pi
fmt.Println(pi)

печатает 3.141592653589793.

Наличие такого большого количества цифр означает, что вычисления, такие как Pi/2 или другие более сложные вычисления, могут нести большую точность до тех пор, пока не будет присвоен результат, что облегчает запись вычислений с использованием констант без потери точности. Это также означает, что не бывает случаев, когда краевые случаи с плавающей точкой, такие как бесконечности, мягкие переполнения и NaN, возникают в постоянных выражениях. (Деление на постоянный ноль - это ошибка времени компиляции, и когда все является числом, нет такой вещи, как "не число".)

Сложные числа (Complex numbers)

Сложные константы ведут себя во многом как константы с плавающей точкой.

type MyComplex128 complex128
const I = (0.0 + 1.0i)
const TypedI complex128 = (0.0 + 1.0i)
var mc MyComplex128
mc = (0.0 + 1.0i) // OK
mc = I            // OK
mc = TypedI       // Ошибка
fmt.Println(mc)

Типом комплексного числа по умолчанию является complex128, версия с большей точностью, состоящая из двух значений float64.

Для ясности в нашем примере мы выписали полное выражение (0.0+1.0i), но это значение можно сократить до 0.0+1.0i, 1.0i или даже 1i.

Попробуем выполнить трюк. Мы знаем, что в Go числовая константа - это просто число. Что, если это число является комплексным числом без мнимой части, то есть является действительным? Вот одно из них:

const Two = 2.0 + 0i

Это нетипизированная комплексная константа. Даже если она не имеет мнимой части, синтаксис выражения определяет, что она имеет тип по умолчанию complex128. Поэтому, если мы используем его для объявления переменной, тип по умолчанию будет complex128. Фрагмент

s := Two
fmt.Printf("%T: %v\n", s, s)

печатает complex128: (2+0i). Но численно Two можно хранить в скалярном числе с плавающей точкой, float64 или float32, без потери информации. Таким образом, мы можем без проблем назначить Two как float64, либо в инициализации, либо в присваивании:

var f float64
var g float64 = Two
f = Two
fmt.Println(f, "and", g)

Выходными данными являются 2 и 2. Несмотря на то, что Two является сложной константой, ее можно назначить скалярным переменным с плавающей точкой. Эта способность константы "скрещивать" типы, подобные этой, окажется полезной.

Целые числа

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

type MyInt int
const Three = 3
const TypedThree int = 3
var mi MyInt
mi = 3          // OK
mi = Three      // OK
mi = TypedThree // Ошибки
fmt.Println(mi)

Тот же пример может быть построен для любого целочисленного типа, а именно:

int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64
uintptr

(плюс byte - псевдоним для uint8, и rune - псевдоним для int32). Это много, но модель работы констант должна быть уже достаточно знакома, чтобы вы могли видеть, как все будет происходить.

Как упоминалось выше, целые числа бывают нескольких форм, и каждая форма имеет свой собственный тип по умолчанию: int для простых констант, таких как 123 или 0xFF или -14, и rune для символов в кавычках, таких как 'a', '世' или '\r'.

Никакая форма константы не имеет в качестве типа по умолчанию целочисленный тип без знака (uint). Тем не менее, гибкость нетипизированных констант означает, что мы можем инициализировать целочисленные переменные без знака, используя простые константы, если нам все ясно о типе. Это аналогично тому, как мы можем инициализировать float64, используя комплексное число с нулевой мнимой частью. Вот несколько разных способов инициализации uint; все они эквивалентны, но все должны явно указывать тип, чтобы результат был без знака.

var u uint = 17
var u = uint(17)
u := uint(17)

Подобно проблеме диапазона, упомянутой в разделе о значениях с плавающей точкой, не все целочисленные значения могут вписываться во все целочисленные типы. Могут возникнуть две проблемы: значение может быть слишком большим или значение может быть отрицательным, назначаемым целому типу без знака. Например, int8 имеет диапазон от -128 до 127, поэтому константы вне этого диапазона никогда не могут быть назначены переменной типа int8:

var i8 int8 = 128 // Ошибка: слишком большое.

Аналогично, uint8, также известный как byte, имеет диапазон от 0 до 255, поэтому большая или отрицательная постоянная не может быть назначена для uint8:

var u8 uint8 = -1 // Ошибка: отрицательное значение.

Эта проверка типов может отловить такие ошибки:

type Char byte
var c Char = '世' // Ошибка: '世' имеет значение 0x4e16, 
                 // слишком большое.

Если компилятор жалуется на использование вами константы, это, вероятно, настоящая ошибка, подобная этой.

Упражнение: самый большой беззнаковый int

Вот небольшое информативное упражнение. Как мы выражаем константу, представляющую наибольшее значение, которое умещается в uint? Если бы мы говорили об uint32, а не об uint, мы могли бы написать

const MaxUint32 = 1<<32 - 1

но мы хотим uint, а не uint32. Типы int и uint имеют одинаковые неопределенные количества битов, 32 или 64. Поскольку количество доступных битов зависит от архитектуры, мы не можем просто записать одно значение.

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

const MaxUint uint = -1 // Ошибка: отрицательное значение

но это недопустимо, потому что -1 не может быть представлен беззнаковой переменной; -1 не находится в диапазоне значений без знака. Конверсия тоже не поможет по той же причине:

const MaxUint uint = uint(-1) // Ошибка: отрицательное значение

Несмотря на то, что во время выполнения значение -1 может быть преобразовано в целое число без знака, правила для константных преобразований запрещают этот тип приведения во время компиляции. То есть это работает:

var u uint
var v = -1
u = uint(v)

но только потому, что v является переменной; если бы мы сделали v константой, даже нетипизированной константой, мы вернулись бы на запрещенную территорию:

var u uint
const v = -1
u = uint(v) // Ошибка: отрицательное значение

Мы возвращаемся к нашему предыдущему подходу, но вместо -1 мы пробуем ^0, побитовое отрицание произвольного числа нулевых битов. Но это тоже не получается по той же причине: в пространстве числовых значений ^0 представляет бесконечное число единиц, поэтому мы теряем информацию, если присвоим ее любому целому числу фиксированного размера:

const MaxUint uint = ^0 // Ошибка: переполнение

Как тогда мы представляем самое большое целое число без знака как константу?

Ключ заключается в том, чтобы ограничить операцию количеством битов в uint и избегать значений, таких как отрицательные числа, которые не могут быть представлены в uint. Простейшим значением uint является типизированная константа uint(0). Если uint'ы имеют 32 или 64 бита, uint(0) имеет 32 или 64 нулевых бита соответственно. Если мы инвертируем каждый из этих битов, мы получим правильное количество битов, которое является наибольшим значением uint.

Поэтому мы не переворачиваем биты нетипизированной константы 0, мы переворачиваем биты типизированной константы uint(0). Вот наша константа:

const MaxUint = ^uint(0)
fmt.Printf("%x\n", MaxUint)
// Вывод: ffffffff 

Какое бы количество бит не потребовалось для представления uint в текущей среде исполнения (в песочнице это 32), эта константа правильно представляет наибольшее значение, которое может содержать переменная типа uint.

Числа

Концепция нетипизированных констант в Go означает, что все числовые константы, будь то целочисленные, с плавающей точкой, комплексные или даже символьные значения, живут в неком объединенном пространстве. Именно когда мы приводим их в вычислительный мир переменных, присваиваний и операций, фактические типы имеют значение. Но пока мы остаемся в мире числовых констант, мы можем смешивать и сопоставлять значения по своему усмотрению. Все следующие константы имеют числовое значение 1:

1
1.000
1e3-99.0*10-9
'\x01'
'\u0001'
'b' - 'a'
1.0+3i-3.0i

Поэтому, хотя они имеют разные неявные типы по умолчанию, записанные как нетипизированные константы, они могут быть назначены переменной любого целочисленного типа:

var f float32 = 1
var i int = 1.000
var u uint32 = 1e3 - 99.0*10.0 - 9
var c float64 = '\x01'
var p uintptr = '\u0001'
var r complex64 = 'b' - 'a'
var b byte = 1.0 + 3i - 3.0i

fmt.Println(f, i, u, c, p, r, b)

Выходные данные из этого фрагмента: 1 1 1 1 1 (1+0i) 1.

Вы даже можете делать такие сумасшедшие вещи, как

var f = 'a' * 1.5
fmt.Println(f)

что дает 145,5, что бессмысленно, кроме как доказать концепцию.

Но настоящая суть этих правил - гибкость. Эта гибкость означает, что, несмотря на то, что в Go в одном выражении запрещено смешивать переменные с плавающей точкой и целые числа, или даже переменные int и int32, можно писать

sqrt2 := math.Sqrt(2)

или

const millisecond = time.Second/1e3

или

bigBufferWithHeader := make([]byte, 512+1e6)

и результаты означают то, что вы ожидаете.

Потому что в Go числовые константы работают так, как вы ожидаете: как числа.


Читайте также:


Комментариев нет:

Отправить комментарий