Skip to content

Fire Code

[译]通过例子学子类型推理(七):跨越式错误信息

上周,我们介绍了在 Cubiml 中添加字面量和运算符,并展示了该语言的第一个完整演示。然而,编译器虽然可以工作,但对用户并不友好。特别是,当发生类型不匹配时它只是打印出“Unexpected types”,没有细化。本周,我们将改进 cubiml 的错误信息。

跨越式错误信息

大多数现代编译器已经趋向于跨越式错误信息,也就是显示你的代码并指出与错误相关的特定部分的错误信息。

例如,这里是 Clang 的错误信息示例,Clang 是一个专注于高质量错误信息的 C++ 编译器。

$ clang -fsyntax-only t.c
t.c:5:11: error: indirection requires pointer operand ('int' invalid)
 int y = \*SomeA.X;
 ^~~~~~~~

作为另一个例子,这里是 Elm 中典型的编译器错误信息。

\-- TYPE MISMATCH ----------------------------------------------- Jump To Problem The 1st argument to \`NewFace\` is not what I expect:

59|           NewFace Model 1
                      ^^^^^
This \`Model\` value is a:

    Int -> Model

But \`NewFace\` needs the 1st argument to be:

    Int

cubiml 的早期前身 IntercalScript 也采用了类似的方法:

TypeError: Unexpected bool
typeck6.ics:26:45: Note: bool originates here
    if context == null then context = {fut: false, unsafe: false, dead: false} e
                                            ^----
typeck6.ics:658:25: but it is required to be an object here
                fut-span.print("Note: future assignment begins here")
                        ^-----

在这篇文章中,我们将看到如何在 cubiml 中实现类似的错误信息。

跨度

第一步当然是添加跨度。跨度指的是输入源文件的一些连续的部分。从概念上讲,跨度就是(源文件,开始位置,结束位置)的三连体。在解析输入时,我们将抽象语法树中可能涉及错误的每一部分都关联一个跨度,然后在发生错误时,利用跨度信息打印出格式良好的带代码片段的消息。

然而,在实现中,我们不实际传递这些三连体,而是将它们隐藏在一个特殊的 SpanManager 类中,以提高关注点的分离,并希望提高性能。SpanManager 类发出不透明的 Span 值,这些值背地里只是进入存储真实数据的管理器的内部列表的一个索引。

#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct Span(usize);

#[derive(Debug, Default)]
pub struct SpanManager {
    sources: Vec<String>,
    spans: Vec<(usize, usize, usize)>,
}
impl SpanManager {
    fn new_span(&mut self, source_ind: usize, l: usize, r: usize) -> Span {
        let i = self.spans.len();
        self.spans.push((source_ind, l, r));
        Span(i)
    }
}

我们还捕获跨度,所以如果同一个元组被传入两次,我们可以重用同一个索引,而不是在内部列表中创建一个多余的跨度。这并不是严格必需的,但我们也可以这样做,而且它确实允许做一些很好的事情,比如相等比较。

在解析源文件的过程中,所有创建的新跨度都将指向同一个源文件,所以我们将跨度创建过程包装在第二个类 SpanMaker 中,它记住了当前的源文件,所以只需要传递起始和结束位置。因此跨度创建是一个两步的过程:首先将源文件添加到返回 SpanMakerSpanManager 中,然后调用 SpanMaker.span(l,r) 来创建新跨度。

impl SpanManager {
    pub fn add_source(&mut self, source: String) -> SpanMaker {
        let i = self.sources.len();
        self.sources.push(source);
        SpanMaker {
            parent: self,
            source_ind: i,
            pool: Default::default(),
        }
    }
}

#[derive(Debug)]
pub struct SpanMaker<'a> {
    parent: &'a mut SpanManager,
    source_ind: usize,
    pool: HashMap<(usize, usize), Span>,
}

由于只有同一源文件的跨度才可能是潜在的重复,所以我们在 SpanMaker 内部使用一个存储了 (left, right) 对的 HashMap 来进行重复检测。当调用 span(l,r) 时,我们只需要检查 (l,r) 是否已经存在于映射中,如果不存在,就在父 SpanManager 的列表中插入一个新条目。

impl<'a> SpanMaker<'a> {
    pub fn span(&mut self, l: usize, r: usize) -> Span {
        // 让借用检查器开心
        let source_ind = self.source_ind;
        let parent = &mut self.parent;

        *self.pool.entry((l, r)).or_insert_with(|| parent.new_span(source_ind, l, r))
    }
}

由于 Rust 的借用检查器的限制,我们需要在插入之前将 SpanMaker 的字段复制到本地变量。有可能将来 Rust 的改进会使这一点变得不必要。

impl SpanManager {
    pub fn print(&self, span: Span) -> String;
}

接下来,我们需要一种方法来实际转换 Span 到那些格式化良好的错误信息。这在 SpanManager.print 方法中完成。基本的想法就是在源码中找到跨度所覆盖的那部分行,然后在它下面打印适当长度的 ^~~~~~ 字符串。cubiml 的实现还包括最多两行的上下文,围绕着跨度发生的行,导致如下结果:

let abs_float =
    fun x -> if x <. 0. then 0. -. x else x;
    ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

let rec fib = fun x ->

我不会在这里展示它的实现,因为它只是很多繁琐的字符串分割和格式化,但如果你好奇的话,可以在这里看到完整的代码。

pub type Spanned<T> = (T, Span);

最后,我们定义了一个公开的类型定义 Spanned<T>,它将跨度与给定的值关联起来,以便在代码库的其他地方使用。

抽象语法树

接下来,我们必须在抽象语法树中添加一个地方来存储跨度,然后在解析器中填充它们。回想一下,我们需要为可能想在错误信息中突出显示的代码的任何部分存储跨度。这有点主观,有很多方法来构建 AST,但这是我采用的实现方式。

pub enum Expr {
    BinOp(Spanned<Box<Expr>>, Spanned<Box<Expr>>, OpType, Op, Span),
    Call(Box<Expr>, Box<Expr>, Span),
    Case(Spanned<String>, Box<Expr>),
    FieldAccess(Box<Expr>, String, Span),
    FuncDef(Spanned<(String, Box<Expr>)>),
    If(Spanned<Box<Expr>>, Box<Expr>, Box<Expr>),
    Let(VarDefinition, Box<Expr>),
    LetRec(Vec<VarDefinition>, Box<Expr>),
    Literal(Literal, Spanned<String>),
    Match(Box<Expr>, Vec<(Spanned<CaseMatchPattern>, Box<Expr>)>, Span),
    Record(Spanned<Vec<(Spanned<String>, Box<Expr>)>>),
    Variable(Spanned<String>),
}

解析器

为了在解析器中生成跨度,首先需要一种方法将 SpanMaker 传递到解析器中,这样就可以在语法产品中使用它。幸运的是,Lalrpop 让这一点很容易做到。你所要做的就是在语法文件的顶部添加这行代码,生成的解析器函数就会多出一个 ctx 参数,然后就可以在任何一个语法规则中访问这个参数。

grammar(ctx: &mut spans::SpanMaker<'input>);

Lalrpop 访问源位置的语法有点烦人,幸运的是可以用宏来包装。

Spanned<T>: spans::Spanned<T> = {
    <l: @L> <val: T> <r: @R> => (val, ctx.span(l, r))
};

Spanned<T> 宏接收一个语法规则,也就是终端和非终端的序列,作为 T 参数,并返回该产品产生的结果,除了在包装时增加了一个 Span

接下来,我们必须更新所有的语法规则,将生成的跨度添加到 AST 中,然后更新类型检查器前端和代码生成(照例跳过)来处理 AST 的改变的形状。这有点繁琐,而且每种类型的 AST 节点大多相同,所以我只展示两种最复杂的情况,即匹配和二元运算符,其余的让你去解决。和往常一样,本文的完整代码也可以在 Github 上查看。

CaseMatchPattern = {
    Tag Ident,
}
MatchArm = {
    <Spanned<CaseMatchPattern>> "->" <CallExpr>,
}
MatchSub = "match" <Spanned<Expr>> "with" <SepList<MatchArm, "|">>;
Match: Box<ast::Expr> = {
    MatchSub => {
        let ((param, span), arms) = <>;
        Box::new(ast::Expr::Match(param, arms, span))
    }
}

对于匹配,我们需要为每个被匹配的 case 模式获取一个跨度,同时也需要为输入参数获取一个跨度。前者通过将 CaseMatchPattern 封装在 Spanned 宏中来处理。在后一种情况下,我们将输入的 Expr 封装在 Spanned 中,但是将跨度稍微重新排列一下,并将其贴在 ast::Expr::Match 的最后一个参数中,以简化 AST 结构。

MultOpSub: (ast::OpType, ast::Op) = {
    "*" => (ast::OpType::IntOp, ast::Op::Mult),
    "/" => (ast::OpType::IntOp, ast::Op::Div),
    "*." => (ast::OpType::FloatOp, ast::Op::Mult),
    "/." => (ast::OpType::FloatOp, ast::Op::Div),
}
MultOp: Box<ast::Expr> = {
    Spanned<(Spanned<MultExpr> MultOpSub Spanned<CallExpr>)> => {
        let ((lhs, op, rhs), span) = <>;
        Box::new(ast::Expr::BinOp(lhs, rhs, op.0, op.1, span))
    },
}

对于二元运算符,我们为每个输入操作数创建跨度,也为整个表达式创建跨度。我还对这些规则进行了重构,以避免同一优先级类的每个单独运算符的代码重复。

错误信息

接下来,我们需要一种方法来报告包含跨度的错误信息。此外,还需要对 cubiml 的错误处理进行一些清理。

在 cubiml 的初始版本中,我们创建了单独的 SyntaxErrorTypeError 类,分别用来保存语法和类型错误。然而,我们从来没有真正利用过它们是独立类型这一事实。相反,这两个错误类只是错误信息字符串的静默持有者,被直接传给了用户。因此,我们将定义一个新的 SpannedError 类来表示包含跨度的错误消息,并重新定义 SyntaxErrorTypeError 只是 SpannedError 的别名。

让所有的错误都是相同的类型,也让我们摆脱了类型检查器前端中的 Box<dyn error::Error> 废话。将 SyntaxErrorTypeError 分开是另一个工程上的决定,在当时看来是个好主意,但事实证明是不必要的。

总之,这是新的 SpannedError 类。它只是持有了一个或多个字符串和跨度对。impl Into<String> 的东西只是一个让 API 更方便的技巧,让我们可以传递静态的 &str 或者拥有的 String,而不需要显式转换。

#[derive(Debug)]
pub struct SpannedError {
    pairs: Vec<(String, Span)>,
}

impl SpannedError {
    pub fn new1(s1: impl Into<String>, s2: Span) -> Self {
        let p1 = (s1.into(), s2);
        SpannedError { pairs: vec![p1] }
    }

    pub fn new2(s1: impl Into<String>, s2: Span, s3: impl Into<String>, s4: Span) -> Self {
        let p1 = (s1.into(), s2);
        let p2 = (s3.into(), s4);
        SpannedError { pairs: vec![p1, p2] }
    }
}

Error 特质要求实现类型可以显示为字符串。然而,我们所有的跨度打印逻辑都隐藏在 SpanManager 类中,这意味着不访问 SpanManager 就无法正确地打印错误。因此,我们只是用一个不做任何事情的方法来假装实现 Error

impl fmt::Display for SpannedError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        Ok(())
    }
}
impl error::Error for SpannedError {}

取而代之的是,我们有单独的接收 SpanManager 的方法来实际打印错误信息。

pub fn print(&self, sm: &SpanManager) -> String {
    let mut out = String::new();
    for (msg, span) in self.pairs.iter() {
        out += &msg;
        out += "\n";
        out += &sm.print(*span);
    }
    out
}

匹配表达错误

现在我们有了新的错误类,是时候使用它了。我们将从报告重复匹配 case 的错误开始。

let mut case_names = HashMap::with_capacity(cases.len());
for (((tag, name), case_span), rhs_expr) in cases {
    if let Some(old_span) = case_names.insert(&*tag, *case_span) {
        return Err(SyntaxError::new2(
            "SyntaxError: Repeated match case",
            *case_span,
            "Note: Case was already matched here",
            old_span,
        ));
    }

为了显示重复匹配 case(或者类似于重复记录字段)的有用错误,我们需要指向被重复的 case 以及该 case 之前的出现。以前,我们只是跟踪已经看到的 case 标记的,以便检测重复,但现在,我们用一个从 case 标记到跨度的映射来代替。每当我们处理新的 case 时,如果它还没有在映射中,我们就把它和它的跨度一起插入映射中。如果它在映射中,我们可以从映射中检索该 case 标记第一次出现的跨度,并将它与该标记新出现的实例的跨度一起传递给 SpannedError::new2

例如,下面的 cubiml 代码

fun x ->
    match x with
      `Foo a -> 0
    | `Bar b -> 1
    | `Foo c -> 2

产生以下错误信息:

SyntaxError: Repeated match case
      `Foo a -> 0
    | `Bar b -> 1
    | `Foo c -> 2
      ^~~~~~
Note: Case was already matched here
fun x ->
    match x with
      `Foo a -> 0
      ^~~~~~
    | `Bar b -> 1
    | `Foo c -> 2

除此之外,在处理匹配表达式时,只需要对前端进行小幅更新。

-        Match(match_expr, cases) => {
+        Match(match_expr, cases, span) => {
             let match_type = check_expr(engine, bindings, match_expr)?;
             let (result_type, result_bound) = engine.var();

我们只是从 ast 节点中获取新添加的跨度字段 ...

-            let bound = engine.case_use(case_type_pairs);
+            let bound = engine.case_use(case_type_pairs, *span);
             engine.flow(match_type, bound)?;

并将其传递给 engine.case_use,供类型错误使用。

二元运算符

为了在类型错误的情况下报告跨度错误信息,我们需要在类型检查器核心的类型图中跟踪跨度。现在,我们使用一个非常简单的方案,它只是为每个值和用跟踪跨度。在类型不匹配事件中,我们只需显示显示值来源的跨度和使用不兼容方式的跨度。

作为如何将新跨度从前端传递到核心的例子,这里是二进制运算符在前端的新实现。

BinOp((lhs_expr, lhs_span), (rhs_expr, rhs_span), op_type, op, full_span) => {
    use ast::OpType::*;
    let lhs_type = check_expr(engine, bindings, lhs_expr)?;
    let rhs_type = check_expr(engine, bindings, rhs_expr)?;

    Ok(match op_type {
        IntOp => {
            let lhs_bound = engine.int_use(*lhs_span);
            let rhs_bound = engine.int_use(*rhs_span);
            engine.flow(lhs_type, lhs_bound)?;
            engine.flow(rhs_type, rhs_bound)?;
            engine.int(*full_span)
        }
        FloatOp => {
            let lhs_bound = engine.float_use(*lhs_span);
            let rhs_bound = engine.float_use(*rhs_span);
            engine.flow(lhs_type, lhs_bound)?;
            engine.flow(rhs_type, rhs_bound)?;
            engine.float(*full_span)
        }
        StrOp => {
            let lhs_bound = engine.str_use(*lhs_span);
            let rhs_bound = engine.str_use(*rhs_span);
            engine.flow(lhs_type, lhs_bound)?;
            engine.flow(rhs_type, rhs_bound)?;
            engine.str(*full_span)
        }
        IntCmp => {
            let lhs_bound = engine.int_use(*lhs_span);
            let rhs_bound = engine.int_use(*rhs_span);
            engine.flow(lhs_type, lhs_bound)?;
            engine.flow(rhs_type, rhs_bound)?;
            engine.bool(*full_span)
        }
        FloatCmp => {
            let lhs_bound = engine.float_use(*lhs_span);
            let rhs_bound = engine.float_use(*rhs_span);
            engine.flow(lhs_type, lhs_bound)?;
            engine.flow(rhs_type, rhs_bound)?;
            engine.bool(*full_span)
        }
        AnyCmp => engine.bool(*full_span),
    })
}

代码很长,但大部分都很简单。对于每个可能的 OpType,主要逻辑都是重复的,但基本思路很简单。对于每个操作数,左,右,我们得到该表达式的跨度,并将其传递给相应的绑定函数。

BinOp((lhs_expr, lhs_span), (rhs_expr, rhs_span), op_type, op, full_span) => {
// ...
let lhs_bound = engine.int_use(*lhs_span);
let rhs_bound = engine.int_use(*rhs_span);

以前,我们为两个操作数使用单一的绑定(用类型),但现在我们必须创建单独的用类型,因为相关的跨度信息会有所不同。这就允许更具体的错误信息,显示操作数中的哪一个导致了错误,就像下面的例子:

TypeError: Value is required to be a integer here,
let y = "Hello, world!";
let z = y;
5 + x * z + 23
        ^
But that value may be a string originating here.
let x = -18;
let y = "Hello, world!";
        ^~~~~~~~~~~~~~~
let z = y;
5 + x * z + 23
BinOp((lhs_expr, lhs_span), (rhs_expr, rhs_span), op_type, op, full_span) => {
// ...
engine.int(*full_span)

我们还有新的 full_span 字段,它包含了整个运算符表达式的跨度,我们将其传递给运算符的返回类型的值构造器。这使得我们也可以在运算符的结果涉及类型错误时打印错误信息,就像下面的例子一样。

TypeError: Value is required to be a record here,
let x = 7.8 *. -9.22;
x.foo
 ^~~~
But that value may be a float originating here.
let x = 7.8 *. -9.22;
        ^~~~~~~~~~~~
x.foo

类型检查器核心

现在我们已经更新了解析器、AST 和类型检查器前端,剩下的就是对类型检查器核心进行上述修改。

 enum TypeNode {
     Var,
-    Value(VTypeHead),
-    Use(UTypeHead),
+    Value((VTypeHead, Span)),
+    Use((UTypeHead, Span)),
 }

首先,用 (head, span) 对替换所有类型节点头。

-    fn new_val(&mut self, val_type: VTypeHead) -> Value {
+    fn new_val(&mut self, val_type: VTypeHead, span: Span) -> Value {
         let i = self.r.add_node();
         assert!(i == self.types.len());
-        self.types.push(TypeNode::Value(val_type));
+        self.types.push(TypeNode::Value((val_type, span)));
         Value(i)
     }

-    pub fn func_use(&mut self, arg: Value, ret: Use) -> Use {
-        self.new_use(UTypeHead::UFunc { arg, ret })
+    pub fn func_use(&mut self, arg: Value, ret: Use, span: Span) -> Use {
+        self.new_use(UTypeHead::UFunc { arg, ret }, span)
     }

-    pub fn obj(&mut self, fields: Vec<(String, Value)>) -> Value {
+    pub fn obj(&mut self, fields: Vec<(String, Value)>, span: Span) -> Value {
         let fields = fields.into_iter().collect();
-        self.new_val(VTypeHead::VObj { fields })
+        self.new_val(VTypeHead::VObj { fields }, span)
     }

然后更新构造器函数,接收并传递跨度。这个改变是相当重复的,所以我只是展示了其中一部分。我相信你会明白的。

-fn check_heads(lhs: &VTypeHead, rhs: &UTypeHead, out: &mut Vec<(Value, Use)>) -> Result<(), TypeError> {
+fn check_heads(lhs: &(VTypeHead, Span), rhs: &(UTypeHead, Span), out: &mut Vec<(Value, Use)>) -> Result<(), TypeError> {
     use UTypeHead::*;
     use VTypeHead::*;

-    match (lhs, rhs) {
+    match (&lhs.0, &rhs.0) {
         (&VBool, &UBool) => Ok(()),
         (&VFloat, &UFloat) => Ok(()),

接下来,当然需要更新 check_heads,以接收 (head, span) 对,而不是仅节点头本身。

                     out.push((lhs2, rhs2));
                     Ok(())
                 }
-                None => Err(TypeError(format!("Missing field {}", name))),
+                None => Err(TypeError::new2(
+                    format!("TypeError: Missing field {}\nNote: Field is accessed here", name),
+                    rhs.1,
+                    "But the record is defined without that field here.",
+                    lhs.1,
+                )),
             }
         }
         (&VCase { case: (ref name, lhs2) }, &UCase { cases: ref cases2 }) => {

然后修改“缺失字段”和“缺失 case”的错误来穿过新增加的跨度,得出这样的错误信息。

TypeError: Missing field bar
Note: Field is accessed here
let x = {foo = 6};
x.bar
 ^~~~
But the record is defined without that field here.
let x = {foo = 6};
        ^~~~~~~~~
x.bar

最后,到了不匹配的类型头处理器。以前,这只是一个简单的 _ => Err(TypeError("Unexpected types".to_string()),。然而,现在我们不仅需要打印出违规值和使用类型的跨度,还需要打印出它们使用了哪些头部构造函数。新版本的“意外类型”处理器如下。

let found = match lhs.0 {
    VBool => "boolean",
    VFloat => "float",
    VInt => "integer",
    VStr => "string",
    VFunc { .. } => "function",
    VObj { .. } => "record",
    VCase { .. } => "case",
};
let expected = match rhs.0 {
    UBool => "boolean",
    UFloat => "float",
    UInt => "integer",
    UStr => "string",
    UFunc { .. } => "function",
    UObj { .. } => "record",
    UCase { .. } => "case",
};

Err(TypeError::new2(
    format!("TypeError: Value is required to be a {} here,", expected),
    rhs.1,
    format!("But that value may be a {} originating here.", found),
    lhs.1,
))

解析器错误

我们现在已经得到了更好的类型和语法错误,但还有最后一个点缀要做。既然有了显示跨度错误的能力,我们也可以用它来显示解析错误。解析错误是由 lalrpop 本身产生的,这个过程我们无法控制,但可以很容易地写一个函数来将 lalrpop 产生的错误转换成漂亮的跨度错误。

fn convert_parse_error<T: Display>(mut sm: SpanMaker, e: ParseError<usize, T, &'static str>) -> SpannedError {
    match e {
        ParseError::InvalidToken { location } => {
            SpannedError::new1("SyntaxError: Invalid token", sm.span(location, location))
        }
        ParseError::UnrecognizedEOF { location, expected } => SpannedError::new1(
            format!(
                "SyntaxError: Unexpected end of input.\nNote: expected tokens: [{}]\nParse error occurred here:",
                expected.join(", ")
            ),
            sm.span(location, location),
        ),
        ParseError::UnrecognizedToken { token, expected } => SpannedError::new1(
            format!(
                "SyntaxError: Unexpected token {}\nNote: expected tokens: [{}]\nParse error occurred here:",
                token.1,
                expected.join(", ")
            ),
            sm.span(token.0, token.2),
        ),
        ParseError::ExtraToken { token } => {
            SpannedError::new1("SyntaxError: Unexpected extra token", sm.span(token.0, token.2))
        }
        ParseError::User { error: msg } => unreachable!(),
    }
}

演示

今后的工作

现在我们有了更漂亮的编译器错误信息。然而,还有一些问题。

制表符和 unicode

目前高亮显示跨度的方法是在行内找到跨度的偏移量,然后在下面打印适当数量的空格和 ~ ,使 ^~~~~ 部分与跨度对齐。

只要源代码中的每一个字节在显示时占用相同的空格,这种方法就适用。但是,制表符和 unicode 字符违背了这个假设。

例如,下面的代码,其中包含一个 unicode 字符串和 s + 2 前的制表符。

let s = "This is ünicodè -> 𒍅 <-";
    s + 2

产生以下错误

TypeError: Value is required to be a integer here,
let s = "This is ünicodè -> 𒍅 <-";
    s + 2
 ^
But that value may be a string originating here.
let s = "This is ünicodè -> 𒍅 <-";
        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    s + 2

请注意第一个箭头是如何错位的,因为它将制表符只算作一个空格,而不是这里显示的四个空格。同时,由于使用了多字节的 unicode 字符,第二个跨度高亮延伸过了它所高亮的字符串的末端。

遗憾的是,我认为在传统的命令行界面范式内,没有很好的方法来解决这个问题,因为输出的内容被限制在静态的纯文本上。然而,可以通过更广泛的编辑器集成来解决这个问题,让代码编辑器自然地意识到错误信息中的跨度。这样一来,编辑器就可以直接高亮显示相关的跨度,无论它们出现在编辑器窗口中的什么地方。

更长的错误链

目前,我们只是在代码中显示出值的原始位置以及它的不兼容使用的位置。这通常工作的很好,特别是对于像这里展示的小型代码样本。然而对于更大、更复杂的代码库来说,这种方法有时无法提供有关问题真实位置的相关信息。

问题是,虽然流的端点是错误的最常见位置,但真正的错误可能在导致编译器推断出类型错误的推理链中的任何位置。例如,如果你有两个标准库类型 FooBar,而你不小心把 Bar 传给了期望 Foo 的函数,那么产生的错误信息只会显示标准库中的两个位置(Bar 构造器和 Foo 函数),而真正错误的位置是发生混合函数调用的用户代码中。

我在开发 IntercalScript 时多次遇到这个问题,这是 cubiml 的早期前身,使用了同样的错误报告方法。遗憾的是,我认为这个问题没有很好的解决方法。当然,你可以很容易地修改 cubiml 来输出导致类型错误的整个推理链,并向用户显示每一步。然而,这个链条在病理情况下会大得不可想象,程序的大小要么是指数型,要么是二次方,取决于你如何输出。

在传统的基于合一的语言中,这个链的最大长度只能是程序大小的线性,但这还是太大了,无法合理地显示给程序员。在 Haskell 等语言中,人们已经花了很多笔墨试图解决这个问题,但没有得到满意的答案。在实践中,人们只是在代码中随处放置手动类型注释,以避免这个问题。然而,我认为更有趣的潜在方法是,再次的,深度编辑器集成,提供一种机制来交互地显示错误,让程序员可以根据需求扩展信息,并利用他们对代码意图的知识快速钻到实际问题。

总之,错误信息说完了,下周我们将回归到为 cubiml 的类型系统添加功能。特别是,我们将添加对可变性的支持。