Bug O нотация: отладочная сложность программных интерфейсов

Про Big O нотацию вы наверняка сами знаете, а мы вам расскажем про Bug O нотацию и отладочную сложность программных интерфейсов.

Bug O нотация: отладочная сложность программных интерфейсов

Хотите писать эффективный и высокопроизводительный код? Тогда вы непременно должны учитывать его алгоритмическую сложность – то, что также известно под кодовым именем Big O нотация.

Big O описывает, насколько медленнее станет выполняться ваш код, если увеличить размер входных данных. Для примера возьмем сортировку. Сложность O(n2) означает, что если входной массив увеличится в 50 раз, то время выполнения алгоритма вырастет аж в 502 = 2500 раз!

Big O нотация – это не точное число, она дает лишь общее представление о масштабируемости алгоритма, например, O(n), O(n log n), O(n2), O(n!).

Читайте также:

На этом разговор про алгоритмы и производительность мы заканчиваем. Цель статьи – обсуждение программных интерфейсов и отладки программ. Вроде бы темы не связанные друг с другом, однако вы удивитесь, как много между ними общего.

Огромная доля нашего рабочего времени уходит на поиск и исправление ошибок. И чем больше кода – тем больше времени.

Некоторые API и языковые конструкции устроены так, что делают невозможным допущение целых классов ошибок, а другие, наоборот, создают бесконечные проблемы на пустом месте. Как же понять, кто из них кто?

Мы часто спорим об изящности наших программных интерфейсов, но иногда забываем, что изящность и опыт практического использования – это разные вещи. Чтобы оценить, насколько замедляет вас ваш API по мере роста кодовой базы, введем новую нотацию: Bug O.

Отладочная сложность: ?(n)

Big O описывает, как замедляется алгоритм, Bug O – как замедляетесь вы.

Посмотрим на реальном примере. Есть довольно неструктурированный код, вручную обновляющий DOM с помощью императивных операций вроде node.appendChild() и node.removeChild().

function trySubmit() {
  // Section 1
  let spinner = createSpinner();
  formStatus.appendChild(spinner);
  submitForm().then(() => {
  	// Section 2
    formStatus.removeChild(spinner);
    let successMessage = createSuccessMessage();
    formStatus.appendChild(successMessage);
  }).catch(error => {
  	// Section 3
    formStatus.removeChild(spinner);
    let errorMessage = createErrorMessage(error);
    let retryButton = createRetryButton();
    formStatus.appendChild(errorMessage);
    formStatus.appendChild(retryButton)
    retryButton.addEventListener('click', function() {
      // Section 4
      formStatus.removeChild(errorMessage);
      formStatus.removeChild(retryButton);
      trySubmit();
    });
  })
}

Проблема не в том, что этот код уродлив. Мы не обсуждаем эстетику. Проблема в том, как вы будете искать здесь ошибку, если она возникнет.

Читайте также:

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

Здесь четыре секции кода и никакой гарантии о порядке их выполнения. На обычном калькуляторе можно посчитать, что есть 4х3х2х1=24 комбинации, в которых они могут сработать. А если добавить еще 4 сегмента, будет 8x7x6x5x4x3x2x1 - сорок тысяч комбинаций! Удачи в отладке :)

Другими словами, этот подход имеет сложность Bug O ?(n!). Здесь n – количество сегментов кода, изменяющих DOM. Да, это факториал. Безусловно, не все комбинации на практике могут возникнуть. С другой стороны, каждый из этих сегментов может выполняться более одного раза. Но даже с уточненным показателем Bug O сложность подобных программных интерфейсов слишком велика, можно сделать лучше.

Уменьшение отладочной сложности

Прежде всего нужно ограничить количество возможных состояний и результатов. Для этого не нужна библиотека – только продуманная структура кода.

let currentState = {
  step: 'initial', // 'initial' | 'pending' | 'success' | 'error'
};

function trySubmit() {
  if (currentState.step === 'pending') {
    // запретить повторную отправку
    return;
  }
  setState({ step: 'pending' });
  submitForm.then(() => {
    setState({ step: 'success' });
  }).catch(error => {
    setState({ step: 'error', error });
  });
}

function setState(nextState) {
  // удалить всех существующих потомков
  formStatus.innerHTML = '';

  currentState = nextState;
  switch (nextState.step) {
    case 'initial':
      break;
    case 'pending':
      formStatus.appendChild(spinner);
      break;
    case 'success':
      let successMessage = createSuccessMessage();
      formStatus.appendChild(successMessage);
      break;
    case 'error':
      let errorMessage = createErrorMessage(nextState.error);
      let retryButton = createRetryButton();
      formStatus.appendChild(errorMessage);
      formStatus.appendChild(retryButton);
      retryButton.addEventListener('click', trySubmit);
      break;
  }
}

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

function setState(nextState) {
  // удалить всех существующих потомков
  formStatus.innerHTML = '';

  // ... добавить что-то в formStatus ...

Очистив контейнер перед выполнением каких-либо манипуляций, вы гарантируете, что DOM-операции всегда начинаются с нуля, энтропия не увеличивается и ошибки не накапливаются.

Если вдруг появится ошибка, достаточно вернуться всего лишь на один шаг назад – к предыдущему вызову setState. Отладочная сложность такого интерфейса – ?(n), где n – количество сегментов, ответственных за рендеринг (тут их 4 – по количеству кейсов в switch).

Проблемы еще могут возникнуть внутри функции trySubmit, но и здесь отладка гораздо проще. Можно зарегистрировать и проверить каждое промежуточное состояние и явно запретить любые нежелательные переходы.

function trySubmit() {
  if (currentState.step === 'pending') {
    // запретить повторную отправку
    return;
  }

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

В такой момент и нужны библиотеки вроде React. Они позволяют не воссоздавать пользовательский интерфейс с нуля без необходимости.

function FormStatus() {
  let [state, setState] = useState({
    step: 'initial'
  });

  function handleSubmit(e) {
    e.preventDefault();
    if (state.step === 'pending') {
      // запретить повторную отправку
      return;
    }
    setState({ step: 'pending' });
    submitForm.then(() => {
      setState({ step: 'success' });
    }).catch(error => {
      setState({ step: 'error', error });
    });
  }

  let content;
  switch (state.step) {
    case 'pending':
      content = <Spinner />;
      break;
    case 'success':
      content = <SuccessMessage />;
      break;
    case 'error':
      content = (
        <>
          <ErrorMessage error={state.error} />
          <RetryButton onClick={handleSubmit} />
        </>
      );
      break;
  }

  return (
    <form onSubmit={handleSubmit}>
      {content}
    </form>
  );
}

Код может выглядеть по-другому, но принцип сохраняется. Компоненты – это абстракции. Они гарантируют, что никакой другой код не испортит DOM или состояние вашей программы. Компонентный подход помогает уменьшить отладочную сложность программных интерфейсов – Bug O.

Если в DOM что-то не так, вы можете проследить путь ошибки, просмотрев последовательно дерево компонентов. Таким образом показатель сложности равен ?(высота этого дерева).

Читайте также:

Размышляя о проектировании программных интерфейсов, подумайте первым делом, какова их отладочная сложность ?. А что насчет уже имеющихся API, с которыми вы работаете? Redux, CSS, наследование – у всего есть своя Bug O стоимость.

Оригинал: The “Bug-O” Notation

РУБРИКИ В СТАТЬЕ

Комментарии

BUG
LIVE