Наивность, fail‑fast, fallback, failback, оптимизация и стратегия — на слизне, на кухне и на JavaScript

Эти слова программисты произносят каждый день, и почти каждый понимает их немного по‑своему. Можно было бы начать со словаря: шесть терминов, шесть определений. Это был бы наивный план — в том самом смысле, о котором пойдёт речь ниже, — и к концу статьи станет видно, почему я от него отказался.

Вместо словаря будет задача. Понятия появляются по одному — тогда, когда без них уже нельзя двигаться дальше. Каждое получает образ, точное определение и код.

Как читать. Статья написана сразу для трёх читателей.

  • Если вы не программист, код можно пропускать: всё важное из него сказано словами рядом.

  • Если вы математик, ищите блоки «Строже»: там формулировки без аналогий. Остальные могут их пропускать.

  • Если вы программист: весь код на JavaScript без зависимостей, любой фрагмент запускается в консоли браузера или в Node.js.

По ходу я задаю вопросы. Они не риторические: если вы нашли ответ раньше, чем дочитали до него, — хороший знак.

1. Слизень под солнцем

Утро, солнце поднимается. Для слизня это опасность: на солнце он теряет влагу и в конце концов высыхает. Ему нужно укрытие — и чем раньше, тем лучше.

Рядом три углубления:

  • А — широкая ямка, вход прямо на поверхности. Добраться легко, но она мелкая: когда солнце поднимется выше, оно достанет до дна.

  • Б — узкая трубка, уходящая вниз под углом.

  • В — узкая вертикальная трубка. Из неё тянет сыростью.

Насколько глубоки трубки, неизвестно. Внутри темно, и слизень там ничего не видит. Он умеет ползти, изгибаться, протискиваться и пятиться, но физику не нарушает: слишком узкий проход он не пройдёт, слишком высокий уступ не преодолеет.

Главная трудность видна уже здесь. Чтобы узнать, годится ли трубка, в неё нужно залезть. А залезть — значит потратить время, которое идёт под солнцем. Исследование расходует тот же ресурс, ради сохранения которого мы его затеяли. Всё, что будет дальше, — способы жить с этим противоречием.

Наш слизень мысленный: он рассуждает. Настоящие слизни решают эту задачу инстинктом, и мы ещё вернёмся к тому, откуда инстинкт знает ответ.

2. Наивность: сначала — решение, которое точно работает

Что значит «найти укрытие»? Скажем так: укрытие — это место, куда слизень помещается и куда не достаёт солнце.

Самое прямое решение следует из этого определения буквально. Взять первое углубление, заползти до конца, проверить оба условия. Не подошло — выбраться и взять следующее. Это полный перебор. Он ничего не знает о трубках, ничего не предполагает и ничего не угадывает. И он работает: если укрытие есть, перебор его найдёт.

При одном условии — если хватит времени. Солнце в определении укрытия не участвовало, и перебор о нём не знает.

Наивная реализация — прямой перевод определения в действия, без какого‑либо знания о задаче сверх самого определения.

Слово «наивный» здесь не ругательство. Наивное решение не глупое — оно доверчивое: верит определению и ничему больше. Поэтому оно работает везде, где работает определение, и ровно так, как сказано в определении.

Заметьте: «ползти в ближайшую» — это уже не наивность. Это жадное правило, эвристика: знание (или вера), что ближайшее обычно и есть лучшее. Жадное правило быстрее перебора, но может завести в тупик; перебор — нет. Простое и наивное — разные вещи.

Наивное возведение в степень

То же самое на числах. Что такое 2⁵? По определению — пять умножений на два:

2⁵ = 1 · 2 · 2 · 2 · 2 · 2

Показатель — это количество умножений. Наивная реализация повторяет это дословно:

function powNaive(n, x) {
  let result = 1;               // ещё ни одного умножения
  for (let i = 0; i < x; i++) {
    result *= n;                // одно умножение на каждую единицу показателя
  }
  return result;
}

powNaive(2, 10);  // 1024
powNaive(34, 0);  // 1

Ни таблиц, ни формул, ни битовых трюков. Умножений ровно x.

Посмотрите на powNaive(34, 0). Цикл не выполнился ни разу, и осталось то, с чего начали, — единица. Правило «любое число в нулевой степени равно 1», которое в школе заучивают, здесь никто не программировал. Оно получилось само.

Один факт, три взгляда:

  • Бытовой: ни разу не умножали — значит, ничего не изменилось. А «ничего не изменилось» для умножения — это «умножили на 1».

  • Программистский: начальное значение аккумулятора. Пустой цикл возвращает то, с чего начал.

  • Математический — в блоке ниже.

Строже. Произведение нуля сомножителей (пустое произведение) равно нейтральному элементу умножения, то есть 1, — так же как пустая сумма равна 0. К тому же выводу ведёт правило, которое степень обязана сохранять: 2^a · 2^b = 2^(a+b). При a = 0 получаем 2⁰ · 2^b = 2^b, откуда 2⁰ = 1. Это не исключение из правила, а его следствие.

А что наивная реализация вернёт для 0⁰? Тоже 1: цикл снова не выполнился. Математики же договариваются об этом случае по‑разному в разных областях. В алгебре и комбинаторике 0⁰ = 1: отобразить пустое множество в пустое можно ровно одним способом. В анализе 0⁰ — неопределённость: предел f(x)^g(x), где обе функции стремятся к нулю, может оказаться разным. Наивная реализация молча выбрала сторону. JavaScript выбрал ту же: 0 ** 0 === 1.

Зачем нужна наивная реализация

  1. Пол. Она работает всегда, когда работает определение. Если всё остальное сломалось, на неё можно опереться.

  2. Эталон. Любую хитрую версию проверяют сравнением с наивной: там, где определены обе, ответы обязаны совпасть. В тестировании такую эталонную реализацию называют оракулом.

  3. Карта границ. Наивная реализация показывает, где кончается определение. Об этом — следующий раздел.

3. Граница модели и fail‑fast

Вопрос: что вернёт powNaive(2, -2)? А powNaive(2, 2.5)?

powNaive(2, -2);   // 1 — а 2⁻² = 0.25
powNaive(2, 2.5);  // 8 — а 2^2.5 ≈ 5.657
powNaive(2, NaN);  // 1 — «не число» в степени дало единицу

А powNaive(2, Infinity) не вернёт ничего: условие i < Infinity верно всегда, и программа зависнет.

Ни одной ошибки. Функция уверенно отвечает — и отвечает неправду. Это хуже падения. Падение видно сразу, а неверное число уходит дальше, в другие расчёты, и обнаруживается (если обнаруживается) далеко от места, где возникло.

Причина — не баг. Код делает ровно то, что написано: умножает, пока i < x. Причина в том, что модель «показатель — это количество умножений» имеет смысл только для целых неотрицательных x. Нельзя умножить «минус два раза» или «два с половиной раза». За пределами своей модели наивная реализация не ошибается — она перестаёт что‑либо значить, продолжая выдавать числа.

У любой модели есть граница. Вопрос в том, как узнать, что мы её пересекли, — и как узнать это рано.

Частичный заход

Вернёмся к слизню. Перебор тратит время на то, чтобы заползти в трубку до конца. Но если трубка сужается уже через пару сантиметров, дальше лезть незачем: ответ «не подходит» уже известен. Значит, лезть целиком не нужно. Можно войти частично: голова внутри, хвост снаружи.

Это одновременно действие и исследование. Одна часть тела получает информацию о трубке, другая остаётся в известном месте, и отступить можно быстро. Слизень не просто быстрее узнаёт плохие новости — он устроил исследование так, чтобы плохие новости приходили первыми.

Fail‑fast — способ устроить проверку так, чтобы непригодность текущего подхода обнаруживалась как можно раньше, как можно явнее и за как можно меньшее число шагов — и однозначно говорила: здесь нужен другой подход.

Главное слово в этом определении — не «ошибка», а «устроить». Fail‑fast — не реакция («что‑то пошло не так — остановись»), а проектирование: мы заранее выбираем, что проверять и где поставить проверку, чтобы провал стал виден до того, как на неудачный путь потрачено много.

Для возведения в степень это одна строка — охранное условие (guard clause) в начале функции:

function powNaive(n, x) {
  // Fail-fast: модель «x — количество умножений» имеет смысл
  // только для целых x ≥ 0. Всё остальное — не к ней.
  if (!Number.isInteger(x) || x < 0) {
    throw new RangeError(`Показатель должен быть целым и не меньше нуля, получено: ${x}`);
  }

  let result = 1;
  
  for (let i = 0; i < x; i++) {
    result *= n;
  }

  return result;
}

Проверка ничего не чинит: считать 2⁻² функция так и не научилась. Но теперь она честно говорит «это не ко мне» вместо того, чтобы молча отвечать 1. Граница модели стала видимой. NaN и Infinity отсекаются той же строкой: целыми числами они не являются.

Проверка бывает и косвенной. Ниже, в быстрой версии, показатель будет переводиться в BigInt, и BigInt(2.5) сам бросит RangeError: дробное число нельзя превратить в целое. Язык сделает часть fail‑fast за нас. Но только часть: BigInt(-2) пройдёт без возражений.

Канарейка и дым

Шахтёры когда‑то брали с собой в забой канарейку. Птица чувствительнее человека к угарному газу и погибает раньше, чем люди что‑то заметят. Канарейка — живой fail‑fast: дешёвый датчик, который срабатывает, пока проблема ещё не смертельна. Отсюда канареечный релиз: новую версию программы сначала выкатывают на малую долю пользователей и смотрят, не станет ли им хуже.

Ещё проще smoke test — проверка «на дым». Собрал устройство, включил: пошёл дым — дальше тестировать нечего. Самая короткая проверка из возможных: один шаг, однозначный ответ.

Раньше — не значит дёшево

Из слов «как можно раньше» легко вывести, что fail‑fast — всегда дешёвая проверка. Это не так. Раньше — значит до того, как на неудачный путь уйдёт больше, чем стоит сама проверка.

Частичный заход слизня — не взгляд со стороны, а полноценное действие: время, силы, риск. Посмотреть на вход снаружи дешевле, но это ничего не решает: сужение может начаться глубже. Канареечный релиз требует инфраструктуры и рискует теми, на ком проверяют. Повар, который пробует соус до того, как подать его двадцати гостям, тратит время и соус. Все три проверки дорогие — и все оправданы, потому что стоят на сюжетной развилке: их ответ решает судьбу целой ветви, а не одного шага.

Второе оправдание дорогой проверки — переиспользование. Набор автотестов дорого написать, но он годами проверяет каждое изменение кода: его цена раскладывается (амортизируется) на тысячи запусков. Проверка, ставшая частью повторяемой стратегии, окупается не одним спасённым случаем, а всеми.

Отмерить или попробовать: LBYL и EAFP

В сообществе Python закрепились названия для двух стилей проверки.

  • LBYL — Look Before You Leap, “посмотри, прежде чем прыгать”: сначала проверь, потом действуй. По‑русски — «семь раз отмерь, один раз отрежь». Слизень ощупывает вход рожками.

  • EAFP — Easier to Ask Forgiveness than Permission, “проще попросить прощения, чем разрешения” (фразу приписывают Грейс Хоппер): сначала действуй, провал обработай. Слизень лезет внутрь и смотрит, что будет.

LBYL дешевле, но видит только то, что доступно снаружи: ширину входа, а не сужение на глубине. EAFP дороже, но проверяет саму правду, а не её признаки. У LBYL в программировании есть ещё одна слабость: между проверкой и действием мир успевает измениться. Файл существовал в момент проверки и исчез к моменту открытия. Поэтому некоторые вещи честно проверяются только попыткой.

Fail‑fast — не третий стиль рядом с этими двумя, а другая ось. LBYL и EAFP отвечают на вопрос как обнаружить проблему: заранее или попыткой. Fail‑fast — на вопрос когда: как можно раньше. Охранное условие в powNaive — fail‑fast в стиле LBYL. Частичный заход слизня — fail‑fast в стиле EAFP.

У проверки две ошибки

Любая проверка ошибается двумя способами: пропускает плохое или бракует хорошее. Канарейка может погибнуть от холода, а не от газа, и шахтёры зря покинут забой. Слишком мягкий fail‑fast бесполезен, слишком строгий отсекает рабочие варианты. Ложная тревога — тоже цена проверки, и её тоже взвешивают. (В разделе 8 будет функция, в которую я намеренно оставил такую ложную тревогу. Попробуйте заметить её раньше, чем дойдёте до разбора.)

Отступить — не значит вернуться в начало

Слизень залез в трубку Б на полтела, почувствовал сужение и выбрался. Физически он почти там же, где был. Но задача уже другая. Он знает, что Б сужается примерно на длине его тела; что до сужения там сыро и прохладно; что вариантов стало на один меньше.

Неудачная попытка — не потерянное время, если её результат сохранён. Ценность fail‑fast не только в том, что отказ наступает рано. После раннего отказа остаются силы, время и знание, чтобы ими воспользоваться. Как именно — следующий раздел.

4. Fallback и failback: вниз и обратно

Трубка Б не подошла. Самая перспективная — В: вертикальная, сырая, прохладная. Но в ней сидит жук. Остаётся ямка А: мелкая, но прямо сейчас, пока солнце низко, в ней есть тень. Слизень забирается в А.

Это fallback — переход к запасному плану. Запасной план хуже основного, но у него есть решающее достоинство: он держится на меньшем числе допущений. Трубке В нужно быть свободной, достаточно широкой и глубокой. Ямке А нужно только, чтобы солнце ещё не поднялось высоко.

Через полчаса жук уползает, и слизень перебирается в В. Это failback — возвращение к предпочтительному плану, когда его допущения снова выполнились.

Термины пришли из инженерии отказоустойчивых систем. Когда основной сервер падает, нагрузку переключают на резервный — это failover. Когда основной восстановлен, нагрузку возвращают на него — это failback. Fallback — слово более общее: любой запасной вариант, на который переходят, когда основной не сработал.

Все три знакомы любому, кто смотрел видео через плохой интернет. Сеть просела — плеер снижает качество с 1080p до 360p, но не останавливается: fallback. Сеть восстановилась — качество возвращается: failback. Плеер выбирает не между «идеально» и «ничего»: у него есть лестница вариантов, и он ходит по ней в обе стороны. Такое поведение называют плавной деградацией (graceful degradation).

Fallback — переход к запасному плану, который держится на меньшем числе допущений. Цепочка fallback’ов спускается ступенька за ступенькой, и нижняя ступенька — наивное решение: у него допущений меньше всего.

Failback — возвращение к предпочтительному плану, когда его допущения снова выполняются.

Fallback прост: что‑то сломалось — шаг вниз. Failback устроен хитрее. Чтобы вернуться, нужно три вещи.

  1. Помнить, куда возвращаться. Слизень, забывший, что В лучше, так и останется в А, пока солнце не доберётся до дна. Стратегия, которая умеет только отступать, со временем оказывается на нижней ступеньке — даже когда проблемы давно нет.

  2. Проверить, что условия действительно восстановились. Жук мог уползти на минуту. Прежде чем переползать целиком, слизень снова заглядывает в В: опять частичный заход, опять fail‑fast. Только теперь он проверяет не «можно ли туда», а «можно ли обратно».

  3. Не метаться. Если жук то уползает, то возвращается, слизень, реагирующий на каждое движение, будет всё время в пути — и всё время под солнцем. Нужен порог: возвращаться, только если В свободна достаточно долго.

Третий пункт хорошо знаком по термостату. Если отопление включается ниже 20° и выключается выше 20°, котёл будет щёлкать без конца. Поэтому его включают ниже 19° и выключают выше 21°. Этот зазор называется гистерезисом, и он нужен любому failback’у, чтобы система не дрожала на границе.

Автомат в щитке

В электрощитке любой квартиры стоит автомат. При перегрузке он размыкает цепь сразу, не дожидаясь, пока расплавится проводка, — это fail‑fast. Когда причину устранили, автомат включают обратно — это failback.

Программисты взяли эту идею целиком и назвали так же — circuit breaker. Допустим, программа берёт курс валют с сервера. Если сервер лёг, каждый запрос к нему будет долго ждать и падать. Лучше после нескольких неудач перестать обращаться к серверу, отдавать последний сохранённый курс и время от времени осторожно проверять, не поднялся ли сервер.

function createBreaker(primary, fallback, { maxFailures = 3, retryAfterMs = 5000 } = {}) {
  let failures = 0;
  let openedAt = null; // когда перестали доверять основному плану

  return async function call(...args) {
    const isOpen = openedAt !== null;
    const isProbe = isOpen && Date.now() - openedAt >= retryAfterMs;

    if (isOpen && !isProbe) {
      return fallback(...args);   // fail-fast: ответ уже известен, даже не пытаемся
    }
    try {
      const result = await primary(...args);
      failures = 0;
      openedAt = null;            // failback: основной план снова работает
      return result;
    } catch {
      failures++;
      if (isProbe || failures >= maxFailures) {
        openedAt = Date.now();    // порог пройден: перестаём обращаться к основному
      }
      return fallback(...args);   // fallback: запасной план
    }
  };
}

Попробовать можно так:

let serverUp = false;
const fetchRate = async () => {
  if (!serverUp) throw new Error('сервер недоступен');
  return 'свежий курс';
};
const getRate = createBreaker(fetchRate, () => 'курс из кэша', { retryAfterMs: 1000 });

await getRate();  // 'курс из кэша' — первая неудача
await getRate();  // 'курс из кэша' — вторая
await getRate();  // 'курс из кэша' — третья: автомат разомкнулся
await getRate();  // 'курс из кэша' — к серверу даже не обращались
serverUp = true;
await getRate();  // (через секунду) 'свежий курс' — пробный запрос удался

В двадцати строках все три понятия. Fallback — если основной план не сработал, отдаём запасной, кэш. Fail‑fast — пока автомат разомкнут, к серверу даже не обращаемся: мы уже знаем, чем это кончится, и не тратим на это время. Failback — раз в retryAfterMs пробный запрос; удался — возвращаемся к основному плану. И гистерезис дважды: автомат размыкается не после первой неудачи, а после maxFailures, и пробует вернуться не сразу, а через retryAfterMs.

Проверка и отступление живут в разных местах

Fail‑fast и fallback работают парой. Проверка без отступления — честная, но бесполезная остановка. Отступление без проверки включается слишком поздно, когда ущерб уже нанесён.

При этом живут они обычно в разных местах. powNaive знает свою границу, но не знает, что делать за ней, — и просто бросает ошибку. Что делать дальше, решает тот, кто её вызвал: он знает, какие есть альтернативы. Проверка живёт там, где известно допущение; решение об отступлении — там, где известны альтернативы. Ради такого разделения в языках программирования и существуют исключения.

5. Оптимизация: знание в обмен на общность

Начнём с самой маленькой оптимизации из возможных. В powNaive первое умножение всегда 1 · n. Его можно сэкономить: начать сразу с n и сделать на одно умножение меньше.

function powMinusOne(n, x) {
  let result = n;                 // сразу n, без умножения на 1
  for (let i = 1; i < x; i++) {
    result *= n;
  }
  return result;
}

powMinusOne(2, 3);  // 8 — верно, и на одно умножение меньше
powMinusOne(2, 0);  // 2 — а должно быть 1

Сэкономленное умножение стоило нам случая x = 0. Функция молча предположила, что x ≥ 1, и за пределами этого допущения врёт. Чтобы починить её, придётся добавить особый случай if (x === 0) return 1 — то есть отдельно проверить границу, которую мы сами же и сдвинули.

Это вся оптимизация в миниатюре. Мы добавили знание (x ≥ 1), получили выигрыш (минус одно умножение) и заплатили общностью: модель стала уже, и её границу теперь нужно охранять. В наивной версии ноль обрабатывался сам собой. В оптимизированной — уже нет.

Быстрое возведение в степень

Выигрыш в одно умножение смешон. Но если посмотреть на задачу внимательнее, можно выиграть почти всё.

Наивно x⁸ — это восемь умножений. Но x⁸ = (x⁴)², x⁴ = (x²)², x² = x · x. Три возведения в квадрат — и готово.

А если показатель не степень двойки? Возьмём 13. В двоичной записи 13 = 1101₂, то есть 8 + 4 + 1, а значит x¹³ = x⁸ · x⁴ · x. Будем по очереди получать x, x², x⁴, x⁸ — каждое как квадрат предыдущего — и домножать результат только на те, которым в двоичной записи показателя соответствует единица:

Шаг

Остаток показателя

Младший бит

Результат

Текущий квадрат

1

1101

1 — берём

x

x → x²

2

110

0 — пропускаем

x

x² → x⁴

3

11

1 — берём

x⁵

x⁴ → x⁸

4

1

1 — берём

x¹³

x⁸ → x¹⁶

Семь умножений вместо тринадцати. В коде:

function powFast(n, x) {
  // Показатель переводим в BigInt: в JavaScript побитовые операции над обычными
  // числами (Number) работают только с 32 битами. Это особенность языка, а не алгоритма.
  let e = BigInt(x);
  let result = 1;
  let square = n;                 // по очереди: n, n², n⁴, n⁸, ...

  while (e > 0n) {
    if (e & 1n) {
      result *= square;           // младший бит равен 1 — эта степень входит в ответ
    }
    square *= square;             // n^k → n^(2k)
    e >>= 1n;                     // отбрасываем младший бит: 1101 → 110
  }
  return result;
}

Для x¹⁰⁰⁰ наивная версия делает 1000 умножений, быстрая — 16. Для показателя в миллиард: миллиард против 43.

Здесь и появляются битовые операции: e & 1n читает младший бит, e >>= 1n отбрасывает его. Двоичная запись числа перестала быть просто способом его хранить — она стала планом вычисления.

Быструю версию проверяем наивной — вот и пригодился оракул:

// Наивная версия — эталон: где определены обе, ответы обязаны совпасть.
// Диапазон выбран так, чтобы все результаты Number представлял точно.
for (let n = 0; n <= 9; n++) {
  for (let x = 0; x <= 16; x++) {
    if (powFast(n, x) !== powNaive(n, x)) {
      throw new Error(`Расхождение: ${n}^${x}`);
    }
  }
}
console.log('Быстрая версия совпала с наивной');

Во что это обошлось

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

Первое очевидно: показатель — целый и неотрицательный. Второе спрятано глубже. Мы переставили скобки: наивная версия считает ((1 · x) · x) · x…, быстрая — (x · x) · (x · x)… Это законно, только если от расстановки скобок результат не зависит, то есть если умножение ассоциативно. Для целых чисел — да. Для чисел с плавающей точкой — лишь приблизительно: каждое умножение округляет, и за пределами точного диапазона быстрая и наивная версии начинают расходиться в последних знаках. Поэтому оракул и проверяет только точный диапазон.

Строже. Быстрому возведению в степень нужна только ассоциативность операции (полугруппа, а с единицей — моноид). Поэтому тот же алгоритм без изменений возводит в степень матрицы, перестановки, вычеты по модулю; на возведении в степень по модулю держится, например, RSA. Оптимизация, чьё допущение названо точно, переносится далеко за пределы исходной задачи: не «числа», а «любая ассоциативная операция».

И ещё: быстрый — не значит наилучший. Двоичный метод тратит на x¹⁵ шесть умножений (x², x⁴, x⁸ и три домножения; реализация выше ради простоты делает ещё пару лишних). А можно за пять: x², x³ = x² · x, x⁶ = (x³)², x¹² = (x⁶)², x¹⁵ = x¹² · x³. Поиск кратчайшей такой аддитивной цепочки — трудная задача, и ради каждого показателя её никто не решает. Быстрый алгоритм не оптимален. Он достаточно хорош.

Оптимизация стареет

Самая знаменитая оптимизация этого рода — быстрый обратный квадратный корень из Quake III Arena (1999). Для освещения в 3D‑графике нужно постоянно считать 1/√x, а деление и корень тогда стоили дорого. Код брал битовое представление числа с плавающей точкой, трактовал его как целое, вычитал его из магической константы 0x5f3759df и получал грубое приближение, которое затем уточнял одним шагом метода Ньютона.

Допущения этого хака лежали глубже любой формулы: точный формат 32-битного числа с плавающей точкой (IEEE 754), то, что целочисленные операции дешевле деления, и то, что игре хватит точности в доли процента. Эти допущения постарели: современные процессоры умеют считать приближённый обратный корень отдельной инструкцией, и хак обычно больше не выигрывает.

У оптимизации есть срок годности: она верна, пока верны её допущения о мире. Наивная версия не стареет — её единственное допущение само определение.

Цена поиска

У слизня тоже есть оптимизация — и тоже в обмен на допущение. Из трубки В тянет сыростью. Если сырость говорит о глубине — а обычно говорит, — В разумно проверить первой. Это предсказание: вывод о невидимой части пространства по видимым признакам. Оно экономит время, пока верно. Но сырой может оказаться и мелкая лужица после дождя. Поэтому предсказание не отменяет частичного захода, а только решает, с какой трубки его начинать.

По мере движения предсказание уточняется. Трубка уже полметра идёт вниз — вероятно, пойдёт и дальше. Стенки сближаются — дальше, скорее всего, будет ещё уже. Наблюдение, предположение, действие, новое наблюдение.

Теперь другой вопрос: когда остановиться? Слизень нашёл трубку, где солнце его не достанет. Может, где‑то есть прохладнее и просторнее. Искать дальше или остаться?

Ответ зависит не столько от того, насколько хороша найденная трубка, сколько от того, сколько стоит продолжение поиска. Если солнце вот‑вот доберётся — оставаться. Если утро раннее — можно поискать. Герберт Саймон назвал такой подход satisficing (satisfy + suffice): искать не наилучшее решение, а достаточно хорошее, потому что поиск наилучшего сам стоит ресурсов. В теории принятия решений та же развилка называется «исследование или использование» (explore/exploit): продолжать узнавать новое или пользоваться тем, что уже знаешь.

Строже. Иногда момент остановки можно вычислить. В «задаче о разборчивой невесте» (secretary problem) кандидатов смотрят по одному, и к отвергнутому вернуться нельзя. Оптимальная стратегия — пропустить первые n/e ≈ 37% кандидатов, запомнив лучшего из них, а затем взять первого, кто окажется лучше. Вероятность выбрать лучшего стремится к 1/e ≈ 37%. Слизню проще: он может вернуться к отвергнутой трубке. Именно поэтому failback так ценен — возможность возврата меняет всю математику остановки.

«Преждевременная оптимизация — корень всех зол», — писал Дональд Кнут. Фразу обычно цитируют как запрет, но смысл в другом. Оптимизация — покупка, и платить за неё стоит, когда известно, что покупаешь. Пока неизвестно, где программа на самом деле тратит время, каждое допущение, введённое ради скорости, — это долг без гарантии, что он окупится.

Оптимизация — улучшение решения по выбранному критерию за счёт дополнительного знания о задаче. Это знание входит в решение в виде допущений, и за него платят общностью, простотой и новыми проверками. Оптимизация — движение к лучшему, а не обязательно достижение наилучшего.

Ключевое слово здесь — критерий. «Быстрее», «точнее», «понятнее», «меньше памяти» — разные критерии, и улучшение по одному часто ухудшает другой. Вопрос «какой вариант лучше?» без критерия ответа не имеет. А выбор критерия — это уже не оптимизация. Это стратегия.

6. Стратегия: работа, которая происходит до плана

Слово пришло из греческого: стратег (στρατηγός) — полководец, буквально «ведущий войско». Полководец сам не сражается. Его работа — решить, какое сражение давать, где, когда, какими силами и куда отходить, если оно пойдёт плохо. Сражается армия; полководец выбирает, по какому плану.

Вечер, холодильник, гости

Вы пришли домой голодным. Решение кажется мгновенным: «сделаю омлет». Проследим, что будет дальше.

Вы открываете холодильник: два яйца. На омлет для одного хватит. Звонит друг: будет через час, и не один. Теперь нужно накормить троих, и не через десять минут, а через час. Омлет из двух яиц на троих не годится, зато появился час: можно запечь курицу, это минут пятьдесят. Вы включаете духовку — она не греется. Остаются паста (макароны есть, соус соберётся из того, что в холодильнике) или магазин за углом, где есть всё, но это ещё двадцать минут. Вы пишете друзьям, что будет паста, — и узнаёте, что один из них не ест глютен.

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

  1. Назвать цель и критерий. «Поесть самому» и «накормить троих» — разные задачи с разными мерами успеха: сытость через пятнадцать минут против «всем вкусно, никто не сидит голодным». Звонок друга не добавил работы — он заменил задачу. Пока критерий не назван, варианты нельзя сравнить: неясно, что значит «лучше».

  2. Перечислить репертуар. Какие планы вообще доступны: омлет, паста, курица, бутерброды, доставка. Стратегия не придумывает планы из ничего — она выбирает из того, что вы умеете. Умение готовить одно блюдо — это план. Умение готовить десять — материал для стратегии.

  3. Вскрыть допущения каждого плана. Омлет — если есть яйца. Курица — если работает духовка и есть пятьдесят минут. Доставка — если есть деньги и час на ожидание. Паста — если все едят глютен. План — это действия плюс список «если». Этот список обычно никто не пишет, потому что он кажется очевидным. Но именно в нём живут все будущие провалы.

  4. Выбрать факторы. Какие величины действительно меняют исход? Время, продукты, число людей, ограничения в еде. А внешний вид блюда — фактор? Для ужина с друзьями, возможно, нет; для первого свидания — возможно, да. Факторы — не свойство мира, а взгляд на него: гипотеза о том, что важно. Если гипотеза неверна, стратегия будет уверенно решать не ту задачу. Вы не считали диету фактором — и узнали о ней последней.

  5. Расставить проверки. Заглянуть в холодильник — дёшево и сразу отсекает половину вариантов (LBYL). Работает ли духовка, можно узнать, только включив её (EAFP). Сначала — дешёвые и решающие проверки, потом дорогие; решающие ставят на развилки, до того как на ветку уйдут силы. Это fail‑fast, только теперь он работает на масштабе всего вечера, а не одного шага.

  6. Построить лестницу отступлений. Курица → паста → бутерброды. Каждая ступенька ниже держится на меньшем числе допущений. Нижняя, бутерброды, — наивное решение: хлеб и что‑нибудь сверху, почти без условий, работает всегда. Лестницу строят до того, как основной план сломался: fallback, придуманный в момент провала, обычно хуже продуманного заранее.

  7. Оценить цену недостающего. Магазин — не ступенька вниз, а способ купить допущение. Нет безглютеновой пасты — можно за ней сходить. Двадцать минут дороги против гостя, оставшегося без ужина. Выбор между «приготовить из того, что есть» и «сходить за недостающим» — это оптимизация внутри стратегии: окупит ли выигрыш свою цену.

  8. Назначить условия возврата. Возможно, духовка просто медленно разгорается. Если она нагреется в ближайшие десять минут, курица успеет — это failback. Условие возврата стоит назвать заранее и с порогом: «нагреется в течение десяти минут — курица, иначе окончательно паста». Без порога можно весь вечер переключаться между планами и не приготовить ничего.

  9. Решить, сколько думать. Если вы так голодны, что думать не получается, съешьте кусок хлеба прямо сейчас. Это не отказ от стратегии, а стратегический ход: купить время. Давление спадает — и появляется возможность выбрать план получше. Слизень делает то же, забираясь в мелкую ямку А: это не решение задачи, а передышка для её решения. Стратегия включает и решение о том, сколько ресурсов тратить на саму стратегию.

  10. Разобрать итог. После ужина вы знаете больше, чем до него: про духовку, про диету друга, про то, что пасты на троих нужно больше, чем казалось. В следующий раз вы спросите про ограничения в еде до выбора блюда. Поздняя неприятная находка превратилась в раннюю дешёвую проверку.

Это не список шагов

Если перечитать вечер, видно, что шаги шли не по порядку. Звонок вернул к пункту 1: поменялась цель. Сломанная духовка — к пункту 6. Диета друга — к пункту 4: оказалось, что неверно выбраны сами факторы. Стратегическая работа — не конвейер, а петля, которая то и дело откатывается назад. Эти «танцы с бубном» — не беспорядок, а нормальный ход работы в мире, который раскрывается по частям.

Два вида откатов стоит различать. Когда проверка говорит «этот план не сработает», меняют план: омлет → паста. Когда проверка говорит «ты неправильно понимаешь задачу», меняют модель: критерий, факторы, репертуар. Теоретик организаций Крис Аргирис называл это одинарной и двойной петлёй обучения. Первая — fail‑fast для действий, вторая — fail‑fast для взглядов. Стратегия, у которой есть только первая петля, будет быстро и уверенно перебирать планы для не той задачи.

Вкус

Опытный повар не проходит все десять пунктов. Он открывает холодильник и уже знает, что готовить. Кажется, это чутьё. Но чутьё — не магия, а сжатый итог сотен таких вечеров. Каждый прошлый разбор переносил какую‑то проверку из конца в начало, и со временем почти все проверки оказались в самом начале — настолько быстрыми, что их уже не замечают. Программист сказал бы: вкус — это кэш стратегической работы.

Этот кэш не обязательно наработан лично. Бабушкин рецепт — план, проверенный поколениями. Кухня народа — уже стратегия: какие блюда для какого сезона, повода и достатка. Инстинкт слизня — стратегия, выработанная естественным отбором: слизни, которых тянуло к тени и сырости, чаще доживали до потомства. У программистов то же самое называется паттернами, «лучшими практиками» и разборами инцидентов — сжатым опытом отрасли. Во всех случаях это усреднённый итог множества попыток, проделанных кем‑то до вас.

У кэша есть известная беда: он устаревает. Инвалидация кэша, как шутят программисты, — одна из двух по‑настоящему трудных вещей в их профессии. Бабушкин рецепт рассчитан на её печь, инстинкт слизня — на мир без раскалённого асфальта, лучшие практики — на прошлое поколение техники, как хак из Quake. Вкусу тоже нужен fail‑fast: время от времени проверять, верны ли ещё его допущения.

Определение

Теперь можно сказать, что такое стратегия, и отличить её от плана.

План — последовательность действий, которая приводит к цели, если выполнены её допущения.

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

Короче: стратегия — это планы, сшитые проверками. Между проверками выполняется план; в точках проверки делается выбор. Fail‑fast ставит эти точки как можно раньше. Fallback и failback — пути, по которым из них уходят вниз и возвращаются обратно. Оптимизация улучшает выбор внутри ветки. Наивное решение — дно, ниже которого не падают.

Отсюда видно, почему стратегия — не просто «сделать хорошо и на будущее». Хороший план тоже бывает; стратегия отличается от него не качеством, а уровнем: она работает не с действиями, а с планами. Она лавирует между ними вслед за факторами — и чем изменчивее факторы, тем больше в ней нужды. В неизменном мире хватило бы одного хорошего плана. Рядом стоит тактика — как хорошо выполнить выбранный план: как нарезать лук, когда солить воду. Стратегия решает, варить ли пасту вообще.

В программировании у слова есть и узкое значение — паттерн «Стратегия» из книги «банды четырёх» Design Patterns (1994): семейство взаимозаменяемых алгоритмов с общим интерфейсом, между которыми программа выбирает во время работы. Паттерн — это розетка, в которую можно вставлять разные планы. Он отвечает на вопрос, как сделать выбор возможным. На вопрос, что и когда выбирать, он не отвечает — это и есть стратегия в полном смысле.

Строже. В теории игр стратегия игрока — полный план действий: правило, которое каждой ситуации, где ходит игрок (точнее, каждому его информационному множеству), сопоставляет действие — включая ситуации, до которых в реальной партии может и не дойти. Это не путь, а правило на всём дереве игры. В теории управления то же различие называется управлением без обратной связи (open loop) и с обратной связью (closed loop). План — это стратегия, которая игнорирует новую информацию.

7. Граф: карта, которой нет

Каждое положение слизня — вершина. Каждое возможное действие — ребро. Вместе они образуют граф. Вот его начало:

                           (под солнцем)
             ┌───────────────────┼───────────────────┐
           в А               в Б на полтела      в В на полтела
             │                   │                   │
       (тень, мелко)       <сужается?>          <свободна?>
                            да /    \ нет         да /    \ нет
                        (назад)   (глубже)    (глубже)   (в А, ждать,
                                                          проверить снова)

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

  • План — один путь по графу: «в В, вглубь, остаться». Он молча предполагает, что в каждой угловой вершине мир ответит так, как нам нужно.

  • Стратегия — правило для всего графа. В каждой вершине выбора она говорит, куда идти, а в каждой вершине мира готова к любому ответу. Если оставить только рёбра, выбранные стратегией, получится не путь, а дерево: на каждый исход — свой план. Это и есть буквальный смысл «плана о плане».

  • Fail‑fast — стремление поставить угловые вершины как можно ближе к корню: узнать ответ мира до того, как ушли далеко по ветке.

  • Fallback — ребро из неудачного ответа в соседнюю, более простую ветку. Failback — ребро обратно, на ветку, которую уже покидали. Чтобы оно существовало, вершины нужно помечать: какие посещены и что там узнали. Граф без пометок — это слизень, забывший про трубку В.

Главное отличие от учебника: слизень не знает этого графа. Он строит его по ходу, и каждое движение открывает новые вершины. Поэтому исследование здесь — не подготовка к решению, а его часть.

У алгоритмов на графах нашлись готовые имена для всего, что делал слизень:

  • «Лезть в первую трубку до конца, потом во вторую» — поиск в глубину (DFS) с возвратом (backtracking).

  • «Во все трубки понемногу, потом глубже» — поиск с итеративным углублением: сначала все ветки на глубину 1, потом на 2 и так далее. Часть работы он повторяет, зато никогда не застревает в одной бесконечно длинной ветке.

  • «Начать с той, из которой тянет сыростью» — эвристика, как в алгоритме A*. Там к уже пройденной цене пути прибавляют оценку оставшейся и в первую очередь раскрывают вершины с лучшей суммой. Оценка и есть предсказание; если она никогда не завышает реальную цену, A* гарантированно находит кратчайший путь.

Строже. Такое дерево — дерево решений в «игре против природы». Стратегия — поддерево, которое содержит ровно одно исходящее ребро из каждой своей вершины выбора и все исходящие рёбра из каждой своей вершины природы. План — частный случай, в котором природа заранее «угадана».

8. Сборка: одна функция, вся схема

Соберём всё в одном месте. Настоящая функция возведения в степень должна принимать и −2, и 2.5 — всё, для чего степень вообще определена. Одной модели на всё нет. Значит, нужна стратегия, и сначала — работа до кода.

  • Цель и критерий: правильный ответ для любого показателя, где степень определена; для целых показателей — точный.

  • Репертуар моделей. Первая: «показатель — количество умножений», для целых x ≥ 0. Вторая: то же правило, продолженное назад, n⁻ˣ = 1 / nˣ — для целых x < 0 и n ≠ 0. Третья: непрерывная, nˣ = e^(x · ln n) — для любых x, но только при n > 0.

  • Факторы: какой показатель (целый или нет, какого знака) и какое основание (ноль, положительное, отрицательное).

  • Дно: если не подходит ни одна модель — честная ошибка, а не правдоподобное число.

Строже. Откуда вторая модель? Из того же правила, что дало 2⁰ = 1: 2^a · 2^b = 2^(a+b). Если оно должно выполняться и для отрицательных показателей, то 2⁻¹ · 2¹ = 2⁰ = 1, значит 2⁻¹ = 1/2. Так же (2^(1/2))² = 2 даёт 2^(1/2) = √2. Математика расширяет степень с натуральных показателей на целые, рациональные и вещественные не произволом, а требованием сохранить правило — инвариант. Каждое расширение — новая модель со своими допущениями: для дробных показателей, например, положительное основание.

function power(n, x) {
  // Fail-fast: вход, для которого не определена ни одна модель.
  if (typeof n !== 'number' || typeof x !== 'number' || Number.isNaN(n) || Number.isNaN(x)) {
    throw new TypeError(`Ожидались два числа, получено: ${n}, ${x}`);
  }

  // Модель 1: x — количество умножений. Быстрая версия, проверенная наивной.
  if (Number.isInteger(x) && x >= 0) {
    return powFast(n, x);
  }

  // Модель 2: то же правило, продолженное на отрицательные: n^(-x) = 1 / n^x.
  if (Number.isInteger(x)) {
    if (n === 0) throw new RangeError('0 в отрицательной степени не определён');
    return 1 / powFast(n, -x);
  }

  // Модель 3: непрерывная: n^x = e^(x · ln n). Требует n > 0: ln n существует только там.
  // Внимание: эта проверка охраняет модель, а не задачу. Из-за неё power(0, 0.5)
  // упадёт на дно, хотя 0^0.5 = 0. Это намеренная ложная тревога, разбор ниже.
  if (n > 0) {
    return Math.exp(x * Math.log(n));
  }

  // Дно: отказ вместо правдоподобного числа.
  throw new RangeError(`${n} ** ${x} не определено в вещественных числах`);
}

Третья модель покрывает и целые показатели. Зачем тогда первые две? Проверьте:

Math.exp(3 * Math.log(2));  // 7.999999999999998
powFast(2, 3);              // 8

Непрерывная модель общее, но для целых показателей хуже: логарифм и экспонента округляют. Стратегия выбирает не первый подходящий план, а лучший из подходящих по критерию, а критерий мы назвали заранее: для целых показателей — точный ответ. Порядок проверок в функции — лестница: от самой узкой и точной модели к всё более общим, каждая следующая требует от показателя меньше предыдущей. Под лестницей — отказ.

Ложная тревога

Обещанная ложная тревога сидит в третьей модели. power(0, 0.5) бросает «не определено в вещественных числах», хотя 0^0.5 = √0 = 0: ответ существует, и вполне обыкновенный.

Откуда в коде такая мысль? Проверка n > 0 написана с точки зрения модели. Третья модель считает степень через логарифм, а ln n существует только при n > 0: при n = 0 формула e^(x · ln n) действительно неприменима. Для модели проверка безупречна — и сработала она правильно.

Ошибка случилась на шаг позже. Проверка сказала ровно то, что и должен говорить fail‑fast: «этот подход здесь не годится, нужен другой». Но другой ступеньки в лестнице нет, и мы провалились сразу на дно. А дно говорит от имени всей задачи: «не определено». Отказ модели выдан за отказ задачи. Честным было бы сообщение «ни одна из моих моделей сюда не подходит» — а это совсем не то же самое, что «ответа не существует».

Такая ошибка появляется естественно, поэтому её стоит разобрать. Охранное условие пишут, глядя на допущения модели: они видны прямо в формуле. Граница задачи в формуле не видна. А модель почти всегда уже задачи — на то она и модель. Поэтому проверка на границе модели по умолчанию строже, чем нужно задаче, и её срабатывание — сигнал стратегии «поищи другую модель», а не приговор входу.

Другая модель здесь есть, и совсем простая: 0^x = 0 при любом x > 0. Это ещё одна ступенька перед третьей моделью. Я не добавил её намеренно: исправленная функция показала бы только результат, а неисправленная показывает, как ложная тревога рождается из совершенно правильной проверки.

Где здесь каждое понятие:

  • Наивность. powNaive не вызывается ни разу, но без неё powFast было бы нечем проверить.

  • Оптимизация. powFast вместо powNaive: допущения (целый показатель, ассоциативность) в обмен на скорость.

  • Fail‑fast. Первая проверка, отказ для нуля в отрицательной степени и BigInt внутри powFast как косвенная проверка. И цена проверок — ложная тревога из разбора выше.

  • Fallback. Здесь он превратился в предварительный выбор: проверки стоят перед каждой моделью, поэтому до провала дело не доходит. Это тот же перенос проверок из конца в начало, что и у опытного повара.

  • Failback здесь не нужен: вид показателя не меняется во время вызова. Failback нужен там, где мир меняется со временем, как в circuit breaker. Стратегия берёт только те механизмы, которых требует задача.

  • Стратегия — всё вместе: критерий, модели, факторы, порядок проверок, дно.

И вся схема одной таблицей:

Слизень

Кухня

Код

Наивность

перебор трубок до конца

бутерброды

powNaive

Fail‑fast

частичный заход

заглянуть в холодильник, попробовать соус

охранное условие; разомкнутый автомат

Fallback

в мелкую ямку А

паста вместо курицы

кэш вместо сервера

Failback

обратно в В, когда жук ушёл

курица, если духовка всё‑таки нагрелась

удачный пробный запрос

Оптимизация

начать с сырой трубки

сходить за недостающим, если окупится

powFast

Стратегия

правило для всех развилок графа

работа от цели до разбора итогов

power

9. Как устроена эта статья

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

  • Цель и критерий. Не «рассказать о шести терминах», а чтобы после чтения их можно было применить к своей задаче. Критерий — не «помню определения», а «узнаю их в новой ситуации».

  • Наивный план — словарь, шесть определений подряд. Он прямо следует из цели «объяснить термины» и работает — при допущении, что у читателя уже есть образы, к которым определения прикрепятся. У программиста они есть, у остальных — не всегда.

  • Fail‑fast. Это допущение проверяется до того, как написано хоть слово: поймёт ли человек, никогда не видевший программ, определение fail‑fast без примера? Нет. Словарь отпал на этапе плана, а не после публикации.

  • Факторы — три читателя с разным входным знанием и ограниченное внимание каждого.

  • Лестница и отступления. У каждого понятия несколько слоёв: образ, определение, затем код или строгая формулировка. Образ — нижняя ступенька: он держится почти ни на каких допущениях и работает для всех. Каждый читатель спускается до того слоя, который работает для него (fallback), а после каждого образа текст возвращается к точному определению (failback).

  • Проверки для читателя — вопросы по ходу текста. Если на вопрос не находится ответа, лучше заметить это сразу, а не в конце статьи, когда непонимание уже накопилось.

  • Оптимизация — сквозные примеры. powNaive вы прочитали один раз, а работал он на все шесть понятий: был наивностью, эталоном, картой границ, объектом fail‑fast, основой для оптимизации и частью итоговой стратегии. Один пример, использованный шесть раз, дешевле шести разных: цена его изучения амортизируется.

  • Бюджет на раздумья — то, что сознательно осталось за кадром: устройство чисел с плавающей точкой, причины 32-битных ограничений JavaScript, доказательства. Каждое тянет на отдельную статью, а здесь было бы веткой, уводящей от цели.

  • Разбор итогов — за вами. Вопросы ниже проверяют критерий: узнаёте ли вы эти понятия в новых ситуациях. Если где‑то не узнаёте, напишите в комментариях: для автора это поздняя находка, которая в следующий раз станет ранней проверкой.

Вопросы на дорогу

Для тех, кто любит проверять сам.

  1. Добавьте в power недостающую ступеньку для нуля. Почему простая замена n > 0 на n >= 0 выглядит как исправление, но им не является? Подсказка: что такая версия вернёт для power(0, -0.5) и как это согласуется с поведением второй модели?

  2. Почему (-8) ** (1/3) в JavaScript равно NaN, хотя кубический корень из −8 — это −2? Подсказка: как выглядит 1/3 в двоичной записи и что это значит для знаменателя.

  3. Возведите в степень с помощью алгоритма powFast не число, а матрицу [[1, 1], [1, 0]] (понадобится своё умножение матриц и своя «единица»). Какие числа получаются? Сколько умножений нужно, чтобы получить тысячное из них?

  4. Найдите цепочку из пяти умножений для x¹⁵. Найдите другие показатели, на которых двоичный метод не оптимален.

  5. Что станет с createBreaker при retryAfterMs = 0? А при maxFailures = 1?

  6. Когда наивная реализация — лучший окончательный выбор, а не только эталон?

  7. Солнце движется, и через час ямка А перестанет быть укрытием. Что это меняет в стратегии слизня?


Слизень не знает ни одного из этих слов. Но если он пережил полдень, у него была стратегия — выработанная не им, проверенная не на нём и сжатая до одного движения: к тени и сырости. Всё, что мы делали в этой статье, — разворачивали это движение обратно в работу, из которой оно состоит.