Groovy常用集合与GString5.Groovy

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 是按照每个商品的分类 ( 苹果 / 香蕉) 将一个大的商品表分成了两个子表:

group_by.png

Map 中的 groupBy 方法则是依照某一个条件作为 key 变换出的值为嵌套 Map 类型的新 Map。在 Groovy 中实现上面的例子:

def map = [
        // 商品名是主键,因此商品名写在 k 的位置。
        "苹果":"水果",
        "香蕉":"水果",
        "芹菜":"蔬菜",
        "香菜":"蔬菜",
        "大葱":"蔬菜",
        "火龙果":"水果",
]

def categories = map.groupBy {
    // 按照每一个商品的分类划分
    k,v -> v
}

//水果={苹果=水果, 香蕉=水果, 火龙果=水果}
//蔬菜={芹菜=蔬菜, 香菜=蔬菜, 大葱=蔬菜}
categories.each {println it}
复制代码

Map 的其它使用途径在之前的章节已经介绍:

  1. 将 Map 用于具名参数的情形,参考 5 分钟的 Java 转 Groovy 教程 (juejin.cn) 2.7 节。
  2. 将 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 类型的 templatereplace() 方法,它返回的 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 流:

  1. in 输入流,用于接收子进程的正常运行结果 ( execute().text 默认从这里获取信息 )。
  2. err 输入流,用于接收子进程本身的异常调用信息 ( 通常都是因为找不到对应的命令,程序,然后由操作系统给出错误提示。也有一些奇怪的特例:比如 java -version 打印的消息会输入到 err 流而非 in 流 )。
  3. 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,因此这里多了一步转换工作。