37. Groovy 类型检查扩展,第一篇 编写类型检查扩展

2023-02-23 17:53:42 浏览数 (2)

1. 介绍

本篇Groovy学习笔记第37篇。开始介绍Groovy中的扩展类型检查相关知识。学会如何定义我们的类型检查器。

在前面分享的关于类型知识,更多的是依靠Groovy中的静态类型检查器实现的。

而本篇开始要介绍的就是定义我们自己的类型检查。也就叫做类型检查扩展,定义自己的类型检查器。

类型检查扩展是一种机制,它允许DSL引擎的开发人员对常规groovy类应用静态类型检查所允许的相同类型的检查,从而使这些脚本更加安全。

PS:总的来说,类型检测扩展的相关知识,可能更多的适合于采用Groovy进行插件开发的工程师使用。用于检测定义的DSL脚本是否合规等。

2. 编写类型检查扩展

下面来介绍,如何编写我们的类型检查。

2.1 智能的类型检查器

Groovy可以在编译时与静态类型检查器一起使用,使用@TypeChecked注解启用。在这种模式下,编译器会变得更加冗长,并抛出错误,例如拼写错误、不存在的方法等。不过,这也带来了一些限制,其中大多数限制来自Groovy本质上仍然是一种动态语言。例如,你不能在使用标记构建器的代码上使用类型检查:

代码语言:javascript复制
def builder = new MarkupBuilder(out)
builder.html {
    head {
        // ...
    }
    body {
        p 'Hello, world!'
    }
}

在上面的例子中,htmlheadbodyp方法都不存在。但是,如果执行代码,它可以工作,因为Groovy使用动态分派并在运行时转换这些方法调用。在这个构建器中,我们可以使用的标记数量和属性都没有限制,这意味着类型检查器没有机会在编译时知道所有可能的方法(标记),除非我们创建一个专用于HTML的构建器。

Groovy是实现内部DSL的首选平台。灵活的语法,结合运行时和编译时元编程功能,使Groovy成为一个有趣的选择,因为它允许程序员专注于DSL,而不是工具或实现。由于Groovy DSL是Groovy代码,因此很容易获得IDE工具的支持,而不必编写专门的插件。

在很多情况下,DSL引擎是用Groovy(或Java)编写的,然后用户代码作为脚本执行,这意味着在用户逻辑之上有某种包装器。例如,包装器可能包含在GroovyShellGroovyScriptEngine中,它们在运行脚本之前透明地执行一些任务(添加导入、应用AST转换、扩展基本脚本等等)。通常,用户编写的脚本无需测试就可以投入生产,因为DSL逻辑达到了任何用户都可以使用DSL语法编写代码的地步。最后,用户可能会忽略他们所编写的实际上是代码。这为DSL实现者增加了一些挑战,例如确保用户代码的执行,或者在这种情况下,及早报告错误。

例如,想象一个DSL:其目标是远程驾驶火星上的漫游者。向探测器发送信息大约需要15分钟。如果漫游者执行脚本失败,出现一个错误(比如一个错字),你就有两个问题:

  • 首先,反馈只在30分钟后出现(探测器获得脚本所需时间和接收错误所需时间)
  • 其次,脚本的某些部分已经执行,您可能必须对固定脚本进行重大更改(这意味着您需要知道漫游车的当前状态……)

类型检查扩展是一种机制,它允许DSL引擎的开发人员对常规groovy类应用静态类型检查所允许的相同类型的检查,从而使这些脚本更加安全。

这里的原则是尽早失败,也就是说尽快编译脚本失败,如果可能的话向用户提供反馈(包括漂亮的错误消息)。

简而言之,类型检查扩展背后的思想是让编译器知道DSL使用的所有运行时元编程技巧,这样脚本就可以获得与冗长的静态编译代码相同级别的编译时检查。我们将看到,您可以执行普通类型检查器无法执行的检查,从而为用户提供强大的编译时检查。

2.2 extensions属性

@TypeChecked注释支持名为extensions的属性。此参数接受一个字符串数组,对应于类型检查扩展脚本列表。这些脚本在编译时在类路径中找到。例如:

代码语言:javascript复制
@TypeChecked(extensions='/path/to/myextension.groovy')
void foo() { ...}

在这种情况下,foo方法将使用普通类型检查器的规则进行类型检查,这些规则由myextension中找到的规则完成groovy脚本。

PS:注意,虽然在内部类型检查器支持多种机制来实现类型检查扩展(包括普通的旧java代码),但推荐的方法是使用那些类型检查扩展脚本。

2.3 用于类型检查的DSL

类型检查扩展背后的思想是使用DSL来扩展类型检查器功能。这个DSL允许我们使用“event-driven”API钩入编译过程,更具体地说是类型检查阶段。例如,当类型检查器进入一个方法体时,它会抛出一个beforeVisitMethod事件,扩展可以对该事件做出反应:

代码语言:javascript复制
beforeVisitMethod { methodNode ->
 println "Entering ${methodNode.name}"
}

假设我们手头有这个漫游者DSL。用户可以这样写:

代码语言:javascript复制
robot.move 100

如果你有一个这样定义的类:

代码语言:javascript复制
class Robot {
    Robot move(int qt) { this }
}

脚本可以在执行之前使用以下脚本进行类型检查:

代码语言:javascript复制
def config = new CompilerConfiguration()
config.addCompilationCustomizers(
    new ASTTransformationCustomizer(TypeChecked)//编译器配置将@TypeChecked注释添加到所有类中
)
def shell = new GroovyShell(config)//使用GroovyShell中的配置                         
def robot = new Robot()
shell.setVariable('robot', robot)
shell.evaluate(script)  //这样,使用shell编译的脚本将使用@ typecheck编译,而用户无需显式地添加它

使用上面的编译器配置,我们可以透明地将@typecheck应用于脚本。在这种情况下,它将在编译时失败,输出下面的错误日志:

代码语言:javascript复制
[Static type checking] - The variable [robot] is undeclared.

现在,我们将稍微更新配置以包含extensions参数:

代码语言:javascript复制
config.addCompilationCustomizers(
    new ASTTransformationCustomizer(
        TypeChecked,
        extensions:['robotextension.groovy'])
)

然后将以下内容添加到类路径中:

首先:创建一个robotextension.groovy文件,然后在文件中添加下面的代码:

代码语言:javascript复制
unresolvedVariable { var ->
    if ('robot'==var.name) {
        storeType(var, classNodeFor(Robot))
        handled = true
    }
}

在这里,我们告诉编译器,如果找到一个未解析的变量,并且变量的名称为robot,那么我们可以确保该变量的类型为robot。

2.4 类型检查扩展的相关API

  • AST:类型检查API是一个低级API,处理抽象语法树。要开发扩展,您必须很好地了解AST,即使DSL比处理纯Java或Groovy的AST代码要容易得多。
  • Events:类型检查器发送以下事件,扩展脚本可以对这些事件做出反应。

具体的Events示例如下表所示:

事件名称(Event name)

调用时间(Called When)

参数(Arguments)

使用(Usage)

备注

setup

在类型检查器完成初始化后调用

没有(none)

setup { //它在任何其他操作之前被调用 }

可以用来执行设置我们的扩展

finish

在类型检查器完成类型检查后调用

没有(none)

finish { // 这是在完成所有类型检查之后 }

可用于在类型检查器完成其工作后执行附加检查。

unresolvedVariable

当类型检查器发现未解析的变量时调用

VariableExpression vexp

unresolvedVariable { VariableExpression vexp -> if (vexp.name == 'people') { storeType(vexp, LIST_TYPE) handled = true } }

允许开发人员帮助类型检查器使用用户注入的变量。

unresolvedProperty

当类型检查器无法在接收器上找到属性时调用

PropertyExpression pexp

unresolvedProperty { PropertyExpression pexp -> if (pexp.propertyAsString == 'longueur' && getType(pexp.objectExpression) == STRING_TYPE) { storeType(pexp, int_TYPE) handled = true } }

允许开发人员处理“dynamic”属性

unresolvedAttribute

当类型检查器无法在接收器上找到属性时调用

AttributeExpression aexp

unresolvedAttribute { AttributeExpression aexp -> if (getType(aexp.objectExpression) == STRING_TYPE) { storeType(aexp, STRING_TYPE) handled = true } }

允许开发人员处理缺失的属性

beforeMethodCall

在类型检查器开始对方法调用进行类型检查之前调用

MethodCall call

beforeMethodCall { call -> if (isMethodCallExpression(call) && call.methodAsString=='toUpperCase') { addStaticTypeError('Not allowed',call) handled = true } }

允许您在类型检查器执行自己的检查之前拦截方法调用。如果您想在有限的范围内用自定义类型检查替换默认类型检查,这是很有用的。在这种情况下,必须将已处理标志设置为true,以便类型检查器跳过自己的检查。

afterMethodCall

在类型检查器完成方法调用的类型检查后调用

MethodCall call

afterMethodCall { call -> if (getTargetMethod(call).name=='toUpperCase') { addStaticTypeError('Not allowed',call) handled = true } }

允许您在类型检查器完成自己的检查之后执行额外的检查。如果您希望执行标准类型检查测试,但也希望确保额外的类型安全性,例如检查参数之间的差异,那么这一点特别有用。注意,afterMethodCall被调用,即使你在beforemethodcall之前做了,并将handled标志设置为true。

onMethodSelection

当类型检查器找到适合方法调用的方法时,由它调用

Expression expr, MethodNode node

onMethodSelection { expr, node -> if (node.declaringClass.name == 'java.lang.String') { if ( count>2) { addStaticTypeError("You can use only 2 calls on String in your source code",expr) } } }

类型检查器通过推断方法调用的参数类型,然后选择目标方法来工作。如果它找到一个对应的,那么它就触发这个事件。例如,如果您想对特定的方法调用做出反应,例如输入一个以闭包作为参数的方法的作用域(如在构建器中),这是很有趣的。请注意,此事件可能针对各种类型的表达式抛出,而不仅仅是方法调用(例如二进制表达式)。

methodNotFound

当类型检查器未能为方法调用找到合适的方法时,由它调用

ClassNode receiver, String name, ArgumentListExpression argList, ClassNode[] argTypes,MethodCall call

methodNotFound { receiver, name, argList, argTypes, call -> if (receiver==classNodeFor(String) && name=='longueur' && argList.size()==0) { handled = true return newMethod('longueur', classNodeFor(String)) } }

与onMethodSelection不同,当类型检查器无法为方法调用(instance或者 static)找到目标方法时,此事件被发送。它使您有机会在错误发送给用户之前拦截错误,但也可以设置目标方法。为此,您需要返回MethodNode的列表。在大多数情况下,你会返回:一个空列表,这意味着你没有找到相应的方法,一个只有一个元素的列表,表明目标方法是毫无疑问的,如果你返回多个MethodNode,那么编译器会向用户抛出一个错误,说明方法调用是模糊的,列出可能的方法。为了方便起见,如果您只想返回一个方法,您可以直接返回它,而不是将它包装到一个列表中。

beforeVisitMethod

在类型检查方法体之前由类型检查器调用

MethodNode node

beforeVisitMethod { methodNode -> handled = methodNode.name.startsWith('skip') }

类型检查器将在开始类型检查方法体之前调用此方法。例如,如果您希望自己执行类型检查,而不是让类型检查器执行,则必须将已处理标志设置为true。此事件还可以用于帮助定义扩展的作用域(例如,仅在方法foo中应用它)。

afterVisitMethod

由类型检查器在类型检查方法体后调用

MethodNode node

afterVisitMethod { methodNode -> scopeExit { if (methods>2) { addStaticTypeError("Method ${methodNode.name} contains more than 2 method calls", methodNode) } } }

使您有机会在类型检查器访问方法体后执行额外的检查。例如,如果您收集信息,并希望在收集完所有信息后执行额外的检查,这是非常有用的。

beforeVisitClass

在类型检查类之前由类型检查器调用

ClassNode node

beforeVisitClass { ClassNode classNode -> def name = classNode.nameWithoutPackage if (!(name[0] in 'A'..'Z')) { addStaticTypeError("Class '${name}' doesn't start with an uppercase letter",classNode) } }

如果一个类进行了类型检查,那么在访问该类之前,将发送此事件。对于在带有@typecheck注释的类中定义的内部类也是如此。它可以帮助您定义扩展的范围,或者您甚至可以用自定义类型检查实现完全取代类型检查器的访问。为此,您必须将已处理标志设置为true。

afterVisitClass

由类型检查器在完成对类型检查类的访问后调用

ClassNode node

afterVisitClass { ClassNode classNode -> def name = classNode.nameWithoutPackage if (!(name[0] in 'A'..'Z')) { addStaticTypeError("Class '${name}' doesn't start with an uppercase letter",classNode) } }

在类型检查器完成它的工作后调用每个被类型检查的类。这包括用@typecheck标注的类,并且不会跳过在同一个类中定义的任何内部/匿名类。

incompatibleAssignment

当类型检查器认为赋值是不正确的,即赋值的右侧与左侧不兼容时调用

ClassNode lhsType, ClassNode rhsType, Expression assignment

incompatibleAssignment { lhsType, rhsType, expr -> if (isBinaryExpression(expr) && isAssignment(expr.operation.type)) { if (lhsType==classNodeFor(int) && rhsType==classNodeFor(Closure)) { handled = true } } }

使开发人员能够处理不正确的分配。例如,如果类覆盖setProperty,这就很有用,因为在这种情况下,将一种类型的变量分配给另一种类型的属性可能是通过运行时机制处理的。在这种情况下,您可以通过告诉类型检查器赋值有效(使用handled set to true)来帮助类型检查器。

incompatibleReturnType

当类型检查器认为返回值与封闭闭包或方法的返回类型不兼容时调用

ReturnStatement statement, ClassNode valueType

incompatibleReturnType { stmt, type -> if (type == STRING_TYPE) { handled = true } }

使开发人员能够处理不正确的返回值。例如,当返回值将进行隐式转换或封闭闭包的目标类型难以正确推断时,这很有用。在这种情况下,您可以通过告诉类型检查器赋值有效(通过设置Handler的属性)来帮助类型检查器。

ambiguousMethods

当类型检查器无法在多个候选方法中进行选择时调用

List<MethodNode> methods, Expression origin

ambiguousMethods { methods, origin -> methods.find { it.parameters.any { it.type == classNodeFor(Integer) } } }

使开发人员能够处理不正确的分配。例如,如果类覆盖setProperty,这就很有用,因为在这种情况下,将一种类型的变量分配给另一种类型的属性可能是通过运行时机制处理的。在这种情况下,您可以通过告诉类型检查器赋值有效(使用handled set to true)来帮助类型检查器。

(PS: 上面的表格看不清楚的,可以访问我的博客网站:zinyan.com/?p=486)

当然,扩展脚本可能由几个块组成,可以使用多个块响应同一个事件。这使得DSL看起来更好,更容易编写。然而,仅仅对事件做出反应是远远不够的。如果我们知道可以对事件做出反应,那么还需要处理错误,这意味着有几个帮助器方法可以使事情变得更简单。

3. 小结

本篇内容就暂时到这里结束了。下一篇继续分享关于类型检查扩展的后续知识。

以上内容可以参考Groovy官方文档:http://docs.groovy-lang.org/docs/groovy-4.0.6/html/documentation/#_type_checking_extensions 进行了解。

下一篇接着继续分享关于类型检查扩展的知识。

0 人点赞