[译]Kotlin珍品 6 -扩展:好的,坏的,丑的

945 阅读7分钟

翻译说明: 翻译水平有限,文章内可能会出现不准确甚至错误的理解,请多多包涵!欢迎批评和指正.每篇文章会结合自己的理解和例子,希望对大家学习Kotlin起到一点帮助.

原文地址: [Kotlin Pearls 6] Extensions: The Good, The Bad and The Ugly

原文作者: Uberto Barbini

扩展还是不扩展?

前言

扩展方法(还有属性)对于Java开发者来说算是个新东西,实际上它们已经在C#中出现了很长时间,不过JVM对它们的支持首次出现在Kotlin中.

ps:扩展函数不难理解,但是在使用场景和规范上的说明和教程并不多.如果你对kotlin的扩展不太熟悉建议先看一下官方文档对扩展的说明.如果你对扩展有了解,那么这篇文章能帮助大家进一步掌握它.

官方文档-扩展

经过学习庞大的Kotlin代码库和浏览开源的Kotlin代码后,我注意到在扩展的地方经常会出现一个现象就是,有些能提高代码的可读性但是有些却弄得更难理解.

这也是一个在第一次用Kotlin做团队开发是的热门讨论话题,所以我认为通过我的成功和失败经验,在这里基于扩展做个总结还是有点价值的.

如果你有不同的意见或者你有好的例子,请一定联系我.

正文

  • 扩展的简单介绍

    在Kotlin中有两种扩展类型:扩展函数和扩展属性.

    首先让我们了解一下什么是一个扩展函数和如何在Kotlin中声明它.

    fun String.tail() = this.substring(1)
    
    fun String.head() = this.substring(0, 1)
    

    我刚写了两个函数,一个返回字符串的第一个字符,另一个返回余下的字符串.

    这里的重点是我们函数名(比如tail)放在了我们希望去调用函数的类型的后面,在这个例子中是String.

    我们写一个测试方法大概长这样:

    @Test
    fun `head and tail`() {t+
        assertThat("abcde".head()).isEqualTo("a")
        assertThat("abcde".tail()).isEqualTo("bcde")
    }
    

    很明显,如你所料.我们只是给String添加了两个方法而已.我们把代码转成java代码再看看:

    @NotNull
    public static final String head(@NotNull String $this$head) {
       String var10000 = $this$head.substring(0, 1);
       return var10000;
    }
    

    发现head变成了静态的方法,并有一个String类型的参数.那这就是一个语法糖?对,这就是个语法糖!

    仍旧,扩展能提高可读性也能变得更难理解.

    扩展函数类型看上去像这样:
    String.() -> String

    String.()左边的String我们称为函数接收者


  • 泛型扩展函数
    看完上面的介绍后,我们知道扩展函数可以应用到任何一个类.那有没有更多的应用场景呢?有!就是下面要说的泛型扩展函数.

    fun <T> T.foo() : String = "foo $this"
    

    一旦foo在你的作用域,那么你可以用任何对象去调用这个方法,包括null.

    下面是证实的例子:

        assertThat(123.foo()).isEqualTo("foo 123")
        val frank = User(1, "Frank")
        assertThat(frank.foo()).isEqualTo("foo User(id=1, name=Frank)")
        assertThat(null.foo()).isEqualTo("foo null")
    

    我们也可以通过改变泛型参数去做扩展限制.比方说我们只想让某些类和它的子类调用:

    fun <T: Number> T.doubleIt(): Double = this.toDouble() * 2
    这里有一点请注意,我们把泛型限定为Number还不是Number?(可空的Number).这样的话null就不是一个被这个扩展函数允许的接收者类型了.

        assertThat(123.doubleIt()).isEqualTo(246.0)
        assertThat(123.4.doubleIt()).isEqualTo(246.8)
        assertThat(123L.doubleIt()).isEqualTo(246.0)
        //assertThat(null.doubleIt()).isEqualTo(null)  编译失败!
    

    所以,如果我们想限制上面提到的foo函数只能是非空类型的函数接收者的话,我们可以像下面这样声明:

    fun <T: Any> T.foo() = "foo $this"

  • 扩展函数在中缀表示法中的应用

    如果对中缀比较陌生可以先看一下官方文档的解释 官方文档-中缀表示法

    举个例子,我不太喜欢Kotlin中可空字符串的拼接.我以为的结果应该是这样null + null == nullnull + "A" == "A"但是实际在Kotlin中结果是"nullnull""和"nullA".

    所以我们可以通过中缀函数+扩展函数++ (在反引号中)来实现我们预期的结果:

        infix fun String?.`++`(s:String?):String? = 
            if (this == null) s else if (s == null) this else this + s
    

    通过验证,达到了语气效果:

        assertThat(null `++` null).isNull()
        assertThat("A" `++` null).isEqualTo("A")
        assertThat(null `++` "B").isEqualTo("B")
        assertThat("A" `++` "B").isEqualTo("AB")
    

    中缀表示法对开发内部使用的DSL来说非常有用.

  • 扩展属性

    现在让我们看一下扩展属性是做什么的.

    很简单!扩展函数让我们可以给现有的类添加方法,那么扩展属性就让我们可以给现有的类添加属性.

    可能你们已经知道,Kotlin编译器会在我们访问Java Bean对象的时候生成对应属性.

        public class JavaPerson {
            private int age;
            private String name;
        
            public String getName() {
                return name;
            }
        
            public void setName(String name) {
                this.name = name;
            }
        
            public int getAge() {
                return age;
            }
        
            public void setAge(int age) {
                this.age = age;
            }
        }
    

    当我们在Kotlin中使用这个bean对象的时候,所有getters和setters都变成了属性:

        val p = JavaPerson()
        p.name = "Fred"
        p.age = 32
        
        assertThat(p.name).isEqualTo("Fred")
        assertThat(p.age).isEqualTo(32)
    

    通过转码可以看到,这些属性都是直接通过映射到getters和setters得到的:

        L1
            LINENUMBER 15 L1
            ALOAD 1
            LDC "Fred"
            INVOKEVIRTUAL com/ubertob/extensions/JavaPerson.setName (Ljava/lang/String;)V
    

    如何声明新的属性呢?我们给Java的Date类添加一个millis属性:

        var Date.millis: Long
            get() = this.getTime()
            set(x) = this.setTime(x)
    

    测试代码

        val d = Date()
        d.millis = 1001
        
        assertThat(d.millis ).isEqualTo(1001L)
        assertThat(d.millis ).isEqualTo(d.time)
    
  • 扩展的应用

    现在让我同个一个例子来展示如何扩展优化你的代码.FizzBuzz是一个面试会被经常问道的问题.如果你不太了解什么是FizzBuzz的话可以简单理解成一个简单算法题(写一个程序打印1到100这些数字。但是遇到数字为3的倍数的时候,打印“Fizz”替代数字,5的倍数用“Buzz”代替,既是3的倍数又是5的倍数打印“FizzBuzz”)

    现在首先我们可以通过属性找出打印Fizz和Buzz的数:

    val Int.isFizz: Boolean
        get() = this % 3 == 0
    
    val Int.isBuzz: Boolean
        get() = this % 5 == 0
    

    再用我们上面定义的可空拼接String的扩展方法++,我们可以用一行代码实现FizzBuzz:

    fun Int.fizzBuzz(): String = "Fizz".takeIf { isFizz } `++` "Buzz".takeIf { isBuzz } ?: toString()
    

    简单分析一下:Int.fizzBuzz()Int类的扩展函数,函数接收者是一个Int;"Fizz".takeIf { isFizz }一个Int类型调用isFizz方法如果返回true就返回字符串"Fizz",否则null;"Buzz".takeIf { isBuzz }一个Int类型调用isBuzz方法如果返回ture就返回字符串"Buzz",反则null;++拼接左右的结果;?:toString()如果是null就调用toString.

    下面是测试代码:

    val res = (1..15).map { it.fizzBuzz()}.joinToString()
    
    val expected = "1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz"
    
    assertThat ( res ).isEqualTo(expected)
    
  • 小结

    什么时候应该用扩展什么时候不应该用扩展?
    下面是我的个人看法.这个总结完全是我个人的意见,你可能有不同的看法和理解.不过这些总结也是我通过review其他人的代码总结出来的,所以应该还是有一定价值的.
    据我所知,目前还可有官方的规范去说明什么时候应该用扩展不过我参照了kotlin标准库总结的下面的使用模式:

    扩展属性的使用模式

    好的使用场景:

    • 替换setters和getters
    • 通过映射重命名属性的字段
    • 单一字段的简单方法调用(例如isFizz)

    不好的使用场景:

    • 仅仅为了在DSL中去掉方法的括号:
      例如转换一个Int类型成为Duration类型 5.toHours()5.hours更让人容易理解

    • 对用到多个字段的重要方法里面的属性做映射

    扩展函数的使用模式:

    好的使用场景:

    • 一个参数的纯函数
      例 String.reverse()

    • 类型转换
      例 Map.toList(), Int.toPrice()

    • 接口转换
      例 Map.asSequence(), User.asPerson()

    • 链式编程
      例 T.apply{…}, T.let{…}

    • 两个参数的中缀表示法
      例 A to B, HttpRoute bind {}

    • 非侵入式的给现有类来点语法糖
      例 User.fullName()

    • 规避泛型的协变和逆变问题
      例 Iterable.flatMap {…}

      class MyContainerClass<in T> {
          fun <U> map(f: (T) -> U): MyContainerClass<U>{...}
      }
      

      编译器会报错,因为T 前面被in修饰符限定了,但是在map方法里面又出现在out的位置.我们可以通过把map方法改成扩展函数来解决:

      class MyContainerClass<in T> {
          ...
      }
      fun <U, T> MyContainerClass<T>.map(f: (T) -> U): 
        MyContainerClass<U>{...}
      

这里属于Kotlin泛型的知识如果有不太熟悉的朋友看的不太明白的话,后期我会出一章专门讲泛型的文章

不好的使用场景

  • 涉及IO线程和单例的复杂方法
    例 User.saveToDb(), 8080.startHttpServer()
  • 会改变全局状态的方法
    例 “12:45”.setTime()
  • 多参数方法
    例 “Joe”.toUser(“Smith”, “joe@gmail.com”)
  • 常用类型上扩展特殊(单一范围)的方法
    例 Date.isFredBirthday(), Double.getDiscountedPrice()