Milestone M5
The Type Checker
Part VI described a checker that types a program before it runs. The checker has one rule per form,
as the evaluator does. It checks or synthesizes depending on whether the context supplies a type,
and it names what neither mode supplies with an inference variable that unification solves. At
each use of a generic function, it instantiates the function’s type parameters afresh. This
milestone puts that checker into src/types/check.rs. Nothing in the evaluator changes, and every
M1–M4 test must still pass.
From this milestone on, the checker stands between the parser and the evaluator. The interpreter
checks every declaration in the program and runs main only if all of them are accepted, so a
program with a type error prints nothing at all. New on the provided side is the driver that walks
the declarations and judges each one when its body has been checked. There is also a module of
helpers, printed in the helper module below, which you paste into your own
file.
To-dos
grep -rn 'todo_m5!' src lists the holes, all in src/types/check.rs: an arm of check_expr for
each form of M1–M4, and the body of unify. Each hole names the rules it implements:
// src/types/check.rs — some of the M5 holes
Expr::Lit(..) => todo_m5!("T-Int / T-Bool / T-Str / T-Unit"),
Expr::Var(..) => todo_m5!("T-Var / T-Self"),
Expr::Block(..) => todo_m5!("T-Block / T-Let / T-Seq"),
Expr::Return(..) => todo_m5!("T-Return (reads ctx.ret())"),
Expr::Call(..) => todo_m5!("T-App (first-order matching of generics at the call)"),
// … and the rest of the M1–M4 forms
pub fn unify(&mut self, a: &Ty, b: &Ty, span: Span) -> Result<(), TyError> {
todo_m5!(
"unify (structural agreement; solves TyKind::Meta with an occurs check)"
)
}
The stub’s let _ = (ctx, expected); keeps the compiler quiet until your arms read those
parameters, and can be deleted once they do. The arms for relations, constructors, match, ?,
structs, and methods stay holes until their milestones, and so does check_rule.
One requirement has no hole of its own: check_expr must stop recursing past a fixed depth. It is
described with the arms below.
Milestone specification
Each form, the rules that type it, and the cases in which the checker rejects it:
| Form | Rules | Rejected when |
|---|---|---|
5, true, "s", () |
T-Int, T-Bool, T-Str, T-Unit |
— |
x |
T-Var, T-Inst |
no local binding, top-level declaration, or prelude function has the name |
-e, not e |
T-Neg, T-Not |
the operand is not Int (for -) or Bool (for
not) |
e1 + e2, e1 < e2, … |
T-Arith, T-Ord |
an operand is not Int |
e1 and e2, e1 or e2 |
T-And, T-Or |
an operand is not Bool |
e1 == e2, e1 != e2 |
T-Eq |
the operands have different types, or their type admits no equality |
e1 ++ e2 |
T-Concat-Str, T-Concat-List |
the operands are not two strings or two lists of one element type, or neither operand settles which |
e1 :: e2 |
T-Cons |
e2 is not a list of e1's type |
(e1, …, en), [e1, …, en] |
T-Tuple, T-List |
a list's elements have different types |
e.i |
T-Proj |
e is not a tuple, i is past its end, or e's type is
not yet known |
{ let x: T = e; … } |
T-Let, T-Seq |
the annotation names no declared type, or e does not have it |
if e1 { e2 } else { e3 } |
T-If |
the condition is not Bool; the branches have different types; a branch of an
if without else has a value |
while e1 { … }, for x in e1 { … } |
T-While, T-For |
the condition is not Bool; e1 is not a list; the body is not
() |
ref e, deref e, e1 := e2 |
T-Ref, T-Deref, T-Assign |
what is read or assigned through is not a reference; the value assigned is not of the cell's type |
return e |
T-Return |
no function encloses it; e is not of the function's result type |
|x, y| e |
T-Lam |
the body is not of the result type |
f(e1, …, en) |
T-App |
f is not a function; the argument count differs from the parameter count; an
argument is not of its parameter's type; a bound on a type parameter is not met |
Wherever two types must agree, unify decides, and it fails with a mismatch or, when a type would
have to contain itself, an infinite type. The rules are collected in Appendix D’s typing
rules, and the static errors by
name gives the TyError variant a
test expects for each case and the span it carries. A type checker and
Function types and polymorphism turn the rules into the algorithm the
arms follow; this table is the checklist your checker is measured against.
The provided driver does the checks that belong to a whole declaration. It builds the top-level
context from the signatures, checks each global in initialization order and reports a cycle among
them, and checks every other declaration in source order, so the first error in the file is the one
reported. It validates every written signature, requires main to take no parameters, and turns a
mismatch in a body whose fn omits -> T into a message saying the result type was left off.
After each declaration it applies the determination rule to every type your arms recorded, and it
judges the bounds your arms and the native functions’ types asked for: NoEquality,
NotPrintable, and UnsatisfiedBound come from there.
These contracts hold across the arms:
- Every arm ends the same way. When there is an expected type, the arm unifies the type it has
built with it (), then resolves the type and records it at its own span with
record. This holds for the arms that pass the expected type down to their parts as well. A list checked againstInt, or a pair against a triple, gets nothing from the expected type, and the final unification is where its mismatch is found; where the parts did use it, the final unification succeeds. The determination rule reads only what was recorded, so a type that is never recorded is never judged. unify(a, b, span)takes the type found first and the type required second. A failure isMismatch { expected: b, found: a, span }, both resolved. Inside compound types the pair reported is the innermost one that disagrees: a[Int]passed where[String]is required isexpected String, found Int, at the argument.- Premises are checked in the order the rule writes them, left to right: an operator’s left operand before its right, a call’s callee before its arguments, a condition before its branches. The first failure is the one reported, and the tests check which one it is.
- A mismatch is reported where the type goes wrong. An arm that passes its expected type down
to a subexpression lets the mismatch surface at the smallest expression whose type disagrees:
(0 - n)indescribe,"one"in anifwhose other branch is1.
Implementing Milestone M5
Paste the helper module into src/types/check.rs first; several arms call
it. Then destructure each arm’s .. into the fields you need, as at M4:
| Variant | Fields |
|---|---|
Expr::Lit | (Lit, Span) |
Expr::Var | (Name, Span) |
Expr::Unary | (UnOp, Box<Expr>, Span) |
Expr::Binary | (BinOp, Box<Expr>, Box<Expr>, Span) |
Expr::Tuple, Expr::List | (Vec<Expr>, Span) |
Expr::Proj | (Box<Expr>, u32, Span) |
Expr::Block | (Vec<Stmt>, Option<Box<Expr>>, Span) |
Expr::If | (Box<Expr>, Box<Expr>, Option<Box<Expr>>, Span) |
Expr::While | (Box<Expr>, Box<Expr>, Span) |
Expr::For | (Name, Box<Expr>, Box<Expr>, Span) |
Expr::Assign | (Box<Expr>, Box<Expr>, Span) |
Expr::Return | (Box<Expr>, Span) |
Expr::Lambda | (Vec<LambdaParam>, Box<Expr>, Span) |
Expr::Call | (Box<Expr>, Vec<Expr>, Span) |
A block’s statements are Stmt::Let(Name, Option<Ty>, Expr, Span), the span covering the whole
binding, and Stmt::Expr(Expr).
Types are built with Ty’s constructors (Ty::int(), Ty::list(t), Ty::func(params, ret),
Ty::reference(t), and the rest in ast.rs) and taken apart by matching on ty.kind, a TyKind.
Match on a type only after resolve, since an inference variable that has been solved still looks
like a TyKind::Meta until it is resolved.
The provided checker
A type checker shows Ctx and the inference variables
(fresh_meta, solve, resolve, record), and Function types and
polymorphism shows decl. The arms also use these, provided
in src/types/mod.rs:
impl Checker {
/// Record that `ty` must satisfy trait `trait_` (a bound met at a use site).
pub fn require_bound(&mut self, trait_: Name, ty: Ty, span: Span);
/// The same, for a bound with type arguments (`T: Conv<String>`).
pub fn require_trait(&mut self, trait_: TraitRef, ty: Ty, span: Span);
/// The expression at `span` never produces a value.
pub fn record_diverging(&mut self, span: Span);
pub fn is_diverging(&self, span: Span) -> bool;
/// `whole` leaves because its part `sub` does: mark `whole` diverging
/// and the code after `sub` within it as never running.
pub fn dead_after(&mut self, sub: Span, whole: Span);
/// T-Eq's side condition; and whether `ty` is a bare type parameter.
pub(super) fn admits_eq(&self, ty: &Ty, visiting: &mut HashSet<Name>) -> bool;
pub(super) fn is_type_param(&self, ty: &Ty) -> bool;
// and the field `depth: usize`, the nesting of the expression being checked
}
/// Whether `ty` still contains an inference variable.
pub(super) fn contains_meta(ty: &Ty) -> bool;
pub const MAX_NESTING: usize = 150_000;
admits_eq takes a set it uses to stop on recursive types; pass it &mut HashSet::new().
The arms
What follows adds to the content chapters what a test will hold you to. A rule’s premises are
checked in the order the rule writes them, and every arm ends as the first contract above says,
with the final unification and record.
- The nesting bound. When
self.depthhas reachedMAX_NESTING,check_exprreturnsTooDeep { limit: MAX_NESTING, span }for the expression in hand instead of matching on it. Otherwise it incrementsdepth, checks the expression, and decrementsdepthagain whatever the outcome. This is the balanceenter_callandleave_callkept at M4. Applying?to the result before decrementing would leavedepthincremented after every error, so take the result as a value, decrement, and then return it. Moving thematchinto a method of its own keeps the increment and the decrement in one place. Varlooks a name up in this order: theCtx; a top-levelfn, throughdecl; a global, throughglobal_type; a native prelude function, throughnative_fn_type. A name found in none of them isUnboundVariableat the name. Afndeclaration gives the typefn(params) -> resultfrom its signature. If it declares type parameters, map each one to a fresh inference variable, a new map at every use, and apply the map withTy::substitute, whichast.rsprovides. For each parameter declared with a boundT: C, record the bound withrequire_traiton the variableTbecame, at the name’s span, with the bound’s own type arguments substituted in the same way.- Operators. Arithmetic and ordering check both operands against
Int,andandoragainstBool,-andnottheir operand likewise. Equality synthesizes the left operand and checks the right against its type. If that type, resolved, still contains a variable, require anEqbound on it for the driver to judge. Otherwise, a type that does not admit equality isNoEquality { ty, is_param, span }at the whole expression, withis_paramfromis_type_param. Concatenation synthesizes the left operand. One whose resolved type is neither a string, a list, nor an unsolved variable isMismatch { expected: String, … }at that operand. The right operand is then checked against the left’s type, and a type still unsolved after both isAmbiguousat the whole expression. Cons synthesizes the element and checks the list against a list of its type. - Tuples, lists, and projection. A tuple checked against a tuple type of the same width passes
each component its counterpart; otherwise its components are synthesized. A list takes its element
type from an expected list type, or else from a fresh variable, and checks every element against
it in order. The empty list is that fresh variable and nothing more, for the context or the
determination rule to settle. A projection synthesizes its operand and resolves it: a tuple wide
enough gives the component, a narrower one is
NoSuchComponent { index, arity }, a type not yet known goes toundetermined, and anything else isNotATuple { index, found }, all at the projection’s span. - References.
ref eis a reference to whateveresynthesizes.deref echeckseagainst a reference to a fresh variable and has that variable as its type.e1 := e2checkse1against a reference to a fresh variable, checkse2against the variable, and has type(). - Blocks thread a
Ctxthrough their statements, eachletextending it for the statements after. Aletwith an annotation passes it throughannotationat the statement’s span, checks the initializer against the typeannotationreturns, and binds the name at that type. Aletwithout an annotation binds the name at the type its initializer synthesizes. An expression statement is synthesized and its type discarded. The block’s tail is checked against the expected type, and a block with no tail has type(). if,while,for. A condition is checked againstBool. With anelse, thethenbranch is checked against the expected type and theelsebranch against thethenbranch’s type, which is theif’s type. Without one, the branch is checked with no expected type and must unify with(). A branch that does not isIfWithoutElse { found }at the branch, and theifhas type(). Awhilebody is checked against(). Aforchecks its iterable against a list of a fresh variable and its body against(), in the context extended with the loop variable at that variable. Both loops have type().return ereads the enclosing function’s result type withctx.ret(). With none, it isReturnOutsideFunction. Otherwiseeis checked against it, thereturnis recorded as diverging, and its own type is a fresh variable, so it fits whatever its context requires.- Lambdas. Each parameter’s type is its annotation, through
annotationat the parameter’s span; failing that, the matching parameter of an expected function type with the same number of parameters; failing that, a fresh variable. The result type is the expected function type’s, or a fresh variable. The body is checked against the result type inctx.enter(Some(result), ctx.self_ty())extended with the parameters, so areturninside the body targets the lambda. The lambda’s type is the function type of the parameters and result, each resolved. - Calls.
check_calleesynthesizes the callee and returns its type, resolved, with the range of bounds it recorded. A function type with a different number of parameters than there are arguments isArityMismatchat the whole call, naming the callee if it is a variable and"this function"otherwise. Each argument is checked against its parameter’s type, left to right; thenfinish_callmoves the callee’s bounds onto the arguments that supply them, and the call has the function type’s result type. When the callee’s type is still an unsolved variable, as in|f| f(1), the call infers a function type for it: synthesize the arguments, make a fresh result variable, and unify the callee’s type with the function type they form, at the call’s span. Any other callee type isNotAFunction { found }at the callee. unifyresolves both types first. An unsolved variable agrees with itself. An unsolved variable against any other type is solved as that type, unless it occurs in that type, which isInfiniteTypeatspan.Int,Bool,String,(), andSelfagree with themselves. Two tuples of the same width, two lists, two references, two function types with the same number of parameters (the parameters in order, then the results), and two named types with the same name and number of arguments agree when their parts unify pairwise, left to right. Any other pair is the mismatch.
Leaving and dead code
Three provided calls tell the driver what your arms know about control flow:
record_diverging(span)marks an expression that produces no value. A diverging expression’s type is exempt from the determination rule, which is whyreturn 0can stand in a branch whose type nothing else fixes. Thereturnarm marks itself. A block whose tail diverges is marked too, and so is anifwhose branches both diverge.dead_after(sub, whole)markswholeas diverging because its partsubis, and the code aftersubwithinwholeas never running. A block calls it as soon as a statement’s expression, or aletinitializer, diverges, with that statement’s span and the block’s, and before it checks the statements after it. Those are still checked, inside the region now marked dead, so the call has to come first forundeterminedand the determination rule to treat them as dead. Only the first diverging statement calls it. A block that leaves this way and has no tail has a fresh variable as its type, where it would otherwise have(). Aniforwhilecalls it when its condition diverges, and aforwhen its iterable does.note_callee(span)exempts a named callee from the determination rule, since whatever is unknown in its type is unknown in an argument or in the call, which are the places to report it.check_calleecalls it.
Code that never runs needs no annotations, so the determination rule skips it, and undetermined
returns a fresh variable for it where it would otherwise report Ambiguous. A mismatch in dead code
is still an error. So
fn f() -> Int { return 1; let y = []; println(y); 2 }
checks, although nothing fixes the element type of y. In
fn f() -> Int { return 1; 1 + true }, by contrast, 1 + true is still rejected.
Where the calls go
The two arms below show where the calls are made and when, with the typing left as comments for you to fill in. Each ends as every arm does: unify the type with the expected one, if there is one, then resolve the type, record it at the arm’s span, and return it. Those last lines are the same in every arm, and a method of your own can save writing them out each time.
Expr::Block(stmts, tail, span) => {
let mut cx = ctx.clone();
let mut leaves = false;
for stmt in stmts {
// T-Let / T-Seq: check the statement, extending `cx` for a `let`.
// `e` is the statement's expression or the `let`'s initializer,
// `at` is the statement's span.
let (e, at) = /* … */;
// The first statement that leaves makes the rest of the block dead;
// the call comes before the remaining statements are checked.
if !leaves && self.is_diverging(e.span()) {
leaves = true;
self.dead_after(at, *span);
}
}
let ty = match tail {
Some(e) => /* the tail, checked in `cx` against `expected` */,
None if leaves => self.fresh_meta(), // a block that leaves fits anywhere
None => Ty::unit(),
};
if tail.as_ref().is_some_and(|e| self.is_diverging(e.span())) {
self.record_diverging(*span);
}
// Chk-Sub, then record: the end of every arm
if let Some(exp) = expected {
self.unify(&ty, exp, *span)?;
}
let ty = self.resolve(&ty);
self.record(*span, ty.clone());
Ok(ty)
}
Expr::If(cond, then, els, span) => {
// the condition, checked against Bool
if self.is_diverging(cond.span()) {
self.dead_after(cond.span(), *span); // both branches are dead
}
// the `then` branch, checked against `expected` when there is an `else`
// and against `None` when there is not; its type `t` is the `if`'s
let t = /* … */;
match els {
Some(e) => {
// the `else` branch, against `t`
if self.is_diverging(then.span()) && self.is_diverging(e.span()) {
self.record_diverging(*span); // every path leaves
}
}
None => { /* `t` must unify with (), or it is IfWithoutElse */ }
}
// Chk-Sub, then record: the end of every arm
if let Some(exp) = expected {
self.unify(&t, exp, *span)?;
}
let t = self.resolve(&t);
self.record(*span, t.clone());
Ok(t)
}
The other forms follow the same pattern. A while calls dead_after(cond.span(), *span) when its
condition diverges, a for makes the same call with its iterable’s span when the iterable
diverges, and a return calls record_diverging(*span) on itself.
The helper module
The starter leaves the module below out. Paste it at the end of src/types/check.rs, after the
closing brace of the impl Checker block. It is a module of its own with its own use lines, so it
cannot clash with the imports your arms add. Its methods are public, so one you have not called yet
raises no warning. It can read the Checker’s private fields because check.rs is part of the
types module.
annotation(ty, ctx, span)checks a type written inside a body, alet’s or a lambda parameter’s, against the declared types and the type parameters in scope. It also replacesSelfby the type animplis for, which matters from Part IX on.native_fn_type(name, span)is the type of a native prelude function, with fresh variables for its generic parts and the bounds of Appendix C recorded on them, orNoneifnameis not a native function. The entries marked#[cfg(not(feature = "m8"))]drop out at M8, where those functions become Bridger code in the prelude thatdeclfinds.global_type(x)is the type of the globalx: its annotation, or the type the driver recorded when it checked the initializer. It isNoneifxis not a global.check_callee(f, ctx)andfinish_call(bounds, param_tys, args, call_span)bracket a call. A bound the callee carries, such asOrdonmin’s parameter, is recorded where the callee is named.finish_callmoves it to the argument whose parameter mentions the bounded type, where the type that fails it is written, solet g = min(f, f);withfa function is reported at the firstf.undetermined(span)is for an operand whose type a rule needs and does not yet know: a fresh variable in dead code,Ambiguousatspanelsewhere.
/// Provided helpers (Milestone M5): plumbing that no typing rule describes.
/// Paste this module at the end of the file, unchanged.
mod helpers {
use crate::ast::{Decl, Expr, Name, Span, Ty, TyKind};
use crate::types::{Checker, Ctx, TyError};
use std::ops::Range;
type Res<T> = Result<T, TyError>;
type Bounds = Range<usize>;
impl Checker {
/// A type annotation written inside a body (`let x: T`, `|x: T|`): `Self`
/// resolved to the enclosing `impl`'s type, and every name it uses checked
/// to be a declared type or a type parameter in scope (`Self` outside an
/// `impl` names no type).
pub fn annotation(&self, ty: &Ty, ctx: &Ctx, span: Span) -> Res<Ty> {
let ty = match ctx.self_ty() {
Some(self_ty) => ty.with_self(&self_ty),
None => ty.clone(),
};
self.validate_type(&ty, span)?;
Ok(ty)
}
/// The type of a native prelude function (Appendix C, "Functions").
/// `print` / `println` / `to_string` carry a `Print` bound and `contains`
/// an `Eq` bound; until M8 `len` carries `Len` and `min` / `max` /
/// `minimum` / `maximum` carry `Ord`, which the built-in types satisfy.
/// `None` for a name that is not a native primitive.
pub fn native_fn_type(&mut self, name: &str, span: Span) -> Option<Ty> {
let opt = |t: Ty| Ty::named("Option".to_string(), vec![t]);
let sig = match name {
"print" | "println" => {
let t = self.fresh_meta();
self.require_bound("Print".to_string(), t.clone(), span);
Ty::func(vec![t], Ty::unit())
}
"to_string" => {
let t = self.fresh_meta();
self.require_bound("Print".to_string(), t.clone(), span);
Ty::func(vec![t], Ty::str())
}
"abs" => Ty::func(vec![Ty::int()], Ty::int()),
"even" | "odd" => Ty::func(vec![Ty::int()], Ty::bool()),
"range" => Ty::func(vec![Ty::int(), Ty::int()], Ty::list(Ty::int())),
#[cfg(not(feature = "m8"))]
"len" => {
let t = self.fresh_meta();
self.require_bound("Len".to_string(), t.clone(), span);
Ty::func(vec![t], Ty::int())
}
#[cfg(not(feature = "m8"))]
"min" | "max" => {
let t = self.fresh_meta();
self.require_bound("Ord".to_string(), t.clone(), span);
Ty::func(vec![t.clone(), t.clone()], t)
}
#[cfg(not(feature = "m8"))]
"minimum" | "maximum" => {
let t = self.fresh_meta();
self.require_bound("Ord".to_string(), t.clone(), span);
Ty::func(vec![Ty::list(t.clone())], opt(t))
}
"contains" => {
let t = self.fresh_meta();
self.require_bound("Eq".to_string(), t.clone(), span);
Ty::func(vec![Ty::list(t.clone()), t], Ty::bool())
}
"head" => {
let t = self.fresh_meta();
Ty::func(vec![Ty::list(t.clone())], opt(t))
}
"tail" => {
let t = self.fresh_meta();
Ty::func(vec![Ty::list(t.clone())], opt(Ty::list(t)))
}
"unwrap_or" => {
let t = self.fresh_meta();
Ty::func(vec![opt(t.clone()), t.clone()], t)
}
"map" => {
let (a, b) = (self.fresh_meta(), self.fresh_meta());
Ty::func(
vec![Ty::list(a.clone()), Ty::func(vec![a], b.clone())],
Ty::list(b),
)
}
"filter" => {
let a = self.fresh_meta();
let keep = Ty::func(vec![a.clone()], Ty::bool());
Ty::func(vec![Ty::list(a.clone()), keep], Ty::list(a))
}
"fold" => {
let (a, b) = (self.fresh_meta(), self.fresh_meta());
Ty::func(
vec![
Ty::list(a.clone()),
b.clone(),
Ty::func(vec![b.clone(), a], b.clone()),
],
b,
)
}
"is_some" => {
let t = self.fresh_meta();
Ty::func(vec![opt(t)], Ty::bool())
}
"read_int" => Ty::func(vec![], Ty::int()),
"read_bool" => Ty::func(vec![], Ty::bool()),
"read_line" => Ty::func(vec![], Ty::str()),
"read_int_opt" => Ty::func(vec![], opt(Ty::int())),
"read_bool_opt" => Ty::func(vec![], opt(Ty::bool())),
"read_line_opt" => Ty::func(vec![], opt(Ty::str())),
_ => return None,
};
Some(sig)
}
/// The type of the top-level global `x`: its annotation, or the type
/// the driver recorded for its initializer. `None` if `x` is not a
/// global.
pub fn global_type(&mut self, x: &Name) -> Option<Res<Ty>> {
let (annot, init) = match self.decl(x) {
Some(Decl::Global(annot, init)) => (annot.clone(), init.clone()),
_ => return None,
};
Some(match (annot, self.global_types.get(x)) {
(Some(ty), _) => Ok(ty),
(None, Some(ty)) => Ok(ty.clone()),
(None, None) => self.check_expr(&init, &Ctx::new(None, None), None),
})
}
/// Synthesize the type of a call's callee, resolved, and note what the
/// call's bookkeeping needs: a named callee is exempt from the
/// determination rule, and the bounds the callee's type carries come
/// back as a range to pass to `finish_call`.
pub fn check_callee(&mut self, f: &Expr, ctx: &Ctx) -> Res<(Ty, Bounds)> {
let start = self.obligations.len();
let ty = self.check_expr(f, ctx, None)?;
if matches!(f, Expr::Var(..)) {
self.note_callee(f.span());
}
Ok((self.resolve(&ty), start..self.obligations.len()))
}
/// Call after checking a call's arguments against the parameter types
/// `param_tys`: a bound the callee placed on a type parameter is
/// reported at the argument whose parameter mentions it, or at the
/// whole call if none does.
pub fn finish_call(
&mut self,
bounds: Bounds,
param_tys: &[Ty],
args: &[Expr],
call_span: Span,
) {
for i in bounds {
let want = &self.obligations[i].1;
let blamed = param_tys.iter().position(|p| contains_ty(p, want));
self.obligations[i].2 = match blamed {
Some(j) => args[j].span(),
None => call_span,
};
}
}
/// An operand whose type is still unknown where its rule needs it: in
/// code that never runs nothing is required of it; elsewhere its type
/// is undetermined.
pub fn undetermined(&mut self, span: Span) -> Res<Ty> {
if self.in_dead_code(span) {
Ok(self.fresh_meta())
} else {
Err(TyError::Ambiguous { span })
}
}
}
/// Whether `needle` occurs in `hay` (structurally, spans aside).
fn contains_ty(hay: &Ty, needle: &Ty) -> bool {
if hay == needle {
return true;
}
let any = |ts: &[Ty]| ts.iter().any(|t| contains_ty(t, needle));
match &hay.kind {
TyKind::Tuple(ts) | TyKind::Named(_, ts) => any(ts),
TyKind::List(t) | TyKind::Ref(t) => contains_ty(t, needle),
TyKind::Fn(ps, r) => any(ps) || contains_ty(r, needle),
_ => false,
}
}
}
Running a program
From this milestone on, cargo run --features m5 checks a program before it runs it. A program the
checker rejects stops with the error before main is called, and the exit status is 1. In
late.brg, total is an ordinary variable, and := assigns only through a reference. The M4
interpreter runs the program until it reaches the assignment:
$ cat late.brg
fn main() {
println("start");
let total = 0;
total := total + 1;
}
$ cargo run -q --features m4 -- late.brg
start
× type error: expected ref<()>, found Int
╭─[late.brg:4:5]
3 │ let total = 0;
4 │ total := total + 1;
· ─────────┬────────
· ╰── here
5 │ }
╰────
With M5’s checker, the program never starts:
$ cargo run -q --features m5 -- late.brg
× type mismatch: expected ref<_>, found Int
╭─[late.brg:4:5]
3 │ let total = 0;
4 │ total := total + 1;
· ──┬──
· ╰── here
5 │ }
╰────
The evaluator reports the assignment, the expression that got stuck. The checker reports total,
the expression whose type disagrees with what := requires of its target: a reference to a fresh
variable, still unsolved when the mismatch is found. An unsolved variable is printed as _.
Because the checker runs first, no program runs under --features m5 until the arms it reaches
are written: the first todo_m5! it meets stops the run. cargo run --features m4 still runs the
evaluator with no checker in front of it.
Worked examples
An expression in an empty context, and the type the checker gives it:
(1, "a").1 : String
[] ++ [true] : [Bool]
|x| x + 1 : fn(Int) -> Int
ref [1] : ref<[Int]>
{ let r = ref 0; r := deref r + 1; deref r } : Int
map([1, 2], to_string) : [String]
fold([1, 2, 3], 0, |acc, x| acc + x) : Int
Expressions the checker rejects:
1 + "a" Mismatch (expected Int, found String), at "a"
(true, 1).4 NoSuchComponent (index 4, arity 2)
{ let f = |x| x(x); () } InfiniteType, at x(x)
{ let f = |x: Int| x; f == f } NoEquality (fn(Int) -> Int)
len(42) UnsatisfiedBound (no impl Len for Int), at 42
{ let xs = []; xs := [1]; () } Mismatch (expected ref<_>, found [_]), at xs
abs(1, 2) ArityMismatch (`abs` takes 1, given 2)
How to know it works
cargo m5 runs the provided milestone M5 tests and, with them, the M4, M3, M2, and M1 tests. The M5
tests come in two kinds. The first builds an Expr by hand with the builders of the earlier
milestones and types it in an empty context with infer, or against a type with check. The
second loads a program from source and checks it with check_program, on top of the prelude:
// |x| x + 1 : fn(Int) -> Int
let f = lambda(&["x"], binary(Add, var("x", 1), int(1, 2), 3), 0);
assert_eq!(infer(&f).unwrap(), Ty::func(vec![Ty::int()], Ty::int()));
// a whole program, checked declaration by declaration
let src = "fn double(x: Int) -> Int = x * 2; fn main() -> Int = double(21);";
assert!(check_program(src).is_ok());
A rejection is tested by its TyError variant and, where it matters, by its span and payload,
never by the text of its message. Fill the arms and unify, run cargo m5, and repeat until every
M5 test, and every earlier one, passes. Then run the programs you wrote for M4 with
cargo run --features m5: a correct program that the checker rejects is either a bug in an arm or
one of the conservative rejections Part VI describes, and the error’s
span says which expression to look at.
Design problems
Your evaluator and your checker make claims about the same programs, and soundness says how the claims relate. Design a test that finds bugs in check.rs without a list of expected types, and say which bugs it cannot find. Try it on a unify that unifies two function types' parameters and forgets their results.
Run every program the checker accepts through the evaluator, and require that none of them stops
with an error soundness rules out: TypeError, NotAFunction, ArityMismatch, UnboundVariable,
or a projection a tuple does not have. A program for the test can come from anywhere, the
examples in this book, the programs you wrote for M1–M4, or a generator, because the test needs to
know only whether the checker accepted it. The forgetful unify accepts
fn apply(f: fn(Int) -> String) -> String = f(1);
fn main() {
let g = |x: Int| x;
println(apply(g) ++ "!");
}
since g’s type fn(Int) -> Int agrees with fn(Int) -> String once the results are skipped.
When run, the program stops at apply(g) ++ "!" with type error: expected String, found Int, and
the test reports it. A checker with this mistake passes every provided M5 test. The test cannot
find a checker that rejects too much: a checker that rejects every program passes it. It cannot
find a wrong variant or span on a correct rejection. And it finds an unsound acceptance only if a
run reaches the code the mistake let through: with the call to apply in a branch that never
runs, the program prints and exits normally. The provided tests check rejections, their variants
and spans included; the soundness test checks what the checker accepts.
The checker rejects this program at ref [], although every use of log agrees that it holds strings. Moved into main as a local let, the same cell checks. Why does the checker treat the two differently, and what could go wrong if it accepted the first?
let log = ref [];
fn note(s: String) { log := s :: deref log; }
fn main() {
note("start");
note("stop");
println(deref log);
}
A global is checked in a declaration of its own, before any function body, and that declaration
must determine its type completely. ref [] leaves the element type open, and the uses that would
settle it are in note and main, other declarations, which no inference variable outlives. As a
local, the cell and its uses are one declaration, main’s body, and note("start") solves the
variable there; the program prints ["stop", "start"].
There are two ways a checker could accept the global version. If each function solved the global’s
variable for itself, a second function fn count(n: Int) { log := n :: deref log; } would solve it
as Int, note as String, and both would be accepted. The M4 evaluator runs that program and
prints [2, "start"]: one cell read and written at two types, the generalized reference
cell of the soundness chapter in another
form. If instead every declaration shared one variable for the global, the conflict would be caught,
but the type of log would depend on which body the checker visited first. Bodies could no
longer be checked in any order, each against its own signature, as the top-level
context allows. The annotation
let log: ref<[String]> = ref []; states the type in the one place every use can see.
Write make_stack, generic in the item type, which returns a pair of closures over one shared cell: one pushes an item, the other removes the top item and returns it as an Option. Use it at Int and at String in one main. Which annotations does the program need beyond the signature?
fn make_stack<T>() -> (fn(T) -> (), fn() -> Option<T>) {
let items = ref [];
let push = |x| items := x :: deref items;
let pop = || {
let top = head(deref items);
items := unwrap_or(tail(deref items), []);
top
};
(push, pop)
}
fn main() {
let ints = make_stack();
ints.0(1);
ints.0(2);
println(ints.1()); // Some(2)
println(ints.1()); // Some(1)
println(ints.1()); // None
let words = make_stack();
words.0("hi");
println(words.1()); // Some("hi")
}
None. In the body, items starts as a reference to a list of and push’s parameter as
, and x :: deref items makes equal to . The tail (push, pop) is checked
against the declared result type, which hands fn(T) -> () to push and solves as T.
In main, each call of make_stack instantiates T with a fresh variable, and ints.0(1) and
words.0("hi") solve the two variables as Int and String, within main’s declaration. A
ref [] is accepted here where the global log of the previous problem was not: each call of
make_stack allocates its own cell at one instantiation of T, and every use of that cell is in
the declaration that fixed it.
The checker stops at the first error. Many compilers report several. What would check_expr have to do to keep going after a mismatch, and what has to be true of the type it continues with?
check_expr returns a Result, and ? passes the first Err up through every enclosing arm and
out of the driver, so the first error ends the check. To keep going, an arm would push the error
onto a list and return a type in place of the Err. Which type it returns decides whether later
reports are real. In
fn main() {
let xs = [1, 2];
let total = sum(xs);
println(total);
}
sum is unbound. If the Var arm reported it and continued with a fresh variable, the call would
infer fn([Int]) -> β for it, and total would have type , which nothing solves. The
determination rule would then report total as undetermined: a second error that exists only
because of the first. The type an arm continues with has to agree with every type in unify, be
exempt from the determination rule and from bounds, and never appear in a later message. The Rust
compiler gives such an expression an error type that behaves this way. Across declarations, Bridger
needs nothing more. The driver could check the next declaration after a failed one, since no type
crosses a declaration except through a signature, and a mistake inside one body cannot change what
the checker concludes about another.