- Published on
Defining languages with sets - A horrible yet correct alternative to grammars
When you want to define a language you usually describe its syntax with a grammar. For example:
term:
'true'
| 'false'
| '[+-]?[0-9]' //Integer number
| 'plusOne' term
| 'minusOne' term
| 'isZero' term
| 'if' term 'then' term 'else' term
This language includes plusOne 0
, plusOne plusOne 0
, if isZero minusOne 1 then 42 else -5
, etc. The thing is, when I see a grammar I tend to focus on the
parse trees it would produce with parser generators like ANTLR4. And the convenience of ANTLR4 makes me forget that the grammar doesn't define the parser, it
defines the language (though yeah, it also defines the structure the parse trees will have).
|
/ / | \ \ \
if isZero then 42 else -5
|
minusOne
|
1
(Now I'm an ASCII artist. I'll turn this tree into a NFT)
In the first post of my series about making a regex engine, I wrote:
A language is just a set of strings, for example the regex
a*
defines the language which can also be expressed as .
The same way the regex a*
defines a set a grammar also defines one. And the same way you use the regex to build recognizer for , you use the grammar to
build a recognizer (a.k.a parser) for the language it defines.
It's no coincidence that regex and grammars are so similar. Both are methods of defining formal languages, but regex are limited to regular languages, which are the lowest tier of languages in Chomsky's hierarchy. You can even use a regular grammar to define a regular language instead of a regex, but they are more inconvenient.
Defining a language with sets
Let's get to the point of this post. Given the above grammar of term
, is it possible to define its language by directly defining
the set of the language? Yes, though it's a bit harder than with regular expressions since grammar rules can be recursive. But you can still make an inductive
definition:
includes literals (as a small reminder is the set of integers). includes literals and calls to functions with a literal argument,
like plusOne 0
, I'll call them simple calls. contains again literals, simple calls and one level nested calls, like plusOne plusOne 0
, contains
literals, simple calls and two level nested calls, like if isZero minusOne 1 then 42 else -5
. You get the idea, each level includes the next level of nested calls.
You might have noticed that every contains the literals () and therefore simple calls too. This is necessary because otherwise you couldn't use them in where . Another approach would be doing something like and in every rule: , but that would be a bit confusing and I'm not sure if it makes sense mathematically.
Of course, each is just a piece of the cake. The complete language is defined by the union of all these sets:
Depth and tree structure (part 1)
In a instinctive way I assumed that the set definition would list the strings that are contained in a language but, unlike grammars, it wouldn't define the structure of the parse trees. So it struck me when I realized the values contained in are correlated to the depth of the parse trees those strings would generate.
i | Tree depth |
---|---|
0 | 1 |
1 | 1, 2 |
2 | 1, 2, 3 |
3 | 1, 2, 3, 4 |
4 | 1, 2, 3, 4, 5 |
5 | 1, 2, 3, 4, 5, 6 |
For example, above I (horribly) drawed the tree of if isZero minusOne 1 then 42 else -5
. It has a depth of 3, so the first where it appears is .
Overall contains all the strings with depth lower or equal than .
The reason that each also contains trees of depth is that each contains literals, which have depth 1. So contains elements of depth 2 because contains elements of depth 1 (literals). And therefore also contain elements of depth 3. You get the idea. I'll make an attempt for a formal proof of this in a collapsible below.
So what's the point of all of this? That even though the complete set doesn't contain structure, the different values of have traces of it. And it makes sense, since I defined the sets using the grammar as a base. For example, if instead of:
term:
...
| 'if' term 'then' term 'else' term;
I wrote:
term:
...
| 'if' term 'then' term elseClause;
elseClause: 'else' term;
The set would have a different definition but its value would be the same. The values of would change to reflect the different definition, but the union of all wouldn't change.
But before we can see this, we need to understand how the definition of the set changes when the grammar has multiple rules.
Defining a grammar with multiple rules as a set
The example above, which I stole extracted from Types and Programming Languages, defines the set given a grammar with a single rule.
But grammars usually have more than one. I guess that every grammar could be transformed into a single rule grammar, at the cheap cost of the sanity of the
developer, so that's a way of generating the set
of the language. But that's boring, I prefer to interpret it in a different way: Each rule defines it's own set, and therefore, it's own sublanguage.
Let's use a simple grammar with two rules.
expr:
expr ('*'|'/') expr
| expr ('+'|'-') expr
| ID '(' arguments ')'
| NUMBER, STRING, BOOL;
arguments:
expr (',' arguments)+;
This is a bit tricky because there is recursion. So this recursion is also be reflected in the definition of the sets. will be the set of expressions and the set of arguments:
There are several really important details:
- Here there are two variables, and . This is because I want to separate the length of the arguments set from its depth.
- contains up to 5 arguments in .
- contains up to 2 arguments in .
- I could have used a single variable to denote both width and depth, but that would break the argument that I plan to do on the next section. See the optional section below for more details.
- The definition of depends on , and at the same time depends on . This might look like a circular definition, but
the arguments set depends on the previous level of the expression sets. So it's more like a spiral definition 😎.
- For it to work, must be a subset of . Otherwise there could be values that are valid for the language but aren't included in the set. I added a "proof" of this at the end of this section.
- In the definition of , the is the same as just saying . So is a subset of . I use this syntax to keep it closer to the grammar definition.
- I decided to leave as an empty set, though I guess I could just made , but that would be confusing since the overall rule is that but there's no
And as before, the value of is correlated to the depth of the parse tree. So even though my definition of the sets might be wrong, it doesn't look horribly wrong. I hope...😇
|
foo ( ArgumentsNode ) <----- E4 (& A5) (The value of every j is 2,
| so I'll ignore it)
var ( ArgumentsNode ) <----- E3 (& A4)
|
xz ( ArgumentsNode ) <----- E2 (& A3)
|
yw ( ArgumentsNode ) <----- E1 (& A2)
/ \
3 2 <----- E0 (& A1)
Optional - More details on why I separated i and j
One of the main points I want to make of this post is that, in the same way two grammars can denote the same language but generate parse trees with different structure, two sets can be equal but have different definitions of each change. So even though doesn't contain any structure, the values of each show traces of the structure, even if that's not enough to make a parse tree.
And the basis of this hipothesis is that always changes with depth. Previously I had define like this:
The problem is that the doesn't only affect the values of the arguments it can use (), but also the number of arguments. Therefore a simple call like
foo(1,2,3,4,5,6,7,8)
would be in even though all of the arguments are in . By having the previous call is in and the remains
unaffected by the number of arguments.
In a way the also denotes the structure of the parse tree. By allowing an arbitrary number of arguments in the same it's like if I had define the grammar like this:
arguments:
expr (',' arguments)+;
While correlating the depth and width in the same would be like defining it like this:
arguments:
expr ',' arguments | expr;
In this case the value of is the same as the depth of the tree. That's because unlike the first example, here you can't write a call like foo(3)
till . But in
you could already have plusOne 0
. In case you were wondering, does contains arithmetic calls, like 3 + 2
. But since those don't call the arguments
rule they aren't interesting for this example, so I casually ignored them.
Optional - Are E_i and A_i subsets of E_i+1 and A_i+1?
The current definition of only works in the assumption that . Otherwise, the fact that uses values of would mean that there are values that the set wouldn't accept but that are valid in the grammar.
To check this my first thought was: What happens if I grab a really long nested tree like:
a( ) <----- E8 (& A9) (j = 1 at every level)
|
b( ) <----- E7 (& A8)
|
c( ) <----- E6 (& A7)
|
d( ) <----- E5 (& A6)
|
e( ) <----- E4 (& A5)
|
f( ) <----- E3 (& A4)
|
g( ) <----- E2 (& A3)
|
h( ) <----- E1 (& A2)
|
3 <----- E0 (& A1)
And I try to add another shorter argument i(j(k(4)))
:
i( ) <----- E3 (& A4) (j = 1 at every level)
|
j( ) <----- E2 (& A3)
|
k( ) <----- E1 (& A2)
|
4 <----- E0 (& A1)
Since i(j(k(4)))
is in , adding it as a first argument would create a string that is in the language but not in the set: a(i(j(k(4))), b(c(d(e(f(g(h(3))))))))
,
right? In an unexpected turn of events, no. Even though the first in which i(j(k(4)))
is contained is , since every can access the
literals, it means that it's also defined in :
i( ) <----- E4 (& A4) (j = 1 at every level)
|
j( ) <----- E3 (& A3)
|
k( ) <----- E2 (& A2) Note that in the previous tree the k() was in E1, not in E2
|
4 <----- E0 (& A1 & A2)
And in :
i( ) <----- E5 (& A4) (j = 1 at every level)
|
j( ) <----- E4 (& A3)
|
k( ) <----- E3 (& A2) Now k() is in E3
|
4 <----- E0 (& A1 & A2 & A3)
Even though this isn't a mathematical proof it infers that for every .
I'll try a probably wrong attempt at a formal proof using the depth of the trees. For simplicity I'll ignore the function calls, and the with it. Since is the maximum number of arguments allowed, and the arguments include every possible combination of I think it's decently obvious that (also when I wrote this I still hadn't divided in and and I don't want to rewrite half of the post for the hundredth time).
As a shortcut I'll define td
as a tree depth operation and
Fist let's define how depth increases with every .
Then, if contains all the possible values with depth , then contains all the possible values with depth . And since contains all the elements of depth 1, then contains all the elements of depth 2.
We could keep this going and infer that contains all elements of depth 3, etc. But here's the twist. By definition is a subset of every , and therefore every also contains trees of depth 2:
And by induction, every where also contains all the elements of depth 3.
The overall conclusion is that each contains all the elements from depth 1 to i+1:
And therefore and won't miss any value.
(Yes, I really need to review math notation... and math in general. I fell like I'm making everything up)
Depth and tree structure (part 2)
With this random made up theory, let's go back to how the way you define the set affects the generated parse tree.
term2:
'true'
| 'false'
| '[+-]?[0-9]' //Integer number
| 'plusOne' term
| 'minusOne' term
| 'isZero' term
| 'if' term 'then' term elseClause;
elseClause:
term 'else' term;
I'll call T
the set defined by term2
and E
the set defined by elseClause
. The result is similar
to the sets created by expr
and arguments
, but luckily this one doesn't allow an arbitrary number of arguments, so there is no need for a .
For the input if isZero 1 then 42 else plusOne -5
the generated tree would be:
|
if isZero then 42 elseClause <----- T2
| |
1 else plusOne <----- E2 (because plusOne -5 is in T1)
|
-5 <----- T0
With this new grammar the parse tree has a depth of 3, but with the first one it was 2. And accordingly, this string is in but in .
I guess it makes sense since the induction rule (the definition of / ) is almost identical to the grammar. It's just a different notation.
Still, even though is correlated to the depth of the tree, it doesn't define the specific structure. For example, it can't differenciate between these two:
| |
/ | \ / | \
3 * + * + 1
/ \ / \
2 1 3 2
Both would be in the same but that tells you nothing about which is the correct one.
So after all this long post, it turns out the set definition is not as powerful as the grammar? Not exactly, even though the value of tells you nothing about the structure, you could still take an arbitrary decision based on how the set is defined:
The same way ANTLR4 prioritizes the subrules by their order, I could decide that since I wrote the multipication and division first
the *
has priority. Still, at this point I think I'm rambling too much.
The end
After all this experiments I've realized that defining languages as a set isn't that different as using a grammar. In fact I guess you could write a parser generator that takes these definitions as inputs. Though grammars are way more convenient. And of course there are probably a million things I haven't taken into account.
I guess my next project will be a parser generator from set defintion! Relax, I'm just joking... or am I?