После пяти лет работы 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[0] = 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-канале ↩