Изучаем Go: руководство для JavaScript-разработчиков. Часть 2
- воскресенье, 10 августа 2025 г. в 00:00:08
После пяти лет работы JavaScript-разработчиком, занимаясь как фронтендом, так и бэкендом, я провел последний год, осваивая Go для серверной разработки. За это время мне пришлось переосмыслить многие вещи. Различия в синтаксисе, базовых принципах, подходах к организации кода и, конечно, в средах выполнения — все это довольно сильно влияет не только на производительность приложения, но и на эффективность разработчика.
Интерес к Go в JavaScript-сообществе тоже заметно вырос. Особенно после новости от Microsoft о том, что они переписывают официальный компилятор TypeScript на Go — и обещают ускорение до 10 раз по сравнению с текущей реализацией.
Эта статья — своего рода путеводитель для JavaScript-разработчиков, которые задумываются о переходе на Go или просто хотят с ним познакомиться. Я постарался структурировать материал вокруг ключевых особенностей языка, сравнивая их с привычными концепциями из JavaScript/TypeScript. И, конечно, расскажу о "подводных камнях", с которыми столкнулся лично — с багажом мышления JS-разработчика.
В этой части мы рассмотрим следующие аспекты этих языков:
Сравнение
Методы и интерфейсы
Обработка ошибок
Конкурентность и параллелизм
Форматирование и линтинг
Строгое сравнение значений в JS может порой сбивать с толку. Примитивные типы сравниваются по значению, а все остальное — по ссылке:
let a = 5
let b = 5
console.log(a === b) // true - сравнение по значению
let str1 = "hello"
let str2 = "hello"
console.log(str1 === str2) // true - сравнение по значению
let a1 = { name: "Hulk" }
let a2 = { name: "Hulk" }
let a3 = a1
console.log(a1 === a2) // false - разные ссылки, несмотря на одинаковое содержимое
console.log(a1 === a3); // true - одна и та же ссылка
Но в Go все иначе: почти все сравнивается по значению — даже составные типы, такие как структуры и массивы, если только они не содержат несравнимые (incomparable) типы (например, срезы, отображения и т.д.). Например:
type Person struct {
Name string
Age int
}
p1 := Person{Name: "Alice", Age: 30}
p2 := Person{Name: "Alice", Age: 30}
fmt.Println("p1 == p2:", p1 == p2) // true - одинаковое содержимое, разные переменные
// Массивы сравниваются по значению
arr1 := [3]int{1, 2, 3}
arr2 := [3]int{1, 2, 3}
fmt.Println("arr1 == arr2:", arr1 == arr2) // true - одинаковое содержимое, разные переменные
// Срезы напрямую не сравниваются
tasks := []string{"Task1", "Task2", "Task3"}
tasks2 := []string{"Task1", "Task2", "Task3"}
// Это не скомпилируется:
// fmt.Println(tasks == tasks2) // invalid operation: tasks == tasks2
// Это допустимо
fmt.Println(tasks == nil) // false
// Но если структура содержит несравнимые типы, она тоже становится несравнимой
type Container struct {
Items []int // срез — несравнимый тип
}
c1 := Container{Items: []int{1, 2, 3}}
c2 := Container{Items: []int{1, 2, 3}}
// Это не скомпилируется:
// fmt.Println("c1 == c2:", c1 == c2) // error: struct containing slice cannot be compared
// Указатели сравниваются по ссылке (адресу)
pp1 := &Person{Name: "Bob", Age: 25}
pp2 := &Person{Name: "Bob", Age: 25}
pp3 := pp1
fmt.Println("pp1 == pp2:", pp1 == pp2) // false - разные переменные
fmt.Println("pp1 == pp3:", pp1 == pp3) // true - одна и та же переменная
fmt.Println("*pp1 == *pp2:", *pp1 == *pp2) // true - после разыменования сравниваются значения
В JS для объединения связанных свойств и методов, описывающих некий объект из реального мира, мы используем классы. Классы в JS позволяют создавать объекты, но на самом деле они лишь упрощают работу с прототипным наследованием (prototype inheritance), скрывая его за более привычным синтаксисом (если интересно, можете почитать подробнее в этой статье):
class Rectangle {
length: number;
width: number;
constructor(length: number, width: number) {
this.length = length;
this.width = width;
}
area() {
return this.length * this.width;
}
}
const r = new Rectangle(4, 5);
console.log(r.area()); // 20
В Go нет классов, как во многих других языках программирования, но есть возможность определять методы прямо для типов. Методы — это особые функции, у которых есть специальный аргумент-приемник (receiver). Он указывается между ключевым словом func
и названием метода. Например:
type Rectangle struct {
length float64
width float64
}
func (r Rectangle) Area() float64 {
return r.length * r.width
}
func main() {
r := Rectangle{
length: 4,
width: 5,
}
fmt.Println(r.Area()) // 20
}
Поскольку методы в Go — это всего лишь функции с аргументом-приемником, приведенный пример можно переписать без каких-либо изменений в функциональности следующим образом:
func Area(r Rectangle) float64 {
return r.length * r.width
}
Пример выше — это метод с приемником по значению (value receiver), то есть в переменную-приемник передается копия значения типа. Однако чаще всего методы объявляют с приемником-указателем (pointer receiver) — это позволяет изменять значение, на которое ссылается указатель:
type Rectangle struct {
length float64
width float64
}
func (r Rectangle) Area() float64 {
return r.length * r.width
}
func (r *Rectangle) Double() {
r.length = r.length * 2
r.width = r.width * 2
}
func main() {
r := Rectangle{
length: 4,
width: 5,
}
r.Double()
fmt.Println(r.Area()) // 80
}
Вызов
r.Double()
в Go автоматически преобразуется в(&r).Double()
, так как методDouble()
использует приемник-указатель.
Еще одно преимущество использования приемников-указателей — отсутствие необходимости копировать значение при каждом вызове метода, что существенно экономит ресурсы при работе с большими структурами.
В TypeScript существуют type
и interface
, с помощью которых задается структура объекта. Как и в других языках, интерфейсы можно использовать с классами для определения сигнатур переменных и методов через ключевое слово implements
:
interface Shape {
area(): number;
perimeter(): number;
}
class Circle implements Shape {
#radius: number
constructor(radius: number) {
this.#radius = radius
}
area(): number {
return Math.PI * this.#radius * this.#radius;
}
perimeter(): number {
return 2 * Math.PI * this.#radius;
}
}
function printArea(s: Shape) {
console.log(s.area())
}
let c = new Circle(3)
printArea(c)
Интерфейсы в Go выполняют схожую функцию: интерфейс в Go определяется как набор сигнатур методов и может содержать значение, реализующее эти методы. Например:
package main
import (
"fmt"
"math"
)
type Shape interface {
area() float64
perimeter() float64
}
type Rectangle struct {
length float64
width float64
}
func (r *Rectangle) area() float64 {
return r.length * r.width
}
func (r *Rectangle) perimeter() float64 {
return 2 * (r.length + r.width)
}
type Circle struct {
radius float64
}
func (c *Circle) area() float64 {
return math.Pi * c.radius * c.radius
}
func (c *Circle) perimeter() float64 {
return 2 * math.Pi * c.radius
}
func printArea(s Shape) {
fmt.Println(s.area())
}
func main() {
r := &Rectangle{
length: 4,
width: 5,
}
c := &Circle{
radius: 3,
}
fmt.Println("Rectangle area:")
printArea(r)
fmt.Println("Circle area:")
printArea(c)
}
Обратите внимание, что в приведенном примере нет ключевого слова implements
для типа Rectangle
, но при этом мы можем передать его в функцию, которая требует тип Shape
. В Go тип реализует интерфейс просто путем реализации его методов.
Сначала это может показаться странным, но на самом деле это очень мощная особенность дизайна Go. Она позволяет отделить определение интерфейса от его реализации, что дает возможность создавать интерфейсы для уже существующих типов.
Под капотом интерфейсы в Go можно представить как кортеж, содержащий значение и конкретный тип. Для примера выше это будет выглядеть так:
var r Shape
r = &Rectangle{
length: 4,
width: 5,
}
fmt.Printf("%v, %T", r, r) // &{4 5}, *main.Rectangle
Подобным образом нулевой интерфейс не содержит ни значения, ни конкретного типа, и попытка обратиться к свойству такого интерфейса вызовет ошибку обращения к нулевому указателю:
var r Shape
fmt.Printf("(%v, %T)\n", r, r) // <nil>, <nil>
r.Area() // RRuntime error: nil pointer exception
Переменная типа пустого интерфейса может хранить любое значение — это эквивалент типа any
в TypeScript:
var r interface{}
r = 42
r = "Bruce Banner"
В Go 1.18 добавлен тип
any
, который является просто синонимом пустого интерфейса, поэтому записьvar r any
тоже будет работать в приведенном примере.
И напоследок, в Go есть механизм утверждения типа (type assertion), который позволяет получить фактическое значение, лежащее в основе интерфейса. Например:
var s Shape
s = &Circle{
radius: 3,
}
c, ok := s.(*Circle) // c будет иметь тип *Circle
fmt.Println(c, ok) // &{3} true
r, ok := s.(*Rectangle) // r будет иметь тип *Rectangle
fmt.Println(r, ok) // <nil> false
И это касается не только структур — утверждения типа также работают с примитивными типами:
var i interface{} = "hello"
s, ok := i.(string)
fmt.Println(s, ok) // hello true
f, ok := i.(float64)
fmt.Println(f, ok) // 0 false
Обработка ошибок в Go — одна из моих любимых фишек, которую JS определенно стоит перенять. В Go ошибки обрабатываются очень явно, а специальные линтеры подскажут, если мы забыли это сделать.
В JS одним из самых распространенных способов обработки ошибок является конструкция try…catch
. Вот типичный пример функции, которая читает JSON-файлы, обрабатывает их и возвращает результат:
async function processFiles(filePaths) {
try {
const fileContents = await Promise.all(
filePaths.map(path => fs.promises.readFile(path, 'utf-8'))
);
const results = fileContents.map(content => JSON.parse(content));
return results;
} catch (error) {
// Какая операция провалилась? Чтение файла или разбор JSON?
// Какой файл вызвал ошибку?
console.error("Something went wrong:", error);
return null;
}
}
Несмотря на то, что мы обрабатываем исключения, в приведенном примере невозможно точно понять, на каком этапе произошла ошибка. Чтобы это выяснить, придется оборачивать каждую операцию — например, чтение файла и парсинг JSON — в отдельный блок try/catch
.
В Go же применяется другой подход к обработке ошибок. Функции могут возвращать несколько значений, и обычно последнее возвращаемое значение является ошибкой. Поэтому аналогичный пример в Go будет выглядеть примерно так:
func processFiles(filePaths []string) ([]map[string]string, error) {
var results []map[string]string
for _, path := range filePaths {
// Обработка каждой ошибки непосредственно в месте ее возникновения
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
}
var result map[string]string
err = json.Unmarshal(data, &result)
if err != nil {
return nil, fmt.Errorf("failed to parse JSON from file %s: %w", path, err)
}
results = append(results, result)
}
return results, nil
}
В приведенном выше фрагменте на Go ошибки обрабатываются явно на каждом этапе, что позволяет точно понять, где и почему произошел сбой. Значение ошибки проверяется сразу после каждой потенциально ошибочной операции. Если ошибка возникает, выполнение функции немедленно прерывается, и возвращается понятное сообщение об ошибке.
Такой подход заставляет разработчиков осознанно обрабатывать все возможные ошибки, а не надеяться, что исключения будут перехвачены где-то выше по стеку вызовов.
Прим. пер.: приведенные фрагменты кода на JS и Go не совсем эквивалентны. Код на JS вполне можно переписать так, чтобы получить почти такой же результат, что и в Go.
Кроме того, в Go есть специальный механизм defer
, который позволяет отложить выполнение инструкции до момента выхода из функции. Например:
func main() {
defer fmt.Println("World")
defer fmt.Println("Go")
fmt.Println("Hello")
}
// Вывод:
// Hello
// Go
// World
Функции defer
выполняются в порядке LIFO (последним вошел — первым вышел), поэтому World
выводится в самом конце.
Функции defer
хорошо дополняют обработку ошибок в Go: они дают возможность писать код очистки рядом с кодом выделения ресурсов, при этом выполняться он будет только после выхода из функции. Например:
package main
import (
"database/sql"
"fmt"
_ "github.com/lib/pq" // Драйвер PostgreSQL
)
func getUsername(userID int) (string, error) {
// Открываем соединение с базой данных
db, err := sql.Open("postgres", "postgresql://username:password@localhost/mydb?sslmode=disable")
if err != nil {
return "", fmt.Errorf("failed to connect to database: %w", err)
}
defer db.Close() // Это гарантирует, что соединение с базой данных будет закрыто при выходе из функции
// Выполняем запрос
var username string
err = db.QueryRow("SELECT username FROM users WHERE id = $1", userID).Scan(&username)
if err != nil {
return "", fmt.Errorf("failed to get username: %w", err)
}
return username, nil
}
В этом примере оператор defer
, который закрывает соединение с базой данных, стоит сразу после его открытия. Это гарантирует, что соединение будет закрыто в любом случае — при условии, что оно было успешно открыто, независимо от того, как завершится выполнение функции. Такой подход позволяет разместить код очистки рядом с кодом получения ресурса, что делает функцию более понятной.
В JS для аналогичной задачи обычно используют блок finally
. Вот как выглядел бы приведенный пример на JS:
const { Client } = require('pg');
async function getUsername(userId) {
const client = new Client({
connectionString: "postgresql://username:password@localhost/mydb"
});
try {
await client.connect();
// Выполняем запрос напрямую
const result = await client.query("SELECT username FROM users WHERE id = $1", [userId]);
if (result.rows.length === 0) {
throw new Error("User not found");
}
return result.rows[0].username;
} catch (error) {
throw new Error(`Database error: ${error.message}`);
} finally {
await client.end(); // Это эквивалентно оператору defer в Go для очистки ресурсов
}
}
В Go функции defer
можно также использовать для восстановления после паники (panic). Паника в Go — это аналог ошибки выполнения или исключения в JS. В обоих языках при возникновении такой ошибки выполнение текущей функции прекращается, начинается разматывание (unwinding) стека вызовов, и, если ошибка не была перехвачена, программа завершает работу. При этом в Go все отложенные (defer) функции продолжают выполняться по пути вверх по стеку.
В JS для обработки подобных ситуаций обычно используют try/catch
, чтобы аккуратно перехватить ошибку во время выполнения. В Go для этого предусмотрена специальная функция recover
, которую вызывают внутри defer
. Например:
package main
import (
"fmt"
)
func riskyOperation() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from panic:", r)
}
}()
// Это вызовет панику
var arr []int
fmt.Println(arr[1]) // Обращение за пределы массива
}
func main() {
riskyOperation()
fmt.Println("Program continues after recovery")
}
В приведенном примере при возникновении паники сначала выполняется отложенная функция, в которой вызывается recover()
. Это позволяет перехватить панику и предотвратить аварийное завершение программы. Таким образом, можно аккуратно обработать ошибку и продолжить выполнение программы.
Наиболее заметное различие между Go и JavaScript — это то, как они обрабатывают конкурентность.
JS по своей сути — однопоточный язык. Однако благодаря событийно-ориентированной архитектуре он позволяет выполнять неблокирующие операции ввода-вывода с помощью колбэков (callbacks), промисов (promises) и т.д. Все это происходит в рамках основного потока. Такая модель позволяет JS достигать конкурентности без использования многопоточности.
Go, напротив, поддерживает настоящую конкурентность с помощью горутин (goroutines) — сверхлегких потоков (всего ~2 КБ каждый), которыми управляет среда выполнения Go. В отличие от однопоточной модели событий в JS, Go способен выполнять код параллельно на нескольких потоках ОС. Несмотря на то, что код Go пишется как синхронный, горутины позволяют задействовать несколько ядер процессора для выполнения задач одновременно.
Пример создания горутины:
package main
import (
"fmt"
"time"
)
func say(s string) {
fmt.Println(s)
}
func main() {
go say("world")
say("hello")
// Мы добавили задержку, чтобы программа не завершилась
// до того, как выполнится горутина. Существуют более
// надежные способы управления этим — каналы и группы ожидания
time.Sleep(100 * time.Millisecond)
}
Ключевое слово go
в приведенном примере запускает функцию в новой горутине, которая выполняется параллельно с текущей.
Чтобы понять, как горутины соотносятся с циклом событий в JS, рассмотрим пример, в котором мы выполняем несколько API-запросов параллельно и ждем их завершения с помощью Promise.all()
:
const fetchData = async () => {
try {
// Запускаем оба запроса "параллельно"
const postPromise = fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(response => response.json());
const commentsPromise = fetch('https://jsonplaceholder.typicode.com/posts/1/comments')
.then(response => response.json());
// Ждем, пока оба промиса завершатся
const [post, comments] = await Promise.all([postPromise, commentsPromise]);
console.log('Post:', post);
console.log('Comments:', comments);
} catch (error) {
console.error('Error fetching data:', error);
}
};
fetchData();
А вот как можно реализовать нечто подобное в Go с помощью горутин:
package main
import (
"fmt"
"io/ioutil"
"net/http"
"sync"
)
func main() {
var wg sync.WaitGroup
var postJSON, commentsJSON string
var postErr, commentsErr error
// Добавляем два элемента в список ожидания
wg.Add(2)
// Получаем пост в отдельной горутине
go func() {
defer wg.Done()
resp, err := http.Get("https://jsonplaceholder.typicode.com/posts/1")
if err != nil {
postErr = err
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
postErr = err
return
}
postJSON = string(body)
}()
// Получаем комментарии в отдельной горутине
go func() {
defer wg.Done()
resp, err := http.Get("https://jsonplaceholder.typicode.com/posts/1/comments")
if err != nil {
commentsErr = err
return
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
commentsErr = err
return
}
commentsJSON = string(body)
}()
// Ожидаем завершения обеих горутин
wg.Wait()
// Обрабатываем возможные ошибки
if postErr != nil {
fmt.Println("Error fetching post:", postErr)
return
}
if commentsErr != nil {
fmt.Println("Error fetching comments:", commentsErr)
return
}
// Выводим результаты
fmt.Println("Post JSON:", postJSON)
fmt.Println("Comments JSON:", commentsJSON)
}
В приведенном примере используется
WaitGroup
из пакетаsync
— он предоставляет базовые примитивы синхронизации для Go.
Каналы (channels) — еще одна мощная возможность Go, которая позволяет горутинам обмениваться данными и синхронизировать выполнение. Мы не рассматриваем их в этом руководстве, так как они заслуживают отдельной подробной статьи, но их определенно стоит изучить, если есть желание глубже понять модель конкурентности в Go.
Ключевое отличие в приведенных примерах состоит в том, что JS достигает конкурентности за счет асинхронного ввода-вывода и цикла событий — операции ввода-вывода делегируются браузеру или Node.js, которые выполняют их вне основного потока. Однако для задач, требующих интенсивных вычислений на процессоре, JS все равно выполняется в одном основном потоке, блокируя остальное.
Go же, наоборот, поддерживает настоящий параллелизм с помощью горутин, которые могут одновременно выполняться на нескольких ядрах процессора. Вот пример того, как можно запускать ресурсоемкие вычислительные задачи параллельно с помощью горутин:
package main
import (
"fmt"
"sync"
)
func sum(s []int, result *int, wg *sync.WaitGroup) {
defer wg.Done() // Сообщаем, что выполнение этой горутины завершено
sum := 0
for _, v := range s {
sum += v
}
*result = sum
}
func main() {
s := []int{7, 2, 8, -9, 4, 0}
var wg sync.WaitGroup
var x, y int
// Добавляем две горутины в группу ожидания
wg.Add(2)
// Запускаем горутины
go sum(s[:len(s)/2], &x, &wg)
go sum(s[len(s)/2:], &y, &wg)
// Ожидаем завершения обеих горутин
wg.Wait()
fmt.Println(x, y, x+y)
}
В приведенном примере мы выполняем "ресурсоемкую" задачу — параллельное суммирование двух половин среза с помощью горутин. В JS невозможно реализовать такую параллельность без использования Web Workers или worker threads (в случае Node.js).
В Go есть официальный инструмент форматирования кода из стандартной библиотеки — пакет Gofmt. В отличие от экосистемы JS, где в проектах часто используются настраиваемые инструменты вроде Prettier, Gofmt почти не поддерживает конфигурацию, но при этом является общепринятым стандартом форматирования кода в Go. Большинство редакторов имеют встроенные расширения, позволяющие автоматически форматировать код с его помощью.
Что касается линтинга, то в Go, как и в JS, существует множество правил и инструментов, разработанных сообществом, которые помогают находить и устранять проблемы с качеством кода. Одним из самых популярных инструментов является golangci-lint
— раннер, который параллельно запускает десятки линтеров и поддерживает более сотни настраиваемых проверок.
Надеюсь, это руководство помогло вам заложить прочную основу в Go и лучше понять, чем он отличается от JavaScript — как с точки зрения самого языка, так и с точки зрения принципов его работы.
Мы рассмотрели основные концепции, но это лишь небольшая часть того, что предлагает мощная стандартная библиотека и экосистема Go. Чтобы продвинуться дальше, лучший способ — начать создавать собственные проекты. Go отлично подходит для разработки CLI-инструментов, веб-серверов, микросервисов, системных утилит и даже компиляторов.
Материалы для изучение Go:
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud - в нашем Telegram-канале ↩