Упростите свой код с помощью конечных автоматов

вступление

Вы когда-нибудь натыкались на кусок кода и говорили: «Что, черт возьми, здесь происходит?! Что это за логика?» 🥴. Если ответ положительный (и даже если нет 😜), вам следует узнать о вычислительной модели под названием «конечный автомат» и о том, как она может помочь вам упростить код.

Что такое «государственная машина»?

Конечный автомат — это поведенческая модель. Он состоит из конечного числа состояний и поэтому также называется автоматом с конечным числом состояний (FSM). Основываясь на текущем состоянии и заданном входе, машина выполняет переходы между состояниями и производит выходные данные. Вот пример, чтобы лучше понять.

Опишем мою жизнь:

const alive = new Date().getFullYear() === '2062'

const repeat = () => {
  while (alive) {
    eat();
    sleep();
    code();
  }
}

repeat();

Теперь я создам конечный автомат своей жизни:

Я начинаю свой день с кодинга. Если я голоден, я буду есть, а если я устал, я буду спать — это означает, что из состояния кодирования я перейду либо к еде, если я голоден, либо к сну, если я устал.

Вы можете взять любую вычислительную модель и создать для нее конечный автомат.

Хорошо, что теперь? 🤷‍♂️

Надеюсь, теперь вы хорошо понимаете, что такое конечный автомат. Итак, как это может помочь вам в повседневных задачах кодирования? Как однажды сказал Ричард Фейнман:

«То, что я не могу создать, я не понимаю».

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

Упростите наш код 🤩

Скажем, у нас есть приложение, которое показывает таблицу данных. Таблица данных извлекает данные из API и при разрешении показывает данные. Пока вызов API не разрешен, мы показываем загрузчик:

Вот наш код, который имеет дело с логикой содержимого таблицы:

import { TableCaption, Tbody, Td, Tr } from '@chakra-ui/react';
import { AreaLoader } from 'src/components/common/AreaLoader';

type TableContentProps = {
  data: Data[];
  isLoading: boolean;
};


export const TableContent = ({ 
  isLoading,
  getTableBodyProps,
  prepareRow,
  rows
}: TableContentProps) => {
  if (isLoading) {
    return (
      <TableCaption>
        <AreaLoader />
      </TableCaption>
    );
  }

  return (
    <Tbody>
      {data.map((row) => 
          <Tr>
            {row.cells.map((cell) => (
              <Td>{cell}</Td>
            ))}
          </Tr>
        )}
    </Tbody>
  );
};

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

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

type TableContentProps = {
  data: Data[];
  isLoading: boolean;
  isFilterOrSearchResultsEmpty: boolean;
};

export const TableContent = ({
  isLoading,
  isFilterOrSearchResultsEmpty,
  data,
}: TableContentProps) => {
  if (isLoading) {
    return (
      <TableCaption>
        <AreaLoader />
      </TableCaption>
    );
  }

  if (isFilterOrSearchResultsEmpty) {
    return (
      <TableCaption>
        <EmptyList text="No results found" />
      </TableCaption>
    )
  }

  return (
    <Tbody>
      {data.map((row) => 
          <Tr}>
            {row.cells.map((cell) => (
              <Td>{cell}</Td>
            ))}
          </Tr>
        );
      }
    </Tbody>
  );
};

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

А теперь полный пример кода:

type TableContentProps = {
  data: Data[];
  isLoading: boolean;
  isError: boolean;
  isFilterOrSearchResultsEmpty: boolean;
};

export const TableContent = ({
  isLoading,
  isFilterOrSearchResultsEmpty,
  isError,
  data,
}: TableContentProps) => {

  if (isLoading) {
    return (
      <TableCaption>
        <AreaLoader />
      </TableCaption>
    );
  }

  if (isFilterOrSearchResultsEmpty) {
    return (
      <TableCaption>
        <EmptyList text="No results found" />
      </TableCaption>
    );
  }

  if (isError) {
    return (
      <TableCaption>
        <Box>
          Something went wrong... Please refresh this page or <Box as="a">Contact support</Box> if the problem persists
        </Box>
      </TableCaption>
    );
  }

  return (
    <Tbody>
      {data.map((row) =>
          <Tr>
            {row.cells.map((cell) => (
              <Td>{cell}</Td>
            ))}
          </Tr>
        );
      }
    </Tbody>
  );
};

Как вы можете видеть выше, этот код очень быстро усложнился. В нашем коде есть несколько операторов «если», невозможные переходы между состояниями и странное поведение пользовательского интерфейса. Теперь я собираюсь показать, как я могу использовать конечный автомат для упрощения этого кода (также примите во внимание, что существует дополнительная логика вызовов API и длина данных как таковая, которые не включены в примеры кода, но вы можете представьте, как они будут выглядеть).

Какие состояния у меня есть?

Если мы посмотрим на код выше, мы можем разбить его на 4 состояния:
1. Загрузка — когда мы получаем данные из API или когда мы выполняем действие.

2. Успех — когда вызов API разрешен и у нас есть данные для отображения.

3. Ошибка — когда мы сталкиваемся с ошибкой при выполнении вызовов API.

4. Нет результатов — когда мы ищем или фильтруем, мы не получаем никаких результатов.

Создайте функцию конечного автомата 🤓

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

Я создам новое перечисление для представления моего состояния:

export enum TableStateEnum {
  ERROR = 'error',
  LOADING = 'loading',
  SUCCESS = 'success',
  EMPTY_FILTER_OR_SEARCH_RESULT = 'emptyFilterOrSearchResult',
}

И это будет моя функция конечного автомата:

type TableStateParams = {
  data: Data[];
  loading: boolean;
  error: boolean;
  isFilterOrSearchResultsEmpty: boolean;
};

export const getTableState = ({ loading, error, data, isFilterOrSearchResultsEmpty }: TableStateParams) => {
  if (loading) {
    return TableStateEnum.LOADING;
  }

  if (error) {
    return TableStateEnum.ERROR;
  }

  if (isFilterOrSearchResultsEmpty) {
    return TableStateEnum.EMPTY_FILTER_OR_SEARCH_RESULT;
  }

  return TableStateEnum.SUCCESS;
};

В приведенной выше функции мы просматриваем каждое логическое условие и определяем текущее состояние нашего приложения. Что хорошего в этой функции, так это то, что мы можем понять все состояния нашего приложения, не погружаясь в логику (которая может быть очень сложной).

Перенесите нашу логику

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

Функция сопоставления даст мне возможность отображать соответствующий компонент для каждого заданного состояния. Это позволит нам иметь меньший компонент с понятной логикой (упрощенной 💯).

export const MAP_CONTENT_BY_STATE = {
  [TableStateEnum.LOADING]: () => (
    <TableCaption>
      <AreaLoader />
    </TableCaption>
  ),
  [TableStateEnum.ERROR]: () => (
    <TableCaption>
      <Box>
        Something went wrong
      </Box>
    </TableCaption>
  ),
  [TableStateEnum.EMPTY_FILTER_OR_SEARCH_RESULT]: () => (
    <TableCaption>
      <EmptyList text="No results found" />
    </TableCaption>
  ),
  [TableStateEnum.SUCCESS]: ({
    data,
  }: TableContentProps) => (
    <Tbody>
      {data.map((row) =>
          <Tr>
            {row.cells.map((cell) => (
              <Td>{cell}</Td>
            ))}
          </Tr>
        );
      }
    </Tbody>
  ),
};

Перестроить компонент — окончательный результат

Теперь у нас есть все наши части, и мы можем увидеть последний пример:

import { getTableState } from './utils';
import { MAP_CONTENT_BY_STATE } from './mapper';

export const TableContentSimplified = ({
  isLoading,
  data,
  isError,
  isFilterOrSearchResultsEmpty,
}: TableContentProps) => {
  const state = getTableState({ 
    loading: isLoading,
    error: isError,
    data,
    isFilterOrSearchResultsEmpty 
  });
  const StateComponent = MAP_CONTENT_BY_STATE[state];

  return <StateComponent data={data} />;
};

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

Как вы можете использовать эту технику в других областях разработки?

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

Должен ли я всегда использовать конечные автоматы в своем коде?

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

это обертка

Мы рассмотрели, что такое конечный автомат, и создали пример кода, чтобы помочь вам понять многочисленные плюсы использования парадигмы конечного автомата в вашем коде. Я надеюсь, что это поможет вам реализовать эту парадигму в вашей кодовой базе. Часто, когда я работаю с очень сложной кодовой базой, я пытаюсь превратить ее в конечный автомат. Это помогает мне понять логику. Поэтому, даже если вы не можете изменить существующий код, я призываю вас принять конечные автоматы в качестве ментальной модели.

Спасибо 💜