Условия и циклы: как программа принимает решения
Зачем это нужно
До этого места программа была прямой: строчка, следующая строчка, ещё строчка. Так пишутся только самые первые примеры. Настоящий код почти целиком состоит из двух вещей: выбрать одно из нескольких и повторить одно и то же для каждого элемента.
Скрипт, который ждёт подтверждения транзакции, — это цикл с условием выхода. Подсчёт суммы по списку переводов — это цикл с накоплением. Проверка «нода ответила успешно или ошибкой» — условие. Разбор статуса транзакции на три возможных состояния — снова условие. Ничего другого в программе, по сути, нет: остальное — оформление этих двух конструкций.
Эта глава разбирает их до последнего символа: где ставятся скобки и почему, чем && отличается от &, почему switch без break проваливается в следующую ветку, чем for..of отличается от for..in, и почему цикл на var печатает три тройки, а на let — ноль, один, два. Все результаты и все сообщения об ошибках сняты с живого запуска Node.js 24 и TypeScript 6.
- Зачем это нужно
if: развилка из одного вопроса- Из чего собирают условие: сравнения и логика
- Лестница
else ifи почему лучше ранний выход - Тернарный оператор: развилка внутри выражения
switchи знаменитое проваливание- Цикл
for: три части в скобках for..of: перебор элементов, а не индексовfor..in: ключи объекта, и почему им не перебирают массивыwhileиdo..while: цикл без счётчикаbreak,continueи метки- Ловушка
varв цикле: три тройки - Методы массива вместо циклов
- Что добавляет TypeScript к условиям и циклам
- Типичные ошибки: текст, перевод, причина, починка
- Тренажёр: конструкции этой главы
- Символы этой главы
- Что должен уметь
- Куда дальше: проверенные ресурсы
if: развилка из одного вопроса
Самая простая форма выбора — «если условие истинно, выполни блок».
const height = 152340;
if (height > 0) {
console.log('нода что-то знает о цепочке');
}
Разбор по частям — здесь важен каждый символ:
Фигурные скобки формально можно опустить, если в теле ровно одна инструкция. Не опускай. Каждый разработчик хотя бы раз добавлял вторую строку в такой блок и получал код, который выглядит как условие, а выполняется всегда:
if (height > 0)
console.log('первая'); // это внутри условия
console.log('вторая'); // а это уже снаружи, выполнится всегда
Ошибки не будет. Программа просто будет работать неправильно — самая дорогая разновидность бага.
Полная форма развилки добавляет вторую ветку:
if (height > 0) {
console.log('цепочка есть');
} else {
console.log('высота нулевая');
}
Слово else пишется на той же строке, что и закрывающая скобка предыдущего блока — так принято в JavaScript и TypeScript, и это не вкусовщина: в языке есть правило автоматической расстановки точек с запятой, из-за которого перенос скобки на новую строку в некоторых местах меняет смысл кода.
Из чего собирают условие: сравнения и логика
Условие — это выражение, дающее true или false. Собирают его из сравнений и логических операторов.
Сравнения:
| Оператор | Читается | Пример |
|---|---|---|
=== | строго равно | status === 'confirmed' |
!== | строго не равно | status !== 'unknown' |
> < | больше, меньше | height > 0 |
>= <= | больше или равно, меньше или равно | amount >= fee |
Про == и != помним из прошлой главы: они приводят типы перед сравнением, и в новом коде их не пишут, кроме идиомы value == null.
Логические операторы:
| Оператор | Читается | Результат |
|---|---|---|
&& | И | истинно, когда истинны обе стороны |
два знака | подряд | ИЛИ | истинно, когда истинна хотя бы одна сторона |
! | НЕ | переворачивает логическое значение |
if (res.ok && data.height > 0) { /* обе стороны истинны */ }
if (!res.ok) { /* res.ok ложно */ }
Два свойства логических операторов, которые важнее, чем кажутся.
Первое: вычисление короткое. В выражении a && b правая часть вообще не вычисляется, если левая ложна: результат уже известен. То же с ИЛИ: правая часть не вычисляется, если левая истинна. Это не оптимизация, а часть языка, на которой держится очень частый приём — защита от обращения к отсутствующему полю:
if (tx.sender && tx.sender.address === recipient) { /* безопасно */ }
Если tx.sender отсутствует, до второй части дело не дойдёт, и TypeError: Cannot read properties of undefined не случится.
Второе: && и || возвращают не логическое значение, а один из операндов. 0 || 'запасное' даёт строку, а не true. Именно поэтому они работают как «значение по умолчанию» — и именно поэтому для этой цели чаще нужен ??, который не считает ноль пустотой.
Порядок вычисления: сначала сравнения, потом &&, потом ||, а ! вычисляется раньше всего. Когда сомневаешься — ставь круглые скобки. Лишние скобки ничего не стоят, а неверный порядок ищется полдня.
Не путай && с & и || с |. Одинарные знаки — это побитовые операторы, они работают с двоичным представлением чисел и в условиях почти всегда означают опечатку. Особенно коварно &: выражение if (a & b) иногда даёт правильный результат случайно, потому что 1 & 1 равно 1, и ошибка живёт в коде месяцами.
Лестница else if и почему лучше ранний выход
Когда вариантов больше двух, ветки выстраивают лестницей:
if (status === 'confirmed') {
console.log('в блоке');
} else if (status === 'unconfirmed') {
console.log('в очереди');
} else if (status === 'unknown') {
console.log('нода про неё не слышала');
} else {
console.log('неизвестный статус: ' + status);
}
Проверки идут сверху вниз, выполняется первая подошедшая, остальные пропускаются. Последний else ловит всё, что не подошло, — и его почти всегда надо писать: молча ничего не сделать при неожиданном значении хуже, чем напечатать понятную жалобу.
У лестницы есть неприятное свойство: она растёт вправо, когда условия вкладываются друг в друга.
// так пишут первые две недели
function describe(tx) {
if (tx) {
if (tx.status) {
if (tx.status === 'confirmed') {
return 'в блоке';
}
}
}
return 'непонятно';
}
Тот же код с ранним выходом (в англоязычной литературе — guard clause, «сторожевая проверка»):
function describe(tx) {
if (!tx) return 'непонятно';
if (!tx.status) return 'непонятно';
if (tx.status !== 'confirmed') return 'непонятно';
return 'в блоке';
}
Логика та же, вложенности нет. Правило, которое стоит принять сразу: сначала отсекай плохие случаи и выходи, потом пиши основной путь без отступов. Читать такой код в разы легче, а на чемпионате читать приходится и чужой код, и свой собственный вчерашний.
Тернарный оператор: развилка внутри выражения
Когда развилка нужна прямо внутри выражения, пишут тернарный оператор — единственный оператор языка с тремя частями:
const h = 7;
console.log(h % 2 === 0 ? 'чётная' : 'нечётная'); // нечётная
Разница с if принципиальная: if — инструкция, она ничего не возвращает; тернарный оператор — выражение, у него есть результат, и его можно присвоить, передать в функцию, вставить в шаблонную строку.
const label = `транзакция ${confirmed ? 'подтверждена' : 'в очереди'}`;
Вкладывать тернарные операторы друг в друга технически можно, но читать такое невозможно. Два уровня — уже повод переписать на if или switch.
switch и знаменитое проваливание
Когда одно значение сравнивают с набором вариантов, пишут switch. Живой запуск — и результат, который удивляет всех:
switch (2) {
case 1: console.log('один');
case 2: console.log('два');
case 3: console.log('три'); break;
default: console.log('другое');
}
два
три
Напечаталось две строки. Причина: switch не выбирает ветку, он ищет точку входа и дальше выполняет всё подряд до первого break или до конца. Ветка case 2 подошла, начали печатать с неё, case 3 не имеет break перед собой — и выполнилась тоже. Это поведение называется проваливанием (fall-through), и оно не ошибка языка, а осознанная возможность: иногда несколько значений обрабатывают одинаково.
Правильная форма — с break в каждой ветке:
switch (status) {
case 'confirmed':
console.log('в блоке');
break;
case 'unconfirmed':
case 'unknown': // намеренное проваливание: два значения, одна обработка
console.log('ещё не в блоке');
break;
default:
console.log('неизвестный статус');
}
Сравнение в switch строгое, как ===. Строка '1' не подойдёт к ветке case 1, и TypeScript про это скажет заранее:
error TS2678: Type '"1"' is not comparable to type '1'.
Перевод: «тип "1" несопоставим с типом 1». В чистом JavaScript такая ветка просто никогда не сработает, молча.
Цикл for: три части в скобках
Цикл повторяет блок кода. Классическая форма выглядит страшно ровно до того момента, пока не разберёшь её на части.
for (let i = 0; i < 3; i++) {
process.stdout.write(i + ' ');
}
// 0 1 2
Порядок выполнения стоит проговорить вслух хотя бы раз: начало → проверка → тело → шаг → проверка → тело → шаг → … — и так, пока проверка не даст ложь. Отсюда сразу два вывода: тело может не выполниться ни разу (если условие ложно с самого начала), а шаг выполняется после тела, а не до.
Имя счётчика традиционно i — от английского index. Вложенные циклы используют j, потом k. Это одно из немногих мест, где однобуквенное имя считается хорошим стилем.
i++ — сокращение для i = i + 1. Есть и i--, и i += 2 (увеличить на два), и i *= 2.
for..of: перебор элементов, а не индексов
Классический for нужен, когда важен номер. Когда важны сами элементы — пишут for..of, и он короче и безопаснее:
const txs = ['a', 'b', 'c'];
for (const t of txs) {
process.stdout.write(t + ' ');
}
// a b c
В переменную t попадает элемент, а не индекс. Объявляют её через const: на каждом проходе создаётся новое имя, переприсваивать его не нужно.
Если номер всё-таки нужен, элементы берут вместе с индексом:
for (const [i, t] of txs.entries()) {
process.stdout.write(`${i}:${t} `);
}
// 0:a 1:b 2:c
Квадратные скобки слева — это деструктуризация: entries() отдаёт пары «индекс, значение», а запись [i, t] разбирает пару на две переменные. Подробно разберём в главе про коллекции.
for..of работает не только с массивами, а со всем, что язык считает перебираемым: строки (по символам), Map, Set. С обычным объектом — не работает, и вот живая ошибка при попытке перебрать число:
TypeError: 5 is not iterable
Перевод: «5 не является перебираемым». TypeScript скажет то же самое заранее и подробнее:
error TS2488: Type '5' must have a '[Symbol.iterator]()' method that returns an iterator.
for..in: ключи объекта, и почему им не перебирают массивы
Похожая по написанию конструкция с совершенно другим смыслом:
const obj = { height: 5, id: 'x' };
for (const k in obj) {
process.stdout.write(k + '=' + obj[k] + ' ');
}
// height=5 id=x
for..in перебирает ключи объекта. Для перебора полей это правильный инструмент. Но у него есть ловушка: применённый к массиву, он выдаёт индексы, причём строками:
const a = ['x', 'y'];
for (const i in a) console.log(typeof i, i);
// string 0
// string 1
Индекс '0' — строка. Попытка сложить его с числом даст склейку, а не сумму. Плюс for..in обходит и унаследованные свойства, что в редких случаях приносит лишние ключи.
Правило без исключений: массивы перебирают через for..of или методы массива, объекты — через for..in или Object.keys. Разница между of и in — два символа, а последствия разные, поэтому проговори её вслух: of — значения, in — ключи.
while и do..while: цикл без счётчика
Когда заранее неизвестно, сколько будет проходов, пишут while — «пока условие истинно».
let n = 3;
while (n > 0) {
process.stdout.write(n + ' ');
n--;
}
// 3 2 1
Условие проверяется перед каждым проходом, поэтому тело может не выполниться ни разу. У do..while проверка стоит в конце, и тело гарантированно выполняется хотя бы один раз:
let m = 0;
do {
process.stdout.write('раз ');
m++;
} while (m < 1);
// раз
do..while встречается редко, но его стоит узнавать в чужом коде.
Главная опасность while — бесконечный цикл. Он получается, когда условие никогда не становится ложным: чаще всего потому, что забыли изменить переменную внутри тела.
let n = 3;
while (n > 0) {
console.log(n); // n никогда не уменьшается — программа зависнет навсегда
}
Node не выдаст ошибку. Программа просто перестанет отвечать, процессор загрузится на сто процентов, и остановить её придётся вручную — в терминале сочетанием Ctrl+C.
Для скрипта, который ждёт подтверждения транзакции, бесконечный цикл — реальный риск. Поэтому у ожидания всегда делают два ограничителя: счётчик попыток и паузу между ними.
let attempts = 0;
while (attempts < 20) {
const status = await fetchStatus(id);
if (status === 'confirmed') break;
attempts++;
await sleep(3000);
}
Правило для любого ожидания внешней системы: у цикла ожидания всегда есть максимальное число попыток. Формулировка «ждём, пока не подтвердится» звучит логично ровно до первого случая, когда транзакция не подтвердится никогда — потому что не хватило комиссии, потому что нода отвалилась, потому что в сети форк. Без ограничителя скрипт зависнет молча и без единого сообщения, а на чемпионате это выглядит как «ничего не работает».
break, continue и метки
Два слова управляют ходом цикла изнутри:
break— выйти из цикла целиком, немедленно.continue— пропустить остаток тела и перейти к следующему проходу.
Живой запуск, где есть оба:
for (let i = 0; i < 5; i++) {
if (i === 2) continue; // двойку пропускаем
if (i === 4) break; // на четвёрке выходим
process.stdout.write(i + ' ');
}
// 0 1 3
Разбор вывода: 0 напечаталось, 1 напечаталось, на 2 сработал continue и печать пропустилась, 3 напечаталось, на 4 сработал break и цикл закончился. Числа 4 в выводе нет — break сработал до печати.
Во вложенных циклах break выходит только из своего, ближайшего цикла. Если нужно выйти сразу из обоих, у цикла ставят метку — имя с двоеточием:
outer: for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (j === 1) continue outer; // перейти к следующему i
process.stdout.write(`${i}${j} `);
}
}
// 00 10 20
Метки — редкий инструмент, и злоупотреблять ими не стоит. Но узнавать в чужом коде надо: конструкция outer: перед for выглядит загадочно, пока не знаешь, что это просто имя.
Ловушка var в цикле: три тройки
Один из самых известных примеров в истории JavaScript. Два почти одинаковых цикла, разный результат — живой вывод:
const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => i);
console.log(fns.map(f => f())); // [ 3, 3, 3 ]
const fns2 = [];
for (let i = 0; i < 3; i++) fns2.push(() => i);
console.log(fns2.map(f => f())); // [ 0, 1, 2 ]
Объяснение: var создаёт одну переменную на всю функцию. Все три функции запомнили одно и то же имя, а к моменту вызова цикл уже закончился и в этом имени лежит 3. let создаёт новую переменную на каждом проходе — каждая функция запомнила свою.
Это ещё одна причина, по которой var в новом коде не пишут, и повод один раз посмотреть на пример своими глазами: пересказ такого поведения запоминается хуже, чем вывод [ 3, 3, 3 ] на собственном экране.
Методы массива вместо циклов
Очень часто цикл писать не надо: у массива есть готовые методы, которые делают то же самое короче и понятнее. Живой вывод:
console.log([1, 2, 3].map(x => x * 2)); // [ 2, 4, 6 ]
console.log([1, 2, 3].filter(x => x > 1)); // [ 2, 3 ]
console.log([1, 2, 3].reduce((a, b) => a + b, 0)); // 6
map— «преобразуй каждый элемент», возвращает новый массив той же длины.filter— «оставь подходящие», возвращает новый массив, обычно короче.reduce— «сверни в одно значение», возвращает что угодно: сумму, объект, строку.forEach— «сделай что-нибудь для каждого», ничего не возвращает.
Правило выбора: если из списка получается другой список — map или filter; если одно значение — reduce; если нужен побочный эффект вроде печати — forEach или обычный цикл.
У forEach есть важное ограничение, которое ловят все: из него нельзя выйти досрочно. break внутри него не компилируется, а return выходит только из текущего вызова, не из перебора:
[1, 2].forEach(x => {
if (x === 1) return; // это не «выйти из цикла», а «закончить обработку одного элемента»
console.log('видно', x);
});
// видно 2
Единица пропущена, но перебор продолжился. Если нужен настоящий досрочный выход — пиши for..of с break.
Что добавляет TypeScript к условиям и циклам
Синтаксис тот же — в этих конструкциях TypeScript не добавляет ни одного нового символа. Он добавляет проверки, и их три вида.
Первая: тип элемента известен внутри цикла.
const arr: number[] = [1, 2, 3];
for (const a of arr) { a.toUpperCase(); }
error TS2339: Property 'toUpperCase' does not exist on type 'number'.
Компилятор знает, что в a число, и метод строки у него вызвать не даст.
Вторая: сужение типа внутри ветки. После проверки компилятор считает значение уточнённым:
function label(value: string | number): string {
if (typeof value === 'number') {
return value.toFixed(2); // здесь value — точно число
}
return value.toUpperCase(); // а здесь — точно строка
}
Это называется сужением (narrowing) и работает с typeof, сравнением с литералом, Array.isArray, проверкой поля через in. Никакого кода в собранный JavaScript при этом не добавляется — вся работа происходит на этапе компиляции.
Третья: проверка полноты возврата. Если функция обещает возвращать строку, но в какой-то ветке ничего не возвращает, компилятор скажет:
function label(h: number): string {
if (h > 0) { return 'есть'; }
}
error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
Перевод: «в функции нет завершающего return, а тип результата не включает undefined». Это ровно тот случай, когда лестница if забыла последний else, — и TypeScript ловит его до запуска.
Ещё одна полезная мелочь: случайное присваивание вместо сравнения внутри условия TypeScript тоже не пропускает, если переменная объявлена через const:
error TS2588: Cannot assign to 's' because it is a constant.
Типичные ошибки: текст, перевод, причина, починка
| Сообщение | Перевод | Что произошло | Как чинить |
|---|---|---|---|
TypeError: 5 is not iterable | 5 не перебираемое | В for..of подставили не массив и не строку | Проверить, что перебирается: Array.isArray(value) |
error TS2488: Type '5' must have a '[Symbol.iterator]()' method | Тип не является перебираемым | То же самое, но поймано до запуска | Исправить тип значения в for..of |
error TS2339: Property 'toUpperCase' does not exist on type 'number' | У числа нет такого метода | Внутри цикла к элементу применили метод другого типа | Посмотреть тип элементов массива |
error TS2366: Function lacks ending return statement | Нет завершающего return | Одна из веток условия ничего не возвращает | Добавить последний else или return в конце |
error TS2678: Type '"1"' is not comparable to type '1' | Строка несопоставима с числом | В switch сравнивают значения разных типов | Привести значение или исправить case |
error TS2588: Cannot assign to 's' because it is a constant | Присваивание константе | В условии написали = вместо === | Исправить на сравнение |
| Программа зависла, ошибки нет | — | Бесконечный цикл: условие никогда не станет ложным | Ctrl+C, затем добавить изменение переменной или счётчик попыток |
| Цикл ничего не напечатал | — | Условие ложно с самого начала | Проверить начальное значение счётчика и знак сравнения |
| Пропущен последний элемент | — | Условие i < arr.length - 1 вместо i < arr.length | Проверить границу: индексы идут от 0 до length - 1 |
Тренажёр: конструкции этой главы
Цель: скорость ≥ 110 зн/мин, точность ≥ 90%
if (!res.ok) throw new Error(`нода ответила ${res.status}`);
Символы этой главы
| Знак | Название | Что делает |
|---|---|---|
( ) | круглые скобки | держат условие у if, while, switch и три части у for |
{ } | фигурные скобки | тело блока: то, что выполняется или повторяется |
&& | логическое И | истинно, когда истинны обе стороны; правая не вычисляется при ложной левой |
два знака | подряд | логическое ИЛИ | истинно, когда истинна хотя бы одна сторона |
! | логическое НЕ | переворачивает истинность |
? : | тернарный оператор | развилка внутри выражения |
% | остаток от деления | h % 2 === 0 — проверка на чётность |
++ -- | инкремент, декремент | увеличить или уменьшить на единицу |
+= *= | составное присваивание | i += 2 — то же, что i = i + 2 |
: после имени цикла | метка | имя цикла для break outer и continue outer |
; внутри for | разделитель частей | ровно два внутри круглых скобок |
Что должен уметь
- Писать
ifиelseс фигурными скобками и объяснять, чем опасно их отсутствие. - Собирать условие из сравнений и логических операторов и объяснять короткое вычисление.
- Отличать
&&от&и понимать, почему одинарный знак в условии — почти всегда опечатка. - Переписывать вложенные условия на ранний выход.
- Использовать тернарный оператор там, где нужен результат, и не вкладывать его в себя.
- Писать
switchсbreakи объяснять проваливание, а также применять его намеренно. - Разбирать заголовок
forна три части и проговаривать порядок выполнения. - Выбирать между
for,for..of,for..inиwhileпо задаче. - Помнить, что
for..inпо массиву даёт строковые индексы. - Ставить у любого цикла ожидания ограничение по числу попыток.
- Пользоваться
breakиcontinue, в том числе с метками во вложенных циклах. - Объяснять, почему цикл на
varс замыканиями печатает три одинаковых числа. - Заменять простые циклы на
map,filter,reduceи знать, что изforEachнельзя выйти досрочно. - Читать ошибки
TS2488,TS2339,TS2366,TS2678и понимать, какая конструкция их вызвала. - Узнавать зависание по бесконечному циклу и останавливать процесс через
Ctrl+C.
Углубиться
Почему for..of работает не со всем. Перебор в языке устроен через протокол итератора: значение считается перебираемым, если у него есть метод с ключом Symbol.iterator. У массивов, строк, Map и Set он есть, у обычных объектов — нет. Именно этот метод и упомянут в тексте ошибки TS2488. Свой объект можно сделать перебираемым, добавив такой метод, — на этом же протоколе работает и распространение через ....
Стоимость вложенных циклов. Цикл внутри цикла по одному и тому же списку из n элементов выполняет тело n × n раз. Для сотни элементов это десять тысяч проходов — незаметно. Для десяти тысяч — сто миллионов, и скрипт начинает думать секундами. На чемпионате это встречается в задачах вида «найти пару транзакций с одинаковым адресом»: наивное решение с двумя циклами работает, но не укладывается в лимит, а решение через Map проходит за один проход.
switch и Map. Длинный switch по строковому значению часто заменяют объектом или Map, где ключ — значение, а значение — обработчик. Кода получается меньше, а добавление нового варианта не требует правки условной конструкции.
Исчерпывающая проверка union. Если статус описан union-типом, а в switch разобраны не все варианты, TypeScript может об этом сообщить — для этого в ветке default присваивают значение переменной типа never. При добавлении нового статуса в тип компилятор укажет на этот switch. Приём называется exhaustiveness checking и очень выручает в проектах, где набор состояний растёт.
label и структурное программирование. Метки — дальний родственник оператора goto, от которого языки отказались в семидесятых. В отличие от goto, метка в JavaScript может только выйти из цикла или перейти к следующей его итерации, то есть прыгнуть вверх по вложенности, но не в произвольное место. Поэтому она безопасна, хотя и встречается редко.
Дальше по теме:
- MDN: управляющие конструкции — условия и обработка ошибок от первоисточника
- MDN: циклы и итерации — все формы циклов с примерами
- TypeScript Handbook: Narrowing — как проверки в условиях уточняют тип
Сводная проверка по всей главе: 13 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
-
MDN: управляющие конструкции и обработка ошибок —
if,switch,tryот первоисточника. -
MDN: циклы и итерации — все формы циклов в одном месте.
-
MDN: for...of — что именно можно перебирать и почему.
-
MDN: for...in — предупреждение про массивы прямо в документации.
-
MDN: switch — проваливание и намеренная группировка веток.
-
MDN: логические операторы — приоритет и короткое вычисление.
-
MDN: тернарный оператор — синтаксис и примеры.
-
MDN: Array.prototype.map — преобразование списка.
-
MDN: Array.prototype.filter — отбор элементов.
-
MDN: Array.prototype.reduce — свёртка в одно значение, включая начальное значение.
-
TypeScript Handbook: Narrowing — как условия уточняют тип значения.
-
Видео и материалы сообщества — ниже — ролики по теме и ссылки от студентов и преподавателей смотри в самом низу страницы.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.