Обложка для статьи, сгенерированная нейросетью :)
Обложка для статьи, сгенерированная нейросетью :)

Если где-то неправ, поправляйте. Если есть вопросы, задавайте. :)

Биты и байты

На заре появления первых компьютеров и программ перед инженерами встала проблема представления привычных им букв, цифр и знаков в понятный компьютеру формат. Нужно было придумать, как запрограммировать компьютер так, чтобы он мог хранить, например, строку "Hello", ведь символы "h", "e", "l", "o" ему непонятны - это не на его языке. Да и вообще таким понятием как "символ" компьютер не владеет.

Язык компьютера - это биты.

Бит — это один разряд двоичного кода (двоичная цифра). Может принимать только два взаимоисключающих значения: «да» или «нет», «1» или «0», «включено» или «выключено».

Да, в самой своей сути все компьютеры оперируют только битами - единицами и нулями. Вся информация, с которой работает компьютер, в конечном итоге представлена в виде единиц и нулей. То есть никакими буквами или прочими символами мы напрямую с компьютером обмениваться не можем - он просто нас не поймёт.

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

Байт — это совокупность бит, обрабатываемых компьютером одновременно. Если в качестве метафоры считать биты за буквы, то байты можно условно рассматривать в качестве слов. Байт состоит из восьми бит, каждый из которых содержит 0 или 1. Например:

  • 00000001 (в десятичной системе счисления равно 1)

  • 00000010 (в десятичной системе счисления равно 2)

  • 00000011 (в десятичной системе счисления равно 3)

  • и так далее, включая все уникальные перестановки этих восьми бит

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

ASCII

Итого мы имеем, что компьютер понимает биты и их объединения в байты. Это его язык. А языком инженеров, разрабатывавших первые компьютеры, был английский. И они хотели, чтобы компьютерные программы тоже могли обрабатывать английский язык, а не только нули и единицы. И было принято напрашивающееся решение: создать некий словарь между языком компьютера (битами и байтами) и языком инженеров (английским). Словарь должен был представлять таблицу, в которой каждому символу английского алфавита, каждой цифре и специальному символу сопоставлялось какое-то численное значение. Тогда компьютер смог бы сопоставлять непонятные ему символы с понятными ему численными значениями (каждое численное значение можно перевести в двоичный формат). И такой словарь был создан и назван ASCII.

ASCII (American Standard Code for Information Interchange) — стандарт кодирования букв латинского алфавита, цифр, некоторых специальных знаков и управляющих символов, принятый в 1963 году Американской ассоциацией стандартов как основной способ представления текстовых данных в ЭВМ.

Когда ASCII был создан, оставалось дело за малым - внедрять поддержку этого стандарта в компьютеры. Таким образом, постепенно разные системы смогли "общаться" друг с другом на одном языке, сопоставляя каждому ASCII-символу одно и то же численное значение. Благодаря этому, программа, написанная на одной системе, поддерживающей ASCII, могла корректно работать на другой системе, поддерживающей ASCII.

Но оставалась проблема - ASCII изначально был семибитной кодировкой (из восьми бит полезную нагрузку несли только семь, а восьмой использовался в служебных целях), а значит позволял закодировать в себя не более 128 символов (2^7). Таким образом, кроме латинского алфавита, цифр, знаков препинания и некоторых управляющих символов, в ASCII ничего не помещалось. И либо весь цифровой мир должен был использовать только латинский алфавит, либо нужно было изобретать более универсальное решение, которое бы позволило стандартизировать все остальные символы помимо латинских, а так же огромное количество эмодзи и прочих символов.

Unicode

Тогда в 1991-м впервые был предложен новый стандарт кодирования символов под названием Unicode. Unicode был призван включить в себя символы всех алфавитов мира, всех эмодзи и прочих специальных знаков, сопоставляя каждому символу численное обозначение. При этом в целях обратной совместимости с ASCII первые 128 позиций полностью с ним совпадали.

Стандарт состоит из двух основных частей: универсального набора символов (англ. Universal character set, UCS) и семейства кодировок (англ. Unicode transformation format, UTF). Универсальный набор символов перечисляет допустимые по стандарту Юникод символы и присваивает каждому символу код в виде неотрицательного целого числа, записываемого обычно в шестнадцатеричной форме с префиксом U+, например, U+040F. Семейство кодировок определяет способы преобразования кодов символов в двоичный вид для передачи в потоке или в файле.

Всего консорциумом было решено использовать 1 112 064 кодовых точек для совместимости всех способов представления Unicode (UTF). На данный момент под различные символы уже зарезервировано (то есть используется) около 160 000 кодовых точек.

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

Есть ещё, к примеру, UTF-32, который подразумевает выделение на каждый символ 32 бит (4 байта) вне зависимости от его численного значения по Unicode. Этот формат не оптимален с точки зрения использования памяти, но при этом реализует более простую логику преобразования символов, что иногда используется разработчиками оперативной памяти с целью повышения скорости обработки операций.

Go и Unicode

Согласно спецификации Go исходный код программ на этом языке всегда должен записываться в кодировке UTF-8. Ряд функций из пакетов стандартной библиотеки рассчитывают на то, что переданная ими строка будет в формате UTF-8. То же можно сказать и о цикле for-range при итерировании по строке.

А вот так можно вывести кодовые точки Unicode для всех символов строки:

fmt.Printf("%+q\n", "Хай") // "\u0425\u0430\u0439"

Go и строки

Строка - это массив байтов

Под капотом у Go для представления строковых значений на самом деле используются массивы байтов, то есть []byte, несмотря на то, что объявляем мы такие переменные типом данных string. Рассмотрим это на примере.

Объявим функцию stringStat(str string), которая принимает в качестве аргумента строку и выводит её значение, длину, полученную функцией len(), и массив байтов, представляющий эту строку.

func stringStat(str string) {
    fmt.Printf("Строка: %s\n", str)
    fmt.Printf("Длина: %d\n", len(str))
    fmt.Printf("Байты: %+v\n", []byte(str))
}

Вызовем эту функцию для строки "Hi", состоящей из двух букв латинского алфавита:

stringStat("Hi")

Вывод в терминале будет следующим:

Строка: Hi
Длина: 2
Байты: [72 105]

Здесь мы видим, что длина строки равняется двум, как мы и ожидали, ведь в строке два символа. Помимо этого мы видим массив байтов, состоящий из двух элементов, что также количественно соответствует длине строки с нашей точки зрения. 72 и 105 - это численные значения, полученные в результате работы алгоритмов UTF-8 по кодированию символов "H" и "i".

Теперь вызовем эту же функцию для строки с кириллицей, состоящей из трёх букв русского алфавита:

stringStat("Хай")

Вывод в терминале будет следующим:

Строка: Хай
Длина: 6
Байты: [208 165 208 176 208 185]

Первое, что можно заметить, это значение длины, равное 6. В переданном слове три буквы, но функция len() вернула значение 6.

Дело в том, что стандартная функция len() для подсчёта длины строки в Go всегда возвращает именно количество байтов, представляющих эту строку, то есть длину того самого массива байтов, который располагается в памяти вместо строки непосредственно.

В данном случае результатом работы алгоритмов UTF-8 по кодированию символов "Х", "а", "й" станет представленный выше массив байтов. Символ "Х" будет закодирован в два первых байта, "а" в третий и четвертый, а "й" - в пятый и шестой соответственно. Здесь наглядно видно, что алгоритмы UTF-8 выделяют разное количество байтов в зависимости от символа, и это количество байтов не совпадает с длиной слова в том смысле, в котором мы привыкли о ней думать. Некоторые китайские иероглифы после кодирования в UTF-8 занимают целых четыре байта. Многие эмодзи тоже представляются более чем одним байтом.

Как в таком случае достоверно узнать длину строки, обозначающую количество символов, а не байтов? С этим нам поможет тип данных rune.

Тип данных rune

Тип данных rune - это тип данных, используемый для работы с отдельными символами строки вне зависимости от того, сколько байт они занимают после кодирования UTF-8. Литерал типа данных rune - это одинарные кавычки.

Например, руну можно объявить следующим образом:

var r rune = 'Ы'

rune на самом деле под капотом Go является алиасом для int32. Они идентичны во всём, но существуют одновременно оба для повышения читаемости кода - чтобы там, где подразумевается работа с символами, явно декларировался rune, а там, где подразумевается работа с целыми числами - int32.

По этой причине, если вывести значение переменной типа данных rune в стандартный вывод, мы получим число, а не сам символ:

var r rune = 'Ы'

fmt.Println(r) // 1067

Это число после преобразования в шестнадцатеричную систему счисления будет равняться 42b, что обозначает кодовую точку этого символа в стандарте Unicode. Убедимся в этом следующим образом:

fmt.Printf("%+q\n", "Ы") // "\u042b"

Как мы видим, здесь тоже выводится 42b. То есть можно заключить, что число, хранящееся в переменной типа данных rune, обозначает кодовую точку Unicode записанного в переменную символа, выраженную в десятичной системе счисления.

Кстати, поскольку rune является алиасом для int32, следующее выражение будет абсолютно корректным:

var r int32 = 'Ы'

Так чем нам поможет rune?

Любую строковую переменную можно преобразовать в массив рун следующим образом:

message := "Хай"
runes := []rune(message)

Теперь можно сравнить вывод результатов вызова функции len() для строковой переменной message и для массива рун runes.

fmt.Println(len(message)) // 6
fmt.Println(len(runes)) // 3

Как мы видим, несмотря на то, что строка "Хай" состоит из шести байтов согласно UTF-8, функция len() от массива рун, созданного по этой строке, возвращает фактическое количество символов, а не байтов, что нам чаще всего и требуется.

Ещё один способ узнать количество символов (рун) в строке - это воспользоваться функцией пакета utf8 под названием RuneCountInString() - так можно избежать самостоятельного преобразования string в []rune:

fmt.Println(utf8.RuneCountInString("Хай")) // 3

Ну и посмотрим, как будет выглядеть вывод самого массива []rune:

fmt.Println([]rune("Хай")) // [1061 1072 1081]

Как и ожидалось, получаем массив числовых значений, представляющих в десятичной системе счисления кодовые точки Unicode для соответствующих им символов.

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

message := "Хай"  
  
for i, r := range message {  
    fmt.Println(i, r)  
}

// 0 1061
// 2 1072
// 4 1081

Но почему мы видим такие странные значения у индексов? Казалось бы, должны выводиться 0, 1, 2. Здесь, как вы могли догадаться, индексы отображают позицию первого байта символа, представленного текущей руной. Как мы помним из примера выше, строка "Хай" имеет по два байта на каждый символ - вот и получается, что выводится индекс первого байта каждого символа.

Например, в случае с итерацией по строке, состоящей из однобайтовых символов, индексы будут "в порядке":

message := "Hei"  
  
for i, r := range message {  
    fmt.Println(i, r)  
}

// 0 72
// 1 101
// 2 105

Получение байтов по индексу

Go позволяет обращаться к байтам строки по индексу:

message := "Хай"  

fmt.Println(message[0])  
fmt.Println(message[1])  
fmt.Println(message[2])  
fmt.Println(message[3])  
fmt.Println(message[4])  
fmt.Println(message[5])  

fmt.Printf("Все байты: %v\n", []byte(message))

// 208
// 165
// 208
// 176
// 208
// 185
// Все байты: [208 165 208 176 208 185]

Следует помнить, что обращение по индексу в случае со строкой происходит именно к байту, а не к символу/руне. Например, в следующем примере тип данных переменной b - это byte. И b при этом содержит числовое значение, а именно 208.

b := message[0]

fmt.Println(b) // 208

Если необходимо осуществить обращение к символам строки, можно использовать либо for-range, либо преобразование строки в массив рун и последующее обращение по индексу:

runes := []rune(message)

a, b, c := runes[0], runes[1], runes[2]
fmt.Println(a, b, c) // 1061 1072 1081

as, bs, cs := string(a), string(b), string(c)
fmt.Println(as, bs, cs) // Х а й

Иммутабельная природа строк

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

Вы можете возразить, что хоть мы и не можем расширять массив, но мы же можем изменять его конкретные элементы! Да, но в случае с массивом байтов, представляющим строку, нельзя даже просто изменять элементы. Этот запрет действует во избежание множества потенциальных ошибок во время выполнения программ и с идеей повышения безопасности языка. Если бы это было возможно, мы бы как минимум должны были учитывать, что имеем дело с массивом байтов, а не символов. Соответственно, чтобы заменить символ, нам нужно определить все его байты и заменить их на другие. При этом новых байтов должно быть столько же, сколько было старых, поскольку мы не можем изменять длину массива. А это значит, что мы бы не смогли заменить, например, однобайтовый символ "W" на двухбайтовый символ "Й", и наоборот. В общем, строки неизменяемы :)

А если все-таки нужно изменять строку?

Изменять не получится, но конструкции, как в следующем примере, вполне возможны. Рассмотрим его и разберёмся, что в нём происходит:

str := ""

for i := 0; i < 10; i++ {
    str += strconv.Itoa(i)
}

fmt.Println(str) // "0123456789"

Этот код корректен и, казалось бы, нам удалось изменить строку на каждой итерации цикла for. Но на самом деле это не так. Под капотом Go создавал новую строку на каждой итерации цикла и помещал её в переменную str. И это на самом деле обходится недёшево. В крошечных масштабах эффекта можно и не ощутить, но в реальном коде такой подход использовать не стоит. Это известный bad practice.

Но задача построить строку подобным образом возникает регулярно, так как же её решать? Лучший способ сделать это - использовать специально предназначенную для этой цели структуру Builder из пакета strings, например:

builder := strings.Builder{}  
  
for i := 0; i < 10; i++ {  
    builder.WriteString(strconv.Itoa(i))  
}  
  
str := builder.String()

Сравним для наглядности производительность этих двух подходов. Напишем реализацию функций построения строки:

// Первый подход - плохой
func FillString() string {  
    str := ""  
  
    for i := 0; i < 1000; i++ {  
       str += strconv.Itoa(i)  
    }  
  
    return str  
}  

// Второй подход - best practice
func BuildString() string {  
    builder := strings.Builder{}  
  
    for i := 0; i < 1000; i++ {  
       builder.WriteString(strconv.Itoa(i))  
    }  
  
    return builder.String()  
}

И добавим две Benchmark-функции для тестовых прогонов с замером производительности:

func BenchmarkFillString(b *testing.B) {  
    for i := 0; i < b.N; i++ {  
       FillString()  
    }  
}  

func BenchmarkBuildString(b *testing.B) {  
    for i := 0; i < b.N; i++ {  
       BuildString()  
    }  
}

Запустим тест с помощью команды go test -bench=. -benchmem ./test_test.goи ознакомимся с результатами:

BenchmarkFillString-10  6960  171093 ns/op 1496914 B/op 1899 allocs/op
BenchmarkBuildString-10 72207 16757 ns/op  11328 B/op   911 allocs/op

Как видим, на более менее больших объемах данных разница становится колоссальной - как в потреблении памяти, так и в затрате процессорного времени.

Так что собираем строки через strings.Builder{} :)

Вот список всех функций (а точнее в данном случае методов), которые предоставляет strings.Builder{}:

  • Cap() int

  • Grow(n int)

  • Len() int

  • Reset()

  • String() string

  • Write(p []byte) (int, error)

  • WriteByte(c byte) error

  • WriteRune(r rune) (int, error)

  • WriteString(s string) (int, error)

Популярная ошибка

Выше было видно, что для преобразования целого числа в строку используется strconv.Itoa(i), хотя практически у всех новичков в Go рука сама тянется написать string(i). Это распространённая ошибка.

Дело в том, что целочисленный параметр на входе в string() считается кодовой точкой Unicode. Таким образом, вспомнив, что у русской буквы "Х" кодовая точка - это 1061 (можно проверить через fmt.Println('Х')), следующий код выведет именно эту букву, а не строку "1061":

fmt.Println(string(1061)) // Х

Для корректного преобразования целого числа в строку следует использовать функцию Itoa() из пакета strconv. А для преобразования строки в число - strconv.Atoi().

Itoa расшифровывается как "Integer to ASCII", а Atoi - "ASCII to Integer". Но при чем тут ASCII, если сейчас эпоха Unicode? Дело в том, что названия этих функций просто унаследованы из языка C и, разумеется, они работают с Unicode, а не только с ASCII.

Взятие подстроки от строки

Go позволяет извлечь подстроку из исходной строки таким же синтаксисом, который используется для срезания среза:

message := "Hello everybody!"
firstWord := message[:5]

fmt.Println(firstWord) // Hello

Однако, подобная индексация по строке, как мы помним, ссылается на байты, а не символы, так что её следует использовать только в строках с однобайтовыми символами или если вы точно знаете, что делаете.

Ещё один нюанс такого взятия подстроки заключается в том, что в приведённом выше примере firstWord - это строка, которая ссылается на тот же массив байтов, что и исходная переменная message. Изменять строки мы не можем, поэтому одна строка не может "сломать" другую, но проблема тут всё же есть.

Заключается она в том, что если у нас есть строка с длинным значением, например, с содержимым текстового файла, и мы берём из неё маленький кусочек с необходимой нам подстрокой с помощью синтаксиса "срезания" (см. пример выше), то поскольку обе эти строки ссылаются на один и тот же массив байтов, всё это огромное значение исходной строки продолжит существовать в памяти в полном объёме. То есть такой синтаксис срезания не делает копию массива байтов для создания новой строки, а вместо этого ссылает новую строку на уже существующий массив байтов с указанием самого левого нужного байта и самого правого. Поэтому здесь возможен такой сценарий непреднамеренного расходования лишней памяти.

Соответственно, чтобы избежать утечки, нужно сделать копию:

firstWord := string([]byte(message[:5]))

Или использовать strings.Clone(), появившийся в Go 1.18:

firstWord := strings.Clone(message[:5])

Обратные кавычки

Допустимым строковым литералом являются не только двойные, но и обратные кавычки. Они удобны тем, что сохраняют расставленные переносы строк, не схлопывают пробелы и позволяют удобно расставлять внутри себя одинарные и двойные кавычки без экранирования.

message := `
    Hello, everybody!
    Welcome to "Tortuga!"
`

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

В Go есть три основных способа сравнения строк:

  • оператор ==

  • strings.Compare()

  • strings.EqualFold()

Если нас интересует сравнение, учитывающее регистр символов, то есть мы считаем, что "Hello" и "hello" не должны быть равны, то лучший вариант - это использование оператора ==. Его превосходство в производительности будет тем значительнее, чем больше длины сравниваемых строк.

Однако, если нас интересует регистронезависимое сравнение, в рамках которого мы считаем, что строки "Hello" и "hello" должны быть равны, самым производительным способом будет использование strings.EqualFold(), который производит регистронезависимое сравнение. Этот способ в среднем будет в четыре раза быстрее остальных двух, для которых еще и придется явно использовать strings.ToLower() или strings.ToUpper().

Строки в качестве ключей в map

Строки могут быть использованы в качестве ключей в отображениях (map).

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

Полезные функции стандартной библиотеки

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

go doc strings
go doc unicode
go doc utf8
go doc bytes

Полезные ссылки

Комментарии (0)