CSC 478 Complete Lecture Notes 2025-2026 Session
CSC 478 Complete Lecture Notes 2025-2026 Session
CONSTRUCTION
Complete Note
PROF. A. O. OGUNDE
2025/2026 SESSION
Recommended Textbook
Compilers: Principles, Techniques and Tools by Alfred V. Aho, Ravi Sethi
and Jeffery D. Ullman.
INTRODUCTION
Compiler construction is a broad field. Compiler writing is perhaps the most
pervasive topic in computer science, involving many fields as:
– Programming languages – Architecture – Theory of computation
– Algorithms – Software engineering
In the early days, much of the effort was on how to implement high-level
constructs. Then, for a long time, the major emphasis was on improving the
efficiency of generated code. These topics remain important today, but many
new technologies have caused them to become more specialized.
Translator
This special software translates other programs into the machine language.
They are in three categories:
No Interpreter Compiler
1 A line in error can be corrected The whole program must be
and run again before moving on compiled again in case there is error
2 Requires more memory space Less memory space is needed for
to store the interpreter when program to run
the program is running
3 Programs run very slow Programs usually run faster
4 Very useful in teaching More useful in production
environment environment
2
More on Compilers
A compiler is a program that reads a program written in one language (the
source language) and translates it into an equivalent program in another
language (the target language). The compiler reports any errors found during
the translation process.
Source Target
program Compiler program
Error
messages
More on Interpreters
A program that reads an executable program and produces the results of
running that program. An interpreter translates code like a compiler but reads
the code and immediately executes on that code, and therefore is initially
faster than a compiler. Thus, interpreters are often used in software
development tools as debugging tools, as they can execute a single in of code
at a time. Compilers translate code all at once and the processor then executes
upon the machine language that the compiler produced. If changes are made
to the code after compilation, the changed code will need to be compiled and
added to the compiled code (or perhaps the entire program will need to be re-
compiled.) But an interpreter, although skipping the step of compilation of the
entire program to start, is much slower to execute than the same program
that’s been completely compiled. Interpreters, however, have usefulness in
3
areas where speed doesn’t matter (e.g., debugging and training) and it is
possible to take the entire interpreter and use it on another ISA, which makes
it more portable than a compiler when working between hardware
architectures. There are several types of interpreters: the syntax-directed
interpreter (i.e., the Abstract Syntax Tree (AST) interpreter), bytecode
interpreter, and threaded interpreter (not to be confused with concurrent
processing threads), Just-in-Time (a kind of hybrid interpreter/compiler), and
a few others. Instructions on how to build an interpreter can be found on the
web. Some examples of programming languages that use interpreters are
Python, Ruby, Perl, and PHP.
More on Assemblers
Assembly language code is more often used with 8-bit processors and
becomes increasingly unwieldy as the processor’s instruction set path
becomes wider (e.g., 16-bit, 32-bit, and 64-bit). It is not impossible for people
to read machine code, the strings of ones and zeros that digital devices
(including processors) use to communicate, but it’s likely only read by people
in cases of computer forensics or brute-force hacking. Assembly language is
the next level up from machine code, and is quite useful in extreme cases of
debugging code to determine exactly what’s going on in a problematic
execution, for instance. Sometimes compilers will “optimize” code in
unforeseen ways that affect outcomes to the bafflement of the developer or
programmer such that it’s necessary to carefully follow the step-by-step action
of the processor in assembly code, much like a hunter tracking prey or a
detective following clue.
Types of Compilers
4
Translates virtual machine code to native code. Operates within a
virtual machine. Example: Sun's HotSpot java machine.
• Preprocessor
Translates source code into simpler or slightly lower level source code,
for compilation by another compiler. Examples: cpp, m4.
• Pure Interpreter
Executes source code on the fly, without generating machine code.
Example: LISP.
5
– INTERPRETERS do not bother to produce target code, but just perform the
operations implied by the source program
6
Phases of a Compiler
The diagram below represents the phases of a compiler.
Source Program
Lexical Analysis
Syntax Analysis
Semantic Analysis
Intermediate Code
Generation
Code Optimization
Code Generation
Target Program
7
used by the compiler to synthesize output. The hierarchical structure of the
source program can be represented by a PARSE TREE, for example:
Assignment statement
=
identifier expression
position +
expression
expression
expression * expression
identifier
rate 60
The hierarchical structure of the syntactic units in a programming language
is normally represented by a set of recursive rules. Example of such rules for
expressions is:
1. Any identifier is an expression
2. Any number is an expression
3. If expression1 and expression2 are expressions, then so are
expression1+expression2
expression1*expression2
(expression1)
Rules (1) and (2) are (nonrecursive) basic rules, while (3) defines expressions
in terms of operators applied to other expressions.
8
Semantic Analysis
The semantic analysis stage:
– Checks for semantic errors, e.g. undeclared variables
– Gathers type information
– Determines the operators and operands of expressions
Example: if rate is a float, the integer literal 60 should be converted to a float
before multiplying. Then, we have
Symbol-table management
A symbol table is a data structure containing a record for each identifier with
fields for the attributes of the identifier. During analysis, we record the
identifiers used in the program. The symbol table stores each identifier with
its attributes. Example of attributes:
– How much storage is allocated for the id – The id’s type– The id’s scope–
For functions, the parameter protocol
Some attributes can be determined immediately; some are delayed until later
phases.
Error detection
Each compilation phase can have errors. Normally, we want to keep
processing after an error, in order to find more errors. Each stage has its own
characteristic errors, e.g.
– Lexical analysis: a string of characters that do not forma legal token
– Syntax analysis: unmatched { } or missing ;
– Semantic: trying to add a float and a pointer
The internal representations of this process is illustrated with our case
example below
9
Intermediate code generation
Some compilers explicitly create an intermediate representation of the source
code program after semantic analysis. The representation is as a program for
an abstract machine. Most common representation is “three-address code” in
which all memory locations are treated as registers, and most instructions
apply an operator to two operand registers, and store the result to a
destination register.
Code optimization
This phase attempts to improve the intermediate code. At this stage, we
improve the code to make it run faster.
Code generation
This is the final phase of the compiler where the target code is generated. In
this final stage, we take the three-address code (3AC)or other intermediate
representation, and convert to the target language. We must pick memory
locations for variables and allocate registers. For example, using registers 1
and 2, the translation of our case sample code might become
MOVF id3, R2
MULF #60.0, R2
MOVF id2, R1
ADDF R2, R1
MOVF R1, id1
i.e.
temp1 := id3 * 60.0
id1 := id2 + temp1
10
Compiler writing tools
Many software tools have been developed to help implement various compiler
phases. The following is a list of some useful compiler construction tools:
Scanner generators produce lexical analyzers automatically.
– Input: a specification of the tokens of a language (usually written
as regular expressions)
– Output: C code to break the source language into tokens.
e.g. Lex
Parser generators produce syntactic analyzers automatically.
– Input: a specification of the language syntax (usually written as a
context-free grammar)
– Output: C code to build the syntax tree from the token sequence.
e.g. Yacc
There are also automated systems for code synthesis.
Assignment
Distinguish between a compiler and an interpreter.
11
LEXICAL ANALYSIS
This is the first phase of a compiler. Its task is to read the input characters
and produce as output a sequence of tokens that the parser uses for syntax
analysis.
Source token
Lexical
Parser
program Analyzer
get next token
Symbol
Table
Usually the token is represented as an integer. The lexer and parser just agree
on which integers are used for each token.
12
Token attributes
If there is more than one lexeme for a token, we have to save additional
information about the token.
Example: the token number matches lexemes 10 and 20.
Code generation needs the actual number, not just the token. With each
token, we associate ATTRIBUTES. Normally just a pointer into the symbol
table. For C source code E=M*C*C
We have token/attribute pairs
<ID, ptr to symbol table entry for E>
<Assign_op, NULL>
<ID, ptr to symbol table entry for M>
<Mult_op, NULL>
<ID, ptr to symbol table entry for C>
<Mult_op, NULL>
<ID, ptr to symbol table entry for C>
Lexical errors
When errors occur, we could just crash but it is better to print an error
message then continue. Possible techniques to continue on error:
– Delete a character
– Insert a missing character
– Replace an incorrect character by a correct character
– Transpose adjacent characters
The three are written in the order of increasing difficulty for the implementer.
Unfortunately, the harder to implement approaches often yield faster lexical
analyzers. Note that the design and use of an automatic generator and some
concepts for the organization of a hand-designed lexical analyzer shall be
discussed here.
Token specification
REGULAR EXPRESSIONS (REs) are the most common notation for pattern
specification. Every pattern specifies a set of strings, so a RE names a set of
strings. Some definitions are stated below:
– The ALPHABET or CHARCATER (often written) is the set of legal input
symbols and denotes any finite set of symbols e.g. letters and characters e.g.
{0,1) is an example of binary alphabet, ASCII and EBCDIC is an example of
computer alphabet
– A STRING over some alphabet is a finite sequence of symbols from e.g.
sentence and word in language theory
13
– The LENGTH of string s is written |s|. It is the number of occurrences of
symbols in s
– The EMPTY STRING is a special 0-length string denoted by
A PREFIX of s is formed by removing 0 or more trailing symbols of s e.g. ban
is a prefix of banana
A SUFFIX of s is formed by removing 0 or more leading symbols of s e.g. nana
is a suffix of banana
A SUBSTRING of s is formed by deleting a prefix and a suffix from s e.g. nan
is a substring of banana
A PROPER prefix, suffix, or substring is a nonempty string x that is,
respectively, a prefix, suffix, or substring of s but with x ≠s.
A SUBSEQUENCE of s is any string formed by deleting zero or more not
necessarily contiguous symbols from s e.g. baaa
A LANGUAGE is a set of strings over a fixed alphabet .Examples are:
– $ (the empty set)
–{"}
– { a, aa, aaa, aaaa }
The CONCATENATION of two strings x and y is written xy
String EXPONENTIATION is written si, where s0 = and si= si-1s for i>0.
Operations on languages
Example:
Let L be the set {A, B..., z, a, b,.., z} and D the set (0, 1, 2, ……9). We can think
of L and D in two ways. We can think of L as the alphabet consisting of the
set of upper and lower case letters, and D as the alphabet consisting of the
set of the ten decimal digits. Alternatively, since a symbol can be regarded as
14
a string of length one, the sets L and D are each finite languages. Here are
some examples of new languages created from L and D by applying the
operators defined above.
5. L(L U D)* is the set of all strings of letters and digits beginning with a
letter
15
(b) (r)(s) is a regu1ar expression denoting L(r)L(s)
(c) (r)* is a regular expression denoting (L(r))* (that is, extra pairs of
parentheses may be placed around regular expressions if we so
desire).
1. the unary operator * has the highest precedence and is left associative
2. The regular expression (a|b)(a|b) denotes {aa, ab, ba, bb), that is, the
set of all strings of a’s and b’s of length two. Another regular expression
for this same set is aa|ab|ba|bb.
3. The regular expression a* denotes the set of all strings of zero or more
a’s, i.e. (, a, aa, aaa, …).
4. The regular expression (a|b)* denotes the set of all strings containing
zero or more instances of an a or b, that is, the set of all strings of a’s
and b’s. Another regular expression for this set is (a*b*)*
5. The regular expression a |a*b denotes the set containing the string a
and all strings consisting of zero or more a’s followed by b.
If two regular expressions r and s denote the same language, we say r and s
are equivalent and write r = s. For example, (a|b) = (b|a).
There are a number of algebraic laws obeyed by regular expressions and these
can be used to manipulate regular expressions into equivalent forms. The
table below shows some algebraic laws that hold for regular expressions r, s.
and t.
16
Regular Definitions
To make our REs simpler, we may wish to give names to regular expressions
and to define regular expressions using these names as If they were symbols.
If S is an alphabet of basic symbols, them a regular definition is a sequence
of definitions of the form
d1→ r1
d2→r2
………
dn→rn
where each di is a distinct name, and each ri is a regular expression over the
symbols in {d1, d2,. . . di-1}, i.e., the basic symbols and the previously
defined names. By restricting each ri to symbols of and the previously
defined names, we can construct a regular expression over for any ri by
repeatedly replacing regular expression names by the expressions they
denote. If ri used dj for some j i, then ri might be recursively defined and this
substitution process would not terminate. To distinguish names from
symbols, we print the names in regular definitions in bold face.
Example:
Here is a regular definition for identifier in Pascal
letter → A | B | . . . | Z | a | b | …… | z
digit → 0|1|...|9
id → letter(letter|digit)*
letter → A | B | . . . | Z | a | b | …… | z
digit → 0|1|...|9
id → letter(letter|___ ) (letter|digit|___)*
17
Example for numbers in Pascal:
digit → 0 | 1 | …….. | 9
digits →digitdigit*
optional_fraction → . digits |
optional_exponent → (E ( + | - | ) digits) |
num →digits optional_fraction optional_exponent
Notational Shorthand
To simplify out REs, we can use a few shortcuts:
1. + “means one or more instances of” e.g. a+ (ab)+
Token Recognition
Having known how to specify the tokens for our language, how then do we
write a program to recognize them?
Consider this grammar example:
stmt→if expr then stmt
| if expr then stmt else stmt
|
expr→term relop term
| term
term→id
| num
where the terminals if, then, else, relop, id and num generates sets of strings
given by the following regular definitions.
if → If
then → Then
else → Else
relop → < | <= | = | <> | > | >=
id → letter(letter | digit)*
num → digit*(.digit*)? (E(+|-)? digit*)?
18
If a match for ws is found, the lexical analyzer does not return a token to the
parser; rather, it proceeds to find a token following the white space and
returns that to the parser. The goal is to construct a lexical analyzer that will
isolate the lexeme for the next token in the input buffer and produce as output
a pair consisting of the appropriate token and attribute-vaIue, using the
translation table given by the table below.
NOTE
Note that the regular expression constructs permitted by Lex are listed below
in decreasing order of precedence. In this table, c stands for any single
character, r for a regular expression, and s for a string.
19
Transition Diagrams
The transition diagram is a stylized flowchart produced as one of the
intermediate steps in the construction of a lexical analyzer. It depicts the
action taken when a lexical analyzer is called by the parser to get the next
token. Transition diagrams are also called finite automata.
Positions in a transition diagram are drawn as circles and are called states.
The states are connected by arrows, called edges. Edges leaving state s have
labels indicating the input characters that can next appear after the transition
diagram has reached state s. The label other refers to any character that is
not indicated by any of the other edges leaving s. Usually, when we recognize
OTHER, we need to put it back in the source stream since it is part of the next
token. This action is denoted with a * next to the corresponding state.
We assume the transition diagrams of this section are deterministic; that is,
no symbol can match the labels of two edges leaving one state. One state is
labeled the start state; it is the initial state of the transition diagram where
control resides when we begin to recognize a token. Certain states may have
actions that are executed when the flow of control reaches that state. On
entering a state we read the next input character. If there is an edge from the
current state whose label matches this input character, we then go to the
state pointed to by the edge. Otherwise, we indicate failure.
The figure below shows a transition diagram for the patterns >= and >. The
transition diagram works as follows, its start state is state 0. In state 0, we
read the next input character. The edge labeled > from state 0 is to be followed
to state 6 if this input character is >. Otherwise, we have failed to recognize
either > or >=.
On reaching state 6 we read the next input character. The edge labeled = from
state 6 is to be followed to state 7 if this input character is an =. Otherwise,
the edge labeled other indicates that we are to go to state 8. The double circle
on state 7 indicates that it is an accepting state, a state in which the token
>=has been found.
Notice that the character > and another extra character are read as we follow
the sequence of edges from the start state to the accepting state 8. Since the
extra character is not a part of the relational operator >, we must retract the
forward pointer one character. We use a * to indicate states on which this
input retraction must take place.
20
beginning pointer. If failure occurs in all transition diagrams, then a lexical
error has been detected and we invoke an error-recovery routine.
The procedure gettoken() similarly looks for the lexeme in the symbol table. If
the lexeme is a keyword, the corresponding token is returned; otherwise, the
token Id is returned. Note that the transition diagram does not change if
additional keywords are to be recognized; we simply initialize the symbol table
with the strings and tokens of the additional keywords. The technique of
placing keywords in the symbol table is almost essential if the lexical analyzer
is coded by hand. Without doing so, the number of states in a lexical analyzer
for a typical programming language is several hundred, while using the trick,
fewer than a hundred states will probably suffice.
21
Transition diagram for unsigned numbers in Pascal
The treatment of ws, representing white space, is different from that of the
patterns discussed above because nothing is returned to the parser when
white space is found in the input. A transition diagram recognizing ws by
itself is shown below:
For each state, in every diagram, a piece of code is written to compare the
next input with the outgoing transitions and set the next state. If no next state
is possible for the given input, we call fail(). fail() backtracks to the beginning
of the token and tries again with the next transition diagram. If all diagrams
fail, we generate an error. Ordering of the diagrams is important. E.g. for
unsigned numbers, the longer diagrams have to be tried first.
22
A LANGUAGE FOR SPECIFYING LEXICAL ANALYZERS
Several tools have been built for constructing lexical analyzers from special-
purpose notations based on regular expressions. We have already seen the
use of regular expressions for specifying token patterns. Before we consider
algorithms for compiling regular expressions into pattern-matching programs
we give an example of a tool that might use such an algorithm.
in this section, we describe a particular tool, called Lex that has been widely
used to specify lexical analyzers for a variety of languages. We refer to the tool
as the Lex compiler, and to its input specification as the Lex Language.
Discussion of an existing tool will allow us to show how the specification of
patterns using regular expressions can be combined with actions, e.g.,
making entries into a symbol table, that a lexical analyzer may be required to
perform. Lex-like specifications can be used even if a compiler is not available;
the specifications can be manually transcribed into a working program using
the transition diagram techniques of the previous section.
Lex is generally used in the manner depicted in the diagram below.
23
First, a specification of a lexical analyzer is prepared by creating a program in
lex.1 in the Lex language. The lex.1 is run through the Lex compiler to produce
a C program [Link].c. The program [Link].c consists of a tabular
representation of a transition diagram constructed from the regular
expressions of lex.1, together with a standard routine that uses the table to
recognize Iexemes. The actions associated with regular expressions in lex.1
are pieces of C code and are carried over directly to [Link].c. Finally, [Link].c
is run through the C compiler to produce an object program [Link], which is
the lexical analyzer that transforms an input stream into a sequence of
tokens.
FINITE AUTOMATA
A recognizer for a language is a program that takes as input a string x and
answers “yes” if x is a sentence of the language and “no” otherwise. Regular
expression is compiled into a recognizer by constructing a generalized
transition diagram called a finite automaton. A finite automaton can be
deterministic or nondeterministic, where nondeterministic means that more
than one transition out of a state may be possible on the same input symbol.
Both deterministic and nondeterministic finite automata are capable of
recognizing precisely the regular sets. Thus they both can recognize exactly
what regular expressions can denote. However, there is a time-space tradeoff;
while deterministic finite automata can lead to faster recognizers than non-
deterministic automata, a deterministic finite automaton can be much bigger
than an equivalent nondeterministic automaton. The conversion into a
nondeterministic automaton is more direct.
24
In a Computer, the transition function of an NFA can be implemented in
several different ways of which easiest implementation is a transition table in
which there is a row for each state and a column for each input symbol and
if necessary. The entry for row i and symbol a in the table is the set of states
(or more likely in practice, a pointer to the set of states) that can be reached
by a transition from state i on input a. The transition table for the NFA above
is shown below:
The transition table representation has the advantage that provides fast
access to the transitions of a given state on a given character; its disadvantage
is that it can take up a lot of space when the input alphabet is large and most
transitions are to the empty set. Adjacency list representations of the
transition function provide more compact implementations, but access to a
given transition is slower. It should be clear that we can easily convert any
one of these implementations of a finite automaton into another.
An NFA accepts an input string x if and only if there is some path in the
transition graph from the start state to some accepting state, such that the
edge labels along this path spell out x. The NFA described above accepts the
input strings abb, aabb, babb, aaabb. For example, aabb is accepted by the
path from 0, following the edge labeled a to state 0 again, then to states I, 2,
and 3 via edges labeled a, b, and b, respectively.
A path can be represented by a sequence of state transitions called moves.
The following diagram shows the moves made in accepting the input string
aabb:
In general, more than one sequence of moves can lead to an accepting stale.
Notice that several other sequences of moves may be made on the input string
aabb but none of the others happen to end in an accepting state. For example,
another sequence of moves on input aabb keeps reentering the non- accepting
state 0:
Exercise:
1. Show that the NFA above accepts (a|b)*abb.
2. Design an NFA to accept aa*|bb*
25
Solution:
A deterministic finite automaton has at most one transition from each state
on any input, if we are using a transition table to represent the transition
function of a DFA, then each entry in the transition table is a single state.
Consequently, it is very easy to determine whether a deterministic finite
automaton accepts an input siring, since there is at most one path from the
start state labeled by that string.
Example below shows the transition graph of a deterministic finite automaton
accepting the same language (a|b)*abb as that accepted by the NFA described
above. It follows the sequence of states 0, 1, 2, 1, 2, 3
Assignment
1. Write Regular Expressions for Identifiers in any other four programming
languages.
26
EXAMPLES OF IDENTIFIERS IN COMMON PROGRAMMING LANGUAGES
IDENTIFIERS IN C++
How to check identifier in C++?
• Names can contain letters, digits and underscores.
• Names must begin with a letter or an underscore (_)
• Names are case-sensitive ( myVar and myvar are different variables)
• Names cannot contain whitespaces or special characters like !, #, %, etc.
Regular Expression
letter → A | B | . . . | Z | a | b | …… | z
digit → 0 | 1 | . . . | 9
id → (letter|___) (letter|digit|___)*
Also written in Lex convention as
^_|A-Z|a-z]+[_|A-Z|a-z|0-9]*$
Example
// C++ program to illustrate the identifiers
#include <iostream>
using namespace std;
int main()
{
// identifiers used as variable names
int studentAge = 20;
double accountBalance = 1000.50;
string student_Name = "Karan";
calculateSum(2, 10);
return 0;
}
Question?
1. How many identifiers are in the above code? Name them.
IDENTIFIERS IN C#
In programming languages, identifiers are used for identification purposes. Or in other words,
identifiers are the user-defined name of the program components. In C#, an identifier can be
a class name, method name, variable name, or label.
27
Example:
public class GFG {
static public void Main ()
{
int x;
}
}
Here the total number of identifiers present in the above example is 3 and the names of these
identifiers are:
GFG: Name of the class
Main: Method name
x: Variable name
Regular Expression
letter → A | B | . . . | Z | a | b | …… | z
digit → 0 | 1 | . . . | 9
id → (letter|___) (letter|digit|___)*
Also written in Lex convention as
^_|A-Z|a-z]+[_|A-Z|a-z|0-9]*$
class GFG {
// Main Method
static public void Main ()
{
// variable
int a = 10;
int b = 39;
int c;
// simple addition
c = a + b;
[Link]("The sum of two number is: {0}", c);
}
}
28
Output:
Below table shows the identifiers and keywords present in the above
example:
Keywords Identifiers
using GFG
public Main
static A
void B
int C
IDENTIFIERS IN PYTHON
Python Identifier is the name we give to identify a variable, function, class, module or other
object. That means whenever we want to give an entity a name, that's called identifier. A
python identifier is a name given to various entities like variables, functions, and classes.
The isidentifier() method returns True if the string is a valid identifier, otherwise False. A
string is considered a valid identifier if it only contains alphanumeric letters (a-z) and (0-9),
or underscores (_). A valid identifier cannot start with a number, or contain any spaces.
In Python, identifiers are case-sensitive, meaning that foo and Foo are considered to be two
different identifiers.
29
Invalid Identifiers in Python
(for, while, in) - these are the keywords in python that cannot use as identifiers in python.
1myname - Invalid identifier because It begins with a digit
\$myname - Invalid identifier because It starts with a special character
a b - Invalid identifier because it contains a blank space
(a/b and a+b) - are Invalid identifiers because contain special character
Regular Expression
letter → A | B | . . . | Z | a | b | …… | z
digit → 0 | 1 | . . . | 9
id → (letter|___) (letter|digit|___)*
Also written in Lex convention as
^_|A-Z|a-z]+[_|A-Z|a-z|0-9]*$
IDENTIFIERS IN JAVA
The valid rules for defining Java identifiers are:
• It must start with either lower case alphabet[a-z] or upper case alphabet [A-Z] or
underscore (_) or a dollar sign($).
• It should be a single word, the white spaces are not allowed.
• It should not start with digits.
Here's a table on valid and invalid identifiers in Java for a quick glance.
30
SYNTAX ANALYSIS
Every programming language has rules that prescribe the syntactic structure
of well-formed programs. In Pascal, for example. a program is made out of
blocks, a block out of statements, a statement out of expressions, an
expression out of tokens. and so on. The syntax of programming language
constructs can be described by context-free grammars or BNF (Backus-Naur
Form) notation. Grammars offer significant advantages to both language
designers and compiler writers.
What is a Grammar?
• A grammar gives a precise, yet easy-to-understand, syntactic specification
of a programming language.
• From certain classes of grammars we can automatically construct an
efficient parser that determines if a source program is syntactically well
formed. As an additional benefit, the parser construction process can reveal
syntactic ambiguities and other difficult-to-parse constructs that might
otherwise go undetected in the initial design phase of a language ad its
compiler.
• A properly designed grammar imparts a structure to a programming
language that is useful for the translation of source programs into correct
object code and for the detection of errors. Tools are available for converting
grammar-based descriptions of translations into working programs.
The parser obtains a string of tokens from the lexical analyzer and verifies
that the string can be generated by the grammar for the source language. We
expect the parser to report any syntax errors in an intelligible fashion. It
should also recover from commonly occurring errors so that it can continue
processing the remainder of its input.
There are three general types of parsers for grammars. Universal parsing
methods such as the Cocke-Younger-Kasami algorithm and Earley’s
algorithm can parse any grammar. These methods, however, are too inefficient
31
to use in production compilers. Commonly used methods in compilers are
classified as being either top-down or bottom-up. As indicated by their names,
top-down parsers build parse trees from the top (root) to the bottom (leaves),
while bottom-up parsers start from the leaves and work up to the root. In both
cases, the input to the parser is scanned from left to right, one symbol at a
time.
The most efficient top-down and bottom-up methods work only on subclasses
of grammars, but several of these subclasses, such as the LL and LR
grammars, are expressive enough to describe most syntactic constructs in
programming languages. Parsers implemented by hand often work with LL
grammars. Parsers for the larger class of LR grammars are usually
constructed by automated tools.
32
Several parsing methods, such as the LL and LR methods, detect an error as
soon as possible. More precisely, they have the viable-prefix property, meaning
they detect that an error has occurred as soon as they see a prefix of the input
that is not a prefix of any string in the language.
Error-Recovery Strategies
There are many different general strategies that a parser can employ to
recover from a syntactic error. Although no one strategy has proven itself to
be universally acceptable, a few methods have broad applicability. E.g.:
• panic mode
• phrase level
• error productions
• global correction
Error productions: If we have a good idea of the common errors that might
be encountered we can augment the grammar for the language at hand with
productions that generate the erroneous constructs. We then use the
grammar augmented by these error productions to construct a parser. If an
error production is used by the parser, we can generate appropriate error
33
diagnostics to indicate the erroneous construct that has been recognized in
the input.
Finally, note that a closest correct program may not be what the programmer
had in mind. Nevertheless, the notion of least-cost correction does provide a
yardstick for evaluating error-recovery techniques, and it has been used for
finding optimal replacement strings for phrase-level recovery.
1. Terminals are the basic symbols from which strings are formed. The term
"token name" is a synonym for '"terminal" and frequently we will use the word
"token" for terminal when it is clear that we are talking about just the token
name. We assume that the terminals are the first components of the tokens
output by the lexical analyzer. In the statement example above, the terminals
are the keywords if and else and the symbols "(" and ")".
34
by nonterminals help define the language generated by the grammar.
Nonterminals impose a hierarchical structure on the language that is key to
syntax analysis and translation.
EXAMPLE
1. Consider the grammar below for arithmetic expressions, identify the
terminals, nonterminals and the start symbol in the grammar.
Solution
In this grammar, the terminal symbols are:
NOTATIONAL CONVENTIONS
To avoid always having to state that "these are the terminals," "these are the
nonterminals ," and so on, the following notational conventions for grammars
are used in most texts.
35
1. These symbols are used to represent terminals:
(a) Lowercase letters early in the alphabet, such as a, b, c.
(b) Operator symbols such as +, *, and so on.
(c) Punctuation symbols such as parentheses, comma, and so on.
(d) The digits 0,1,. . . ,9.
(e) Boldface strings such as id or if, each of which represents a single terminal
symbol.
7. Unless stated otherwise, the head of the first production is the start symbol.
Example: Using the above conventions, the grammar in the example above
can be rewritten concisely as
E → E+T | E-T | T
T → T*F | T/F | F
F → (E) | id
The notational conventions tell us that E, T, and F are nonterminals, with E
the start symbol. The remaining symbols are terminals.
36
DERIVATIONS
The construction of a parse tree can be made precise by taking a derivational
view, in which productions are treated as rewriting rules. Beginning with the
start symbol, each rewriting step replaces a nonterminal by the body of one
of its productions. This derivational view corresponds to the top-down
construction of a parse tree, but the precision afforded by derivations will be
especially helpful when bottom-up parsing is discussed. As we shall see,
bottom-up parsing is related to a class of derivations known as “rightmost"
derivations, in which the rightmost nonterminal is rewritten at each step.
For example, consider the following grammar, with a single nonterminal E,
E → E + E | E * E | -E | ( E ) | id
∗
If S → , where S is the start symbol of a grammar G, we say that is a
sentential form of G. Note that a sentential form may contain both terminals
and nonterminals, and may be empty.
37
Thus, a string of terminals w is in L(G), the language generated by G, if and
∗
only if w is a sentence of G (or S → w).
The string - (id + id) is a sentence of the above grammar because there is a
derivation
E →-E →-(E) → -(E + E) → -(id + E) → -(id + id)
Note that the strings -E, -(E), ……… -(id + id) are all sentential forms of this
grammar.
∗
We write E → -(id + id) to indicate that -(id + id) can be derived from E.
Each nonterminal is replaced by the same body in the two derivations, but
the order of replacements is different. To understand how parsers work, we
shall consider derivations in which the nonterminal to be replaced at each
step is chosen as follows:
1. In leftmost derivations, the leftmost nonterminal in each sentential form
is always chosen. If → β is a step in which the leftmost nonterminal in
is replaced, we write → 𝛽.
𝑙𝑚
2. In rightmost derivations, the rightmost nonterminal is always chosen;
we write → 𝛽 in this case.
𝑟𝑚
Therefore, the first derivation above, which is leftmost, can be rewritten as
E → -E →-(E) → -(E + E) → -(id + E) → -(id + id)
𝑙𝑚 𝑙𝑚 𝑙𝑚 𝑙𝑚
38
node is labelled with the nonterminal A in the head of the production; the
children of the node are labelled, from left to right, by the symbols in the body
of the production by which this A was replaced during the derivation.
For example, the parse tree for -(id + id) resulting from the above derivations
is shown below.
Note that the leaves of a parse tree are labelled by nonterminals or terminals
and, read from left to right, constitute a sentential form, called the yield or
frontier of the tree.
To see the relationship between derivations and parse trees, consider any
derivation 1 → 2 → 3 → ……… n, where 1 is a single nonterminal A. For
each sentential form i in the derivation, we can construct a parse tree whose
yield is i. The process is an induction on i.
Note that a parse tree ignores variations in the order in which symbols in
sentential forms are replaced, and there is a many-to-one relationship
between derivations and parse trees. For example, both the leftmost and
rightmost derivations above are associated with the same parse tree. The
figure below shows the sequence of parse trees for the leftmost derivation
above.
39
In what follows, we shall frequently parse by producing a leftmost or a
rightmost derivation, since there is a one-to-one relationship between parse
trees and either leftmost or rightmost derivations. Both leftmost and
rightmost derivations pick a particular order for replacing symbols in
sentential forms, so they too filter out variations in the order. It is not hard to
show that every parse tree has associated with it a unique leftmost and a
unique rightmost derivation.
Ambiguity
A grammar that produces more than one parse tree for some sentence is said
to be ambiguous. Put another way, an ambiguous grammar produces more
than one leftmost derivation or more than one rightmost derivation for the
same sentence.
Note that the parse tree in (a) above reflects the commonly assumed
precedence of + and *, while the tree of (b) does not. That is, it is customary
to treat operator * as having higher precedence than +, corresponding to the
fact that we would normally evaluate an expression like a + b * c as a + (b *
c), rather than as (a + b) * c.
40
For most parsers, it is desirable that the grammar be made unambiguous, for
if it is not, we cannot uniquely determine which parse tree to select for a
sentence. In other cases, it is convenient to use carefully chosen ambiguous
grammars, together with disambiguating rules that “throw away" undesirable
parse trees, leaving only one tree for each sentence.
Eliminating Ambiguity
Sometimes an ambiguous grammar can be rewritten to eliminate the
ambiguity. As an example, we shall eliminate the ambiguity from the following
“dangling-else" grammar:
stmt → if expr then stmt
| If expr then stmt else stmt
| other
Here “other" stands for any other statement.
According to this grammar, the compound conditional statement
if E1 then S1 else if E2 then S2 else S3
below has the parse tree shown below.
Also, note that the above grammar is ambiguous since the string
if E1 then if E2 then S1 else S2
has the two parse trees below.
41
In all programming languages with conditional statements of this form, the
first parse tree is preferred. The general rule is, “Match each else with the
closest unmatched then." This disambiguating rule can theoretically be
incorporated directly into a grammar, but in practice it is rarely built into the
productions.
Example
We can rewrite the dangling-else grammar above as the following
unambiguous grammar.
The idea is that a statement appearing between a then and an else must be
“matched"; that is, the interior statement must not end with an unmatched
or open then. A matched statement is either an if-then-else statement
containing no open statements or it is any other kind of unconditional
statement. Thus, we may use the unambiguous grammar above. This
grammar generates the same strings as the dangling-else grammar, but it
allows only one parsing for string if E1 then if E2 then S1 else S2; namely, the
one that associates each else with the closest previous unmatched then.
Exercise
Consider the context-free grammar: S → S S + | S S * | a and the string aa
+ a*.
a) Give a leftmost derivation for the string.
b) Give a rightmost derivation for the string.
c) Give a parse tree for the string.
d) Is the grammar ambiguous or unambiguous? Justify your answer.
e) Describe the language generated by this grammar.
42
Writing a Grammar
Grammars are capable of describing most, but not all, of the syntax of
programming languages. For instance, the requirement that identifiers be
declared before they are used, cannot be described by a context-free grammar.
Therefore, the sequences of tokens accepted by a parser form a superset of
the programming language; subsequent phases of the compiler must analyze
the output of the parser to ensure compliance with rules that are not checked
by the parser.
There are no firm guidelines as to what to put into the lexical rules, as opposed
to the syntactic rules. Regular expressions are most useful for describing the
structure of constructs such as identifiers, constants, keywords, and white
space. Grammars, on the other hand, are most useful for describing nested
structures such as balanced parentheses, matching begin-end's,
corresponding if-then-else's, and so on. These nested structures cannot be
described by regular expressions.
43
BASIC PARSING TECHNIQUES
The previous section showed how a context-free grammar can be used to
define the syntax of a programming language. This section shows how to
check whether an input string is a sentence of a given grammar and how to
construct, if desired, a parse tree for the string. As every compiler performs
some type of syntax analysis, usually after lexical analysis, the input to a
parser is typically a sequence of tokens. The output of the parser can be of
many different forms. This note assumes for simplicity that the output is some
representation of the parse tree.
The primary advantage of these methods is that they are easy to implement
by hand. But there are some drawbacks as well. Operator precedence has the
curious property that if one is not careful, one can recognize inputs that are
not in the language of the underlying grammar. Likewise, recursive descent,
particularly when augmented with backtracking, can produce further
unexpected results.
Fortunately, there are two newer methods that are more general than the older
methods and more firmly grounded in grammar theory. Moreover, with the
proper tools (parser generators) the newer methods are easier to use than the
more classical approaches. The first of these methods, LL parsing, will be
mentioned in this section, as it is really a table-based variant of recursive
descent. The second method, LR, is the subject of the next section.
Parsers
A parser for grammar G is a program that takes as input a string w and
produces as output either a parse tree for w, if w is a sentence of G or an
error message indicating that w is not a sentence of G. Often the parse tree is
produced in only a figurative sense, in reality, the parse tree exists only as a
sequence of actions made by stepping through the tree construction process.
This section discusses the operation of two basic types of parsers for context-
free-free grammars: bottom-up and top-down. As indicated by their names,
bottom-up parsers build parse trees from the bottom (leaves) to the top (root),
while top-down parsers start with the root and work down to the lcaves. In
both cases, the input to the parser is being scanned from left to right, one
symbol at a time.
44
The bottom-up parsing method discussed here is called "shift-reduce" parsing
because it consists of shifting input symbols onto a stack until the right side
of a production appears on top of the stack. The right side may then be
replaced by (reduced to) the symbol on the left side of the production, and the
process repeated.
Unfortunately, if A → XYZ is a production, then not every time that XYZ is on
top of the stack is it correct to reduce XYZ to A; there may be occasions where
it is necessary to continue to shift input symbols on top of XYZ.
Designing an algorithm from a grammar so that shift-reduce decisions are
made properly is the fundamental problem of bottom-up parser construction.
In the next section, we show how to construct one kind of shift-reduce parser
called an operator-precedence parser. A more general type of shift-reduce
parser will also be discussed later called LR parsers. The top-down parsing
method we discuss is called recursive descent parsing. Also, how to construct
a tabularized form of recursive descent parser called a predictive parser will
be shown. A discussion of LL parsers, a special kind of predictive parser will
also be presented.
Here, i, t, e stands for if, then and else, C and S stand for “condition” and
“statement”. A leftmost derivation for the sentence w = i b t i b t a e a is shown
below.
S→ iCtS
𝑙𝑚
→ ibtS
𝑙𝑚
→ ibtiCtSeS
𝑙𝑚
→ ibtibtSeS
𝑙𝑚
→ ibtiCtaeS
𝑙𝑚
→ ibtiCtaea
𝑙𝑚
45
The corresponding parse tree is also shown below.
Shift-Reduce Parsing
This parsing method is bottom-up because it attempts to construct a parse
tree for an input string beginning at the leaves (the bottom) and working up
towards the root (the top). We can think of this process as one of reducing a
string w to the start symbol of a grammar. At each step, a string matching
the right side of a production is replaced by the symbol on the left.
For example., consider the grammar
S → aAcBe
A → Ab | b
B→d
and the string abbcde. We want to reduce this string to S. We scan abbcde
looking for substrings that match the right side of some production. The
substrings b and d qualify. Let us choose the leftmost b and replace with A,
the left side of the production A → b. We obtain the string aAbcde. We now
find that Ab, b, and d each match the right side of some production. Suppose
this time we choose to replace the substring Ab by A, the left-side of the
production A → Ab. We now obtain aAcde. Then replacing d by the left side of
the production B →d, we obtain aAcBe. We can go on and replace the entire
string S.
Each replacement of the right side of a production by the left side in the
process above is called a reduction. Thus, by a sequence of four reductions we
were able to reduce abbcde to S. These reductions, in fact figure out a
rightmost derivation in reverse.
Informally, if we consider a substring which is the right side of a production,
such replacement of that substring by the production on the left side leads
eventually to a reduction to the start symbol, by the reverse of a rightmost
derivation called a "handle." The process of bottom-up parsing may be viewed
as that of finding and reducing handles.
Handles
A handle of a right-sentential form is a production A → β and a position of
where the string β may be found and replaced by A to produce a previous
right-sentential form in a rightmost derivation of . That is,
46
If S → 𝐴𝑤 → 𝛽𝑤 , then A → β in the position following is a handle of 𝛽𝑤.
𝑟𝑚 𝑟𝑚
The string w to the right of the handle contains only terminal symbols.
In the example above, abbcde is a right-sentential form whose handle is A →
β at position 2. Likewise, aAbcde is a right-sentential form whose handle is A
→ Ab at position 2.
Sometimes we shall say “the substring β is a handle of βw” if the position of
β and the production A → β we have in mind are clear. If a grammar is
unambiguous, then every right-sentential form of the grammar has exactly
one handle.
Consider the grammar above and the example that followed and the input
string idl + id2 + id3. The following sequence of reductions reduces idl + id2 +
id3 to the start symbol E.
Observe that the sequence of right-sentential forms in this example is just the
reverse of the sequence in the rightmost derivation in the upper example.
Again, recall that there is another rightmost derivation in which idl + id2
comes from a single E, and this rightmost derivation implies a different
sequence of handles.
47
Stack Implementation of Shift-Reduce Parsing
There are two problems that must be solved if we are to automate parsing by
handle pruning. The first is how to locate a handle in a right-sentential form
and the second is what production to choose in case there is more than one
production with the same right side. Before we get to these questions, let us
first consider the type of data structures to use in a handle-pruning parser. A
convenient way to implement a shift-reduce parser is to use a stack and an
input buffer. We shall use $ to mark the bottom of the stack and the right
end of the input.
Stack Input
$ w$
The parser operates by shifting zero or more input symbols onto the stack
until a handle β is on top of the stack. The parser then reduces β to the left
side of the appropriate production. The parser repeats this cycle until it has
detected an error or until the stack contains the start symbol and the input
is empty.
Stack Input
$S $
48
Example:
Let us step through the actions a shift-reduce parser might make in parsing
the input string idl + id2 + id3; according to grarmmar
(1) E → E+E
(2) E → E * E
(3) E → (E)
(4) E → id
using the derivation that followed the grammar above.
Note that because the grammar used has two rightmost derivations for this
Input, there is another sequence of steps a shift-reduce parser could take.
49
OPERATOR PRECEDENCE PARSING TECHNIQUE
For a certain small class of grammars, we can easily construct efficient shift-
reduce parsers by hand. These grammars have the property (among other
essential requirements) that no production right side is empty or has two
adjacent nonterminals. A grammar with the latter property is called an
operator grammar.
This grammar is not an operator grammar, because the right side EAE has
two (in fact three) consecutive nonterminals. However, if we substitute for A
using each of its alternates, we obtain the following operator grammar:
E → E+E| E-E| E*E| E/E| E^E| (E) | -E| id
precedence over” b.
50
Please note that while these relations may appear similar to the arithmetic
relations “less than”, "equal to” and “greater than”, the precedence relations
have quite different properties. For example, we could have a <. b and a .> b
for the same language, or we might have none of a <. b, a =. b, and a .> b
holding for some terminals a and b.
There are two common ways of determining what precedence should hold
between a pair of terminals. The first method presented here is intuitive and
is based on the traditional notions of associativity and precedence of
operators. For example, if * is to have higher precedence than +, we make + <.
* and * .> +. This approach will be seen to resolve the ambiguities of the
expression grammar discussed in the above section and to enable us to write
an operator precedence parser for it (although the unary minus sign causes
problems).
Operator-Precedence Relations
The intention of the precedence relations is to delimit the handle of a
sentential form, with <. marking the left end, =. appearing in the middle of
the handle (if any) and .> marking the right end. To be more precise, suppose
we have a right-sentential form of an operator grammar, the fact that no
adjacent nonterminals appear on the right sides of productions implies that
no right-sentential form will have two adjacent nonterminals either. Thus, we
may write the right-sentential form as a1β1, ……………………., amβn, where
each β, is either (the empty string) or a single nonterminal, and each a, is a
single terminal. Suppose that between ai and ai+1, exactly one of the relations
<., =. and .> holds. Further, $ marks each end of the string, and we define $
<. b and b .> $ for all terminals b.
Now suppose we remove the nonterminals from the string and place the
correct relation, <., =. , or .> between each pair of terminals and between the
endmost terminals and the $'s marking the ends of the string. For example,
51
suppose we initially have the right-sentential form id + id * id and the
precedence relations are given below,
Id + * $
id .> .> .>
1. Scan the string from the left end until the leftmost .> is encountered. In
the example above, this occurs between the first id and +.
2. Then scan backwards (to the left) over any = 's until a < is
. .
Since the nonterminals do not influence the parse, we need not worry about
distinguishing among them. A single marker “nonterminal" can be kept on the
stack of a shift-reduce parser to indicate placeholders for nodes of the parse
tree being constructed or to indicate a variety of possible translations.
It may appear from the discussion above that the entire right-sentential form
must be scanned at each step to find the handle. Such is not the case if the
precedence relations are used to guide the actions of a shift-reduce parser. If
52
the precedence relation <. or =. holds between the topmost terminal symbol
on the stack and the next input symbol, the parser shifts. The parser has not
yet found the right end of the handle. If the relation .> holds, a reduction is
called for. Now the parser has found the right end of the handle, and the
precedence relations can be used to find the left-end of the handle in the
stack. If no precedence relation holds between a pair of terminals (indicated
by a blank entry in the table above), then a syntactic error has been detected
and an error recovery routine is invoked.
Rules
1. If operator 1, has higher precedence than operator 2, make 1.> 2 and
2 <. 1. For example, if * has higher precedence than +, make * .> + and
+ <. *. These relations ensure that, in an expression of the form
E+E*E+E, the central E*E is the handle that will be reduced first.
3. Make <. id, id .> , <. (, ( <. , ) .> , .> ), .> $ and $ <. for all
operators . Also, the following rules hold.
( =. ) $ <. ( $ <. id
( <. ( id .> $ ) .> $
( <. id id .> ) ) .> )
These rules ensure that id will be reduced to E wherever found and (E) will be
reduced to E wherever found. Also, $ will serve as both left and right
endmarker, causing handles to be found between $'s wherever possible.
Using the above rules, the operator precedence relations for the grammar
E → E+E| E-E| E*E| E/E| E^E| (E) | -E| id
is as follows.
53
Assuming,
1. ^ is of highest precedence and right-associative,
2. * and / are of next highest precedence and left-associative, and
3. + and - are of lowest precedence and left-associative,
+ - * / ^ id ( ) $
+ .> .> <. <. <. <. <. .> .>
Note that in the table, blanks denote error entries. Also there could be some
problems associated with the treatment of the unary minus.
Exercise
Using the grammar for arithmetic expressions below,
E → E+E| E-E| E*E| E/E| E^E| (E) | -E| id
show the processing of the input id * (id ^ id) – id/id.
54
TOP-DOWN PARSING
In this section, we first discuss a general form of top-down parsing that may
involve backtracking, that is, making repeated scans of the input. After that,
we discuss a special case, called recursive-descent parsing, which eliminates
the need for backtracking over the input.
To construct a parse tree for this sentence top-down, we initially create a tree
consisting of a single node labelled S. An input pointer points to c, the first
symbol of w. We then use the first production for S to expand
the tree and obtain the tree below.
The leftmost leaf, labelled c, matches the first symbol of w. So we now advance
the input pointer to a, the second symbol of w, and consider the next leaf,
labelled A. We can then expand A using the first alternate for A to obtain the
tree below.
We now consider d, the third input symbol, and the next leaf, labelled b. Since
b does not match d, we report failure and go back to A to see whether there
is another alternate for A that we have not tried but which might produce a
match. In going back to A we must reset the input pointer to position 2, the
position it had when we first came to A. We now try the second alternate for
A to obtain the tree below.
55
The leaf a matches the second symbol of w and the leaf d matches the third
symbol. Since we have now produced a parse tree for w, we halt and announce
successful completion of parsing.
56
compensate somewhat for the lack of backtracking by using the next
input symbol to guide the parsing actions.
iii. The order in which the alternates are tried can affect the language
accepted.
iv. When failure is reported, we have very little idea where the error
actually occurred. In the form given here, a top-down parser with
backtrack simply returns failure no matter what the error is.
without changing the strings derivable from A. This rule by itself suffices for
many grammars.
Example
Consider the following grammar
E→E+T|T
T→T*F|F
F → (E) | id
Eliminating the immediate left recursion, we obtain,
E → TE
E → +T E |
T → FT
T → * F T |
F → (E) | id
57
A → β1A | β2A | …………… |βnA
A → 1A |2A | …………… |mA |
The nonterminal A generates the same strings as before but is no longer left
recursive. This procedure eliminates all left recursions from the A and A
productions (provided no i is ), but it does not eliminate left recursion
involving derivations of two or more steps.
For example, consider the grammar below
S → Aa | b
A → Ac |Sd |
The nonterminal S is left recursive because S → Aa → Sda, but it is not
immediately left recursive.
Example
Let us apply the Algorithm above to the grammar above it. Technically, the
algorithm is not guaranteed to work, because of the -production, but in this
case, the production A → turns out to be harmless.
We order the nonterminals S, A. There is no immediate left recursion among
the S-productions, so nothing happens during the outer loop for i = 1. For i =
2, we substitute for S in A → Sd to obtain the following A-productions.
58
A → Ac |Aad |bd |
Eliminating the immediate left recursion among these A-productions yields
the following grammar.
S →A a | b
A → b d A
A → c A | a d A |
Recursive-Descent Parsing
In many practical cases, a top-down parser needs no backtrack. In order that
no backtracking be required, we must know, given the current input symbol
a and the nonterminal A to be expanded, which one of the alternates of a
production A → 1 |2 | …………… |n is the unique alternate that derives a
string beginning with a. That is, the proper alternate is detectable by looking
at only the first symbol it derives. For example, control structures with their
distinguishing keywords, are detectable in this way. Suppose we have
productions,
Statement → if condition then statement else statement
| while condition do statement
| begin statement-list end
Then the keywords if, while, and begin tell us which alternate is the only one
that could possibly succeed if we are to find a statement. One nuance
concerns the empty string. If one alternate for A is , and none of the other
alternates derives a string beginning with a, then on input a, we may expand
A by A → , that is, we succeed without further ado in recognizing an A. A
parser that uses a set of recursive procedures to recognize its input with
no backtracking is called a recursive-descent parser. The recursive
procedures can be quite easy to write and fairly efficient if written in a
language that implements procedure calls efficiently.
Left-Factoring
Often the grammar one writes down is not suitable for recursive-descent
parsing, even if there is no left-recursion. For example, if we have the
productions
Statement → if condition then statement else statement
| if condition then statement
59
we could not, on seeing the input symbol if., tell which to choose to expand
statement. A useful method for manipulating grammars into a form suitable
for recursive-descent parsing is left-factoring, the process of factoring out the
common prefixes of alternates.
If A → β| are two A-productions, and the input begins with a nonempty
string derived from , we do not know whether to expand A to β or to . We
may defer the decision by expanding A to A. Then, after seeing the input
derived from a, we expand A to β or to . That is, left-factored, the original
productions become
A → A
A → β |
Example
Consider again the grammar below, which abstracts the dangling else
problem. Left-factor this grammar.
Solution
S → iCtSS | a
S → eS |
C→b
Thus, we may expand S to iCtSS on input i, and wait until iCtS has been
seen to decide whether to expand S to eS or to .
PREDICTIVE PARSERS
A predictive parser is an efficient way of implementing recursive-descent
parsing by handling the stack of activation records explicitly. We can picture
a predictive parser as we have in the Figure below.
60
The predictive parser has an input, a stack, a parsing table, and an output.
The input contains the string to be parsed, followed by $, the right endmarker.
The stack contains a sequence of grammar symbols, preceded by $, the
bottom-of-stack marker. Initially, the stack contains the start symbol of the
grammar preceded by $. The parsing table is a two-dimensional array M[A, al,
where A is a nonterminal, and a is a terminal or the symbol $. The parser is
controlled by a program that behaves as follows.
The program determines X, the symbol on top of the stack, and a, the current
input symbol. These two symbols determine the action of the parser.
There are three possibilities.
2. If X = a ≠ $, the parser pops X off the stack and advances the input
pointer to the next input symbol.
Stack Input
$S w$
where S is the start symbol of the grammar and w is the string to be parsed.
The program that utilizes the predictive parsing table to produce a parse is
shown in the algorithm below.
61
Example
Consider grammar the grammar below
E → TE
E → +T E |
T → FT
T → * F T |
F → (E) | id
Note from the table that blanks are error entries. Also, note that we have not
yet indicated how these entries could be selected. but we shall do so shortly.
With input id + id * id, the predictive parser would make the sequence of
moves below.
62
If we observe the actions of this parser carefully, we see that it is tracing out
a leftmost derivation for the input. The productions’ outputs are those of a
leftmost derivation. The input symbols that have already been scanned,
followed by the grammar symbols on the stack (from top to bottom), make up
the left-sentential forms in the derivation.
63
For a preview of how FIRST can be used during predictive parsing, consider
two A-productions A → | β, where FIRST() and FIRST(β ) are disjoint sets.
We can then choose between these A-productions by looking at the next input
symbol a, since a can be in at most one of FIRST() and FIRST(β), not both.
For instance, if a is in FIRST(β), then choose the production A → β.
64
Now, we can compute FIRST for any string X1X2 …………. Xn as follows. Add
to FIRST(X1X2 …………. Xn) all non- symbols of FIRST(X1). Also add the non-
symbols of FIRST(X2), if is in FIRST(X1); the non- symbols of FIRST(X3), if
is in FIRST(X1) and FIRST(X2), and so on. Finally, add to FIRST(X1X2 … Xn)
if, for all i, is in FIRST(Xi).
Example
Consider again the non-left-recursive grammar below. Compute the FIRST
and FOLLOW sets.
E → TE
E → +T E |
T → FT
T → * F T |
F → (E) | id
Solution
FIRST(F) = FIRST(T) = FIRST(E) = { (. id }
FIRST(E) = { +, }
FIRST(T) = { *, }
FOLLOW(E) = FOLLOW(E) = { ), $ }
FOLLOW(T) = FOLLOW(T) = { +, ), $ }
FOLLOW(F) = { +, *, ), $ }
65
2. FIRST(E) = { +, }.
The reason is that one of the two productions for E has a body that begins
with terminal +, and the other's body is . Whenever a nonterminal derives ,
we place in FIRST for that nonterminal.
3. FIRST(T) = { *, }.
The reasoning is analogous to that for FIRST(E).
4. FOLLOW(E) = FOLLOW(E) = { ), $ }.
Since E is the start symbol, FOLLOW(E) must contain $. The production body
( E ) explains why the right parenthesis is in FOLLOW(E). For E, note that
this nonterminal appears only at the ends of bodies of E-productions. Thus,
FOLLOW(E) must be the same as FOLLOW(E).
5. FOLLOW(T) = FOLLOW(T) = { +, ), $ }.
Notice that T appears in bodies only followed by E. Thus, everything except
that is in FIRST(E) must be in FOLLOW(T); that explains the symbol +.
∗
However, since FIRST(E) contains (i.e., E → ), and E is the entire string
following T in the bodies of the E-productions, everything in FOLLOW(E) must
also be in FOLLOW(T). That explains the symbols $ and the right parenthesis.
As for T, since it appears only at the ends of the T-productions, it must be
that FOLLOW(T) = FOLLOW(T).
6. FOLLOW(F) = { +, *, ), $ }.
The reasoning is analogous to that for T in point (5).
If, after performing the above, there is no production at all in M[A, a], then set
M[A, a] to error (which we normally represent by an empty entry in the table).
66
The idea behind the algorithm is simple.
Suppose A→ is a production with a in FIRST(). Then, whenever the parser
has A on top of the stack with a as the current input symbol, the parser will
∗
expand A by a. The only complication occurs when = or → . In this case,
we should also expand A by if the current input symbol is in FOLLOW(A),
or if the $ on the input has been reached and $ is in FOLLOW(A).
Example
For the non-recursive expression grammar below.
E → TE
E → +T E |
T → FT
T → * F T |
F → (E) | id
The above algorithm produces the parsing table below. Blanks are error
entries; nonblanks indicate a production with which to expand a nonterminal.
LL(1) Grammars
Predictive parsers, that is, recursive-descent parsers needing no
backtracking, can be constructed for a class of grammars called LL(1). The
first “L" in LL(1) stands for scanning the input from left to right, the second
“L" for producing a leftmost derivation, and the “1" for using one input symbol
of lookahead at each step to make parsing action decisions.
67
table M. For every LL(1) grammar, each parsing-table entry uniquely identifies
a production or signals an error. For some grammars, however, M may have
some entries that are multiply defined. For example, if G is left-recursive or
ambiguous, then M will have at least one multiply defined entry. Although
left-recursion elimination and left factoring are easy to do, there are some
grammars for which no amount of alteration will produce an LL(1) grammar.
Example
For instance, the language in the following example has no LL(1) grammar at
all.
S → iEtSS | a
S → eS |
E→b
EXERCISE
1. The following is an LL(1) grammar for regular expressions over alphabet
{a, b}, with + standing for the union operator ( | ) and with € for the
symbol .
E → TE
E → +E |
T → FT
T → T |
F → PF
F → *F |
P → (E) | a | b |
68
a) Compute the FIRST and the FOLLOW sets for each nonterminal of the
above grammar.
b) Show that the grammar is LL(1).
c) Construct the predictive parsing table for the grammar.
d) Construct a recursive-descent parser for the grammar.
BOTTOM-UP PARSERS
A bottom-up parse corresponds to the construction of a parse tree for an input
string beginning at the leaves (the bottom) and working up towards the root
(the top). It is convenient to describe parsing as the process of building parse
trees, although a front end may in fact carry out a translation directly without
building an explicit tree. The sequence of tree snapshots in the figure below
illustrates a bottom-up parse of the token stream id * id, with respect to the
expression grammar below.
ABOUT LR PARSERS
This section shows how to construct efficient bottom-up parsers for a large
class of context-free grammars. These parsers are called LR parsers because
they scan the input from left to right and construct a rightmost derivation in
reverse. LR parsers are attractive for a variety of reasons. Some of them are
listed as follows.
• LR parsers can be constructed to recognize virtually all programming-
language constructs for which context-free grammars can be written.
• The LR parsing method is more general than operator precedence or
other common shift-reduce techniques discussed in the last section.
69
Yet, it can be implemented with the same degree of efficiency as these
different methods. LR parsing also dominates the common forms of top-
down parsing without backtracking.
• LR parsers can detect syntactic errors as soon as it is possible to do so
on a left-to-right scan of the input.
The principal drawback of the LR method is that there is too much work to be
done to implement an LR parser by hand for a typical programming language
grammar. One needs a specialized tool- an LR parser generator. Fortunately,
many such generators are available. With such a generator, one can write a
context-free grammar and have the generator automatically produce a parser
for that grammar. If the grammar contains ambiguities or other constructs
that are difficult to parse in a left-to-right scan of the input, then the parser
generator can locate these constructs and inform the compiler designer of
their presence.
70
As the driver routine is simple to implement, we shall often consider the LR
parser-construction process as one of producing the parsing table for a given
grammar.
There are many different parsing tables that can be used in an LR parser for
a given grammar. Some parsing tables may detect errors sooner than others,
but they all accept the same sentences, exactly the sentences generated by
the grammar. In this section, we shall give three different techniques for
producing LR parsing tables but only one will be fully explained here.
The first method called simple LR (SLR for short), is the easiest to implement.
Unfortunately, it may fail to produce a table for certain grammars on which
the other methods succeed.
The second method, called canonical LR, is the most powerful and will work
on a very large class of grammars. Unfortunately, the canonical LR method
can be very expensive to implement.
71
The parser consists of an input, an output, a stack, a driver program, and a
parsing table that has two parts (ACTION and GOTO). The driver program is
the same for all LR parsers; only the parsing table changes from one parser
to another. The parsing program reads characters from an input buffer one at
a time. Where a shift-reduce parser would shift a symbol, an LR parser shifts
a state. Each state summarizes the information contained in the stack below
it.
The stack holds a sequence of states, s0s1 …………… sm, where sm is on top. In
the SLR method, the stack holds states from the LR(0) automaton; the
canonical-LR and LALR methods are similar. By construction, each state has
a corresponding grammar symbol. Recall that states correspond to sets of
items and that there is a transition from state i to state j if GOTO(Ii, X) = Ij. All
transitions to state j must be for the same grammar symbol X. Thus, each
state, except the start state 0, has a unique grammar symbol associated with
it.
d. Error. The parser discovers an error in its input and takes some
corrective action.
LR-Parser Configurations
To describe the behaviour of an LR parser, it helps to have a notation
representing the complete state of the parser: its stack and the remaining
input. A configuration of an LR parser is a pair:
(s0s1 …………… sm, ai ai+1 ………. an$)
72
where the first component is the stack contents (top on the right), and the
second component is the remaining input. This configuration represents the
right-sentential form
X1X2 ………….. Xm ai ai+1 ………… an
in essentially the same way as a shift-reduce parser would; the only difference
is that instead of grammar symbols, the stack holds states from which
grammar symbols can be recovered. That is, Xi is the grammar symbol
represented by state si. Note that s0, the start state of the parser, does not
represent a grammar symbol, and serves as a bottom-of-stack marker, as well
as playing an important role in the parse.
1. If ACTION[sm, ai] = shift s, the parser executes a shift move; it shifts the
next state s onto the stack, entering the configuration
(s0s1 …………… sm s, ai+1 ………. an$)
The symbol ai need not be held on the stack, since it can be recovered from s,
if needed (which in practice it never is). The current input symbol is now ai+1.
where r is the length of β , and s = GOTO[sm , A]. Here the parser first popped
r state symbols off the stack, exposing state sm-r. The parser then pushed s,
the entry for GOTO[sm-r, A], onto the stack. The current input symbol is not
changed in a reduce move.
For the LR parsers we shall construct, Xm-r+1, ……..… Xm, the sequence of
grammar symbols corresponding to the states popped off the stack, will
always match β, the right side of the reducing production.
4. If ACTION[sm , ai] = error, the parser has discovered an error and calls an
error recovery routine.
73
The LR-parsing algorithm is summarized below. All LR parsers behave in this
fashion; the only difference between one LR parser and another is the
information in the ACTION and GOTO fields of the parsing table.
LR-parsing algorithm.
METHOD: Initially, the parser has s0 on its stack, where s0 is the initial state,
and w$ in the input buffer. The parser then executes the program in Fig. 4.36.
Example
The figure below shows the ACTION and GOTO functions of an LR-parsing
table for the expression grammar below, repeated here with the productions
numbered.
(1) E → E + T
(2) E → T
(3) T → T * F
(4) T → F
(5) F → (E)
(6) F → id
74
The codes for the actions are:
1. si means shift and stack state i,
2. rj means reduce by the production numbered j,
3. acc means accept,
4. blank means error.
Note that the value of GOTO [s , a] for terminal a is found in the ACTION field
connected with the shift action on input a for state s. The GOTO field gives
GOTO[s , A] for nonterminals A. Although we have not yet explained how the
entries in this parsing table were selected, we shall deal with this issue
shortly.
75
Also shown for clarity, are the sequences of grammar symbols corresponding
to the states held on the stack. For example, at line (1) the LR parser is in
state 0, the initial state with no grammar symbol, and with id the first input
symbol. The action in row 0 and column id of the action field of the parsing
table above is s5, meaning shift by pushing state 5. That is what has
happened at line (2): the state symbol 5 has been pushed onto the stack, and
id has been removed from the input.
Then, * becomes the current input symbol, and the action of state 5 on input
* is to reduce by F → id. One state symbol is popped off the stack. State 0 is
then exposed. Since the goto of state 0 on F is 3, state 3 is pushed onto the
stack. We now have the configuration in line (3). Each of the remaining moves
is determined similarly.
LR Grammars
Our primary question is, "How do we construct an LR parsing table from a
grammar? A grammar for which we can construct a parsing table in which
every entry is uniquely defined is said to be an LR grammar. Unfortunately,
there are context-free grammars which are not LR, but these can generally be
avoided for typical programming-language constructs. Intuitively, in order for
a grammar to be LR, it is sufficient that a left-to-right parser be able to
recognize handles when they appear on top of the stack.
An LR parser does not have to scan the entire stack to know when the handle
appears on top. Rather, the state symbol on top of the stack contains all the
information it needs. It is a remarkable fact that if it is possible to recognize
a handle knowing only what is in the stack, then a finite automaton can, by
76
reading the stack from bottom to top, determine what handle, if any, is on top
of the stack. The driver routine of an LR parser is essentially such a finite
automaton. It need not, however, read the stack on every move. The state
symbol stored on top of the stack is the state the handle-recognizing finite
automaton would be in if it had read the stack from bottom to top. Thus, the
LR parser can determine from the state on top of the stack everything that it
needs to know about what is in the stack.
Another source of information than an LR parser can use to help make its
shift-reduce decisions is the next k input symbols. In practice, k=0 or k=1 is
sufficient, and we shall only consider LR parsers with k ≤ 1 here. A grammar
that can be parsed by an LR parser examining up to k input symbols on each
move is called an LR(k) grammar.
A → .XYZ
A → [Link]
A → XY.Z
A → XYZ.
77
Inside the computer, items are easily represented by pairs of integers, the first
giving the number of the production and the second the position of the dot.
Intuitively, an item indicates how much of a production we have seen at a
given point in the parsing process. For example. the first item above would
indicate that we are expecting to see a string derivable from XYZ next on the
input. The second item would indicate that we have just seen on the input a
string derivable from X and that we next expect to see a string derivable from
YZ. We group items together into sets, which give rise to the states of an LR
parser. The items can be viewed as the states of an NFA recognizing viable
prefixes, and the "grouping together' is really the subset construction
discussed earlier in the course.
One collection of sets of LR(0) items, called the canonical LR(0) collection,
provides the basis for constructing a deterministic finite automaton that is
used to make parsing decisions. Such an automaton is called an LR(0)
automaton. In particular, each state of the LR(0) automaton represents a set
of items in the canonical LR(0) collection. The automaton for the expression
grammar we have been using is shown in the figure below, This will serve as
the running example for discussing the canonical LR(0) collection for a
grammar.
78
To construct the canonical LR(0) collection for a grammar, we need to define
an augmented grammar and two functions, CLOSURE and GOTO.
If G is a grammar with the start symbol S, then G, the augmented grammar
for G. is G with a new start symbol S and production S → S. The purpose of
this new starting production is to indicate to the parser when it should stop
parsing and announce acceptance of the input. This would occur when the
parser is about to reduce by S → S.
Example
Consider the augmented expression grammar below.
E → E
E→E+T|T
T→T*F|F
F → (E) | id
If I is the set of one item {[E → E ]}, then CLOSURE(I) contains the set of items
I0 in the DFA above (see below).
E → .E
E → .E + T
E → .T
T → .T * F
T → .F
F → .(E)
F → .id
79
That is, E → .E is in CLOSURE(I) by rule (1). Since there is an E immediately
to the right of a dot, by rule (2) we are forced to add the E-productions with
dots at the left end, that is, E → .E + T and E → .T. Now there is a T immediately
to the right of a dot, so we add T → .T*F and T → .F. Next, the F to the right of
a dot forces F → .(E) and F → .id to be added. be added. No other items are
put into CLOSURE(I) by rule (2).
GOTO
The second useful function is GOT0(I, X) where I is a set of items and X is a
grammar symbol. GOTO(I, X) is defined to be the closure of the set of all items
[A → X.β] such that [A → .Xβ] is in I. Intuitively, if l is the set of items that
are valid for some viable prefix , then GOTO(I, X) is the set of items that are
valid for the viable prefix X.
Example
If I is the set of items {[E → E.]}, {[E → E .+ T]}, then GOTO(I, +) consists of
E → E +.T
T → .T * F
T → .F
F → .(E)
F → .id
80
That is, we examine I for items with + immediately to the right of the dot. E
→ E. is not such an item, but E → E.+T is. We move the dot over + to get E →
E +.T and take the closure of this set.
Example
The canonical collection of sets of items for the expression grammar below is
shown in the figure below it. The GOTO function for this set of items has been
shown as the transition diagram of a deterministic finite automaton above.
(1) E → E + T
(2) E → T
(3) T → T * F
(4) T → F
(5) F → (E)
(6) F → id
81
Constructing SLR-Parsing Tables
The SLR method for constructing parsing tables is a good starting point for
studying LR parsing. We shall refer to the parsing table constructed by this
method as an SLR table, and to an LR parser using an SLR-parsing table as
an SLR parser. The other two methods augment the SLR method with
lookahead information.
The SLR method begins with LR(0) items and LR(0) automata, introduced
before. That is, given a grammar, G, we augment G to produce G, with a new
start symbol S. From G, we construct C, the canonical collection of sets of
items for G together with the GOTO function.
The ACTION and GOTO entries in the parsing table are then constructed
using the following algorithm. It requires us to know FOLLOW(A) for each
nonterminal A of a grammar
82
The parsing table consisting of the ACTION and GOTO functions determined
by the Algorithm is called the SLR(1) table for G. An LR parser using the SLR(1)
table for G is called the SLR(1) parser for G, and a grammar having an SLR(1)
parsing table is said to be SLR(1). We usually omit the “(1)" after the “SLR,"
since we shall not deal here with parsers having more than one symbol of
lookahead.
Example
Let us construct the SLR table for the augmented expression grammar that
we have been using in this section. The canonical collection of sets of LR(0)
items for the grammar was constructed above.
First, consider the set of items I0:
E → .E
E → .E + T
E → .T
T → .T * F
T → .F
F → .(E)
83
F → .id
The item F → .(E) gives rise to the entry ACTION[0 , (] = shift 4, and the item F
→ .id to the entry ACTION[0 , id] = shift 5. Other items in I0 yield no actions.
The first item yields ACTION[1 , $] = accept, and the second yields ACTION[1
, +] = shift 6.
Continuing in this fashion, we obtain the ACTION and GOTO tables that was
already shown above. In that figure, the numbers of productions in reduce
actions are the same as the order in which they appear in the original
grammar. That is, E → E + T is number 1, E → T is 2, and so on.
Finally, let us note that every SLR(1) grammar is unambiguous, but there
are many unambiguous grammars that are not SLR(1).
Example
Consider the grammar with productions
S→L=R|R
L → *R | id
R→L
84
Consider the set of items I2. The first item in this set makes ACTION[2 , =] be
“shift 6." Since FOLLOW(R) contains = (to see why, consider the derivation S
→ L= R → *R = R, the second item sets ACTION[2 , =] to “reduce R → L." Since
there is both a shift and a reduce entry in ACTION[2 , =], state 2 has a
shift/reduce conflict on input symbol =.
85
SEMANTIC ANALYSIS
We have learnt how a parser constructs parse trees in the syntax analysis
phase. The plain parse tree constructed in that phase is generally useless for
a compiler, as it does not carry any information on how to evaluate the tree.
The productions of context-free grammar, which makes the rules of the
language, do not accommodate how to interpret them.
For example, consider the grammar production below.
E→E+T
The above CFG production has no semantic rule associated with it, and it
cannot help in making any sense of the production.
Semantic ("meaning") analysis refers to a phase of compilation in which the
input program is studied in order to determine what operations are to be
carried out. The two primary components of a classic semantic analysis phase
are variable reference analysis and type checking. These components both
rely on an underlying symbol table.
Example:
int x = 10;
x = "Hello"; // Semantic error: type mismatch
86
The syntax is correct, but semantically invalid because a string cannot be
assigned to an integer.
• Type Checking
The compiler must determine, for each operation in the source code, the types
of the operands and resulting value, if any.
It uses syntax tree and symbol table to check whether the given program is
semantically consistent with language definition. It gathers type information
and stores it in either syntax tree or symbol table. This type information is
subsequently used by compiler during intermediate-code generation.
Example:
float x = 10.1;
float y = x*30;
In the above example integer 30 will be typecasted to float 30.0 before
multiplication, by semantic analyzer.
Static and Dynamic Semantics:
1. Static Semantics –
It is named so because of the fact that these are checked at compile
time. The static semantics and meaning of program during
execution, are indirectly related.
2. Dynamic Semantic Analysis –
It defines the meaning of different units of program like expressions
and statements. These are checked at runtime unlike static
semantics.
87
Concept of Types in Programming Languages
A type defines:
• The kind of values a variable can hold
• The operations that can be performed on it
• The memory layout and representation
Common Basic Types
• Integer
• Float
• Character
• Boolean
• String
Composite Types
• Arrays
• Structures
• Classes
• Pointers
• Functions
Type Systems
A type system is a set of rules that assigns types to various components of a
program such as variables, expressions, functions, etc.
The compiler uses the type system to:
• Detect invalid operations
• Ensure safe memory access
• Prevent runtime errors
Dynamic Typing
• Type checking occurs at runtime
• Variable types determined during execution
Examples:
• Python
• JavaScript
x = 10
x = "Hello" # Allowed in Python
Advantages
• Flexibility
• Faster development
88
Strongly Typed vs Weakly Typed Languages
This is often confused with static vs dynamic typing, but they are different
concepts.
Type Checking
Type checking ensures that operations are applied to compatible types.
Example:
int a = 5;
float b = 3.2;
float c = a + b; // Valid (implicit conversion)
Type Checking Strategies
1. Static Type Checking
Done at compile time
2. Dynamic Type Checking
Done at runtime
Type Compatibility
Type compatibility determines whether two types are compatible for
assignment or operation.
Two main methods:
1. Name Equivalence
Types are compatible only if they have the same name.
2. Structural Equivalence
Types are compatible if their structures are identical.
89
Example:
struct A { int x; };
struct B { int x; };
Under name equivalence → Not compatible
Under structural equivalence → Compatible
Type Conversion
Implicit Conversion (Coercion)
Automatically performed by compiler.
int x = 5;
float y = x; // Implicit conversion
Explicit Conversion (Casting)
The programmer forces conversion.
float y = (float) 5;
Comparison Summary
Feature Static Typing Dynamic Typing
Type Check Time Compile-time Runtime
Performance Faster Slightly slower
Error Detection Early Late
90
Semantic Errors
We have mentioned some of the semantics errors that the semantic analyzer
is expected to recognize:
• Type mismatch
• Undeclared variable
• Reserved identifier misuse.
• Multiple declarations of variables in a scope.
• Accessing an out-of-scope variable.
• Actual and formal parameter mismatch.
Attribute Grammar
Attribute grammar is a special form of context-free grammar where some
additional information (attributes) are appended to one or more of its non-
terminals in order to provide context-sensitive information. Each attribute has
a well-defined domain of values, such as integer, float, character, string, and
expressions.
Semantic attributes may be assigned to their values from their domain at the
time of parsing and evaluated at the time of assignment or conditions. Based
on the way the attributes get their values, they can be broadly divided into
two categories: synthesized attributes and inherited attributes.
Synthesized Attributes
These attributes get values from the attribute values of their child nodes. To
illustrate, assume the following production:
S → ABC
As in our previous example (E → E + T), the parent node E gets its value from
its child node. Synthesized attributes never take values from their parent
nodes or any sibling nodes.
91
Inherited attributes
In contrast to synthesized attributes, inherited attributes can take values
from parent and/or siblings. As in the following production,
S → ABC
A can get values from S, B and C. B can take values from S, A, and C. Likewise,
C can take values from S, A, and B.
Expansion: When a non-terminal is expanded to terminals as per a
grammatical rule
The semantic analyzer receives AST (Abstract Syntax Tree) from its previous
stage (syntax analysis).
Semantic analyzer attaches attribute information with AST, which are called
Attributed AST.
For example:
int value = 5;
<type, “integer”>
<presentvalue, “5”>
For every production, we attach a semantic rule.
92
S-attributed SDT (Syntax Directed Translation)
If an SDT uses only synthesized attributes, it is called as S-attributed SDT.
These attributes are evaluated using S-attributed SDTs that have their
semantic actions written after the production (right hand side).
L-attributed SDT
This form of SDT uses both synthesized and inherited attributes with
restriction of not taking values from right siblings.
In L-attributed SDTs, a non-terminal can get values from its parent, child,
and sibling nodes. As in the following production
S → ABC
S can take values from A, B, and C (synthesized). A can take values from S
only. B can take values from S and A. C can get values from S, A, and B. No
non-terminal can get values from the sibling to its right.
93
Difference Between a Parse Tree and a Syntax Tree
In compiler design, both Parse Tree and Syntax Tree (Abstract Syntax Tree
– AST) are data structures generated during syntax analysis. However, they
serve different purposes and differ in structure and level of abstraction.
Example
For the same expression:
a+b*c
94
Syntax Tree Representation
+
/\
a *
/\
b c
Observation
• Only operators and operands are shown.
• Grammar symbols (E, T, F) are removed.
• Shows operator precedence clearly (* before +).
3. Key Differences
Feature Parse Tree Syntax Tree (AST)
Also Called Concrete Syntax Tree Abstract Syntax Tree
Grammar Symbols Includes all Removes unnecessary
Size Large Compact
Purpose Syntax checking Semantic analysis & code
generation
Dependency Grammar-dependent More abstract
Nodes Terminals & Nonterminals Operators & Operands
Another Example: Using the same grammar above, construct (i) parse tree
and (ii) syntax tree for the expression (3 + 4) * 5.
Parse Tree (Simplified)
E
|
T Syntax Tree
/|\ *
T* F /\
/ \ + 5
F 5 /\
/|\ 3 4
( E)
/| \
E+T
| |
T F
| |
F 4
|
3
95
INTERMEDIATE CODE GENERATION
In the analysis-synthesis model of a compiler, the front end of a compiler translates a
source program into an independent intermediate code, then the back end of the
compiler uses this intermediate code to generate the target code (which can be
understood by the machine). The benefits of using machine-independent intermediate
code are:
• Because of the machine-independent intermediate code, portability will be
enhanced. For example, if a compiler translates the source language to its
target machine language without having the option for generating
intermediate code, then for each new machine, a full native compiler is
required. Because, obviously, there were some modifications in the compiler
itself according to the machine specifications.
• Retargeting is facilitated.
• It is easier to apply source code modification to improve the performance of
source code by optimizing the intermediate code.
If we generate machine code directly from source code then for n target machines, we
will have optimizers and n code generators but if we will have a machine-independent
intermediate code, we will have only one optimizer. Intermediate code can be either
language-specific (e.g., Bytecode for Java) or language independent (three-address
code).
The following are commonly used intermediate code representations:
1. Postfix Notation: Also known as reverse Polish notation or suffix notation.
The ordinary (infix) way of writing the sum of a and b is with an operator in
the middle: a + b.
The postfix notation for the same expression places the operator at the right
end as ab +. In general, if e1 and e2 are any postfix expressions, and + is any
binary operator, the result of applying + to the values denoted by e1 and e2 is
postfix notation by e1e2+. No parentheses are needed in postfix notation
96
because the position and arity (number of arguments) of the operators permit
only one way to decode a postfix expression. In postfix notation, the operator
follows the operand.
97
98
CODE OPTIMIZATION
The code optimization in the synthesis phase is a program transformation technique,
which tries to improve the intermediate code by making it consume fewer resources (i.e.
CPU, Memory) so that faster-running machine code will result. Compiler optimizing
process should meet the following objectives:
• The optimization must be correct, it must not, in any way, change the
meaning of the program.
• Optimization should increase the speed and performance of the program.
• The compilation time must be kept reasonable.
• The optimization process should not delay the overall compiling process.
When to Optimize?
Optimization of the code is often performed at the end of the development stage since
it reduces readability and adds code that is used to increase the performance.
Why Optimize?
Optimizing may involve reducing the size of the code. So optimization helps to:
• Reduce the space consumed and increases the speed of compilation.
• Manually performing the optimization is tedious and is better done using a
code optimizer.
• An optimized code often promotes re-usability.
99
1. Compile Time Evaluation:
C
(i) A = 2*(22.0/7.0)*r
Perform 2*(22.0/7.0)*r at compile time.
(ii) x = 12.4
y = x/2.3
Evaluate x/2.3 as 12.4/2.3 at compile time.
2. Variable Propagation:
C
//Before Optimization
c = a * b
x = a
till
d = x * b + 4
//After Optimization
c = a * b
x = a
till
d = a * b + 4
3. Constant Propagation:
• If the value of a variable is a constant, then replace the variable with the
constant. The variable may not always be a constant.
Example:
C
(i) A = 2*(22.0/7.0)*r
Performs 2*(22.0/7.0)*r at compile time.
(ii) x = 12.4
y = x/2.3
Evaluates x/2.3 as 12.4/2.3 at compile time.
(iii) int k=2;
if(k) goto L3;
It is evaluated as:
goto L3 (Because k = 2 which implies condition is always
true)
4. Constant Folding:
100
Example:
C
#define k 5
x = 2 * k
y = k + 5
This can be computed at compile time and the values of x and y are :
x = 10
y = 10
5. Copy Propagation:
//After Optimization
c = a * b
x = a
till
d = a * b + 4
101
Example:
C
c = a * b
x = a
till
d = a * b + 4
//After elimination :
c = a * b
till
d = a * b + 4
9. Function Inlining:
102
Example 2:
i = 1;
while (i<10)
{
y = i * 4;
}
//After Reduction
i = 1
t = 4
{
while (t<40)
y = t;
t = t + 4;
}
2. Loop Jamming:
• Two or more loops are combined in a single loop. It helps in reducing the
compile time.
Example:
C
// Before loop jamming
for(int k=0;k<10;k++)
{
103
x = k*2;
}
for(int k=0;k<10;k++)
{
y = k+3;
}
3. Loop Unrolling:
• It helps in optimizing the execution time of the program by reducing the
iterations.
• It increases the program’s speed by eliminating the loop control and test
instructions.
Example:
C
//Before Loop Unrolling
for(int i=0;i<2;i++)
{
printf("Hello");
}
printf("Hello");
printf("Hello");
Now that we learned the need for optimization and its two types, now let us see where
to apply these optimizations.
• Source program: Optimizing the source program involves making changes
to the algorithm or changing the loop structures. The user is the actor here.
• Intermediate Code: Optimizing the intermediate code involves changing
the address calculations and transforming the procedure calls involved. Here
compiler is the actor.
104
• Target Code: Optimizing the target code is done by the compiler. Usage of
registers, and select and move instructions are part of the optimization
involved in the target code.
• Local Optimization: Transformations are applied to small basic blocks of
statements. Techniques followed are Local Value Numbering and Tree
Height Balancing.
• Regional Optimization: Transformations are applied to Extended Basic
Blocks. Techniques followed are Super Local Value Numbering and Loop
Unrolling.
• Global Optimization: Transformations are applied to large program
segments that include functions, procedures, and loops. Techniques followed
are Live Variable Analysis and Global Code Replacement.
• Interprocedural Optimization: As the name indicates, the optimizations
are applied inter procedurally. Techniques followed are Inline Substitution
and Procedure Placement.
General representation –
a = b op c
Where a, b or c represents operands like names, constants or compiler generated
temporaries and op represents the operator
Example-1: Convert the expression a * – (b + c) into three address code.
105
1. i=1 Explanation Optimized Version
2. L1: • i = 1 → Initialization (Since x * 5 is Constant
3. if i > 10 goto L2 • L1: → Loop start label in Loop)
4. t1 = x * 5 • if i > 10 goto L2 → Because x * 5 does not
5. t2 = i Loop termination depend on i, it can be
6. a[t2] = t1 check computed once:
7. i=i+1 • t1 = x * 5 → Evaluate 1. i = 1
8. goto L1 expression 2. t1 = x * 5
9. L2: • a[t2] = t1 → Store 3. L1:
value into array 4. if i > 10 goto L2
• i = i + 1 → Increment 5. a[i] = t1
• goto L1 → Repeat loop 6. i = i + 1
• L2: → Exit point 7. goto L1
8. L2:
106
2. Triples –
This representation does not make use of extra temporary variable to represent a
single operation instead when a reference to another triple’s value is needed, a pointer
to that triple is used. So, it consists of only three fields namely op, arg1 and arg2.
Disadvantage –
• Temporaries are implicit and difficult to rearrange code.
• It is difficult to optimize because optimization involves moving
intermediate code. When a triple is moved, any other triple referring to it
must be updated also. With help of pointer one can directly access symbol
table entry.
107
3. Indirect Triples –
This representation makes use of pointer to the listing of all references to
computations which is made separately and stored. Its similar in utility as compared to
quadruple representation but requires less space than it. Temporaries are implicit and
easier to rearrange code.
Example – Consider expression a = b * – c + b * – c
Question – Write quadruple, triples and indirect triples for the following expression :
(x + y) * (y + z) + (x + y + z)
108
109
110
CODE GENERATION
Code generation can be considered as the final phase of compilation. Code
generation translates the intermediate representation (IR) of a source program
into target machine code or assembly language that a computer can execute.
Through post code generation, optimization process can be applied on the
code, but that can be seen as a part of code generation phase itself. The code
generated by the compiler is an object code of some lower-level programming
language, for example, assembly language. The quality of this phase
determines the efficiency, correctness, and performance of the compiled
program.
We have seen that the source code written in a higher-level language is
transformed into a lower-level language that results in a lower-level object
code, which should have the following minimum properties:
• It should carry the exact meaning of the source code.
• It should be efficient in terms of CPU usage and memory management.
Code Generator
A code generator is expected to have an understanding of the target machine’s
runtime environment and its instruction set. The code generator should
consider the following things to generate the code:
1. Target language: The code generator has to be aware of the nature of
the target language for which the code is to be transformed. That
language may facilitate some machine-specific instructions to help the
compiler generate the code in a more convenient way. The target
machine can have either CISC or RISC processor architecture.
2. IR Type: Intermediate representation has various forms. It can be in
Abstract Syntax Tree (AST) structure, Reverse Polish Notation, or 3-
address code.
3. Selection of Instruction: The code generator takes Intermediate
Representation as input and converts (maps) it into target machine’s
instruction set. One representation can have many ways (instructions)
to convert it, so it becomes the responsibility of the code generator to
choose the appropriate instructions wisely.
4. Register Allocation: A program has a number of values to be
maintained during the execution. The target machine’s architecture
may not allow all of the values to be kept in the CPU memory or
registers. Code generator decides what values to keep in the registers.
Also, it decides the registers to be used to keep these values.
5. Ordering of Instructions: At last, the code generator decides the order
in which the instruction will be executed. It creates schedules for
instructions to execute them.
111
Inputs to Code Generation
The code generator receives:
a. Intermediate Representation (IR)
Example:
Source Code:
a=b+c
Intermediate Representation:
t1 = b + c
a = t1
b. Symbol Table
Contains details such as:
• Variable names
• Data types
• Memory locations
Example:
Variable Type Memory Location
a Integer 1000
b Integer 1004
c Integer 1008
b. Register Allocation
Registers are faster than memory. The compiler assigns frequently used
variables to registers.
Example:
Without Register Optimization:
LOAD b
ADD c
STORE temp
112
LOAD temp
STORE a
With Register Allocation:
MOV R1, b
ADD R1, c
MOV a, R1
c. Memory Management
The compiler determines how data is stored in memory.
Example:
If variables a, b, and c are stored in memory addresses, the compiler generates
instructions to access them properly.
d. Instruction Scheduling
The compiler rearranges instructions to improve execution efficiency.
Example:
Before Scheduling:
LOAD R1, b
WAIT
ADD R1, c
After Scheduling:
LOAD R1, b
LOAD R2, d
ADD R1, c
113