5. Groovy 常用工具
本专题包括集合类的两个最主要工具,序列 ( java.util.ArrayList
) 和映射 java.util.LinkedHashMap
以及相关的操作方法 ( 通过传入闭包来实现 ) 。另外,文末补充了 Groovy 独有的字符串类型 —— GString 类型的一些使用技巧。
5.1 定义 ArrayList
Groovy 使用方括号 []
创建一个 "可扩容数组":
def seq = [1,2,3,4,5]
println seq.getClass().name
复制代码
不过,如果我们打印这个数组的类型,可以发现 Groovy 的数组,其底层其实为 java.util.ArrayList
( 换句话说,Groovy 没有原生的 [Object
类型 )。不过由于 Groovy 实现了运算符重载方法,因此我们仅需要通过下标就可以访问到元素。
// 访问第一个元素
println seq[0]
// 访问最后一个元素
println seq[seq.size()-1]
复制代码
如果寻找的元素在序列的后边位置,我们可以使用负数下标令 Groovy 从后向前寻找元素:
seq[-1] // 等价于 seq[seq.size()-1]
复制代码
甚至,可以像使用 Go 语言的切片一样使用它:
// 截取下标 0 ~ 4 (包括) 的新序列
def slice = seq[0..3]
//[0,1,2,3]
println slice
复制代码
在更古老的 Groovy 版本中,如果这个新的 "切片" 是基于另一个 "底层数组" 获得的,那么这处改动会同样影响到 "底层数组" —— 这段描述跟 Go 非常像。但是在新版本 ( 笔者是 3.0.8 ) 中,这种行为已经发生了改变 —— 得到的新 "切片" 将是独立的,它的修改不再会影响到原先的 "底层数组"。
def seq = [1,2,3,4,5]
def slice = seq[0..3]
slice[0] = 999
// 下面是在 Groovy 3.0.8 的运行结果。在更古老的版本中,下面两个运行结果可能是相同的。
// 999
println slice[0]
// 1
println seq[0]
复制代码
Groovy 的序列提供两个非常直观的符号:+
( 添加元素 ) ,或者是 -
( 删除元素 ) 。但从广义角度来说,这两个符号也可代表求并集,或是求相对补集。因此,这两个操作符可以接收单个元素,也可以接收另一个 ArrayList
。需要注意的是,这两种操作总是返回一个新的 ArrayList
,而原 ArrayList
并不会被改变。
def seq = [1,2,3,4,5]
// [1,2,3,4,5] - [6] = [1,2,3,4,5]
println seq - 6
// [1,2,3,4,5] + [6] = [1,2,3,4,5,6]
println seq + 6
// [1,2,3,4,5] - [1,3,5] = [2,4]
println seq - [1,3,5]
// [1,2,3,4,5] + [6,7,8] = [1,2,3,4,5,6,7,8]
println seq + [6,7,8]
复制代码
5.2 使用 ArrayList
在 Java 中,对某个列表使用 foreach
等操作要率先将其转换成一个流,待转换完毕之后再经过一步终结操作将结果收集起来 ...... Groovy 处理这些操作则非常容易且直观。
5.2.1 each
在 Groovy 中,只需要使用闭包来告诉 Groovy 应该对每一个元素怎么做就 Ok 了。
def seq = [1,2,3,4,5]
// Java: for(Integer i : seq){System.out.println(i)} ...
// Groovy: I want to println each element.
seq.each {println it}
复制代码
假如现在相对 seq
内的每一个元素做翻倍操作,然后以此创建出一个新的序列:
// 好像缺了一点什么。
seq.each { it * 2 }
复制代码
不过,现在这种写法达不到目的,因为 Groovy 只是单纯地对每一个元素进行了计算而没有进行存储。这里需要借助一个 <<
( 对应重载运算符的 leftShift()
) 方法,将运算后的结果收集起来:
seq = [1,2,3,4,5]
def doubled = []
// 相当于 doubled.leftShift(it*2)
seq.each {doubled << it * 2}
println doubled
复制代码
如果要通过两两归并的方式对内部所有元素进行折叠,可以使用 sum()
方法:
seq.sum {x1,x2 -> x1 + x2}
复制代码
对于基本数据类型的 sum
,可以不主动传入闭包。
seq.sum()
复制代码
Groovy 中的 ArrayList
还提供其它的遍历方式,比如反向迭代:
seq.reverseEach {println it}
复制代码
或者,如果关注迭代过程中的下标,可以使用 eachWithIndex()
方法:
// 提供的闭包需要包含两个参数,第一个是元素类型,第二个是索引类型。
seq.eachWithIndex { Object entry, int i ->
println "index of ${i} , objct: ${entry}"
}
复制代码
5.2.2 collect
如果要在操作之后顺便收集所有元素,并在调用之后返回,Groovy 提供 collect()
方法。和之前 each
配合 <<
操作符的做法相比较,它俩的区别在于:collect
会主动返回一个匿名的结果集,而后者是通过副作用来实现的。
Groovy 的 collect()
方法语义上类似于 Scala 或者 Java Stream 的 map()
方法。
def doubled = seq.collect { it * 2 }
println doubled
复制代码
5.2.3 find / any / every
查找元素使用 find()
方法更方便一些,比如:
seq = [1,2,3,4,5]
// 传入的闭包得是 Closure<Boolean>.
def i = seq.find { it == 2 }
// 2
print(i)
复制代码
find()
方法只查找一个满足条件的元素,而 findAll()
方法能够将所有满足条件的元素装入到一个 ArrayList
中返回。Groovy 的 findAll()
方法语义上类似于 Scala 或者 Java Stream 的 filter
方法。
seq = [1,2,3,4,5]
def seq1 = seq.findAll {it > 1}
// [2,3,4,5]
println seq1
复制代码
有时,我们仅仅是想知道满足目的的元素是否存在。这个时候更推荐使用 any()
方法,它将返回一个布尔值:
// 是否至少有一个元素满足 >2,显然是满足条件的。
def bool = seq.any {
it > 2
}
println bool
复制代码
另一个极端则是:检查所有的元素是否都满足条件。这时应该使用 every()
方法:
def seq =[1,2,3,4,5]
// 所有的元素都大于 0 吗? -true.
println seq.every { it > 0 }
// 所有的元素都大于 2 吗? -false.
println seq.every { it > 2 }
复制代码
5.2.4 join
Groovy 的 join
指将序列内的所有元素通过一个字符串做分隔符连接,并返回一个完整的长字符串。在需要输出数据内容到外部的场合,join
方法可能会变得很实用,这和 Scala 的 mkString()
语义相类似。
seq = [1,2,3,4,5]
//1-2-3-4-5
println seq.join("-")
复制代码
5.2.5 特殊运算符 *
假定有一个由学生类 Student
构成的序列,现在程序需要通过调用内部每一个实例的 info()
方法来收集学生信息。之前提到的 collect
方法可以实现它:
import groovy.transform.Canonical
@Canonical
class Student {
String name
String id
String info(){
return "student id:${id},name:${name}"
}
}
def students = [new Student(name:"Zhao",id: "0001"),
new Student(name: "Qian",id:"0002"),
new Student(name: "Sun",id:"0003"),
new Student(name: "Li",id:"0004")
]
// 写法 1
students.collect {it.info()}
复制代码
然而,Groovy 还提供了一个 *
作为展开操作符,来表示 "序列内的每一个元素"。比如:
// 写法 2
student*.info()
复制代码
它和前文的写法 1 语义相同。即 student
序列的每一个元素都调用 info()
方法,并且隐式地将所有变换结果都收集到另一个序列返回。 *
和 .
共同组成了 *.
操作符,它像 ?.
一样能够避免空指针异常,并安全地返回一个 null
。
如果操作的序列中混进了一个空指针,如果是 collect
的写法,程序将报错,但是 *.
则不会。
def students = [new Student(name:"Zhao",id: "0001"),
new Student(name: "Qian",id:"0002"),
new Student(name: "Sun",id:"0003"),
new Student(name: "Li",id:"0004"),
null
]
// students.collect {it.info()}
// 不会报错,空调用会插入 null 做代替。
// [Student(...),Student(...),...,null]
println students*.info()
复制代码
*
承担了太多的功能 —— 当它用作前置运算符时,表达的含义又不同了。比如:
def cfg = ["mongodb","192.168.2.140","27017"]
def getURI(String db,String ip,String port){
return "${db}://${ip}:${port}"
}
// println getURI(cfg[0],cfg[1],cfg[2])
println getURI(*cfg)
复制代码
这种用法出现在方法调用时的参数赋值。*cfg
表示将序列内部的每一个元素全部 "分发" 到参数列表的对应位置。如果要让这段代码正常运作,那么序列内部的元素个数必须和方法的参数个数相同,或者方法本身接收可变参数。
5.3 定义 Map
在前几期文章我们已经演示过如何快速使用 Groovy 的语法来创建 Map:
def map = ["java":"11","groovy":"3.0.8","scala":"2.11.8","python":"3.8"]
println map.getClass()
复制代码
和 ArrayList 类似,Groovy 的 Map 即 "包了糖衣之后" 的 java.util.LinkedHashMap
,但是创建起来要比 Java 优雅地多。在 key 为字符串的情形,引号甚至可以省略掉。形如:
def map = [java:"11",groovy:"3.0.8",scala:"2.11.8",python:"3.8"]
复制代码
访问 Map 元素更像是访问一个索引为键的值:
println map["java"]
复制代码
论搞花活这方面,Groovy 从来不会让人失望。比如上面的写法等价于:
// 当这个键是不带特殊符号的字符串时,可以不带括号。
println map."java"
prtinln map.java
复制代码
Map 的键总是 unique 的。如果在创建 Map 或者添加键值对的过程中某些 key 重复了,则 Groovy 会保存最新的那个。
map["java"] = 11
map["java"] = 8
// 8
println map["java"]
复制代码
下面演示 Map 的元素添加和修改:
def map = ["java":"11","groovy":"3.0.8","scala":"2.11.8","python":"3.8"]
map.java = "8"
map.java = "6"
// 相当于添加了 (c++,14) 键值对。
map["c++"] = "14"
// 删除键值对。
map.remove("c++")
复制代码
同样,在了解 ArrayList 的批量添加删除方法之后,我们将很容易接受这种写法:
def map = ["java":"11","groovy":"3.0.8","scala":"2.11.8","python":"3.8"]
map + ["c++":"20","go":"1.15.5"]
// 即使是单个元素,也需要使用 [] 括起来。
map + ["java":"13"]
map - ["c++":"20","go":"1.15.5"]
map - ["java":"13"]
复制代码
5.4 使用 Map
以 each 方法为例子。在遍历 Map 时,可以操作完整的键值对 entry 本身,也可以分别操作键值对的 k 和 v 。下面的代码演示了这两个使用风格:
// 操作键值对
map.each {
entry ->
println "the key is ${entry.key} and the value is ${entry.value}"
}
// 分开操作 k 和 v
map.each {
k,v ->
println "the key is $k and the value is $v"
}
复制代码
那么剩下的 collect()
,find()
等相关用法和 ArrayList
中类似,这里就不再做赘述。主要介绍 Map 的其它便捷方法。
5.5 groupBy
在使用 SQL 语句时,group by 允许我们能够快速地依照某一个条件对整个表进行划分归类。比如,下面的 group by 是按照每个商品的分类 ( 苹果 / 香蕉) 将一个大的商品表分成了两个子表:
Map 中的 groupBy
方法则是依照某一个条件作为 key 变换出的值为嵌套 Map 类型的新 Map。在 Groovy 中实现上面的例子:
def map = [
// 商品名是主键,因此商品名写在 k 的位置。
"苹果":"水果",
"香蕉":"水果",
"芹菜":"蔬菜",
"香菜":"蔬菜",
"大葱":"蔬菜",
"火龙果":"水果",
]
def categories = map.groupBy {
// 按照每一个商品的分类划分
k,v -> v
}
//水果={苹果=水果, 香蕉=水果, 火龙果=水果}
//蔬菜={芹菜=蔬菜, 香菜=蔬菜, 大葱=蔬菜}
categories.each {println it}
复制代码
Map 的其它使用途径在之前的章节已经介绍:
- 将 Map 用于具名参数的情形,参考 5 分钟的 Java 转 Groovy 教程 (juejin.cn) 2.7 节。
- 将 Map 用于接口实现的情形,参考上述链接的 2.10 节。
5.6 GString
Groovy 几乎隐去了数据类型 char
。对于一个表达 'G'
,Java 会认为它是一个 char
类型,但是 Groovy 仍然会将它当作是一个字符串常量。如果有必要将它认作是一个 char
类型,Groovy 必须进行一次强制转换:'G' as char
。
除了双引号包括的字符串以外,由正斜杠 //
包括的字符串也可能会被认为是 GString :
def str1 = "hello Groovy"
def str2 = /hello Groovy/
复制代码
GString 实际上是一个表达式,或者说是一个模板,通过 $
预留了一些占位符。即便我们不改变 GString 本身的引用,当占位符引用的内容 ( 这里指的是值 ) 发生改变时,GString 返回的内容也会随之变化:
// 单线程环境下,选择 StringBuilder。由于它不是线程安全的,因此多线程环境下选择 StringBuffer。
def lang = new StringBuilder('lang')
def template = " i use ${lang} to finish my project."
println template.getClass().name
println template
lang.replace(0,5,'groovy')
// 我们只是重复地打印 template,但是它的内容却改变了。
println template
复制代码
GString 总是惰性计算的,它只有在被使用的时候才会重新计算表达式的实际内容并返回。当 GString 表达式内部没有任何占位符时,那么 Groovy 就会省去 String -> GString
的包装过程。比如:
def str1 = "hello Groovy"
// 由于内部没有任何占位符,因此它实际上是个 String 类型。
// java.lang.String
println str1.getClass().name
复制代码
5.6.1 避免使用陷阱
回到刚才的话题。通过调用 StringBuilder
类型的 template
的 replace()
方法,它返回的 toString()
内容也发生了变化,因此导致两次输出的结果不同,这是符合预期的,但如果 lang
是纯粹的 String
类型呢?
def lang = 'java'
def template = " i use ${lang} to finish my project."
println template.getClass().name
println template
lang = 'groovy'
// 打印的内容没有发生变化。
println template
复制代码
由于 Java 字符串不可变,template
在编译期就会将占位符 ${lang}
替换成常量池中的某一个存放着 "java"
的索引。如果使用 javap
工具解析这段字节码,其 ${lang}
的传入是通过 ldc
指令 ( 它将常量池的值压入操作数栈 ) 实现的。
换句话说,lang
之后再怎么变化,template
都始终指向那个 "java"
字符串常量。想要打破这个局面,就必须让 lang
是可变的,上述 StringBuilder
是个可行的方法,但是会让我们的代码变得冗长。另一个更简便办法是让将其修改成一个返回 lang
的闭包:
def lang = 'java'
def template = " i use ${->lang} to finish my project."
println template.getClass().name
println template
lang = 'groovy'
// 重新得到预期的结果,并且没有增加任何其它的代码。
println template
复制代码
如果引用 Scala 的术语来解释,那就是这里的传值调用变成了传名调用。简单来说,这种做法推迟了一步 GString
的计算,因此 GString
现在无法直接指向一个字符串常量来蒙混过关。
在避开这个陷阱之后,使用 GString 来做一些重复拼装字符串的任务会变得非常轻松愉快 ( 幸亏这里使用的不是 StringBuilder
或者是 StringBuffer
) 。
(lang,frame) = ["_","_"]
def template = " i use ${->lang} to build ${->frame}."
def map = [
'java':'spring',
'groovy':'grails',
'go':'gin'
]
map.each {
k,v ->
lang = k
frame = v
println template
}
复制代码
5.6.2 重载操作符
Groovy 对字符串给出了很多便捷的重载实现,比如 +
,-
或者 *
运算符。
// from org.codehaus.groovy.runtime.StringGroovyMethods.
// "hellohello"
println "hello" * 2
// "he"
println "hello" - "llo"
// "hello guys"
println "hello" + " guys"
复制代码
String 按理说是不可拓展的 ( 因为它被 final
关键字标注 ),但是 Groovy 仍通过一些 "魔术机制" 将一些方法注入给了 String,但它们实际归属于 org.codehaus.groovy.runtime.StringGroovyMethods
。有关于这些 "魔术机制" 内容,我们在下一章节就会介绍。
5.6.3 正则表达式
一旦在字符串前面加上 ~
符号,那么这个字符串将成为一段正则表达式,其字符串形式不限于 GString 或是 String,因此可以使用单引号,双引号,正斜杠将表达式括起来。
~'hello'
~/hello/
~"hello"
复制代码
在正则表达式的场合,习惯上使用正斜杠,也就是上述代码的第二种表示法。正则表达式中经常会出现反斜杠 \
,而该写法将允许表达式内部不用对反斜杠进行转义。比如:
~'\\d*\\w*'
~"\\d*\\w*"
~/\d*\w/
复制代码
Groovy 针对匹配给出了两种运算符:精确匹配 ==~
和模糊匹配 =~
。前者要求字符串内容完全匹配表达式,而后者只要求字符串内容有一部分匹配表达式。比如:
// 匹配 Groovy 或者 groovy。
pattern = ~'([Gg])roovy'
// 这个字符串包含了 groovy,满足条件。
println "java and groovy" =~ pattern?"match":"not match"
// 这个字符串并不是只包含 Groovy 或者 groovy,不满足条件。
println "java and groovy" ==~ pattern?"match":"not match"
复制代码
同样的字符串和正则表达式,由于运算符的严格程度不同,因此程序给出了两种不同的运算结果。这两个操作符均返回 java.util.regex.Matcher
实例,只不过当它用于三目运算符或者 if
的条件判断时,Groovy 会自动将其转换成布尔值。
5.6.4 String as Scrpit
在最开始的章节,我们曾经介绍过:Groovy 可以非常轻松地和系统进程进行交互:
// 通过 Groovy 程序打印 Groovy 自身的 GDK
println 'cmd /C groovy -v'.execute().text
复制代码
和重载运算符类似,其 execute()
方法实际归属于 org.codehaus.groovy.runtime.ProcessGroovyMethods
类。
如果对 Groovy 自动化非常感兴趣,请参照 The Apache Groovy programming language - Groovy Development Kit (groovy-lang.org) 并搜索上文的类名,然后搜索相关 API 的用法。execute()
方法实际返回的是 Java 的 Process
类,代表着一个运行的进程,通过访问 Process 实例的相关属性,我们可以获取到子进程的运行状态,因此也建议去 javadoc 或者在这篇文章 java Process类详解! - 知乎 (zhihu.com) 中了解 Process 类的相关内容。
Groovy 提供了简单的方式来获取一个 Process 的 I/O 流:
in
输入流,用于接收子进程的正常运行结果 (execute().text
默认从这里获取信息 )。err
输入流,用于接收子进程本身的异常调用信息 ( 通常都是因为找不到对应的命令,程序,然后由操作系统给出错误提示。也有一些奇怪的特例:比如java -version
打印的消息会输入到err
流而非in
流 )。out
输出流,用于向子进程传递信息。
下面这段 Groovy 调用 java
命令执行另一个 HelloWorld
类的主程序,此主程序正通过 Scanner
类阻塞式地等待接收外部的消息,并在回显这条消息后退出。这里演示了这三个流的傻瓜式操作:
// execute() 的第一个参数表示环境变量参数。传入空数组或 null 表示沿用当前进程的环境变量参数。
def p = "java HelloWorld".execute([],new File("C:\\Users\\i\\Desktop"))
// 向这个 java 进程传入消息。
p.out.withWriter {
w -> w.write("hi")
}
// 接收此 java 进程的返回值,或者中途抛出的异常。
p.in.withReader {
r -> println r.text
}
// 如果进程本身报错,通过 err 流取出。
p.err.withReader {
r -> println r.text
}
复制代码
另一个例子是,Groovy 跳转到其它的路径下执行 dir
命令 ( Windows 系统 ) 查看那个路径下的文件信息:
// Windows 命令默认输出是 GBK 字符集,Java/String 的输入流默认是 UTF-8 字符集。
println "cmd /C dir".execute([],new File("C:\\Users\\i\\Desktop")).in.withReader("GBK") {
reader -> reader.text
}
复制代码
如果打印的内容包含中文,那么输出的内容可能包含乱码。原因是 Windows 命令默认的编码格式是 GBK,而 Java Reader 的默认编码是 UTF-8,因此这里多了一步转换工作。
近期评论