如你所知,java在标准库中有一些与特定的类相关联的语言特性。例如,实现java.lang.Iterable接口的对象可以在for循环中使用;实现了java.lang.AutoCloseable接口的对象可以在try-with-resource语句中使用。
注意,如何使用operator关键字来声明plus函数。用于重载运算符的所有函数都需要用该关键字来标记,用来表示你打算把这个函数作为相应的约定的实现,并且不是碰巧地定义一个同名函数。
在使用了operator修饰符声明了plus函数之后,你就可以直接使用+号来求和了。事实上,这里它调用的是plus函数。
除了把这个运算符声明为一个成员函数外,也可以把它定义为一个扩展函数。
和其他一些语言相比,在kotlin中不管是定义还是使用重载运算符都更为简单,因为你不能定义自己的运算符。kotlin限定了你能重载哪些运算符,以及您需要在你的类中定义的对应名字的函数。
自定义类型的运算符,基本上和标准数字类型的运算符有着相同的优先级。例如,如果是a + b * c,乘法将始终在添加之前执行,即使你已经自己定义了这些运算符。
当你在定义一个运算符的时候,不要求两个运算数是相同的类型。例如,让我们定义一个运算符,它允许你用一个数字来缩放一个点,可以用它在不同坐标系之间做转换。
注意,kotlin中运算符不会自动支持交换性(交换运算符的左右两边)。如果希望用户能够使用1.5 * p以外,还能使用p * 1.5,你需要为它定义一个单独的运算符:operator fun Double.times(p: Point): Point。
运算符函数的返回类型也可以不同于任一运算数的类型。 注意,和普通的函数一样,可以重载operator函数:可以定义多个同名的,但是参数类型不同的方法。
通常情况下,当你在定义像plus这样的运算符函数时,kotlin不止支持+号运算,也支持+=。像+=、-=等这些运算符被称为复合赋值运算符。
在一些情况下,定义+=运算可以修改使用它的变量所引用的对象,但不会重新分配引用。将一个元素添加到可变集合,就是一个很好的例子:
如果你定义了一个返回值为Unit,名为plusAssign的函数,kotlin将会在用到+=运算符的地方调用它。其他二元算术符也有命名相似的对应函数:如minusAssign、TimesAssign等。
kotlin标准库为可变集合定义了plusAssign函数。
当你在代码中用到了+=的时候,理论上plus和plusAssign都可能被调用。如果在这种情况下,两个函数都有定义且适用,编译器会报错。一种可行的解决办法是:替换运算符的使用为普通函数调用。另一个办法是,使用val替代var,这样plusAssign运算符就不再适用。但一般来说,最好一致地设计出新的类:尽量不要同时给一个类添加plus和plusAssign运算。如果像前面的一个示例中的Point,这个类是不可变的,那么就应该只提供返回一个新值(如plus)的运算。如果一个类是可变的,比如构建器,那么只需要提供plusAssign和类似的运算就够了。
kotlin标准库支持集合的这两种方法。+和-运算符总是返回一个新的集合。+=和-=运算符用于可变集合时,始终在一个地方修改它们:而它们用于只读集合时,会返回一个修改过的副本(这意味着只有当引用只读集合的变量被声明为var的时候,才能使用+=和-=)。作为它们的运算符,可以使用单个元素,也可以使用元素类型一致的其他集合。
最后来看看集合的二元运算符是如何实现的。
- /**
- * Returns a list containing all elements of the original collection and then the given [element].
- */
- public operator fun
Collection .plus(element: T): List { - val result = ArrayList
(size + 1) - result.addAll(this)
- result.add(element)
- return result
- }
- /**
- * Adds the specified [element] to this mutable collection.
- */
- @kotlin.internal.InlineOnly
- public inline operator fun
MutableCollection .plusAssign(element: T) { - this.add(element)
- }
用于重载一元运算符的函数,没有任何参数。
可重载的一元算法的运算符包括:
与算术运算符一样,在kotlin中,可以对任何对象使用比较运算符(==、!=、>、<等),而不仅仅限于基本数据类型。不用像java那样调用equals或compareTo函数,可以直接使用比较运算符。
如果在kotlin中使用==运算符,它将被转换成equals方法的调用。这只是我们要讨论的约定原则中的一个。
使用!=运算符也会被转换成equals函数的调用,明显的差异在于,它们的结果是相反的。注意,和所有其他运算符不同的是,==和!=可以用于可空运算数,因为这些运算符事实上会检查运算数是否为null。比如a==b会检查a是否为非空,如果不是,就调用a.equals(b);否则,只有两个参数都是空引用,结果才是true。
对于Point类,因为已经被标记为数据类,equals的实现将会由编译器自动生成。但如果要手动实现,那么代码可以是这样的。
这里使用了恒等运算符===来检查参数与调用了equals的对象是否相同。恒等运算符与java中的==运算符是完全相同的:检查两个参数是否是同一个对象的引用(如果是基本数据类型,检查它们是否是相同的值)。 在实现了equals方法之后,通常会使用这个运算符来优化调用代码。注意,===运算符不能被重载。
equals函数之所以被标记为override,那是因为与其他约定不同的是,这个方法的实现是在Any类中定义的(kotlin中的所有对象都支持等式比较)。这也解释了为什么你不需要将它标记为operatpor: 因为Any的基本方法就已经标记了,而且函数的operator修饰符也适用于所有实现或重写它的方法。还要注意,equals不能实现为扩展函数,因为继承自Any类的实现始终优先于扩展函数。
这个例子显式使用!=运算符也会转换为euqals方法的调用。编译器会自动对返回值取反,因此你不需要再做别的事情,就可以正常运行。
来看看Any中equals的定义:
- public open class Any {
-
- public open operator fun equals(other: Any?): Boolean
- }
在java中,类可以实现Comparable接口,以便在比较值的算法中使用。Kotlin也支持相同的Comparable接口。但是接口中定义的compareTo方法可以按约定调用,比较运算符(<、>、<=、>=)的使用将被转换为compareTo。
在这种情况下,可以实现Comparable接口,这样Person对象不仅可以在kotlin代码中用来比较,还可以被java函数(比如用于对集合进行排序的功能)进行比较。与equals一样,operator修饰符已经被用在了基类的接口中,因此重写该接口时无须再重复。
要注意如何使用kotlin标准库中的compareValueBy函数来简洁地实现compareTo方法。这个函数接受用来计算比较值的一系列回调,按顺序依次调用回调方法,两两一组分别做比较,并返回结果。如果值不同,则返回比较结果;如果它们相同,则继续调用下一个;如果没有更多回调来调用,则返回0。这些回调函数可以像lambda一样传递,或者像这里做的一样,作为属性引用传递。
注意,尽管自己直接实现字段的比较会运行的更快一点,然而这样会包含更多的代码。一般情况下,更推荐使用简洁的写法,不用过早地担心性能问题,除非你知道这个实现将会被频繁调用。
所有java中实现了Comparable接口的类,都可以在kotliin中使用简洁的运算符语法,不用再增加扩展函数。
在kotlin中,可以用类似java中数组的方式来访问map中的元素——使用方括号:
val value = map[key]
也可以使用同样的运算符来改变一个可变map的元素:
mutableMap[key] = newValue;
来看看它是如何工作的。在kotlin中,下标运算符是一个约定。使用下标运算符读取元素会被转换为get运算符方法的调用,并且写入元素将调用set。Map和MutableMap的接口已经定义这些方法。
- public interface Map<K, out V> {
- /**
- * Returns the value corresponding to the given [key], or `null` if such a key is not present in the map.
- */
- public operator fun get(key: K): V?
-
- }
可以使用方括号来引用点的坐标:p[0]访问X坐标,p[1]访问Y坐标。下面是它的实现和调用:
你只需要定义一个名为get的函数,并标记operator。之后,像p[1]这样的表达式,其中p具有类型Point,将被转换为get方法的调用。
注意,get的参数可以是任何类型,而不只是Int。例如,当你对map使用下标运算符时,参数类型是键的类型,它可以是任意类型。还可以定义具有多个参数的get方法。例如,如果要实现一个类来表示二维数组或矩阵,你可以定义一个方法,例如operator fun get(rowIndex:Int, colIndex: Int),然后调用matrix[row, col]。
我们也可以用类似的方式定义一个函数,这样就可以使用方括号语法更改给定下标处的值。 我们来定义另一个类来表示一个可变的点。
这个例子也很简单:只需要定义一个名为set的函数,就可以在赋值语句中使用下标运算符。set的最后一个参数用来接收赋值语句中(等号)右边的值,其他参数作为方括号内的下标,例如下图所示:
集合支持的另一个运算符是in运算符,用来检查某个对象是否属于集合。相应的函数叫做contains。我们来实现一下,使用in运算符来检查点是否属于一个矩阵。
in右边的对象将会调用contains函数,in左边的对象将会作为函数入参。 在Rectangle.contains的实现中,我们用到了标准库中的until函数,来构建一个开区间,然后使用运算符in来检查某个点是否属于这个区间。
要创建一个区间,请使用..语法:举个例子,1..10代表所有从1到10的数字。..运算符也是kotlin中的一个约定:..运算符是调用rangeTo函数的一个简洁方法。
rangeTo函数返回一个区间。你可以为自己的类定义这个运算符。但是,如果该类实现了Comparable接口,那么就不需要了:你可以通过kotlin标准库创建一个任意可比较元素的区间,这个库定义了可以用于任何可比较元素的rangeTo函数:
- /**
- * Creates a range from this [Comparable] value to the specified [that] value.
- *
- * This value needs to be smaller than [that] value, otherwise the returned range will be empty.
- * @sample samples.ranges.Ranges.rangeFromComparable
- */
- public operator fun
> T.rangeTo(that: T): ClosedRange = ComparableRange(this, that)
在kotlin中,for循环中也可以使用in运算符,和做区间检查一样。但是在这种情况下它的含义是不同的:它被用来执行迭代。这意味着一个诸如for(x in list) {...}将被转换成list.iterator()的调用,然后就像在java中一样,在它上面重复调用hasNext()和next()方法。
请注意,在kotlin中,这也是一种约定,这意味着iterator方法可以被定义为扩展函数。这就解释了为什么可以遍历一个常规的java字符串:标准库已经为CharSequence定义了一个扩展函数iterator,而它是String的父类。
可以为自己的类定义iterator方法。例如,可以这样定义方法来遍历日期。
请注意如何在自定义区间类型上定义iterator方法:使用LocalDate作为类型参数。如上一小节所示,rangeTo库函数返回一个ClosedRange的实例,并且ClosedRange
解构声明允许你展开单个复合值,并使用它来初始化多个单独的变量。
解构声明也用到了约定的原理。要在解构声明中初始化每个变量,将调用名为componentN的函数,其中N是声明中的变量。换句话说,前面的例子可以被转换成下图:
对于数据类,编译器为每个在主构造方法中声明的属性生成一个componentN函数。下面的例子现实了如何手动为非数据类声明这些功能:
解构声明主要使用场景之一,是从一个函数返回多个值,这个非常有用。如果要这样做,可以定义一个数据类来保存返回所需的值,并将它作为函数的返回类型。 在调用函数后,可以用解构声明的方式,来轻松地展开它,使用其中的值。举个例子,让我们写一个简单的函数,来将一个文件名分割成名字和扩展名。
如果你注意到componentN函数在数组和集合上也有定义,可以进一步改进这个代码。当你在处理已知大小的集合时,这是非常有用的。一个例子就是,用split来分割返回两个元素的列表。
当然,不可能定义无限数量的componentN函数,这样这个语法就可以与任意数量的集合一起工作,但这也没用。标准库只允许使用此语法来访问一个对象的前五个元素。
让一个函数能返回多个值有更简单的方法,是使用标准库中的Pair和Triple类。在语义表达上这种方式会差一点,因为这些类也不知道它返回的对象包含什么,但因为不需要定义自己的类所以可以少些代码。
解构声明不仅可以用作函数中的顶层语句,还可以用在其他可以声明变量的地方,比如in循环。
这个简单的例子用到了两个kotlin约定:一个是迭代一个对象,另一个是用于解构声明。kotliin标准库给map增加了一个扩展的iterator函数,用来返回map条目的迭代器。因此,与java不同的是,可以直接迭代map。它还包含Map.Entry上的扩展函数component1和component2,分别返回它的键和值。实际上,前面的循环被转换成了下面这样的代码:
kotlin中最独特和最强大的功能之一:委托属性。这个功能可以让你轻松实现这样的属性,它们处理起来比把值存储在支持字段中更复杂,却不用在每个访问器中都重复这样的逻辑。例如,这些属性可以把它们的值存储在数据库表中,在浏览器会话中,在一个map中等。
这个功能的基础是委托,这是一种设计模式,操作的对象不用自己执行,而是把工作委托给另一个辅助的对象。我们把辅助对象称为委托。当我们讨论类的委托的时候,你之前在4.3.3节中看到过这种模式。当这个模式应用于一个属性时,它也可以将访问器的逻辑委托给一个辅助对象。你可以手动实现它(稍后我们会有示例)或使用更好的解决方案:利用kotlin的语言支持。
委托属性的基本语法是这样的:
- class Foo {
- var p: Type by Delegate()
- }
属性p将它的访问器逻辑委托给了另一个对象:这里是Delegate类的一个新的实例。通过关键字by对其后的表达式求值来获取这个对象,关键字by可以用于任何符合属性委托约定规则的对象。
编译器创建一个隐藏的辅助属性,并使用委托对象的实例进行初始化,初始属性p会委托给该实例。为了简单起见,我们把它称为delegate:
按照约定,Delegate类必须具有getValue和setValue方法(后者仅适用于可变属性)。像往常一样,它们可以是成员函数,也可以是扩展函数。Delegate类的简单实现差不多应该是这样的:
可以把foo.p作为普通的属性使用,事实上,它将调用Delegate类型的辅助属性的方法。
惰性初始化是一种常见的模式,直到在第一次访问该属性的时候,才根据需要创建对象的一部分。当初始化过程中消耗大量资源并且在使用对象时并不总是需要数据时,这个非常有用。
下面展示了如何使用额外的_emails属性来实现惰性加载,在没有加载之前为null,然后加载为邮件列表。
这里使用了所谓的支持属性技术。 你有一个属性,_emails,用来存储这个值,而另一个emails,用来提供对属性的读取访问。你需要使用两个属性,因为属性具有不同的类型:_emails可以为空,而emails为非空。这种技术经常会使用到,值得熟练掌握。
但这个代码有点啰嗦:要是有几个惰性属性那得有多长。而且,它并不总是正常运行:这个实现不是线程安全的。kotlin提供了更好的解决放啊。
使用委托属性会让代码变得简单的多,可以封装用于存储值的支持属性和确保该值只被初始化一次的逻辑。在这里可以使用标准库函数lazy返回的委托。
lazy函数返回一个对象,该对象具有一个名为getValue且签名正确的方法,因此可以把它与by关键字一起使用来创建一个委托属性。lazy的参数是一个lambda,可以调用它来初始化这个值。默认情况下,lazy函数是线程安全的,如果需要,可以设置其他选项来告诉它要使用哪个锁。或者完全避开同步,如果该类永远不会在多线程环境中使用。
要了解委托属性的实现方式,让我们来看另一个例子:当一个对象的属性更改时通知监听器。这在许多不同的情况下都很有用:例如,当对象显示在UI时,你希望在对象变化时UI能自动刷新。java具有此类通知的标准机制:PropertyChangeSupport和PropertyChangeEvent类。让我们看看在kotlin中不适用委托属性的情况下,该如何使用它们,然后再将代码重构为使用委托属性的方式。
PropertyChangeSupport类维护了一个监听器列表,并向它们发送PropertyChangeEvent事件。要使用它,你通常需要把这个类的一个实例存储为bean类的一个字段,并将属性更改的处理委托给它。
为了避免要在每个类中去添加这个字段,你需要创建一个小的工具类,用来存储PropertyChangeSupport的实例并监听属性更改。之后,你的类会继承这个工具类,以访问changeSupport。
现在我们来写一个Person类,定义一个只读属性(作为一个人的名称,一般不会随时更改)和两个可写属性:年龄和工资。当这个人的年龄或工资发生变化时,这个类将通知它的监听器。
注意,这里的代码是如何使用field标识符来访问age和salary属性的支持字段的,与4.2.4小节所讨论的一样。
setter中有很多重复的代码。我们尝试来提取一个类,用来存储这个属性的值并发起通知。
现在,你应该已经差不多理解了在kotlin中,委托属性时如何工作的。你创建了一个保存属性值的类,并在修改属性时自动触发更改通知。你删除了重复的逻辑代码,但是需要相当多的样板代码来为每个属性创建ObservableProperty实例,并把getter和setter委托给它。kotlin的委托属性功能可以让你摆脱这些样板代码。但是在此之前,你需要更改 ObservableProperty方法的签名,来匹配kotlin约定所需的方法。
通过关键字by,kotlin编译器会自动执行之前版本的代码中手动完成的操作。如果把这份代码与以前版本的Person类进行比较:使用委托属性时生成的代码非常类似。右边的对象被称为委托。kotlin会自动将委托存储在隐藏的属性中,并在访问或修改属性时调用委托的getValue和setValue。
你不用手动去实现可观察的属性逻辑,可以使用kotlin标准库,它已经包含了类似于ObservableProperty的类。标准库类和这里使用的PropertyChangeSupport类没有耦合,因此你需要传递一个lambda,来告诉它如何通知属性值的更改。可以这样做:
by右边的表达式不一定是新创建的实例,也可以是函数调用、另一个属性或任何其他表达式,只要这个表达式的值,是能够被编译器用正确的参数类型来调用getValue和setValue的对象。与其他约定一样, getValue和setValue可以是对象自己声明的方法或扩展函数。
注意,为了让示例保持简单,我们只展示了如何使用类型为Int的委托属性。委托属性机制其实是通用的,适用于任何其他类型。
让我们来总结一下委托属性时怎么工作的,假设你已经有了一个具有委托属性的类:
MyDelegate实例会被保存到一个隐藏的属性中,它被称为
因此,在每个属性访问器中,编译器都会生成对应的 getValue和setValue方法,如下图
这个机制非常简单,但它可以实现许多有趣的场景。你可以自定义存储该属性值的位置(map、数据库表或者用户会话的Cookie中),以及在访问该属性时做点什么(比如添加验证、更改通知等)。所有这一切都可以用紧凑的代码完成。
委托属性发挥作用的另一种常见用法,是用在有动态定义的属性集的对象中。这样的对象有时被称为自订对象。例如,考虑一个联系人管理系统,可以用来存储有关联系人的任意信息。系统中的每个人都有一些属性需要特殊处理(例如名字),以及每个人特有的数量任意的额外属性(例如,最小的孩子的生日)。
实现这种系统的一种方法时将人的所有属性存储在map中,不确定提供属性,来访问需要特殊处理的信息。来看个例子:
这里使用了一个通用的api来把数据加载到对象中(在实际项目中,可以是JSON反序列化或类似的方法),然后使用特定的api来访问一个属性的值。把它改为委托属性非常简单,可以直接将map放在关键字by后面。
因为标准库已经在标准Map和MutableMap接口上定义了getValue和setValue扩展函数,所以这里可以直接这样用。属性的名称将自动用作在map中的键,属性值作为map中的值。在上面7.25代码清单中, p.name隐藏了_attributes.getValue(p, prop)的调用,这里变为_attributes[prop.name]。
更改存储和修改属性的方式对框架的开发人员非常有用。
假设数据库中Users的表包含两列数据:字符串类型的name和整形的age。可以在kotlin中定义Users和User类。在kotlin代码中,所有存储在数据库中的用户实体的加载和更改都可以通过User类的实例来操作。
Users对象描述数据库的一个表:它被声明为一个对象,因为它对应整个表,所以只需要一个实例。对象的属性表示数据表的列。
User类的基类Entity,包含了实体的数据库列与值的映射。特定User的属性拥有这个用户在数据库中指定的值name和age。
框架用起来会特别方便,因为访问属性会自动从Entity类的映射中检索相应的值,而修改过得对象会被标记为脏数据,在需要时可将其保存在数据库中。可以在kotlin代码中编写user.age += 1,数据库中相应实体将自动更新。
现在,你已经充分了解了如何实现具有这种api的框架。每个实体属性(name, name)都实现为委托属性,使用列对象(Users.name, Users.age)作为委托:
让我们来看看怎样显式地指定类的类型:
至于Column类,框架已经定义了getValue和setValue方法,满足kotliin的委托约定: