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

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