Лекция 12. Конечные автоматы, оператор switch

Пример простого конечного автомата

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

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

_images/simple_fsm.png

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

#include <stdio.h>
// Воспользуемся функциями isspace и isupper из ctype.h,
// isspace возвращает истину, если символ является
// пробельным (' ', '\t', '\r', '\n'), и ложь в противном случае,
// isupper возвращает истину, если символ является
// буквой в верхнем регистре, и ложь в противном случае.
#include <ctype.h>

// Определим по константе для каждого состояния
#define SPACES 0
#define ALLCAPITAL 1
#define NOTALLCAPITAL 2

int main()
{
    int c;
    int state = SPACES;
    int capital_count = 0;
    while ((c = getchar()) != EOF) {
        if (SPACES == state) {
            if (isspace(c)) {
                // просто не меняем текущее состоние
            } else if (isupper(c)) {
                state = ALLCAPITAL;
            } else {
                state = NOTALLCAPITAL;
            }
        } else if (ALLCAPITAL == state) {
            if (isspace(c)) {
                // Увеличиваем счётчик, затем переходим
                // в начальное состояние.
                ++capital_count;
                state = SPACES;
            } else if (isupper(c)) {
                // просто не меняем текущее состоние
            } else {
                state = NOTALLCAPITAL;
            }
        } else if (NOTALLCAPITAL == state) {
            if (isspace(c)) {
                state = SPACES;
            } else {
                // просто не меняем текущее состоние
            }
        }
    }
    // Если в конце мы находимся в слове из больших букв,
    // то, так как пробела уже не случится, увеличим
    // счётчик в отдельном if-е.
    if (ALLCAPITAL) {
        ++capital_count;
    }
    printf("Words with all catpital letters: %d\n", capital_count);

    return 0;
}

Определение

Конечный автомат состоит из конечного множество состояний \(S\), начального состояния \(S_0\) и конечного множества переходов. Переходы имеют вид \((d, X, t, a)\), где

\(d \in S\)
начальное состояние, из которого возможно выполнить переход
\(X\)
множество символов, которые активируют переход
\(t \in S\)
состояние, в которое будет выполнен переход
\(a\)
действие, которое конечный автомат выполняет при переходе из состояния \(d\) в \(t\). Часто действие может отсутствовать

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

Ещё один пример конечного автомата, который разбивает входной текст на последовательность строк в стиле языка Си. Например, он разобъёт ввод

"1 2 3" "4 5 6" "abcdef GHI" "\a\b\"\"\""

на строки

1 2 3
4 5 6
abcdef GHI
ab"""

Обратите внимание, что нужно понимать, какая кавычка является синтаксическим элементом и открывает или закрывает строку, а какая – частью строки (такие кавычки предворяются обратным слешем).

#include <stdio.h>
#include <stdlib.h>

#define S0 0
#define S_STR 1
#define S_STR_2 2

void report_error(int state, int c)
{
    printf("Error: unexpected input symbol '%c' (char code %d) in state %d\n", c, c, state);
    exit(1);
}

int main(void)
{
    int c;
    int state = S0;
    char current_string[200] = {};
    int current_size = 0;
    while((c = getchar()) != EOF) {
        if (S0 == state) {
            if (c == ' ' || c == '\n') {
                // Nothing.
            } else if (c == '"') {
                state = S_STR;
            } else {
                report_error(state, c);
            }
        // Внимание: else if тут важен, чтобы не обработать один
        // и тот же входной символ в двух разных состояниях за один
        // раз.
        } else if (S_STR == state) {
            if (c == '"') {
                current_string[current_size++] = '\0';
                printf("string = %s\n", current_string);
                current_size = 0;
                state = S0;
            } else if (c == '\\') {
                state = S_STR_2;
            } else {
                current_string[current_size++] = c;
            }
        } else if (S_STR_2 == state) {
            if (c == 'n') {
                current_string[current_size++] = '\n';
                state = S_STR;
            } else if (c == '"') {
                current_string[current_size++] = '"';
                state = S_STR;
            } else {
                current_string[current_size++] = c;
                state = S_STR;
            }
        }
    }

    return 0;
}

Оператор switch

Мы уже часто встречались с конструкциями вида

int x;
x = get_some_value();
if (0 == x) {
    // Действие 0.
} else if (1 == x) {
    // Действие 1.
} else if (2 == x) {
    // Действие 2.
} else if (3 == x) {
    // Действие 3.
} else {
    // Действие для всех остальных x.
}

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

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

Для использования этой оптимизации в Си есть оператор switch:

int x;
x = get_some_value();
switch(x) {
case 0:
    // Действие 0.
    break;
case 1:
    // Действие 1.
    break;
case 2:
    // Действие 2.
    break;
case 3:
    // Действие 3.
    break;
default:
    // Действие для всех остальных x.
    break;
}

Из описанного выше происхождения оператора switch следуют некоторые его ограничения:

  • в switch(x) x должно быть переменной целого типа или выражением с результатом целого типа;
  • в case могут стоять только целые числа и они должны быть константами. Иначе при компиляции не получится сформировать таблицу переходов.

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

int c;
c = getchar();
switch(c) {
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
    // Проваливание из case-а в case позволяет назначить
    // одно действие на обработку нескольких case-ов.
    break;
case ' ':
    // Или продолжить обработку в следующем. Управление
    // отсюда перейдёт в следующий case, так как нет break.
case '\n':
    // Инструкции
    break;
default:
    // Действия по умолчанию.
    break;
}

В этом примере в качестве значений в case используются символьные константы, которые при компиляции компилятор преобразует в коды символов. Код ‘\n’ – число 13, пробелу соответствует 32, код чисел от ‘0’ до ‘9’ – числа от 48 до 57. Как видим, таблица переходов не очень хорошо строится. Однако, даже если компилятору не получится сделать оператор switch эффективным, его всё равно можно использовать, так как компилятор гарантирует его работу как в случае применения оптимизации, так и без.