函数的定义与调用

在 kotlin 中创建集合

利用 kotlin 提供的标准库,来创建常见的集合:

// 创建常见的集合类
val hasSet = hashSetOf(3, 6, 4)
val arrayList = arrayListOf(3, 6, 8)
val hasMap = hashMapOf(1 to "one", 2 to "two")

注意: to 并不是一个特殊的结构,而是一个普通函数。

可以通过 .javaClass 属性来获取标准库提供的集合类型,相当于 Java 中的 getClass() 方法:

println(set.javaClass)      //java.util.HashSet
println(list.javaClass)     //ava.util.ArrayList
println(map.javaClass)      //java.util.HashMap

可以看到,都是标准的 Java 集合类,Kotlin 并没有提供新的集合类,这样可以更容易与 Java 代码交互。尽管 Kotlin 的集合类和 Java 的集合类完全一致,但 Kotlin 还不止于此,举个例子,可以通过以下方式来获取一个列表中的最后一个元素,或者得到一个数字列表的最大值:

fun printLastOrMaxElement() {
    val list = arrayListOf("cat", "dog", "panda")
    val set = setOf(44, 77, 99, 11)
    println(list.last())    //panda
    println(set.max())      //99
}

这并不是 Java 集合类有的方法,Kotlin 用的是 Java 的集合类,却能使用 Java 不存在的方法,这些新增的方法从何而来呢?答案是: 扩展函数。

让函数更好调用

Java 的集合类都有一个默认的 toString 实现,但是它格式化的输出是固定的,而且往往不是你需要的样子:

val set = setOf(44, 77, 99, 11)
println(set)    // 默认触发 toString() 

// 输出 [44, 77, 99, 11]

如果你需要打印不一样的样式,那就需要利用第三方库或重写 toString 方法来实现,但在 Kotlin 中,它的标准库有一个专门的函数来处理这种情况,我们不使用标准库,先自己实现:

// 最原始的版本实现
fun <T> joinToString(
    collection: Collection<T>,
    separator: String,
    prefix: String,
    postfix: String
): String {
    val builder = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) builder.append(separator)
        builder.append(element)
    }
    builder.append(postfix)
    return builder.toString()
}

这个函数是泛型的,它可以支持元素为任意类型的集合,我们来验证一下:

println(joinToString(hasSet,"-","<",">"))    //<4-6-3>

命名参数

如果只看方法调用,根本就看不出来这些 String 都对应着什么参数,必须借助 IDE 工具或函数本身才能知道这些参数的意义,在 Kotlin 中可以做的很优雅:

println(joinToString(hasSet, separator = "-", prefix = "<", postfix = ">"))

当调用一个 Kotlin 定义的函数时,可以显式地标明一些参数的名称。如果在调用一个函数时,指明了一个参数的名称,为了避免混淆,那它之后的所有参数都需要标明名称。

注意: 在调用 Java 函数时,不能采用命名参数,把参数名称存到 .class 文件是 Java 8 及更高版本的一个可选功能,而 Kotlin 需要保持和 Java 6 的兼容性。

默认参数值

Kotlin 中,可以在声明函数的时候,指定参数的默认值,这样就可以避免创建重载的函数。我们利用默认参数值来改进一下前面的 joinToString 函数:

fun <T> joinToString(
    collection: Collection<T>,
    separator: String = ",",
    prefix: String = "(",
    postfix: String = ")"
): String {
    val builder = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) builder.append(separator)
        builder.append(element)
    }
    builder.append(postfix)
    return builder.toString()
}

现在再调用这个函数,可以省略掉有默认参数值的参数了,效果就像在 Java 中声明的重载函数一样:

println(joinToString(hasSet))    //(4,6,3)
println(joinToString(hasSet,";"))    //(4;6;3)

当你使用常规的调用语法时,必须按照函数申明中定义的参数顺序来给定参数,可以省略的只有排在末尾的参数。如果使用命名参数,可以省略其中的一些参数,也可以用你想要的任意顺序只给定你需要的参数:

// 打乱了参数顺序,并且separator参数使用了默认值
println(joinToString(hasSet,postfix = "]",prefix = "["))

//输出 [4,6,3]

注意: Java 没有参数默认值的概念,当你从 Java 中调用 Kotlin 函数的时候,必须显式地指定所有参数值。如果需要从 Java 代码中调用也能更简便,可以使用 @JvmOverloads 注解函数。这个注释会指示编译器生成 Java 的重载函数,从最后一个开始省略每个参数。

顶层函数

Java 中,所有的代码都需要定在类内,如果多个类内用到完全相同的逻辑处理,则需要把这些函数封装到一些工具类,以静态函数的方式来进行调用。而在 Kotlin 中 ,不再需要创建这些无意义的工具类,可以把这些函数直接放到代码文件的顶层,不用从属任何类,实际上 joinToString 函数就是直接写在 FunctionPratice.kt 文件中的。

这会怎样运行呢?当编译这个文件的时候,会生成一些类,因为 JVM 只能执行类中的代码,来看下编译后的类是怎样的:

public final class FunctionPraticeKt {

 public static final String joinToString(@NotNull Collection collection,
     @NotNull String separator, @NotNull String prefix, @NotNull String postfix) {
}

可以看到 Kotlin 编译生成的类的名称,对应于包含函数的文件名称,这个文件中的所有顶层函数编译为这个类的静态函数。因此,当从 Java 调用这个函数的时候,和调用任何其他静态函数一样简单。

FunctionPraticeKt.joinToString(list, ";");

修改文件类名

要改变包含 Kotlin 顶层函数生成的类的名称,需要为这个文件添加 @JvmName 注解,并将其放在这个文件的开头,位于包名的前面

@file:JvmName("FuctionUtil")

package zeng.fanda.com.kotlinpratice.function

现在需要用新类名来调用函数了:

FuctionUtil.joinToString(list, ";");

顶层属性

和函数一样,属性也可以放到文件的顶层。从 Java 的角度来看就是静态属性,没啥特别的,而且由于没有了类的存在,这种属性用到的机会也不多。
需要注意的是顶层属性和其他任意属性一样,默认是通过访问器暴露给 Java 使用的(也就是通过 gettersetter 方法)。

// 直接写在文件顶层参数的类型决定了调
val son = "son"
var father = "father"

生成的相应 Java 代码如下:

 // val 属性,只有 getter 方法
private static final String son = "son";
private static String father = "father";

public static final String getSon() {
   return son;
}

public static final String getFather() {
   return father;
}

public static final void setFather( String var0) {
   father = var0;
}

为了方便使用,如果你想要把一个常量以 public static final 的属性暴露给 Java ,可以用 const 来修饰属性:

const val TAG = "TAG"

生成的相应 Java 代码如下:

public static final String TAG = "TAG";

相对来说,const val 修饰的顶层属性比较常用。

扩展函数和属性

扩展函数:在不修改源码的情况下,扩展原有类的功能,可以看作是类的成员函数,不过定义在类的外面。为了方便阐释,让我们添加一个方法,来计算一个字符串的最后一个字符:

    //String 是接收者类型        // this 是接收者对象
fun String.lastChar() = this.get(this.length - 1)

你所要做的,就是把你要扩展的类或者接口的名称,放到即将添加的函数前面,这个类的名称被称为接收者类型,用来调用这个扩展函数的那个对象,叫做接收者对象。

注意:接收者类型是由扩展函数定义的,接收者对象是该类型的一个实例。

可以像调用类的普通成员函数一样去调用扩展函数:

println("kotlin".lastChar())

在上述的调用中,String 就是接收者类型, "kotlin" 就是接收者对象。在这个扩展函数中,可以像其他成员函数一样用 this ,也可以像普通函数一样省略它:

// 省略 this
fun String.lastChar() = get(length - 1)

注意: 在扩展函数中,可以直接访问被扩展的类的其他方法和属性,就好像是在这个类自己的方法中访问它们一样。但是不允许打破封装性,不能访问私有或受保护的成员。

导入扩展函数

对于定义的扩展函数,是不会自动地在整个项目范围内生效的,需要进行导入,导入单个函数和导入类的语法一样:

import zeng.fanda.com.kotlinpratice.function.lastChar

println("kotlin".lastChar())

当然也可以用 * 来进行导入:

import zeng.fanda.com.kotlinpratice.function.*

甚至可以用关键字 as 来修改导入的类或者函数的名称:

import zeng.fanda.com.kotlinpratice.function.lastChar as last

println("kotlin".last())

通过 as 可以解决函数重名的问题,在 Java 中通常要通过类或函数的全名来使用,太过繁琐。

从 Java 中调用扩展函数

实际上,扩展函数是静态函数,它把调用对象作为函数的第一个参数。在 Java 中调用扩展函数和其他顶层函数一样,通过 .kt 文件生成 Java 类调用静态的扩展函数,把接收者对象传入第一个参数即可。例如,上面的 lastChar 扩展函数是定义在 FunctionUtil.kt 中,在 Java 中调用就是这样的:

System.out.println(FunctionUtil.lastChar("kotlin"));

作为扩展函数的工具函数

现在我们用扩展函数的方式来实现 joinToString 的终级版本:

// 终极版本,用扩展函数的方式实现
@JvmOverloads
fun <T> Collection<T>.joinToString(
    separator: String = ",",
    prefix: String = "(",
    postfix: String = ")"
): String {
    val builder = StringBuilder(prefix)
    for ((index, element) in withIndex()) {
        if (index > 0) builder.append(separator)
        builder.append(element)
    }
    builder.append(postfix)
    return builder.toString()
}

接收者类型是接口 Collection ,接收者对象是接口实例,即所有实现该接口的实例对象都可以调用,调用方式如下:

println(hasSet)
println(hasSet.joinToString("-", "<", ">"))
println(hasSet.joinToString(separator = "-", prefix = "<", postfix = ">"))
println(hasSet.joinToString(";"))
// 打乱了参数顺序,并且separator参数使用了默认值
println(hasSet.joinToString(postfix = "]", prefix = "["))

使用方法也像是 Collection 类的成员函数一样了(当然 Java 调用还是静态方法,第一个参数传入 Collection 对象)。

不可重写的扩展函数

先来看一个重写的例子:

// kotlin 中类和函数默认是 final 的,如果需要继承需要 open  修饰符
open class View {
    open fun click() = println("View clicked")
}

class Button : View() {     //继承
    override fun click() {
        println("Button clicked")
    }
}

当你声明了类型为 View 的变量,那它可以被赋值为 Button 类型的对象,因为 ButtonView 的一个子类。当你在调用这个变量的一般函数,比如 click 的时候,如果 Button 复写了这个函数,将会调用到 Button 中复写的函数:

val view: View = Button()

// 输出 Button clicked
view.click()

但是对于扩展函数来说,并不是这样的。扩展函数并不是类的一部分,它是声明在类之外的。尽管可以给基类和子类都分别定义一个同名的扩展函数,当这个函数被调用时,它会用到哪一个呢?这里,它是由该变量的静态类型所决定的,而不是这个变量的运行时类型

fun View.showOff() = println("I am a View")
fun Button.showOff() = print("I am a button")

val view: View = Button()

// 输出 I am a View
view.showOff()

当你在调用一个类型为 View 的变量的 showOff 函数时,对应的扩展函数会被调用,尽管实际上这个变量现在是一个 Button 对象。回想一下,扩展函数会在 Java 中编译为静态函数,同时接受值将会作为第一个参数。这样其实 2 个 showOff 扩展函数就是不同参数的静态函数,

public static final void showOff(@NotNull View $this$showOff) {
   String var1 = "I am a View";
   System.out.println(var1);
}

public static final void showOff(@NotNull Button $this$showOff) {
   String var1 = "I am a button";
   System.out.print(var1);
}

参数的类型决定了调用哪个静态函数,想要调用 Button 的扩展函数,则必须先将参数转成 Button 类型才行: XxKt.showOff((Button)view) ;

注意: 如果一个类的成员函数和扩展函数有相同的签名,成员函数往往会被优先使用,扩展函数并不起作用,相当于没有定义。

扩展属性

扩展属性提供了一种方法,用于扩展类的 API ,可以用来访问属性,用的是属性语法而不是函数的语法。尽管他们被称为属性,但它们可以没有任何状态,因为没有合适的地方来存储它,不可能给现有的 Java 对象的实例添加额外的字段,举个例子吧:

val String.lastChar: Char
    get() = get(length - 1)

print("kotlin".lastChar)

扩展属性也像接收者的一个普通成员属性一样,这里必须定义 getter 函数,因为没有支持字段,因此没有默认的 getter 的实现。同理,初始化也不可以:因为没有地方存储初始值。 刚刚定义是一个 val 的扩展属性,也可以定义 var 属性:

var StringBuilder.lastChar: Char
    get() = get(length - 1)
    set(value) = setCharAt(length - 1, value)

print(StringBuilder("kotlin").lastChar)

val 属性不可变,因此只需要定义 getter ,而 var 属性可变,所以 gettersetter 都需要。

最终生成的 Java 代码是这样的:

public static final char getLastChar(@NotNull String $this$lastChar) {
   return $this$lastChar.charAt($this$lastChar.length() - 1);
}

public static final char getLastChar(@NotNull StringBuilder $this$lastChar) {
   return $this$lastChar.charAt($this$lastChar.length() - 1);
}

public static final void setLastChar(@NotNull StringBuilder $this$lastChar, char value) {
   $this$lastChar.setCharAt($this$lastChar.length() - 1, value);
}

和扩展函数是相同的,仅仅是静态函数:提供获取 lastChar 的功能,这样的定义方式可以在 Kotlin 中像使用普通属性的调用方式来使用扩展属性,给你一种这是属性的感觉,但本质上在 Java 中就是静态函数。

可变参数

我们看一下 listOf 函数的定义:

public fun <T> listOf(vararg elements: T): List<T>

修饰符 vararg 表示是可变参数,类似于 Java 中使用的 ... ,功能跟 Java 是一样的,但是有一个区别:

当需要传递的参数已经包装在数组中时,调用该函数的语法,在 Java 中可以按原样传递数组,而 Kotlin 则要求你显式地解包数组,以便每个数组元素在函数中能作为单独的参数来调用。从技术的角度来讲,这个功能被称为展开运算符,而使用的时候,不过是在对应的参数前面放一个 *

fun testVararg() {
    val array = arrayOf("kotlin", "java", "python")

    //输出 [php, [Ljava.lang.String;@5b2133b1]
    println(listOf("php", array))

    //输出 [php, kotlin, java, python]
    println(listOf("php", *array))
}

对比可知,只有加上 * 后,数组中所有的元素才能被单独使用,否则会把数组看成一个对象。我们来看一下在 Java 中的调用:

public static void testVarage() {
    String[] array = new String[]{"kotlin", "java", "python"};
    // 输出 [kotlin, java, python]
    System.out.println(CollectionsKt.listOf(array));
    // [php, [Ljava.lang.String;@1f32e575]
    System.out.println(CollectionsKt.listOf("php",array));
}

Java 中可以直接传入数组来获取每个元素,但是如果同时传入单个元素和数组,得到的结果跟在 Kotlin 中没有会用 * 的效果是一样的,而且 Java 中不能用 * 展开运算符。

中缀调用和解构声明

我们之前创建 map 的方式是这样的:

val map = mapOf(1 to "one", 2 to "second")
// 输出 {1=one, 2=second}
println(map)

其中的 to 并不是一个内置的结构,而是一种特殊的函数调用,称为中缀调用。中缀调用时,函数名称放在目标对象名称和参数之间,没有其他任何分隔符,这种调用看起来更加简洁,我们来对比一下:

1.to("one") // 普通函数调用

1 to "one"  // 中缀调用

怎么定义中缀函数呢?有两个条件,第一是函数只有一个参数,无论是普通函数还是扩展函数。第二是使用 infix 修饰符来标记函数,我们来看一下简单的 to 函数声明:

infix fun Any.to(other: Any) = Pair(this, other)

to 函数会返回一个 Pair 对象,这是 Kotlin 标准库中的类,用来表示一对元素。我们也可以直接用 pair 的内容来初始化两个变量:

val (num, value) = 1 to "one"
// 输出 num:1 , value:one
println("num:$num , value:$value")

这个功能称为解构声明,1 to "one" 会返回一个 Pair 对象,Pair 包含一对元素,也就是 1one ,接着又定义了变量(number, name) 分别指向 Pair 中的 1one 。解构声明特征不止用于 Pair 。还可以使用 mapkey和 value 内容来初始化两个变量。并且还适用于循环,正如你在使用的 withIndex 函数的 joinToString 实现中看到的:

for ((index, element) in collection.withIndex()) {
    println("$index, $element")
}

to 函数是一个扩展函数,可以创建一对任何元素,这意味着它是泛型接受者的扩展:可以使用 1 to "one""one" to 1list to list.size() 等写法。我们来看看 mapOf 函数的声明:

public fun <K, V> mapOf(vararg pairs: Pair<K, V>): Map<K, V>

listOf 一样,mapOf 接受可变数量的参数,但这次他们应该是键值对。尽管在 Kotlin 中创建 map 可能看起来像特殊的解构,而它不过是一个具有简明语法的常规函数。

字符串和正则表达式的处理

Java 中我们会使用 Stringsplit 方法分割字符串。但有时候会产生一些意外的情况,例如当我们这样写 "12.345-6.A".split(".") 的时候,我们期待的结果是得到一个 [12, 345-6, A] 数组。但是 Javasplit 方法竟然返回一个空数组!这是因为它将一个正则表达式作为参数,并根据表达式将字符串分割成多个字符串。这里的点(.) 是表示任何字符的正则表达式。在 Kotlin 中不会出现这种令人费解的情况,因为正则表达式需要一个 Regex 类型承载,而不是 String 。这样确保了字符串不会被当做正则表达式。

// 用点号或破折号来分割字符串
// 输出 [12, 345, 6, A]
println("12.345-6.A".split("[.-]".toRegex()))
// 等价于
println("12.345-6.A".split("\\.|-".toRegex()))

kotlin 中, split 扩展函数支持任意数量的纯文本字符串分隔符,这比 Java 中只能使用一个字符分隔符要强大的多。

// 可以指定多个分隔符
println("12.345-6.A".split(".", "-"))

输出结果都是等价的。

正则表达式和三重引号的字符串

现在有这样一个需求:解析文件的完整路径名称 /Users/yole/Kotlin-book/chapter.adoc 到对应的组件:目录、文件名、扩展名。 Kotlin 标准库中包含了一些可以用来获取在给定分隔符第一次(或最后一次)出现之前(或之后)的子字符串的函数。

fun parsePath(path: String) {
    val directory = path.substringBeforeLast("/")
    val fullName = path.substringAfterLast("/")
    val fileName = fullName.substringBeforeLast(".")
    val extension = fullName.substringAfterLast(".")

    // 输出 Dir: /Users/yole/Kotlin-book , FullName: chapter.adoc , FileName: chapter , Extension: adoc
    println("Dir: $directory , FullName: $fullName , FileName: $fileName , Extension: $extension")
}

利用这些扩展函数,处理字符串变得更加简单,如果想用正则表达式来实现,也是可以的:

// 用正则表达式的方式来解析
fun parsePathRegex(path: String) {
    val regex = """(.+)/(.+)\.(.+)""".toRegex()
    val matchResult = regex.matchEntire(path)
    if (matchResult != null) {
        val (directory, fileName, extension) = matchResult.destructured
        println("Dir: $directory , FileName: $fileName , Extension: $extension")
    }
}

这里正则表达式写在一个三重引号的字符串中。在这样的字符串中,不需要对任何字符进行转义,包括反斜线,所以可以用 \. 而不是\\. 来表示点,正如写一个普通字符串的字面值。在这个正则表达式中:第一段 (.+) 表示目录,/ 表示最后一个斜线,第二段 (.+) 表示文件名,\. 表示最后一个点,第三段 (.+) 表示扩展名。

多行三重引号的字符串

三重引号字符串的目的,不仅在于避免转义字符,而且使它可以包含任何字符,包括换行符。

val kotlinLogo = """.| //
    .|//
    .|/  \
""".trimMargin(".")

// 输出
| //
|//
|/  \

多行字符串包含三重引号之间的所有字符,包括用于格式化代码的缩进。如果要更好的表示这样的字符串,可以去掉缩进(左边距)。为此,可以向字符串内容添加前缀,标记边距的结尾,然后调用 trimMargin 来删除每行中的前缀和前面的空格。在这个例子中使用了 . 来作为前缀。

局部函数和扩展

我们先看一个例子,将用户信息保存到数据库,并在保存前对数据进行校验:

// 用户类
class User(val id: Int, val name: String, val address: String)

// 保存用户数据
fun saveUser(user: User) {

    // 数据校验
    if (user.name.isEmpty()) {
        throw IllegalArgumentException("Can't save user ${user.id}: empty Name")
    }
    if (user.address.isEmpty()) {
        throw IllegalArgumentException("Can't save user ${user.id}: empty Address")
    }

    println("Save user success!")
}

上面的示例,会对用户的每个属性进行检验,当属性更多的时候,这种重复的代码将会更多,局部函数的方式可以摆脱重复同时保持清晰的代码结构。局部函数: 就是定义在函数中的函数。

// 利用局部函数的方式
fun saveUser(user: User) {

    // 定义局部函数,可以直接访问外部函数所有的参数和变量
    fun validate(value: String, fileName: String) {
        if (value.isEmpty()) {
            throw IllegalArgumentException("Can't save user $user.id: empty $fileName")
        }
    }

    validate(user.name, "Name")
    validate(user.address, "Address")

    println("Save user success!")
}

我们还可以将检验的逻辑提取到 User 类的扩展函数中:

// 将校验逻辑封装到类的扩展函数里
fun User.validateBeforeSave() {
    // 定义局部函数,可以直接访问外部函数所有的参数和变量
    fun validate(value: String, fileName: String) {
        if (value.isEmpty()) {
            throw IllegalArgumentException("Can't save user $id: empty $fileName")
        }
    }
    validate(name, "Name")
    validate(address, "Address")
}

那么我们现在的调用就变成这样了:

// 利用扩展函数来优化
fun saveUserLocal(user: User) {
    user.validateBeforeSave()
    println("Save user success!")
}

现在只需要调用两行代码即可,非常简洁。这种扩展的方式有很多好处:

  1. 即使 User 类是属于第三方库中的类,也能正常使用扩展函数,非常方便。
  2. 因为校验逻辑不只是当前逻辑用到,所有的保存逻辑都要先检验,如果写在某个类中,会导致多处代码重复。
  3. 能够让类保持精炼和内聚,也可以让逻辑更加清晰。

注意:局部函数可以让代码更加简洁,但不建议使用多层嵌套。

小结

  • Kotlin 没有自定义自己的集合类,而是在 Java 集合类的基础上提供了更多丰富的扩展 API
  • Kotlin 可以给函数参数设置默认值,降低了重载函数的必要性,并且命名函数参数让函数调用更加清晰易读。
  • Kotlin 允许定义顶层属性和函数,而不仅仅定义在类中。
  • Kotlin 可以利用扩展函数和属性的方式来扩展类的功能,而不需要修改源码。
  • 中缀调用,让代码调用更加简洁。
  • 局部函数帮助保持代码整洁的同时,避免重复代码。