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

Дело в том, что ... в контексте массивов решает проблему ручного подсчета элементов.

Как это работает

Когда объявляется массив в Go, мы обязаны указать его размер:

var arr [5]int = [5]int{10, 20, 30, 40, 50}

Если мы захотим добавить в этот массив еще один элемент (например, 60), нам придется изменить размер в двух местах:

var arr [6]int = [6]int{10, 20, 30, 40, 50, 60} // Изменили 5 на 6 в типе и в инициализаторе

Чтобы избавиться от этого рутинного подсчета, в Go и был введен токен ... для массивов:

arr := [...]int{10, 20, 30, 40, 50}

Здесь мы как бы говорим компилятору: “Посчитай сам, сколько здесь элементов, и подставь это число в тип массива”.

Очень важно понимать: ... здесь не делает массив динамическим. Результатом выполнения [...]int{10, 20, 3} будет обычный статический массив [3]int. Как только компилятор посчитал элементы, размер жестко фиксируется.

В чем отличие от слайсов?

Если нам нужна структура данных, размер которой может меняться во время выполнения программы (добавление/удаление элементов), мы используем слайсы (slices).

Разница в синтаксисе:

  • [...]int{1, 2, 3} — это массив фиксированного размера [3]int. Добавить 4-й элемент в него нельзя (размер зашит в тип).
  • []int{1, 2, 3} — это слайс. У него под капотом скрыт массив, но сам слайс может динамически расти с помощью append().

Итог:

Для массивов ... существует, и он имеет смысл: это синтаксический сахар для автоматического вычисления размера массива на этапе компиляции. Он не нарушает правило о фиксированности массивов, а лишь автоматизирует их создание.

Строго говоря, с точки зрения формальной спецификации языка Go, ... не является оператором (в отличие от +, =, <- и т.д.).

Официально это называется многоточием (ellipsis) и представляет собой специальный токен (знак препинания/символ), который используется в определенных синтаксических конструкциях. Однако среди разработчиков в неформальном общении его часто называют “оператором” для удобства.

Вот 4 основные ситуации, где используется ... в Go:

1. Вариативные функции (Variadic functions)

Используется при объявлении функции, чтобы указать, что она может принимать произвольное количество аргументов указанного типа:

func sum(nums ...int) int {
    // nums внутри функции представляет собой слайс []int
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

2. Распаковка слайса (Slice unpacking / spread)

Используется при вызове функции, чтобы передать элементы слайса как отдельные аргументы (аналог spread-оператора в JavaScript или Python):

nums := []int{1, 2, 3}
// Распаковываем слайс nums и передаем его элементы в функцию sum
sum(nums...)

3. Автоматическое определение размера массива

Используется при объявлении массива, чтобы компилятор сам посчитал количество элементов (вместо того чтобы писать цифру вручную):

// Компилятор сам поймет, что размер массива равен 3
arr := [...]int{10, 20, 30} 

4. Использование в утилите go (командная строка)

Хотя это не относится к самому языку Go, можно часто встретить ... при работе с консольными командами. Оно означает “во всех пакетах, включая вложенные”:

go test ./...    # запустить тесты во всех пакетах текущей директории и поддиректориях
go mod tidy ./...

Итог: Технически это не оператор, а синтаксический токен многоточия, но по смыслу оно выполняет роль оператора распаковки/группировки.`

Использовать bufio.Scanner, чтобы не загружать весь файл в память:

file, err := os.Open("large_file.txt")
if err != nil {
    log.Fatal("Произошла ошибка при открытии файла", err)
}
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    fmt.Println(scanner.Text())
}
if err := scanner.Error(); err != nil {
    log.Fatal("Произошла ошибка чтения файла", err)
}

Здесь bufio.NewScanner создаёт объект, который умеет “читать по строкам”. Он сам заботится о разбиении потока байт на отдельные текстовые строки (по символу новой строки).

Каждый вызов scanner.Scan() читает из файла до следующей строки, возвращая её текст через scanner.Text(). В памяти хранится только одна строку за раз, а не весь файл целиком. Это позволяет обрабатывать файлы размером в сотни мегабайт даже на машинах с ограниченным объёмом ОЗУ.

Как работает буферизация?

При первом вызове scanner.Scan() bufio.Scanner запрашивает у ОС не по байту, а целый блок (обычно ~4 КБ) и кладёт его в свой внутренний буфер. Внутренний алгоритм разбивает этот блок по символам \n, отдаёт строки одну за другой и, когда буфер почти исчерпан, подгружает следующий блок

Такой подход резко сокращает число дорогостоящих обращений к диску (системных вызовов) и делает чтение больших файлов намного быстрее.

Файл — это не просто значок на рабочем столе. Это структурированный набор данных, который хранится на диске и управляется операционной системой (ОС).

В Go работа с файлами строится на двух китах:

  • пакет os для низкоуровневых операций (открытие файла, закрытие файла, чтение или запись файла)
  • пакет io для абстракции ввода-вывода

Когда файл открывается в Go, то на выходе получается объект типа *os.File. Это не просто “ссылка” на файл — это структура, которая содержит:

  • дескриптор файла
  • позиция чтения/записи (offset)
  • метаданные

Дескриптор файла

Дескриптор — это целое число, которое ОС присваивает открытому файлу. Это как номер столика в ресторане: официант (ОС) знает, что заказ (данные) относится к вашему столику (файлу).

Зачем нужно? ОС использует дескрипторы для управления всеми открытыми файлами. Каждая программа имеет ограничение на количество дескрипторов (зависит от ОС).

file, _ := os.Open("awesome_file.txt")
fmt.Printf("Дескриптор файла %d\n", file.Fd()) // -> Выведет число, например, 3

Позиция чтения и записи

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

  • при открытии файла offset = 0 (начало файла)
  • после чтения N байт offset сдвигается на N
  • при записи offset перемещается в конец записанных данных

Метаданные

Объект os.File хранит информацию о файле:

  • путь к файлу в файловой системе ОС
  • режим доступа (чтение, запись и т.д.)
  • права доступа (например -> 0644)

Права доступа — это набор разрешений, который определяет, какие действия могут выполнять разные категории пользователей в ОС с файлом. Обычно права задаются в восьмеричной системе (0XYZ), где:

  • X — права владельца (User)
  • Y — права группы (Group)
  • Z — права остальных (Others)

Каждая цифра — сумма следующих флагов:

  • 4 (r) — чтение: право открывать и просматривать содержимое файла
  • 2 (w) — запись: право изменять или дописывать файл
  • 1 (x) — выполнение: право запускать файл как программу

Например, 0644 означает, что владелец может читать и записывать файл (6 = 4 + 2), а группа и остальные имеют только право чтения (4).

Стратегия замены (подмены)

Самый простой вариант - это стратегия замены:

const stream$ = from([1, 2, 3, 'text', 4, 5]);
stream$
  .pipe(
    map((value) => {
      if (isNaN(value)) {
        throw new Error('Value is not a number!');
      }
      return parseInt(value);
    }),
    catchError((error) => of())
  )
  .subscribe({
    next: (value) => console.log('stream emit value', value),
    error: (error) => console.log('complete stream with error', error),
    complete: () => console.log('complete stream'),
  });

Что мы здесь имеем? Есть поток stream$, который обрабатывается в pipe при помощи операторов map и catchError. Эвенты последовательно приходят из потока в оператор map, внутри которого выполняется проверка при помощи isNaN. Для первых трех эвентов - 1, 2, 3 - условие if не выполняется, поэтому выполняется следующая операция parseInt(value) и преобразованный эвент - попадает в оператор catchError. Так как эвент не является ошибкой, то оператор catchError - пропускает эвент дальше; эвент попадает в подписку subscribe - конкретно в next.

Но вот для эвента со значением ‘text’ - все складывается по другому. Когда он попадает в оператор map, то выполняется условие if и оператор map возвращает вновь созданный эвент со сгенерированной ошибкой. Этот эвент попадает в оператор catchError и так как в данном эвенте содержится ошибка, то сработает callback-функция внутри оператора catchError, которая вернет как результат - новый поток of(). И этот поток - подменит собой поток stream$, в результате subscribe переподпишется на новый поток of(). Этот новый поток не эммитит эвентов, поэтому он сразу завершает свою работу, и в subscribe сработает callback-функция для случая complete.

Стоит обратить внимание, что в данном примере случай error внутри subscribe не сработает, так как обработка ошибки выполняется внутри потока pipe.

Стратегия перенаправления

Чуть более усложеннный вариант предыдущей стратегии:

const stream$ = from([1, 2, 3, 'text', 4, 5]);
stream$
  .pipe(
    map((value) => {
      if (isNaN(value)) {
        throw new Error('Value is not a number!');
      }
      return parseInt(value);
    }),
    catchError((error) => {
      return throwError(() => error);
    })
  )
  .subscribe({
    next: (value) => console.log('stream emit value', value),
    error: (error) => console.log('complete stream with error', error),
    complete: () => console.log('complete stream'),
  });

В данном случае мы имеем почти тот же случай, что и со стратегией подмены. Но - теперь callback-функция внутри оператора catchError - возвращает новый поток, который создан при помощи оператора throwError. То есть - в данном случае не происходит подмены данных в потоке, так как таже ошибка, что пришла из map и catchError, прокидывается дальше - только уже в другом потоке - в subscribe. Этот поток эммити ничего, кроме той самой ошибки, которую он получил от оператора throwError и аварийно завершает свою работу. Поэтому внутри переподписанного на него subscribe - сработает случай error.

Обратим внимание, что в данном случае внутри subscribe сработает случай error и вызовется на исполнение callback-функция, прописанная в нем.

Стратегия повторных попыток

const stream$ = from([1, 2, 3, 'text', 4, 5]);
stream$
  .pipe(
    map((value) => {
      if (isNaN(value)) {
        throw new Error('Value is not a number!');
      }
      return parseInt(value);
    }),
    retry(3),
    catchError((error) => {
      return throwError(() => error);
    })
  )
  .subscribe({
    next: (value) => console.log('stream emit value', value),
    error: (error) => console.log('complete stream with error', error),
    complete: () => console.log('complete stream'),
  });

Данная стратегия почти ничем не отличается от предыдущей стратегии перенаправления, за исключением использования оператора retry(). Как само собой понятно - при возникновении ошибки, которую обнаружит оператор catchError - мы заново перезапустим поток stream$. И таких попыток - в данном случае у нас будет три, то есть - поток перезапустится трижды. Если все три попытки оказались неудачными - выбросится новый поток при помощи оператора throwError и мы упадем в subscribe на случай error.

Есть более продвинутая версия оператора retry() - retryWhen(); можно гибко управлять моментом, когда должна запуститься новая попытка.