Написание простого DSL компилятора на Delphi (Intermezzo)
Перевод поста Writing a Simple DSL Compiler with Delphi (Intermezzo)open in new window.
Когда я подготавливал статью про компилятор для моего игрушечного языкового проектаopen in new window, я обнаружил что концепцию обёртки целой программы в связку анонимных функций (что делает компилятор) чрезвычайно сложна для объяснения. Поэтому я подготовил упрошенную версию компилятора, написанную для очень упрошенного языка... а затем я так и не смог остановится и добавил AST, пакрсер и токинезатор.
Результатом всего этого является программа introduction.dpropen in new window, автономная программа которая содержит полностью язык (очень тривиальный) вместе с полной документацией, написанная в стиле Грамотного программированияopen in new window. Упрощено — вы можете читать её сверху вниз как историю.
В качестве intermezzo и для упрощения моего объяснения компилятора, я опишу эту программу здесь полностью, отформатировав её как пост в блог.
introduction.dpr
Эта программа является мягким введением в тему "compiler-compiler" (программ которые генерируют компиляторы или их части). Она написана в стиле Грамотного программирования и предназначена для чтения от начала до конца.
program introduction;
{$APPTYPE CONSOLE}
{$R *.res}
uses
System.SysUtils,
System.Classes,
System.Character,
System.Generics.Collections;
2
3
4
5
6
7
8
9
Наша задача: мы хотим вычислять выражения в форме
number1 + number2 + ... + numberN
Все числа целые и позитивные, только один оператор — сложение, переполнение игнорируется.
Формально, мы можем описать нашу программу следующей грамматикой
S → Term
Term → number
Term → Term '+' Term
2
3
Пробельные символы игнорируются парсером и следовательно не являются частью грамматики.
Мы начнём с очень простого AST который будет хранить разобранную версию программы
type
TTerm = class abstract
end;
TAST = TTerm;
2
3
4
5
На верху нашего дерева находится 'term' (слагаемое). Слагаемое может быть либо константой либо сложением.
Константа, как и можно ожидать, содержит целочисленное значение.
Здесь мы непоследовательны — язык позволяет только позитивные числа, но AST более общее и допускает негативные числа. Мы будем просто игнорировать это.
TConstant = class(TTerm)
strict private
FValue: integer;
public
constructor Create(AValue: integer);
property Value: integer read FValue write FValue;
end;
2
3
4
5
6
7
Сложение — бинарная операция над двумя слагаемыми (левым и правым).
TAddition = class(TTerm)
strict private
FTerm1: TTerm;
FTerm2: TTerm;
public
constructor Create(ATerm1, ATerm2: TTerm);
destructor Destroy; override;
property Term1: TTerm read FTerm1 write FTerm1;
property Term2: TTerm read FTerm2 write FTerm2;
end;
constructor TConstant.Create(AValue: integer);
begin
inherited Create;
FValue := AValue;
end;
constructor TAddition.Create(ATerm1, ATerm2: TTerm);
begin
inherited Create;
FTerm1 := ATerm1;
FTerm2 := ATerm2;
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Объект TAddition
является владельцем своих дочерних объектов.
destructor TAddition.Destroy;
begin
FreeAndNil(FTerm1);
FreeAndNil(FTerm2);
inherited;
end;
2
3
4
5
6
7
Следующая функция строит AST из массива чисел. Владелец отвечает за уничтожение полученного AST.
function CreateAST(const values: TArray): TAST;
var
iValue: integer;
begin
if Length(values) = 0 then
Exit(nil);
2
3
4
5
6
7
Мы будем создавать слагаемые из массив в начиная с конца к началу и использовать промежуточные результаты как слагаемые в следующих слагаемых.
Result := TConstant.Create(values[High(values)]);
for iValue := High(values) - 1 downto Low(values) do
Result := TAddition.Create(TConstant.Create(values[iValue]), Result);
end;
2
3
4
5
6
Вызов CreateAST([1, 2, 3])
создаст следующее AST с тремя узлами:
TAddition
Term1 = TConstant
Value = 1
Term2 = TAddition
Term1 = TConstant
Value = 2
Term2 = TConstant
Value = 3
2
3
4
5
6
7
8
Давайте сделаем из этого тест.
Сначала, несколько вспомогательных функций, которые одновременно проверяют и преобразовывают тип.
function IsConstant(term: TTerm; out add: TConstant): boolean;
begin
Result := term is TConstant;
if Result then
add := TConstant(term);
end;
function IsAddition(term: TTerm; out add: TAddition): boolean;
begin
Result := term is TAddition;
if Result then
add := TAddition(term);
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
И теперь реальный тест.
procedure TestCreateAST;
var
add1 : TAddition;
add2 : TAddition;
ast : TAST;
const1: TConstant;
const2: TConstant;
const3: TConstant;
begin
ast := CreateAST([1, 2, 3]);
try
if assigned(ast)
and IsAddition(ast, add1)
and IsConstant(add1.Term1, const1) and (const1.Value = 1)
and IsAddition(add1.Term2, add2)
and IsConstant(add2.Term1, const2) and (const2.Value = 2)
and IsConstant(add2.Term2, const3) and (const3.Value = 3)
then
// everything is fine
else
raise Exception.Create('CreateAST is not working correctly!');
finally FreeAndNil(ast); end;
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Мы напишем просто парсер который создаст AST из выражения в форме number1 + number2 + ... numberN
.
Наш "язык" имеет только два токена: 'number' (число) и 'addition' (сложение). Пробельные символы не важны будут игнорироваться токинезатором (лексическим анализатором). Все не распознанные символы будут возвращать токен 'unknown'.
type
TTokenKind = (tkNumber, tkAddition, tkUnknown);
2
Больше информации про токены:
- tkNumber — "\d+"
- tkAddition — "+"
- "\s+" — пропускаются
- tkUnknown — принимает всё остальное: "[^\d+\s]"
Токинезатор и парсер нуждаются только в следующей информации:
- Входная строка.
- Текущая позиция.
Класс TStringStream
обеспечивает оба эти пункта так что мы будем использовать его.
TParserState = TStringStream;
Единственная функция токинезатора возвращает следующий токен и его значение как параметры с модификатором var
и возвращает True
если пара токен\значение была возвращена и False
если достигнут конец потока.
Эта реализация очень проста, но одновременно крайне неоптимизирована.
function GetToken(state: TParserState; var token: TTokenKind; var value: string): boolean;
var
nextChar: string;
position: int64;
begin
repeat
nextChar := state.ReadString(1);
Result := (nextChar <> '');
// Ignore whitespace
until (not Result) or (not nextChar[1].IsWhiteSpace);
if Result then begin
value := nextChar[1];
// Addition
if value = '+' then
token := tkAddition
// Number
else if value[1].IsNumber then begin
token := tkNumber;
repeat
position := state.Position;
nextChar := state.ReadString(1);
// End of stream, stop
if nextChar = '' then
break //repeat
// Another number, append
else if nextChar[1].IsNumber then
value := value + nextChar[1]
// Read too far, retract
else begin
state.Position := position;
break; //repeat
end;
until false;
end
// Unexpected input
else
token := tkUnknown;
end;
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Необходимо несколько тестов для токинезатора..
ExpectFail(state)
вызывает GetToken
и ожидает что он вернёт False
.
procedure ExpectFail(state: TParserState);
var
token: TTokenKind;
value: string;
begin
if GetToken(state, token, value) then
raise Exception.Create('ExpectFail failed');
end;
2
3
4
5
6
7
8
9
Expect(State, token, value)
вызывает GetNextToken
и ожидает что он вернёт True
и те же токен/значение которые переданы в параметрах.
procedure Expect(state: TParserState; expectedToken: TTokenKind; expectedValue: string);
var
token: TTokenKind;
value: string;
begin
if not GetToken(state, token, value) then
raise Exception.Create('Expect failed')
else if token <> expectedToken then
raise Exception.CreateFmt( 'Expect encountered invalid token kind (%d, expected %d)',
[Ord(token), Ord(expectedToken)])
else if value <> expectedValue then
raise Exception.CreateFmt( 'Expect encountered invalid value (%s, expected %s)',
[value, expectedValue])
end;
procedure TestGetToken;
var
state: TParserState;
begin
state := TParserState.Create('');
ExpectFail(state);
FreeAndNil(state);
state := TParserState.Create('1');
Expect(state, tkNumber, '1');
ExpectFail(state);
FreeAndNil(state);
state := TParserState.Create('1+22 333 Ab');
Expect(state, tkNumber, '1');
Expect(state, tkAddition, '+');
Expect(state, tkNumber, '22');
Expect(state, tkNumber, '333');
Expect(state, tkUnknown, 'A');
Expect(state, tkUnknown, 'b');
ExpectFail(state);
FreeAndNil(state);
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Парсер принимает любую допустимую строку и преобразует её в AST.
Если программа корректна, он создаст AST для этой программы, вернёт его в параметре ast
и результат функции будет True
.
Если программа не корректна, параметр ast
будет nil
и результат функции False
.
Пустой ввод не допускается.
function Parse(const prog: string; var ast: TAST): boolean;
var
accept : TTokenKind;
numbers: TList;
state : TParserState;
token : TTokenKind;
value : string;
begin
2
3
4
5
6
7
8
Мы можем легко увидеть как показанная грамматика генерирует следующую последовательность токенов:
tkNumber (tkAddition tkNumber)*
(Доказательство опущено в качестве упражнения для читателя)
Код проверит синтаксис и извлечёт из строки все числа в TArray
.
В конце он передаст этот массив в функцию CreateAST
для создания AST.
ast := nil;
Result := false;
state := TParserState.Create(prog);
try
numbers := TList.Create;
try
accept := tkNumber;
while GetToken(state, token, value) do begin
if token <> accept then
Exit;
if accept = tkNumber then begin
numbers.Add(StrToInt(value));
accept := tkAddition;
end
else
accept := tkNumber;
end;
if accept = tkNumber then
// Last token in the program was tkAddition, which is not allowed.
Exit;
if numbers.Count > 0 then begin
ast := CreateAST(numbers.ToArray);
Result := true;
end;
finally FreeAndNil(numbers); end;
finally FreeAndNil(state); end;
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Нам нужно больше тестов...
procedure TestParse;
var
add1 : TAddition;
add2 : TAddition;
ast : TAST;
const1: TConstant;
const2: TConstant;
const3: TConstant;
begin
if not Parse('1+2 + 3', ast) then
raise Exception.Create('Parser failed');
try
if assigned(ast)
and IsAddition(ast, add1)
and IsConstant(add1.Term1, const1) and (const1.Value = 1)
and IsAddition(add1.Term2, add2)
and IsConstant(add2.Term1, const2) and (const2.Value = 2)
and IsConstant(add2.Term2, const3) and (const3.Value = 3)
then
// everything is fine
else
raise Exception.Create('CreateAST is not working correctly!');
finally FreeAndNil(ast); end;
if Parse('1+2 +', ast) then begin
if assigned(ast) then
raise Exception.Create('Invalid program resulted in an AST!)')
else
raise Exception.Create('Invalid program compiled into an empty AST!');
end;
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Для интерпретации этого AST мы будем использовать простую рекурсию.
function InterpretAST(ast: TAST): integer;
var
add1 : TAddition;
const1: TConstant;
begin
if not assigned(ast) then
raise Exception.Create('Result is undefined!');
// Alternatively, we could use Nullable as result,
// with Nullable.Null as a default value.
if IsConstant(ast, const1) then
Result := const1.Value
else if IsAddition(ast, add1) then
Result := InterpretAST(add1.Term1) + InterpretAST(add1.Term2)
else
raise Exception.Create('Internal error. Unknown AST element: ' + ast.ClassName);
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Несколько sanity tests всегда приветствуются...
procedure TestInterpretAST;
procedure Test(const testName: string; const values: TArray; expectedResult: integer);
var
ast : TAST;
calcResult: integer;
begin
ast := CreateAST(values);
if not assigned(ast) then
raise Exception.CreateFmt('Compilation failed in test %s', [testName]);
try
calcResult := InterpretAST(ast);
if calcResult <> expectedResult then
raise Exception.CreateFmt(
'Evaluation failed in test %s. ' +
'Calculated result %d <> expected result %d',
[testName, calcResult, expectedResult]);
finally
FreeAndNil(ast);
end;
end;
begin
Test('1', [42], 42);
Test('2', [1, 2, 3], 6);
Test('3', [2, -2, 3, -3], 0);
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Для компиляции этого AST, мы должны:
- Изменить каждый узел с типом 'constant' в анонимную функцию которая возвращает значение этого узла.
- Изменить каждый узел с типом 'summation' в анонимную функцию которая возвращает значение двух параметров.
- Первый - анонимная функция которая вычисляет значение левого слагаемого и
- второй - анонимная функция которая вычисляет значение правого слагаемого
- Механизм связывания переменныхopen in new window заботится о получении правильных входных данных
function MakeConstant(value: integer): TFunc;
begin
Result :=
function: integer
begin
Result := value;
end;
end;
function MakeAddition(const term1, term2: TFunc): TFunc;
begin
Result :=
function: integer
begin
Result := term1() + term2();
end;
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Важная точка здесь в том что не MakeConstant
не MakeAddition
не делает никаких вычислений. Они просто настраивают анонимный метод и возвращают ссылку на него, что более или менее соответствует созданию объекта и возврату его интерфейса, но с добавление затрат на связывание переменных (variable capturing).
Кстати, так как наш "язык" только вычисляет целочисленные выражения что всегда на выходе даёт целое число, то "функция которая возвращает число" или TFunc
точно подходит под наши требования.
Для "компиляции" AST мы должны использовать рекурсию так как нам нужно создать дочерне-вычисляемые анонимные функции перед их вычислением (как параметры) для создания анонимной функции вычисляющей родительский узел.
function CompileAST(ast: TTerm): TFunc;
var
add1: TAddition;
const1: TConstant;
begin
if IsConstant(ast, const1) then
// this node represents a constant
Result := MakeConstant(const1.Value)
else if IsAddition(ast, add1) then
// this node represent an expression
Result := MakeAddition(CompileAST(add1.Term1), CompileAST(add1.Term2))
else
raise Exception.Create('Internal error. Unknown AST element: ' + ast.ClassName);
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Этот код работает корректно потому что захватывает значение const1.Value
, а не ссылку (указатель) на него. Откуда я это знаю? Потому что функция TestCompileAST
явным образом проверяет это поведение.
Вызывая CompileAST(CreateAST[1,2,3])
будет сгенерирована следующая анонимная функция:
(*
function: integer
begin
Result :=
(function: integer
begin
Result := 1;
end)()
+
(function: integer
begin
Result :=
(function: integer
begin
Result := 2;
end)()
+
(function: integer
begin
Result := 3;
end)();
end)();
end;
*)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(*): я знаю что результатом этого будет уточка памяти так как AST не уничтожается.
Трудно проверить что сгенерированная анонимная функция в корректной форме, но мы можем запустить её на некотором числе тестов и надеятся что всё ОК 😉
procedure TestCompileAST;
procedure Test(const testName: string; const prog: string; expectedResult: integer);
var
add1 : TAddition;
ast : TAST;
calcResult: integer;
code : TFunc;
const1 : TConstant;
begin
if not (Parse(prog, ast) and assigned(ast)) then
raise Exception.CreateFmt('Parser failed in test %s', [testName]);
try
code := CompileAST(ast);
if not assigned(code) then
raise Exception.CreateFmt('Compilation failed in test %s', [testName]);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Давайте удостоверимся что ast.Value
был связан по значению а не по ссылке.
Изменение AST сейчас не должно влиять на скомпилированный код.
if (IsAddition(ast, add1) and IsConstant(add1.Term1, const1))
or IsConstant(ast, const1)
then
const1.Value := const1.Value + 1
else
raise Exception.CreateFmt('Unexpected AST format in test %s', [testName]);
calcResult := code(); //execute the compiled code
if calcResult <> expectedResult then
raise Exception.CreateFmt(
'Evaluation failed in test %s. ' +
'Codegen result %d <> expected result %d',
[testName, calcResult, expectedResult]);
finally
FreeAndNil(ast);
end;
end;
begin
Test('1', '42', 42);
Test('2', '1 + 2 + 3', 6);
Test('3', '2 + 2 +3+3', 10);
end;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Если все тесты проходят, мы запустим цикл Чтение-Выполнение-Вывод (Read-Eval-Print Loop) так что пользователь сможет проверить наш компилятор.
procedure RunREPL;
var
ast : TAST;
prog: string;
begin
repeat
Write('Enter an expression (empty line exits): ');
Readln(prog);
if prog = '' then
break;
if not Parse(prog, ast) then
Writeln('Syntax is not valid')
else
Writeln('Result is: ', CompileAST(ast)());
until false;
end;
begin
try
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Запустим все модульные тесты для проверки корректности программы.
Writeln('Running AST creation tests ...');
TestCreateAST;
Writeln('Running tokenizer tests ...');
TestGetToken;
Writeln('Running parser test ...');
TestParse;
Writeln('Running AST interpreter tests ...');
TestInterpretAST;
Writeln('Running AST compilation tests ...');
TestCompileAST;
RunREPL;
except
on E: Exception do begin
Writeln(E.ClassName, ': ', E.Message);
Readln;
end;
end;
end.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23