Type Inference
Type Inference
Runtime
Environment
Program
Output
Program
Input
while b ≠ 0:
if a > b:
a := a - b
else:
b := b - a
return a
A compiler checks if the program is meaningful as per the rules of the source language. This is done during the compiler’s
semantic analysis, and it includes type checking. This will probably make intuitive sense, since we introduced types to
lambda calculus in order to produce “meaningful” programs with the appropriate invariants. like lamda n. 1/n for n is 0 case as an example.
Type checking typically uses a symbol table, which maps names to their types. This table defines an environment for the
program. When the program enters a new scope, this table is extended with new bindings (each name-type entry is a key-value
pair in the table/map).
• Please keep in mind the concept of shadowing: when a new binding is added, it may shadow an older binding; when the
program exits the scope, the scope’s bindings are removed and the shadowed bindings are not directly accessible again.
• You can think of the symbol table as a standard map/table/dictionary data structure, but with additional stack-like
properties: you can only directly access the names and types of the “top” scope.
Semantic analysis also performs other checks. For example: if pattern matching is exhaustive (OCaml), if a Java attribute
marked as private is being accessed outside the class, if a Java attribute marked as final has been initialized in the
constructor, etc.
Note: There is some overlap between parsing (syntax analysis) and semantic analysis. Parsing does just enough work to
produce an AST for a program. Everything else is done by semantic analysis. But, strictly speaking, producing the AST may
also require checking whether the program is meaningful. ex: the woman duck with a telescope.
Programs may require semantic analysis to determine the correct AST. For example, consider this Java/C code snippet:
(expr_a) – token_b
• Is the above line evaluating a numeric expression expr_a and then subtracting another number token_b from it?
ex: expr_a is float i.e
• Or is it performing unary negation of the number token_b, and then explicitly casting it to the data type expr_a?
(float) - token_b
This is not immediately obvious! Additional semantic analysis is needed to determine if expr_a is a variable name or a type
name before the compiler can correctly choose one of the above options.
Typically, the parser and semantic analysis work together: the parser produces an AST with some “ambiguous” nodes, then the
semantic analysis stage modifies the tree so that the ambiguity is resolved. so parsers produces multiple AST and check for semantics and
accepts which one is meaningful.
Usually, the compiler does not translate the AST directly to the target
language (even though, in theory, it could).
C++
This is because, in practice, the target language is machine code, and Java
thus, machine-dependent (e.g., x86 or ARM). Instead of translating the AST
to each target, the process is broken up: the compiler first translates the
AST to an intermediate representation, which is an abstraction of several F#
assembly languages. for example javabytecode
• Java bytecode is an example of such an intermediate
representation (IR). Many languages use C as an IR, because C is
essentially an abstraction of assembly (and it is the de facto Intermediate this IR can be translated to corresponding
The final stage is target code generation from the IR. Here, concrete machine instructions (not oblivious to the physical CPU
architecture any more) are selected for machine-dependent optimizations.
Various resource allocation and storage decisions are made while translating the IR to the target (native machine language).
It is common to group the compiler’s stages into its
• front end (lexing, parsing, and semantic analysis, producing the AST and symbol tables);
• middle (CPU-agnostic optimizations on the IR); and
• back end (target code generation and machine-dependent optimizations).
An interpreter works the like front end of a full-fledged compiler: it does lexing, parsing, and semantic analysis. After that, it
can either execute the AST, or transform the AST into an IR and then execute the IR.
It is very unlikely that you will ever need to build a lexer or parser from scratch. Most languages have built-in tools that
automatically generate them from the formal syntax descriptions of the language: lex (or some variant of it) to generate a lexer,
and yacc (or some variant of it) to generate a parser. ex: ocmal lex
Lexer are generated using deterministic finite automata (DFA), which are abstract machines that accept regular languages (and
for our purpose, we can think of them as regular expressions).
• The input to a lexer generator is a set of regular expressions. These describe the tokens of the language. For example, if
we build an interpreter for arithmetic expressions, we will need a regular expression to capture all valid numeric values.
• The output of a lexer generator is a DFA implemented in a higher-level language (C, if we use lex; OCaml, if we use
ocamllex; etc.). This automaton takes strings as its input, i.e., each character in the source program text now becomes a
character input to the automaton. In the end, the automaton either accepts the input string as a valid token in the source
language, or rejects the input string as an invalid token.
Recall how we started our journey into syntax and semantics with the
Backus-Naur Form and a “toy” language of arithmetic expressions. If +
we only care about addition and multiplication, our BNF becomes
e ::= i | e op e | (e) here op is operator + *
i ::= <integers>
6 2
Recall how we started our journey into syntax and semantics with the How would you define such an expression in OCaml?
Backus-Naur Form and a “toy” language of arithmetic expressions. If Probably something like this:
we only care about addition and multiplication, our BNF becomes type expr =
e ::= i | e op e | (e) | Int of int
i ::= <integers> | Operator of expr * op * expr
and
Note: the name “integers” is yet undefined. op = Add | Mult;;
If you connect that material to the second programming assignment,
We don’t need to model the third component from
you may quickly realize that (a) we were able to work with
our BNF, (e): it gets abstracted out because the tree
expressions that have whitespace, parentheses, and multiple
structure already captures the meaning of the
operators, and (b) those expressions were represented as trees that
parentheses tokens.
represented how the tokens related to each other. E.g., (5 + 7) + 2
* 3 or 6 * 2 + 7. In general, you will see a very close correspondence
between the formal specifications of a language’s
syntax (as expressed using BNF), and the abstract
syntax of that language (as expressed using algebraic
data types).
After lexing and parsing, semantic analysis yields the abstract syntax tree (AST) of a program.
• Next, a compiler rewrites the AST into an intermediate expression (IR). for efficiency tradeoffs. to simplify the AST's into other features which are
easily understandable.
• An interpreter has two options: it may also rewrite the AST into an IR, or it may directly evaluate the AST.
Why rewrite the AST?
To Simplify it. Often, certain language features can be rewritten (i.e., implemented) in terms of other features. It makes sense to
simplify the core language so that we don’t have to worry about too many distinct features. This keeps the core of the
compiler/interpreter smaller.
• One obvious example of such simplification we can see in programming languages is what’s called “syntactic sugar”
(and eliminating them from our set of features is de-sugaring the language)
understand this cncept in detail.
To illustrate operational semantics using step-by-step evaluation, let us augment our toy arithmetic language in two ways: (i)
include let and if-then-else; and (ii) include the inequality <= as an operator.
In this language, a terminal irreducible value is either an integer or a Boolean constant. The BNF of our language is now
e ::= x | int | bool | e1 op e2
| if e1 then e2 else e3
| let x = e1 in e2
op ::= + | * | <=
v ::= int | bool
bool ::= True | False
Note: We are still using some built-in underlying notions like “what is an integer” and “what are +, *, and <=“.
The operational semantics of such a language can be defined in terms of the reduction (->) relation. Additionally, it helps to
introduce the negated relation (!->) as well.
For example, we can specify that a terminal value cannot be reduced with the rules int !-> and bool !->
Earlier in this course, we saw the stepwise evaluation of our toy arithmetic language. For example,
e1 -> e3 => e1 op e2 -> e3 op e2
This is an example of inductive reasoning: two expressions are related by -> if two other expressions (at least one of which is
simpler) are also related by ->.
This rule reduces the first argument of a binary operator. Once that is complete (i.e., the first argument has been reduced step-
by-step all the way to a terminal value), the evaluation of the second argument can begin:
e2 -> e3 => v1 op e2 -> v1 op e3
And finally, when both arguments are values, we can use the built-in definition of the binary operator to evaluate
v1 op v2 -> v
Previously, it was pointed out that the evaluation bears resemblance to the 𝛽-reduction we saw in lambda calculus. For the
“let” binding, this is literally true:
This is exactly what 𝛽-reduction did! The lambda calculus syntax for the same substitution was [x -> v] (e2).
Note: please look out for the square brackets [ ] surrounding a reduction. Unfortunately, the syntax for lambda calculus’
substitution and the general operational semantics step both use the -> arrow. If we use the square brackets, the context
should tell you that we are using the arrow to indicate 𝛽-reduction in lambda calculus.
In operational semantics, there are actually two (not one) relations that define the meaning of a program/expression.
The first, which we denoted by ->, is called small-step semantics. It formally describes how the individual steps of a
computation take place. This is useful to describe and understand specific details and features of a programming language.
There is also the big-step semantics, sometimes called natural semantics, which describes the final result of a program. This
is a faithful abstraction of the small-step semantics, and it is quite similar to how interpreters are often implemented. If we
denote the big-step semantics by the relation ↦, we can represent it as follows:
!
∀ expressions 𝑒 and values 𝑣, 𝑒 ↦ 𝑣 if and only if there exists a sequence 𝑒" "#$ , 𝑛 ≥ 0, such that 𝑒 = 𝑒$ → ⋯ → 𝑒! = 𝑣.
In other words, if an expression reaches a value through a sequence of well-defined small-step evaluations, then and only then
does it reach that value in a big step.
This is why we call ↦ an abstraction of -> (it just “forgets” the intermediate low-level details and jumps to the final result).
What we have seen so far today is the use of substitution rules to perform step-by-step reductions and arrive at the final
“meaning” of a program/expression.
We also concluded (using the BNF for lambda calculus) that this definition of a program’s semantics aligns with call-by-value
semantics. This approach is not very smart, when it comes to practical implementations. It’s “too eager”!
• For instance, we could have a program of the form let x = 5 in e (or equivalently, calling a function with argument 5,
where the function body is the expression e). This evaluation technique requires substituting every occurrence of x in e
(if e is very large, this means parsing a huge amount of text looking for x). What if x never even occurs in e or it occurs
only in a specific branch that never gets evaluated?
• In many scenarios, it is better to be “lazy” and substitute only when the value of a variable is needed for the next step of
computation. Otherwise, the interpreter may be working a lot for nothing.
For this lazy evaluation approach, a data structure called the dynamic environment is used.
• You can think of it as a dictionary (or map), mapping variable names to values. Instead of eager substitutions, the
interpreter looks up the value in this dictionary when the value is needed.
After lexing and parsing, we jumped into the program evaluation. However, one
extremely important component of the semantic analysis that happens before
evaluation is type checking. This is a major (in fact, it is *the* major) task
within the semantic analysis phase of a compiler/interpreter’s job.
If an expression is well-typed, the type system also determines the type of that
expression. For example, the type system of OCaml determines that the type of
fun x -> 1 + x is int -> int.
“Prevention is better than cure”: The goal of type checking is to prevent runtime type errors. These errors are detectable at
compile time, so we should never allow them to happen at runtime.
A type checker is a program that implements a type system. It analyzes a program and rejects it if there are any type errors. In
other words, if an error is detectable at compile time, the type checker will simply not allow that program to run.
And to achieve this goal, a type checker uses a static (compile-time) environment, which maps names (in scope) to types, as
opposed to the dynamic (runtime) environment, which maps names to values:
Let’s revisit our toy language, where I now use “i” and “b” for integers and Booleans in the BNF to avoid confusion with the data
types:
e ::= x | i | b | e1 op e2
| if e1 then e2 else e3
| let x = e1 in e2
op ::= + | * | <=
We want to define a type system E ⊢ 𝑒: 𝑡. The only data types are integers and Boolean constants:
The ternary relation will be inductively defined. That is, the type of an expression is based on the type of its sub-expressions.
let x = 5 in x + x;;
The constant 5 has type int (this was one of our three base rules). That is, E = 5: int .
In other words, if an expression e1 has type t1 in the environment E, and an expression e2 has type t2 in an environment that is
E plus the variable x being bound to the type t1, then “let x = e1 in e2” has type t2.
Exercise
Similarly, write down the type system rules for (i) the binary operators, and (ii) the if construct.
OCaml and Java are both statically typed languages. Thus, type checking is a compile-time process that either accepts or
rejects a program (unlike dynamically typed languages like Python or JavaScript).
But unlike Java (and other languages like C, C++, etc.), Ocaml is implicitly typed. A programmer does not usually need to
specify the data types, as the types are inferred. This is possible due to the sophistication of the type checker, which can figure
out what the types would have been, if the programmer had correctly specified them in the program text.
• Such specifications are often called type annotations.
Type inference and type checking are usually rolled into a single process called type reconstruction. At a very high level, it
works as follows:
1. Determine the types of later definitions using the types of earlier definitions. E.g., determine the type of fun x -> 1 + x to
be int -> int by using the fact that the type of 1 + x is int.
2. For each “let” definition, use the definition to determine the constraints about its type. E.g., determine the type of x to be
int, based on the constraint imposed by the expression 1 + x. The set of all constraints (from if-then-else, pattern
matches, etc.) form a system of equations.
3. Use the system of equations to solve for the type of the name being defined.
The system just described is called the Hindley-Milner type system. We will not go deeper into this, and we will also not
formally explore how type reconstruction handles mutable data types.
But we will use the intuition of this algorithmic approach to reconstruct/infer the types of certain programs and expressions.
Here are some example exercises (the goal is to figure out the type of the name shown in red):
let double x = 2 * x;;
let square x = x * x;;
let twice f x = f (f x);;
let quad = twice double;;
let fourth = twice square;;
let rec f x = g x and g x = f x