После пяти лет работы JavaScript-разработчиком, занимаясь как фронтендом, так и бэкендом, я провел последний год, осваивая Go для серверной разработки. За это время мне пришлось переосмыслить многие вещи. Различия в синтаксисе, базовых принципах, подходах к организации кода и, конечно, в средах выполнения — все это довольно сильно влияет не только на производительность приложения, но и на эффективность разработчика.
Интерес к Go в JavaScript-сообществе тоже заметно вырос. Особенно после новости от Microsoft о том, что они переписывают официальный компилятор TypeScript на Go — и обещают ускорение до 10 раз по сравнению с текущей реализацией.
Эта статья — своего рода путеводитель для JavaScript-разработчиков, которые задумываются о переходе на Go или просто хотят с ним познакомиться. Я постарался структурировать материал вокруг ключевых особенностей языка, сравнивая их с привычными концепциями из JavaScript/TypeScript. И, конечно, расскажу о «подводных камнях», с которыми столкнулся лично — с багажом мышления JS-разработчика.
В этой части мы рассмотрим следующие аспекты этих языков:
-
Основы
-
Компиляция и выполнение
-
Пакеты
-
Переменные
-
Структуры и типы
-
Нулевые значения
-
Указатели
-
Функции
-
-
Массивы и срезы
-
Отображения (maps)
Поскольку у JavaScript имеется несколько сред выполнения, во избежание лишней путаницы, в этой статье я буду сравнивать Go с Node.js — ведь и Go, и Node в первую очередь используются на сервере. Кроме того, сегодня TypeScript фактически является стандартом в веб-разработки, поэтому большинство примеров в статье будет на нем.
Основы
Компиляция и выполнение
Первое фундаментальное различие — то, как выполняется код. Go — это компилируемый язык, то есть перед запуском код необходимо собрать в исполняемый бинарный файл, содержащий машинный код. В свою очередь, JavaScript интерпретируемый язык, код можно выполнять сразу, без предварительной компиляции (в V8 существует ряд оптимизаций, выполняемых в процессе JIT-компиляции — например, он умеет выявлять «горячие» участки кода (hot paths) и компилировать их в машинный код, но эти детали выходят за рамки статьи, поэтому углубляться в них не будем).
Например, в Node.js можно просто создать JS-файл и сразу запустить его через командную строку с помощью node:
// hello.js console.log("Hello, World!")
node hello.js Hello, World!
Чтобы начать работать с Go, нужно скачать бинарную версию языка под вашу операционную систему с официального сайта: https://go.dev/dl/.
Вот как выглядит классическая программа «Hello, World!» на Go:
// hello.go package main import "fmt" func main() { fmt.Println("Hello, World!") }
Подробности синтаксиса, использованного в примере, мы рассмотрим в следующих разделах.
Чтобы запустить эту программу, ее сначала нужно скомпилировать, а затем выполнить полученный бинарный файл:
go build hello.go ./hello Hello, World!
Или можно воспользоваться командой run, которая компилирует и запускает программу за один шаг:
go run hello.go Hello, World!
Поскольку Go компилируется в нативный машинный код, для разных платформ нужно создавать отдельные бинарные файлы под соответствующую архитектуру. К счастью, в Go это делается довольно просто с помощью переменных окружения GOOS и GOARCH.
Пакеты
Любая программа на Go состоит из пакетов (модулей, package) и всегда начинается с выполнения пакета main. Внутри этого пакета обязательно должна быть функция с именем main — именно она служит точкой входа в программу. Когда выполнение main() завершается, программа завершает свою работу.
// main.go package main import ( "fmt" ) func main() { fmt.Println("Hello world") }
Для краткости в остальных примерах я буду опускать
package mainиfunc main(). Если захочется посмотреть, как работает тот или иной фрагмент, можно будет воспользоваться ссылками на Go Playground.
Пакеты в Go во многом похожи на модули в JS — это просто набор связанных между собой исходных файлов. Создание и импорт пакетов в Go напоминает импорт модулей в JS. Например, в приведенном выше фрагменте мы импортируем пакет fmt из стандартной библиотеки Go.
fmt(сокращение от format) — один из базовых пакетов в Go. Он отвечает за форматированный ввод/вывод и во многом повторяет подход, использованный вprintfиscanfиз языка C. В примере выше мы использовали функциюPrintln, которая выводит аргументы в дефолтном формате и добавляет перевод строки в конце.
Далее по тексту вы также встретите функцию
Printf— она позволяет выводить текст, отформатированный с помощью спецификаторов. Подробнее о доступных спецификаторах можно почитать в официальной документации.
Аналогично тому, как в JS-проектах используется файл package.json, в Go-программах есть файл go.mod. Это конфигурационный файл модуля, в котором содержится информация о самом модуле и его зависимостях. Пример стандартного go.mod:
module myproject go 1.16 require ( github.com/gin-gonic/gin v1.7.4 golang.org/x/text v0.3.7 )
Первая строка указывает путь импорта модуля, который служит его уникальным идентификатором. Вторая строка — минимально требуемая версия Go для работы модуля. Далее идут все зависимости — как прямые, так и косвенные — с указанием конкретных версий.
Чтобы создать пакет в Go, достаточно создать новую директорию с нужным именем — и все Go-файлы внутри нее автоматически будут частью этого пакета, если в начале каждого файла указано соответствующее имя с помощью директивы package.
Интересно реализована и система экспорта. В JS (с ESM) мы явно указываем export, чтобы сделать функцию или переменную доступной за пределами модуля.
В Go все проще: если имя начинается с заглавной буквы — оно экспортируется.
Пример ниже демонстрирует все вышесказанное:
// go.mod module myproject go 1.24 // main.go package main import ( "fmt" "myproject/fib" ) func main() { sequence := fib.FibonacciSequence(10) // Это вызовет ошибку // firstFibonacciNumber := fib.fibonacci(1) fmt.Println("Fibonacci sequence of first 10 numbers:") fmt.Println(sequence) } // fib/fib.go package fib // Эта функция не экспортируется, так как ее имя начинается с маленькой буквы func fibonacci(n int) int { if n <= 0 { return 0 } if n == 1 { return 1 } return fibonacci(n-1) + fibonacci(n-2) } // Эта функция экспортируется, так как ее имя начинается с заглавной буквы func FibonacciSequence(n int) []int { sequence := make([]int, n) for i := 0; i < n; i++ { sequence[i] = fibonacci(i) } return sequence }
В приведенном примере мы создали пакет fib, просто создав директорию с таким именем.
Обратите внимание: из двух функций экспортируется только FibonacciSequence, так как ее имя начинается с заглавной буквы — именно поэтому она доступна за пределами пакета.
Переменные
Go — это язык со статической типизацией, то есть тип каждой переменной должен быть либо явно указан, либо выведен автоматически, и проверка типов выполняется еще на этапе компиляции. В отличие от JS, где переменные могут содержать значения любого типа, и типизация проверяется только во время выполнения программы.
Например, в JS вполне допустим следующий код:
let x = 5; let y = 2.5; let sum = x + y; // Все работает: 7.5 let weird = x + "2"; // Тоже "работает": "52" (но, возможно, это не совсем то, что мы ожидали получить)
А вот в Go с типами нужно быть гораздо осторожнее: все примитивные типы перечислены здесь.
Ключевое слово var в Go выполняет примерно ту же роль, что и let в современном JS.
var x int = 5 // Или x := 5 — это короткое присваивание (short assignment), // которое можно использовать вместо var с неявным указанием типа var y float64 = 2.5 // Такой код не скомпилируется sum := x + y // Error: mismatched types int and float64 // Преобразовывать тип следует явно sum := float64(x) + y
Стоит отметить, что TypeScript помогает решить проблему с типами в JS, но в конечном итоге это все же лишь синтаксическое расширение JS, которое компилируется все в тот же JS.
Аналогично JS, в Go тоже есть ключевое слово const, которое используется для объявления констант. Объявляются они так же, как переменные, но с использованием const вместо var:
const pi float64 = 3.14 // Или без указания типа, он будет определен автоматически const s = "hello"
В отличие от JS, в Go с помощью const можно объявлять только примитивные значения — такие как символы, строки, логические и числовые типы. Для более сложных типов данных const в Go не применяется.
В Go объявление переменной, которая затем не используется, приводит не к предупреждению, как это бывает в JavaScript или TypeScript при использовании линтеров, а к полноценной ошибке компиляции.
Структуры и типы
В JS для представления набора полей используют объекты. В Go для этого существуют структуры (structs):
type Person struct { Name string Age int } p := Person{ Name: "John", Age: 32, } // Создаем составную структуру type User struct { Person Person ID string } u := User{ Person: p, ID: "123", }
В Go поля структуры нужно именовать с заглавной буквы, чтобы они были экспортируемыми (то есть доступными в других пакетах или для сериализации в JSON). Поля с именами, начинающимися со строчной буквы, не экспортируются и доступны только внутри пакета.
На первый взгляд синтаксис может показаться похожим на TypeScript — особенно на типы или интерфейсы, но поведение отличается. В TypeScript типы только определяют форму значений (контракт), поэтому допустимо передать объекты, содержащие больше полей, чем указано в типе — и это сработает без ошибок.
В Go же структуры — это конкретные типы данных, и совместимость при присваивании определяется по имени, а не по структуре. Так что если в TypeScript такой код будет работать:
interface Person { name: string, age: number } interface User { name: string, age: number, username: string } function helloPerson(p: Person) { console.log(p) } helloPerson({ name: "John", age: 32 }) const x: User = { name: "John", age: 32, username: "john", } helloPerson(x)
То в Go нет:
type Person struct { Name string Age int } type User struct { Name string Age int Username string } func HelloPerson(p Person) { fmt.Println(p) } func main() { // Этот вариант работает без ошибок HelloPerson(Person{ Name: "John", Age: 32, }) // Этот — не сработает x := User{ Name: "John", Age: 32, Username: "john", } // Error: cannot use x (type User) as type Person in argument to HelloPerson HelloPerson(x) // Чтобы все заработало, нужно выполнить явное преобразование // HelloPerson(Person{Name: x.Name, Age: x.Age}) }
type в Go используется не только для определения структур. С их помощью можно определять любые значения, которые может хранить переменная:
type ID int var i ID i = 2
Часто встречающийся сценарий — создание строковых перечислений (enum):
type Status string const ( StatusPending Status = "pending" StatusApproved Status = "approved" StatusRejected Status = "rejected" ) type Response struct { Status Status Meta string } res := Response{ Status: StatusApproved, Meta: "Request successful", }
В отличие от исключающих объединений (discriminated unions) в TypeScript, пользовательские типы в Go (например, Status) — это лишь псевдонимы для базового типа. Переменной типа Status можно присвоить любую строку:
var s Status s = "hello" // Это компилируется
В TypeScript система типов является полноценно вычислимой (Turing complete), что позволяет расширять и преобразовывать существующие типы, создавать новые и выполнять сложные вычисления непосредственно на уровне типов. Это открывает возможности для продвинутой проверки типов и создания безопасных абстракций:
type Person = { firstName: string; lastName: string; age: number; } // Расширенный тип, включающий все свойства Person // и добавляющий дополнительные свойства type Doctor = Person & { speciality: string; } type Res = { status: "success", data: Person } | { status: "error", error: string } // Res — исключающее объединение, которое позволяет // обращаться к разным свойствам в зависимости от статуса function getData(res: Res) { switch (res.status) { case "success": console.log(res.data) break; case "error": console.log(res.error) break; } } // Тип, в котором все свойства необязательны type OptionalDoctor = Partial<Doctor> // Тип, содержащий только свойства firstName и speciality type MinimalDoctor = Pick<Doctor, "firstName" | "speciality">
В Go структуры в первую очередь служат контейнерами для данных и не обладают возможностями изменения типов, как это реализовано в TypeScript. Ближайший аналог этому в Go — встраивание структур (struct embedding), которое позволяет реализовать композицию и представляет собой своего рода наследование:
type Person struct { FirstName string LastName string } type Doctor struct { Person Speciality string } d := Doctor{ Person: Person{ FirstName: "Bruce", LastName: "Banner", }, Speciality: "gamma", } fmt.Println(d.Person.FirstName) // Bruce // Ключи встроенных структур "поднимаются" наверх, // поэтому этот вариант тоже работает fmt.Println(d.FirstName) // Bruce
Нулевые значения
Еще одна вещь, которая может сбить с толку JS-разработчика — это концепция нулевых значений в Go. В JS, если объявить переменную без присвоения значения, ее значением по умолчанию будет undefined:
let x: number | undefined; console.log(x); // undefined x = 3 console.log(x) // 3
В Go, если определить переменную без явного значения, ей автоматически присваивается так называемое «нулевое значение». Вот какие значения по умолчанию получают некоторые примитивные типы:
var i int // 0 var f float64 // 0 var b bool // false var s string // "" x := i + 7 // 7 y := !b // true z := s + "string" // string
Аналогично, структуры в Go получают нулевые значения по умолчанию для всех своих полей:
type Person struct { name string // "" age int // 0 } p := Person{} // Создает структуру Person с пустым именем и возрастом 0
В Go есть значение nil, похожее на null в JS, но его могут принимать только переменные ссылочных (reference) типов. Чтобы понять, что это за типы, нужно разобраться с указателями (pointers) в Go.
Указатели
В Go есть указатели, похожие на те, что используются в языках C и C++, где указатель хранит в памяти адрес, по которому находится значение.
Указатель на тип T объявляется с помощью синтаксиса *T. Нулевое значение любого указателя в Go — это nil.
var i *int i == nil // true
Оператор & создает указатель на свой операнд, а оператор * получает значение по указателю — это называется разыменованием (dereferencing) указателя:
x := 42 i := &x fmt.Println(*i) // 42 *i = 84 fmt.Println(x) // 84
Следует иметь в виду, что попытка разыменования указателя, равного nil, приведет к ошибке null pointer dereference:
var x *string fmt.Println(*x) // panic: runtime error: invalid memory address or nil pointer dereference
Это подводит нас к важному отличию для JS-разработчиков: за исключением примитивных значений, в JS все передается по ссылке автоматически, тогда как в Go это делается явно с помощью указателей. Например, объекты в JS передаются по ссылке, поэтому если изменить объект внутри функции, изменится и исходный объект:
let obj = { value: 42 } function modifyObject(o) { o.value = 84 // Исходный объект изменяется } modifyObject(obj) console.log(obj.value) // 84
В Go почти все передается по значению (кроме срезов (slices), отображений (maps) и каналов (channels), о чем мы поговорим позже), если не использовать указатели. Поэтому такой код в Go работать не будет:
type Object struct { Value int } func modifyObject(o Object) { o.Value = 84 } o := Object{Value: 42} modifyObject(o) fmt.Println(o.Value) // 42
Но если использовать указатели:
func modifyObjectPtr(o *Object) { o.Value = 84 // Упрощенный синтаксис для работы со структурами, // фактически выполняется (*o).Value } o := Object{Value: 42} modifyObjectPtr(&o) fmt.Println(o.Value) // 84
Это связано с тем, что при передаче указателя мы передаем адрес памяти исходного объекта, что позволяет напрямую менять его значение. И это касается не только структур — указатель можно создать для любого типа, включая примитивные:
func modifyValue(x *int) { *x = 100 } y := 42 modifyValue(&y) fmt.Println(y) // 100
Функции
Мы уже вкратце рассмотрели функции в Go в предыдущем разделе, и, как вы, наверное, уже догадались, они во многом похожи на функции в JS. Их сигнатура тоже довольно схожа, за исключением ключевого слова — в Go используется func вместо function.
func greet(name string) string { if name == "" { name = "there" } return "Hello, " + name }
Как и в JS, функции в Go являются первоклассными (first-class) — их можно присваивать переменным, передавать в качестве аргументов и возвращать из других функций. Благодаря этому поддерживаются функции высшего порядка и замыкания. Например:
func makeMultiplier(multiplier int) func(int) int { return func(x int) int { return x * multiplier } } double := makeMultiplier(2) double(2) // 4
В Go также можно возвращать несколько значений из функции. Этот подход особенно полезен при обработке ошибок — к этому мы еще вернемся в одном из следующих разделов:
func parseName(fullName string) (string, string) { parts := strings.Split(fullName, " ") if len(parts) < 2 { return parts[0], "" } return parts[0], parts[1] } firstName, lastName := parseName("Bruce Banner") fmt.Printf("%s, %s", lastName, firstName) // Banner, Bruce
Массивы и срезы
В Go, в отличие от JS, массивы имеют фиксированную длину — она является частью их типа, поэтому менять ее нельзя. Пусть это и звучит как ограничение, но у Go есть удобное решение, которое мы рассмотрим позже.
Давайте освежим в памяти, как массивы работают в JS:
let s: Array<number> = [1, 2, 3]; s.push(4) s[1] = 0 console.log(s) // [1, 0, 3, 4]
Чтобы объявить массив в Go, нужно указать его размер, например так:
var a [3]int // Это создает массив из 3 элементов с нулевыми значениями: [0 0 0] a[1] = 2 // [0 2 0] // Можно также определить массив с начальными значениями b := [3]int{1,2,3}
Обратите внимание, что метода push нет — в Go массивы имеют фиксированную длину. И вот тут на сцену выходят срезы (slices). Срез — это динамически изменяемый и гибкий «прозрачный» доступ к массиву:
c := [6]int{1,2,3,4,5,6} d := c[1:4] // [2 3 4]
С первого взгляда это может показаться похожим на срез в JS, но важно помнить: в JS срез — это поверхностная копия массива, а в Go срез хранит ссылку на исходный массив. Поэтому в JS это работает:
let x: Array<number> = [1, 2, 3, 4, 5, 6]; let y = x.slice(1, 4) y[1] = 0 console.log(x, y) // x = [1, 2, 3, 4, 5, 6] // y = [2, 0, 4]
Изменение среза в Go влияет на исходный массив, поэтому для приведенного выше примера:
y[1] = 0 fmt.Println(x) // [1 0 3 4 5 6]
Интересная особенность — литералы срезов. Их можно создавать без указания длины массива:
var a []int // или b := []int{1,2,3} a == nil // true
Для переменной b создается тот же массив, что мы видели ранее, но b хранит срез, который ссылается на этот массив. И если вспомнить нулевые значения из предыдущего раздела, то нулевым значением для среза является nil, поэтому в приведенном примере a будет иметь значение nil, так как указатель на базовый массив равен nil.
Кроме базового массива, срезы также имеют длину и емкость: длина — это количество элементов, которые срез содержит в данный момент, а емкость — количество элементов в базовом массиве. Доступ к длине и емкости среза можно получить с помощью методов len и cap, соответственно:
s := []int{1,2,3,4,5,6} t := s[0:3] fmt.Printf("len=%d cap=%d %v\n", len(t), cap(t), t) // len=3 cap=6 [1 2 3]
В приведенном примере срез t имеет длину 3, так как он был взят из исходного массива именно с таким количеством элементов, но исходный массив при этом имеет емкость 6.
Также можно использовать встроенную функцию make для создания среза с помощью синтаксиса make([]T, len, cap). Эта функция выделяет нулевой массив и возвращает срез, ссылающийся на этот массив:
a := make([]int, 5) // len(a)=5, cap(a)=5 b := make([]int, 0, 5) // len(b)=0, cap(b)=5
В Go есть встроенная функция append, которая позволяет добавлять элементы в срез, не думая о его длине и емкости:
a := []int{1,2,3} a = append(a,4) // [1 2 3 4]
append() всегда возвращает срез, который содержит все элементы исходного среза плюс добавленные значения. Если исходный массив слишком мал, чтобы вместить новые элементы, append() создает новый массив большего размера и возвращает срез, указывающий на этот новый массив (команда Go подробно объясняет, как это работает, в одной из своих статей).
В отличие от JS, в Go нет встроенных декларативных функций высшего порядка, таких как map, reduce, filter и т.п. Поэтому для обхода срезов или массивов используется обычный цикл for:
for i, num := range numbers { fmt.Println(i, num) } // Или так, если требуется только само число // for _, num := range numbers
И напоследок: как известно, в JS массивы — это не примитивный тип, поэтому они всегда передаются по ссылке:
function modifyArray(arr) { arr.push(4); console.log("Внутри функции:", arr); // Внутри функции: [1, 2, 3, 4] } const myArray = [1, 2, 3]; modifyArray(myArray); console.log("Снаружи функции:", myArray); // Снаружи функции: [1, 2, 3, 4]
В Go массивы передаются по значению, а срезы, как мы уже обсуждали, описывают часть массива и содержат указатель на него. Поэтому при передаче среза изменения его элементов влияют на исходный массив:
func modifyArray(arr [3]int) { arr[0] = 100 fmt.Println("Массив внутри функции:", arr) // Массив внутри функции: [100, 2, 3] } func modifySlice(slice []int) { slice[0] = 100 fmt.Println("Срез внутри функции:", slice) // Срез внутри функции: [100, 2, 3] } myArray := [3]int{1, 2, 3} mySlice := []int{1, 2, 3} modifyArray(myArray) fmt.Println("Массив после вызова функции:", myArray) // Массив после вызова функции: [1, 2, 3] modifySlice(mySlice) fmt.Println("Срез после вызова функции:", mySlice) // Срез после вызова функции: [100, 2, 3]
Отображения (maps)
В Go отображения по своей сути гораздо ближе к Map в JS, чем к обычным JS-объектам (JSON), которые чаще всего используются для хранения пар ключ–значение.
Давайте вспомним, как работают отображения в JS:
const userScores: Map<string, number> = new Map(); // Добавляем пары ключ–значение userScores.set('Alice', 95); userScores.set('Bob', 82); userScores.set('Charlie', 90); // Определяем интерфейс для объекта с возрастом пользователя interface UserAgeInfo { age: number; } // Альтернативное создание Map с начальными значениями и использованием интерфейса const userAges: Map<string, UserAgeInfo> = new Map([ ['Alice', { age: 28 }], ['Bob', { age: 34 }], ['Charlie', { age: 22 }] ]); // Получаем значения console.log(userScores.get('Alice')); // 95 // Удаляем элемент userScores.delete('Bob'); // Размер отображения (количество элементов) console.log(userScores.size); // 2
А вот как с отображениями работают в Go:
// Создание отображения userScores := map[string]int{ "Alice": 95, "Bob": 82, "Charlie": 90, } type UserAge struct { age int } // Альтернативный способ создания userAges := make(map[string]UserAge) userAges["Alice"] = UserAge{age: 28} userAges["Bob"] = UserAge{age: 34} userAges["Charlie"] = UserAge{age: 22} // Получаем значения aliceScore := userScores["Alice"] fmt.Println(aliceScore) // 95 // Удаляем элемент delete(userScores, "Bob") // Размер отображения fmt.Println(len(userScores)) // 2
Стоит отметить, что если обратиться к ключу, которого нет в map, то вернется нулевое значение соответствующего типа. В приведенном примере переменная davidScore получит значение 0, в отличие от undefined в JS.
davidScore := userScores["David"] // 0
Как же тогда понять, действительно ли элемент присутствует в map? При обращении к значению по ключу map возвращает два значения: первое — это само значение (как мы видели выше), а второе — логическое значение, которое указывает, существует ли такой ключ в map на самом деле:
davidScore, exists := userScores["David"] if !exists { fmt.Println("David not found") }
И, наконец, как и в случае со срезами, переменные типа map в Go являются указателями на внутреннюю структуру данных, поэтому они также передаются по ссылке:
func modifyMap(m map[string]int) { m["Zack"] = 100 // Это изменение будет видно вызывающей стороне } scores := map[string]int{ "Alice": 95, "Bob": 82, } fmt.Println("До:", scores) // До: map[Alice:95 Bob:82] modifyMap(scores) fmt.Println("После:", scores) // После: map[Alice:95 Bob:82 Zack:100]
На этом первая часть руководство завершена. В следующей части мы рассмотрим следующие темы:
-
Сравнение
-
Методы и интерфейсы
-
Обработка ошибок
-
Конкурентность и параллелизм
-
Форматирование и линтинг
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
ссылка на оригинал статьи https://habr.com/ru/articles/933118/
Добавить комментарий