08 | 实战:用Kotlin写一个英语词频统计程序
迭代过程:
1.0 版本:使用
命令式风格
的代码实现基础功能
2.0 版本:利用
扩展函数
、
高阶函数
优化代码,实现
函数式风格
的代码
3.0 版本:使用
inline
提升软件的性能,并分析高阶函数的实现原理
命令式风格
统计步骤:
文本清洗
:将标点符号替换成空格
文本分割
:以空格作为分隔符,对文本进行分割
词频统计
:遍历 List,将
单词
与
频率
存储在 Map 中
词频排序
:将高频词放在前面,低频词放在后面
data class WordFreq(val word: String, val frequency: Int) // 数据类
class TextProcessorV1 {
fun processText(text: String): List<WordFreq> { // 逻辑入口
val cleaned = clean(text)
val words = cleaned.split(" ")
val map = getWordCount(words)
val list = sortByFrequency(map)
return list
fun clean(text: String): String {
return text.replace(".", " ")
.replace("!", " ")
.trim()
标点符号型较多时,需要尝试去枚举所有的标点符号:
fun clean(text: String): String {
return text.replace(".", " ")
.replace("!", " ")
.replace(",", " ")
.replace("?", " ")
.replace("'", " ")
.replace("*", " ")
.replace("#", " ")
.replace("@", " ")
.trim()
使用正则表达式
优化:
fun clean(text: String): String {
return text.replace("[^A-Za-z]".toRegex(), " ") // 将所有不是英文字母的字符都替换成空格
.trim()
用一个 Map 来存储单词和它对应频率:
fun getWordCount(list: List<String>): Map<String, Int> {
val map = hashMapOf<String, Int>()
for (word in list) {
if (word == "") continue // 过滤掉连续的空格
val trim = word.trim()
val count = map.getOrDefault(trim, 0)
map[trim] = count + 1
return map
将无序的数据结构换成有序的。
fun sortByFrequency(map: Map<String, Int>): MutableList<WordFreq> {
val list = mutableListOf<WordFreq>()
for (entry in map) {
if (entry.key == "") continue
val freq = WordFreq(entry.key, entry.value)
list.add(freq)
list.sortByDescending { // 以词频降序排序
it.frequency
return list
利用扩展
方法,可以让程序支持统计文件
当中的单词词频:
fun processFile(file: File): List<WordFreq> {
val text = file.readText(Charsets.UTF_8) // 从文件里读取文本
return processText(text)
函数式风格
代码风格分析
上面的代码,就是很明显地在用 Java 思维写 Kotlin 代码。这种情况下,我们甚至可以省略掉中间所有的临时变量,将代码缩减成这样:
fun processText(text: String): List<WordFreq> {
return sortByFrequency(getWordCount(clean(text).split(" ")))
不过,很明显的是,以上代码的可读性
并不好。
Kotlin 既有命令式的一面,也有函数式的一面,它们有着各自擅长的领域。而在这里,我们就完全可以借助函数式编程
的思想来优化代码,就像下面这样:
fun processText(text: String): List<WordFreq> {
return text
.clean()
.split(" ")
.getWordCount()
.sortByFrequency { WordFreq(it.key, it.value) }
这段代码从上读到下,可读性非常高,它也非常接近我们说话的习惯。那么,要如何实现这样的代码风格呢?
转为扩展方法
为了让 String 能够直接调用 clean() 方法,我们必须将 clean() 定义成 String 的扩展方法
。
fun clean(text: String) = text.replace("[^A-Za-z]".toRegex(), " ").trim() // 原函数
fun String.clean() = this.replace("[^A-Za-z]".toRegex(), " ").trim() // 转换成扩展方法
真实的项目中,String.clean() 使用顶层扩展
并不合适,顶层扩展只适用于通用的逻辑
,否则不清楚的人看着 idea 提示的扩展函数也一脸懵逼。
其他方法也是类似。
private fun List<String>.getWordCount(): Map<String, Int> {
val map = HashMap<String, Int>()
for (element in this) { // 原本的参数 list 集合变成了 this
if (element == "") continue
val trim = element.trim()
val count = map.getOrDefault(trim, 0)
map[trim] = count + 1
return map
private fun Map<String, Int>.sortByFrequency(): MutableList<WordFreq> {
val list = mutableListOf<WordFreq>()
for (entry in this) { // 将参数变成 this
val freq = WordFreq(entry.key, entry.value)
list.add(freq)
list.sortByDescending {
it.frequency
return list
方法逻辑拆分
sortByFrequency() 并不符合函数式编程的习惯,因为这个方法明显包含两个功能:
将 Map 转换成 List
使用 sort 对 List 进行排序
因此针对这样的情况,我们应该再对这个方法进行拆分:
private fun <T> Map<String, Int>.mapToList(transform: (Map.Entry<String, Int>) -> T): MutableList<T> {
val list = mutableListOf<T>()
for (entry in this) {
val freq = transform(entry) // 调用传入的函数
list.add(freq)
return list
上面的代码中引入了高阶函数
。
优化后的效果
调用处的代码:
fun processText(text: String): List<WordFreq> {
return text
.clean()
.split(" ")
.getWordCount()
.mapToList { WordFreq(it.key, it.value) } // 传入了一个 Lambda 表达式,这个语法后面再研究
.sortedByDescending { it.frequency } // 根据词频降序排序,这个语法后面再研究
进一步优化
上面的 getWordCount()、mapToList() 等方法都是我们自己实现的。事实上,我们完全可以借助 Kotlin 标准库函数来实现:
fun processText(text: String): List<WordFreq> {
return text
.replace("[^A-Za-z]".toRegex(), " ")
.trim()
.split(" ")
.filter { it != "" }
.groupBy { it }
.map { WordFreq(it.key, it.value.size) }
.sortedByDescending { it.frequency }
可以看到,借助 Kotlin 库函数,我们用简单的几行代码,就成功实现了单词频率统计功能。
使用 inline 优化
这个版本,我们只需要为 mapToList 这个高阶函数增加一个 inline
关键字即可。
private inline fun <T> Map<String, Int>.mapToList(transform: (Map.Entry<String, Int>) -> T): MutableList<T> {}
这么做的原因比较复杂,涉及到 inline 关键字的实现原理。
高阶函数的实现原理
由于 Kotlin 兼容 Java 1.6,因此 JVM 是不懂什么是高阶函数
的,我们的高阶函数最终一定会被编译器转换成 JVM 能够理解的格式。
// HigherOrderExample.kt
fun foo(block: () -> Unit) {
block()
fun main() {
var i = 0
foo {
以上代码经过反编译成 Java 后,会变成这样:
public final class HigherOrderExampleKt {
public static final void foo(Function0 block) { // 参数是一个接口
block.invoke();
public static final void main() {
int i = 0
foo((Function0)(new Function0() { // 参数是一个实现 Function0 接口的【匿名内部类】
public final void invoke() { // 接口中定义的方法,这个方法【没有参数】
可以看到,Kotlin 高阶函数当中的函数类型
参数,变成了 Function0
,而 main() 函数当中的高阶函数调用,也变成了匿名内部类
的调用方式。那么,Function0
又是个什么东西?
public interface Function0<out R> : Function<R> {
public operator fun invoke(): R
Function0
其实是 Kotlin 标准库当中定义的接口,它代表没有参数
的函数类型。在 Functions.kt
这个文件当中,Kotlin 一共定义了 23 个类似的接口,从 Function0
一直到 Function22
,分别代表了无参数的函数类型
到22 个参数的函数类型
。
inline 的作用及实现原理
接下来我们看看使用 inline
优化过的高阶函数是什么样的:
inline fun fooInline(block: () -> Unit) { // 唯一的区别:多了一个关键字 inline
block()
fun main() {
var i = 0
fooInline {
我们再来看看它反编译后的 Java 长什么样:
public final class HigherOrderInlineExampleKt {
public static final void fooInline(Function0 block) { // 没有变化
block.invoke();
public static final void main() {
int i = 0; // 区别:匿名内部类不见了,方法调用也不见了
int i = i + 1; // 原理:将 inline 函数当中的代码拷贝到调用处
inline 的作用其实就是:将 inline 函数当中的代码拷贝到调用处。
使用 JMH 测试 inline 性能
从上面的案例中,我们发现,不使用 inline
时:
main() 中需要调用 foo(),这里多了一次函数调用的开销
foo() 中需要创建了匿名内部类对象,这也是额外的开销
为了验证使用 inline
前后的性能差异,我们可以使用 JMH
(Java Microbenchmark Harness)对这两组代码进行性能测试。JMH 这个框架可以最大程度地排除外界因素的干扰(比如内存抖动、虚拟机预热),从而判断出我们这两组代码执行效率的差异。它的结果不一定非常精确,但足以说明一些问题。
下面以两组测试代码为例,来探究下 inline 到底能为我们带来多少性能上的提升:
fun foo(block: () -> Unit) = block() // 不用 inline 的高阶函数
inline fun fooInline(block: () -> Unit) = block() // 使用 inline 的高阶函数
// 测试无inline的代码
@Benchmark
fun testNonInlined() {
var i = 0
foo {
// 测试无inline的代码
@Benchmark
fun testInlined() {
var i = 0
fooInline {
最终的测试结果如下,分数越高性能越好:
Benchmark Mode Score Error Units
testInlined thrpt 3272062.466 ± 67403.033 ops/ms
testNonInlined thrpt 355450.945 ± 12647.220 ops/ms
从上面的测试结果我们能看出来,是否使用 inline
,它们之间的效率几乎相差 10
倍。而这还仅仅只是最简单的情况,如果在一些复杂的代码场景下,多个高阶函数嵌套执行,它们之间的执行效率会相差上百倍。
测试多层嵌套的性能差异
为了模拟复杂的代码结构,我们可以简单地将上面这两个函数分别嵌套 10 个层级,然后看看它们之间的性能差异:
@Benchmark
fun testNonInlined() {
var i = 0
foo {
foo {
foo {
foo {
foo {
foo {
foo {
foo {
foo {
foo {
@Benchmark
fun testInlined() {
var i = 0
fooInline {
fooInline {
fooInline {
fooInline {
fooInline {
fooInline {
fooInline {
fooInline {
fooInline {
fooInline {
Benchmark Mode Score Error Units
testInlined thrpt 3266143.092 ± 85861.453 ops/ms
testNonInlined thrpt 31404.262 ± 804.615 ops/ms
从上面的性能测试数据我们可以看到,在嵌套了 10 个层级以后,我们 testInlined 的性能几乎没有什么变化;而当 testNonInlined 嵌套了 10 层以后,性能也比 1 层嵌套差了 10
倍。
在这种情况下,testInlined() 与 testNonInlined() 之间的性能差异就达到了 100
倍。随着代码复杂度的进一步上升,它们之间的性能差异会更大。
如果将它们反编译成 Java 代码,能看到:
对于 testNonInlined(),由于 foo() 嵌套了 10 层,它反编译后的代码也嵌套了 10 层函数调用,中间还伴随了 10 次匿名内部类的创建
而对于 testInlined(),则只有简单的两行代码,完全没有任何嵌套的痕迹
inline 的使用限制
既然 inline 这么神奇,那我们是不是可以将所有函数都用 inline
来修饰呢?答案当然是否定的。
Kotlin 官方建议我们 仅将 inline 用于修饰高阶函数,而且,也不是所有高阶函数都可以用 inline
对于普通的 Kotlin 函数,如果我们用 inline 去修饰它,IntelliJ 会对我们发出警告
例如,如果我们在 processText() 前面增加 inline 关键字,IntelliJ 会提示一个警告:
Expected 期望 performance impact 性能影响 of inlining is insignificant 微不足道. Inlining works best for functions with parameters of functional types
函数类型参数的函数
另外,在 processText() 方法的内部,getWordCount() 和 mapToList() 这两个方法还会报错:
Public-API inline function cannot access non-public API xxx
报错的原因是,由于 inline 的作用其实就是将 inline 函数当中的代码拷贝
到调用处,为了让开发者清楚有这个逻辑,所以要求只有 public 的方法才可以被拷贝。
2016-06-01
本文来自博客园,作者:白乾涛,转载请注明原文链接:https://www.cnblogs.com/baiqiantao/p/5549261.html