相关文章推荐
谈吐大方的鸵鸟  ·  Android Studio ...·  1 年前    · 
八块腹肌的荒野  ·  详解pandas ...·  1 年前    · 
沉稳的火车  ·  ORA-01034: ORACLE not ...·  1 年前    · 

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