РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ / REACT разработчик СЕВЕРСТАЛЬ IT & DIGITAL - Middle / Senior
Сегодня мы разберем живое техническое собеседование на позицию фронтенд-разработчика, в котором акцент сделан на практическом применении TypeScript и работе с асинхронным JavaScript. В процессе интервью кандидат пошагово решает задачу по синхронизации изображений с сервером, обсуждает сложность алгоритма, оптимизацию через структуры данных и параллельную отправку запросов с ограничением по количеству.
Вопрос 1. Как в TypeScript написать type guard-функцию, которая принимает значение типа string | number и определяет, является ли оно строкой?
Таймкод: 00:01:31
Ответ собеседника: неправильный. Неуверенно описывает проверку через typeof, но не использует корректный синтаксис type guard в сигнатуре функции и не демонстрирует понимания механизма сужения типов.
Правильный ответ:
В TypeScript type guard — это специальная конструкция (функция или выражение), которая позволяет на уровне типов сузить объединение (union type) до более конкретного типа внутри условной ветки. Для пользовательских type guard-функций ключевым является возвращаемый тип с предикатом вида:
value is SomeType
Задача: функция, принимающая string | number и определяющая, является ли аргумент строкой.
Базовый и правильный пример:
function isString(value: string | number): value is string {
return typeof value === 'string';
}
Что здесь важно:
-
Сигнатура type guard-а:
value: string | number— входной тип (union).value is string— это не булево выражение, а type predicate.- TypeScript понимает: если
isString(value)вернулоtrue, то в этой веткеvalueимеет типstring.
-
Механизм сужения типа:
function printLengthOrDouble(value: string | number) {
if (isString(value)) {
// Здесь value: string
console.log(value.length);
} else {
// Здесь value: number
console.log(value * 2);
}
}
Благодаря предикату value is string компилятор умеет безопасно сузить тип без дополнительных утверждений (as string).
- Отличие от простой проверки
typeofвнутри вызывающего кода:
-
Если написать так:
function isStringPrimitive(value: string | number): boolean {
return typeof value === 'string';
}TypeScript не использует возвращаемое
booleanдля сужения типа. В коде:if (isStringPrimitive(value)) {
// Здесь value по-прежнему string | number, а не string
} -
Только предикат
value is stringв сигнатуре возвращаемого типа делает функцию полноценным type guard.
- Общая форма пользовательского type guard:
function isX(arg: unknown): arg is X {
// логика проверки типов/структуры
}
Это активно применяют для:
- проверки сложных структур (объекты с полями),
- работы с
unknownиany, - безопасных runtime-проверок, которые синхронизированы с типовой системой.
Итого: корректный type guard должен использовать сигнатуру с type predicate (value is string), а не просто boolean, и обеспечивать консистентность runtime-проверки с описанным типом.
Вопрос 2. Что произойдет при вызове функции, принимающей строго строку, если значение передать через промежуточную функцию с типом any: будет ли ошибка типизации?
Таймкод: 00:10:35
Ответ собеседника: правильный. Правильно замечает, что при использовании типа any TypeScript не выдаст ошибок, так как any отключает типобезопасность.
Правильный ответ:
В TypeScript any фактически выключает систему типов для конкретного значения. Если у нас есть функция, принимающая строго строку, и мы "прокидываем" аргумент через сущность с типом any, TypeScript не выдаст ошибку типизации, даже если фактическое значение несовместимо с ожидаемым типом.
Рассмотрим пример:
function acceptsString(value: string) {
console.log(value.toUpperCase());
}
function wrapper(value: any) {
// Компилятор считает, что value: any, и позволяет передать его куда угодно
acceptsString(value);
}
wrapper(42); // На этапе компиляции: ОК. В рантайме: ошибка.
Ключевые моменты:
-
Почему нет ошибки компиляции:
anyговорит компилятору: "поверь мне, я знаю, что делаю".- Все проверки совместимости типов для значения
anyотключаются. - Вызов
acceptsString(value)сvalue: anyсчитается валидным.
-
Что происходит в рантайме:
- TypeScript не существует в рантайме, это чисто compile-time инструмент.
- В примере выше при выполнении
value.toUpperCase()для числа будет выброшено исключение:TypeError: value.toUpperCase is not a function.
-
Почему это опасно:
anyразмывает границы типобезопасности и легко "протаскивает" ошибки в продуктив.- Ошибки, которые могли бы быть пойманы на этапе компиляции, превращаются в runtime-краши.
-
Как делать правильно:
- Избегать
any, использоватьunknownили более конкретные типы. - При использовании
unknownкомпилятор требует явных проверок:
- Избегать
function wrapperSafe(value: unknown) {
if (typeof value === 'string') {
acceptsString(value); // здесь value: string
} else {
throw new Error('Expected string');
}
}
Таким образом:
- При прокидывании через
anyошибки типизации не будет. - Это ожидаемое, но потенциально опасное поведение, поэтому
anyнужно использовать осознанно и точечно.
Вопрос 3. В чем разница между типами unknown и any в TypeScript и какие проверки нужно выполнить перед использованием значения типа unknown?
Таймкод: 00:11:17
Ответ собеседника: неполный. Верно отмечает, что any не накладывает проверок, а unknown требует предварительных проверок типа, упоминает typeof, но не раскрывает, что для unknown необходимы явные сужения типа перед каждым использованием по назначению.
Правильный ответ:
Типы any и unknown — оба используются для значений, тип которых нам заранее не известен, но их семантика в системе типов принципиально различается.
Основные различия:
-
any:
- Полностью отключает систему типов для данного значения.
- Можно:
- вызывать любые методы,
- обращаться к любым свойствам,
- передавать куда угодно.
- Компилятор почти ничего не проверяет.
Пример (потенциально опасно):
let value: any = 42;
value.foo.bar(); // ОК для TypeScript, может упасть в рантайме
value.toUpperCase(); // ОК для TS, упадет в рантаймеanyлегко протаскивает ошибки в прод, поэтому его использование должно быть редким, точечным и контролируемым. -
unknown:
- Это «безопасный any».
- Значение может быть чем угодно, но:
- Нельзя напрямую вызывать методы,
- Нельзя обращаться к полям,
- Нельзя использовать в местах, где ожидается конкретный тип,
- Нельзя делать арифметику и т.п.
- Пока вы явно не докажете (через проверки), что это более конкретный тип, компилятор не позволит использовать значение.
Пример:
let value: unknown = getValueFromSomewhere();
// Ошибки компиляции:
// value.toUpperCase();
// value.trim();
// value + 10;
if (typeof value === 'string') {
// Здесь value сужено до string
console.log(value.toUpperCase());
} else if (typeof value === 'number') {
// Здесь value: number
console.log(value + 10);
}
Что значит «нужна проверка» для unknown:
Для использования unknown по назначению нужно выполнить явное сужение типа. Подходящие механизмы:
- Проверка примитивов:
typeof value === 'string' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'undefined'
- Проверка массивов и объектов:
Array.isArray(value)value !== null && typeof value === 'object'
- Проверка через оператор
in:if (typeof value === 'object' && value !== null && 'id' in value) { ... }
- Пользовательские type guards (рекомендуется для сложных структур):
type User = {
id: number;
name: string;
};
function isUser(v: unknown): v is User {
return (
typeof v === 'object' &&
v !== null &&
'id' in v &&
'name' in v &&
typeof (v as any).id === 'number' &&
typeof (v as any).name === 'string'
);
}
function handleValue(v: unknown) {
if (isUser(v)) {
// Здесь v: User
console.log(v.id, v.name);
} else {
// Здесь v: не User
console.log('Not a user');
}
}
Ключевой принцип:
- Для
any: компилятор «верит на слово» и не защищает вас. - Для
unknown: компилятор заставляет вас сначала доказать тип (через проверки/guards), и только потом использовать значение, что сильно повышает типобезопасность в больших кодовых базах.
Вопрос 4. Как оценить временную сложность алгоритма фильтрации картинок с вложенными проходами по массивам и можно ли её улучшить?
Таймкод: 00:40:01
Ответ собеседника: правильный. Определяет сложность как квадратичную из-за вложенных проходов по массивам и признает необходимость оптимизации для больших объемов данных.
Правильный ответ:
Рассмотрим典ичный сценарий: есть массив картинок и, например, массив тегов/условий/идентификаторов, по которым выполняется фильтрация. Если для каждой картинки мы итерируемся по другому массиву или делаем линейный поиск, то получаем вложенные циклы.
Упрощенный пример:
type Image struct {
ID int
Tags []string
}
// filter возвращает картинки, у которых есть хотя бы один тег из filterTags.
func filter(images []Image, filterTags []string) []Image {
res := make([]Image, 0)
for _, img := range images { // O(n)
matched := false
for _, tag := range img.Tags { // O(k)
for _, f := range filterTags { // O(m)
if tag == f {
matched = true
break
}
}
if matched {
break
}
}
if matched {
res = append(res, img)
}
}
return res
}
Оценка сложности:
- n — количество картинок,
- k — среднее число тегов у картинки,
- m — количество фильтрующих тегов.
В худшем случае:
- Внутренний поиск по
filterTags— O(m), - Для всех тегов каждой картинки — O(k * m),
- Для всех картинок — O(n * k * m).
Если рассматривать два уровня (например, просто for по картинкам и линейный поиск по списку ID/условий), то это O(n * m). Часто это называют квадратичной сложностью относительно размера входных наборов.
Для небольших объемов данных это может быть приемлемо, но для больших (десятки/сотни тысяч элементов) такая реализация становится бутылочным горлышком.
Как улучшить:
Ключевая идея — заменить повторяющийся линейный поиск на более эффективную структуру данных:
-
Хеш-таблица/мапа для быстрых проверок принадлежности.
- В Go — это
map[KeyType]struct{}илиmap[KeyType]bool. - Временная сложность проверки принадлежности: амортизированно O(1).
Пример оптимизации фильтрации по тегам:
func filterOptimized(images []Image, filterTags []string) []Image {
// Подготовка множества фильтров: O(m)
filterSet := make(map[string]struct{}, len(filterTags))
for _, t := range filterTags {
filterSet[t] = struct{}{}
}
res := make([]Image, 0, len(images))
for _, img := range images { // O(n)
matched := false
for _, tag := range img.Tags { // O(k)
if _, ok := filterSet[tag]; ok {
matched = true
break
}
}
if matched {
res = append(res, img)
}
}
return res
}Сложность:
- Построение множества: O(m)
- Фильтрация: O(n * k) (проверка тега — O(1))
- Итог: O(m + n * k), что асимптотически лучше O(n * k * m) или O(n * m).
- В Go — это
-
Если в исходной реализации был простой вложенный цикл по двум массивам (картинки и, скажем, список разрешенных ID), переход к
mapпозволяет:- снизить сложность с O(n * m) до O(n + m),
- что критично при росте данных.
-
Возможные дополнительные оптимизации:
- Предварительное индексирование картинок по ID, тегам, категориям:
map[id]Imagemap[tag][]Image
- Использование битмапов или bloom-фильтров для очень больших наборов.
- Кеширование результатов фильтрации при повторяющихся запросах.
- Предварительное индексирование картинок по ID, тегам, категориям:
Важно:
- Выявить, где именно происходит повторяющийся линейный поиск (вложенный цикл).
- Вынести «медленный» поиск во внешнюю фазу построения индекса (map), сделать фильтрацию односкановой.
- Следить за балансом: память vs скорость; для больших систем индексы — стандартный и оправданный инструмент.
Кратко:
- Квадратичная (или умноженная) сложность из-за вложенных проходов по массивам — сигнал к оптимизации.
- Применение
map/индексов позволяет превратить вложенный поиск из O(n*m) в O(n+m), что критично для производительности на больших данных.
Вопрос 5. Как уменьшить сложность фильтрации картинок, используя подходящие структуры данных?
Таймкод: 00:43:08
Ответ собеседника: неполный. По подсказке вспоминает Set/Map и пытается применить их для оптимизации, но путается в логике и не формирует четкое, корректное решение.
Правильный ответ:
Основная цель — убрать вложенные линейные проходы и заменить их на операции с амортизированной сложностью O(1) с помощью хеш-структур (map/set), тем самым снизив общую сложность с O(n*m) до близкой к O(n + m).
Рассмотрим типичный кейс:
Есть список картинок и есть набор критериев (например, список разрешенных ID, URL, или тегов), по которым нужно быстро определить, какие картинки подходят.
Базовый (неэффективный) вариант:
type Image struct {
ID int
URL string
}
func filterSlow(images []Image, allowedIDs []int) []Image {
res := make([]Image, 0)
for _, img := range images { // O(n)
for _, id := range allowedIDs { // O(m)
if img.ID == id {
res = append(res, img)
break
}
}
}
return res
}
// Сложность: O(n * m)
Это плохо масштабируется при больших n и m.
Оптимизация через map (хеш-сет):
Идея: превратить список условий фильтрации в множество (set) для O(1)-проверки принадлежности.
В Go нет встроенного Set, но используется map[T]struct{} или map[T]bool:
func filterFast(images []Image, allowedIDs []int) []Image {
// Подготовка множества разрешенных ID: O(m)
allowed := make(map[int]struct{}, len(allowedIDs))
for _, id := range allowedIDs {
allowed[id] = struct{}{}
}
// Линейный проход по картинкам: O(n)
res := make([]Image, 0, len(images))
for _, img := range images {
if _, ok := allowed[img.ID]; ok {
res = append(res, img)
}
}
return res
}
// Итоговая сложность: O(m + n)
// Вместо O(n * m)
Аналогично, если фильтрация по URL:
func filterByURL(images []Image, allowedURLs []string) []Image {
urlSet := make(map[string]struct{}, len(allowedURLs))
for _, u := range allowedURLs {
urlSet[u] = struct{}{}
}
res := make([]Image, 0, len(images))
for _, img := range images {
if _, ok := urlSet[img.URL]; ok {
res = append(res, img)
}
}
return res
}
Фильтрация по тегам (сложнее, но принцип тот же):
Допустим, нужно выбрать картинки, у которых есть хотя бы один из заданных тегов.
type TaggedImage struct {
ID int
Tags []string
}
func filterByTags(images []TaggedImage, filterTags []string) []TaggedImage {
// Множество фильтрующих тегов: O(m)
tagSet := make(map[string]struct{}, len(filterTags))
for _, t := range filterTags {
tagSet[t] = struct{}{}
}
res := make([]TaggedImage, 0, len(images))
for _, img := range images { // O(n)
matched := false
for _, tag := range img.Tags { // O(k)
if _, ok := tagSet[tag]; ok {
matched = true
break
}
}
if matched {
res = append(res, img)
}
}
// Итог: O(m + n * k), без дополнительного умножения на m
return res
}
Дополнительный шаг: индексирование для многократных запросов
Если фильтрация выполняется часто, а набор картинок большой и относительно стабильный, имеет смысл построить индексы один раз.
Примеры индексов:
- По ID:
type ImageIndex struct {
byID map[int]Image
}
func NewImageIndex(images []Image) *ImageIndex {
idx := &ImageIndex{byID: make(map[int]Image, len(images))}
for _, img := range images {
idx.byID[img.ID] = img
}
return idx
}
func (idx *ImageIndex) FilterByIDs(ids []int) []Image {
res := make([]Image, 0, len(ids))
for _, id := range ids {
if img, ok := idx.byID[id]; ok {
res = append(res, img)
}
}
return res
}
// Запросы по IDs теперь O(k), где k — количество искомых ID
- По тегу:
type TagIndex struct {
byTag map[string][]Image
}
func NewTagIndex(images []Image) *TagIndex {
idx := &TagIndex{byTag: make(map[string][]Image)}
for _, img := range images {
for _, tag := range img.Tags {
idx.byTag[tag] = append(idx.byTag[tag], img)
}
}
return idx
}
func (idx *TagIndex) FilterByTags(tags []string) []Image {
// Можно объединять результаты, пересекать, дедуплицировать
seen := make(map[int]struct{})
res := make([]Image, 0)
for _, t := range tags {
for _, img := range idx.byTag[t] {
if _, ok := seen[img.ID]; !ok {
seen[img.ID] = struct{}{}
res = append(res, img)
}
}
}
return res
}
Ключевые выводы:
- Использование
any/линейного поиска по массиву условий — типичный источник O(n*m). - Правильный способ уменьшить сложность:
- Преобразовать условия фильтрации в
map/set. - Для частых запросов — строить индексы по ключевым полям (ID, URL, тегам).
- Преобразовать условия фильтрации в
- В результате основная логика фильтрации становится линейной по входным данным (или лучше), что критично для систем с большими объемами изображений и высоким QPS.
Вопрос 6. Как правильно понимать задачу про очередь загрузки картинок: синхронная обработка с ограничением не более двух параллельных загрузок?
Таймкод: 00:49:10
Ответ собеседника: неполный. Пересказывает идею очереди и цикла, уточняет трактовку ограничения, но не формулирует корректный механизм контроля максимум двух одновременных загрузок.
Правильный ответ:
Задача формулируется так:
- Есть список (очередь) URL картинок для загрузки.
- Нужно:
- обрабатывать их «по очереди» в смысле управляемого процесса, без хаотического создания неограниченного числа параллельных запросов;
- при этом разрешается не более 2 одновременных загрузок в каждый момент времени;
- как только одна загрузка завершилась — сразу можно запускать следующую из очереди, сохраняя лимит параллелизма.
Ключевая идея: это классический сценарий «пул воркеров» / «ограничение параллелизма»:
- У нас есть:
- очередь задач (URL картинок),
- фиксированное число одновременных «рабочих» (2),
- пока есть задачи — занятые воркеры загружают, освободившиеся берут новые.
Важно правильно понять термин «синхронная загрузка» в формулировке:
- Обычно под этим в таком контексте имеют в виду управляемый, детерминированный процесс, а не реально блокирующие вызовы.
- Фактически требуется:
- асинхронная модель с ограниченным параллелизмом,
- при этом весь список будет обработан, но не более 2 запросов одновременно.
Пример реализации в Go (ограничение до 2 параллельных загрузок):
Используем:
- буферизированный канал как семафор,
WaitGroupдля ожидания завершения всех загрузок.
package main
import (
"fmt"
"net/http"
"sync"
)
func downloadImage(url string) error {
// Пример: простая проверка доступности
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", resp.Status)
}
// Тут могла бы быть логика сохранения файла
return nil
}
func downloadWithLimit(urls []string, maxParallel int) {
if maxParallel <= 0 {
maxParallel = 1
}
sem := make(chan struct{}, maxParallel) // семафор параллелизма
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
// Блокируем, если уже запущено maxParallel задач
sem <- struct{}{}
go func(u string) {
defer wg.Done()
defer func() { <-sem }() // освобождаем "слот"
if err := downloadImage(u); err != nil {
fmt.Printf("failed to download %s: %v\n", u, err)
} else {
fmt.Printf("downloaded %s\n", u)
}
}(url)
}
wg.Wait()
}
func main() {
urls := []string{
"https://example.com/1.jpg",
"https://example.com/2.jpg",
"https://example.com/3.jpg",
"https://example.com/4.jpg",
}
// Не более двух одновременных загрузок
downloadWithLimit(urls, 2)
}
Как это работает:
sem := make(chan struct{}, maxParallel):- емкость канала = максимум одновременных задач;
- каждая горутина при старте записывает в канал элемент (
sem <- struct{}{}), - если канал заполнен — новая задача ждет, пока кто-то освободит слот.
defer func() { <-sem }():- по завершении загрузки освобождаем слот, позволяя следующей задаче стартовать.
- В любой момент времени выполняется не более
maxParallelзагрузок (в задаче — 2). - Все URL обрабатываются до конца, порядок старта соответствует очереди, но завершение может быть в другом порядке — это нормально при ограниченном параллелизме.
Альтернативный вариант через пул воркеров:
- Запускаем ровно 2 воркера (2 горутины),
- Кладем все URL в канал задач,
- Каждый воркер читает из канала и качает,
- Когда задачи кончились — воркеры завершаются.
Семантически оба подхода корректны для условия: «есть очередь задач и не более двух загрузок одновременно».
Ключевые моменты, которые нужно явно проговаривать на собеседовании:
- Понимание отличия:
- «последовательная загрузка» (строго одна за другой) vs.
- «ограниченная параллельность» (до N одновременно).
- Умение реализовать ограничение параллелизма:
- через семафор (buffered channel),
- через пул воркеров,
- через токены или middleware на уровне HTTP-клиента.
- Гарантия отсутствия «взрыва» горутин/запросов при большом количестве картинок.
Вопрос 7. Как реализовать отправку картинок с ограничением не более двух одновременных запросов, в том числе при нечётном количестве элементов?
Таймкод: 00:50:29
Ответ собеседника: неправильный. Пытается группировать элементы по два и использовать цикл, но не реализует корректную параллельную отправку с ожиданием обеих операций; либо нарушает лимит параллельности, либо сводит выполнение к последовательному, особенно при нечётном количестве картинок.
Правильный ответ:
Ключевая цель: гарантировать, что:
- В каждый момент времени активно не более 2 запросов отправки картинок.
- Все элементы (в том числе при нечётном количестве) будут обработаны.
- Новая отправка начинается, как только освободилось место (завершился один из запросов).
- Не нужно вручную «группировать по два» — это часто приводит к ошибкам в контроле параллелизма.
Оптимальное решение — использовать примитив, реализующий семафорный контроль параллелизма: буферизированный канал в Go.
Базовый паттерн (через семафор):
func sendImage(img string) error {
// Здесь логика отправки картинки (HTTP-запрос, запись в хранилище и т.д.)
// Это может быть синхронный вызов, блокирующий до завершения.
return nil
}
func sendImagesWithLimit(urls []string, maxParallel int) error {
if maxParallel <= 0 {
maxParallel = 1
}
sem := make(chan struct{}, maxParallel) // семафор для ограничения параллельности
var wg sync.WaitGroup
errs := make(chan error, len(urls))
for _, url := range urls {
wg.Add(1)
sem <- struct{}{} // блокируем, если уже запущено maxParallel горутин
go func(u string) {
defer wg.Done()
defer func() { <-sem }() // освобождаем слот по завершении
if err := sendImage(u); err != nil {
errs <- fmt.Errorf("failed to send %s: %w", u, err)
}
}(url)
}
wg.Wait()
close(errs)
// Опционально: агрегация ошибок
var resultErr error
for err := range errs {
if err != nil {
// В реальном коде лучше накапливать/логировать все ошибки
resultErr = err
}
}
return resultErr
}
Почему это решение корректно:
- В любой момент времени в
semможет лежать не болееmaxParallelэлементов. - Перед запуском горутины мы делаем
sem <- struct{}{}:- если уже запущено
maxParallelгорутин, следующая будет ждать, пока одна из них не завершится и не освободит слот.
- если уже запущено
- По завершении отправки делаем
<-sem:- это освобождает место для следующей задачи.
wg.Wait()гарантирует, что мы дождёмся завершения всех операций.- Лимит
maxParallel = 2выполняется строго для любого количества элементов:- при 1 элементе — 1 запрос;
- при 2 элементах — максимум 2 параллельно;
- при 3, 5, 101 элементах — всегда максимум 2 одновременно, без необходимости искусственно группировать по два.
Таким образом:
- Не нужно вручную бить на пары и писать вложенные
ifдля “последний нечётный”. - Семафорный подход автоматически корректно обрабатывает:
- чётное и нечётное число элементов,
- любые задержки: как только одна отправка завершилась — запускается следующая, не нарушая лимит.
Альтернатива: пул воркеров на 2 потока
Если хочется более явно выразить модель «2 воркера обслуживают очередь задач»:
func sendImagesWithWorkers(urls []string, workers int) error {
if workers <= 0 {
workers = 1
}
tasks := make(chan string)
errs := make(chan error, len(urls))
var wg sync.WaitGroup
// Стартуем фиксированное количество воркеров
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for url := range tasks {
if err := sendImage(url); err != nil {
errs <- err
}
}
}()
}
// Кладем задачи в очередь
go func() {
for _, url := range urls {
tasks <- url
}
close(tasks)
}()
wg.Wait()
close(errs)
var resultErr error
for err := range errs {
if err != nil {
resultErr = err
}
}
return resultErr
}
Свойства этого решения:
- Ровно
workers(2) одновременных обработчиков. - Каждый воркер берёт следующее задание из очереди.
- Нечётное количество элементов обрабатывается естественным образом:
- когда задачи заканчиваются, воркеры корректно завершаются.
- ограничение “не более двух одновременно” сохраняется на всём протяжении обработки.
Типичные ошибки (как в неудачном ответе):
- Группировка по два элемента и последовательный запуск без реального параллелизма.
- Запуск горутин для всех картинок разом без ограничения (взрыв параллелизма).
- Отсутствие ожидания завершения всех горутин (
WaitGroup/каналы), что ведёт к раннему завершению функции. - Некакорректная обработка последнего (нечётного) элемента вручную, когда логика “пар” ломает общий контракт.
Правильный подход:
- мыслить не «парами элементов», а «ограниченным пулом исполнителей»;
- использовать семафор (buffered channel) или пул воркеров;
- гарантировать строгий контроль верхнего лимита параллельных операций для любых размеров входных данных.
