The stub for lab 2 uses Java 21, which admits a more readable
alternative to the visitor pattern suggested in the book. The following
example code shows how you can process an object exp
of the
type Exp
generated by the parser defined in the stub.
switch(exp) {
case EInt e -> ... ;
case EDouble e -> ... ;
case EAnd e -> ... ;
case EOr e -> ... ;
...
default -> throw new IllegalStateException("Case for " + e + " is not yet implemented.");
}
On the right hand side of the ->
you can extract
further information from e
, for example, in the case
EAnd e
, you can use the two operands e.exp_1
and e.exp_2
.
switch
, if used like above produces a Java-expression,
which means that you can use it in assignments:
var typedExpression = switch(exp) { ... }
To define the neccessary datatypes, record
s are very
useful. You can define a record like this:
record TypedAnd(TypedExpr e1, TypedExpr e2) {
}
Objects of this type can be constructed with
new TypedAnd(e1,e2)
where e1
and
e2
are TypedExpr
s. For solving the lab, it is
reasonable to define an interface
TypedExpr
with a method which returns the type of an expression:
interface TypedExpr {
Type type();
}
Where Type
is a datatype for types that needs to be
defined. With this interface, the TypedAnd
from above can
be turned into an implementation like this:
record TypedAnd(TypedExpr e1, TypedExpr e2) {
Type type() {
return new Type.Bool; // this is what it could look like if you implement basic types as an enum
}
}
To keep a good overview, you can write implentations of an interface directly into the interface as "inner classes":
interface TypedExpr {
Type type();
record And(TypedExpr e1, TypedExpr e2) implements TypedExpr {
Type type() {
return new Type.Bool;
}
record Var(String name, Type type) implements TypedExpr { }
}
The second record Var
shows a handy shortcut to
overriding the method type
: if the record has an attribute
of the right Type and name ("type
" in this case), it will
automotically become the implementation.
You can work with a variable of type TypedExpr
like in
the example above, by using switch
expressions. If you want
to avoid the default
case, you can use a
sealed interface
instead. To make that work, you have to
declare all implementations inside the interface (or use
permits
):
sealed interface TypedExpr {
Type type();
record And(TypedExpr e1, TypedExpr e2) implements TypedExpr {
Type type() {
return new Type.Bool;
}
record Var(String name, Type type) implements TypedExpr { }
}
This is just one way to start and the ideas given are not complete -
be prepared to change everything along the way to a solution that passes
all tests. To get started, here is a possible first try at an
implementation of the typecheck
function in the
TypeChecker
:
public AnnotatedProgram typecheck(Program p) {
var funDefs = ((PDefs) p).listdef_.stream()
.map(def -> (DFun) def)
.toList();
= new HashMap<>();
signatures extractSignatures(funDefs);
return new AnnotatedProgram(checkFunDefs(funDefs));
}
The first statement extracts a list of function definitions from (the
parse-tree) p
and stores it in funDefs
. The
var
means that the type of funDefs
is
automatically infered. Then the signatures, which are stored in a field
in the TypeChecker
class, are initialized. To make the last
line of this functions work, the type AnnotatedProgram)
was
modified to an inner record
of the TypeChecker
in the following way (the functions extractSignatures
and
checkFunDefs
will be explained below):
record AnnotatedProgram(Map<String, TypedFunDef> defs) { }
The type TypedFunDef
is also defined as an inner
record
of TypeChecker
:
record TypedFunDef(CType returns, List<TypedArg> args, List<Statement> stms) { }
record TypedArg(String name, CType type) { }
... and Statement
and CType
are defined in
separate files:
sealed interface Statement {
record Decl(String varName, CType type) implements Statement {}
// TODO: add more records for all the statements we need in the typed syntax
}
enum CType {
, Double, Bool, Void
Int}
A Type
from the parser syntax can then be translated to
a CType
like this (where this function definition could for
example be put into TypeChecker
):
typeFrom(Type type) {
CType return switch(type) {
case Type_int ignored -> CType.Int;
// TODO: other cases
default -> throw new IllegalStateException("Unexpected value: " + type);
};
}
Now, we'll get back to the functions we left unexplained above,
extractSignatures
and checkFunDefs
. As the
name suggests, extractSignatures
should pass through all
function definitions and extract their signatures, which we will need to
check the actual function defintions. This is a possible
implementaion:
void extractSignatures(List<DFun> funDefs) {
for(var def : funDefs) {
List<CType> argTypes = def.listarg_.stream()
.map(arg -> typeFrom(((ADecl) arg).type_))
.toList();
Signature signature = new Signature(typeFrom(def.type_), argTypes);
if(signatures.put(def.id_, signature) != null) {
throw new TypeException("Signature already defined for " + def.id_);
};
}
}
One thing that could be added here (or somewhere else) is, that there
should be exactly one function called "main" which returns
int
and has no arguments. We will only give a sketch of an
implementation of checkFunDefs
. One thing to take care of
is dealing with contexts in the right way, which needs some data
structure like a stack or list. This is not explained in detail
here:
HashMap<String, TypedFunDef> checkFunDefs(List<DFun> funDefs) {
var checkedFunDefs = new HashMap<String, TypedFunDef>();
for(var funDef : funDefs) {
// TODO: produce 'args' which should be a list of argument names with their types
// TODO: produce 'contexts' which should be some datastructure storing types of declared variables
= new TypedFunDef(typeFrom(funDef.type_),
TypedFunDef checkedFunDef ,
argscheckStms(funDef.liststm_, contexts));
if(checkedFunDefs.put(funDef.id_, checkedFunDef) != null) {
throw new TypeException("Function " + funDef.id_ + " already defined!");
}
}
return checkedFunDefs;
Now we will give an oversimplified sketch of the interpreter. All the
functions below should still be changed to solve lab 2. We will also
give a start for an implementation of Value
as an interface
below and we will assume the Interpreter
has a field
env
which stores the environment(s).
void interpret(TypeChecker.AnnotatedProgram p) {
= p.defs();
defs if (!defs.containsKey("main")) {
throw new RuntimeException("Main function not found");
}
runFunction(defs.get("main")); // TODO: deal with arguments...
}
// TODO: Extends to functions with arguments
runFunction(TypeChecker.TypedFunDef f) {
Value // TODO: 'env', used in the following should be a field of the Interpreter,
// which should open a new block at this point, with the arguments from the function call
for (var s : f.stms()) {
run(s, env);
}
return new Value.Void();
}
void run(Statement s) {
switch (s) {
case Statement.Decl decl -> env.addVar(decl.name(), decl.type());
case Statement.Expr expr -> evalExpr(expr.typedExpr());
};
}
Depending on how return
statements are implemented, it
might be reasonable to return more information here than just
Value
s. For the following evaluation function for
expressions, it should however be enough to return only a
Value
:
evalExpr(TypedExpr expr) {
Value return switch(expr) {
case TypedExpr.Int anInt -> new Value.Int(anInt.i());
case TypedExpr.Var var -> env.getVar(var.id());
case TypedExpr.Assign assign -> // TODO: update environment;
case TypedExpr.App app -> // TODO: run a function;
};
}
Here is a start for the value datatypes:
sealed interface Value {
record Int(Integer i) implements Value {}
record Void() implements Value {}
}