Введение
Преобразование последовательностей символов в последовательности токенов в информатике. Лексическая токенизация — это преобразование текста в (семантически или синтаксически) значимые лексические токены, принадлежащие к категориям, определяемым программой-лексером. В случае естественного языка эти категории включают существительные, глаголы, прилагательные, знаки препинания и т. д. В случае языка программирования категории включают идентификаторы, операторы, символы группировки и типы данных. Лексическая токенизация связана с типом токенизации, используемым в больших языковых моделях (LLM), но имеет два отличия. Во-первых, лексическая токенизация обычно основана на лексической грамматике, в то время как токенизаторы LLM обычно основаны на вероятностных моделях. Во-вторых, токенизаторы LLM выполняют второй этап, преобразующий токены в числовые значения.
Lexical tokenization is conversion of a text into (semantically or syntactically) meaningful lexical tokens belonging to categories defined by a "lexer" program. In case of a natural language, those categories include nouns, verbs, adjectives, punctuations etc. In case of a programming language, the categories include identifiers, operators, grouping symbols and data types. Lexical tokenization is related to the type of tokenization used in Large language models (LLMs), but with two differences. First, lexical tokenization is usually based on a lexical grammar, whereas LLM tokenizers are usually probability based. Second, LLM tokenizers perform a second step that converts the tokens into numerical values.
Программы, основанные на правилах
Программа, основанная на правилах и выполняющая лексическую токенизацию, называется токенизатором или сканером, хотя термин "сканер" также используется для обозначения первой стадии лексера. Лексер составляет первую фазу фронтенда компилятора при обработке исходного кода. Анализ обычно выполняется за один проход. Лексеры и парсеры чаще всего применяются в компиляторах, но могут использоваться и в других инструментах для работы с языками программирования, таких как prettyprinters или линтеры. Лексический анализ можно разделить на два этапа: сканирование, которое разбивает входную строку на синтаксические единицы, называемые лексемами, и классифицирует их по классам токенов; и преобразование, которое преобразует лексемы в обработанные значения. Лексеры обычно достаточно просты, большая часть сложности переносится на этапы синтаксического или семантического анализа, и их часто можно сгенерировать с помощью генератора лексеров, например lex или его производных. Однако лексеры иногда могут включать в себя некоторую сложность, например, обработку фразовой структуры для упрощения ввода и работы парсера, и могут быть написаны частично или полностью вручную, либо для поддержки большего числа функций, либо для повышения производительности.
Недвусмысленность слова "lexeme"
То, что называется "лексемой" в обработке естественного языка, основанной на правилах, не тождественно тому, что называется лексемой в лингвистике. То, что называется "лексемой" в обработке естественного языка, основанной на правилах, может совпадать с лингвистическим эквивалентом лишь в аналитических языках, таких как английский, но не в высокосинтетических языках, например, в языках с агглютинацией и флексией. То, что называется лексемой в обработке естественного языка, основанной на правилах, скорее соответствует понятию слова в лингвистике (не следует путать со словом в компьютерной архитектуре), хотя в некоторых случаях оно может быть ближе к морфеме.
Лексическая грамматика
Спецификация языка программирования часто включает в себя набор правил, лексическую грамматику, которая определяет лексический синтаксис. Лексический синтаксис обычно является регулярным языком, грамматические правила которого состоят из регулярных выражений; они определяют набор возможных последовательностей символов (лексем) токена. Лексер распознает строки, и для каждого найденного типа строки лексическая программа выполняет действие, чаще всего – генерирует токен. Две важные общие лексические категории – это пробелы и комментарии. Они также определяются в грамматике и обрабатываются лексером, но могут быть отброшены (не генерируя никаких токенов) и считаться незначимыми, в большинстве случаев разделяя два токена (как в `if x` вместо `ifx`). Существует два важных исключения. Во-первых, в языках с правилами отступов, где блоки кода определяются отступами, начальные пробелы имеют значение, поскольку они определяют структуру блока и обычно обрабатываются на уровне лексера; см. ниже раздел о фразовой структуре. Во-вторых, в некоторых случаях использования лексеров комментарии и пробелы должны сохраняться – например, программа красивого вывода (prettyprinter) также должна выводить комментарии, а некоторые инструменты отладки могут предоставлять сообщения программисту, показывающие исходный код. В 1960-х годах, особенно для ALGOL, пробелы и комментарии удалялись в процессе реконструкции строки (начальная фаза фронтенда компилятора), но этот отдельный этап был упразднен, и теперь ими занимается лексер.
Сканер
Первый этап, сканер, обычно основан на конечном автомате (FSM). В нем закодирована информация о возможных последовательностях символов, которые могут содержаться в любом из обрабатываемых токенов (отдельные экземпляры этих последовательностей символов называются лексемами). Например, лексема целого числа может содержать любую последовательность числовых символов. Во многих случаях первый непробельный символ позволяет определить тип следующего токена, а последующие входные символы обрабатываются по одному, пока не будет достигнут символ, не входящий в допустимый набор символов для этого токена (это называется правилом максимального захвата или правилом наибольшего соответствия). В некоторых языках правила создания лексем более сложны и могут включать возврат к ранее прочитанным символам. Например, в языке C одного символа 'L' недостаточно, чтобы различить идентификатор, начинающийся с 'L', и строковый литерал широкого символа.
Препятствия
Как правило, лексическая токенизация происходит на уровне слов. Однако иногда бывает сложно определить, что подразумевается под "словом". Часто токенизатор опирается на простые эвристики, например: знаки препинания и пробелы могут включаться или не включаться в результирующий список токенов. Любая непрерывная последовательность буквенных символов является частью одного токена; то же самое относится и к числам. Токены разделяются пробельными символами, такими как пробел или перенос строки, либо знаками препинания. В языках, использующих пробелы между словами (например, большинство языков, использующих латинский алфавит, и большинство языков программирования), этот подход достаточно прямолинеен. Однако даже в этом случае существует множество особых случаев, таких как сокращения, слова с дефисом, эмодзи и более сложные конструкции, такие как URI (которые в некоторых случаях могут рассматриваться как единые токены). Классический пример – "New York based", который наивный токенизатор может разделить по пробелу, хотя более правильным (вероятно) было бы разделить по дефису. Токенизация особенно сложна для языков, написанных слитным письмом (scriptio continua), в которых отсутствуют границы слов, например, древнегреческий, китайский или тайский. Агглютинативные языки, такие как корейский, также усложняют задачу токенизации. Некоторые способы решения более сложных проблем включают разработку более сложных эвристик, обращение к таблице распространенных особых случаев или адаптацию токенов к языковой модели, которая определяет коллокации на более позднем этапе обработки.
Punctuation and whitespace may or may not be included in the resulting list of tokens. All contiguous strings of alphabetic characters are part of one token; likewise with numbers. Tokens are separated by whitespace characters, such as a space or line break, or by punctuation characters. In languages that use inter word spaces (such as most that use the Latin alphabet, and most programming languages), this approach is fairly straightforward. However, even here there are many edge cases such as contractions, hyphenated words, emoticons, and larger constructs such as URIs (which for some purposes may count as single tokens). A classic example is "New York based", which a naive tokenizer may break at the space even though the better break is (arguably) at the hyphen. Tokenization is particularly difficult for languages written in scriptio continua which exhibit no word boundaries such as Ancient Greek, Chinese, or Thai. Agglutinative languages, such as Korean, also make tokenization tasks complicated. Some ways to address the more difficult problems include developing more complex heuristics, querying a table of common special cases, or fitting the tokens to a language model that identifies collocations in a later processing step.
Генератор Лексера
Лексеры часто генерируются генератором лексеров, аналогично генераторам парсеров, и такие инструменты часто поставляются в комплекте. Наиболее распространенным является lex, в паре с генератором парсеров yacc, или, точнее, с одной из многочисленных их реимплементаций, таких как flex (часто используемый с GNU Bison). Эти генераторы представляют собой форму предметно-ориентированного языка, принимающего лексическую спецификацию – как правило, регулярные выражения с некоторой разметкой – и выдающего лексер. Эти инструменты обеспечивают очень быструю разработку, что особенно важно на ранних этапах, как для получения рабочего лексера, так и из-за частых изменений в спецификации языка. Более того, они часто предоставляют расширенные возможности, такие как предусловия и постусловия, которые сложно реализовать вручную. Однако автоматически сгенерированный лексер может быть недостаточно гибким и, следовательно, может потребовать ручной доработки или полной ручной реализации. Производительность лексера является важным фактором, и оптимизация оправдана, особенно для стабильных языков, где лексер запускается очень часто (например, C или HTML). Лексеры, сгенерированные lex/flex, достаточно быстры, но при использовании более оптимизированных генераторов можно добиться улучшения в два-три раза. Ручные лексеры иногда используются, но современные генераторы лексеров создают более быстрые лексеры, чем большинство написанных вручную. Семейство генераторов lex/flex использует табличный подход, который значительно менее эффективен, чем подход с прямой кодировкой. При последнем подходе генератор создает движок, который напрямую переходит к следующим состояниям с помощью операторов goto. Инструменты, такие как re2c, доказали свою способность создавать движки, которые в два-три раза быстрее, чем движки, созданные flex. В целом, вручную написать анализаторы, превосходящие по производительности движки, генерируемые этими инструментами, довольно сложно.
Структура фразы
Лексический анализ в основном разбивает входной поток символов на токены, просто группируя символы в фрагменты и классифицируя их. Однако лексический анализ может быть значительно сложнее; в простейшем случае лексеры могут пропускать токены или добавлять новые. Пропуск токенов, особенно пробелов и комментариев, встречается очень часто, когда они не требуются компилятору. Добавление токенов происходит реже. Это делается главным образом для группировки токенов в операторы или операторов в блоки, чтобы упростить работу парсера.
Продолжение линии
Продолжение строки — это особенность некоторых языков программирования, где символ новой строки обычно завершает оператор. Чаще всего, если строка заканчивается обратной косой чертой (сразу за которой следует новая строка), то строка продолжается на следующей строке, которая присоединяется к предыдущей. Обычно это реализуется на уровне лексера: обратная косая черта и символ новой строки отбрасываются, а не преобразуются в токен. Примерами являются bash, другие скрипты оболочки и Python.
Вставка точки и запятой
Многие языки используют точку с запятой как терминатор оператора. Чаще всего это обязательно, но в некоторых языках точка с запятой является необязательной во многих контекстах. Это в основном происходит на уровне лексера, где лексер выводит точку с запятой в поток токенов, несмотря на то, что она не присутствует в потоке входных символов, и это называется вставкой точки с запятой или автоматической вставкой точки с запятой. В этих случаях точки с запятой являются частью формальной грамматики языка, но могут не быть найдены во входном тексте, поскольку они могут быть вставлены лексером. Необязательные точки с запятой или другие терминаторы или разделители также иногда обрабатываются на уровне парсера, особенно в случае завершающих запятых или точек с запятой. Вставка точки с запятой является особенностью BCPL и его далекого потомка Go, хотя она отсутствует в B или C. Вставка точки с запятой присутствует в JavaScript, хотя правила несколько сложны и подвергаются критике; чтобы избежать ошибок, некоторые рекомендуют всегда использовать точки с запятой, в то время как другие используют начальные точки с запятой, называемые защитными точками с запятой, в начале потенциально неоднозначных операторов. Вставка точки с запятой (в языках с операторами, завершающимися точкой с запятой) и продолжение строки (в языках с операторами, завершающимися новой строкой) можно рассматривать как взаимодополняющие: вставка точки с запятой добавляет токен, даже если новые строки обычно не генерируют токены, в то время как продолжение строки предотвращает генерацию токена, даже если новые строки обычно генерируют токены.
Правило "офф-сайд"
Правило "офф-сайд" (блоки, определяемые отступами) может быть реализовано в лексере, как в Python, где увеличение отступа приводит к тому, что лексер генерирует токен INDENT, а уменьшение отступа – один или несколько токенов DEDENT. Эти токены соответствуют открывающей и закрывающей фигурной скобке { и } в языках, использующих скобки для обозначения блоков, и означают, что фразовая грамматика не зависит от того, используются скобки или отступы. Для этого лексеру необходимо хранить состояние, а именно стек уровней отступа, что позволяет ему обнаруживать изменения в отступах и, следовательно, лексическая грамматика не является контекстно-свободной: токены INDENT и DEDENT зависят от контекстной информации предыдущих уровней отступа.
Контекстный лексикон
В целом, лексические грамматики не зависят от контекста или почти не зависят от него, и поэтому не требуют обратного или предварительного просмотра, или отката, что обеспечивает простую, чистую и эффективную реализацию. Это также позволяет простое одностороннее взаимодействие от лексера к парсеру, без необходимости передачи какой-либо информации обратно лексеру. Однако существуют исключения. Простые примеры включают: автоматическую вставку точки с запятой в Go, которая требует просмотра предыдущего токена; конкатенацию последовательных строковых литералов в Python, которая требует хранения одного токена в буфере перед его выдачей (чтобы проверить, является ли следующий токен еще одним строковым литералом); и правило отступов в Python, которое требует ведения подсчета уровня отступа (фактически, стека уровней отступов). Все эти примеры требуют только лексического контекста, и хотя они несколько усложняют работу лексера, они не видны парсеру и последующим этапам. Более сложным примером является "хак" лексера в C, где класс токена последовательности символов не может быть определен до этапа семантического анализа, поскольку имена `typedef` и имена переменных лексически идентичны, но относятся к разным классам токенов. Таким образом, в этом "хаке" лексер обращается к семантическому анализатору (например, к таблице символов) и проверяет, требуется ли для последовательности имя `typedef`. В этом случае информация должна поступать обратно не только от парсера, но и от семантического анализатора к лексеру, что усложняет разработку.