иконка discount

Скидка 15% по промокоду

кибер понедельник до 01.12иконка discount
CYBER2025
логотип PurpleSchool
логотип PurpleSchool

Функция append в Go (Golang)

Автор

Олег Марков

Введение

Функция append в Go — это основной инструмент для работы со срезами, когда вам нужно добавить новые элементы. Вы будете использовать ее почти в каждом проекте на Go, даже если не будете обращать на это внимание.

Смотрите, я покажу вам, как это обычно выглядит в коде:

nums := []int{1, 2, 3}
// Добавляем один элемент
nums = append(nums, 4)

// Добавляем несколько элементов
nums = append(nums, 5, 6, 7)

Снаружи все выглядит очень просто: есть срез, вы вызываете append, и появляются новые элементы. Но под капотом происходит перераспределение памяти, изменение длины и емкости, копирование элементов и другие важные вещи.

В этой статье мы подробно разберем:

  • как устроены срезы в Go и почему без этого понимания сложно правильно использовать append
  • синтаксис append и разные варианты применения
  • что происходит с длиной и емкостью среза при добавлении элементов
  • как работает перераспределение памяти (reallocation) и когда оно происходит
  • распространенные ошибки при использовании append и как их избежать
  • приемы оптимизации, которые помогают сократить количество аллокаций памяти

Давайте двигаться по шагам — от базового понимания к более тонким деталям.

Срезы в Go и их связь с append

Чтобы уверенно пользоваться append, важно понимать, что такое срез (slice) в Go. Без этого легко попасть в ловушки с неожиданными изменениями данных и лишними аллокациями.

Что такое срез в Go

Срез — это не просто динамический массив. Это структура, которая содержит три поля:

  • указатель на область памяти, где хранятся элементы
  • длину (len) — сколько элементов сейчас доступно
  • емкость (cap) — сколько элементов можно поместить, не выделяя новую память

В упрощенном виде внутреннее устройство среза можно представить так:

type sliceHeader struct {
    Data uintptr // указатель на первый элемент массива
    Len  int     // текущая длина среза
    Cap  int     // емкость среза
}

В обычном коде вы этого не видите, но понимание структуры помогает лучше понять поведение append.

Давайте разберемся на примере:

nums := make([]int, 2, 4)
// nums = [0 0], len = 2, cap = 4
nums[0] = 10
nums[1] = 20

Комментарии к примеру:

// Срез nums содержит два элемента, но в памяти уже выделено место под четыре
// Это значит, что вы можете добавить еще два элемента без перераспределения памяти

Теперь, если вы вызовете append:

nums = append(nums, 30) // len = 3, cap = 4
nums = append(nums, 40) // len = 4, cap = 4

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

Почему append возвращает новый срез

Важный момент, который часто вызывает вопросы у новичков: почему нужно присваивать результат append обратно в переменную?

nums := []int{1, 2}
append(nums, 3)   // Так делать бессмысленно
// nums все еще []int{1, 2}

Смотрите, что происходит:

  • функция append может вернуть срез, который указывает на тот же массив, а может создать новый массив
  • если не присвоить результат, вы потеряете ссылку на новый (возможно, расширенный) срез

Правильный вариант:

nums := []int{1, 2}
nums = append(nums, 3) // Теперь nums = [1 2 3]

Здесь важно помнить: append всегда возвращает срез. Он может указывать на:

  • тот же самый базовый массив (если емкости хватило)
  • новый массив (если емкости не хватило, и память была выделена заново)

Поэтому всегда присваивайте результат append.

Синтаксис и базовое использование append

Теперь, когда у вас есть представление о срезах, давайте внимательно посмотрим на синтаксис функции append.

Общий вид функции append

Общий вид функции:

newSlice := append(oldSlice, elements...)

Где:

// oldSlice — исходный срез
// elements — один или несколько добавляемых элементов
// newSlice — новый срез, который нужно использовать дальше

Простой пример добавления одного элемента:

names := []string{"Ann", "Bob"}
// Добавляем один элемент
names = append(names, "Carl")
// Теперь names = []string{"Ann", "Bob", "Carl"}

Добавление нескольких элементов сразу:

nums := []int{1, 2}
// Добавляем сразу три числа
nums = append(nums, 3, 4, 5)
// nums = []int{1, 2, 3, 4, 5}

Добавление одного среза в другой

Очень частый сценарий — добавить содержимое одного среза в конец другого. Здесь многие сначала делают так:

a := []int{1, 2}
b := []int{3, 4}

// Так КОМПИЛЯТОР не пропустит
// a = append(a, b)

Здесь важно использовать оператор раскрытия среза:

a := []int{1, 2}
b := []int{3, 4}

// Здесь я размещаю пример, чтобы вам было проще понять.
// Оператор ... раскрывает срез b в набор аргументов.
a = append(a, b...)
// Теперь a = []int{1, 2, 3, 4}

Комментарии:

// b... означает "передать элементы среза b как обычные аргументы функции"
// без троеточия вы бы попытались передать один аргумент типа []int вместо нескольких int

Добавление элементов к nil-срезу

В Go допустимо создавать nil-срез:

var nums []int // nil-срез, len = 0, cap = 0

Если вы попробуете к нему применить append, все будет работать:

var nums []int
nums = append(nums, 10, 20, 30)
// Теперь nums = []int{10, 20, 30}, len = 3, cap >= 3

Функция append сама выделит память, создаст базовый массив и вернет корректный срез. Это один из удобных способов пошаговой инициализации среза.

Добавление элементов в срез структур

Срезы со структурами часто используются для хранения сущностей:

type User struct {
    ID   int
    Name string
}

func main() {
    var users []User // nil-срез

    // Добавляем нового пользователя
    users = append(users, User{
        ID:   1,
        Name: "Alice",
    })

    // Добавляем сразу двух
    users = append(users,
        User{ID: 2, Name: "Bob"},
        User{ID: 3, Name: "Charlie"},
    )
}

Комментарии:

// Функция append работает одинаково для любых типов элементов
// Нет разницы, добавляете ли вы int, string или структуру — логика та же

Как меняются длина и емкость при append

Теперь давайте посмотрим глубже на то, как функция append влияет на len и cap, и как это связано с производительностью.

Наблюдаем изменения len и cap

Приведу наглядный пример:

package main

import "fmt"

func main() {
    nums := make([]int, 0, 2)
    // len = 0, cap = 2

    for i := 1; i <= 5; i++ {
        nums = append(nums, i)
        fmt.Printf("После append(%d): nums = %v, len = %d, cap = %d\n",
            i, nums, len(nums), cap(nums))
    }
}

Комментарии к коду:

// Мы создаем срез с длиной 0 и емкостью 2
// В цикле добавляем 5 чисел подряд
// После каждого append выводим длину и емкость

Типичный вывод (точные значения зависят от реализации, но идея такая):

  • после первых двух append емкость остается 2
  • затем при третьем добавлении емкость увеличивается, чаще всего в 2 раза (до 4)
  • при пятом добавлении емкость снова увеличивается (обычно до 8)

Важно понимать:

  • если len < cap — новые элементы помещаются в существующий массив
  • если len == cap — append вынужден выделить новый массив, скопировать туда старые элементы и добавить новые

Пример с перераспределением памяти

Давайте посмотрим, как перераспределение влияет на адрес базового массива.

package main

import "fmt"

func main() {
    nums := make([]int, 0, 2)
    fmt.Printf("Начальный адрес: %p\n", nums)

    for i := 1; i <= 5; i++ {
        nums = append(nums, i)
        fmt.Printf("После append(%d) адрес: %p, len = %d, cap = %d\n",
            i, nums, len(nums), cap(nums))
    }
}

Комментарии:

// %p в fmt.Printf покажет адрес первого элемента среза
// Если адрес изменился — значит, под капотом был создан новый массив

Вы увидите, что после превышения емкости адрес изменится. Это важный сигнал: старые копии среза могут указывать на другой массив, чем новый.

Работа с под-срезами и append: подводные камни

Одно из самых непростых мест — это сочетание под-срезов и append. Здесь чаще всего появляются скрытые баги.

Как создается под-срез

Когда вы создаете под-срез, он по умолчанию "делит" базовый массив с исходным срезом:

nums := []int{10, 20, 30, 40, 50}
sub := nums[1:3] // элементы с индексами 1 и 2: [20 30]

// sub и nums указывают на один и тот же базовый массив

Комментарии:

// sub имеет len = 2 (элементы 20 и 30)
// но cap у sub может быть больше, чем 2, потому что базовый массив длиннее
// часто cap(sub) == len(nums) - startIndex

Теперь, если вы будете изменять элементы sub, это изменит и nums:

sub[0] = 200
// Теперь nums = [10 200 30 40 50]

Это логично. Но ситуация становится интереснее с append.

Опасность: append к под-срезу меняет исходный срез

Посмотрите на такой пример:

package main

import "fmt"

func main() {
    nums := []int{10, 20, 30, 40}
    sub := nums[1:3] // [20 30]

    fmt.Printf("До:  nums = %v, sub = %v\n", nums, sub)

    // Добавляем элемент к под-срезу
    sub = append(sub, 99)

    fmt.Printf("После: nums = %v, sub = %v\n", nums, sub)
}

Комментарий:

// У sub была емкость, позволяющая добавить хотя бы один элемент без перераспределения
// Поэтому append записал 99 в базовый массив, который общий с nums

Результат может быть таким:

  • nums = [10 20 30 99]
  • sub = [20 30 99]

Как видите, этот код выполняет неочевидное изменение исходного среза nums: последний элемент стал 99. Если вы не учитывали общую емкость и общий базовый массив, это может стать неожиданностью.

Как избежать неожиданных изменений

Если вам нужен новый независимый срез, лучше явно скопировать элементы:

nums := []int{10, 20, 30, 40}
sub := nums[1:3] // [20 30]

// Создаем новый срез с такой же длиной
copySub := make([]int, len(sub))
// Копируем элементы sub в новый срез
copy(copySub, sub)

// Теперь copySub не разделяет базовый массив с nums
copySub = append(copySub, 99)

Комментарии:

// Теперь append к copySub не повлияет на nums
// Это безопаснее, если вы не хотите менять исходные данные

Еще один прием — ограничить емкость под-среза с помощью "трехаргументного" среза:

nums := []int{10, 20, 30, 40}
sub := nums[1:3:3] // [20 30], len = 2, cap = 2

// Здесь cap(sub) = 2, то есть нет свободного места в базовом массиве
sub = append(sub, 99)
// Теперь будет создан новый массив, и sub станет независимым

Комментарии:

// nums[low:high:max] — синтаксис с ограничением емкости
// max задает верхнюю границу емкости под-среза
// это хороший способ защититься от случайного изменения исходного среза через append

Механика перераспределения памяти и рост capacity

Теперь давайте чуть глубже разберемся, как растет емкость срезов и почему это важно для производительности.

Когда выделяется новый массив

Новый базовый массив выделяется, когда вы вызываете append и длина среза после добавления должна превысить текущую емкость. То есть:

  • если len + количество добавляемых элементов <= cap — новый массив не нужен
  • если больше — будет аллокация и копирование

Покажу вам, как это выглядит в общих чертах:

func AppendInt(slice []int, v int) []int {
    if len(slice) < cap(slice) {
        // Места достаточно — просто расширяем длину
        slice = slice[:len(slice)+1]
        slice[len(slice)-1] = v
        return slice
    }

    // Места нет — нужно выделить новый массив
    newCap := calcNewCap(cap(slice)) // псевдо-функция рассчета новой емкости
    newSlice := make([]int, len(slice)+1, newCap)

    // Копируем элементы старого среза
    copy(newSlice, slice)

    // Добавляем новый элемент
    newSlice[len(slice)] = v
    return newSlice
}

Комментарии:

// Это не реальный код стандартной библиотеки, а упрощенная иллюстрация
// Главное — показать, что иногда создается новый массив и выполняется копирование

Как обычно растет емкость

Точный алгоритм роста емкости зависит от реализации рантайма Go и может меняться между версиями. В общих чертах:

  • для маленьких срезов емкость часто удваивается
  • для более крупных рост может становиться менее агрессивным (например, увеличение на 25 процентов и т.п.)

Практические выводы:

  • многократный append по одному элементу к срезу без заранее заданной емкости приводит к множеству аллокаций и копирований
  • если вы заранее знаете (или можете оценить) количество элементов — лучше сразу задать подходящую емкость

Оптимизация работы с append

Теперь давайте посмотрим, как можно сделать работу с append более предсказуемой и эффективной.

Использование make с емкостью

Самый простой способ оптимизировать append — сразу резервировать нужный объем памяти:

// Мы знаем, что нам нужно примерно 100 элементов
nums := make([]int, 0, 100)

// В цикле добавляем элементы
for i := 0; i < 100; i++ {
    nums = append(nums, i)
}

Комментарии:

// len = 0 — срез изначально пустой
// cap = 100 — выделено место под 100 элементов
// В процессе цикла не будет дополнительных аллокаций памяти

Это особенно полезно:

  • при чтении данных из файла или сети, когда вы знаете примерный размер
  • при обработке массивов фиксированного размера
  • при сборе результатов вычислений заранее известной длины

append vs ручное управление длиной

Иногда можно обойтись без append и управлять длиной среза вручную:

nums := make([]int, 100) // len = 100, cap = 100

for i := 0; i < 100; i++ {
    nums[i] = i
}

Комментарии:

// Здесь мы создали срез сразу нужной длины
// Не было ни одного вызова append, но по сути результат тот же
// Такой подход полезен, когда длина точно известна заранее

Но чаще удобнее использовать append, потому что он:

  • автоматически увеличивает длину
  • может работать с частично заполненными срезами
  • удобен, когда длина не известна точно

Предупреждение о лишних аллокациях

Частая ошибка — многократно увеличивать срез без оценки емкости:

var data []int // nil-срез
for i := 0; i < 1_000_000; i++ {
    data = append(data, i)
}

Такой код будет работать, но создаст много промежуточных массивов. Лучше так:

// Мы предполагаем, что элементов будет около миллиона
data := make([]int, 0, 1_000_000)

for i := 0; i < 1_000_000; i++ {
    data = append(data, i)
}

Разница в производительности и количестве аллокаций может быть существенной.

Частые ошибки при использовании append

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

Ошибка: забыли присвоить результат append

Классический пример:

nums := []int{1, 2, 3}
append(nums, 4)
// nums все еще [1 2 3]

Правильно:

nums := []int{1, 2, 3}
nums = append(nums, 4)

Комментарии:

// Помните, что append возвращает новый (или модифицированный) срез
// Если не использовать результат — изменения не сохранятся в вашей переменной

Ошибка: ожидание независимости под-среза

Пример:

nums := []int{1, 2, 3, 4}
sub := nums[:2] // [1 2]

// Разработчик думает, что sub — независимая копия
sub = append(sub, 99)

Чего ожидали:

// nums: [1 2 3 4]
// sub: [1 2 99]

Что реально может получиться:

// nums: [1 2 99 4]
// sub: [1 2 99]

Комментарии:

// Проблема в том, что sub использует тот же базовый массив
// При append изменяется часть исходного массива

Решение — использовать копирование или трехаргументный срез, как мы разбирали выше.

Ошибка: изменение одного среза неожиданно влияет на другой

Еще один пример, который часто вызывает вопросы:

a := []int{1, 2, 3}
b := a           // b указывает на тот же срез
b = append(b, 4) // что произойдет с a?

fmt.Println("a:", a)
fmt.Println("b:", b)

Здесь возможны два сценария (в зависимости от емкости):

  1. Если емкости a хватило, чтобы вместить добавленный элемент:

// a и b указывают на один массив
// оба станут [1 2 3 4]

  1. Если емкости не хватило:

// для b будет создан новый массив
// a останется [1 2 3]
// b станет [1 2 3 4]

То есть результат зависит от cap. Это делает код неочевидным.

Безопасный подход:

  • не полагаться на неявное совместное использование базового массива
  • при необходимости независимости явно копировать данные
a := []int{1, 2, 3}
b := make([]int, len(a))
copy(b, a)       // теперь b независим от a
b = append(b, 4) // не влияет на a

Ошибка: забыли про раскрытие среза (b...)

Еще одна типичная ситуация:

a := []int{1, 2}
b := []int{3, 4}

// Попытка добавить b в a:
a = append(a, b) // ошибка компиляции

Компилятор скажет, что типы не совпадают: он ожидал элементы типа int, а получил один аргумент типа []int.

Правильно:

a := []int{1, 2}
b := []int{3, 4}

a = append(a, b...) // раскрываем b в набор аргументов

Комментарии:

// a = append(a, b...) — добавит 3 и 4 как отдельные элементы
// a = append(a, b) — пыталось бы добавить сам срез b как один элемент, что невозможно

Продвинутые приемы с append

Теперь давайте посмотрим на несколько более практических паттернов, где append используется чуть интереснее, чем просто "добавить в конец".

Построение среза шаг за шагом

Частый сценарий — когда вы проходите по данным и по определенному условию добавляете элементы:

func FilterEven(nums []int) []int {
    // Создаем результирующий срез с нулевой длиной
    // Можно оценить емкость как len(nums) / 2, если ожидаете примерно половину четных
    result := make([]int, 0, len(nums))

    for _, n := range nums {
        if n%2 == 0 {
            result = append(result, n)
        }
    }

    return result
}

Комментарии:

// append здесь удобно сочетать с логикой фильтрации
// make с заранее оцененной емкостью уменьшит количество аллокаций

Объединение нескольких срезов

Если у вас есть несколько срезов одного типа, их можно объединить в один:

func Concat[T any](slices ...[]T) []T {
    // Сначала оценим общую длину, чтобы сразу выделить нужную емкость
    totalLen := 0
    for _, s := range slices {
        totalLen += len(s)
    }

    result := make([]T, 0, totalLen)

    for _, s := range slices {
        result = append(result, s...)
    }

    return result
}

Комментарии:

// Здесь я показываю пример с дженериками, чтобы вам было проще адаптировать его
// Мы сначала считаем общую длину, затем делаем один большой срез и постепенно добавляем в него данные
// Это экономит память и ускоряет выполнение

Использование append для "копирования" среза

Интересный прием — использовать append для создания копии среза:

original := []int{1, 2, 3}

// Создаем новый срез и добавляем в него элементы original
copySlice := append([]int(nil), original...)
// или
copySlice2 := append([]int{}, original...)

Комментарии:

// append([]int(nil), original...) создает новый срез, независимый от original
// оба варианта создают новый базовый массив и копируют туда элементы
// это удобный способ быстро скопировать срез

"Удаление" элементов через append

В Go нет встроенного удаления элемента из середины среза, но с помощью срезов и append можно сделать это довольно просто.

Удаление элемента по индексу:

func RemoveIndex(nums []int, idx int) []int {
    if idx < 0 || idx >= len(nums) {
        // Индекс вне диапазона — возвращаем исходный срез
        return nums
    }

    // Давайте посмотрим, что происходит в этом выражении.
    // nums[:idx] — элементы до удаляемого
    // nums[idx+1:] — элементы после удаляемого
    return append(nums[:idx], nums[idx+1:]...)
}

Комментарии:

// Мы создаем новый срез, который "перепрыгивает" через элемент с индексом idx
// Внутри это может изменить базовый массив, поэтому будьте аккуратны, если где-то еще храните ссылки на nums

Удаление диапазона элементов:

func RemoveRange(nums []int, from, to int) []int {
    if from < 0 {
        from = 0
    }
    if to > len(nums) {
        to = len(nums)
    }
    if from >= to {
        return nums
    }

    // Оставляем элементы до from и после to
    return append(nums[:from], nums[to:]...)
}

Комментарии:

// Здесь мы вырезаем целый диапазон элементов
// Это стандартный прием в Go при работе со срезами

Заключение

Функция append в Go — это фундаментальный инструмент для работы со срезами. На уровне синтаксиса она выглядит очень просто, но под капотом скрывает важные особенности, которые напрямую влияют на корректность программы и ее производительность.

Кратко зафиксируем ключевые моменты, которые стоит держать в голове:

  • append всегда возвращает срез, поэтому результат нужно присваивать
  • срез — это "обертка" над массивом, содержащая указатель, длину и емкость
  • при превышении емкости создается новый массив, и элементы копируются в него
  • под-срезы по умолчанию разделяют базовый массив с исходным срезом, что может приводить к неожиданным изменениям
  • оператор ... (раскрытие среза) необходим при добавлении одного среза в другой
  • заранее задавая емкость с помощью make, вы можете сильно сократить количество аллокаций и ускорить код
  • для независимости данных используйте копирование (copy или append к пустому срезу) и при необходимости трехаргументные срезы

Теперь, когда вы понимаете, как работает append, вы можете осознанно применять его в повседневном коде и избегать типичных ловушек, связанных с срезами и динамическим ростом.

Частозадаваемые технические вопросы

Как безопасно передать срез в функцию, которая использует append, чтобы она не меняла исходный массив?

Если вы передадите срез в функцию и внутри нее будет append, то:

  • при достаточной емкости изменения могут затронуть исходный базовый массив
  • при перераспределении памяти изменения останутся только внутри функции

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

func Process(data []int) []int {
    // Делаем копию входных данных
    copyData := append([]int(nil), data...)
    // Работаем только с копией
    copyData = append(copyData, 100)
    return copyData
}

Так вы гарантируете, что исходный срез data не изменится.

Можно ли использовать append с срезами, размещенными в статических массивах?

Да, если вы создаете срез поверх массива, то append будет работать, пока не будет превышена емкость массива. Например:

var arr [5]int
s := arr[:0] // len = 0, cap = 5

s = append(s, 1, 2, 3)
// Пока len <= 5, элементы помещаются в arr

Если вы превысите емкость:

s = append(s, 4, 5, 6) // здесь потребуется новый массив

После этого s перестанет указывать на arr, потому что будет выделен новый базовый массив. Сам arr не изменится для новых элементов.

Как понять, что append привел к аллокации памяти, в реальном проекте?

Для анализа аллокаций удобно использовать флаг go test -run=NONE -bench=. -benchmem или профилировщик pprof. Но есть и простой прием для локальных экспериментов: вы можете печатать адрес первого элемента среза до и после append.

fmt.Printf("%p\n", slice) // адрес до
slice = append(slice, x)
fmt.Printf("%p\n", slice) // адрес после

Если адрес изменился — была аллокация и создание нового массива. Это удобно при оптимизации "узких мест" кода.

Как с помощью append реализовать стек (push/pop) без лишних аллокаций?

Для стека на срезах удобно использовать append и операцию "обрезки" среза:

type Stack []int

func (s *Stack) Push(v int) {
    *s = append(*s, v)
}

func (s *Stack) Pop() (int, bool) {
    if len(*s) == 0 {
        return 0, false
    }
    last := (*s)[len(*s)-1]
    *s = (*s)[:len(*s)-1]
    return last, true
}

Чтобы снизить количество аллокаций, можно изначально задать емкость:

st := make(Stack, 0, 1000)

Так стек выдержит до 1000 push без увеличения емкости.

Можно ли "освободить" память, уменьшив срез после большого append?

Если вы просто уменьшите длину среза:

nums = nums[:10]

емкость при этом останется прежней, и большой массив продолжит занимать память. Чтобы позволить сборщику мусора освободить лишнее, можно "отсечь" емкость, создав новый срез:

tmp := make([]int, len(nums))
copy(tmp, nums)
nums = tmp // теперь cap(nums) == len(nums)

Или использовать трехаргументный срез:

nums = nums[:10:10] // len = 10, cap = 10

Теперь старый большой массив станет кандидатом на освобождение, если на него больше нет ссылок.

Функция append в Go GolangСтрелочка вправо

Постройте личный план изучения Html до уровня Middle — бесплатно!

Html — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Все гайды по Html

Тег section в HTML - семантическая разметка структуры страницыТег main в HTML - подробное руководство по использованиюТег nav в HTML - полное руководство по семантической навигацииТег header в HTML - полное практическое руководствоТег footer в HTML - назначение семантика и практические примерыТег figure в HTML - как правильно оформлять иллюстрации и подписиТег figcaption в HTML - подробное руководство с примерамиТег aside в HTML - назначение правильная семантика и примеры
Текстовая область HTML textarea - практическое руководствоВыпадающий список HTML select - полное руководство для разработчиковОпция списка HTML option - как работает и как правильно использоватьАтрибут method в HTML - как правильно отправлять данные формыЗаголовок группы HTML legend - как правильно использовать и оформлятьТег input в HTML - типы атрибуты валидация и примерыТег формы form в HTMLГруппа полей HTML fieldsetАтрибут action в HTML - как правильно задавать адрес отправки формы
Открыть базу знаний

Лучшие курсы по теме

изображение курса

HTML и CSS

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.9
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажеры
Практика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий