Kotlin-安卓开发学习手册-全-
Kotlin 安卓开发学习手册(全)
一、您的第一个 Kotlin 应用:Hello Kotlin
在本章中,我们将学习如何使用 Android Studio 集成开发环境(IDE)来编写和执行第一个简单的 Kotlin 程序。
设置 IDE: Android Studio
虽然计算机程序可以用简单的文本编辑器编写,然后通过在系统终端输入一些系统级命令来准备和执行,但使用 IDE 有助于将项目文件放在一起,还可以简化各种与开发相关的活动。
注意
计算机语言有两种风格:要么你有一些程序代码,当程序运行时由一些执行引擎解释,然后在 CPU 上执行,要么你有一种编译语言,用特殊的预备系统命令首先将程序代码翻译成编译的程序,可以直接由操作系统或一些特别定制的执行引擎执行。Kotlin 就是这样一种编译语言。如果您使用像 Android Studio 这样的 IDE,编译步骤通常会自动完成。
在本书中,我们使用 Android Studio 作为 IDE。它由 Google 公司开发,基于 IntelliJ IDEA 的社区版。您可以免费下载、安装和使用它。在撰写本文时,下载页面位于 https://developer.android.com/studio/
。如果该链接不起作用,您可以通过在您最喜欢的搜索引擎中输入“android studio download”来轻松找到下载位置。要使用 Android Studio,您不必购买私人或商业项目的许可证。要在您的电脑上安装 Android Studio,请遵循以下步骤:
-
下载适用于您的操作系统的安装程序。有针对 Linux 的安装程序(针对 Ubuntu 14.04 测试;更高版本应该也可以)、Windows(从版本 7 开始)、MacOS(从 MacOS X 10.10 开始)。
-
启动安装程序。对于 Linux,解压缩安装程序 ZIP,然后导航到
bin
文件夹,并在终端中启动studio.sh
。在 Windows 系统上,启动.exe
文件。在 MacOS X 系统上,启动.dmg
文件,然后将 Android Studio 拖放到Applications
文件夹中。从那里再发射一次。
注意
要在 Ubuntu Linux 中打开终端,请按 Ctrl+Alt+T。在终端中,需要使用键盘输入命令。要更改目录,输入cd /path/to/directory
。要启动一个.sh
文件,输入./name.sh
安装的细节取决于您的操作系统细节,包括操作系统版本,以及您的 Android Studio 下载版本。你下载 Android Studio 的页面会给你更多的详细信息,甚至会提供安装过程的视频。
在任何情况下,Android Studio 的安装程序都会下载额外的组件。当您创建新项目时,这同样适用于项目向导,这取决于项目所需的功能以及已经安装的组件。因此,在开始你的第一个项目之前,你需要有一些耐心;后续的创业当然会更快。
继续安装,直到系统询问您是否要创建一个新项目。对于 Linux,这将看起来像图 1-1 ,对于其他操作系统,您将看到类似的东西。
图 1-1
项目创建向导
连接您的 Android 设备
首先,很重要的一点是,开发 Android 应用不一定需要手边有一个真正的硬件设备。在本章后面的“设置和使用模拟器”一节中,我们将讨论如何使用模拟器来模拟 Android 设备。然而,对于专业应用来说,手头至少有一个 Android 硬件设备是个好主意。
Android Studio 允许使用真实和模拟设备。很明显,只使用智能手机这样的真实设备可以给你的应用运行带来最大的保证。然而,它会告诉你只有你的智能手机可以操作你的应用;你不能确定其他设备会对此满意。你肯定不想买几十种不同的智能手机和其他 Android 设备。同样,尽管只在模拟设备上工作而不在真实设备上工作,也不能百分之百保证你的应用能在任何真实设备上工作。
因此,建议的开发技术是同时使用真实设备和模拟设备。你不必检查两个世界中的每一个发展步骤,但是一旦你到达一个里程碑,你应该做双重检查。当然,在你发布你的应用并提供给更广泛的受众之前,你应该在真实和模拟设备上测试它。
将 studio 连接到真实设备的过程可能会有所不同,但理想情况下,您只需将智能手机连接到 PC 或笔记本电脑的 USB 端口,并确保您的设备是可调试的设备。描述任何可能出现的问题的解决方案在这里没有太大的意义,因为你的操作系统或 Android Studio 的任何更新都可能很容易改变这种情况。因此,如果您有问题,请查阅 Android 和 Android Studio 官方文档,并使用您最喜欢的搜索引擎来查找相应的博客条目。连接硬件设备的过程基本如下:
-
要使您的智能手机可调试,对于 Android 或更高版本,请打开设置对话框,转到关于手机,并在内部版本号上点击七次。对于之前的版本,您可能需要转到设置➤开发选项➤检查“USB 调试”
-
通过 USB 电缆将智能手机连接到笔记本电脑或 PC。
要查看工作室是否实际连接到设备,请转到工具➤安卓➤安卓设备监视器。您应该会在设备监视器的设备部分看到您的设备,如图 1-2 所示。
图 1-2
硬件 Android 设备
开始你的第一个 Kotlin 应用
现在是时候在 Android Studio 中编写我们的第一个 Kotlin 应用了。在安装步骤中,系统会询问您是否要创建一个项目,或者在您第一次启动已安装的 Android Studio 实例后,或者在运行的 Android Studio 文件➤新➤新项目中,在菜单内进行如下操作:
-
选择或单击开始新的 Android Studio 项目。
-
在项目向导中,输入
HelloKotlin
作为应用名称。虽然不是绝对必要的,但是最好避免在名称中使用空格字符。 -
对于公司域,输入
example.com
。除了不使用空格之外,您在这里输入什么由您自己决定。然而,输入你或你的公司拥有的真实域名是一个好习惯。对于你知道你永远不会发表的项目,选择你喜欢的。 -
Android Studio 建议的项目位置足够体面,但如果你喜欢,你可以选择不同的位置。
-
确保选择了“包括 Kotlin 支持”。
-
选择手机和平板电脑作为外形规格。
-
选择 API 19 作为最低软件开发工具包(SDK)。
-
选择空活动。使用建议的 MainActivity 作为活动名称。确保选择了 Generate Layout File,并接受建议的 activity_main 作为布局名称。确保也选择了向后兼容性。
第一次创建项目时,Android Studio 会自动下载并安装它需要的任何附加组件,然后它还会执行初始构建。这将需要几分钟的时间,所以请耐心等待。
此时,如果一切正常,Android Studio 主窗口将会出现,如图 1-3 所示。
图 1-3
Android Studio 主窗口
设置和使用模拟器
现在是安装设备模拟器的时候了。模拟器非常方便,因为它们允许开发 Android 应用,而无需连接真实的设备。模拟器在电脑屏幕上模拟 Android 设备。要安装一个可用的,去工具➤ AVD 管理器。出现的屏幕显示标题您的虚拟设备。单击创建虚拟设备。以下屏幕显示设备列表,如图 1-4 所示。
图 1-4
仿真设备
在“类别”下,确保选择“电话”。在中间窗格中,选择 Nexus 6 条目。单击下一步。在下一个屏幕上,单击 Oreo,API 27 的下载链接。浏览随后出现的子向导。这里下载了一个系统映像;这有点像模拟器设备的操作系统。回到系统图像屏幕,Oreo,API 27 项目现在被选中,可以单击 Next。单击下一步,然后在下一个屏幕上单击完成。
您的虚拟设备屏幕现在显示一个条目,如图 1-5 所示。你现在可以关闭窗口了。
图 1-5
带有条目的仿真设备
继续使用 HelloKotlin 应用
回到 Android Studio 主窗口,在应用的左侧,通过点击名称旁边的小三角形,您可以导航到以下文件(参见图 1-6 ):
图 1-6
HelloKotlin 应用
app → java →
com.example.hellokotlin → MainActivity
app → res →
layout → activity_main.xml
双击任何文件都会将它们显示在窗口中央窗格的编辑器中。两个文件MainActivity
和activity_main.xml
是我们第一个简单的 Kotlin 应用需要调整的中心文件。文件activity_main.xml
定义了智能手机屏幕的布局。我们将修改它来显示一个按钮和一个文本区域。为此,打开文件,通过选择窗格底部的 text 选项卡切换到编辑器的文本视图,然后编写以下内容作为其内容:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:tools=
"http://schemas.android.com/tools"
xmlns:app=
"http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent">
<Button android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Go"
android:onClick="go"/>
<EditText
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:ems="10"
tools:layout_editor_absoluteY="286dp"
tools:layout_editor_absoluteX="84dp"/>
</LinearLayout>
</android.support.constraint.ConstraintLayout>
图形设计到此为止。程序进入MainActivity.kt
文件。双击名称,在编辑器中打开它。
作为其内容,请编写以下内容:
package kotlin.hello.hellokotlin
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import kotlinx.android.synthetic.main.activity_main.*
import java.util.*
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
fun go(v:View) {
text.setText("Hello Kotlin!" + "\n" + Date())
}
}
单击窗口顶部任务按钮栏中的绿色三角形启动应用。从可用虚拟设备列表中,选择 Nexus 6 API 27,然后单击确定。第一次你可能会被问及是否要安装一个名为 Instant Run 的功能。如果是,请单击安装并继续。
现在模拟器窗口出现。如图 1-7 所示,应用被构建,发送到仿真器,并在那里启动。
图 1-7
HelloKotlin 应用已启动
单击 Go,仿真设备屏幕会更新,显示文本“Hello Kotlin!”以及当前日期,如图 1-8 所示。
图 1-8
设备上的 HelloKotlin 应用
恭喜你!您刚刚编写、编译并运行了您的第一个 Kotlin 应用!
使用命令行
虽然您可以继续使用 Android Studio 来处理任何深度的项目,但也可以在终端中使用命令行来构建和运行应用。如果你想继续使用 Android Studio,你可以安全地跳过这一部分。对于所有其他人,我想描述如何使用终端来构建应用,更准确地说是我们在上一节中创建的 HelloKotlin 应用。
注意
使用命令行很有帮助,例如,在没有桌面环境的情况下,比如在服务器上。你也可以在开发人员提供代码的自动化构建环境中使用它,但是要在 Android 设备上执行的程序是自动生成的。
有趣的是,Android Studio 帮助我们摆脱了自我。对于您在 Android Studio 中成功构建的任何项目,包含项目文件的文件夹也将包含专门定制的构建脚本,您可以使用这些脚本来构建应用,而无需使用 Android Studio。
首先我们需要打开一个终端:在 Ubuntu Linux 中,按 Ctrl+Alt+T。在 Windows 中,你可以通过在系统菜单中搜索 CMD 来找到一个终端。对于 Apple Mac OS,在 Spotlight 中搜索终端后可以打开终端。接下来,我们需要知道项目文件在文件系统中的位置。如果您接受了 Android Studio 在创建项目时给出的建议,路径如下:
/home/[USER]/AndroidStudioProjects/HelloKotlin
for Linux
/Users/[USER]/AndroidStudioProjects/HelloKotlin
for Mac OS X
C:\Users\[USER]\AndroidStudioProjects\HelloKotlin
for Windows
其中[USER]
是您的登录用户名。如果您使用了自定义项目位置,则必须使用该位置。
流畅地使用终端是一门艺术,我们在这里不赘述。但是,以下命令将为您提供一个起点。在终端中,我们切换到项目文件夹,如下所示:
cd [PATH] #for Linux and Mac OS X and Windows
其中[PATH]
是我们刚刚确定的项目文件夹。在这里,我们可以通过输入
./gradlew app:build #for Linux and Mac OS X
gradlew app:build #for Windows
注意
gradlew
命令属于 Gradle 构建系统。Gradle 在 Android Studio 中被用来构建可执行的应用。
最终的应用作为一个带有.apk
后缀的 APK 文件将出现在app/build/outputs/apk/debug/
中。APK 来自安卓包;这种文件是 Android 在设备上安装应用所需的所有文件的压缩集合。gradlew
包装器脚本实际上允许更多的选项来构建和调查项目。输入-help
或tasks
作为参数,将它们全部列出。
./gradlew –help #for Linux and Mac OS X
./gradlew tasks #for Linux and Mac OS X
gradlew –help #for Windows
gradlew tasks #for Windows
为了让tasks
命令具体显示应用的任务,你必须在前面加上app:
,这是我们之前看到的build
任务。
注意
描述如何处理这样一个由构建产生的 APK 文件的工作留给了 Android 书籍。作为开始的提示,学习如何使用 SDK 中提供的工具,尤其是adb
平台工具。
二、类和对象:面向对象的哲学
在本书的开始,我们说计算机程序是关于处理一些输入并从中产生一些输出,也可能改变一些数据保存实例如文件或数据库的状态。虽然这肯定是真的,但这并没有说明全部情况。在现实世界的场景中,计算机程序表现出另一个特点:它们应该有一些实际用途,因此,模拟现实世界的事件和事物。
比方说,你写了一个简单的程序来登记纸质发票并计算每天的金额。输入很清楚:它是电子形式的纸质发票。输出是每天的总数,同时数据库保存所有注册发票的记录。建模方面告诉我们,我们处理以下对象:电子形式的发票、用于保存记录的数据库,以及一些用于访问数据库和执行求和操作的计算引擎。为了实用,这些物体需要具有以下特征。首先,根据它们的性质,它们可能有一个状态。例如,对于发票对象,我们有卖方名称、买方名称、日期、货物名称,当然还有金额。这种状态元素通常被称为属性。数据库显然将数据库内容作为其状态。相比之下,计算引擎不需要自己的状态,它使用其他对象的状态来完成工作。对象的第二个特性是你可以对它们执行的操作,通常被称为方法。例如,invoice 对象可能有设置和告诉我们其状态的方法,database 对象需要从其存储中保存和检索数据的方法,计算引擎显然必须能够执行每天的求和计算。
在我们加深对那种真实世界到计算机世界映射方法的理解之前,让我们先总结一下到目前为止我们所看到的。
-
对象:我们使用对象来标识真实世界中我们想要在计算机程序中建模的事物。
-
状态:对象通常有一个状态,它描述了每个对象必须能够处理它应该执行的任务的特征。
-
属性:对象的状态由一组属性组成。因此,属性是状态的元素。
-
方法:方法是访问对象的一种方式。这描述了一个对象需要展示的功能方面,以处理它应该执行的任务,可能包括改变和查询它的状态。
注意
根据所使用的术语,方法有时也被称为操作或函数,属性有时被称为属性。虽然我们继续使用属性,但是我们将切换到使用函数作为方法,因为 Kotlin 文档使用了术语函数并且我们希望让事情变得简单,包括 Kotlin 文档中的参考研究。
要理解的一个重要概念是,发票不是物体,人也不是,三角形也不是。这怎么可能呢?我们刚刚谈到发票是对象,为什么人和三角形不是对象?这种矛盾来自于某种语言上的含混。你知道我们在谈论发票,但不是发票吗?这两者之间有一个主要的区别:发票,或者更准确地说是一个特定的发票,是一个对象,但发票是一个分类或类。所有可能的发票共享 Invoice 类中的成员资格,所有具体的人共享 Person 类中的成员资格,就像所有可能的三角形都属于三角形类一样。这是否显得理论化甚至吹毛求疵?也许吧,但是它有重要的实际意义,我们真的需要理解阶级的概念。假设在某一天有一千张发票到达。在一些计算机程序中,我们真的想写这样的东西吗:
object1 = Invoice(Buyer=Smith,Date=20180923,Good=Peas,Cost=$23.99), object2 = Invoice(...), ..., object1000 = Invoice(...)
这是没有意义的,因为我们不想每天都编写一个庞大的新计算机程序。相反,有意义的是拥有一个描述所有可能发票的 Invoice 类。从这个类,我们必须能够创建一些发票样式输入的具体发票。在伪代码中:
data = [Some incoming invoice data]
这提供了特定纸质发票的接收发票数据。确保数据可以用 Invoice 类的抽象特征来表示,这样它就有了买家、日期、商品或服务等等。这就相当于说 Invoice 是所有可能输入数据的有效分类。
object = concrete object using that data
给定分类和数据,您可以构建一个具体的发票对象。从 invoice 类构建具体 Invoice 对象的另一种说法是从该类构建一个对象或创建一个 Invoice 实例。我们将在本书的其余部分使用实例和构造函数的概念。
我们这一章的主题,面向对象,正是关于类、实例化和对象的。一些细节仍未提及,但让我们先总结一下我们刚刚学到的内容,然后继续我们的定义列表:
-
类:一个类表征了某种类型的所有可能对象。因此,它是一个抽象,任何以类为特征的对象都属于那个特定的类。
-
实例:一个类的实例恰好代表一个属于该类的对象。从类和具体数据创建对象的过程称为实例化。
-
构造:从类创建实例的过程也叫构造。
有了这些面向对象的概念,我们现在可以开始看 Kotlin 如何处理对象、类和实例化。在接下来的章节中,我们还将讨论一些我们还没有介绍的面向对象的方面。在这里,我们可以用理论的方式来做,但是用 Kotlin 语言来描述会更容易掌握。
Kotlin 和面向对象编程
在这一节中,我们将讨论 Kotlin 中类和对象的主要特征。有些方面在后面的章节中会有更详细的介绍,但是这里我们想给你一些 Kotlin 编程需要的基础知识。
类别声明
注意
这里的术语声明用于描述一个类的结构和组成部分。
在 Kotlin 中,要声明一个类,你基本上要写
class ClassName(Parameter-Declaration1, Parameter-Declaration2, ...) {
[Class-Body]
}
让我们检查一下它的各个部分:
-
这是类的名称。它不能包含空格,按照惯例,在 Kotlin 中应该使用 CamelCase 符号;也就是说,以一个大写字母开始,而不是在单词之间使用空格,将第二个单词的第一个字母大写,如
EmployeeRecord
。 -
Parameter-Declaration:
These declare a primary constructor and describe data that are needed to instantiate classes. We talk more about parameters and parameter types later, but for now we mention that such parameter declarations basically come in three varieties:-
Variable-Name:Variable-Type
:一个例子就是userName: String
。使用它来传递可用于实例化类的参数。这发生在一个叫做init{}
块的特殊结构中。我们稍后将讨论初始化。 -
val Variable-Name:Variable-Type
(例如val userName: String
):使用它从init{}
块内部传递一个可用的参数,同时定义一个不可改变的属性。因此,该参数用于直接设置对象状态的一部分。 -
var Variable-Name:Variable-Type
(例如var userName: String
):使用它从init()
函数内部传递一个可用的参数,同时定义一个可变的属性来设置对象状态的一部分。
对于名称,使用 CamelCase 符号,这次以小写字母开始,如 nameOfBuyer。变量类型有很多可能性。例如,您可以使用
Int
表示一个整数,这样声明看起来就像val a:Int
。在第三章中,我们会更多地讨论类型。 -
-
[Class-Body]
:这是任意数量的函数和附加属性的占位符,也是实例化一个类时使用的init { ... }
块。此外,你还可以有二级构造函数和伴随对象,我们后面会描述,还有内部类。
练习 1
下列哪一项似乎是有效的类声明?
1\. class Triangle(color:Int) (
val coordinates:Array<Pair<Double,Double>>
= arrayOf()
)
2\. class Triangle(color:Int) {
val coordinates:Array<Pair<Double,Double>>
= arrayOf()
}
3\. class simple_rectangle() {
val coordinates:Array<Pair<Double,Double>>
= arrayOf()
}
4\. class Colored Rectangle(color:Int) {
val coordinates:Array<Pair<Double,Double>>
= arrayOf()
}
财产申报
我们将在第三章中讨论属性的详细特征。在这里,我对简单的属性声明做了一个简单的总结:它们基本上看起来像
val Variable-Name:Variable-Type = value
对于不可变属性,以及
var Variable-Name:Variable-Type = value
对于可变属性。然而,如果变量值在一个init { }
块中被设置,就不需要= value
。
class ClassName(Parameter-Declaration1,
Parameter-Declaration2, ...) {
...
val propertyName:PropertyType = [init-value]
var propertyName:PropertyType = [init-value]
...
}
关于可变性,有一个词是合适的:不可变意味着val
变量在某个地方获得它的值,并且之后不能被改变,而可变意味着var
变量可以在任何地方自由改变。不可变变量在程序稳定性方面有一些优势,所以根据经验,你应该总是选择不可变变量而不是可变变量。
练习 2
以下哪一个是有效的类?
1\. class Invoice() {
variable total:Double = 0.0
}
2\. class Invoice() {
property total:Double = 0.0
}
3\. class Invoice() {
Double total =
0.0
}
4\. class Invoice() {
var total:Double = 0.0
}
5\. class Invoice() {
total:Double = 0.0
}
练习 3
下面的类有什么问题(不是技术上的,而是从功能的角度)?
class Invoice() {
val total:Double = 0.0
}
怎么修?
类初始化
类体内的init { }
块可能包含当类被实例化时被处理的语句。顾名思义,它应该在实际使用之前用来初始化实例。这包括准备实例的状态,以便正确设置它来完成工作。事实上,一个类中可以有几个init{ }
块。在这种情况下,init{ }
块按照它们在类中出现的顺序被处理。然而,这样的init{ }
块是可选的,所以在简单的情况下,不提供这样的块是完全可以接受的。
class ClassName(Parameter-Declaration1,
Parameter-Declaration2, ...) {
...
init {
// initialization actions...
}
}
注意
A //开始一个所谓的行尾注释**;Kotlin 语言会忽略从该行开始直到当前行结束的任何内容。您可以将它用于注释和文档。
如果您在一个init { }
块中设置属性,就不再需要在属性声明中写= [value]
。
class ClassName(Parameter-Declaration1,
Parameter-Declaration2, ...) {
val someProperty:PropertyType
...
init {
someProperty = [some value]
// more initialization actions...
}
}
如果您在属性声明中指定了一个属性值,然后在init { }
中更改了属性值,那么在init{ }
开始之前,属性声明中的值将被用来初始化属性。稍后,在init { }
中,属性的值会被合适的语句改变:
class ClassName {
var someProperty:PropertyType = [init-value]
...
init {
...
someProperty = [some new value]
...
}
}
练习
下面这个类有什么问题?
class Color(val red:Int,
val green:Int,
val blue:Int)
{
init {
red = 0
green = 0
blue = 0
}
}
练习 5
下面这个类有什么问题?
class Color() {
var red:Int
var green:Int
var blue:Int
init {
red = 0
green = 0
}
}
Kotlin 的发票
这是足够的理论;让我们来解决我们已经讨论过的发票类。为了简单起见,我们的发票将具有以下属性:买方的名和姓、日期、单个产品的名称和数量,以及每件产品的价格。我知道在现实生活中我们需要更多的属性,但是这个子集在这里已经足够了,因为它描述了足够多的情况,并且你可以很容易地扩展它。实际的Invoice
类的初稿是这样的:
class Invoice(val buyerFirstName:String,
val buyerLastName:String,
val date:String,
val goodName:String,
val amount:Int,
val pricePerItem:Double) {
}
我们将在本章后面讨论数据类型,但是现在我们需要知道String
是任意字符串,Int
是整数,Double
是浮点数。您可以看到,对于传递给类的所有参数,我都使用了val ...
形式,因此在实例化之后,所有这些参数都将作为不可变(不可更改)的属性可用。这在这里很有意义,因为这些参数正是描述一个发票实例的特征或状态所需要的。
注意
在 Kotlin 中,允许完全省略空块。因此,您可以从Invoice
类声明中移除{ }
。尽管如此,我们把它留在这里,因为我们很快就会给身体添加元素。
更多发票属性
类体仍然是空的,但是我们可以很容易地想到我们可能想要添加的属性。例如,手头有买家的全名和所有商品的总价可能会很有趣。我们可以添加相应的属性:
class Invoice(val buyerFirstName:String,
val buyerLastName:String,
val date:String,
val goodName:String,
val amount:Int,
val pricePerItem:Double)
{
val buyerFullName:String
val totalPrice:Double
}
我们忘记通过= something
添加值来初始化属性了吗?嗯,是也不是。这样写实际上是被禁止的,但是因为我们很快就会初始化那些在init{ }
块中的属性,所以不初始化这些属性是允许的。
发票初始化
说到做到,我们添加一个相应的init{ }
块:
class Invoice(val buyerFirstName:String,
val buyerLastName:String,
val date:String,
val goodName:String,
val amount:Int,
val pricePerItem:Double)
{
val buyerFullName:String
val totalPrice:Double
init {
buyerFullName = buyerFirstName + " " +
buyerLastName
totalPrice = amount * pricePerItem
}
}
顺便说一下,有一种更短的方法来编写这样的单行属性初始化器:
...
val buyerFullName:String = buyerFirstName + " " + buyerLastName
val totalPrice:Double = amount * pricePerItem
...
这使得init{ }
块变得不必要。然而,使用一个init{ }
块并没有什么功能上的区别,后者允许进行不适合一条语句的更复杂的计算。
练习 6
编写没有init{ }
块的Invoice
类,保留其全部功能。
Kotlin 中的实例化
现在类声明已经准备好了,要从它实例化一个Invoice
对象,你要做的就是这样写:
val firstInvoice = Invoice("Richard", "Smith", "2018-10-23", "Peas", 5, 2.99)
如果你不知道如何把所有这些放进一个程序中,在 Kotlin 中,把所有东西都写在一个文件中是完全可以接受的,这个文件的内容是:
class Invoice(val buyerFirstName:String,
val buyerLastName:String,
val date:String,
val goodName:String,
val amount:Int,
val pricePerItem:Double)
{
val buyerFullName:String
val totalPrice:Double
init {
buyerFullName = buyerFirstName + " " +
buyerLastName
totalPrice = amount * pricePerItem
}
}
fun main(args:Array<String>) {
val firstInvoice = Invoice("Richard", "Smith",
"2018-10-23", "Peas", 5, 2.99)
// do something with it...
}
main()
函数是 Kotlin 应用的入口点。不幸的是,这对 Android 来说并不适用,因为 Android 对如何启动应用有着不同的想法。请耐心等待,因为我们很快就会回来。
注意
话虽如此,请不要写包含大量不同类或长函数的文件。我们将在本章后面的“结构化和包”一节中讨论程序结构。现在,只要记住拥有短的可识别的代码片段对编写好的软件有很大的帮助!
向发票添加功能
我们的Invoice
类还没有显式函数。我故意说显式,因为凭借构造函数属性和我们在类体中添加的属性,Kotlin 以objectName.propertyName
的形式为我们提供了隐式访问函数。例如,我们可以在任何函数中添加:
...
val firstInvoice = Invoice("Richard", "Smith",
"2018-10-23", "Peas", 5, 2.99)
val fullName = firstInvoice.buyerFullName
其中firstInvoice.buyerFullName
从对象中读取购买者的全名。在不同的情况下,我们也可以使用访问器来编写属性,如
...
val firstInvoice = Invoice("Richard", "Smith",
"2018-10-23", "Peas", 5, 2.99)
firstInvoice.buyerLastName = "Doubtfire"
你明白为什么我们不能在这里做吗?记住,我们将buyer- LastName
声明为不可变的val
,所以它不能被改变。如果我们用var
代替val
,变量变得可变,设置变成了允许的操作。
作为一个显式函数的例子,我们可以创建一个方法让对象告诉它的状态。让我们称这个函数为getState()
。一种实现是:
class Invoice( [constructor parameters] ) {
val buyerFullName:String
val totalPrice:Double
init { [initializer code] }
fun getState(): String {
return "First name: ${firstName}\n" +
"Last name: ${lastName}\n" +
"Full name: ${buyerFullName}\n" +
"Date: ${date}\n" +
"Good: ${goodName}\n" +
"Amount: ${amount}\n" +
"Price per item: ${pricePerItem}\n" +
"Total price: ${totalPrice}"
}
}
其中fun getState(): String
中的:String
表示函数返回一个字符串,return ...
实际执行返回动作。字符串中的${some- Name}
被替换为someName
的值,而\n
代表换行符。
注意
开发人员经常使用术语实现来描述从一个想法到执行这个想法的代码的转换。
要从类外部调用函数,只需使用对象名和函数名,并编写
objectName.functionName(parameter1, parameter2, ...)
因为我们没有关于getState()
的任何参数,这将是:
...
val firstInvoice = Invoice("Richard", "Smith",
"2018-10-23", "Peas", 5, 2.99)
val state:String = firstInvoice.getState()
然而,如果我们发现自己在类中,比如在一个init{ }
块中或者在类的任何其他函数中,调用一个函数只需使用它的名字,如
...
// we are inside the Invoice class
val state:String = getState()
函数将在本章后面详细描述。现在,我只想提一下函数可能有一个参数列表。例如,Invoice
类使用税率作为参数计算税款的方法如下:
fun tax(taxRate:Double):Double {
return taxRate * amount * pricePerItem
}
参数列表后的:Double
声明该方法返回一个浮点数,而return
语句实际上是这样做的。对于包含多个元素的参数列表,请使用逗号(,)作为分隔符。如果你还没有意识到,星号(*)是用来描述乘法运算的。
要调用税收方法,您需要编写
...
val firstInvoice = Invoice("Richard", "Smith", "2018-10-23", "Peas", 5, 2.99)
val tax:Double = firstInvoice.tax(0.11)
练习 7
添加一个方法goodInfo()
,返回类似“5 块苹果”的内容提示:使用amount.toString()
将金额转换为字符串。
完整的发票分类
到目前为止,我们已经讨论过的包含所有属性和方法的Invoice
类,以及调用它的一些代码,如下所示:
class Invoice(val buyerFirstName:String,
val buyerLastName:String,
val date:String,
val goodName:String,
val amount:Int,
val pricePerItem:Double)
{
val buyerFullName:String
val totalPrice:Double
init {
buyerFullName = buyerFirstName + " " +
buyerLastName
totalPrice = amount * pricePerItem
}
fun getState():String {
return "First name: ${buyerFirstName}\n" +
"Last name: ${buyerLastName}\n" +
"Full name: ${buyerFullName}\n" +
"Date: ${date}\n" +
"Good: ${goodName}\n" +
"Amount: ${amount}\n" +
"Price per item: ${pricePerItem}\n" +
"Total price: ${totalPrice}"
}
fun tax(taxRate:Double):Double {
return taxRate * amount * pricePerItem
}
}
fun main(args:Array<String>) {
val firstInvoice = Invoice("Richard", "Smith", "2018-10-23", "Peas", 5, 2.99)
val state:String = firstInvoice.getState()
val tax:Double = firstInvoice.tax(0.11)
// do more things with it...
}
这适用于您为桌面或服务器应用构建的应用风格的调用。它不能在 Android 上运行,因为启动应用和与硬件通信的程序与这样一个简单的main()
方法相比有很大的不同。因此,回到主题,在本章的剩余部分,我们将开发一个更加 Android 风格的应用。
一个简单的数字猜谜游戏
在 Android 中,应用围绕着活动、活动,从用户工作流程的角度来看,这些活动是对应于特定职责的可识别代码片段。这些职责中的每一项都可以由位于屏幕布局中的图形对象构建的不同屏幕来处理。一个应用可以有一个或多个由不同的类表示的活动,以及资源和配置文件。正如我们在第一章中已经看到的,Android Studio 帮助准备和裁剪所有必要的文件。
在本章的剩余部分以及接下来的大部分章节中,我们将学习一个简单的游戏,叫做数字猜谜游戏。虽然理解起来非常简单,但它足够复杂,足以显示基本的 Kotlin 语言结构,并允许进行扩展,以帮助说明本书过程中介绍的大多数语言功能。因此,我们既没有从最优雅的解决方案开始,也没有从一开始就展示最高性能的代码。我们的目标是从一个可用的应用开始,逐步引入新功能,这样我们就可以提高我们的 Kotlin 语言能力。
游戏描述如下:开始时,用户会看到一些信息文本和一个开始按钮。一旦启动,应用会在内部选择一个 1 到 7 之间的随机数。用户被要求猜测该数字,并且在每次猜测之后,用户被告知该猜测是匹配、太高还是太低。一旦随机数被选中,游戏就结束了,用户可以开始新的游戏。
要开始应用开发,请打开 Android Studio。如果你的上一个项目是第一章中的 HelloKotlin 应用,来自该应用的文件就会出现。要开始一个新项目,从菜单中选择文件➤新➤新项目。输入NumberGuess
作为应用名称,输入book.kotlinforandroid
作为公司域名。接受建议的项目位置或选择您自己的位置。确保选择了“包括 Kotlin 支持”。单击下一步。选择手机和平板电脑作为外形规格,API 19 作为最低软件开发套件(SDK)版本。再次单击下一步。选择空活动,然后单击下一步。接受建议的活动名称 MainActivity 和布局名称 activity_main。确保“生成布局文件”和“向后兼容”都已选中。单击完成。
Android Studio 现在将为游戏应用生成所有构建文件和基本模板文件。在res
文件夹中,你会找到几个资源文件,包括用于用户界面的图像和文本。我们现在不需要图像,但是我们定义了两个文本元素,用于布局文件和编码。双击文件res/values/strings.xml
将其打开。让文件读作:
<resources xmlns:tools="http://schemas.android.com/tools"
tools:ignore="ExtraTranslation">
<string name="app_name">
NumberGuess</string>
<string name="title.numberguess">
NumberGuess</string>
<string name="btn.start">
Start</string>
<string name="label.guess">
Guess a number:</string>
<string name="btn.do.guess">
Do guess!</string>
<string name="edit.number">
Number</string>
<string name="status.start.info">
Press START to start a game</string>
<string name="label.log">
Log:</string>
<string name="guess.hint">
Guess a number between %1$d and %2$d</string>
<string name="status.too.low">
Sorry, too low.</string>
<string name="status.too.high">
Sorry, too high.</string>
<string name="status.hit">
You got it after %1$d tries!
Press START for a new game.</string>
</resources>
布局文件位于res/layout/activity_main.xml
中。打开该文件,通过单击中间窗格底部的文本选项卡切换到文本视图,然后根据其内容编写以下内容:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android=
"http://schemas.android.com/apk/res/android"
xmlns:tools=
"http://schemas.android.com/tools"
xmlns:app=
"http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="30dp"
tools:context=
"kotlinforandroid.book.numberguess.MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title.numberguess"
android:textSize="30sp" />
<Button
android:id="@+id/startBtn"
android:onClick="start"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/btn.start"/>
<Space android:layout_width="match_parent"
android:layout_height="5dp"/>
<LinearLayout
android:orientation="horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<TextView android:text="@string/label.guess"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<EditText
android:id="@+id/num"
android:hint="@string/edit.number"
android:layout_width="80sp"
android:layout_height="wrap_content"
android:inputType="number"
tools:ignore="Autofill"/>
<Button
android:id="@+id/doGuess"
android:onClick="guess"
android:text="@string/btn.do.guess"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
<Space android:layout_width="match_parent"
android:layout_height="5dp"/>
<TextView
android:id="@+id/status"
android:text="@string/status.start.info"
android:textColor="#FF000000" android:textSize="20sp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<Space android:layout_width="match_parent"
android:layout_height="5dp"/>
<TextView android:text="@string/label.log"
android:textStyle="bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<kotlinforandroid.book.numberguess.Console
android:id="@+id/console"
android:layout_height="100sp"
android:layout_width="match_parent" />
</LinearLayout>
您将得到一个错误,因为该文件引用了尚不存在的类kotlinforandroid.book.numberguess.Console
。暂时忽略这个;我们将很快解决这个问题。该布局文件的所有其他元素在 Android 开发人员文档或相应的 Android 书籍中有详细描述。不过,这里有一些提示似乎是合适的。
-
如果没有切换到该文件的编辑器视图中的 Text 选项卡,则会显示 Design 视图类型。后者允许以图形方式排列用户界面元素。在本书中,我们不会使用图形设计编辑器,但是您也可以尝试一下。只是期望得到的 XML 会有一些小的不同。
-
我不使用花哨的布局容器;相反,我更喜欢在查看 XML 代码时易于编写和理解的代码。您不必为您的项目做同样的事情,事实上,根据具体情况,一些其他的解决方案可能会更好,所以您可以自由地尝试其他的布局方法。
-
在 XML 代码中的任何地方看到
@string/...
,它指的是来自strings.xml
文件的一个条目。 -
kotlinforandroid.book.numberguess.Console
元素指的是自定义视图。您在教程中不会经常看到这种情况,但是自定义视图允许更简洁的编码和改进的可重用性,这意味着您可以在其他项目中轻松使用它们。Console
指的是我们即将编写的自定义类。
Kotlin 代码进入文件java/kotlinforandroid/book/numberguess/MainActivity.kt
。打开它,里面写着:
package kotlinforandroid.book.numberguess
import android.content.Context
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.widget.ScrollView
import android.widget.TextView
import kotlinx.android.synthetic.main.activity_main.*
class MainActivity : AppCompatActivity() {
var started = false
var number = 0
var tries = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fetchSavedInstanceData(savedInstanceState)
doGuess.setEnabled(started)
}
override fun onSaveInstanceState(outState: Bundle?) {
super.onSaveInstanceState(outState)
putInstanceData(outState)
}
fun start(v: View) {
log("Game started")
num.setText("")
started = true
doGuess.setEnabled(true)
status.text = getString(R.string.guess_hint, 1, 7)
number = 1 + Math.floor(Math.random()*7).toInt()
tries = 0
}
fun guess(v:View) {
if(num.text.toString() == "") return
tries++
log("Guessed ${num.text} (tries:${tries})")
val g = num.text.toString().toInt()
if(g < number) {
status.setText(R.string.status_too_low)
num.setText("")
} else if(g > number){
status.setText(R.string.status_too_high)
num.setText("")
} else {
status.text = getString(R.string.status_hit,
tries)
started = false
doGuess.setEnabled(false)
}
}
///////////////////////////////////////////////////
///////////////////////////////////////////////////
private fun putInstanceData(outState: Bundle?) {
if (outState != null) with(outState) {
putBoolean("started", started)
putInt("number", number)
putInt("tries", tries)
putString("statusMsg", status.text.toString())
putStringArrayList("logs",
ArrayList(console.text.split("\n")))
}
}
private fun fetchSavedInstanceData(
savedInstanceState: Bundle?) {
if (savedInstanceState != null)
with(savedInstanceState) {
started = getBoolean("started")
number = getInt("number")
tries = getInt("tries")
status.text = getString("statusMsg")
console.text = getStringArrayList("logs")!!.
joinToString("\n")
}
}
private fun log(msg:String) {
Log.d("LOG", msg)
console.log(msg)
}
}
class Console(ctx:Context, aset:AttributeSet? = null)
: ScrollView(ctx, aset) {
val tv = TextView(ctx)
var text:String
get() = tv.text.toString()
set(value) { tv.setText(value) }
init {
setBackgroundColor(0x40FFFF00)
addView(tv)
}
fun log(msg:String) {
val l = tv.text.let {
if(it == "") listOf() else it.split("\n")
}.takeLast(100) + msg
tv.text = l.joinToString("\n")
post(object : Runnable {
override fun run() {
fullScroll(ScrollView.FOCUS_DOWN)
}
})
}
}
如果到现在为止你还没有理解文件中的所有内容,不要担心。在这一章的剩余部分和随后的章节中,我们会多次提到这个项目,最终你会明白所有的一切。现在这里是你需要知道的。
-
文件顶部的
package ...
既定义了该文件中声明的元素的名称空间,又指明了它在文件层次结构中的位置。我们将在后面讨论项目结构;现在,知道参数应该反映文件在java
文件夹中的位置就足够了,用点.
作为分隔符。 -
该文件包含两个类。在其他语言中,每个类都应该放在自己的文件中,事实上你可以将
Console
类的声明移到文件Console.kt
中。在 Kotlin 中,你可以在一个文件中写尽可能多的声明。但是,您不应该过度使用这个特性,因为在一个大文件中写太多东西不可避免地会导致代码混乱。然而,对于小型项目,为了简单起见,将几个声明放在一个文件中是可以接受的。 -
import ...
语句引用了其他项目中的类或者内置到 Kotlin 中的类。在import
语句中列出它们允许我们只使用它们的简单名称来处理导入的元素。否则,您必须在它们的包名前面加上前缀才能使用它们。通常的做法是尽可能多地导入以保持代码的可读性。 -
import 语句
kotlinx.android.synthetic.main.activity_main.*
是特殊的,因为它导入了 studio 从布局文件派生的用户界面相关的类。这与 Kotlin 无关;它是由 Android Studio 控制的一些自动化。 -
属性
var started = false
、var number = 0,
和var tries = 0
似乎缺少属性类型。然而,Kotlin 可以从赋值语句的右边自动推断出类型:false
属于一个布尔值,其他两个属于一个整数。因此,:PropertyType
在这里可以被忽略。 -
class MainActivity : AppCompatActivity() { ... }
声明表明类MainActivity
是从类AppCompatActivity
派生的,或者从它继承了。我们将在后面详细讨论继承;现在,知道MainActivity
是AppCompatActivity
的一种复制,某些部分被重新定义就足够了。 -
当用户界面被创建时,函数
onCreate()
被 Android 调用。其类型为Bundle
的参数可能包含也可能不包含用户界面重启后保存的数据。这是 Android 应用中经常发生的事情,因此每当活动重新启动时,我们都使用该参数来重建活动的状态。 -
当活动暂停时,调用
onSaveInstanceState()
。我们用它来保存活动的状态。 -
当用户点击用户界面中的按钮时,函数
start()
和guess()
都会被调用。你可以在布局文件中看到。我们将它们用作游戏动作,并相应地更新用户界面和活动对象状态。 -
标有
private
的函数只能在同一个类中使用;外部看不到它们。我们稍后将讨论可见度。为了强调这一点,我通常将所有私有函数放在类的末尾,并用两行注释//////....
将普通函数与私有函数分开 -
Console
是一个自定义视图对象。它可以放在任何布局中,就像 Android 提供的所有其他内置视图一样。 -
为简洁起见,没有添加内嵌文档。我们将在后面的章节中回到文档问题。
你现在可以开始游戏了。点按 Android Studio 顶部工具栏中的绿色箭头,并选择模拟器或连接的硬件设备来指定运行应用的位置。
构造器
我们已经知道,实例化发生时传递给类的参数在类名后的括号中声明:
class ClassName(Parameter-Declaration1,
Parameter-Declaration2, ...) {
[Class-Body]
}
我们还知道,可以从任何init{
块内部访问参数,而且如果我们在参数声明前加上val
或var
,会导致创建属性:
Variable-Name:Variable-Type
对于init{ }
块所需要的参数,
val Variable-Name:Variable-Type
如果您还希望将参数转换为不可变的属性,并且
var Variable-Name:Variable-Type
如果您还想将参数转换为可变属性。
Kotlin 中这样的参数声明列表被称为主构造函数。正如你可能猜到的,也有二级构造函数。不过,让我们先谈谈主构造函数,因为它们展示了我们还没有看到的特性。
完整的主构造函数声明实际上是:
class ClassName [modifiers] constructor(
Parameter-Declaration1,
Parameter-Declaration2, ...)
{
[Class-Body]
}
如果没有修饰符,参数列表前面的构造函数可以省略(连同空格字符)。作为修改器,可以添加以下可见性修改器之一:
-
实例化可以在程序内外的任何地方完成。这是默认设置。
-
只能从同一个类或对象内部进行实例化。如果使用辅助构造函数,这是有意义的。
-
protected
:设置与private
相同,但是实例化也可以从子类开始。子类属于继承,这在第三章讨论。 -
可以在模块内部的任何地方进行实例化。在 Kotlin 中,模块是一组编译在一起的文件。如果不希望其他程序(来自其他项目)访问构造函数,但是希望该构造函数可以从程序中的其他类或对象自由访问,可以使用此修饰符。
注意
在其他语言中,构造函数包含要在实例化时执行的语句或代码。Kotlin 的设计者决定只命名(主)构造函数中的参数,并将任何类初始化代码移到init{ }
块中。
在我们的NumberGuess
游戏中,活动类MainActivity
没有构造函数。实际上,它隐含了默认的无操作构造函数,不需要声明。事实上,Android 的一个特点是活动不应该有显式的构造函数。不过,这与 Kotlin 无关;这就是 Android 处理其对象生命周期的方式。相反,Console
类有一个构造函数。这也是 Android 对其视图元素的要求。
练习 8
用构造函数参数创建一个类Person
:firstName
(一个String
)lastName
(一个String
)ssn
(一个String
)dateOfBirth
(一个String
)和gender
(一个Char
)。确保这些参数以后可以作为实例属性使用,并且以后可以更改。
构造函数调用
在上一节中,我们已经应用了主要的使用模式:例如,给定一个类
class GameUser(val firstName:String,
val lastName:String,
val birthday:String,
val userName:String,
val registrationNumber:Int,
val userRank:Double) {
}
您可以通过以下方式实例化该类
...
val firstUser = GameUser("Richard", "Smith",
"2008-10-23", "rsmith", 123, 0.0)
您可以看到,对于这种类型的实例化,您必须按照与类定义中完全相同的顺序来指定参数。
练习 9
使用姓名John Smith
、出生日期1997-10-23
、SSN 0123456789
和性别M
实例化上一个练习中的Person
类。将其分配给变量val person1
。提示:对Char
文字使用单引号,如'A'
或'B'
。
练习 10
将本节我们谈到的GameUser
类加入到NumberGuess
游戏中。现在只添加类;不要编写在游戏逻辑中包含用户的代码。
命名构造函数参数
与仅仅按照声明中给出的顺序列出参数相比,实际上有一种方法可以以可读性更好、更不容易出错的方式构造对象。对于实例化,您还可以显式指定参数名,然后随意应用任何顺序:
val instance = TheClass(
parameterName1 = [some value],
parameterName2 = [some value],
...)
上一个练习中的GameUser
你可以写
...
val user = GameUser(
lastName = "Smith",
firstName = "Richard",
birthday = "2098-10-23",
userName = "rsmith",
registrationNumber = 765,
userRank = 0.5)
有了给定的名称,调用参数的排序顺序不再起作用。Kotlin 知道如何正确分配传入的参数。
练习 11
使用命名参数重写练习 9 中的Person
实例化。
练习 12
给MainActivity
增加一个var gameUser
属性,用名字John Doe
,用户名jdoe
,生日1900-01-01
,注册号= 0
,用户等级= 0.0
初始化。使用命名参数。提示:使用var gameUser = GameUser(...).
初始化声明中的属性
构造函数默认值
构造函数参数也可以有默认值。例如,我们可以使用“”作为默认生日,使用0.0
作为等级,以防我们不在乎。这简化了不指定生日的游戏用户和新用户的构造,例如,初始排名为0.0
。要声明这样的默认值,您需要编写:
class GameUser(val firstName:String,
val lastName:String,
val userName:String,
val registrationNumber:Int,
val birthday:String = "1900-01-01",
val userRank:Double = 0.0) {
}
如果使用带默认值和不带默认值的参数,这些默认值通常会出现在参数列表的末尾。只有这样,在调用期间传入参数的分布才是唯一的。现在,您可以像以前一样执行完全相同的构造,但是要注意顺序的变化:
...
val firstUser = GameUser("Richard", "Smith", "rsmith", 123, "2008-10-23", 0.4)
现在,凭借默认参数,可以省略参数。在…里
...
val firstUser = GameUser("Richard", "Smith", "rsmith", 123, "2008-10-23")
值0.0
将应用于排名,并且在
...
val firstUser = GameUser("Richard", "Smith", "rsmith", 123)
此外,将使用默认的生日1900-01-01
。
为了使事情变得更简单并进一步扩展可读性,您还可以混合使用默认参数和命名参数,如
...
val firstUser = GameUser(firstName = "Richard",
lastName = "Smith",
userName = "rsmith",
registrationNumber = 123)
这一次使用您喜欢的任何参数排序顺序。
练习 13
更新前面练习中的Person
类:将默认值" "(空字符串)添加到ssn
参数中。使用命名参数执行实例化,应用 SSN 的默认值。
练习 14
从NumberGuess
游戏中更新GameUser
类:在birthday
中加入默认值" "(空字符串),在userRank
参数中加入0.0
。
次要构造函数
有了命名参数和默认参数值,我们已经有了多种多样的方法来满足各种构造需求。如果这还不够,还有另一种描述不同构造方法的方式:二级构造函数。你可以有几个这样的构造函数,但是它们的参数列表必须不同于主构造函数的参数列表,并且它们彼此之间也必须不同。
注意
更准确地说,主构造函数和次构造函数都必须有不同的参数签名。签名是一组参数类型,其中考虑了顺序。
要声明一个二级构造函数,在类体内部写
constructor(param1:ParamType1,
param2:ParamType2, ...)
{
// do some things...
}
如果该类也有一个显式主构造函数,则必须委托给主构造函数调用,如下所示:
constructor(param1:ParamType1,
param2:ParamType2, ...) : this(...) {
// do some things...
}
在this(...)
中,必须指定主构造函数的参数。这里也可以为另一个次级构造函数指定参数,次级构造函数又委托给主构造函数。
在我们的GameUser
示例中,从主构造函数中移除默认参数值,次构造函数可能是这样的:
constructor(firstName:String,
lastName:String,
userName:String,
registrationNumber:Int) :
this(firstName = firstName,
lastName = lastName,
userName = userName,
registrationNumber = registrationNumber,
birthday = "",
userRank = 0.0
)
{
// constructor body
// do some things...
}
您可以通过以下方式实例化该类
...
val firstUser = GameUser(firstName = "Richard",
lastName = "Smith",
userName = "rsmith",
registrationNumber = 123)
在次级构造函数体内,你可以执行任意的计算和其他操作,这就是次级构造函数的用途,除了不同的,可能更短的参数列表。
这个结构firstName = firstName, lastName = lastName, userName
= userName
,registrationNumber = registrationNumber
可能看起来有点混乱。然而,如果您记得等号左边的部分指向主构造函数的参数列表中的名称,而右边是从constructor(...)
参数列表中获取的值,这就很容易理解了。
注意
如果您可以使用默认值和二级构造函数实现同样的事情,那么您应该倾向于使用默认值,因为这种表示法更有表现力,也更简洁。
练习 15
在前面练习的Person
类中,添加一个二级建造师,参数为firstName
(a String
)、lastName
(a String
)、ssn
(a String
、gender
(a Char
)。让它调用主构造函数,将缺少的dateOfBirth
设置为0000-00-00
。使用辅助构造函数创建一个实例。
如果不需要类:单例对象
偶尔,对象不需要分类,因为你知道永远不会有不同的状态与它们相关联。这是另一种说法:如果我们有一个类,就永远不会需要一个以上的实例,因为在应用的生命周期中,所有的实例都会以某种方式被强制携带相同的状态,因此是不可区分的。
为了清楚起见,Kotlin 允许使用以下语法创建这样的对象:
object ObjectName { [Object-Body]
}
其中对象体可以包含属性声明、init{ }
块和函数。主构造函数和次构造函数都不允许。为了将这种对象与本节其余部分的类实例化结果的对象区分开来,我使用了术语 singleton object 。
要访问单例对象的属性和函数,您可以使用与作为类实例化结果的对象类似的符号:
ObjectName.propertyName
ObjectName.function([function-parameters])
你不会太频繁地使用单例对象,因为没有类的面向对象没有太大的意义,经常使用太多的单例对象是糟糕的应用设计的标志。然而,在一些突出的例子中,对象声明是有意义的:
-
常量:对于您的应用,您可能希望有一个包含应用需要的所有常量的对象。
-
首选项:如果您有一个带有首选项的文件,您可能希望在应用启动后使用一个对象来读取首选项。
-
数据库:如果您的应用需要一个数据库,并且您认为您的应用永远不会访问不同的数据库,那么您可能希望将数据库访问函数移到一个对象中。
-
效用:效用函数在某种意义上是起作用的,它们的输出只取决于它们的输入,而没有状态与之相关联;比如
fun degreeToRad(deg: Double) = deg * Math.PI / 180
。它们还有一个共同的目的,从概念的角度来看,将它们添加到某些类中是没有意义的。因此,在单例对象中提供这样的实用函数是合理的,例如名为Utility
。
其他用例也是可能的;只要确保你使用类或单例对象的决定是基于合理的推理。如果有疑问,经验告诉我们使用类更有意义。
对于我们的NumberGuess
游戏,查看文件MainActivity.kt
我们可以看到,我们使用数字1
和7
作为游戏逻辑的下限和上限。这些数字在功能fun start(...)
中用于用户界面中显示的文本,并用于确定随机数:
status.text = getString(R.string.guess_hint, 1, 7)
number = 1 + Math.floor(Math.random()*7).toInt()
最好将这些常量提取到它们自己的文件中,这样以后就可以更容易地修改它们,或者在必要时从其他类中使用它们。单例对象似乎是一个非常适合它的地方。为了改进代码,我们通过在项目视图中右键单击包kotlinforandroid.book.numberguess
➤新➤Kotlin 文件/类来创建一个新文件。输入Constants
作为名称,并确保在下拉列表中选择文件。在创建的文件中,在package
声明下面,写下
object Constants {
val LOWER_BOUND = 1
val UPPER_BOUND = 7
}
我们再次省略了属性类型,因为 Kotlin 可以推断出1
和7
是Int
类型。
注意
这种自动插入也适用于其他类型,所以通常的做法是省略类型规范,只在需要或有助于提高可读性时才添加。
您可能已经注意到了另一件事:我们偏离了同伴对象中的val
的命名模式。使用这种带下划线的全大写符号表示我们有一个真正不可变的独立于实例的常量。因此,这样的常量更容易从代码内部识别。
回到start()
函数中的MainActivity.kt,
,我们现在可以写
status.text = getString(R.string.guess_hint,
Constants.LOWER_BOUND,
Constants.UPPER_BOUND)
val span = Constants.UPPER_BOUND -
Constants.LOWER_BOUND + 1
number = Constants.LOWER_BOUND +
Math.floor(Math.random()*span).toInt()
用于用户界面文本和密码。然后,该函数总共读取:
fun start(v: View) {
log("Game started")
num.setText("")
started = true
doGuess.setEnabled(true)
status.text = getString(R.string.guess_hint,
Constants.LOWER_BOUND,
Constants.UPPER_BOUND)
val span = Constants.UPPER_BOUND -
Constants.LOWER_BOUND + 1
number = Constants.LOWER_BOUND +
Math.floor(Math.random()*span).toInt()
tries = 0
}
练习 16
以下哪一项是正确的?
-
使用大量的单例对象有助于提高代码质量。
-
实例化单例对象是可能的。
-
要声明单例对象,可以使用
object
、singleton,
或singleton object
中的任意一个。 -
单例对象没有状态。
-
单例对象可能有一个构造函数。
练习 17
用以下属性创建一个Constants
singleton 对象:numberOf- Tabs = 5
、windowTitle = "Astaria"
、prefsFile = "prefs.properties"
。编写一些代码,打印出用于诊断目的的所有常量。提示:为了格式化,你可以在字符串中使用\n
来换行。
如果状态无关紧要:伴随对象
通常,也许你甚至没有注意到,你的类有两类属性和函数:状态相关和非状态相关。与状态无关意味着属性的值对于所有可能的实例都是相同的。对于函数,这意味着它们将对所有可能的实例做完全相同的事情。这在某种程度上与单例对象有关,单例对象根本不关心可区分的状态,因此 Kotlin 允许一个名为伴随对象的构造。这种伴随对象对于它们所伴随的特定类的所有实例都有一个不可区分的状态,这就是名称中“伴随”的来源。
要在类体中声明一个伴随对象,请编写以下代码:
companion object ObjectName {
...
}
其中ObjectName
是可选的;在大多数情况下,你可以省略它。在伴随对象的主体中,您可以添加与单独对象相同的元素(参见上一节)。
注意
只有当您想从类外部寻址它时,才需要伴生对象有一个名称,使用一个专用的名称:ClassName.ObjectName
。然而,即使缺少名称,您也可以通过ClassName.Companion
访问它。
一个伴随对象是一个声明类所使用的常量的好地方。然后,您可以在类内的任何地方使用这些常量,就像它们是在类本身中声明的一样:
class TheClass {
companion object ObjectName {
val SOME_CONSTANT: Int = 42
}
...
fun someFunction() {
val x = 7 * SOME_CONSTANT
...
}
}
在我们的NumberGuess
游戏中,Console
类中有两个常量:看看init{ }
函数,我们在其中为背景色指定了一个颜色值0x40FFFF00
(这是一个浅黄色)。此外,在功能fun log(...)
中,您可以看到一个100,
,它恰好指定了一个记忆的行数限制。我故意将这些留在了Constants
伴随对象中,因为这两个新常量可以被认为更接近于属于Console
类,并且可能被放错在一个公共常量文件中。
然而,将它们移动到一个伴随对象中是一个好主意,因为颜色和行号限制值由Console
类的所有实例共享,并且不会从一个实例内部被改变。相应重写的Console
类如下所示:
class Console(ctx:Context, aset:AttributeSet? = null)
: ScrollView(ctx, aset) {
companion object {
val BACKGROUND_COLOR = 0x40FFFF00
val MAX_LINES = 100
}
val tv = TextView(ctx)
var text:String
get() = tv.text.toString()
set(value) { tv.setText(value) }
init {
setBackgroundColor(BACKGROUND_COLOR)
ddView(tv)
}
fun log(msg:String) {
val l = tv.text.let {
if(it == "") listOf() else it.split("\n") }.
takeLast(MAX_LINES) + msg
tv.text = l.joinToString("\n")
post(object : Runnable {
override fun run() {
fullScroll(ScrollView.FOCUS_DOWN)
}
})
}
}
伴随对象属性和函数也可以从类外部访问。就写这个:
TheClass.THE_PROPERTY
TheClass.someFunction()
从相关联的伴随对象直接寻址属性或函数。当然,该函数也可以有参数。
练习 18
创建一个Triangle
类。随意添加构造函数参数和属性,还要创建一个带有常量NUMBER_OF_CORNERS = 3
的伴随对象。在类内部,创建一个info()
函数来指示角的数量。
练习 19
在一个main()
函数中,实例化练习 18 中的Triangle
类,然后将角的数量分配给某个val numberOfCorners
。
描述契约:接口
软件开发是关于需要做的事情,在面向对象的开发中,这意味着需要在类内部描述的对象上做的事情。然而,面向对象揭示了一个我们直到现在还没有谈到的特性:意图和实现的分离。
例如,考虑收集关于二维图形对象的信息的一个类或几个类,以及提供这种图形对象的另一个类或几个类。这引入了自然的类分离。我们将类的信息收集部分称为信息收集器模块,将提供图形对象的部分称为客户端模块。我们希望通过允许几个客户端模块来扩展这个想法,并且最终我们希望确保信息收集器模块不会关心有多少个客户端(参见图 2-1 )。
图 2-1。
收集器模块和客户端
注意
我们偏离通常的路径,暂时离开NumberGuess
游戏。如果我们有几个共享某些特性的类,那么接口的概念就更容易描述了,而这是NumberGuess
游戏所没有的。然而,我将在其中一个练习中使用一个接口来提议对NumberGuess
游戏的一个可能的扩展。
现在最重要的问题是:图形对象如何在模块之间进行通信?这里有一个显而易见的想法:因为客户机产生图形对象,为什么不让客户机为它们提供类呢?起初,这听起来不错,但有一个主要缺点:信息收集器模块需要知道如何处理每个客户端的图形对象类,并且当新客户端想要传输它们的对象时,它也需要更新。这样的策略对于一个好的程序来说不够灵活。
让我们试着反过来:信息收集器模块提供了所有的图形对象类,客户端使用它们来传递数据。虽然这弥补了收集器模块中不同类的增加,但是这种方法有一个不同的问题。比方说,信息收集器获得软件更新,并为图形对象类提供更改的版本。如果发生这种情况,我们还必须更新所有的客户端,这导致了大量的工作,包括增加专业项目的费用。所以这种方式也不是最好的。我们能做什么?
我们可以引入一个新的概念,它不描述事情如何被做,而只描述需要做什么。这以某种方式在不同的程序组件之间进行协调,因此它被称为接口。如果这样的接口不依赖于实现,而客户端只依赖于接口,那么如果信息收集器发生变化,则需要改变客户端的可能性会低得多。您也可以将界面视为双方之间的某种契约:就像在现实生活中一样,如果对契约中的措辞感到满意,那么即使完成的方式存在某种差异,契约也会得到履行。
在我进一步解释这一点之前,让我们更深入地了解一下图形收集器示例的细节。我们向图形收集器添加了以下职责:图形收集器必须能够获取执行以下操作的多边形对象:
-
说说他们有多少个角。
-
告诉我们每个角的坐标。
-
讲述它们的填充颜色。
您可以随意扩展它,但是对于我们的目标来说,这三个特征就足够了。我们现在引入一个接口声明,并编写如下代码:
interface GraphicsObject {
fun numberOfCorners(): Int
fun coordsOf(index:Int): Pair<Double, Double>
fun fillColor(): String
}
Pair<Double, Double>
代表一个点的 x 和 y 坐标的一对浮点数。我们让图形收集器模块定义接口,因为接口是客户端需要从图形收集器模块了解以与之通信的内容。然而,这三个函数的实现完全是客户的事情,因为对于图形采集器模块来说,合同履行的如何并不重要。然而,接口本身只是意图的声明,所以客户端模块必须定义如何完成契约。换句话说,客户必须实现接口功能。这种新情况如图 2-2 所示。
图 2-2。
带接口的模块通信
例如,对于三角形,客户可能会提供以下内容:
class Triangle : GraphicsObject {
override fun numberOfCorners(): Int {
return 3
}
override fun coordsOf(index:Int):
Pair<Double,Double> {
return when(index) {
0 -> Pair(-1.0, 0.0)
1 -> Pair(1.0, 0.0)
2 -> Pair(0.0, 1.0)
else throw RuntimeException(
"Index ${index} out of bounds")
}
}
override fun fillColor(): String {
return "red"
}
}
对于 Kotlin,如果函数的结果可以用一个表达式计算,那么允许为函数编写“= ...
”,因此Triangle
类实际上可以编写如下:
class Triangle : GraphicsObject {
override fun numberOfCorners() = 3
override fun coordsOf(index:Int) =
when(index) {
0 -> Pair(-1.0, 0.0)
1 -> Pair(1.0, 0.0)
2 -> Pair(0.0, 1.0)
else -> throw RuntimeException(
"Index ${index} out of bounds")
}
override fun fillColor() = "red"
}
我们还使用了 Kotlin 在许多情况下可以自动推断返回类型的事实。类声明中的: GraphicsObject
表示Triangle
遵守GraphicsObject
契约,每个函数前面的override
表示该函数实现了一个接口函数。当然,Triangle
类也可能包含任意数量的非接口函数;在这个例子中我们不需要。
注意
类头中的:可以翻译为“implements”或“is a ...”如果它的右边有一个接口名称。
在coordsOf()
函数中,我们使用了一些我们还没有见过的新构造。现在,when(){ }
根据参数选择一个x -> ...
分支,throw RuntimeException()
停止程序流并向终端写入一条错误消息。我们将在后续章节中更详细地讨论这些结构。
注意
您可以看到,对于三角形示例,我们允许角索引为 0、1 和 2。在许多计算机语言中,从 0 开始任何类型的索引都是常见的。Kotlin 在这里也不例外。
我们仍然需要收集器模块的一个类中的访问器函数,客户端需要它来注册图形对象。我们称之为add()
,它可以这样读:
class Collector {
...
fun add(graphics:GraphicsObject) {
// do something with it...
}
}
客户现在写了这样的东西:
...
val collector = [get hold of it]
val triang:GraphicsObject = Triangle()
collector.add(triang)
...
我们也可以编写val triang:Triangle = Triangle()
,程序将会正确无误地运行。然而,这两者之间有着巨大的概念差异。你能说出原因吗?答案是这样的:如果我们写val triang:Triangle = Triangle()
,我们就表达了将Triangle
类传递给收集器,这是我们实际上不想做的。这是因为我们希望将客户机与收集器适当分离,并且只使用接口GraphicsObject
进行通信。唯一可以接受的表达方式是写val triang:GraphicsObject = Triangle()
。
注意
在内部,如果我们写triang:Triangle
或triang:GraphicsObject
,相同的对象被传递给收集器。但是我们不仅仅想写能工作的程序;他们还必须恰当地表达他们所做的事情。因此,triang:GraphicsObject
是更好的选择。
为了让您开始自己的实验,在下面的清单中,我提供了这个接口过程的基本实现。首先,在一个文件中,我们编写一个图形对象收集器并添加接口。
interface GraphicsObject {
fun numberOfCorners(): Int
fun coordsOf(index:Int): Pair<Double, Double>
fun fillColor(): String
}
object Collector {
fun add(graphics:GraphicsObject) {
println("Collector.add():")
println("Number of corners: " +
graphics.numberOfCorners())
println("Color: " +
graphics.fillColor())
}
}
你可以看到我们在这里使用了一个单例对象来简化访问。在另一个文件中,我们创建一个GraphicsObject
并访问收集器。
class Triangle : GraphicsObject {
override fun numberOfCorners() = 3
override fun coordsOf(index:Int) =
when(index) {
0 -> Pair(-1.0, 0.0)
1 -> Pair(1.0, 0.0)
2 -> Pair(0.0, 1.0)
else -> throw RuntimeException(
"Index ${index} out of bounds")
}
override fun fillColor() = "red"
}
fun main(args:Array<String>) {
val collector = Collector
val triang:GraphicsObject = Triangle()
collector.add(triang)
}
你可以看到将一个单例对象分配给一个val
是可能的,尽管你也可以使用本章前面描述的直接单例对象访问符号。
虽然接口的概念对于一个初学开发的人来说并不容易理解,但是试图从一开始就理解接口并尽可能地使用它们对于编写好的软件来说是一个无价的帮助。
练习 20
基本粒子至少有三个共同点:质量、电荷和自旋。创建一个接口ElementaryParticle
,有三个对应的函数要取:mass():Double
、charge():Double,
和spin():Double
。创建实现该接口的类Electron
和Proton
。一个电子返回质量9.11 · 10
-31
,输入为9.11e
-31
,电荷1.0,
,自旋0.5
。一个质子返回质量1.67·10
-27
,被输入为1.67e-27
,电荷和自旋0.5
。
练习 21
以练习 20 中的接口和类为例,哪一个是正确的?
-
一个
ElementaryParticle
可以被实例化:var p = ElementaryParticle().
-
一个
Electron
可以被实例化:val electron = Electron().
-
一个
Proton
可以被实例化:val proton = Proton().
-
初始化
var p:ElementaryParticle = Electron()
是可能的。 -
重新分配
p = Proton()
是可能的。 -
初始化
var p:Proton = Electron()
是可能的。
练习 22
想象一下,对于NumberGuess
游戏,我们希望能够尝试不同的随机数生成功能。用一个函数fun rnd(minInt:Int, maxInt:Int): Int
创建一个接口RandomNumberGenerator
。使用来自MainActivity
类:val span = maxInt - minInt + 1; return minInt + Math.floor(Math.random()*span).toInt()
的当前代码创建一个实现该接口的类StdRandom
。创建另一个类RandomRandom
,它也实现了接口,但是具有属性val rnd:Random = Random()
(将import java.util.*
添加到导入中)并使用代码minInt + rnd.nextInt( maxInt - minInt + 1 )
。使用其中一个实现,将类型为RandomNumberGenerator
的属性添加到活动中。将活动中的start()
函数改为使用该接口。
结构化和包
对于 Kotlin 应用,可以将所有类、接口和单例对象写入主文件夹java
中的一个文件。然而对于实验和小项目来说,这是完全可以接受的,对于大项目来说,你不应该这样做。从鸟瞰的角度来看,中型到大型的项目不可避免地会有类、接口和单例对象,它们可以被分组到模块中做不同的事情。拥有大文件意味着实际项目中没有的某种概念上的平坦性。
注意
为了避免总是重复这个列表,我此后使用术语结构单元来表示类、单例对象、伴随对象和接口。
由于这个原因,Kotlin 允许我们将结构单元放入不同的包中,对应于不同的文件夹并跨越不同的名称空间。我们首先需要建立的是一个层级结构。这意味着我们将结构单元分配给树中的不同节点。因此,每个节点都包含几个结构单元,这些结构单元表现出高度的内聚性,这意味着它们彼此之间有着紧密的联系。
结构化项目
让我们看一下NumberGuess
的例子,看看这种结构到底意味着什么。到目前为止,包括所有的改进和练习,我们有以下的类、接口和单例对象:活动本身,一个控制台类,一个常量对象,两个类和一个随机数接口,一个用户数据类。由此,我们确定了以下包:
-
活动类的根。
-
随机数包
random
。我们将接口放入包中,并将两个实现放入一个子包impl
。 -
用于
Console
视图元素的gui
包。 -
用户数据类的
model
包。开发人员经常使用术语模型来指代数据结构和数据关系。 -
用于
Constants
单例对象的common
包。
我们将它放在src
下相应的目录和子目录中,从而得到如图 2-3 所示的包和文件夹结构。
图 2-3。
包装
按照惯例,您必须在每个文件中添加一个包声明来反映这个打包结构。语法是:
package the.hierarchical.position
...
例如,RandomRandom.kt
文件必须以
package kotlinforandroid.book.numberguess.random.impl
class RandomRandom {
...
}
练习 23
在 Android Studio 项目中准备这个结构。从空文件开始。提示:包(即文件夹)、类、接口和单例对象都可以通过右键单击 Android Studio 主窗口左侧包结构中的一个项目并选择 New 来初始化。
命名空间和导入
如前所述,层次结构还跨越了名称空间。例如,Console
类依赖于kotlinforandroid.book.numberguess.gui
名称空间中的kotlinforandroid.book.numberguess.gui
包声明而存在。这意味着在同一个包中不能有另一个Console
类,但是在其他包中可以有Console
类,因为它们都有不同的名称空间。
警告
Kotlin 允许您使用不同于文件系统中层次位置的package
声明。然而,帮你自己一个忙,保持包和文件路径同步,否则你最终会弄得一团糟。
结构单元(即类、接口、单例对象和伴随对象)可以通过简单的名字使用同一个包中的其他结构单元。但是,如果他们使用其他包中的结构单元,他们必须使用他们的完全限定名,这意味着有必要在包名前面加上点作为分隔符。例如,Console,
的完全限定名读作kotlinforandroid.book.numberguess.gui.Console
。然而,有一种方法可以避免输入大量的长名字来引用其他包中的结构单元:作为一种捷径,你可以通过使用一个import
语句导入被引用的结构单元。我们已经在几个例子中看到了这一点,无需进一步解释。例如,要导入Console
类,您直接在package
声明下编写:
package kotlinforandroid.book.numberguess
import kotlinforandroid.book.numberguess.gui.Console
class Activity {
...
}
在这种情况下,在这个文件的任何地方,你都可以使用Console
来寻址kotlinforandroid.book.numberguess.gui.Console
类。一个文件可以有任意数量的这样的导入语句。要导入Constants
类,请编写以下代码:
package kotlinforandroid.book.numberguess
import kotlinforandroid.book.numberguess.gui.Console
import kotlinforandroid.book.numberguess.common.
Constants
class Activity {
...
}
注意
像 Android Studio 这样的 ide 可以帮助你完成这些导入。如果你输入一个简单的名字,Android Studio 会尝试确定这个包是什么。然后,您可以在名称上按住 Alt+Enter 键,以执行导入。
通过使用星号(*)作为通配符,甚至有一个从包中导入所有结构单元的快捷方式。因此,举例来说,要从包kotlinforandroid.book.numberguess.random.impl,
中导入所有的类,您应该编写
package kotlinforandroid.book.numberguess
import kotlinforandroid.book.numberguess.
random.impl.*
class Activity {
...
}
你可以看到NumberGuess
游戏的所有包的公共根写着kotlinforandroid.book.numberguess
。Android Studio 在我们初始化项目的时候完成了这项工作。这是一种常见的做法,预先考虑一个反向域名,指向你作为一个开发人员,或你的教育机构或你的公司,加上一个名称为您的项目。例如,如果您拥有一个域john.doe.com
,并且您的项目被命名为elysium
,那么您将使用com.doe.john.elysium
作为您的根包。
注意
这种域名没有存在的实际必要。如果您不能使用现有的域名,您可以使用一个虚构的域名。只要确保与现有项目冲突的可能性很低。如果你不打算发布你的软件,你可以使用你想要的,包括根本不使用域名根。
练习 24
将我们为NumberGuess
游戏编写的所有代码分发到上一节中新结构的文件中。
三、工作中的类:属性和功能
在阅读了关于类和对象的第二章后,现在是时候更多地关注属性和它们的类型,以及我们必须声明函数的选项和从函数内部可以做什么。这一章讨论了属性和函数声明,也讨论了面向对象语言的一个重要特性,继承,通过这个特性,一些类的属性和函数可以被其他类修改和重定义。我们还学习了可见性和封装,这有助于我们改进程序结构。
属性及其类型
属性是定义对象状态的数据容器或变量。类中的属性声明使用可选的可见性类型、可选的修饰符、不可变(不可改变)变量的关键字val
或可变(可改变)变量的关键字var
、名称、类型和初始值:
[visibility] [modifiers] val propertyName:PropertyType = initial_value
[visibility] [modifiers] var propertyName:PropertyType = initial_value
除此之外,一个类的构造函数中的任何属性由val
或var
直接自动添加到一个使用相同名称的隐藏属性中。在下面的段落中,我们将讨论类体中给定属性的所有可能选项。
简单属性
简单属性既不提供可见性也不提供任何修饰符,因此它们的声明如下
val propertyName:PropertyType = initial_value
var propertyName:PropertyType = initial_value
分别用于不可变和可变变量。以下是一些附加规则:
-
如果在类或单例对象或伴随对象中,一个值在
init{ }
块中被赋值,那么= initial_value
可以被省略。 -
如果 Kotlin 可以通过给定的初始值推断出类型,那么
:PropertyType
可以省略。
这种简单的属性可以从外部通过instanceName.propertyName
访问类,通过ObjectName.propertyName
访问单例对象。在类或单例对象内部,只需使用propertyName
来访问它。
让我们给第二章的NumberGuess
项目中的GameUser
类添加两个简单的属性。我们从构造函数中知道了名字和姓氏,因此派生一个首字母属性和一个全名属性可能会很有趣,如下所示:
class GameUser(val firstName:String,
val lastName:String,
val userName:String,
val registrationNumber:Int,
val birthday:String = "",
val userRank:Double = 0.0) {
val fullName:String
val initials:String
init {
fullName = firstName + " " + lastName
initials = firstName.toUpperCase() +
lastName.toUpperCase()
}
}
这里你可以看到对于fullName
和initials
我们只有val
s,所以不可能给它们重新赋值。因为我们首先在init{ }
中分配它们,所以在属性声明中省略= initial value
是可能的。同样,因为所有的构造函数参数都有一个val
前缀,所以它们都被传递给相应的属性,所以它们都是属性:firstName
、lastName
、userName
、registrationNumber
、birthday,
和userRank
。为了访问它们,我们使用,例如:
val user = GameUser("Peter", "Smith", "psmith", 123, "1988-10-03", 0.79)
val firstName = user.firstName
val fullName = user.fullName
用user.firstName = "Linda"
赋值是不可能的,因为我们有不可变的val
s。如果我们有var
s,这将是允许的:
class GameUser(var firstName:String,
var lastName:String,
var userName:String,
var registrationNumber:Int,
var birthday:String = "",
var userRank:Double = 0.0) {
var fullName:String
var initials:String
init {
fullName = firstName + " " + lastName
initials = firstName.toUpperCase() +
lastName.toUpperCase()
}
}
// somewhere inside a function in class MainActivity
val user = GameUser("Peter", "Smith", "psmith",
123, "1988-10-03", 0.79)
user.firstName = "Linda"
console.log(user.fullName)
你能猜出产量吗?这个短程序打印了Peter Smith
,虽然我们把名字改成了Linda
。这个问题的答案是,全名是在init{ }
中计算出来的,而且在我们改变名字后init{ }
不会被再次调用,所以我们必须注意这一点。
注意
例如,您可以引入一个像setFirstName()
这样的新函数,并相应地更新名字、全名和首字母。一个可能更简洁的变体是一个动态计算全名的函数,不使用单独的属性:fun fullName() = firstName + " " + lastName
这也是你应该尽可能选择val
s 而不是var
s 的原因之一;避免损坏的状态更容易。
练习 1
以下代码有什么问题?
class Triangle(color: String) {
fun changeColor(newColor:String) {
color = newColor
}
}
属性类型
在示例代码片段中,我们已经看到了一些可以用于属性的类型。这是一份详尽的清单。
-
String
:这是一个字符串。来自基本多语言平面(最初的 Unicode 规范)的每个字符都是类型Char
(见后面)。补充字符使用两个Char
元素。对于大多数实际用途和大多数语言来说,假设每个字符串元素都是一个单独的Char
是一种可以接受的方法。 -
Int
:这是一个整数。值的范围从 2,147,483,648 到 2,147,483,647。 -
Double
:这是一个介于 4.94065645841246544 10-324 和 1.79769313486231570 10+308 之间的浮点数,正负符号均可。形式上,它是 IEEE 754 规范中的 64 位浮点值。 -
Boolean
:这是一个布尔值,可以是真,也可以是假。 -
任何类:属性可以保存任何类或单例对象的实例。这包括内置类、库中的类(由您使用的其他人构建的软件)以及您自己的类。
-
Char
:这是一个单字符。Kotlin 中的字符使用 UTF-16 编码格式(来自原始 Unicode 规范的字符)来存储它们。 -
Long
:这是一个扩展整数,取值范围在 9,223,372,036,854,775,808 和 9,223,372,036,854,775,807 之间。 -
Short
:这是一个缩小了取值范围的整数。值从–32,768 到 32,767。您不会经常看到这种情况,因为对于大多数实际用例来说,Int
是更好的选择。 -
Byte
:这是一个从–128 到 127 的很小范围内的整数。这种类型经常用于低级操作系统函数调用。您可能不会经常使用这种类型,除非您对文件执行输入/输出(I/O)操作时会经常用到它。 -
Float
:这是一个精度较低的浮点数。正负符号的范围从 1.40129846432481707 10-45到 3.4028234638528860 10+38。形式上,它是 IEEE 754 规范中的 32 位浮点值。除非存储空间或性能是个大问题,否则你通常会更喜欢Double
而不是Float
。 -
你可以使用任何类或接口作为类型,包括那些由 Kotlin 提供的内置的,来自你使用的其他程序,以及来自你自己的程序。
-
枚举是一组无序文本值中可能值的数据对象。详见第四章。
属性值分配
属性可以在四个地方赋值。第一个位置是在属性声明处,如
class TheClassName {
val propertyName1:PropertyType1 = initial_value
var propertyName2:PropertyType2 = initial_value
...
}
object SingletonObjectName {
val propertyName1:PropertyType1 = initial_value
var propertyName2:PropertyType2 = initial_value
...
}
class TheClassName {
companion object {
val propertyName1:PropertyType1 = initial_value
var propertyName2:PropertyType2 = initial_value
...
}
}
其中initial_value
是可以转换为预期属性类型的任何表达式或文字。我们将在本章后面讨论文字和类型转换。
第二个可以赋值的地方是在init{ }
块内:
// we are inside a class, a singleton object, or
// a companion object
init {
propertyName1 = initial_value
propertyName2 = initial_value
...
}
这只有在属性之前声明过的情况下才有可能,要么在类、单例对象或伴随对象中声明,要么在主构造函数声明中声明为var
。
只有当属性在一个init{ }
块中被赋值时,你才能省略属性声明中的初始值赋值。因此,可以这样写
// we are inside a class, a singleton object, or
// a companion object
val propertyName1:PropertyType1
var propertyName2:PropertyType2
init {
propertyName1 = initial_value
propertyName2 = initial_value
...
}
可以给属性赋值的第三个地方是函数内部。很明显,这只对可变的var
变量是可能的。那些变量必须已经用var propertyName:PropertyType =
声明过了,对于赋值你必须省略var
。
// we are inside a class, a singleton object, or
// a companion object
var propertyName1:PropertyType1 = initial_value
...
fun someFunction() {
propertyName1 = new_value
...
}
第四个可以赋值的地方是在类、单例对象或伴随对象之外。使用instanceName.
或ObjectName.
并添加属性名,如下所示:
instanceName.propertyName = new_value
ObjectName.propertyName = new_value
这显然只可能发生在可变的。
练习 2
创建一个具有一个属性var a:Int
的类A
。执行赋值:(a)在声明中将其设置为1
,( b)在init{ }
块中将其设置为2
,( c)在函数fun b(){
… }
中将其设置为3
,( d)在main
函数中将其设置为4
。
文字
文字表示可用于属性赋值和内部表达式的固定值。数字是文字,但字符串和字符也是。以下是一些例子:
val anInteger = 42
val anotherInteger = anInteger + 7
val aThirdInteger = 0xFF473
val aLongInteger = 700_000_000_000L
val aFloatingPoint = 37.103
val anotherFloatingPoint = -37e-12
val aSinglePrecisionFloat = 1.3f
val aChar = 'A'
val aString = "Hello World"
val aMultiLineString = """First Line
Second Line"""
表 3-1 列出了你可以用于 Kotlin 程序的所有可能的文字。
表 3-1。
文字
|文字类型
|
描述
|
进入
|
| --- | --- | --- |
| 小数整数 | 整数 0,1,2,… | 0, 1, 2, …, 2147483647,–1, –2, …, –2147483648 如果您愿意,可以使用下划线作为千位分隔符,如 2_012 所示 |
| 两倍精确浮动 | 之间的双精度浮点数 4.94065645841247.10 -324和 1.79769313486232.10 +308带有正号或负号 | 点符号:[s]三。场流分级法(field flow fractionation)其中[s]不为任何值,或为正值的+号,为负值的–号;III 是整数部分(任意位数),FFF 是小数部分(任意位数)科学符号:《气候公约》。FFFe[t]DDD 其中,[s]为空,正值为+,负值为 CCC。FFF 是尾数(一位或多位数字;那个。如果不需要的话,可以省略 FFF),[t]是零或+表示正指数,–表示负指数,DDD 是(十进制)指数(一位或多位) |
| 茶 | 单个字符 | 使用单引号,如val someChar
=‘A’。有许多特殊字符:写\t
表示制表符、\b
表示退格、\n
表示换行符、\r
表示回车、\
表示单引号、\\
表示反斜杠、\$
表示美元符号。此外,您可以为任何 unicode 字符 XXXX(十六进制值)编写\ uXXXX 例如,\u03B1
是一个 α |
| 线 | 一串字符 | 使用双引号,如val someString = "Hello World".
中的字符,适用与Char
s 相同的规则,除了对于单引号,不使用前面的反斜杠,但是对于双引号,使用一个反斜杠:"Don't say \"Hello\""
。在 Kotlin 中还有多行的原始的字符串文字:使用三重双引号,如在""" Here goes multiline contents"""
中。这里里面的字符的转义规则不再适用(这就是名字 raw 的由来)。 |
| 十六进制的整数 | 使用十六进制的整数 0,1,2,… | 0x 0.0x 1.0x 2、…、0x 9.0x a、0x x、0x x、0x x 10、…、0x 7 fff、–0x 1、–0x 2、…、–0x 800000000 |
| 长的小数整数 | 具有扩展限制的长整数 0,1,2,… | 0, 1, 2, …, 9223372036854775807, –1, –2, …, –9223372036854775808 如果你愿意,你可以使用下划线作为千位分隔符,如 2_012L |
| 长的十六进制的整数 | 使用十六进制的整数 0,1,2,…具有扩展的限制 | 0x0,0x1,0x2,…,0x9,0xA,0xB,…,0xF,0x10,…,0x 7 fffffffffffffffff,–0x 1,–0x 2,…,–0x 80000000000000 |
| 浮动 | 单精度浮点数 | 与双精度浮点数相同,但在末尾加一个 f;例如val f = 3.1415f
|
注意
记住,在十进制中 214 的意思是2 · 10
2
+ 1 · 10
1
+ 4 · 10
0
。在十六进制系统中我们相应地有 0x13D 的意思2 · 16
2
+ 3 · 16
1
+ 13 · 16
0
。字母 A,B,…,F 对应于 10,11,…,15。
至于类型兼容性,可以将普通整数赋给长整数属性,但不能反过来。您还可以将精度降低的浮点数赋给 double 属性,但不能反过来。不允许的赋值要求你使用一个转换(见第五章)。
要将文字分配给Short
和Byte
属性,请使用整数,但要确保不超过限制。
单引号和三双引号String
文字表示都展示了一个称为字符串模板的特性。这意味着一个以美元符号开始的表达式,后面是一个用花括号括起来的表达式,这个表达式被执行,其结果被传递给字符串。因此"4 + 1 = ${4+1}"
的计算结果是字符串"4 + 1 = 5"
。对于仅由单个属性名构建的简单表达式,可以省略花括号,如在"The value of a is $a"
中。
练习 3
找到一种更短的书写方式
val a = 42
val s = "If we add 4 to a we get " + (a+4).toString()
避免字符串串联"
… " + "
… "
属性可见性
可见性是指程序的哪些部分可以从其他类、接口、对象或伴随对象中访问哪些函数和属性。我们将在本章后面的“类和类成员的可见性”一节中深入讨论可见性。
空值
特殊关键字null
指定了一个可以用于任何可空属性的值。null
as 值意味着未初始化、尚未决定或未定义。任何属性都可以为空,但是在声明中,您必须给类型说明符添加一个问号:
var propertyName:PropertyType? = null
这对于任何类型都是可能的,包括类,因此您可以编写,例如:
var anInteger:Int? = null
var anInstance:SomeClass? = null
对于可变可空的var
属性,你也可以在任何时候分配null
值:
var anInteger:Int? = 42
anInteger = null
像 Java 这样的其他语言允许任何对象类型为空,这经常会导致问题,因为null
既没有属性也没有函数。例如,如果someInstance
指向一个真实的对象,那么someInstance.someFunction(),
表现良好。但是,如果您设置了someInstance = null,
,则随后的someInstance.someFunction()
是不可能的,因此会导致异常状态。因为 Kotlin 区分了普通属性和可空属性,所以 Kotlin 编译器可以更容易地避免这种状态不一致。
我们已经使用了所谓的解引用操作符(。)来访问函数和属性。为了提高稳定性,Kotlin 不允许。可空变量(或表达式)的运算符。相反,有一个安全调用变体?."在这种情况下,您必须使用——只有当运算符左侧的值不是null
时,才会发生解引用。如果是null
,操作员计算到null
本身。看看这个例子:
var s:String? = "Hello"
val l1 = s?.length() // -> 5
s = null
val l2 = s?.length() // -> null
练习
以下哪一项是正确的?
-
您可以执行任务
val a:Int = null.
-
可以写
val a:Int? = null; val b:Long = a.toLong().
-
可以写
val a:Int? = null; val b:Long? = a.toLong().
-
可以写
val a:Int? = null; val b:Long? = a?.toLong().
属性声明修饰符
您可以在属性声明中添加以下修饰符:
const
:增加const
如
const val name = ...
来声明将该属性转换成一个编译时间常数。属性的类型必须是Int
、Long
、Short
、Double
、Float
、Byte
、Boolean
、Char,
或String
才能工作。您可以使用它来避免将常量放入伴随对象中。除此之外,关于使用,使用和不使用const
没有区别。
lateinit
:如果加上lateinit
如
lateinit var name:Type
其中Type
是一个类、接口,或者String
(Int
、Long
、Short
、Double
、Float
、Byte
、Boolean
、Char
都不是)你告诉 Kotlin 编译器接受var
存在或者不存在null
。你可以这样写
class TheClass {
lateinit var name:String
fun someFunction() {
val stringSize = name.length
}
}
这会导致运行时错误,但不会导致编译时错误,从而阻碍了 Kotlin 可空性检查系统。如果变量以 Kotlin 编译器无法检测的方式初始化(例如,通过反射),那么使用lateinit
是有意义的。除非你真的知道你想做什么,否则不要使用lateinit
。顺便说一下,可以通过使用::name.isInitialized.
来检查lateinit var
是否已经初始化
成员函数
成员函数是负责访问它们的类、单例对象和伴随对象的元素。在函数内部,结构单元的状态被查询和/或更改。基于状态的计算可以通过获取输入并产生依赖于该输入和状态的输出来进行。函数也可以是不使用状态的纯函数,这意味着给定一些特定的输入参数,它们总是产生相同的输出。图 3-1 说明了各种可能性。
图 3-1。
功能
根据所使用的术语,函数有时也被称为操作或方法。
不返回值的函数
要声明一个不返回任何东西的函数,在 Kotlin 中,你要在一个类、一个单例对象或一个伴随对象的主体内部进行编写。
[modifiers]
fun functionName([parameters]) {
[Function Body]
}
在函数体内,可以有任意数量的return
语句退出函数。一个return
在主体的末尾也是允许的,但不是必需的。
函数可能有也可能没有输入参数。如果他们没有,就写fun functionName() {
… }
。如果输入参数存在,它们将如下声明:
parameterName1:ParameterType1,
parameterName2:ParameterType2, ...
注意
在 Kotlin 中,函数参数不能在函数体内重新分配。这不是一个缺点,因为在函数内部重新分配函数参数无论如何都被认为是不好的做法。
函数也可以有可变参数列表。这个特性被称为 varargs ,我们将在后面讨论它。我们稍后将讨论的另一个特性是默认参数。如果在函数调用中没有指定参数,这样的参数允许指定将使用的默认值。
例如,有参数和没有参数的两个简单函数声明如下所示:
fun printAdded(param1:Int, param2:Int]) {
console.log(param1 + param2)
}
fun printHello() {
console.log("Hello")
}
在接口内部——请记住,我们使用接口来描述需要做什么,而不是如何做——函数没有实现,因此不允许声明主体。对于不返回任何内容的函数,接口中的函数声明如下所示:
fun functionName([parameters])
您可以在函数声明前添加可选的[modifiers]
来微调函数的行为,如下所示:
-
private
、protected
、internal
和public
:这些是可见性修饰符。可见性将在本章后面的“类和类成员的可见性”一节中解释。 -
open
:用它来标记一个类中的函数,使其可以被子类覆盖。有关详细信息,请参阅本章后面的“继承”一节。 -
override
:使用这个来标记一个类中的一个函数,作为从一个接口或者从一个超类中重写一个函数。有关详细信息,请参阅本章后面的“继承”一节。 -
final override
:同override
,但表示禁止子类进一步覆盖。 -
abstract
:抽象函数不能有体,有抽象函数的类不能实例化。您必须在子类中覆盖这样的函数,使它们具体化(这意味着“不抽象”它们)。有关详细信息,请参阅本章后面的“继承”一节。
您不能随意混合修改器。特别是对于可见性修饰符,只允许一个。但是,您可以将任何可见性修改器与此处列出的其他修改器的任何组合进行组合。如果需要多个修饰符,要使用的分隔符是空格字符。
注意,接口中的声明通常没有也不需要修饰符。例如,此处不允许除public,
以外的可见性值。接口中的函数默认为public
,由于它们本身在接口中没有实现,你可以默认认为它们是abstract
,所以没有必要显式添加abstract
。
练习 5
以下函数有什么问题?
fun multiply10(d:Double):Double {
d = d * 10
return d
}
练习 6
以下函数有什么问题?
fun printOut(d:Double) {
println(d)
return
}
返回值的函数
要在 Kotlin 中声明一个类、单例对象或伴随对象中的返回值函数,在函数体中添加: ReturnType
到函数头并编写
[modifiers]
fun functionName([parameters]): ReturnType {
[Function Body]
return [expression]
}
函数参数与不返回值的函数相同,前面讨论的修饰符也是如此。对于返回的值或表达式,Kotlin 必须能够将表达式的类型转换为函数返回类型。这种函数的一个例子如下:
fun add37(param:Int): Int {
val retVal = param + 37
return retVal
}
主体中可能有多个return
语句,但是它们都必须返回预期类型的值。
注意
经验告诉我们,为了提高代码质量,最好总是在末尾使用一个return
语句。
如果可能,也可以用一个表达式替换正文:
[modifiers]
fun functionName([parameters]): ReturnType = [expression]
如果表达式生成的类型是预期的函数返回类型,这里可以省略: ReturnType
。Kotlin 因此可以推断
fun add37(param:Int) = param + 37
函数的返回类型是Int
。
同样,对于接口,函数没有实现,这种情况下的函数声明如下
fun functionName([parameters]): ReturnType
注意
实际上,Kotlin 内部让所有函数返回值。如果不需要返回值,Kotlin 会假设一个特殊的 void 类型,并将其称为Unit
。如果您省略了: ReturnType
并且函数不返回值,或者如果函数体根本没有return
语句,则假定为Unit
。如果,不管出于什么原因,它有助于提高你的程序的可读性,你甚至可以写fun name(
… ) : Unit {
… }
来表达一个函数不返回任何有趣的值。
练习 7
以下是真的吗?
fun printOut(d:Double) {
println(d)
}
与...相同
fun printOut(d:Double):Unit {
println(d)
}
练习 8
创建以下类的较短版本:
class A(val a:Int) {
fun add(b:Int):Int {
return a + b
}
fun mult(b:Int):Int {
return a * b
}
}
练习 9
创建一个接口AInterface
来描述练习 8 中的所有类A
。
访问屏蔽属性
在名称冲突的情况下,函数参数可能会屏蔽类属性。比方说,一个类有一个属性 xyz ,一个函数参数有一个完全相同的名字 xyz ,如
class A {
val xyz:Int = 7
fun meth1(xyz:Int) {
[Function-Body]
}
}
据说参数xyz
屏蔽了函数体内的属性xyz
。这意味着如果你在函数中写xyz
,参数被寻址,而不是属性。不过,仍然可以通过在名称前添加this.
来寻址属性:
class A {
val xyz:Int = 7
fun meth1(xyz:Int) {
val q1 = xyz // parameter
val q2 = this.xyz // property
...
}
}
this
指的是这个当前对象,所以this.xyz
指的是这个当前对象的属性xyz
,而不是函数规范中可见的xyz
。
注意
有些人用术语遮蔽的而不是遮蔽的来描述这样的性质。两者的意思是一样的。
练习 10
的产量是多少
class A {
val xyz:Int = 7
fun meth1(xyz:Int):String {
return "meth1: " + xyz +
" " + this.xyz
}
}
fun main(args:Array<String>) {
val a = A()
println(a.meth1(42))
}
函数调用
给定一个实例、一个单例对象或一个伴随对象,调用函数如下:
instance.functionName([parameters]) // outside the class
functionName([parameters]) // inside the classObject.functionName([parameters]) // outside the objectfunctionName([parameters]) // inside the object
要从类内部调用伙伴对象的函数,你也只需使用functionName([parameters])
。在类外,你可以在这里使用ClassName.functionName([parameters])
。
练习 11
给定这个类
class A {
companion object {
fun x(a:Int):Int { return a + 7 }
}
}
描述如何在一个println()
函数中从类外访问带有参数42
的函数x()
。
函数命名参数
对于函数调用,可以使用参数名来提高可读性:
instance.function(par1 = [value1], par2 = [value2], ...)
或者
function(par1 = [value1], par2 = [value2], ...)
从类或对象内部。这里的parN
是函数声明中确切的函数参数名。使用命名参数的另一个好处是,您可以使用任何您喜欢的参数排序顺序,因为 Kotlin 知道如何正确分配所提供的参数。您也可以混合使用未命名参数和命名参数,但是有必要将所有命名参数放在参数列表的末尾。
练习 12
给定这个类
class Person {
var firstName:String? = null
var lastName:String? = null
fun setName(fName:String, lName:String) {
firstName = fName
lastName = lName
}
}
创建一个实例,并使用命名参数将名称设置为John Doe
。
警告
在函数调用中使用命名参数极大地提高了代码的可读性。但是,如果您使用其他程序的代码,请小心,因为在新的程序版本中,参数名称可能会改变。
函数默认参数
如果在函数调用中省略,函数参数可能会有默认值。要指定默认值,您只需使用
parameterName:ParameterType = [default value]
在函数声明中。函数参数列表可以有任意数量的默认值,但它们都必须位于参数列表的末尾:
fun functionName(
param1:ParamType1,
param2:ParamType2,
...
paramM:ParamTypeM = [default1],
paramM+1:ParamTypeM+1 = [default2],
...) { ... }
要应用缺省值,只需在调用中省略它们。如果您省略列表末尾的 x 参数,最右边的 x 参数将采用默认值。这种排序顺序依赖性使得使用默认参数有点麻烦。但是,如果混合使用命名参数和缺省参数,使用缺省参数会增加函数的通用性。
练习 13
到函数声明
fun set(lastName:String,
firstName:String,
birthDay?:String,
ssn:String?) { ... }
添加为默认值lastName = "", firstName = ""
、birthDay = null
、ssn = null
。然后使用命名参数调用函数,只需指定lastName = "Smith"
和ssn = "1234567890"
。
函数 Vararg 参数
我们知道函数的存在是为了获取输入数据,并根据输入数据改变对象的状态,可能会产生一些输出数据。到目前为止,我们已经学习了固定参数列表,涵盖了所有可能用例的一个大的子集。但是,未知的、潜在的无限大小的列表呢?这样的列表被称为数组或集合、,除了保存单个数据元素的类型之外,任何现代计算机语言都需要提供一种方法来处理这样的数据。我们将在第九章中更详细地介绍数组和集合,但是现在你应该知道数组和集合是完全成熟的类型,你可以将它们用于单个构造函数和函数参数,如……, someArray:Array<String>,
…
然而,在使用许多不同的单值参数和一个数组或集合参数之间有一个构造: varargs 。想法如下:作为函数声明的参数列表中的最后一个元素,添加一个vararg
限定符,如
fun functionName(
param1:ParamType1,
param2:ParamType2,
...
paramN:ParamTypeN,
vararg paramV:ParamTypeV) { ... }
结果是一个能够接受 N + x 个参数的函数,其中 x 是从0
到无穷大的任意数。然而,前提是所有的vararg
参数都是由ParamTypeV
指定的类型。当然,N 可能是0
,所以一个函数可以有一个vararg
参数:
fun functionName(varargs paramV:ParamTypeV) {
...
}
注意
Kotlin 实际上允许vararg
参数出现在参数表的前面。然而,只有当vararg
之后的下一个参数具有不同的类型时,Kotlin 才能在函数调用期间分发传入的参数。因为这会使调用结构变得复杂,所以最好避免这种vararg
结构。
要调用这样一个函数,在调用中提供所有非vararg
参数,然后是任意数量的vararg
参数(包括零):
functionName(param1, param2, ..., paramN,
vararg1, vararg2, ...)
作为一个简单的例子,我们创建一个函数,它将日期作为String
,然后是任意数量的名字,再次作为String
。
fun meth(date:String, vararg names:String) {
...
}
现在可以进行以下调用:
meth("2018-01-23")
meth("2018-01-23", "Gina Eleniak")
meth("2018-01-23", "Gina Eleniak",
"John Smith")
meth("2018-01-23", "Gina Eleniak",
"John Smith", "Brad Cold")
你可以随意扩充名单。
现在的问题是:我们如何在函数内部处理vararg
参数?答案是该参数是一个指定类型的数组,它具有我们在第九章中描述的所有特性,包括一个size
属性和访问操作符[],以获取元素,如[0]、[1]等等。因此,如果我们使用带参数的示例函数(date:String, vararg names:String
)并通过
meth("2018-01-23", "Gina Eleniak",
"John Smith", "Brad Cold")
在函数内部,你将有date = "2018-01-23"
和vararg
参数:
names.size = 3
names[0] = "Gina Eleniak"
names[1] = "John Smith"
names[2] = "Brad Cold")
练习 14
构建一个Club
类并添加一个带有单个vararg
参数names
的函数addMembers
。在函数内部,使用
println("Number: " + names.size)
println(names.joinToString(" : "))
打印参数。在类外创建一个main(args:Array<String>)
函数,实例化一个Club
,用“Hughes,John”,“Smith,Alina”,“Curtis,Solange”三个名字调用其addMembers()
函数。
抽象函数
类内部的函数可以不用体来声明,并标记为abstract
。这也将该类转换成一个抽象类,Kotlin 要求该类被标记为abstract
可编译。
abstract class TheAbstractClass {
abstract fun function([parameters])
... more functions ...
}
抽象类是介于接口和普通类之间的东西:它们为一些函数提供实现,而将其他函数抽象(未实现)以允许一些变化。因此,抽象类经常服务于某种“基础”实现,将细节留给一个或多个实现抽象功能的类。
抽象函数也使得函数的行为像接口函数,包括具有这种函数的类不能被实例化。你必须从这样一个实现所有功能的抽象类中创建一个子类,才能拥有可以实例化的东西。
abstract class TheAbstractClass {
abstract fun function([parameters])
... more functions ...
}
// A subclass of TheAbstractClass ->
class TheClass : TheAbstractClass() {
override fun function([parameters]) {
// do something...
}
}
这里TheClass
可以被实例化,因为它实现了抽象函数。有关子类化的更多细节,请参阅本章后面的“继承”一节。
多态性
在一个类、一个单例对象、一个伴随对象或一个接口中,可以有几个函数使用相同的名称和不同的参数。这并没有什么神奇之处,但是这个特性在面向对象理论中有自己的名字:多态。
如果有几个同名的函数,Kotlin 会通过查看参数来决定。调用代码指定实际使用哪个函数。这个调度过程通常是有效的,您不会看到任何问题,但是对于复杂的类和某个类的许多可能性,可能包括带有默认参数、接口和 varargs 的复杂参数列表,决定调用哪个函数是不明确的。在这种情况下,编译器会发出一条错误消息,您必须重新设计函数调用或您的类,这样一切才能正常工作。
多态性的用例是多种多样的;作为一个简单的例子,考虑一个具有几个add()
函数的类,这些函数允许一个Int
参数、Double
参数或String
参数。它的代码可以是:
class Calculator {
fun add(a:Int) {
...
}
fun add(a:Double) {
...
}
fun add(a:String) {
...
}
}
如果您现在用某个参数调用calc.add(
… )
,Kotlin 会获取参数的类型来找出要调用哪个函数。
警告
小心函数命名:多态性(即几个函数同名)不应该是偶然发生的,或者仅仅是出于技术原因。相反,从功能的角度来看,使用一个特定名称的所有函数应该服务于相同的目的。
本地功能
在 Kotlin 中,函数可以在函数内部声明。这样的函数被称为局部函数,它们可以从声明开始使用,直到封闭函数结束。
fun a() {
fun b() {
...
}
...
b()
...
}
遗产
在现实生活中,继承意味着把自己的财产留给别人。在像 Kotlin 这样的面向对象的计算机语言中,想法是相似的。给定一个类A,
,写class B : A
表示我们将所有素材从类A
给类B
。除了拥有一个重新命名的A
副本之外,这有什么好处呢?神奇的是,类B
可以否决或者否决它从类A
继承的部分素材。这可以用来改变它所继承的类的某些方面,以引入新的行为。
尽管这种对函数和属性的重写与现实生活中的继承有些不同,但继承类和重写特定的函数和属性是任何面向对象语言的核心方面之一。
从其他类继承的类
继承的精确语法是
open class A { ... }
class B : A() {
[overriding assets]
[own assets]
}
如果A
有一个空的默认构造函数,并且
open class A([constructor parameters]) { ... }
class B : A([constructor parameters]) {
[overriding assets]
[own assets]
}
否则。当然,类B
可能有自己的构造函数:
open class A([constructor parameters]) { ... }
class B([own constructor parameters]) :
A([constructor parameters])
{
[overriding assets]
[own assets]
}
类声明中的open
是 Kotlin 的专长。只有标有open
的类才能用于继承。
注意
这是 Kotlin 制造商的一个有点奇怪的设计决定。它基本上禁用了继承,除非您将open
添加到所有可能用于继承的类中。在现实生活中,开发人员很可能会忘记在他们的所有类中添加open
,甚至拒绝在任何地方添加open
,因为这感觉很讨厌,所以如果你的程序使用其他程序或库中的类,继承很可能会被破坏。不幸的是,没有出路,所以我们不得不接受这一点。当然,您可以在自己的类中任何需要的地方添加open
。
相对于彼此,用作继承基础的类也被称为超类,从其继承的类是子类。因此,在前面的代码中,A
是B
的超类,B
是A
的子类。
在我们的NumberGuess
例子中,你可以看到,例如,我们的MainActivity
类继承自AppCompatActivity
。这种内置活动类的子类化对于任何与 Android 一起工作的应用都很重要。
构造函数继承
在子类构造的最开始,将调用超类的构造函数,包括init{ }
块。如果超类提供了二级构造函数,那么子类也可以调用二级构造函数。这可以通过简单地使用二级构造函数的参数签名来实现:
open class A([constructor parameters]) {
constructor([parameters2]) { ... }
}
class B : A([parameters2]) {
...
}
因为我们知道次级构造函数总是调用主构造函数,所以任何情况下的设计继承总是调用超类的主构造函数和init{ }
块。如果子类提供了自己的init{ }
块,这也是正确的,然后这个块被第二个调用。初学者往往会忘记这个事实,但如果你记住这一点,你可以避免一些困难。
在 Kotlin 中,子类可以从超类的构造函数中窃取属性。为此,val
或var
需要加上open,
,如下例所示:
open class A(open val a:Int) {
}
然后,子类可以覆盖相关的参数:
open class A(open val a:Int) {
}
class B(override val a:Int) : A(42) {
...
}
这种被重写的属性将由以前使用其自己的属性原始版本的超类中的任何代码来处理。
练习 15
的输出会是什么
open class A(open val a:Int) {
fun x() {
Log.d("LOG",
"A.x() -> a = ${a}")
}
fun q() {
Log.d("LOG",
"A.q() -> a = ${a}")
}
}
class B(override val a:Int) : A(37) {
fun y() {
Log.d("LOG",
"B.y() -> a = ${a}")
q()
}
}
// inside some activity function:
val b = B(7)
b.y()
请注意,Log.d("TAG",
… )
将第二个参数打印到控制台。
覆盖功能
要覆盖超类的函数,在子类中你必须使用override
修饰符并编写
open class A {
open fun function1() { ... }
}
class B : A() {
override
fun function1() { ... }
}
同样,我们必须将open
添加到超类中的函数,以使它有资格继承。当然,该函数可以有一个参数列表,并且超类和子类中的参数类型必须相同,以使重写正确工作。被覆盖的函数在子类中获得了一个新版本,但是原始版本并没有完全丢失。可以通过写来寻址原始函数
super.functionName(param1, param2, ...)
在子类中。
覆盖属性
Kotlin 有一个其他面向对象语言没有的特性。不仅可以覆盖函数,还可以覆盖属性。为此,这些属性需要在超类中标记为open
,如
open class A {
open var a:Int = 0
}
从此超类继承的类可以通过声明来重写该属性
class B : A() {
override var a:Int = 0
}
使用这种符号,来自类B
和A
内部的属性的任何使用都被类B
中声明的属性所覆盖。该属性的行为就好像类A
中的声明不再存在一样,并且A
中以前使用该属性的“他们的”版本的函数将使用来自类B
的属性。
练习 16
的输出会是什么
open class A() {
private var g:Int = 99
fun x() {
Log.d("LOG", "A.x() : g = ${g}")
}
fun q() {
Log.d("LOG", "A.q() : g = ${g}")
}
}
class B : A() {
var g:Int = 8
fun y() {
Log.d("LOG", "B.y() : g = ${g}")
q()
}
}
// inside some activity function:
val b = B()
b.x()
b.y()
注意Log
是由自动包含在你的项目中的 Android 库提供的。如果第一次出现错误,请将光标放在它上面,然后按 Alt+Enter 以获得解决方法。你能猜到为什么类A
中的属性g
必须被声明为private
,这意味着其他类不能看到或使用它吗?
练习 17
在练习 16 中,从属性声明中移除private
,并使类B
覆盖来自类A
的属性g
。输出会是什么?
访问超类素材
即使函数或属性在某个子类中被覆盖,如果在前面加上一个super.
,您也可以从超类中访问原始版本,例如,在
open class A() {
open var a:Int = 99
open fun x() {
Log.d("LOG", "Hey from A.x()")
}
}
class B : A() {
override var a:Int = 77
override fun x() {
Log.d("LOG", "Hey from A.x()")
}
fun show() {
Log.d("LOG", "Property: " + a)
Log.d("LOG", "Formerly: " + super.a)
Log.d("LOG", "Function: ")
x()
Log.d("LOG", "Formerly: ")
super.x()
}
}
// inside some activity function:
val b = B()
b.show()
输出显示,从子类B
中,我们可以使用被覆盖的和原始的属性和函数:
Property: 77
Formerly: 99
Function:
Hey from B.x()
Formerly:
Hey from A.x()
局部变量
局部变量是在某个函数中声明和使用的val
或var
变量;例如:
class TheClass {
fun function() {
...
var localVar1:Int = 7
val localVar1:Int = 8
...
}
}
这种局部变量从声明到函数结束都是有效的;这就是为什么他们被称为本地的。当然,它们被允许计算从函数返回某些东西所必需的任何表达式,因为它们在返回发生之前不会被销毁。
出于代码质量的原因,局部变量不应该屏蔽函数参数。如果你有一个任何类型的函数参数xyz
,你不应该在函数内部使用名字xyz
来声明一个局部变量。编译器允许这样做,但是它会发出一个关于隐藏的警告。
练习 18
下面哪个类是有效的?对于任何无效的类,描述问题是什么。
1\. class TheClass {
var a:Int = 7
fun function() {
val a = 7
}
}
2\. class TheClass {
fun function(a:String) {
val a = 7
}
}
3\. class TheClass {
fun function() {
println(a)
val a = 7
}
}
4\. class TheClass {
fun function():Int {
val a = 7
return a - 1
}
}
5\. class TheClass {
fun function1():Int {
val a = 7
return a - 1
}
fun function2():Int {
a = 8
return a - 1
}
}
类和类成员的可见性
到目前为止,我们主要以一种字面上自由的方式谈论了类、单例对象和伴随对象(结构单元)以及它们的属性和功能:
class TheName { // or object or companion object
val prop1:Type1
var prop2:Type2
fun function() {
...
}
}
这里字面上的自由意味着以这种方式声明的结构单元、函数和属性可以从任何地方自由访问。在 Kotlin,这种可达性被称为公众可见性。你甚至可以用这种方式给它们添加关键字public
来明确描述这种公共可见性。
public [class or (companion) object] TheName {
public val prop1:Type1
public var prop2:Type2
public fun function() {
...
}
}
然而,为了简洁起见,您通常会省略它,因为 public 是 Kotlin 中的默认可见性。
在 Kotlin,可以对能见度进行限制。乍一看,如果我们在任何地方都保持默认的公共可见性,这可能会更容易,因为任何东西都可以从任何地方访问,并且您不必考虑限制。然而,对于任何重要的项目,都有很好的理由考虑区分可见性。与之相关的关键术语是封装。我们这样说是什么意思?以模拟时钟为例。它显示时间,并提供了一种通过一些时钟控制来调整时间的方法。我们可以用两个函数对此建模,time()
和setTime()
:
class Clock {
fun time(): String {
...
}
fun setTime(time:String) {
...
}
}
从用户的角度来看,这就是与时钟“交谈”所需要的一切。时钟内部发生的事情是一个不同的故事:首先,为了调整时间,时钟需要从当前显示的时间中增加或减少一些时间。这是通过转动时钟的控制盘来实现的。第二,时针、分针和秒针的角度更全面地描述了时钟的当前状态。还有一个技术装置,对每一秒的滴答声做出反应。这对应于时钟的齿轮。我们还需要一个每秒触发事件的计时器,就像模拟时钟中的弹簧一样。最后,我们还需要在init{ }
块中添加一些定时器初始化代码。考虑到所有这些因素,我们必须重写我们的类,使其如下所示:
class Clock {
var hourAngle:Double = 0
var minuteAngle:Double = 0
var secondsAngle:Double = 0
var timer:Timer = Timer()
init {
...
}
fun time(): String {
...
}
fun setTime(time:String) {
...
}
fun adjustTime(minutes:Int) {
...
}
fun tick() {
...
}
}
我们现在有两种访问素材的类:用户关心的外部类和用户不需要知道的内部类。封装通过引入一个新的可见性类 private,精确地处理了对客户隐藏内部的问题。顾名思义,私有属性和函数是结构单元的私有属性,外部的任何人都不需要关心它们,甚至不允许访问它们。要表明一个属性或函数是私有的,只需在它前面加上private
关键字。对于我们的Clock
类,我们这样写
class Clock {
private var hourAngle:Double = 0
private var minuteAngle:Double = 0
private var secondsAngle:Double = 0
private var timer:Timer = Timer()
init {
...
}
fun time(): String {
...
}
fun setTime(time:String) {
...
}
private fun adjustTime(minutes:Int) {
...
}
private fun tick() {
...
}
}
以这种方式分离功能和属性有以下好处:
-
客户端不需要知道一个类或一个对象的内部功能的细节。它可以忽略任何标有
private,
的东西,减少干扰,更容易理解和使用这个类或对象。 -
因为客户端只需要知道公共属性和函数,所以只要公共属性和函数以预期的方式运行,私有函数以及所有私有属性的实现就可以在任何时候自由地改变。因此,更容易改进类或修复缺陷。
回到NumberGuess
游戏,我们已经使用了private
作为可见性说明符。如果您只查看 activity 类的函数签名,您会看到:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?)
override fun onSaveInstanceState(outState: Bundle?)
fun start(v: View)
fun guess(v:View)
///////////////////////////////////////////////////
///////////////////////////////////////////////////
private fun putInstanceData(outState: Bundle?)
private fun fetchSavedInstanceData(
savedInstanceState: Bundle?)
private fun log(msg:String)
}
这里你也清楚地看到我们需要onCreate()
和onSaveInstanceState()
成为公共的,因为 Android 运行时需要从外部访问它们以进行生命周期处理。此外,start()
和guess()
也需要是公共的,因为它们是通过按键从外部访问的。剩下的三个函数只能从类内部访问,因此这些函数具有private
可见性。
除了public
和private
之外,还有两个可见性修改器:internal
和protected
。表 3-2 连同我们已经知道的两个一起描述了它们。
表 3-2。
能见度
|能见度
|
素材
|
描述
|
| --- | --- | --- |
| public
| 功能或属性 | (默认)该函数或属性在结构单元内外的任何地方都是可见的。 |
| private
| 功能或属性 | 该功能或属性仅在同一结构单元内部可见。 |
| protected
| 功能或属性 | 从同一个结构单元内部,以及从任何直接子类内部,函数或属性都是可见的。子类是通过class TheSubclass-Name : TheClassName {
… }
声明的,它们继承了它们所继承的类的所有公共的和受保护的属性和函数。 |
| internal
| 功能或属性 | 函数和属性仅对来自同一程序的结构单元是公共的。对于来自其他编译的程序,尤其是来自您的软件中包含的其他程序,internal
会得到和private
一样的待遇。 |
| public
| 类、单例对象或伴随对象 | (默认)结构单元在程序内外的任何地方都是可见的。 |
| private
| 类、单例对象或伴随对象 | 结构单元仅在同一文件中可见。对于内部类,结构单元仅在封闭结构单元中可见。例如class A {``private class B {``... }``fun function() {``val b = B()``}``}
|
| protected
| 类、单例对象或伴随对象 | 结构单元仅在封闭结构单元或其子类中可见。例如class A {``protected class B {``... }``fun function() {``val b = B()``}``}``class AA : A {``// subclass of A``fun function() {``val b = B()``}``}
|
注意
对于小项目,除了默认的public
之外,你不会关心任何可见性修饰符。对于较大的项目,添加可见性限制有助于提高软件质量。
自我参考:这个
在任何类的函数中,关键字this
指的是当前的实例。我们知道,在类内部,我们可以通过使用它们的名字来引用同一个类中的函数和属性。如果可见的话,从类的外部,我们将不得不预先考虑实例名。您可以将this
视为可以在类内部使用的实例名,因此,如果我们在一个函数中,引用来自同一个类的属性或函数,我们可以等效地使用
functionName() -the same as- this.functionName()
propertyName -the same as- this.propertyName
如果一个函数的参数与同一个类的属性同名,我们已经知道参数屏蔽了属性。我们还知道,如果我们加上this
,我们仍然可以访问该属性。事实上,这是使用this
的主要用例。在某些情况下,如果在函数或属性名前面加上this.
,也会有助于提高可读性。例如,在设置实例属性的函数中,使用this
有助于表达设置属性是函数的主要目的。
考虑一下这个:
var firstName:String = ""
var lastName:String = ""
var socialSecurityNumber:String = ""
...
fun set(fName:String, lName:String, ssn:String) {
this.lastName = lName
this.firstName = fName
this.socialSecurityNumber = ssn
}
从技术上来说,没有这三个this.
实例它也能工作,但是在这种情况下,它的表达能力就弱了。
将类转换为字符串
在 Kotlin 中,任何类都自动且隐式地从内置类Any
继承。不用明确陈述,也没有办法阻止。这个超超类已经提供了几个函数,其中一个具有名称和返回类型toString():String
。这个函数是一种多用途的诊断函数,经常被用来让一个实例在文本表示中告诉它的状态。这个函数是open
,所以任何类都可以覆盖这个函数,让你的类以一种非正式的方式指示实例状态。
您可以在被覆盖的toString()
中自由地做任何您想做的事情,但是大多数情况下会返回一个或另一个属性,例如在本例中:
class Line(val x1:Double, val y1:Double,
val x2:Double, val y2:Double) {
{
override fun toString() =
"(${x1},${y1}) -> (${x2},${y2})"
}
通常你不想错过超类在它们自己的toString()
实现中所做的事情,所以你可能更喜欢这样写:
class Line(val x1:Double, val y1:Double,
val x2:Double, val y2:Double) {
{
override fun toString() = super.toString()
" (${x1},${y1}) -> (${x2},${y2})"
}
记住super.
地址没有覆盖属性和函数。
练习 19
你能猜到如果你写这个会发生什么吗?
class Line(val x1:Double, val y1:Double,
val x2:Double, val y2:Double) {
{
override fun toString() = toString() +
" (${x1},${y1}) -> (${x2},${y2})"
}
许多内置类在它们的toString()
实现中已经提供了一些有用的输出,所以在大多数情况下,你不必仅仅为了提供合理的toString()
输出而覆盖内置类。对于其他一些内置类和任何没有自己的toString()
实现的类来说,toString()
表示实例的内存位置。例如:
class A
val a = A()
println(a.toString())
将打印类似于@232204a1 的内容,根据具体情况,这些内容并不丰富。因此,对于诊断输出,提供一个toString()
实现是一个好主意。
四、类和对象:扩展功能
这一章涵盖了一些扩展的面向对象特性,这些特性对于一个程序来说并不是必须的,但是仍然可以提高可读性和表现力。本章假定你已经阅读了第二章。我们还使用第二章中的NumberGuess
示例应用。
匿名类
在您的代码中,在某些情况下,您可能希望创建接口的一次性实现或某个类的一次性子类。在 Kotlin,可以写
class A : SomeInterface {
// implement interface functions ...
}
val inst:SomeInterface = A()
// use inst ...
或者
open class A : SomeBaseClass() {
// override functions ...
}
val inst:SomeBaseClass = A()
// use inst ...
在函数内部,有一种更简洁的方法来创建和使用这样的一次性实例:
val inst:SomeInterface = object : SomeInterface {
// implement interface functions ...
}
// use inst ...
或者
val inst:SomeBaseClass = object : SomeBaseClass() {
// override functions ...
}
// use inst ...
如果像后面的清单中那样扩展一些超类,这个超类也可能是抽象的。然而,随后有必要实现所有的抽象函数,这通常是实例化成为可能的情况。因为接口实现或子类的名字既没有指定也不需要,这样的类叫做匿名类。在花括号之间的类体中,你可以写任何你可以在命名类体内写的东西。
注意
声明中的object :
表明只有一次实例化。匿名类不可能有多个实例。
我们知道this
指的是实际的实例。匿名类内部也是如此,这里的this
指的是匿名类的一个实例。有一个对this
的扩展,它允许我们获得包围类的的实例:只需将@ClassName
附加到this
上。例如在
interface X {
fun doSomething()
}
class A {
fun meth() {
val x = object : X {
override doSomething() {
println(this)
println(this@A)
}
}
}
}
第一个this
指的是匿名类,this@A
指的是类A
的实例。
内部类
类和单例对象也可以在其他类或单例对象中声明,甚至可以在函数中声明。然后可以从它们的作用域内访问它们,所以如果一个类或单例对象在某个类A
内被声明,它可以在A
内被实例化。如果它是在函数内部声明的,那么它可以从声明开始一直使用到函数结束。
class A {
class B { ... }
// B can now be instantiated from
// everywhere inside A
fun meth() {
...
class C { ... }
// C can now be used until the end of
// the function
...
}
}
其他类或其他对象内部的类和对象可以使用类似于包的路径规范从外部寻址:如果在A
(类或单例对象)内部声明了作为类或对象的X
,那么可以编写A.X
从外部访问它。然而,只有当内部类提供了某种到封闭类的接口时,您才应该这样做,以避免破坏封装原则。
class A {
class B { ... }
}
fun main(args:Array<String>) {
val ab = A.B()
// do something with it ...
}
类外的函数和属性
在您的项目中,您可以拥有不包含单个class
、interface
或object
声明,但显示val
和var
属性和功能的 Kotlin 文件。尽管乍一看,如果我们使用这样的文件,我们似乎是在面向对象之外工作,但实际上 Kotlin 编译器隐式地、秘密地基于包名创建了一个 singleton 对象,并将这样的属性和功能放入这个对象中。
注意
对于非常小的项目,不使用显式类和单例对象是可以接受的。如果一个项目变得更大,仍然有可能只使用这样的非类文件,但是你最终会有混乱和不可读的代码的风险。
根据这一事实,以下规则适用于此类属性和功能:
-
在文件的什么地方声明
val
和var
属性和函数并不重要;它们在文件的任何地方都是可用的。 -
这样的属性和函数对你用
import
the.package.name.name
写在其他文件中的其他类或单例对象是可见的,其中最后的name
指的是属性或函数名。 -
一个包中可以有几个这种类型的文件。然后,Kotlin 编译器只是顺序解析所有文件,并收集所有既不是来自类内部也不是单例对象的函数和属性。文件名在这里不起作用。
-
如果在不同的包中有几个这样的文件(由文件顶部的
package
声明定义),名称冲突不会引起问题。属性和函数可以使用相同的名称。然而,您应该避免这种情况,以保持代码的可读性。 -
可以向这样的文件中添加类、接口和单例对象。您可以从声明的地方开始使用这样的结构单元,直到文件结束。
此外,可以使用通配符符号import
the.package.name.*
从特定包中的所有文件导入属性和函数。这对于避免冗长的import
列表非常方便。
练习 1
你有一个实用的单例对象
package com.example.util
object Util {
fun add10(a:Int) = a + 10
fun add100(a:Int) = a + 100
}
和一个客户
package com.example
import com.example.util.Util
class A(q:Int) {
val x10:Int = Util.add10(q)
val x100:Int = Util.add100(q)
}
你能想出一种方法来重写Util.kt
文件以不使用object
{ }声明吗?客户端代码会是什么样子?
导入函数和属性
单例对象的函数和属性可以通过如下的 import 语句导入
import package.of.the.object.ObjectName.propertyName
import package.of.the.object.ObjectName.functionName
在文件的顶部,在package
声明之后,与其他用于导入类和单例对象的import
语句一起。这样就可以直接使用函数或属性,只需使用它的名字,而不需要加上ObjectName
。
注意
没有通配符可以导入单例对象的所有属性和函数。你必须把它们放入各自的行中。
练习 2
假设Math.log()
计算一个数的对数,并且Math
驻留在包java.lang
中,重写
package com.example
class A {
fun calc(a:Double) = Math.log(a)
}
使得不再需要Math.
。
数据类别
只包含属性而不包含或包含很少函数的类很可能是数据类,其目的是在几个属性周围提供一个括号。因此,它们作为一种容器,聚集了一系列的属性。想象一个Person
类,它为一个人收集姓名、生日、出生地、SSN 等等。Kotlin 对此类有一个特殊的符号:Prepend data
如
data class ClassName([constructor])
这看起来与普通的类没有太大的不同,但是与它们相反,前置data
会导致以下结果:
-
该类根据属性自动获得一个特别定制的
toString()
函数;你不用自己写。 -
该类仅基于属性自动获得合理的
equals()
和hashCode()
函数。我们稍后会谈到对象平等;现在,您需要知道的是:只有当两个数据类实例属于同一个数据类,并且它们的所有属性都需要相等时,这两个数据类实例的相等检查关系a == b
才会产生true
。
如果您需要一个函数来返回结构化或复合数据,那么数据类就派上了用场。在其他语言中,你必须经常使用成熟的类、数组或列表来实现这个目的,这使得这个任务看起来有点笨拙。在 Kotlin 中,你可以简洁地写出,例如:
data class Point(val x:Double, val y:Double)
fun movePoint(pt:Point, dx:Double, dy:Double):Point =
Point(pt.x + dx, pt.y + dy)
// somewhere in a function ...
val pt = Point(0.0, 1.0)
val pt2 = movePoint(pt, 0.5, 0.5)
你可以看到,在顶部的单个data class
行,我们可以让函数movePoint()
返回一个结构化的数据。
练习 3
使用数据类
data class Point2D(val x:Double, val y:Double)
data class Point3D(val x:Double, val y:Double, val z:Double)
眼下,以下哪一项是正确的(==
代表等于)?
-
Point2D(0, 1) == Point2D(1, 0)
-
Point2D(1, 0) == Point3D(1, 0, 0)
-
Point2D(1, 0).x == Point3D(1, 0, 0).x
-
Point2D(1, 0) == Point2D(1.0, 0)
-
Point2D(1, 0) == Point2D(1, 0)
描述为什么或为什么不。
练习
NumberGuess
游戏的哪些类算是数据类?执行转换。
列举
枚举类型基本上是一种非数值数据类型,其值来自给定的集合。这里基本上意味着默认情况下类型由整数处理,但是在基本的使用场景中,你不必担心这个。术语 set 是在数学意义上使用的,这意味着值必须是唯一的,并且没有排序顺序。
Kotlin 中的枚举是类的一种特殊形式:
enum class EnumerationName {
VALUE1, VALUE2, VALUE3, ...
}
其中,EnumerationName
可以使用任何 camelCase 名称,VALUEx
是字符集A-Z0-9_
中以字母或_
开头的任何字符串。
注意
对于这些值,技术上有更多的字符可用,但是按照惯例,您应该使用这里显示的字符组合。
要声明您编写的枚举类型,就像声明任何其他类一样,
val e1: EnumerationName = ...
var e2: EnumerationName = ...
在赋值的右边你用EnumerationName
。追加任何枚举值。例如,将水果作为值的枚举声明为 and,并与以下结果一起使用:
enum class Fruit {
BANANA, APPLE, PINEAPPLE, GRAPE
}
val f1 = Fruit.BANANA
val f2 = Fruit.BANANA
val f3 = Fruit.APPLE
var fx:Fruit? = null
// you can check for equality:
val b1:Boolean = f1 == f2 // -> true
val b2:Boolean = f1 == f3 // -> false
// you can reassign vars:
fx = Fruit.APPLE
fx = Fruit.BANANA
// toString() gives the textual value name
val s = fx.toString() // -> "BANANA"
注意==
相当于等号。这是一个布尔表达式,我们还没有正式介绍。如果愿意,您可以自己定义枚举值的内部数据类型:只需向enum
类添加一个主构造函数,并将其用于值:
enum class Fruit(val fruitName:String) {
BANANA("banana"),
APPLE("apple"),
PINEAPPLE("pineapple"),
GRAPE("grape")
}
然后,您可以使用您引入的属性名称来获取这个自定义内部值:
val f1 = Fruit.BANANA
var internalVal = f1.fruitName // -> "banana"
枚举类的一个有趣的内置函数是动态查找函数valueOf()
:如果您需要从字符串中动态获取一个值,请编写
val f1 = Fruit.valueOf("BANANA")
// <- same as Fruit.BANANA
使用
EnumerationName.values()
获取枚举的所有值(例如,对于循环)。枚举值本身也有两个内置属性:
-
使用
enumVal.name
以字符串形式获取值的名称。 -
使用
enumVal.ordinal
获取枚举值列表中值的索引。
练习 5
在NumberGuess
游戏应用的GameUser
类中添加一个Gender
枚举。允许值M
、F
和X
。在默认值为X
的GameUser
构造函数参数中添加相应的构造函数参数gender
。
自定义属性访问器
我们知道一个var
属性基本上是通过书写来声明的
var propertyName:PropertyType = [initial_value]
我们还知道,为了得到var
,我们写object.propertyName
,为了设置它,我们写object.propertyName =...
在 Kotlin 中,当您获取或设置属性时,可以改变正在发生的事情。为了适应获取过程,您可以这样写:
var propertyName:PropertyType = [initial_value]
get() = [getting_expression]
在[getting_expression]
里面,你可以写你喜欢的东西,包括访问函数和其他属性。对于更复杂的情况,你也可以提供一个身体,如
var propertyName:PropertyType = [initial_value]
get() {
...
return [expression]
}
改为更改适用于您编写的propertyName = ...
的设置过程
var propertyName:PropertyType = [initial_value]
set(value) { ... }
在set
主体中,你可以访问对象的所有函数和所有其他属性。此外,您可以使用特殊的field
标识符来引用与属性对应的数据。
当然,你可以双管齐下;也就是说,调整获取和设置过程:
var propertyName:PropertyType = [initial_value]
get() = [getting_expression]
set(value) { ... }
您可以微调属性的 getters 和 setters 的可见性。只管写
[modifier] var propertyName:PropertyType = ...
private get
private set
或任何其他可见性修饰符。但是,要使 getter 成为私有的,属性本身也必须声明为私有的。相反,将 setter 设置为公共属性的私有是一个有效的选择。
有趣的是,可以定义在类或单例对象中没有相应数据的属性。如果您同时定义了属性的 setter 和 getter,并且既没有指定初始值也没有在 setter 代码中使用field
,则不会为该属性生成任何数据字段。
练习 6
你能猜到用val
代替var
属性能做什么吗?
练习 7
编写一个与toString()
功能相同的str
属性(因此可以编写obj.str
而不是obj.toString()
)。
练习 8
回忆一下NumberGuess
游戏 app:
data class GameUser(var firstName:String,
var lastName:String,
var userName:String,
var registrationNumber:Int,
var gender:Gender = Gender.X,
var birthday:String = "",
var userRank:Double = 0.0) {
enum class Gender{F,M,X}
var fullName:String
var initials:String
init {
fullName = firstName + " " + lastName
initials = firstName.toUpperCase() +
lastName.toUpperCase()
}
}
我们遇到的问题是,随着后来的firstName
更改,fullName
会被破坏。
val u = GameUser("John", "Smith", "jsmith", 123)
u.firstName = "Linda"
val x = u.fullName // -> "John Smith" WRONG!
找到避免这种腐败状态的方法。提示:之后不再需要一个init{ }
块。相应地更新您的代码。
Kotlin 扩展
在 Kotlin 中,可以“动态地”向类添加扩展。我们需要将其动态地放在引号中,因为在执行之前,必须在代码中定义这种扩展的用法。在 Kotlin 中,不可能在运行时决定是否要使用哪些扩展,如果要使用的话。计算机语言设计者通常将这样的特性称为静态特性。
这就是我们所说的扩展:如果我们可以给任何类添加函数和自定义属性,那不是很好吗?这可能非常有用,例如,如果我们想要向其他人提供的类和函数添加额外的功能。我们知道我们可以使用继承来达到这个目的,但是根据具体情况,这可能是不可能的,或者实现可能会感觉笨拙。
警告
扩展机制非常强大。小心不要过度使用它。如果不花时间研究扩展定义,您可以使用没有人理解的扩展编写非常优雅的代码。
扩展功能
假设我们希望在内置的String
类中有一个hasLength(l:Int): Boolean
函数。你可能认为这就是继承的用途。然而,扩展String
类是不可能的,因为通过设计来扩展String
是被禁止的,所以我们不能为此使用继承。不过,Kotlin 扩展机制在这里帮助了我们。我们可以写作
package the.ext.pckg
fun String.hasLength(len:Int) = this.length == len
在某个包the.ext.pckg
内的某个文件fileName.kt
(文件名在这里不起作用,所以随便用)。记住==
检查平等。
我们现在可以在任何类或单例对象中使用扩展函数
import the.ext.pckg.*
// anywhere inside a function ...
val hasLen10:Boolean = someString.hasLength(10)
同样的过程也适用于任何其他类,包括您自己的类和伴随对象。对于后一种情况,编写fun SomeClass.Companion.ext() { }
来定义一个新的扩展函数ext
。这里的Companion
是一个文字标识符,用于寻址伴随对象。
注意
如果扩展函数与已存在的函数具有相同的名称和函数签名(参数集),则后者优先。
扩展属性
类似的过程也适用于属性。假设您想给String
添加一个l
属性,它与.length()
做同样的事情,并计算字符串长度。您可以通过如下结构来实现:
package the.ext.pckg
val String.l get() = this.length
注意,我们不能使用val String.l = this.length
,因为出于技术原因,扩展属性不允许实际创建真正的数据字段。因此初始化是不可能的,因为事实上没有什么可以初始化。至于 getters 我们想怎么写就怎么写,可以直接参考.length
。现在可以写了
import the.ext.pckg.*
// anywhere inside a function ...
val len1 = someString.length
val len2 = someString.l // this is the same
具有可空接收器的扩展
注意
接收者指的是被扩展的类或单例对象。
可以捕捉扩展的null
值。如果你在前面加上一个问号
fun SomeClass?.newFunction(...) { ... }
你可以检查this == null
体内是否还有适当的反应,在这种情况下做正确的事情。即使instance
是null
,你也可以写instance.newFunction(...)
,然后进入扩展函数。
封装扩展
如果您想在特定的类、单例对象或伴随对象中封装扩展,可以编写如下代码:
class SomeClass {
fun SomeOtherClass.meth() {
...
}
}
这里SomeOtherClass
接收扩展函数,但是该函数只能从SomeClass
内部使用。对于String
类的hasLength()
扩展,封装版本如下所示
class SomeClass {
fun String.hasLength(len:Int) = this.length == len
fun function() {
...
// we can use hasLength() here
val len10:Boolean = someString.hasLength(10)
...
}
}
class SomeClass2 {
// we can't use String.hasLength() here
}
类似的过程允许我们封装扩展属性。这些属性的符号如下
class SomeClass {
val SomeOtherClass.prop get() = ...
}
字符串长度的String.l
扩展的封装版本如下
class SomeClass {
val String.l get() = this.length
fun function() {
...
// we can use .l here
val len = someString.l
...
}
}
封装扩展的明显优势是我们不必导入扩展文件。如果我们想要定义可用于许多类的扩展,非封装的变体将是更好的选择。
尾部递归函数
递归函数调用自己。对于某些算法,这种情况偶尔会发生。例如,阶乘函数n! = n
⋅(n
⋅(n
⋅...2
⋅1
可以实现为
fun factorial(n:Int):Int {
return if(n==1) n else n * factorial(n-1)
}
注意,if()
表达式返回在else
之前或之后的部分,这取决于参数的计算结果是true
还是false
(我们将在本书后面讨论分支)。
为了让应用正常运行,运行时引擎需要跟踪函数调用,所以在内部,对factorial()
的调用看起来会像factorial( factorial( factorial (...) ) )
一样,如果递归深度不太高,这不是问题。但是,如果它真的很高,我们就会遇到内存使用和性能方面的问题。然而,如果递归发生在这样一个函数的最后一条语句中,它可以被转换成一个尾递归函数,这样在内部就不会发生系统资源的过度使用。
要将一个函数转换成尾部递归函数,只需在tailrec
前面加上fun
,如
tailrec fun factorial(n:Int) {
return if(n==1) n else n * factorial(n-1)
}
中缀运算符
中缀运算符用于由表示的运算
operand1 OPERATOR operand2
我们知道很多这样的中缀运算符:想想乘法(3 * 4),加法(3 + 4),等等。在 Kotlin 中,许多这样的中缀运算符是预定义的,但是也可以定义自己的中缀运算符。为此,请编写
infix operator
fun SomeClass1.oper(param:SomeClass2) = ...
其中oper
是操作符的名称(使用您自己的名称),...
使用this
(SomeClass1
的实例)和param
执行任何计算。然后你可以写
[expression1] oper [expression2]
其中[expression1]
的类型为SomeClass1
,[expression2]
的类型为SomeClass2
。对于更复杂的计算,您也可以像往常一样使用函数体:
infix operator
fun SomeClass1.oper(param:SomeClass2):ResultType {
...
return [result_expression]
}
例如,为了允许一个字符串使用新操作符TIMES
重复 n 次,我们编写
infix operator fun String.TIMES(i:Int) =
(1..i).map { this }.joinToString("")
(第二行是功能性构造;稍后我们将讨论功能设计。)我们可以接着写
val s = "abc" TIMES 3 // -> "abcabcabc"
如果我们考虑到 Kotlin 有标准操作符的文本对应物,我们可以更巧妙地做到这一点。例如,*,
的文本表示是times
,所以我们可以写
operator fun String.times(i:Int) =
(1..i).map { this }.joinToString("")
这样我们就可以使用星号进行相同的操作:
val s = "abc" * 3 // -> "abcabcabc"
这里可以省略infix
,因为 Kotlin 知道*
属于中缀操作。
使用标准运算符来定义自定义计算称为运算符重载。在下一节中,我们将使用所有标准操作符的列表和文本表示来了解更多信息。
运算符重载
运算符采用一个或两个表达式,并使用以下符号生成一个输出:
[OPER] expression
[expression] [OPER] [expression]
处理一个表达式被称为一元操作,该操作符相应地被称为一元操作符。同样,处理两个表达式给了我们二元操作和二元操作符。
从数学中我们知道很多运算符,比如-a,a + b,a * b,a / b 等等。当然,Kotlin 为其数据类型内置了许多这样的操作符,所以7 + 3
和5 * 4
等做了预期的事情。我们将在本书后面详细讨论操作符表达式,但现在我们想关注一下操作符重载,Kotlin 的这一功能允许你使用标准操作符为自己的类定义自己的操作符。
比方说,你有一个Point
类指定空间中的一个点(
x,y )
,还有一个Vector
类指定两点之间的直接连接。从我们已经学到的,我们知道我们可以通过
data class Point(val x:Double, val y:Double)
data class Vector(val dx:Double, val dy:Double)
现在从数学上我们知道,从点 P 1 到点 P 2 的向量可以写成表达式。计算结果是 dx = * p * 2。x p1。 x 和 dy = * p * 2。y p1。 y 。如果我们可以只写v=p2-p1 来执行那个操作,不是很好吗,就像
val p1 = Point(1.0, 1.0)
val p2 = Point(4.0, -2.0)
val v:Vector = p2 - p1
使用运算符重载,我们可以做到这一点。这很简单:首先,我们需要——操作符的文本表示,恰好是负的。其次我们写
data class Point(val x:Double, val y:Double) {
operator fun minus(p2:Point) =
Vector(p2.x-this.x, p2.y-this.y)
}
就是这样。val v:Vector = p2 - p1
现在可以工作了,所以每当编译器看到两个Point
实例之间有一个-
时,它就会计算组合它们的向量。
对于一元运算符,过程是相同的,但是不需要在运算符函数中指定参数。例如,如果你想让-Vector(1.0, 2.0))
工作,给定反向向量,你只需加上
operator fun unaryMinus() = Vector(-this.dx, -this.dy)
到Vector
班。
你可以对 Kotlin 认识的所有操作员做同样的事情。所有这些的文字表示如表 4-1 所示。
表 4-1
经营者
|标志
|
Arity
|
本文的
|
标准含义
|
| --- | --- | --- | --- |
| +
| U | 一元加号 | 再现数据(如+3
)。 |
| − | U | 一元减操作 | 对数据求反(例如,7
)。 |
| !
| U | 不 | 逻辑否定数据(如!true == false
)。 |
| ++
| U | 股份有限公司 | 递增数据(如var a = 6; a++; // -> a == 7
)。操作员不得更改调用它的对象!增量值的分配在幕后进行。 |
| −− | U | 十二月 | 递减数据(例如var a = 6; a
-; // -> a == 5
)。操作员不得更改调用它的对象!递减值的分配在幕后进行。 |
| +
| B | 加 | 添加两个值。 |
| − | B | 负的 | 减去两个值。 |
| *本文件迟交 | B | 倍 | 将两个值相乘。 |
| / | B | 差异 | 将两个值相除。 |
| % | B | 雷姆 | 除法的余数(例如,5 % 3 = 2)。 |
| .. | B | 范围到 | 创建一个范围(例如 2..5 -> 2, 3, 4, 5) |
| 在!在 | B | 包含 | 检查右侧是否包含在左侧中。 |
| [ ] | B+ | 获取/设置 | 索引访问。如果在像q[5] = ...
这样的赋值的左边,set()
函数与指定要设置的值的最后一个参数一起使用。get()
和set()
函数允许多个参数,这些参数对应于[]
中几个逗号分隔的索引;比如q[i]
→ q.get(i)
、q[i,j]
→ q.get(i, j)
、q[i,j] = 7
→ q.set(i, j, 7)
|
| ( )
| B+ | 引起 | 祈祷。允许多个参数,这些参数对应于()
中几个逗号分隔的参数;比如q(a)
→ q.invoke(a)
和q(a, b)
→ q.invoke(a, b)
。 |
| + =
| B | plusAssign | 与plus()
相同,但是将结果分配给调用操作符的实例。 |
| —=
| B | 减法赋值 | 与minus()
相同,但是将结果分配给调用操作符的实例。 |
| *=
| B | 时间分配 | 与times()
相同,但是将结果分配给调用操作符的实例。 |
| / =
| B | 二次分配 | 与div()
相同,但是将结果分配给调用操作符的实例。 |
| % =
| B | 再分配 | 与rem()
相同,但是将结果分配给调用操作符的实例。 |
| ==
| B | 等于 | 检查是否相等。! =
代表不相等,对应equals()
返回false
。 |
| <``>``<=``>=
| B | 比较 | 比较两个值。根据参数是小于、等于还是大于函数所应用的值,函数compareTo()
应该返回1, 0, +1
。 |
注意
因为在操作符函数体或表达式中你可以计算你想要的,你可以让操作符做奇怪的事情。请记住,当使用操作符时,您的类用户期望某个特定的行为,所以合理地使用您在那里计算的内容。
顺便说一下,如果您喜欢在扩展文件中重载操作符,就不需要什么魔法了。对于前面的点和向量的例子,只需写operator fun TheClass.operator_name = ...
,如下所示
operator fun Point.minus(p2:Point) =
Vector(p2.x-this.x, p2.y-this.y)
不要忘记导入扩展文件,就像对任何其他扩展一样。
练习 9
在Vector
类中添加-
和+
操作符。如果v2
是操作函数参数,计算包括增加或减少dx
和dy
成员:Vector(this.dx + v2.dx, this.dy + v2.dy)
和Vector(this.dx - v2.dx, this.dy - v2.dy)
。
授权
我们通过class TheClass : SomeInterface {
了解到遗传...}
让TheClass
实现接口仅以抽象方式声明的功能。实现代码在TheClass
中输入被覆盖的函数。委托类似于继承;它以同样的方式开始:class TheClass : SomeInterface
...。不同之处在于实现代码所在的位置:对于委托,假设手边有一个已经实现了接口的对象,而TheClass
主要是将工作委托给这个对象。使用我们已知的结构,这可以写成:
interface TheInterface {
fun someMethod(i:Int):Int
...more functions
}
class Implementor0 : SomeInterface {
override fun someMethod(i:Int):Int = i*2
...implement the other functions
}
class Implementor : TheInterface {
val delegate = Implementor0()
override fun someMethod(i:Int):Int = delegate(i)
...do the same for the other functions
}
Implementor
类中的方法someMethod()
委托给了delegate
,但这也可能会增加一些额外的工作,如
override fun someMethod(i:Int):Int = delegate(i-1) + 1
Kotlin 对委托基本模式有一个简明的符号。你只需要写
class Implementor : TheInterface by Implementor0()
// or
val impl0 = Implementor0()
class Implementor : TheInterface by impl0
然后,Kotlin 编译器通过将工作转发给委托来自动实现所有接口方法。您仍然可以通过覆盖它来更改任何函数:
class Implementor : TheInterface by Implementor0() {
override fun someMethod(i:Int):Int = i * 42
}
如果您明确需要委托对象,则必须将其添加到构造函数中,如
val b = Implementor0()
class Implementor(val b:TheInterface) :
TheInterface by b {
override
fun someMethod(i:Int):Int = b.someMethod(i-1) + 1
}
val instance = Implementor(b)
五、表达式:对数据的操作
我们已经用过几次表达了。每当你需要给一个变量赋值,需要函数调用参数,或者需要给某种语言结构赋值时,你就需要一个表达式。表达式也会出现在你意想不到的地方,如果我们不需要,它们可以被忽略。
表达式示例
表达式可以细分为不同的类型:数字表达式、布尔表达式、字符串和字符表达式、作用于位和字节的表达式,以及一些未分类的表达式。在我们开始详细解释它们之前,这里有一些例子:
4 * 5 // multiplication
3 + 7 // addition
6 – 1 // subtraction
"a" + "b" // concatenation
( 1 + 2 ) // grouping
-5 // negation
a && b // boolean a AND b
"Hello" // constant (String)
78 // another constant (Int)
3.14 // another constant (Double)
'A' // another constant (Char)
arr[43] // index access
funct(...) // function invocation
Clazz() // instantiation
Obj // singleton instance access
q.a // dereferencing
q.f() // another dereferencing
if(){ } // language construct
when(){ } // another language construct
表达式的普遍性
与许多其他计算机语言不同,在 Kotlin 中,几乎所有东西都是表达式。例如,看看函数调用funct()
。你可能会认为一个没有像在fun funct() { ... }
中那样声明返回值的函数不是一个表达式,因为它似乎不能被赋值给一个变量。试试看,写一写
fun a() {
}
val aa = a()
令人惊讶的是,编译器没有将这段代码标记为错误。事实上,这样的函数确实会返回值;它是Unit
类的实例,被称为Unit
。你不能用它做任何有趣的事情,但它是一个值,它使一个函数不显式返回任何东西,而是隐式返回一些东西。
在本章的其余部分,我们将介绍不同的表达式类型以及它们之间的转换。
数字表达式
数字表达式是由文字、属性和子表达式等元素构建的结构,可能由运算符组合在一起并产生一个数字。涉及加、减、乘、除的一组众所周知的运算符通常被称为算法。在计算中,这组标准运算符通常会增加一个递增和递减运算符++
和—,以及一个整数除法余数运算符%。对于 Kotlin 内部可用于数值表达式的可能元素的完整列表,见表 5-1 。
表 5-1
数字表达式元素
|标志
|
意义
|
例子
|
| --- | --- | --- |
| 文字 | 字面意思 | 3
或7.5
|
| 变量 | 一处房产 | val a = 7; val b = a + 3
|
| 函数( ) | 函数的值,如果它返回一个数字 | fun a() = 7; val b = 3 + a()
|
| [ ]
| 访问数组或数字列表中的元素 | arr[0]``list[7]
|
| ( )
| 替换为内部表达式的结果 | 7 * ( a + b )
|
| +
| 如果用在表达式前面,则复制数据 | val a = +3``val a = +7.4
|
| -
| 如果用在表达式前面,则对数据求反 | val a = -(7+2)
|
| ++
| 可以用在一个var
的前面或者后面;如果用在它的前面,则计算为var + 1
的当前值;如果在它后面使用,则计算为var
的当前值;作为副作用,增加了var
| var a = 7``val b = 7 + ++a``val c = 7 + a++
|
| --
| 可以用在一个var
的前面或者后面;如果用在它的前面,则计算为var
- 1 的当前值;如果在它后面使用,则计算为var
的当前值;作为副作用,减少了var
| var a = 7``val b = 7 + --a``val c = 7 + a--
|
| +
| 将两个值相加 | 7 + 6
|
| -
| 减去两个值 | 7 – 6
|
| *
| 将两个值相乘 | 7 * 6
|
| /
| 将两个值相除;如果在两个非浮点值之间,则返回一个非浮点值;否则返回一个Double
或一个Float
| 7 / 6
(给出 1)7.0 / 6.0
(给出1:16667
) |
| %
| 两个整数值相除的余数 | 5 % 3
(给出2
) |
| subexpr
| 用作子表达式的任何表达式,返回一个数字 | 在5 + a / 7
中,a/7
可以被认为是一个子表达式 |
如果在一个表达式中混合不同类型的数字,具有较大取值范围的数字将被用作返回值的类型,因此用一个Long
除以一个Int
将得到一个Long
:
val l1:Long = 234567890L
val i1:Int = 37
val x = l1 / i1 // -> is a Long
同样,如果您在一个表达式中混合了普通精度的Float
元素和双精度的Double
元素,Double
将获胜:
val f1:Float = 2.45f
val d1:Double = 37.6
val x = f1 / d1 // -> is a Double
将整数与浮点数元素混合会产生浮点数:
val i1:Int = 33
val d1:Double = 37.6
val x = i1 * d1 // -> is a Double
如果我们需要组合三个值(或子表达式)并在一行中有两个操作符,如
expr``1
expr``2
expr
**
**问题是先评估哪个操作符。这称为运算符优先级,其 Kotlin 规则如表 5-2 所示。
表 5-2
算术运算符的优先级
|优先
|
经营者
|
例子
|
| --- | --- | --- |
| one | ++ --
作为后缀 | a++
|
| Two | -
(在一个表达式前面)+
(在一个表达式前面)++ --
作为前缀 | –(3 + 4)``--a
|
| three | * /
% | 7 * a
|
| four | + -
| 7 – a
|
您总是可以使用圆括号( ... )
来指定任何运算符的求值顺序。就像在数学中使用的一样,在使用括号内的解之前,首先计算括号内的值。
练习 1
用Math.sqrt(...)
表示平方根√,用 Kotlin 代码写下:
假设a, b,
和x
是现有属性。
布尔表达式
布尔表达式是评估为布尔值true
或false
之一的表达式。如果我们需要决定程序的哪些部分参与程序流,我们经常使用布尔表达式。参与布尔表达式的对象和运算符列于表 5-3 。
表 5-3
布尔表达式元素
|标志
|
意义
|
例子
|
| --- | --- | --- |
| 文字 | 字面意思 | true
或false
|
| 变量 | 一处房产 | val a = true; val b = a
|
| funct()
| 函数的值,如果它返回一个布尔值 | fun a() = true; val b = a()
|
| [ ]
| 访问数组或布尔列表中的元素 | arr[0]``list[7]
|
| ( )
| 替换为内部表达式的结果 | b1 && ( a || b )
(注意:&& = AND,|| = OR) |
| &&
| 和操作;只有当a
和b
都为真时,a && b
才为真;注意,如果左边的求值结果为false
,那么&&
的右边永远不会被求值 | true && true
(产量→ true
) |
| ||
| 或者运营;只有当a
和b
中至少有一个为真时,a || b
才为真;注意,如果左边的求值结果为true
,那么||
的右边永远不会被求值 | true || false
(产量→ true
) |
| !
| 对以下布尔表达式求反 | val b = true; val r = !b
(yields r
为假) |
| a == b
| 如果a
和b
相等,则产生true
;a
和b
是任意对象或子表达式;如果布尔或数字子表达式的值相同,则它们相等;如果对象a
和b
的hashCode()
函数返回相同的值,并且a.equals(b)
返回true
,则它们相等;如果两个字符串都包含相同的字符,则它们相等;如果一个特定数据类的两个实例的所有属性都相等,那么它们就是相等的 | a == 3
(如果a
的值为 3,则为true
)a == "Hello"
( true
如果a
是字符串“你好”) |
| a != b
| 不相等,同!( a == b )
| true)
→true)``false)
→false)
|
| a < b
| 如果数字a
小于数字b
,则为真;还评估是否在对象a
和b
上定义了接口Comparable
| a < 7
(→ true
如果a
小于 7) |
| a > b
| 如果数字a
大于数字b
,则为真;还评估是否在对象a
和b
上定义了接口Comparable
| a > 3
(→ true
如果a
大于 3) |
| a <= b
| 如果数字a
小于或等于数字b
,则为真;还评估是否在对象a
和b
上定义了接口Comparable
| a <= 7
(→ true
如果a
小于或等于 7) |
| a >= b
| 如果数字a
大于或等于数字b
,则为真;还评估是否在对象a
和b
上定义了接口Comparable
| a >= 3
(→ true
如果a
大于或等于 3) |
| a is b
| 如果对象a
实现了类或接口b
则为真 | true)
→true)
|
| a !is b
| 同!(a is b)
| true)
→true)
|
| a === b
| 检查引用是否相等;如果对象是same
并因此强于==
比较,则返回true
;通常不经常使用,因为使用==
操作符的语义检查在大多数情况下更有意义 | class A``val a = A(); val b = A()``val c = a === b``false)
→false)
|
与上一节中的数值表达式类似,如果使用带有更多运算符的表达式,布尔表达式运算符具有优先权。布尔运算符优先级的 Kotlin 规则如表 5-4 所示。
表 5-4
布尔运算符的优先级
|优先
|
经营者
|
例子
|
| --- | --- | --- |
| one | !
(在一个表达式前面) | val a = true; val b = !a
|
| Two | is
,!is
| a in b && c
|
| three | <
、<=
、>=
、>
| a < 7 && b > 5
|
| four | ==
,!=
| a == 7 && b != 8
|
| five | &&
| a == 4 && b == 3
|
| six | ||
| a == 4 || a == 7
|
对于数值表达式,您可以使用圆括号强制不同的优先顺序:
val b1 = a == 7 && b == 3 || c == 4
val b2 = a == 7 && (b == 3 || c == 4)
如你所见,它们是不同的。在第一行中,&&
获胜并首先被计算,因为它比||
具有更高的优先级。在第二行中,||
获胜,因为它在一个括号内。
字符串和字符表达式
字符串没有太多的表达式元素。但是,您可以连接字符串并执行字符串比较。字符串表达式元素的完整列表见表 5-5 。
表 5-5
字符串表达式元素
|标志
|
意义
|
例子
|
| --- | --- | --- |
| 文字 | 字面意思 | "Hello world"
或者"""Hello world"""
|
| 变量 | 一处房产 | val a = "abc"; val b = a
|
| funct()
| 函数的值,如果它返回一个字符串 | fun a() = "abc"; val b = a()
|
| [ ]
| 访问数组或字符串列表中的元素 | arr[0]列表[7] |
| str[ ]
| 从字符串中提取字符 | "Hello" [1]
(产量"
e "
) |
| ( )
| 替换为内部表达式的结果 | "ab" + ("cd" + "ef" )
|
| +
| 串并置 | val a = "Hello " + "world"
(产量→ "Hello world"
) |
| a == b
| 检查是否相等;如果两个字符串都包含相同的字符,则它们相等 | a == "Hello"
( true
如果a
是字符串“Hello”) |
| a != b
| 不相等,同!( a == b )
| true)
→true)
|
| a < b
| 如果字符串a
在字典顺序上小于字符串b
则为真 | true)
→true)
|
| a > b
| 如果字符串a
在字典上比字符串b
大,则为 True | true)
→true)
|
| a <= b
| 如果字符串a
在字典上小于或等于字符串b
则为真 | true)
→true)``false)
→false)
|
| a >= b
| 如果字符串a
在字典上大于或等于字符串b
则为真 | true)
→true)
|
| a in b
| 如果a
是一个Char
,true
如果b
包含a
;如果a
是字符串本身,true
如果a
是字符串b
的一部分 | true)
→true)``true)
→true)
|
| a !in b
| 同!(a in b)
| true)
→true)
|
字符串文字有几种特殊情况。
-
使用三组双引号的字符串被称为原始字符串。它们可以包含任何内容,包括换行符和反斜杠()等特殊字符。书写
"Hello\n world"
会产生由换行符分隔的“Hello world”。然而,如果你写"""Hello\n world"""
,输出将是字面上的“Hello \n world”。一个例外是$
;你得写${'$'}
才能得到。 -
在原始和普通(“转义”)字符串中,你都可以使用模板:一个
${}
被包含在花括号中的任何内容的toString()
表示所取代。例如:"The sum of 3 and 4 is ${3+4}"
得出字符串“3 和 4 之和是 7”。如果它是一个单一的标识符,比如一个房产的名字,你也可以省略括号,写成$propertyName
,比如"And the value of a is $a".
字符具有整数表示,因为它们对应于字符表中的索引。这允许一些算术和比较运算符处理字符。字符表达式元素列表如表 5-6 所示。
表 5-6
字符表达元素
|标志
|
意义
|
例子
|
| --- | --- | --- |
| 文字 | 字面意思 | 'A'
或者'7'
|
| 变量 | 一处房产 | val a = 'x'; val b = a
|
| funct()
| 函数的值,如果它返回一个字符 | fun a() = 'x'; val b = a()
|
| [ ]
| 访问数组或字符列表中的元素 | arr[0]``list[7]
|
| -
| 字符表中的距离 | val d = 'c' - 'a'
(产量→ 2) |
| a == b``a != b``a < b``a > b``a <= b``a >= b
| 性格比较;比较字符表中的索引 | 'c' > 'a'
(产量→ true
) |
比特和字节
字节是更面向硬件的数据存储单位。我们知道有一个Byte
类型,它的值在128
和127
之间。一个字节对应于一些硬件存储和处理元素,可以以极快的方式访问和使用。在你的应用中,你只是偶尔使用字节,尤其是在使用一些低级系统功能或寻址连接的硬件元素(如摄像头或扬声器)时。
你知道当你写下125
这样的十进制数字系统中的一个数字时,你实际上的意思是5
1 + 2
10 + 1
100
。计算机内部不喜欢十进制计数系统,因为如果他们使用它,例如,7
和8
之间的差异不能可靠地用一些技术属性来表示,如两个触点之间的电压。计算机能做得很好的是发现某个东西是否被打开,用密码0
和1
来表示。因此,他们在内部使用二进制编码系统。如果我们需要一个125
它实际上由二进制数01111101
表示,意思是1·1 + 0·2 + 1·2
2
3
+ 1·2
4
+ 1·2
5
+ 1·2
6
+ 0·2
7
。这个数里面的数字被称为位,碰巧的是,我们需要 8 位来表示一个字节所有可能的值。**
**因为一个字节是一个数字,你可以用它做所有的事情,我们之前已经讨论过,关于数字表达式。然而,一个字节也是八位的集合,并且有一些特殊的操作可以在位级上进行(见表 5-7 )。注意,Short
、Int
和Long
值对应 2、4 和 8 个字节,因此对应 8、16 和 32 位。因此,位级操作不仅可以在字节上执行,还可以在其他整数类型上执行。
表 5-7
位表达式元素
|标志
|
意义
|
例子
|
| --- | --- | --- |
| a and b
| 位级上的 ANDa
的每一位都与b
的相应位配对,如果两者都是1
,那么结果号中的位也将被设置为1
| 13 and 11
(评估为 9: 00001101
和00001011
→ 00001001
) |
| a or b
| 位级别上的或;a
的每个位与b
的相应位配对,如果其中一个或两个都是1
,结果号中的位也将被设置1
| 13 or 11
(评估为 15: 00001101
或00001011
→ 00001111
) |
| a xor b
| 比特级的异或运算;a
的每一位都与b
的相应位配对,如果其中恰好有一位是1
,那么结果号中的位也将被置位1
| 13 xor 11
(计算结果为 6: 00001101
异或00001011
→ 00000110
) |
| inv a
| 将某个数字a
的所有位从 0 切换到 1,反之亦然 | inv 13
(计算结果为 114: inv 00001101
→ 11110010
= 114) |
| a shl b
| 将所有位从a
向左移动b
位 | 13 shl 2
(评估为 52:00001101
→ 00110100
= 52) |
| a ushr b
| 将所有位从a
向右移动b
位位置;这个名字是无符号右移的缩写,意味着最左边的位没有得到特殊处理 | 13 shr 2
(评估为 3: 00001101
→ 00000011
= 3) |
| a shr b
| 将所有位从a
向右移动b
位位置;如果最左边的位被设置为1
,则每次移位后最左边的位也被设置为1
| -7 shr 2
(计算结果为-2: 11111001
→ 11111110
= -2) |
注意,有符号右移操作的shr
运算符指的是位表示中的负数。这样的负数是这样建立的:确保负数的位和它的算术倒数的位相加在一起正好导致溢出。将—3
表示为一个字节就产生了11111101
,因为这个加上00000011
(代表+3
)就产生了100000000
。一个字节的最后一个九位数导致溢出,最高的第九位丢失,导致零。这最终也给了我们所需的二进制表示形式的+3 +
-3 = 0
。
其他操作员
Kotlin 还有一些我们可以在表达式中使用的操作符。它们不适合区分数字、布尔、字符串和字符以及位表达式,所以我们在表 5-8 中单独列出它们。
表 5-8
其他表达元素
|标志
|
意义
|
例子
|
| --- | --- | --- |
| a in b
| 检查某个a
是否包含在b
中,b
可能是数组,也可能是集合;一般来说,in
操作符适用于任何定义了operator fun contains(other:SomeClass): Boolean
函数的对象,甚至是你自己的类 | class B``class A { operator fun``contains(other:B):Boolean``{ ... } }``val b = B()``val a = A()``val contained = b in a
|
| a !in b
| a in b
的反面;如果为a
的类定义了operator fun contains(other:SomeClass): Boolean
也有效 | 参见a in b;
添加val notContained = b !in a
|
| ::
| 如果像ClassName::class
一样使用,它创建一个对类的引用;如果像ClassName::funName
或ClassName::propertyName
一样使用,它会创建一个对函数或属性的引用 | val c = String::class``val f = String::length
|
| a .. b
| 创建从一个整数(文字、Byte
、Short
、Int
、Long
或Char
) a
到另一个整数b
的范围 | 1..100
|
| a ?: b
| Elvis
操作员;如果a
不是null
,取;否则采取b
| var s:String? = ...``var ss = s?:"default"``(
如果s
是null
,取“默认”代替) |
| a ?. b
或者a ?. b()
| 安全解引用或安全调用运算符;对于某个对象a
,仅当a
不是null
时,从函数b()
调用中检索属性b
或结果(可以有参数);否则评估为null
本身 | var i:Int? = ...``var ss:String? =``i?.toString()
|
| a!!
| 确保a
不是null
;否则会引发异常 | var s:String? = ...``var ss = s!!.toString()
|
表达式末尾的!! operator
不仅检查它不是null
,还将其转换为不可空的类型:
val c:Int? = ... // an int or null
val b = c!! // b is non-nullable!
// the same: val b:Int = c!!
更好的是,Kotlin 记得我们检查了c
不是null
,并且对于函数的其余部分,将c
视为不可空的属性。
警告
即使!!
似乎是一个简化编码的通用工具,你也不应该经常使用它。操作符在某种程度上阻碍了 Kotlin 处理可空性的方式。!!
打破了不可为空性,并通过区分可为空和不可为空的类型和表达式隐藏了我们的优势。
练习 2
创建一个允许通过函数add(s:String)
连接字符串的类Concatenator
。添加另一个函数,这样就可以编写下面的代码来查看连接的字符串是否包含子字符串。
val c = Concatenator()
c.add("Hello")
c.add(" ")
c.add("world")
val contained = "ello" in c
转换策略
如果你有一个val
或者var
属性或者某种类型的函数参数,问题是如果在赋值中我们提供一个不同类型的表达式会发生什么。如果这种类型不匹配很严重,例如,如果我们需要一个Int
号,而提供了一个String
,编译器将会失败,我们需要修复它。在其他情况下,例如,如果我们实际上需要一个Long
,就提供一个Int
,类型之间的简单转换会很好。
Kotlin 通过提供几个可用于手动执行类型转换的函数来帮助我们。在下面的列表中,我们研究了类型不匹配时的选项。
-
需要一个
Int
。-
Byte
、Short
、Int
、Long
:所有这些都提供了一个到Int()
的函数,执行直接转换。 -
Char
:有一个toInt()
函数,给出字符在字符表中的索引。 -
Float
、Double
:提供一个toInt()
函数,对于正数,返回给定浮点数下面最接近的Int
。对于负数,返回给定浮点数上面最接近的Int
。此外,它们还有一个roundToInt()
功能,提供向上舍入到下一个整数的功能。 -
String
:提供一个toInt()
函数,解析给定的字符串,并试图将其转换成一个Int
。如果提供的字符串不包含整数,这将失败,因为只允许使用可选符号和 0 到 9 的密码。此外,还有一个toIntOrNull
函数处理相同的转换,但不会失败,如果转换不可能,它将返回null
。变体toInt(radix:Int)
和toIntOrNull(radix:Int)
使用不同的计数系统(基数)进行转换。例如,对于十六进制基数(使用16
作为radix
参数),允许使用密码 0 到 9 和字母 A 到 F。 -
Boolean
:从布尔值到整数的转换是不可能的。
-
-
需要一个
Long
、Byte
或Short
。所有类型
Byte
、Short
、Int
、Long
、Char
、Float
、Double
和String
都提供了toLong()
、toByte()
和toShort()
功能,这些功能遵循与Int
目标类型相同的规则,除了适用不同的数字范围。请注意,对于字符串,长文本不允许使用 L 后缀。 -
需要一个充电器。
所有整数类型
Byte
、Short
、Int
和Long
都提供了一个toChar()
函数,该函数使用所提供的数字在字符表中执行索引查找。AChar.toChar()
原封不动地返回参数。类型Float
和Double
提供了一个toChar()
函数,该函数首先应用一个toInt()
,然后执行字符表查找。字符串不提供到Char
的转换,但是您可以使用toCharArray()
和索引操作符[]
来访问数组元素(例如,"123".toCharArray()[0]
给出‘1’)。 -
需要一个
Double
或一个Float
。-
Byte
、Short
、Int
、Long
:这些都提供了toFloat()
和toDouble()
功能,执行明显的转换。 -
Char
:字符也有toFloat()
和toDouble()
函数,但是它们返回字符表中转换成浮点数的索引。 -
Float
、Double
:这些提供toFloat()
和toDouble()
功能,必要时执行精度转换。 -
String
:它有toFloat()
和toDouble()
函数,这些函数试图解析提供的字符串,将其转换成Float
或Double
。String
可以使用英文格式浮点数表示或科学记数法;比如27.48
、-3.0
、1.8e4
。如果转换不可能,此过程将失败。变量toDoubleOrNull()
和toFloatOrNull()
将尝试相同的转换,但如果出现转换错误,则返回null
。 -
Boolean
:从布尔值到浮点数的转换是不可能的。
-
-
需要一个
String
。Kotlin 中的任何对象都提供了一个
toString()
转换,将它翻译成人类可读的表示。对于包含字符的整数,转换很明显;对于浮点数,将选择英语格式;布尔值被翻译成true
或false
。类型Byte
、Short
、Int,
和Long
也有一个toString(radix:Int)
功能,使用提供的编号系统(基数)进行转换。
应用了几个自动转换,所以有可能写val l:Long = 7
,这看起来像是自动的Int
到Long
的转换。
注意
根据编码过程中的经验,您可以测试自动转换是否可行,但在大多数情况下,最好显式声明转换。
在运算符起作用的表达式中,适用另一种转换规则。对于任何运营商
a
**
*其中a
是类型AType
而b
是类型BType
,操作符实现决定了操作结果的类型。一个重要的案例是
[Number] ° [Number]
其中[Number]
选自Byte
、Short
、Int
、Long
、Float,
或Double
,运算符为任意数值运算符(+ - / * %
)。这里,表达式返回的类型在大多数情况下是具有更高精度的类型。精度排名是Byte
<Short
<Int
<Long
<Float
<Double
。例如:
7 + 10_000_000_000L -> Long
34 + 46.7 -> Double
在 Kotlin 程序中,另一种由操作符引起的转换是
String + [Any]
在这里,字符串和[Any]
上的.toString()
的结果将发生连接。例如:
"Number is " + 7.3 -> "Number is 7.3"
"Number is " + 7.3.toString() -> "Number is 7.3"
"Hell" + 'o' -> "Hello"
```*****
# 六、Kotlin 文件中的注释
计算机语言文件中的注释是不属于计算机语言本身的文本,因此对程序执行没有影响,但提供了程序中使用的元素和结构的文本描述。注释有助于读者理解你的程序。
从技术角度来看,注释很容易生成,并与程序语法本身相区别。
* 以双斜线`//`(不在字符串中)开始到行尾的所有内容都是注释。
* 所有以`/*`开始并以`*/`结束的内容(都不在字符串中)都是注释,不管它跨越了多少行。
乍一看,注释似乎是程序中一个不错的特性,添加或省略注释似乎是每个开发人员的个人决定。不过,评论还有更多的内容。仔细看看这件事,评论是在两个界限之间的范围内处理的:
* 完全不写注释:对于短程序和那些结构非常好、不言自明的程序来说,完全不写注释是一种有效的、尽管有争议的立场。这种方法的优点是显而易见的:您必须编写更少的代码,没有混淆注释和源代码的危险,并且正确地遵循这种方法将会产生高质量的综合代码。但是也有缺点:您可能错误地评估了您的代码是否是自解释的,依赖于注释的工具不提供输出,或者您公司的质量保证指南可能被违反。
* *冗长的注释* *:* 另一方面,如果你冗长地注释你程序的每一个部分,你将不得不写很多,并且你可能会忽略代码质量,因为程序中模糊或混乱的结构被注释澄清了。
最佳方法介于这些限制之间。作为一个经验法则,你应该为类、接口和单例对象写注释,解释它们的好处,并且你应该在它们中注释公共函数,包括它们的参数描述。
### 注意
我欠你一个坦白。前几章的`NumberGuess`游戏应用在我提供的来源中没有包含任何评论。为了保持列表较小,注释被省略了,这些列表周围的浮动文本充当了读者的替代品。在你读完这一章之后,你可以随意修改这个问题,并给那里的类、接口和单例对象添加适当的注释。
在这一章中,我们将介绍如何将注释添加到 Kotlin 文件中,包括如何使用它们。
## 包注释
我们了解到包与文件相对应,它们的目的和功能有很强的凝聚力。从技术角度来看,每个包也对应于操作系统文件层次结构中的一个目录。
通过适当的注释来描述包是有意义的,我们在 Kotlin 中这样做的方式如下:对于每个包,也就是说在每个文件夹中,创建一个文件`package-info.md`。要在 Android Studio 中实现这一点,你必须在项目浏览器中切换到项目文件视图类型(参见图 6-1 )。单击 Android 旁边的灰色向下小矩形来切换视图类型。然后,您可以右键单击其中一个包,并从快捷菜单中选择“新建➤文件”。输入完整的文件名`package-info.md`。
后缀为`.md`的文件是*降价*文件。Markdown 是一种类似于 HTML 的样式语言,但是有自己简化的语法。我们将很快描述 Markdown,但首先我们必须教会 Android Studio 如何处理 Markdown 文件。为此,双击其中一个新的`package-info.md`文件。工作室在其标准文本编辑器中打开该文件,但它在编辑窗格的顶部显示一条警告消息,如图 6-2 所示。
![img/476388_1_En_6_Fig2_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-android-zh/raw/master/docs/learn-kt-andr-dev/img/476388_1_En_6_Fig2_HTML.jpg)
图 6-2。
Android Studio 试图打开一个降价文件
![img/476388_1_En_6_Fig1_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-android-zh/raw/master/docs/learn-kt-andr-dev/img/476388_1_En_6_Fig1_HTML.jpg)
图 6-1。
项目文件视图
单击安装插件链接。在随后的屏幕上,接受任何许可声明,如果询问,选择使用 JetBrains 的 markdown 支持。
在每个`package-info.md`文件中,让第一行读作
```kt
# Package full.name.of.the.package
在这里,您用每个包的名称来代替full.name.of.the.package
。以单个#
开头的行实际上代表 1 级标题。
文件的其余部分包含 Markdown 样式的文本。例如,包kotlinforandroid.book.numberguess.random
中的package-info.md
文件可以读为
# Package kotlinforandroid.book.numberguess.random
This package contains *interfaces* and *classes* for generating random numbers.
In your code you will write something like this:
val rnd:RandomNumberGenerator = [ one of the 'impl' classes instantiated ]
For example,
val rnd:RandomNumberGenerator = StdRandom()
// or
val rnd:RandomNumberGenerator = RandomRandom()
这些package-info.md
文件和我们将在这里讨论的所有其他文档结构可以用来为您的项目生成文档。在这个过程中,*interface*
将被翻译成强调的文本,行首有四个空格的段落将应用代码样式格式。反引号(')内的文本将被标记为内联代码。例如,这个特定的降价文件将被翻译成如图 6-3 所示的文档。下一节将描述这些以及所有其他标准的降价语法元素。
图 6-3。
翻译后的降价代码
减价
用于包描述的 Markdown 文件和 Kotlin 代码文件中的内联文档都使用通用的语法来处理样式问题。这些降价语法元素在表 6-1 中描述。
表 6-1。
降价语法
|风格
|
降价语法
|
暗示
|
| --- | --- | --- |
| 标题级别 1 | # Heading
| package-info.md
文件不得包含一个以上的一级标题。您可以在标题行的末尾添加一个#。 |
| 标题级别 2–6 | ## Heading``### Heading
… | #的数量决定了等级。您可以通过在标题末尾追加相同数量的#来提高可读性。 |
| 无序列表 | - Item1``- Item2
… | 您也可以使用+或作为项目指示器。 |
| 有序列表 | 1\. Item1``2\. Item2
… | 连续编号将被自动确保,所以你可以写任何数字(写总是“1”或者别的什么)。 |
| 强调 | *some text*
或者_some text_
| 如果文本中需要星号()或下划线(),请写\*
或\_.
|
| 强烈强调 | **some text**
或者_ _some text_ _
| 如果文本中需要星号(*)或下划线(),请写\*
或\_.
|
| 批量报价 | > some text
| 您可以通过在行首使用更多>字符来提高级别。块引号可以包含其他降价元素。 |
| 段落分隔符 | | 某些文本末尾的换行符不会结束一个段落。 |
| 环 | 见下文 | — |
| 内嵌代码 | 'some text'
(反勾号) | 如果你在文本中需要一个反勾号('),写\'.
|
| 分组码 | t 0t 0… | 这必须用空行包围。⊔是一个空格字符(您也可以使用一个制表符代替)。 |
| 规则 | - - -``* * *
| 您也可以使用更多的这些字符,并使用空格字符作为分隔符。 |
| 逃脱 | 前置一个"\
" | 使用它来避免角色做一些特殊的事情,如表中前面所述。合格字符为\ * _ [ ] ( ) # + - . ! '
|
插入链接有几种选择。首先,您可以创建一个内联链接,如下所示:
link text
or
link text
如果文档被转换成 HTML,那么可选的"Title"
将进入title
属性。例如,一旦鼠标悬停在链接上,就会向用户显示title
属性(这种行为取决于所使用的浏览器)。以下是这种内联链接的一个示例:
Find the link here:
[Link](http://www.example.com/somePage.html "Page")
引用链接使用引用 ID,这样你就可以在一个文本中多次引用同一个链接。语法是
[link text][link ID]
其中link ID
可以包含字母、空格、数字和标点符号,但不区分大小写。文本中的其他地方需要提供链接定义本身,单独占一行:
[link ID]: link-URL
or
[link ID]: link-URL "Title"
对于长 URL 或长标题,可选的"Title"
也可以放在下一行。请注意,链接定义不产生任何输出,它们只是使 Markdown 文件中的文本更容易阅读。
作为一个缩写,链接文本可以作为文本和 ID,如果你写
[link text][]
对于这个定义
[link text]: link-URL
or
[link text]: link-URL "Title"
如果你不需要链接文本,只是想告诉网址,你应该把链接转换成自动链接,用尖括号把它们括起来,如< http://www.apress.com
>。然后 URL 按原样打印出来,但也可以点击。
作为上述链接的扩展,您可以引用类、属性和方法,就像它们是隐式链接一样:
[com.example.TheClass]
[com.example.TheClass.property]
[com.example.TheClass.method]
对于接口和单例对象,也可以用同样的方法。如果您想提供自己的链接文本,请这样写:
[link text][com.example.TheClass]
[link text][com.example.TheClass.property]
[link text][com.example.TheClass.method]
如果被记录的元素可能通过它们的简单名称寻址类、接口或单例对象,因为它们已经被导入,那么可以省略包说明符,你可以直接写[TheClass]
、[TheClass.property]
和[TheClass.method].
班级评论
我们知道多行注释可以写成/* ... */
。作为一个小小的修改,为了记录代码元素,惯例是在左边的注释括号中添加另一个星号(*
):/** ... */
,此外,注释中的每一行都应该以星号开头,如下所示:
/**
*The comment ...
* ...
*/
这仍然是一个碰巧以星号开始的多行注释,但是知道如何从代码中提取文档的工具认为这是需要处理的事情。你仍然可以随意使用普通的多行注释/* ... */
,但是文档工具会忽略它们。
类注释就写在class ...
声明的前面,这样一个改编的多行注释/** ... */
。如前所述,类描述注释的内容是 Markdown 代码。
此类文档的第一段应该提供一个简短的摘要,因为工具可能会使用它来列出清单。除了标准降价元素之外,您还可以在文档中添加如下元素:
-
@param <name> description
:描述类的类型参数<name>
。类类型参数将在本书的后面描述。你也可以写@param[name] description
。 -
@constructor description
:描述类的主构造函数。 -
@property <name> description
:描述主构造函数的一个参数。 -
@sample <specifier>
:插入指定功能的代码。 -
@see <specifier>
:添加一个链接到指定的标识符(类、接口、单例对象、属性或方法)。 -
@author description
:添加创作信息。 -
@since description
:添加关于文档化元素已经存在多长时间的信息(版本信息等)。). -
@suppress
:从文档中排除类、接口或单例对象。
NumberGuess
的MainActivity
类的文档示例
游戏是这样的:
/**
* The main activity class of the NumberGuess game app.
* Extends from the
* [android.support.v7.app.AppCompatActivity]
* class and is thus compatible with earlier
* Android versions.
*
* The app shows a GUI with the following buttons:
* - **Start**: Starts the game
* - **Do Guess**: Used for guessing a number
*
* Once started, the game secretly determines a random
* number the user has to guess. The user performs
* guesses and gets told if the guessed number is too
* low, too high, or a hit.
*
* Once hit, the game is over.
*
* @see Constants
*
* @author Peter Späth
* @since 1.0
*/
class MainActivity : AppCompatActivity() {
...
}
相应的输出,一旦被文档工具转换,将如图 6-4 所示。
图 6-4。
数字猜测活动的文档
函数和属性注释
对于函数和属性,您基本上可以像对待类一样进行操作。只要在任何你想注释的函数或者属性前面加上/** ... */
就可以了。对于类文档,您可以用任意数量的空格和星号开始每一行。再次使用降价代码。例如:
...
class SomeClass {
/**
* This describes property prop
* ...
*/
val prop:Int = 7
/**
* This describes function func
* ...
*/
fun func() {
...
}
}
至于类、接口和单例对象,这类文档的第一段应该提供一个简短的摘要,因为工具可能会用它来列出清单。
对于属性,您可以使用几个附加元素:
-
@sample <specifier>
:插入指定功能的代码。 -
@see <specifier>
:添加一个链接到指定的标识符(类、接口、单例对象、属性或方法)。 -
@author description
:添加创作信息。 -
@since description
:添加关于文档化元素已经存在多长时间的信息(版本信息等)。). -
@suppress
:从文件中排除遗产。
函数文档片段还应该描述函数的参数和返回值。具体来说,这里是函数的所有文档元素。
-
@param <name> description
:描述一个函数参数。 -
@return description
:描述函数返回的内容。 -
@receiver description
:描述扩展功能的接收方。 -
@throws <specifier>
:表示函数可能抛出由说明符指定的异常。我们将在本书的后面讨论异常。 -
@exception <specifier>
:同@throws
。 -
@sample <specifier>
:插入指定功能的代码。 -
@see <specifier>
:添加一个链接到指定的标识符(类、接口、单例对象、属性或方法)。 -
@author description
:添加创作信息。 -
@since description
:添加关于文档化元素已经存在多长时间的信息(版本信息等)。). -
@suppress
:从文件中排除遗产。
练习 1
给NumberGuess
游戏 app 的所有包、类、公共函数添加评论。
生成您自己的 API 文档
当一个程序的所有元素都被恰当地文档化后,我们现在需要找到一种方法来提取文档,以便创建,例如,一个相互链接的 HTML 文档的集合。生成的文档应该描述所有的类、接口和单例对象,以及所有的公共方法和属性。因为这些元素足以让客户端软件知道如何与你的程序交互,所以这样的文档通常被称为应用编程接口(API)文档 。
Dokka 是 Kotlin 可以用来创建这种 API 文档的工具。要安装 Dokka,请打开 Android Studio。在 Gradle Scripts 抽屉里(你可能需要切换回 Android 视图类型),有两个名为build.gradle
的文件,一个标记为 Project NumberGuess,一个标记为 Module: app(见图 6-5 )。这两个构建文件负责描述如何构建应用以正确运行。这包括声明需要对您的程序可用的库。
图 6-5。
构建脚本
注意
术语库通常指其他人构建的程序,你的应用使用其中的部分来执行某些任务。您经常会将库添加到您的项目中,这样您就可以从其他人提供给公众的工作中受益。
打开项目build.gradle
,在buildscript {
下添加下面一行:
ext.dokka_version = '0.9.17'
在同一个文件中,在dependencies
块内还添加(一行):
classpath "org.jetbrains.dokka:
dokka-android-gradle-plugin:${dokka_version}"
这确保了 Dokka 库被添加到项目中。
现在打开 Moduĺe build.gradle
,在所有其他应用插件行的下面,添加
apply plugin: 'org.jetbrains.dokka-android'
在同一个文件中,在底部添加以下内容:
task myDokka(type: org.jetbrains.dokka.gradle.
DokkaAndroidTask) {
outputFormat = 'html'
outputDirectory = "dokka"
includes = ['packages.md']
doFirst {
// build the packages.md file
def pckg = new File(projectDir.absolutePath +
File.separator + "packages.md")
pckg.text = ""
def s = ""
projectDir.eachFileRecurse(
groovy.io.FileType.FILES) { f ->
if(f.name == 'package-info.md') {
s += "\n" + f.text
}
}
pckg.text = s
}
}
这将配置 Dokka 并增加一个准备步骤。
注意
默认情况下,Dokka 不知道如何处理我们的package-info.md
文件。相反,它期望一个单独的文件packages.md
。准备步骤收集所有的package- info.md
文件并建立一个packages.md
文件。顺便说一下,这个小脚本是用 Groovy 编写的,这是 Gradle build 系统所依赖的语言。
现在实际执行文档生成,打开窗口最右边的 Gradle 选项卡,然后导航到 NumberGuess: ➤ NumberGuess ➤任务➤文档。双击 myDokka(参见图 6-6 )。
图 6-6。
Dokka 构建任务
现在,您会发现 API 文档是文件夹dokka
中相互链接的 HTML 文件的集合(切换到 Project Files 视图类型,在 Android Studio 中查看)。
七、结构性构造
从计算机语言一开始,程序流的条件分支就是程序代码必须能够表达的最基本的东西之一。这种分支发生在函数内部,因此它在类和单例对象内部强加了某种子结构。在这一章中,我们将介绍这样的分支结构,以及帮助我们编写相应代码的辅助类。
如果和何时
在现实生活中,许多行为都是基于决策的。如果满足某些条件,动作 A 发生;否则,会发生动作 B。对于任何编程语言,我们都需要类似的东西,而创建这种程序流分支的最基本方法是古老的 if–else if–else 结构。你检查某个条件,如果满足,if 分支被执行。如果不是,可以选择检查另一个 else if 条件,如果满足这个条件,就执行相应的分支。在可能更多的 else if 子句之后,如果 if 和 else if 检查都没有产生true
,则执行最后一个 else 块。
当然,在 Kotlin 中,我们有这样一个 if-else if-else 程序结构,它是这样的
if( [condition] ) {
[statements1]
} else if( [condition2] ) {
[statements2]
} else if( [condition3] ) {
[statements3]
... more "else ifs"
} else {
[statementsElse]
}
其中所有 else if 和 else 子句都是可选的,并且每个条件的计算结果都必须是布尔值。如何计算这个值取决于你:它可以是一个常数,一个变量,或一个复杂的表达式。作为一个例子,考虑检查某个变量v
是否等于某个特定的常数,如果是,调用某个函数abc1()
。如果没有,调用函数abc2()
代替。代码如下:
if( v == 7 ) {
abc1()
} else {
abc2()
}
如果块只包含一条语句,可以省略花括号甚至换行符,所以
if( v == 7) abc1() else abc2()
一行是有效代码。
作为一种特性,类似于 Kotlin 中的大多数其他构造,这样的条件构造可以有一个值,因此可以在表达式中使用。为此,所有语句块的最后一行必须计算相应的数据。
val x = if( [condition] ) {
[statements1]
[value1]
} else if( [condition2] ) {
[statements2]
[value2]
} else if( [condition3] ) {
[statements3]
... more "else ifs"
} else {
[statementsElse]
[valueElse]
}
这一次 else 子句不是可选的;否则,如果没有 else 值,则完整构造的结果是未定义的。不用说,块末尾的所有值都必须具有相同的期望类型,这个结构才能工作。
类似于非表达式变量,如果块中没有语句,可以省略括号和换行符,因此这是一个有效的语句:
val x = if( a > 3 ) 27 else 28
带有大量 else if 子句的大型条件分支结构相当笨拙。这就是为什么有另一个更简洁的结构,其内容如下:
when( [expression] ) {
val1 -> { ... }
val2 -> { ... }
...
else -> { ... }
}
当[expression]
在->
前面给出值时,分支{
… }
被执行。这个也能评估出一个值:
val x = when( [expression] ) {
val1 -> { ... }
val2 -> { ... }
...
else -> { ... }
}
其中每个{
… }
中的最后一个元素将被用作在相应的检查匹配时返回的值。
为了避免代码块的重复,您还可以定义评估组,如
when( [expression] ) {
val1 -> { ... }
val2,val3 -> { ... }
...
else -> { ... }
}
这也适用于价值产出型。
对于->
左侧的值,您可以使用任意表达式,包括函数调用:
val x = when( [expression] ) {
calc(val1) + 7 -> { ... }
val2,val3 -> { ... }
...
else -> { ... }
}
此外,我们可以使用一个特殊的in
操作符或者它的反操作符!in
来进行包含检查:
val l = listOf(...)
val x = when( [expression] ) {
in l -> { ... }
in 27..53 -> { ... }
!in 100..110 -> { ... }
...
else -> { ... }
}
这也适用于数组。27..53
和100..110
定义了范围,表示它们代表了给定的极限值和之间的所有值。我们将在下一节更详细地讨论范围。
另一个方便的检查是一个特殊的is
操作符,它执行类型检查:
val q:Any = ... // any type
val x = when(q) {
is Int -> { ... }
is String -> { ... }
...
else -> { ... }
}
还有一个is
的否定变体:不出意外,读起来是!is
。
同样,对于单行代码块,可以省略括号,如下所示:
val q = ... // some Int
val x = when( q ){ 1 -> "Jean" 2 -> "Sam" else -> "" }
如果您需要来自内部when(
… )
的[expression]
用于内部程序块的评估,可以捕获它:
val x = when(val q = [some value]) {
1 -> q * 3
2 -> q * 4
...
else -> 0
}
其中捕获变量仅在when
块内有效。
范围
范围经常用于循环需要。我们将在下一节讨论循环,所以请将这一节视为准备步骤。范围由两个界限值和两个界限值之间的插值方式定义。
在 Kotlin 中,有三种类型的范围用于Int
、Long
和Char
类型。使用构造函数,可以按如下方式构建它们:
val r1 = IntRange(1, 1000)
val r2 = LongRange(1, 10_000_000_000)
val r3 = CharRange('A', 'I')
此外,为了达到同样的目的,您可以使用范围运算符..
,如下所示:
val r1 = 1..1000
val r2 = 1L..10_000_000_000L
val r3 = 'A'..'I'
最后,一些 Kotlin 标准库函数返回范围或作用于范围。任何整数类型(即Byte
、Short
、Int
、Long
和Char)
)都有一个rangeTo()
函数来创建一个范围。因此,也可以通过编写7.rangeTo(77)
来构建7..77
。
范围还有一个step
属性,它定义了如何在范围边界之间插值。默认情况下,步长为+1
,但您可以按如下方式进行调整:
1..1000 step 5
(1..1000 step 5).reversed()
其中最后一行的reversed()
交换边界并否定该步骤。请注意,根据语言设计,不允许显式指定负步长。然而,允许使用downTo
操作符:
1000 downTo 1 step 5
如果使用first
或last
属性,范围表示第一个和最后一个值:
(1..1000 step 5).first // -> 1
(1..1000 step 5).last // -> 996
(1000 downTo 1 step 5).first // -> 1000
(1000 downTo 1 step 5).last // -> 5
For 和 While 循环
循环对应于反复迭代多次的程序部分。这种循环的一种可能是for
循环,如下所示:
for( i in [loop data] ) {
// do something with i
}
其中[loop data]
是一个范围、一个集合、一个数组或任何其他具有函数iterator()
的对象,返回一个具有next():E
和hasNext():Boolean
函数的对象(E
是循环变量类型)。在后一种情况下,所有三个功能iterator()
、next()
和hasNext()
必须标有operator
。
与for
循环类似的还有while
和do .. while
循环,它们会继续循环,直到某个条件产生false
:
while( [condition] ) {
// do something
}
do {
// do something
} while( [condition] )
其中,在第一种情况下,在最开始时检查条件,在第二种情况下,在任何迭代(包括第一次)结束时检查条件。
for
和while
循环都可以通过在内部程序流中使用break
来优先退出。同样,在循环中的任何地方使用continue
语句都会强制进行下一次迭代,忽略continue
后面的任何内容:
while( [condition] ) {
...
break // -> exit loop
...
continue // -> next iteration
...
}
或者类似地用于for
和do .. while
循环。
注意
For
和while
循环现在被认为是非常老派的。在集合上使用forEach()
可以更好地控制循环准备动作,比如转换和过滤,所以比起for
和while
,更喜欢使用forEach()
。在后面的章节中,我们会谈到很多关于集合和集合数据的迭代。
范围函数
当涉及到代码的表现力时,Kotlin 的几个标准库函数非常强大。其中的五个apply
、let
、also
、run
和with
被称为作用域函数,因为它们在函数内部打开了一个新的作用域,从而改善了程序流的结构。让我们看看他们做了什么,以及他们如何帮助我们写出更好的代码。
注意
顺便说一句,如果你需要一个助记符来记住它们,读读“让我们也用 APPLY 运行”
应用功能
让我们看看这些作用域函数中的第一个,apply
。你可以把它挂在任何物体上,比如
object.apply {
...
}
这看起来并不太冒险,但是神奇的是在apply
的花括号内的对象实例发生了什么:它被传输到this
。另外,apply
自动返回对象实例。因此,如果我们写this.someProperty
或someProperty
,或this.someFunction()
别名someFunction()
,它指的是apply
前面的object
,而不是周围的上下文。这是什么意思?好吧,想想这个:
class A { var x:Int, var y:Int }
val instance = A()
instance.x = 4
instance.y = 5
instance.y *= instance.x
如果我们现在将.apply{
… }
写在已初始化的对象后面,我们可以使用this
来访问实例并获得
class A { var x:Int, var y:Int }
val instance = A().apply{
this.x = 4
this.y = 5
this.y *= this.x
}
其可以进一步缩短,因为this.
可以省略:
class A { var x:Int, var y:Int }
val instance = A().apply{
x = 4
y = 5
y *= x
}
注意
因为propertyName
和functionName()
针对的是this
实例,所以我们也可以说this
代表了这种简单属性和函数访问的接收者。没有作用域函数,this
指的是周围的类实例或单例对象。随着this
在apply{ ... }
中被重新定义,.apply
前面的实例成为新的接收者。
如果在apply{}
构造中使用的属性或函数标识符在 receiver 对象中不存在,则使用周围的上下文:
var q = 37
class A { var x:Int, var y:Int }
val instance = A().apply {
x = 4
y = 5
q = 44 // does not exist in A, so the q from
// outside gets used
}
apply{
… }
被操作的对象与同一对象接收的花括号内的this
作用域函数和属性之间的这种强耦合,使得apply{}
构造成为在对象实例化后立即准备对象的极好候选:
val x = SomeClass().apply {
// do things with the SomeClass instance
// while assigning it to x
}
来自周围上下文(类或单例对象)的this
不会丢失。如果您在apply{}
中需要它,您可以通过添加一个限定符@Class
来获得它,如
class A {
fun goA() { ... }
...
val x = SomeClass().apply {
this.x = ... // -> SomeClass.x
x = ... // -> SomeClass.x
this@A.goA() // -> A.goA()
...
}
}
字母功能
let
作用域函数经常被用来将一个对象转换成一个不同的对象。它的完整概要是这样的:
object.let { o ->
[statements] // do s.th. with 'o'
[value]
}
最后一行必须包含let{}
应该返回的表达式。let{}
构造有一个函数作为参数,如果你像这里这样写它,并使用一个匿名的 lambda 函数和o
作为参数,这个参数函数获得对象本身作为参数。您也可以省略o ->
,在这种情况下,会自动使用一个特殊变量it
:
object.let {
[statements] // do s.th. with 'it'
[value]
}
注意
在花括号内写没有x ->
的let { }
,看起来好像{ }是一个功能块。这是一个句法上的巧合;实际上,它是一个匿名的 lambda 函数,以自动变量it
为参数。
以其他函数为参数的函数称为高阶函数。我们将在第十二章中讲述高阶函数。
举个简单的例子,我们取一个字符串,用let{}
给它附加一个换行符"\n"
:
val s = "Hello World"
val s2 = s.let { it + "\n" }
// or s.let { string -> string + "\n" }
with 函数
with
作用域函数是apply{
… }
的兄弟。不同之处在于,它只是获取要转换为接收方的对象或值作为参数:
val o = ... // some value
with(o){
// o is now "this"
...
}
with
函数经常用于避免重复编写要操作的对象,如
with(object){ f1(37)
f1(12)
fx("Hello")
}
代替
object.f1(37)
object.f1(12)
object.fx("Hello")
“也”函数
also
作用域函数与apply{
… }
函数相关,但不重新定义this
。相反,它将also
前面的对象或值作为参数提供给 lambda 函数参数:
object.also { obj ->
// 'obj' is object
...
}
或者
object.also {
// 'it' is object
...
}
您将also{ }
用于横切关注点,这意味着您不改变对象(这就是apply{
… }
的目的),但是执行与当前程序流无关的动作。执行缓存、日志记录、身份验证或在某个注册表对象中注册对象都是合适的例子。
运行功能
run
作用域函数类似于apply{
… }
函数。但是,它不返回 receiver 对象,而是返回最后一条语句的值:
val s = "Hello"
val x = s.run {
// 'this' is 's'
...
[value]
}
// x now has [value]
你可以把run{
… }
看做一个通用的“用一个物体做点什么”的括号。不过,一个突出的用例是,只在对象不为空时才处理它。代替
var v:String? = ...
...
if(v != null) {
...
}
你可以写作
var v:String? = ...
...
v?.run {
...
}
记住,只有当前面的对象不是null
时,?.
才会访问一个属性或调用一个函数。在某些情况下,更简洁的后一种变体可能更具可读性。
条件执行
允许我们将条件分支编写为实例函数的结构如下所示:
someInstance.takeIf { [boolean_expression] }?.run {
// do something
}
在布尔表达式中,您可以使用it
来引用someInstance
。如果布尔表达式的计算结果为true
,则takeIf()
函数返回接收者(这里是someInstance
);否则返回null
。这适用于任何对象。
八、异常:如果出现问题
对于非常简单的程序来说,确保程序的所有部分都准确地做它们应该做的事情可能很容易。对于复杂程度更高的程序,那些由许多开发人员构建的程序,或者那些使用外部程序(库)的程序,情况就不那么清楚了。例如,如果列表或数组的地址越界,对文件或网络数据流的一些 I/O 访问失败,或者对象以意外或损坏的状态结束,就会出现问题。
这就是异常的用途。异常是,如果发生了意想不到的、可能是恶意的事情,对象会被创建或被抛出。然后,特殊的程序部分可以接收这样的异常对象并适当地动作。
Kotlin 和异常
Kotlin 对待异常状态的方式相当自由,但 Android 没有。如果你不关心你的应用中的异常,并且任何程序部分碰巧抛出了异常,Android 会清醒地告诉你应用崩溃了。您可以通过将可疑的程序部分放入 try-catch 块来防止这种情况:
try {
// ... statements
} catch(e:Exception) {
// do something
}
or
try {
// ... statements
} catch(e:Exception) {
// do something...
} finally {
// do this by any means: ...
}
在这两种情况下,该构造都被称为捕获异常。可选的 finally 块在构造结束时被执行,不管异常是否被捕获。你通常用它来清理try { }
中的代码可能造成的混乱,包括关闭任何打开的文件或网络资源以及类似的操作。
注意
根据经验,在代码中使用许多 try-catch 子句很难提高代码质量。别这样。不过,在应用的中心位置放几个这样的图标通常是个好主意。
一旦在try{ }
块中出现异常——这包括来自那里的任何方法调用——程序流立即分支到catch{ }
块。这是一个很难回答的问题,尤其是在 Android 环境下。当你开发一个应用时,写日志条目肯定是一个好主意。这不是 Kotlin 标准库的一部分,但是 Android 提供了一个单例对象android.util.Log
,你可以用它来写日志:
import android.util.Log
...
try {
// ... statements
} catch(e:Exception) {
Log.e("LOG", "Some exception occurred", e)
}
当然,您可以写一些更具体的信息,而不是这里显示的日志文本。
注意
如果你看一下android.util.Log
类,你会发现这是一个 Java 类,函数e()
是一个不需要实例的静态函数。因此,严格来说,它不是一个单例对象,但是从 Kotlin 的角度来看,它就像是一个单例对象。
开发应用时,您可以在 Logcat 选项卡上看到日志语句,前提是您使用的是仿真器或连接的硬件设备,并且打开了调试功能。使用来自Log
类的e()
函数提供了一个优势,你可以得到一个堆栈跟踪,这意味着行号被指出,导致错误程序部分的函数调用被列出。图 8-1 给出了一个例子。
图 8-1。
Android Studio 中的异常记录
对于您的最终用户来说,以这种方式提供日志记录是不可取的,因为在大多数情况下,您的用户不知道如何检查日志文件。您可以做的是以Toast
的形式显示一条简短的错误消息,如下所示:
import android.util.Log
...
try {
// ... statements
} catch(e:Exception) {
Log.e("LOG", "Some exception occurred", e)
Toast.makeText(this,"Error Code 36A",
Toast.LENGTH_LONG).show()
}
当然,您向用户呈现的具体内容取决于异常的严重程度。也许您可以以某种方式清除错误状态,并继续正常的程序流程。在非常严重的情况下,您可以显示一个错误消息对话框或分支到一个错误处理活动。
更多异常类型
到目前为止,我们看到的Exception
类只是一种异常。如果我们在一个catch
语句中使用Exception
,我们正式表达了一种非常一般的异常。根据具体情况,您的应用可能与只使用Exception
的 try-catch 子句共存得很好。然而,你也可以使用Exception
的许多子类。例如,有一个ArrayIndexOutOfBounds
异常,一个IllegalArgumentException
,一个IllegalStateException
,等等。通过添加更多的catch{ }
子句,您甚至可以同时使用多个:
try {
// ... statements
} catch(e:ExceptionType1) {
// do something...
} catch(e:ExceptionType2) {
// do something...
... possibly more catch statements
} finally {
// do this by any means: ...
}
如果在try{ }
中抛出一个异常,那么所有的catch
子句都会被一个接一个地检查,如果其中一个声明的异常匹配,那么相应的catch
子句就会被执行。如果你想捕捉几个异常,你通常做的是把更具体的捕捉放在列表的开始,把最一般的放在最后。例如,假设您有一些访问文件、处理数组的代码,此外还可能抛出未知的异常。你可以在这里写
try {
// ... file access
// ... array access
} catch(e:IOException) {
// do something...
} catch(e:ArrayIndexOutOfBoundsException) {
// do something...
} catch(e:Exception) {
// do something...
} finally {
// do this by any means: ...
}
这里的finally
子句是可选的,和往常一样。
自己抛出异常
从您编写的代码中引发异常
throw exceptionInstance
其中exceptionInstance
是异常类的实例,例如
val exc = Exception("The exception message")
throw exc
或者
throw Exception("The exception message")
因为异常是普通类,除了在catch
子句中的可用性,还可以定义自己的异常。只需扩展Exception
类或它的任何子类:
class MyException(msg:String) : Exception(msg)
...
try {
...
throw MyException("an error occurred")
...
} catch(e:MyException) {
...
}
练习 1
在NumberGuess
游戏 app 中,定义一个新的类GameException
作为Exception
的扩展。检查用户输入的数字,如果超过最小或最大的可猜测数字,抛出一个GameException
。在guess()
函数中捕捉新的异常,并可能显示一条Toast
消息。提示:使用if (num.text.toString().toInt() < Constants.LOWER_BOUND) throw ...
和if (num.text.toString().toInt() > Constants.UPPER_BOUND) throw ...
进行检查。
表达式中的异常
Kotlin 的一个有趣特性是,可以在表达式中使用 try-catch 块和 throw 语句。try-catch 块的结果是try{ }
或catch(...){ }
块中最后一行的值,这取决于异常是否被捕获。例如,如果出现问题,您可以将它用作默认值。在…里
val x = try{ arr[ind] }
catch(e:ArrayIndexOutOfBoundsException) { -1 }
对于一些名为arr
的IntArray
,如果违反了数组边界限制,变量x
将获得默认值1
。
警告
注意不要滥用 try-catch 块来处理一些异常的但却是预期的程序流路径。你真的应该只对意料之外的问题使用异常。
一个throw someException
也有一个值。它属于Nothing
类型,在 Kotlin 类型层次结构中是所有事物的子类。因此有可能写
val v = map[someKey] ?: throw Exception("no such key in the map")
注意算子?:
(有时被称为埃尔维斯算子)只有在左侧产生null
时才对右侧求值;否则它走左边。这意味着如果map[someKey]
的值为null
,相当于地图没有这个键,那么就会抛出异常。
九、数据容器
自然和人类文明都是关于收藏的。家庭收集亲戚,城市收集一起行动的人,房子收集人和他们的财产,数学集合论使用关系公式的集合,星系收集恒星,原子收集基本粒子,等等。因此,毫不奇怪,旨在模拟现实世界场景的计算机语言也必须能够模拟集合。
在现实世界中,这不是一个很大的话题,但是计算机从一开始就在固定大小的集合和可变大小的集合之间画出了明显的区别。固定大小的集合更容易处理并表现出高性能,而可变大小的集合速度较慢,但表现出更大的灵活性,并且根据具体情况可以表现出更低的内存占用。各种与集合相关的任务都需要这两个选项,所以开发人员必须学会如何处理这两种情况。为了使区别更加清晰,固定大小的集合被称为数组,对于可变大小的集合,使用的术语是集合。
Kotlin 的内置库包含几个在数组和集合之间进行协调的函数,为了使开发人员的工作更轻松,Kotlin 还试图统一数组和集合的处理,因此两个世界之间的切换更容易实现。在接下来的部分中,我们首先讨论数组,因为它们最早出现在计算机语言的历史中,后来转到集合。
定义和使用数组
数组是固定大小的元素容器,元素可以是对象或原始数据类型。到目前为止,我们已经知道了很多关于对象的知识,但是我们还没有过多地讨论过原始数据类型。从 Kotlin 的观点来看,最好是完全没有这种区分,只处理对象而不处理其他。
那么为什么会有类似原始数据类型的东西,它们到底是什么,为什么我们需要使用它们?答案是原始数据类型在计算机硬件上有一个直接的表示。因此,在将对象放入数组之前,我们既不需要执行对象的实例化,也不需要使用某种引用将数组元素连接到实例(见图 9-1 )。
图 9-1
原始数组与对象数组
对象数组通过字符串Array
获取 Kotlin 中声明的类型,后跟尖括号中的元素类型:
Array<ElementType>
其中ElementType
可以是任何类或接口。在 Kotlin 中,这些类型被视为普通类型,所以你可以让var
和val
使用它们
val someStringArray:Array<String> = ...
var someObjectArray:Array<Any> = ...
注意
位于 Kotlin 类型层次结构的最顶端,任何对象都隐式地自动继承它。因此,任何对象,不管你如何声明它,自动也是一个Any
的实例。
尖括号声明了一个泛型类型。我们将在本书的后面讨论泛型。
原始元素数组存在于以下元素类型:Boolean
、Byte
、Char
、Double
、Float
、Int
、Long
和Short
。对于相应的数组类型,使用其中任何一种并添加Array
,如
val byteArr:ByteArray = ...
var intArr:IntArray = ...
...
要访问数组元素,使用array[index]
,其中index
的范围从0
到数组长度减一。长度本身由属性size
给出,最后一个索引由属性lastIndex
给出。可以像在val elem1 = array[0]
中一样读取数组元素,像在array[77] = ...
中一样写入数组元素。如果在访问数组元素时,您试图寻址一个超出界限的元素,您的应用将会崩溃,因此您必须采取预防措施,以确保不超过索引限制。
数组实例化
现在我们知道了如何声明数组,我们需要知道如何创建或实例化它们。这是val arr:IntArray = ...
的右边,一模一样。
首先,我们可以使用 Kotlin 为数组提供的构造函数。对于具有对象元素类型的数组和专用的基本元素数组来说都是如此。第一组构造函数允许用指定数量的元素实例化原始元素数组(例如,我们给它们的大小都是 7):
val byteArr:ByteArray = ByteArray(7)
val shortArr:ShortArray = ShortArray(7)
val intArr:IntArray = IntArray(7)
val longArr:LongArray = LongArray(7)
val doubleArr:DoubleArray = DoubleArray(7)
val floatArr:FloatArray = FloatArray(7)
val booleanArr:BooleanArray = BooleanArray(7)
这些数组的元素都用缺省值0
初始化为数字类型,用缺省值false
初始化为布尔数组。
一组不同的构造函数允许我们设置单独的数组成员。您所要做的就是将一个函数作为第二个参数添加到任何一个构造函数中。但是,函数怎么可能是构造函数的参数呢?这是 Kotlin 和其他计算机语言必须提供的特性之一:将函数视为可以在函数调用中传递的对象,甚至让属性指向它们。我们将在第十二章中详细介绍这些方面。我们目前需要的是一个没有函数名的所谓的λ函数。这样的 lambda 函数看起来像这样:
{ i ->
[ program code ]
[ expression ]
}
其中i
是设置为数组内部索引的参数。使用i
只是一个例子;你可以选择一个不同的名字,比如ind
、index
,或者任何你喜欢的名字。最后一行[expression]
必须计算为数组元素声明的类型的值。但是,在最后一个表达式语句之前,你要做什么取决于你自己。因为[program code]
你可以写任何东西,包括使用索引参数。比方说,我们想要创建一个大小为 10 的IntArray
,数组元素包含平方索引,从 1 开始计数:1, 2, 4, 9, 16, ...
。这里我们不需要一个[program code]
,但是[expression]
将会是(i + 1)
∫(i + 1)
(记住i
是一个从0
开始的索引)。lambda 函数然后读取
{ i -> (i+1) * (i+1) }
完整的数组声明和初始化是
val arr = IntArray(10, { i -> (i+1) * (i+1) })
添加了初始化函数后,我们现在还可以使用构造函数来生成对象数组,如
val objArr:Array<SomeType> =
Array<SomeType>(7, { i -> ... })
在这里你用一个现有的类来代替SomeType
。
练习 1
用元素100, 99, 98, ..., 0
定义并初始化一个IntArray
。把它分配给一个val arr
。
如果我们有一组已知的初始成员值,有一种不同的方法来初始化数组。例如,如果我们手头有五个年龄分别为26
、56
、12
、17
和26
的人,并且想要将他们放入IntArray
中,那么就没有一种优雅的方式来使用构造函数来实现这个目的。当然,我们可以写
val ages = IntArray(5)
ages[0] = 26
ages[1] = 56
ages[2] = 12
ages[3] = 17
ages[4] = 26
但这看起来相当冗长。Kotlin 帮助我们用更简短的形式来写。在它的标准库中,它包含了几个函数来创建给定元素值的数组。对于IntArray
s,这个函数读为intArrayOf(...)
,因为它允许我们写任意数量的参数
val ages = intArrayOf(26, 56, 12, 17, 26)
看起来简洁多了。所有数组类型都有相应命名的数组初始化函数,您可以在代码中的任何地方使用它们:intArrayOf()
、longArrayOf()
、doubleArrayOf()
等等。
还有两个特殊的数组创建函数。第一个创建了一个null
对象引用的数组,您编写
val size = ...
val arr:Array<ElementType?> = arrayOfNulls(size)
使用它(用您需要的实际元素类替换ElementType
)。另一个创建对象引用的空数组:
val arr:Array<ElementType?> = emptyArray()
最后但同样重要的是,像集合和列表(我们将在后面描述)这样的集合可以转换成数组:
-
coll.toTypedArray(): Array<ElementType>
这会将元素类型为
ElementType
的集合转换为对象数组。它从不返回原始元素数组。 -
coll.toXXXArray(): XXXArray
这将元素类型为
XXX
(Int
、Long
、Byte
、Short
、Double
、Float
、Boolean
或Char
中的一种)的集合转换为相应的原始元素数组。
练习 2
用值true
、false
、true
创建一个BooleanArray
。
数组运算
除了访问元素,数组还允许对它们应用一些操作(E
是元素类型):
-
first(): E
这是数组的第一个元素。
-
last(): E
这是数组的最后一个元素。
-
copyOf(): Array<E>
对于对象数组,这将创建数组的副本。这是一个浅层副本,意味着该副本将包含与原始数组相同的对象引用。
-
copyOf(): XXXArray
对于原始元素类型
XXX
(Int
、Long
、Byte
、Double
、Float
、Boolean
或Char
中的一种),创建数组的副本。 -
fill(element:E>)
这将所有数组元素设置为给定的
element
。 -
sliceArray(indices:IntRange)
这将从原始数组的一部分创建一个新数组。可以输入一个
IntRange
(例如1..100)
)。指数通常是从零开始的。 -
contains(element:E): Boolean
这将检查指定的元素是否包含在数组中。
-
all(predicate: (E) -> Boolean): Boolean
如果所有元素都满足谓词,则返回
true
。谓词是一个接受每个元素并执行检查的函数;比如{ element -> ... [boolean expression] }
。 -
any(predicate: (E) -> Boolean): Boolean
如果满足任何元素的谓词,则返回
true
。谓词是一个接受每个元素并执行检查的函数,例如{ element -> ... [boolean expression] }
。
这个列表并不详尽。有关更多功能,请参考在线 API 文档。
注意
截至本文撰写之时,Kotlin 的 API 文档可以在 https://kotlinlang.org/api/latest/jvm/stdlib/index.html
.
找到,如果这个链接过期了,你可以通过在你喜欢的搜索引擎中搜索“kotlin stdlib api 文档”轻松找到该文档。
Android Studio 在查找对象属性和功能方面帮助很大。只需输入对象的名称,一个点,如有必要,按 Alt+Enter。Android Studio 随后会显示一个包含所有属性和功能的列表,您可以使用光标上下键浏览该列表(参见图 9-2 )。你甚至可以去找资料来源;将光标放在类名上,然后按 Ctrl+B。
图 9-2
自动化 API 文档
集合、列表和地图
如果您需要许多元素的数据容器,并且不知道或不想指定大小,那么可以使用集合。我们基本上有三种系列:
-
集合:集合是唯一元素的集合。它们包含任意数量的元素,但不允许重复。所以[
1
、27
、3
是一个集合,而[5
、1
、5
不是。还有,集合没有顺序,所以[1
、3
、5
]和[1
、5
、3
]是同一个集合。 -
列表:列表是元素的有序集合,其中允许重复。所以[
1
、2
、3
、1
和[1
、1
、2
、3
都是列表,但又不一样。 -
映射:映射是对[A,B]的无序集合,其中对成员之间存在映射关系:A → B,其背后的思想是,如果你有一个映射 A1 → B1,A2 → B2,A3 → B3,给定任意一个 A,你可以用映射函数或算子确定对应的 B。对[A,B]通常被称为键/值对,其中 A 是键,B 是值。在 Kotlin 的习惯用法中,如果
m
是地图,属性a
包含一个键,那么m[a]
和m.get(a)
都可以用来检索相应的值。
与数组相反,集合总是存在两种变体:集合可以是可变的(可变的)或不可变的(不可变的)。不仅有(不可变的)集合、列表和映射,还有可变集合、可变列表和可变映射。说到班级,我们有以下内容:
Set<MemberType>
List<MemberType>
Map<KeyType, ValueType>
MutableSet<MemberType>
MutableList<MemberType>
MutableMap<KeyType, ValueType>
设置
要创建集合,您可以使用以下构造函数或库函数之一:
val set1:Set<String> = HashSet()
val set2:MutableSet<String> = HashSet()
val set3 = setOf(4, 7, 55)
val set4 = setOf<Long>(4, 7, 55)
val set5 = mutableSetOf(4, 7, 55)
看着这些代码,我们需要解释一些事情。
-
Set
不是类,是接口。对于实例化,我们需要一个实现。您在这里看到的HashSet
是一个标准实现,经常用于集合。 -
HashSet
可用于可变和不可变集合。我们必须在属性声明中显式声明变量。参见示例中的set1
和set2
。 -
setOf()
和mutableSetOf()
试图从它们的参数中推断元素类型。如果不清楚或者需要进行转换,类型必须在尖括号内显式声明,例如示例中的set4
。
就像其他集合类型一样,Set
和MutableSet
类包含大量的属性和函数。把它们都列在这里会增加这本书的篇幅。相反,我提出了最常用的;对于所有其他内容,请参考在线 API 文档。
注意
在撰写本文时,Kotlin 的 API 文档可以在 https://kotlinlang.org/api/latest/jvm/stdlib/index.html
. If
找到。该链接已经过时,您可以在您喜欢的搜索引擎中搜索“kotlin stdlib api 文档”来轻松找到该文档。
至于数组,你可以让 Android Studio 向你展示一个对象的所有属性和功能。输入对象的名称,一个点,如有必要,按 Alt+Enter(回头参考图 9-2 )。要查看源代码,请将光标放在类名上,然后按 Ctrl+B。
这里给出了Set
和MutableSet
接口最常用的属性和功能。我从基本属性和功能开始。
-
size
这表示集合的大小。
-
add(element:E): Boolean
(
MutableSet
only)添加元素。如果元素真的被添加了(它以前不存在),那么返回true
。 -
addAll(elements:Collection<E>): Boolean
这增加了许多元素。一个
Collection
是另一个集合或者一个列表。如果集合由于该操作而被修改,它将返回true
。 -
addAll(elements:Array<out E>): Boolean
(
MutableSet
only)这将添加指定数组中的所有元素。数组类型参数中的out
允许我们添加包含元素的数组,这些元素也是集合所需类型的子类。如果集合由于该操作而被修改,它将返回true
。 -
intersect(other:Iterable<E>): Set<T>
这将返回包含在这个集合和指定的
Iterable
中的一组元素。集合和列表也是可迭代的。
下一组属性和函数是用来处理空集的。
-
clear()
(
MutableSet
only)这将删除所有元素。 -
isEmpty(): Boolean
如果集合为空,则返回
true
。 -
isNotEmpty(): Boolean
如果集合不为空,则返回
true
。
下列属性和函数用于检查。
contains(element:E): Boolean
这将检查指定的元素是否包含在集合中。
-
containsAll(elements:Collection<E>): Boolean
这将检查所有指定的元素是否都包含在集合中。
对于完整集合上的操作,请使用下列属性和函数。
-
toMutableSet(): MutableSet<E>
(仅非
MutableSet
)这将基于不可变集合中的元素返回一个新的可变集合。 -
map(transform: (E) -> R): List<R>
这将对集合中的每个元素应用映射函数,并从中返回一个列表。例如,给定一组名字,转换函数
{ s -> s + " (${s.length})" }
返回一个附加了名字长度的名字列表。
使用下列属性和函数删除元素。
-
remove(element:E): Boolean
(
MutableSet
only)如果集合中存在指定的元素,则删除该元素。如果元素存在并被移除,它将返回true
。 -
removeAll(elements:Collection<E>): Boolean
(
MutableSet
only)这将删除集合中存在的所有指定元素。一个Collection
是另一个集合或者一个列表。如果至少删除了一个元素,它将返回true
。 -
removeAll(elements:Array<E>): Boolean
(
MutableSet
only)这将删除集合中存在的所有指定元素。如果至少删除了一个元素,它将返回true
。 -
retainAll(elements:Collection<E>): Boolean
(
MutableSet
only)这将删除不在指定元素内的所有元素。一个Collection
是另一个集合或者一个列表。如果至少删除了一个元素,它将返回true
。 -
retainAll(elements:Array<E>): Boolean
(
MutableSet
only)这将删除不在指定数组中的所有元素。如果至少删除了一个元素,它将返回true
。
要检查谓词,请使用这些属性和函数。
-
all(predicate: (E) -> Boolean): Boolean
如果所有元素都满足谓词,则返回
true
。谓词是一个接受每个元素并执行检查的函数;比如{ element -> ... [boolean expression] }
。 -
any(predicate: (E) -> Boolean): Boolean
如果满足任何元素的谓词,则返回
true
。谓词是一个接受每个元素并执行检查的函数;比如{ element -> ... [boolean expression] }
。 -
filter(predicate: (E) -> Boolean): List<E>
这将返回谓词为其返回
true
的集合中的所有元素。谓词是一个接受每个元素并执行检查的函数;比如{ element -> ... [boolean expression] }
。
最后一项用于循环。
-
forEach(action: (E) -> Unit)
这在集合中循环。循环将在本章后面讨论。
由于多了几个扩展函数,+
和-
操作符得到了支持,可以用来添加或删除元素或其他集合:
setOf(1, 2, 3) + setOf(2, 3, 4) // -> [1, 2, 3, 4]
setOf(1, 2, 3, 4) + 5 // -> [1, 2, 3, 4, 5]
setOf(1, 2, 3) - setOf(3, 4) // -> [1, 2]
setOf(1, 2, 3) – 2 // -> [1, 3]
练习 3
用元素Apple
、Banana
、Grape
和Engine
创建一个可变集合val fruits
。在单独的语句中,将Cherry
添加到集合中。换句话说,从器械包中移除Engine
。从这个集合中创建一个新的集合val fruits5
,作为过滤具有五个字符的元素的结果。注意:您可以通过寻址它的length
属性来获得一个字符串的长度。
列表
列表类似于集合,但它们不要求唯一性,因此元素可能会出现多次。此外,列表有一个顺序。
为了创建一个列表,我们再次使用了 Kotlin 标准库中的列表实现构造函数和函数。
val list1:List<Int> = ArrayList()
val list2:MutableList<Int> = ArrayList()
val list3 = listOf(4, 7, 55)
val list4 = listOf<Long>(4, 7, 55)
val list5 = mutableListOf(4, 7, 55)
我们前面提到的例子或多或少也适用于列表:
-
List
是一个接口。我们在这里使用的实现ArrayList
,是一个经常使用的选择。 -
ArrayList
可用于可变和不可变列表。我们必须在属性声明中显式声明变量。参见示例中的list1
和list2
。 -
listOf()
和mutableListOf()
试图从它们的参数中推断元素类型。如果不清楚或者需要进行转换,类型必须在尖括号内显式声明,例如示例中的list4
。
作为一种额外的方法,通过使用数组的toList()
或toMutableList()
函数,可以很容易地将数组转换成列表:
val array = arrayOf(...)
val list = array.toList()
由于它们的性质,列表和集合共享许多属性和方法,因此下面的列表包括了集合可能使用的方法。同样,该列表并不详尽,因此请参考在线文档以了解更多详细信息。
注意
这种共性不仅仅是巧合。事实上,Set
和List
都在扩展Collection
接口。你会看到Collection
接口偶尔被用于某些任务,但是通常Set
和List
之间的概念差异值得保留,所以 set 和 list 接口被更频繁地使用。
首先,这里有一些基本的属性和功能。
-
size
这表示列表的大小。
-
lastIndex
是列表的大小减去
1
。 -
add(element:E): Boolean
(
MutableList
only)这在末尾添加了一个元素。如果确实添加了元素,它将返回true
。因为这种情况总是会发生,函数总是会返回true
。 -
addAll(elements:Collection<E>): Boolean
这增加了许多元素。一个
Collection
是另一个列表或集合。如果该操作导致列表被修改,它将返回true
。除非所提供的参数属于空集合,否则该函数将始终返回true.
-
addAll(elements:Array<out E>): Boolean
(
MutableList
only)这将添加指定数组中的所有元素。数组类型参数中的out
允许我们添加包含元素的数组,这些元素也是列表所需类型的子类。如果该操作导致列表被修改,它将返回true
。除非所提供的参数属于空数组,否则该函数将始终返回true.
-
get(index:Int): E
这会从列表中检索一个元素。该指数从零开始。它映射到
[ ]
操作符,因此您可以使用list[index]
来获得相同的结果。 -
set(index:Int, element:E): E
(
MutableList
only)这将在列表中设置一个元素。该指数从零开始。它映射到了[ ]
操作符,所以您可以使用list[index] = ...
来获得相同的结果。
下一组属性和函数用于处理空列表。
-
clear()
(
MutableList
only)这将删除所有元素。 -
isEmpty(): Boolean
如果列表为空,则返回
true
。 -
isNotEmpty(): Boolean
如果列表不为空,则返回
true
。
下列属性和函数用于检查包含性。
-
contains(element:E): Boolean
这将检查指定的元素是否包含在列表中。
-
containsAll(elements:Collection<E>): Boolean
这将检查所有指定的元素是否都包含在列表中。
-
indexOf(element:E): Int
这将检索列表中指定元素的索引,如果没有找到,则为-
1
。该指数从零开始。 -
lastIndexOf(element:E): Int
这将检索列表中指定元素的最后一个索引,如果没有找到,则为-
1
。该指数从零开始。
对于完整列表上的操作,请使用下列属性和函数。
-
toMutableList(): MutableList<E>
(仅非
MutableList
)这将基于不可变列表中的元素返回一个新的可变列表。 -
subList(fromIndex:Int, toIndex:Int): List<E>
这将返回从索引
fromIndex
开始直到(不包括)toIndex
的列表视图。该视图意味着,如果您更改返回列表中的元素,原始列表中也会发生更改。 -
asReversed(): List<E>
这会以相反的顺序返回列表的只读视图。原始列表中的任何更改也会反映在反转列表中。
-
distinct(): List<E>
这将返回一个删除了重复项的新列表。
-
shuffled(): List<E>
这将返回一个新的列表,其中的元素来自原始列表。
-
map(transform: (E) -> R): List<R>
这将在列表的每个元素上应用一个映射函数,并从中返回一个新列表。例如,给定一个姓名列表,转换函数
{ s -> s.length }
将从中返回一个姓名长度列表。
使用下列属性和函数删除元素。
-
remove(element:E): Boolean
(
MutableList
only)如果列表中存在指定的元素,则删除该元素。如果元素存在并被移除,它将返回true
。 -
removeAt(index:Int): E
(
MutableList
only)这将删除指定索引处的元素(从零开始)并返回删除的元素。 -
removeAll(elements:Collection<E>): Boolean
(
MutableList
only)这将删除列表中存在的所有指定元素。一个Collection
是另一个列表或集合。如果至少删除了一个元素,它将返回true
。 -
removeAll(elements:Array<E>): Boolean
(
MutableList
only)这将删除列表中存在的所有指定元素。如果至少删除了一个元素,它将返回true
。 -
retainAll(elements:Collection<E>): Boolean
(
MutableList
only)这将删除不在指定元素内的所有元素。一个Collection
是另一个列表或集合。如果至少删除了一个元素,它将返回true
。 -
retainAll(elements:Array<E>): Boolean
(
MutableList
only)这将删除不在指定数组中的所有元素。如果至少删除了一个元素,它将返回true
。
使用下列属性和函数获取列表的一部分。
-
drop(n:Int): List<E>
这将返回一个新列表,其中的
n
元素已从开头删除。 -
dropLast(n:Int): List<E>
这将返回一个新列表,从末尾删除了
n
元素。 -
first(): E
这将返回第一个元素。
-
take(n:Int): List<E>
这将返回一个新列表,其中包含原始列表的前
n
个元素。 -
first(predicate: (E) -> Boolean): E
这将返回匹配谓词的第一个元素。谓词是一个接受每个元素并执行检查的函数;例如,
{ element -> ... [boolean expression] }.
-
last(): E
这将返回最后一个元素。
-
takeLast(n:Int): List<E>
这将返回一个新列表,其中包含原始列表的最后
n
个元素。 -
last(predicate: (E) -> Boolean): E
这将返回与谓词匹配的最后一个元素。谓词是一个接受每个元素并执行检查的函数;比如
{ element -> ... [boolean expression] }
。
要检查谓词,请使用这些属性和函数。
-
all(predicate: (E) -> Boolean): Boolean
如果所有元素都满足谓词,则返回
true
。谓词是一个接受每个元素并执行检查的函数;比如{ element -> ... [boolean expression] }
。 -
any(predicate: (E) -> Boolean): Boolean
如果满足任何元素的谓词,则返回
true
。谓词是一个接受每个元素并执行检查的函数;比如{ element -> ... [boolean expression] }
。 -
filter(predicate: (E) -> Boolean): List<E>
这将返回列表中谓词返回
true
的所有元素。谓词是一个接受每个元素并执行检查的函数;比如{ element -> ... [boolean expression] }
。
这些项目用于循环。
-
forEach(action: (E) -> Unit)
该方法遍历列表。
-
forEachIndexed(action: (index:Int,E) -> Unit)
这个方法也遍历列表。
由于有几个额外的函数,列表理解+
操作符,所以可以使用+
添加元素或集合(其他列表或集合)。
listOf(1, 2, 3) + listOf(2, 3) // -> [1, 2, 3, 2, 3]
listOf(1, 2, 3, 4) + 5 // -> [1, 2, 3, 4, 5]
可以使用toArray()
、toIntArray()
、toDoubleArray()
等将列表转换为数组。只有当元素具有正确的类型时,转换为基元类型数组才会成功。
地图
在 Kotlin 中,地图可能是最有趣的,但也是集合框架中涉及最多的部分。每当需要数学意义上的映射时,就会用到映射,这意味着集合 A = {a0,a1,a2,...}映射到集合 B = {b0,b1,b2,...}.因此,每当你有一个 a i 时,你可以立即从中确定(唯一的)映射 b j 。在计算机语言中,你映射的数据通常被称为键,你映射到的值被命名为值。
在自然和文化中,地图无处不在:地球上的一对地理坐标映射到一个高度,1 月 23 日的每一秒映射到纽约的气温,每个社会安全号码映射到一个名字,时间映射到地球在其轨道上的位置,温度映射到水的聚集状态(固体、液体、气体),乐器演奏的音符映射到一个频率,数组中元素的索引映射到某个值,等等。
类似于集合和列表,我们再次区分了可变(可改变的)和不可变(不可改变的)映射。
在下面的代码片段中,我们将使用下面的地图:SSN
→ name
(所有数字都是虚构的):
152835937 -> Albert Einstein
273495357 -> John Smith
346068589 -> John Smith
484767775 -> Barbra Streisand
要声明映射,可以使用Map
或MutableMap
作为类型,并在其后的尖括号中添加键和值类型。
val map1:Map<String,Int> = ...
var map2:Map<Int,Double> = ...
val map3:MutableMap<Int,String> = ...
为了创建地图,我们首先可以选择使用一个构造函数:
val map: MutableMap<Int,String> =
HashMap<Int,String>().apply {
this[152835937] = "Albert Einstein"
this[273495357] = "John Smith"
this[346068589] = "John Smith"
this[484767775] = "Barbra Streisand"
}
其中HashMap
是最常用的实现之一。apply{...}
是新的。实际上,您可以在任何情况下使用它,但是在这里它的意思是:使用我们刚刚创建的地图并对它做一些事情。this
指的是正在创建的地图实例,而不是我们当前所在的类实例或对象。我们在这里使用apply{ ... }
来添加一些键/值对。
接下来是 Kotlin 标准库函数,帮助我们创建和初始化地图:
val map = mutableMapOf(
152835937 to "Albert Einstein",
273495357 to "John Smith",
346068589 to "John Smith",
484767775 to "Barbra Streisand"
)
val map2 = mapOf(
152835937 to "Albert Einstein",
... )
注意
前面初始化器中的to
实例实际上是创建内置Pair
类实例的操作符。如果需要,您可以使用您自己的Pair
的显式实例,比如在val p1 = Pair(152835937, "Albert Einstein")
和mapOf(p1, ...)
中。
映射也是列表、集合和数组的一些操作的结果。对于后三种类型中的任何一种,您都可以使用其中的一种(T
是元素类型):
-
associate(transform: (T) -> Pair<K, V>): Map<K,V>
这将创建一个键类型为
K
而值类型为V
的映射。假定给定原始集合、列表或数组的每个元素,transform 函数将创建一个Pair<K,V>
。例如,给定一组整数(T =Int
),这样的转换函数可以读作{ i -> Pair(i, i*i) },
,创建一个将整数映射到它们的平方的映射。 -
associateWith(ValueSelector: (K) -> V): Map<K,V>
这与
associate()
非常相似,但是作为快捷方式总是将原始元素作为键。valueSelector
应该生成这个值。例如,给定一组整数,lambda 函数{ i -> i * i }
再次将整数映射到它们的平方。 -
associateBy(keySelector: (T) -> K): Map<K,V>
这与
associate()
非常相似,但是作为快捷方式总是将原始元素作为值。keySelector
应该生成密钥。比如给定一组双精度数,lambda 函数{ d
- > Math.floor(d)。toInt() }使用等于或小于给定双精度值的整数作为键。 -
groupBy(keySelector: (T) -> K): Map<K, List<T>>
这将从原始集合或数组中收集元素,并将它们保存在结果映射中生成的键下。比方说,你有几个名字——约翰、亚当、乔和加布里埃尔——并应用
keySelector { s -> s.length }
。然后,生成的映射将姓名长度映射到姓名:3
→ [“乔”]、4
→ [“约翰”、“亚当”],以及7
→ [“加百列”]。
注意,如果可能的话,你应该选择associateWith()
和associateBy()
而不是associate()
,因为后者意味着一个对象的创建,这总是需要一些时间。
成对和三对
另外两种类型的数据容器是 pairs 和 triples。我们已经看到了第一个,由Pair
类表示,用于映射目的。三元组使用类Triple
并且只包含三个成员。当然,你可以用这两者来完成你喜欢的任何任务。声明和初始化是
val pair = Pair<FirstType, SecondType>(
firstVal, secondVal)
val triple = Triple<FirstType, SecondType, ThirdType>(
firstVal, secondVal, thirdVal)
通常,如果类型说明< ... >
可以通过值的类型来推断,那么它可以被省略。例如,你可以写
val pair = Pair("Earth", 12800.0)
去弄一对String
和Double
。
要获取对的第一个和第二个组件,只需使用属性first
和second
,就像在pair.first
和pair.second
中一样。因此,三元组的组件可通过属性first
、second
和third
访问。
数据容器上的循环
对数据容器进行循环意味着访问它们的每个成员来对其执行一些操作。如果您想打印、转换或聚合数据容器以推断一些容器特征,这是数据容器的一个重要用例。考虑求和、串联或平均。
在过去,计算机语言提供了某种环绕索引变量的循环结构,事实上 Kotlin 也可以做到这一点。我们在本书的前面已经讨论过这种老式的循环,但是让我在这里向您展示一种更优雅和简单的方法来循环 Kotlin 中的容器。
所有集合类型的数据容器,如数组、集合和列表,都提供了一个forEach()
函数,您可以简单地使用它来满足循环需求。更准确地说,写
val container = ... // array or set or list
container.forEach { elem ->
// do something with elem
}
如果它看起来像一个带有块的语句,为什么我们称它为函数呢?这或多或少是一种巧合;这个例子也可以写成container.forEach({ ... })
,Kotlin 编译器允许去掉多余的圆括号。实际上,{ ... }
不是一个语句块,而是一个函数文字,也称为 lambda 函数。elem
只是一个标识符;你也可以使用e
或element
或任何你喜欢的名字。在任何情况下,它获取数组或集合中当前访问过的元素,并自动拥有与其相同的类型。例如,在
val container = listOf(1, 2, 3, 4, -1)
container.forEach { elem ->
Log.d("LOG", elem.toString())
}
elem
一个接一个地获取整数 1,2,3,4,-1,elem
自动拥有类型Int
,因为容器是一个Int
元素的列表。
注意
事实上,如果有助于提高代码的可读性,你可以像在forEach { elem:Int -> ... }
中一样在这里添加:Int
。
如果您需要函数内部的迭代索引,您可能会想写
var index = 0
container.forEach { elem ->
// ... do s.th. with elem
index++ // NOT ALLOWED!
}
每次迭代递增index
变量。然而,这是行不通的。不允许重新分配“外部”变量是对内部函数的限制。如果你需要一个索引,你可以使用forEach()
的变体,读作forEachIndexed()
。这一次,内部函数接收两个参数,即Int
类型索引和元素变量:
container.forEachIndexed { index, elem ->
// ... do s.th. with elem
}
index
变量获取值 0、1、2、...并且总是有类型Int
。同样,您可以随意更改索引变量的名称。
遍历地图的方式不同,但也不复杂。地图也有一个forEach()
函数,但是参数类型不同。
-
如果像在
map.forEach { me -> ...}
中一样使用单个参数,该参数将是类型Map.Entry<K,V>
,其中K
是键类型,V
是值类型。然后从me
通过me.key
得到密钥,通过me.value
得到值。你也可以写me.toPair()
来构建一个Pair
。 -
(仅适用于 Android API 级别 24 或更高)如果使用两个参数,他们将在每次迭代中收到密钥和值:
map.forEach
{ k,v - >...}.
排序数组和集合
在将数据呈现给应用用户之前,对数组和集合(如列表和集合)进行排序是一项经常需要完成的任务。此外,在开始二分搜索法算法之前,必须进行排序,我们将在本章后面的“在数组和集合中搜索”一节中讨论该算法。
排序可以就地进行,这意味着您要排序的数组或集合发生了变化,或者以函数方式进行,这意味着操作的结果是排序后的数组或集合,而原始数据容器保持不变。就地排序将是更快的选择,但是如果其他程序部分持有对原始数组或集合的引用,则会有被破坏的风险。函数排序可以提高程序的稳定性,但是可能会有一些性能损失,所以要明智地选择。
对于原始数组或集合不变的函数式排序,您有几个选项(T
是元素类型)。
-
Array.sorted() : List<T>
这将返回一个
List
,数组中的元素根据它们的自然排序顺序进行排序。类型T
必须是Comparable,
的子接口,这是所有内置数值类型和字符串的情况。作为数组,您可以使用一个对象数组或任何原始元素类型数组(IntArray
、DoubleArray
等)。). -
Array.sortedArray() : Array<T>
这与
Array.sorted()
相同,但是返回一个数组。Kotlin 总是返回一个对象数组,而不是一个原始类型的数组。因此,arrayOf(1,2,3).sorted()
返回一个Array<Int>
,而不是一个IntArray
。但是,您可以添加方法toIntArray()
来将Int
对象数组转换为IntArray
。这同样适用于其他基本元素类型对象。 -
Collection.sorted() : List<T>
这与
Array.sorted()
相同,但适用于集合和列表。
您可以给它们中的任何一个添加一个Descending
来颠倒排序顺序。
一些额外的方法允许您显式地指定元素排序的比较操作。
-
Array.sortedBy(selector: (T) -> R?) : List<T>
这将根据选择器函数返回值的自然排序顺序创建一个排序列表。类型
R
必须实现Comparable
接口。比方说,你想通过make
对一个data class Car(val make:Int, val name:String)
的数组进行排序。然后你可以写array.sortedBy({ car -> car.make }).
-
Collection.sortedBy(selector: (T) -> R?) : List<T>
这与前面显示的
Array.sortedBy()
相同,但适用于集合和列表。 -
Array.sortedWith(comparator: Comparator<in T>) : List<T>
这将根据提供的比较器创建一个排序列表。您可以提供一个
Comparator
的子类实现,但是 Kotlin 标准库也提供了几个Comparator
生成器函数。类型说明符中的in
表明比较器处理一个T
的超类就足够了。 -
Array.sortedArrayWith(comparator: Comparator<in T>) : Array<T>
这与前面显示的
Array.sortedWith()
相同,但是返回一个数组。 -
Collection.sortedWith(comparator: Comparator<in T>) : List<T>
这与前面显示的
Array.sortedWith()
相同,但适用于集合和列表。
你可以给它们中的大多数加一个Descending
来颠倒排序顺序(没有sortedWithDescending()
也没有sortedArrayWithDescending()
)。
对于任何sortedWith()
函数中需要的比较器,Kotlin 提供了标准库函数,您可以使用它们来创建这样的比较器。
-
compareBy(vararg selectors: (T) -> Comparable<*>?): Comparator<T>
和
-
compareByDescending( vararg selectors: (T) -> Comparable<*>?): Comparator<T>
这是您可能希望在
sortedWith()
中显示的一个重要功能。它需要任意数量的函数来评估一个Comparable
。这些函数以连续的顺序工作。第一次Comparable
比较没有导致等于,将会中断链,并继续排序算法中的下一次迭代。作为函数元素,您可以编写一个 lambda 函数,如{elem -> elem.someProperty}
如果这个属性是一个类似于
Int
或String
的Comparable
,但是你也可以通过写T::propertyName
来直接引用属性 getters。举个例子:举一个清单data class Car(val make:Int, val name:String)
并考虑按品牌进行比较。使用
sortedWith()
进行排序,然后读取list.sortWith( compareBy( Car::make ) )
。 -
compareBy(comparator: Comparator<in K>, selector: (T) -> K): Comparator<T>
和
-
compareByDescending(comparator: Comparator<in K>, selector: (T) -> K): Comparator<T>
这将创建一个比较器,它首先将选择器应用于输入数据,然后将提供的比较器应用于选择器的结果。
-
nullsFirst(): Comparator<T>
使用它作为
compareBy()
的第一个参数来扩展自然顺序比较器,该比较器隐式地允许在排序的数组或集合中使用null
值。这样的null
值将首先出现在返回的列表中。nullsFirst()
比较器只能在比较Comparable
元素的环境中使用,如果使用nullsFirst()
作为compareBy()
中的第一个参数,就会自动出现这种情况。 -
nullsLast(): Comparator<T>
这类似于
nullsFirst
,但是null
值将显示在返回列表的最后。 -
reverseOrder(): Comparator<T>
使用它作为
compareBy()
的第一个参数来反转这里隐式使用的自然顺序比较器的顺序。可以与其他比较器扩展器混合,例如在nullsFirst( reverseOrder() ).
中 -
then
使用它作为中缀运算符来链接比较器。例如,你可以在
sortWith().
里面写compareBy(...) then compareBy(...)
练习
使用sortWith()
,对来自NumberGuess
游戏应用的GameUser
实例列表val gul = listOf(...)
进行排序,首先按姓氏排序,然后按名字排序。将结果分配给val sorted
。
就地排序不同于迄今为止处理的排序函数,因为原始数组或集合(列表或集合)被改变以包含排序后的数据。对于列表和集合,这显然只对可变变量有意义。这里给出了就地排序的函数。
-
sort() and sortDescending()
这将根据元素的自然排序顺序对数组或可变集合进行就地排序。元素必须实现
Comparable
接口才能工作。 -
sortBy(selector: (T) -> R?)
和sortByDescending(selector: (T) -> R?)
这将根据所提供的选择器函数对数组或可变集合进行排序,该函数必须返回一个
Comparable
。 -
sortWith(comparator: Comparator<in T>)
这将根据提供的比较器对数组或可变集合进行就地排序。比较器类型规范中的
in
意味着比较器必须处理元素,但也可以处理元素类型的超类。对于comparator
参数,可以使用与前面描述的函数式排序函数相同的 Kotlin 标准库函数。
注意
比起就地排序,您应该更喜欢函数式排序,除非性能或资源管理是一个重要的问题。
练习 5
执行与练习 4 相同的操作,但执行就地排序。
分组、折叠、缩小和压缩
分组、折叠、归约和压缩是对数组和集合(如列表和集合)的高级操作。我们依次讨论每一个问题。
分组
分组是指以这样一种方式重新组织数据,即根据从数据中推断出的或强加给数据的某个关键字来收集数据组。例如,看一组汽车:
data class Car(id:Int,make:Int,name:String,vin:String)
val cars = listOf(
Car(1, 1994, "Sirus", "WXX 130 007-1J-582943"),
Car(2, 1997, "Sirus", "WXX 130 008-1J-582957"),
Car(3, 2010, "Casto 4.0", "WXQ 456 088-4K-005614"),
Car(4, 2010, "Babo MX", "WYY 518 004-55-171598"),
Car(5, 1994, "Casto 4.0", "WXQ 456 005-4K-005658"),
Car(6, 2011, "Quasto", "WA0 100 036-00-012378")
)
如果我们想找出哪些汽车属于某一制造年份,该怎么办?如果查看 id,我们可以看到两辆车属于 1994 年,一辆属于 1997 年,两辆属于 2010 年,还有一辆属于 2011 年。:
1994 -> [ 1, 5 ]
1997 -> [ 2 ]
2010 -> [ 3, 4 ]
2011 -> [ 6 ]
这个操作叫做分组,在这个特例中,我们基于make
分组。
在 Kotlin 中,我们有一个分组函数来帮助我们实现我们的目标:groupBy( keysSelector: (T) -> K ): Map<K, List<T>>
其中keySelector
应该推导出分组密钥。类型参数T
是原始元素的类或其超类。Type K
是分组键所需的任何类型。汽车示例的分组函数为:
data class Car(id:Int,make:Int,name:String,vin:String)
val cars = listOf( ... )
val groupedByMake = cars.groupBy(Car::make)
...
val group1997:List<Car> = groupedByMake[1997]
我们为 make: Car::make
应用了 getter 函数。不太简洁,但结果相同,我们也可以用这个:
val groupedByMake = cars.groupBy { car -> car.make }
练习 6
用substring(0,3)
从字符串中提取前三章,用vin
的前三个字符作为关键字对汽车列表进行分组。称它为val groupedByManufacturer
。从分组结果中提取 WXX 制造商。
还有三个分组功能。第一个是多了一个参数的groupBy()
。这个函数在将值添加到分组结果之前对它们执行转换。还有两个功能,groupByTo()
、将分组结果保存到作为参数提供的地图中。它们或多或少都是方便的功能。有关详细信息,请参考官方 Kotlin API 文档。
可折叠的
折叠是让对象扫描数组或集合(集合或列表)的所有元素,并在每次迭代中更新自己。例如,想象一个发票列表,并对所有金额进行汇总。这没什么了不起的;一个人可以写
val someObject = ...
list.forEach { elem ->
// update someObj using elem
...
}
然而,有一个内在的危险,代码可能在循环开始做许多奇怪的事情之前初始化对象,所以有一个函数使用一条语句执行任务。其实就是一套功能。
-
fold(initial: R, operation: (acc: R, T) -> R)): R
该函数将每次循环迭代要更新的对象和执行更新的函数作为参数。这个更新程序将收集对象的实际版本和当前循环元素作为参数。这将返回应用了所有数据容器元素的收集对象。在大多数实际情况下,第一个参数可能是一个新构造的对象,如
list.fold(Gatherer(), ...)
所示。 -
foldRight(initial: R, operation: (T, acc: R) -> R)): R
这类似于
fold()
,但是它以相反的顺序遍历数组或集合。为了表示这种反向扫描,内部函数的参数顺序也颠倒了。 -
foldIndexed(initial: R, operation: (index:Int, acc: R, T) -> R)): R
这与
fold
相同,但是内部函数获取循环迭代索引作为附加的第一个参数。 -
foldRightIndexed(initial: R, operation: (index:Int, T, acc: R) -> R)): R
这类似于
foldIndexed()
,但是它以相反的顺序遍历数组或集合。同样,为了表示这种反向扫描,内部函数的参数 2 和参数 3 的顺序也颠倒了。
还有一个高级的折叠机制,包括分组操作。如果您在数组或集合(列表或集合)上使用groupingBy()
,您将收到一个Grouping
对象,稍后您可以将它应用于数组或集合,如集合或列表。这是一种方便的功能,因为您可以进行分组,然后手动折叠。有关详细信息,请参考 Kotlin API 文档。
减低
还原是折叠的小兄弟。未显式指定 gatherer,而是使用数组或集合(集合或列表)的第一个元素。折叠操作或者更准确地说是减少操作然后可以理解地从数据的第二个元素开始。这里列出了归约函数。
-
reduce(operation: (acc: S, T) -> S): S
这将对当前 gatherer 值和当前 loop 元素执行所提供的归约操作。然后返回归约结果。reduction 函数可能返回原始数据类型
T
或其子类的值。 -
reduceRight(operation: (T, acc: S) -> S): S
这类似于
reduce()
,但是它以相反的顺序扫描数据。注意,归约函数的参数顺序也是相反的。 -
reduceIndexed(operation: (index: Int, T, acc: S) -> S): S
这与
reduce()
相同,但是 reduction 函数接收当前循环索引作为附加的第一个参数。 -
reduceRightIndexed(operation: (T, acc: S) -> S): S
这类似于
reduceRight()
,但是它以相反的顺序扫描数据。请注意,归约函数的参数 2 和参数 3 的顺序也是相反的。
练习 7
创建一个列表[1, 2, 3, 4, ..., 100]
。然后利用reduce
,从中计算出数字1
√2
√3
√...
√100
。提示:您可以将范围( f 从..通过函数toList()
将添加到列表中。
拉链
循环、排序、折叠和归约已经为处理数组和集合(集合和列表)提供了一个非常通用的工具集。然而,我们还没有一个工具可以将两个数组或集合元素结合起来。在 Kotlin 中,有一组函数专门处理这类任务。
这里帮助我们的主要函数叫做zip()
和unzip()
。其中第一个zip()
,有如下签名:zip(other: Array<out R>): List<Pair<T, R>>)
或zip(other: Iterable<R>): List<Pair<T, R>>)
。两者都定义为中缀函数,所以可以写
array.zip(otherArray)
-or- array zip otherArray
array.zip(list)
-or- array zip list
collection.zip(array)
-or- collection zip array
collection.zip(otherCollection)
-or- collection zip otherCollection
它们都返回一个Pair
实例列表,如图 9-3 所示。请注意,Iterable
是数组、集合和范围实现的接口,所以您也可以在这里使用范围。
图 9-3
拉链
例如,假设我们有两个列表[Bananas, Apples, Oranges]
和相应的价格[1.69, 2.19, 2.79]
。要建立一个配对列表[Bananas, 1.69]
、[Apples, 2.19],
和[Oranges, 2.79]
,你所要做的就是写下来
val fruits = listOf("Bananas", "Apples", "Oranges")
val prices = listOf(1.69, 2.19, 2.79)
val fruitsAndPrices = fruits zip prices
// or fruits.zip(prices)
...
fruitsAndPrices.forEach { p ->
// p = Pair("Bananas", 1.69) aso.
}
如果你试图压缩不相等长度的数组或集合,较大的一个会在最后被剪切掉,得到的列表将具有较小的一个的大小。
unzip()
函数执行相反的操作:它获取一个成对列表,并从中提取两个单元素列表,打包成一对:unzip(): Pair<List<T>, List<R>>
,其中类型T
和R
是原始列表中每对的第一个和第二个元素的类型。
对于压缩,除了添加第二个参数之外,还有一个替代函数。这是一个转换函数,在将成对的元素输出到 zip 结果之前对它们做一些事情;例如,list1.zip(list2, a,b -> ...
其中a
和b
是相同索引的列表 1 和列表 2 的元素。这是一种避免在不需要的情况下创建配对的方法。同样的过程也适用于数组。
练习 8
使用两个列表val fruits = listOf("Bananas", "Apples", "Oranges")
和val prices = listOf(1.69, 2.19, 2.79)
以及一个data class Fruit(val name:String, val price:Double)
,对包含Fruit
元素的结果列表执行压缩。
在数组和集合中搜索
我们已经看到,通过使用indexOf()
我们可以找到数组或列表中特定元素的索引。对于非常大的数组或集合(例如,一百万个条目),这可能不是查找元素的最快方法。在内部indexOf()
必须遍历整个数组或列表,并且必须检查每个数据值是否相等,直到命中为止。
对于排序的数组或列表,有一个更好的选择:二分搜索法。在这样的二分搜索法中,数组或列表中的 N 个元素被分成大小大约为 N /2 的两个相等部分。然后选择包含搜索元素的部分。对于较小的范围,我们再次在中间执行分割,进行另一次检查,等等。使用这种算法在一百万个条目的数组或列表中搜索一个元素,我们不需要超过 20 次检查就可以找到它。
这里给出了这种二分搜索法的函数签名(E
是元素类型)。
-
binarySearch(element:E, fromIndex:Int = 0, toIndex:Int = size)
这将使用自然排序在数组或列表中查找元素。元素类型必须有
Comparable
接口,这是所有数字类型和字符串的自动情况。如果数组或列表中不存在该元素,则返回适当的插入索引。在[1, 2, 4, 5]
中搜索一个3
从而返回2
,因为这是[1, 2, 3, 4, 5]
中3
的索引。因为fromIndex
和toIndex
有足够的默认值,如果你想搜索整个列表,可以省略它们。 -
binarySearch(element:E, comparator: Comparator<in E> fromIndex:Int = 0, toIndex:Int = size)
这将使用提供的比较器在数组或列表中查找元素。列表或数组必须已经根据提供的比较器预先排序。如果数组或列表中不存在该元素,则返回适当的插入索引。因为
fromIndex
和toIndex
有足够的默认值,如果你想搜索整个列表,可以省略它们。类型参数中的in
表示使用的比较器也可以处理E
的超类。
请注意,在所有情况下,都必须对数组或列表进行升序排序。否则结果是不确定的。
扩展运算符
对于任何带有vararg
参数的函数,您可以使用数组向函数传递值:
function xyz(a:Int, vararg x:String) {
...
}
val arr = arrayOf("1", "2", "3", "4", "5")
xyz(42, *arr)
参数前面的*
称为传播算子。请注意,这仅适用于数组,但是对于列表或集合,您可以通过.toArray()
、.toIntArray()
、.toDoubleArray()
等等来执行适当的转换。
队列和堆栈:队列
集合和列表不是您可以使用的唯一集合类型。Kotlin 并不显式处理集合和列表之外的集合类型,但是它位于 Java 虚拟机(JVM) 之上,并且包含了 Java 标准库的一个重要子集,包括所有的 Java 集合类型。我们不会涵盖所有这些,因为有些相当专业,并显示出您通常不需要的功能。不过,有一种有趣的类型值得更彻底的调查:德克斯特。
Deques 是非常类似于 lists 的集合,但是除此之外,它们还允许在头部添加元素,并且还提供了从集合的两侧移除元素的功能。在讨论 deques 提供的功能之前,我们首先澄清几个术语:
-
头部:列表的头部是最先添加到列表中的元素。因此,它是索引为
0
的元素。对于 deques,您通过使用名称中包含First
的函数之一,明确声明您想要对头部做一些事情。 -
尾部:列表的尾部是通过
add()
函数添加元素的地方。对于 deques,您通过使用名称中包含Last
的函数之一,明确声明您想要对尾部做一些事情。
因为Deque
是一个接口,所以我们需要一个实现。有几个,其中java.util.ArrayDeque
可能是最常用的一个。类ArrayDeque
有三个构造函数(E
是元素类型)。
-
ArrayDeque<E>()
这将构造一个初始容量为 16 个 e 类型元素的 deque。从客户端的角度来看,除非性能或资源管理有问题,否则您不必考虑容量。如果希望有很多元素,可以使用后面显示的构造函数指定一个更高的初始容量大小。
-
ArrayDeque<E>(numElements:Int)
这将构造一个具有给定初始容量的队列。
-
ArrayDeque<E>(c:Collection<out E>)
这将构造一个用给定元素初始化的队列。类型规范中的
out
表示参数允许有E
的子类。
例如,要创建一个包含Int
元素的队列,您可以编写:val dq
= ArrayDeque<Int>()
。注意ArrayDeque
类会让它的内部数据容器根据需要增长;因此,初始容量只是一个提示。
除了列表提供的属性和函数之外,下面是 deques 提供的函数的非穷尽列表。
-
addFirst(element:E)
这会将一个元素添加到 deque 的头部。
-
addLast(element:E)
这会将一个元素添加到 deque 的尾部。它对应于一个列表的
add()
。 -
removeFirst(): E
这将获取并移除队列头部的元素。如果队列为空,它将抛出一个异常。
-
removeLast(): E
这将获取并移除队列尾部的元素。如果队列为空,它将抛出一个异常。
-
getFirst(): E
这会检索但不会移除队列头部的元素。如果队列为空,它将抛出一个异常。
-
getLast(): E
这会检索但不会移除队列尾部的元素。如果队列为空,它将抛出一个异常。
此外,您可以使用以下函数之一,如果 deque 为空,这些函数不会抛出异常,而是返回null
。
-
peekFirst():E?
这将获取但不会移除 deque 的头。如果 deque 为空,则返回
null
。 -
peekLast():E?
这将获取但不会移除 deque 的尾部。如果 deque 为空,则返回
null
。 -
pollFirst():E?
这个函数获取并移除一个队列的头。如果 deque 为空,则返回
null
。 -
pollLast():E?
这将获取并移除 deque 的尾部。如果 deque 为空,则返回
null
。
如果使用addLast()
和removeFirst()
,Deques 可以用来模拟先进先出(FIFO)队列。同样,可以使用addLast()
和removeLast()
来模拟后进先出(LIFO)堆栈。
NumberGuess 应用的统计类
到目前为止,我们的游戏应用不包含任何类似列表的结构,这就是为什么我们暂时没有提到它。这可以很容易地改变,我们为此添加的扩展是一个专门的统计活动,为我们计算尝试和点击。
向应用添加操作栏
我们做的第一件事是给NumberGuess
应用添加一个动作栏:
-
更新
AndroidManifest.xml
文件。在<activity>
标记中添加一个 XML 属性:android:theme = "@style/AppTheme.NoActionBar"
(紧接在新行中的android:name= ...
条目之后)<activity android:name=... android:theme="@style/AppTheme.NoActionBar">
-
更新
res/values/styles.xml
文件。在<resources>
标签内添加:<resources> ... <style name="AppTheme.NoActionBar"> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> </style> <style name="AppTheme.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar"/> <style name="AppTheme.PopupOverlay" parent="ThemeOverlay.AppCompat.Light"/> </resources>
-
更新
res/layout/activity_main.xml
文件:<?xml version="1.0" encoding="utf-8"?> <android.support.design.widget.CoordinatorLayout xmlns:android= "http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <android.support.design.widget.AppBarLayout android:layout_height="wrap_content" android:layout_width="match_parent" android:theme="@style/AppTheme.AppBarOverlay"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay" /> </android.support.design.widget.AppBarLayout> <include layout="@layout/content_main"/> </android.support.design.widget.CoordinatorLayout>
-
创建一个新文件
res/layout/content_main.xml
:<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android= "http://schemas.android.com/apk/res/android" xmlns:app= "http://schemas.android.com/apk/res-auto" xmlns:tools= "http://schemas.android.com/tools" android:layout_width= "match_parent" android:layout_height= "match_parent" app:layout_behavior= "@string/appbar_scrolling_view_behavior" tools:showIn= "@layout/activity_main" tools:context= ".MainActivity"> <LinearLayout android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" android:padding="30dp" tools:showIn="@layout/activity_main" tools:context=".MainActivity"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/title.numberguess" android:textSize="30sp"/> <Button android:id="@+id/startBtn" android:onClick="start" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/btn.start"/> <Space android:layout_width="match_parent" android:layout_height="5dp"/> <LinearLayout android:orientation="horizontal" android:layout_width="wrap_content" android:layout_height="wrap_content"> <TextView android:text="@string/label.guess" android:layout_width="wrap_content" android:layout_height="wrap_content"/> <EditText android:id="@+id/num" android:hint="@string/edit.number" android:layout_width="80sp" android:layout_height="wrap_content" android:inputType="number" tools:ignore="Autofill"/> <Button android:id="@+id/doGuess" android:onClick="guess" android:text="@string/btn.do.guess" android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout> <Space android:layout_width="match_parent" android:layout_height="5dp"/> <TextView android:id="@+id/status" android:text="@string/status.start.info" android:textColor="#FF000000" android:textSize="20sp" android:layout_width="wrap_content" android:layout_height="wrap_content"/> <Space android:layout_width="match_parent" android:layout_height="5dp"/> <TextView android:text="@string/label.log" android:textStyle="bold" android:layout_width="wrap_content" android:layout_height="wrap_content"/> <kotlinforandroid.book.numberguess.gui.Console android:id="@+id/console" android:layout_height="100sp" android:layout_width="match_parent"/> </LinearLayout> </android.support.constraint.ConstraintLayout>
-
确保
MainActivity.kt
文件包含 as 导入:import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.content_main.*
-
同样在类
MainActivity
中,让函数onCreate()
读作:override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) setSupportActionBar(toolbar) // NEW fetchSavedInstanceData(savedInstanceState) doGuess.setEnabled(started) }
-
创建菜单资源文件夹。为此,右击
res
文件夹,然后选择新➤ Android 资源目录。输入menu
作为目录名,并从资源类型中选择菜单。 -
创建菜单资源:右键单击
res/menu
文件夹,然后选择新建➤菜单资源文件。输入menu_options
作为文件名。打开文件后,通过按编辑器视图底部的选项卡切换到文本视图。如内容,写<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android= "http://schemas.android.com/apk/res/android" xmlns:app= "http://schemas.android.com/apk/res-auto"> <item android:id="@+id/statistics" android:icon= "@android:drawable/ic_menu_info_details" android:title= "@string/statistics.menu_title" app:showAsAction="ifRoom"/> </menu>
-
创建一个字符串资源:打开
res/values/strings
并添加<string name="statistics.menu_title"> Statistics</string>
-
在
MainActivity
类中,添加
```kt
override
fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater: MenuInflater = menuInflater
inflater.inflate(R.menu.menu_options, menu)
return true
}
private fun openStatistics() {
val intent: Intent = Intent(this,
StatisticsActivity::class.java)
startActivity(intent)
}
```
activity 类现在将显示一个错误,因为StatisticsActivity
还不存在。我们在下一节中创建它。
统计活动
我们现在为统计数据创建一个新的活动。
-
右键单击
app
,然后选择新建➤活动➤清空活动。输入StatisticsActivity
作为活动的名称。确保选择生成布局文件,并使用kotlinforandroid.book.numberguess
作为包名。选择 Kotlin 作为源语言,并将 main 设置为目标源。单击完成。 -
打开文件
res/layout/activity_statistics.xml
,切换到文本视图类型,将其内容替换为:<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android= "http://schemas.android.com/apk/res/android" xmlns:tools= "http://schemas.android.com/tools" xmlns:app= "http://schemas.android.com/apk/res-auto" android:id="@+id/statisticsContainer" android:layout_width="match_parent" android:orientation="vertical" android:layout_height="match_parent" tools:context=".StatisticsActivity"> </LinearLayout>
-
Open the new activity class
StatisticsActivity
and replace its contents with this:package kotlinforandroid.book.numberguess import android.support.v7.app.AppCompatActivity import android.os.Bundle import android.view.ViewGroup import android.widget.TextView import kotlinforandroid.book.numberguess. statistics.Statistics class StatisticsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_statistics) showData(Statistics.getStatistics()) } fun showData(s:List<String>) { val container = findViewById<ViewGroup>( R.id.statisticsContainer) container.removeAllViews() s.forEach {line -> container.addView(TextView(this).apply { text = line }) } } }
最后一个
import
必须在一行中输入,不像这里显示的那样。编辑器会显示该类的错误,但我们会很快更正它们。 -
创建一个新的包
kotlinforandroid.book.numberguess.statistics
和一个新的对象Statistics
。 -
现在让
Statistics
读作:package kotlinforandroid.book.numberguess.statistics object Statistics { fun getStatistics(): List<String> { return emptyList() } }
类StatisticsActivity
中的错误现在应该消失了。
现在,您应该能够在模拟器或连接的设备中运行该应用。如果单击任务栏上的“新建”( I)按钮,应该会出现新的统计活动。现在,它将显示一个空屏幕,但我们稍后会更改并向其添加内容(参见图 9-4 )。
图 9-4
空统计活动
统计数据的状态内务处理
在Statistics
singleton 对象中,我们在一个列表中收集所有游戏的结果。因为在一个会话中我们得到两个数字——要猜测的数字和猜测数字所需的尝试次数——我们定义了一个内部类GameSessionRecord
来保存一个结果对。因此,我们相应地更新了Statistics
对象:
package kotlinforandroid.book.numberguess.statistics
object Statistics {
data class GameSessionRecord(val numberToGuess:Int,
val tries:Int) : Serializable
val data: ArrayList<GameSessionRecord> = ArrayList()
fun getStatistics(): List<String> {
return emptyList()
}
}
其中,ArrayList<GameSessionRecord>
意味着我们需要一个可变列表,其中包含这样的会话记录。: Serializable
是一个标记接口,它确保来自这个类的对象可以被转换成语言无关的表示。
注意
不幸的是,我们不能写val data = mutableListOf()
,因为它没有被标记为可序列化。我们还需要一个与语言无关的完整列表表示,所以我们必须回到具体的实现上。
这个data
列表代表了Statistics
对象的完整状态。我们从MainActivity
中得知,我们必须找到一种方法来保存和恢复状态,因为 Android 可能会意外地将任何活动置于暂停状态,导致其失去属性。因此,我们添加了两个函数,save()
和restore()
。作为参数,它们拥有由活动控制的状态保存和恢复所需的Bundle
实例。我们给onCreate()
添加一个函数调用:
override
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_statistics)
restoreData(savedInstanceState) // new!
showData(Statistics.getStatistics())
}
两个新功能如下:
fun restoreData(savedInstanceState: Bundle?) {
savedInstanceState?.run {
getSerializable("statistics.data")?.run {
Statistics.data.clear()
Statistics.data.addAll( this as
ArrayList<Statistics.GameSessionRecord>)
}
}
}
override fun onSaveInstanceState(outState: Bundle?) {
super.onSaveInstanceState(outState)
outState?.putSerializable("statistics.data",
Statistics.data)
}
?.run{ }
构造确保run
块只有在它前面的值不是null
时才会被执行。如果被执行,this
正好包含这个值。因此,getSerializable()
实际上转化为this.getSerializable()
,从而从savedInstanceState
对象转化为getSerializable()
。需要this as ...
是因为语言设计的getSerializable()
已经丢失了它的类型信息,所以我们必须显式地声明类型。
活动之间的沟通
游戏本身由MainActivity
类处理,统计数据由StatisticsActivity
处理。对于那些运行相同进程的人,我们选择最简单的通信方式:使用单例对象来共享数据。我们还没有涵盖流程;在大多数情况下,知道一个进程是围绕应用组件的一个技术支架,并且单例对象位于进程的边界内就足够了。
注意
对于在应用组件之间共享的更复杂的数据结构,可以考虑使用内置的 Android 数据库。
此外,将游戏会话的数据发送到统计组件不需要任何用户界面操作,因此不需要任何复杂的应用内通信。因此,我们向Statistics
singleton 对象添加了一个简单的函数,用于向列表添加记录。
object Statistics {
...
fun register(numberToGuess:Int, tries:Int) {
data.add(GameSessionRecord(numberToGuess,
tries))
}
}
现在,在MainActivity
中,我们调用这个函数。
fun guess(v:View) {
...
if (g < number) {
...
} else if (g > number) {
...
} else {
Statistics.register(number, tries)
...
}
...
}
注意,要实现这一点,需要导入Statistics
单例对象:import kotlinforandroid.book.numberguess.statistics.Statistics
。
实施统计计算
统计活动都设置好了,来自游戏活动的数据也传达了,我们现在准备好对Statistics
类中的数据进行一些计算。我们从一个简单的开始,游戏的次数。
private fun numberOfSessions() : Int =
data.size
下一个计算是正确猜测数字所需的平均尝试次数:
private fun averageTriesNeeded() : Double =
if(data.size > 0) data.map { rec -> rec.tries }
.sum().toDouble() / data.size ;
else 0.0
让我们一步一步地研究这个问题:
-
if()
...;else
...检查我们是否有数据。如果没有记录,平均值就无法建立,我们必须避免被0.0
分割。这种情况下,我们返回0.0
。 -
map()
将提供的 lambda 函数应用于data
的每个元素,并返回一个新的列表,以尝试次数作为元素。 -
sum()
可用于任何具有数字类型元素的集合。这里它显然总结了所有的尝试。 -
toDouble()
是必要的,因为否则Int
除法的Int
将导致Int
,而不是期望的Double.
我们用总数除以游戏的次数来得到结果。
我们要计算的下一个数字是猜测秘密数字所需尝试次数的标准差。如果你不知道什么是标准差,它会告诉我们一个数字的“粗糙度”,也就是说这些数字与我们刚刚计算的平均值相差多少。标准差的公式为
其中表示平均值。相应的 Kotlin 函数为:
private fun triesNeededStdDev() : Double {
if(data.size < 2) return 0.0
val avg = averageTriesNeeded()
return Math.sqrt(
data.map {
rec -> Math.pow(rec.tries - avg, 2.0)
}.sum() / (data.size - 1))
}
并具有以下特点:
-
因为我们需要循环中的平均值,所以我们引入一个
val
变量来保存平均值。因此,这里不可能使用fun functName() = ...
符号。我们不能用一个简单的表达式来表达。好吧,我们可以,但不能没有重大的性能损失。 -
对于标准差,我们至少需要两个记录。如果我们少了,我们就提前退出函数并返回
0.0
。 -
Math.sqrt()
计算计算所需的平方根:。
-
Math.pow(x, 2.0)
计算平方:x2。 -
类似于平均值计算,
map()
提取,其中
指定平均值。
-
我们再次通过应用
sum()
从结果列表中获取总和。 -
除以大小 1 是公式的一部分。
我们编写的下一个函数计算尝试次数的直方图。对于每一次可能需要的 k 尝试,我们计算出 k 出现在统计数据中的频率。这是映射图Int
→ Int
将 k 映射到其频率的典型情况。为我们的Statistics
类做这件事的 Kotlin 函数是这样的:
private fun neededTriesDistrib() : Map<Int, Int> =
data.groupBy({rec -> rec.tries}).
mapValues { me -> me.value.size }
它以这种方式执行:
-
我们看到了
groupBy()
的实际实现。在根据某种标准计算事物时,你会经常看到groupBy()
。这里我们根据尝试的次数来计算记录对象的数量。这正是这里的groupBy()
的函数参数的作用。 -
groupBy()
功能的结果是一张地图Int
→List<GameSessionRecord>
。不过,我们不需要每次尝试的列表,只需要记录的数量。这就是mapValues()
的用途;它从映射中转换每个映射到条目的值,并用列表大小替换它所在的列表。mapValues()
参数中的me
为Map.Entry
类型。这是mapValues()
规定的事情。Map.Entry
有两个属性:.key
和.value
。key
为试图,value
为列表。我们取值并从中得到大小。结果是所需的地图。
最后一个有趣的函数试图确定猜测一个数所需的尝试次数是否取决于该数本身。我们根据标准numberToGuess
计算尝试次数并取平均值。代码是这样的:
private
fun triesByNumberToGuess() : Map<Int, Double> =
data.groupBy({rec -> rec.numberToGuess})
.mapValues { me ->
me.value.map{it.tries}.sum().toDouble()
/ me.value.size }
让我们研究一下零件:
-
我们再次使用
groupBy()
函数。然而,这一次,我们需要计算numberToGuess
成员的数字,并相应地提取这个属性作为groupBy
键。 -
我们得到一个 map,其中有一列作为值的
GameSessionRecord
元素。我们获取每个列表并计算所需的平均尝试次数。为此,我们将列表映射到一个新的列表,该列表只包含尝试次数,取总和,将其转换为 double,然后除以列表大小。
现在剩下的就是修改getStatistics()
函数,以包含来自新计算函数的统计数字。例如,它可以读作:
fun getStatistics(): List<String> {
val twoDigits = DecimalFormat().
apply{ maximumFractionDigits = 2 }
val triesDistrib = neededTriesDistrib().
toSortedMap().toString()
val triesByNumber = triesByNumberToGuess().
toSortedMap().mapValues {
me -> twoDigits.format(me.value) }
.toString()
return listOf(
"Sessions: ${numberOfSessions()}",
"Average Tries: ${averageTriesNeeded()}",
"Tries Standard Dev: ${triesNeededStdDev()}",
"Tries Distrib: ${triesDistrib}",
"Tries by Number: ${triesByNumber}"
)
}
为此,需要将导入import java.text.DecimalFormat
添加到导入列表中。这里用的NumberFormat
是新的;我们需要它来避免双精度数被打印出太多的小数位数。.toSortedMap()
确保地图按照它们的键排序。
启动应用,玩几次游戏,然后启动统计活动,输出可能如图 9-5 所示。
图 9-5
数字猜测统计
十、真、假和未定:可空性
在学校里,你学到了对与错的二分法,你可能听说过没有别的了。到目前为止,读完这本书,你已经知道在 Kotlin 中存在一个布尔类型Boolean
,它有精确的可能值:true
和false
。句号。真的吗?
如果你想想现实生活,经验会告诉你一些别的东西。问某人:明天会下雨吗?答案可能是肯定的,也可能是否定的。不过,老实说,没有人有百分之百的把握知道答案。因此我们有真、假、和未定(或未知)。这种三分法被称为三值逻辑(也称为三值逻辑、三值逻辑、或三值逻辑)。为什么我们在这里谈论这个?这不是一本哲学书,是吗?对于类和对象,我们已经指出,计算机程序需要模拟真实世界的场景;因此,我们需要一些在计算机语言中既不是true
也不是false
的东西。
什么是空值
即使计算机语言开发人员不是真正的健全的哲学家,或者可能只是没有意识到这种三分法,未定从计算机语言历史的一开始就已经存在。只是没人这么叫它。比方说,你需要一个代表列表大小的变量。根据具体情况,大小为零的列表可能是有意义的,出于编码的原因,我们可能需要表明列表尚未定义。我们能做什么?嗯,大小的范围是 0,1,2,3,...
,所以我们只取一个通常没有意义的数字,定义这个来代表一个还没有定义的。你能猜出这是什么数字吗?一个可能的答案是1
。
有了数组,情况就更加多样化了。通常数组由某个指针变量定义,该变量指向计算机内存中数组的第一个元素。如果我们需要说数组还没有定义,我们使用一个没有意义的指针值。这可能是1
,但更实际的是值0
。由于技术原因,在内存地址0
实际启动一个数组是不可能的,所以未决定的0
是一个有效的选择。为了阐明真实内存地址和未确定内存地址之间的区别,表示后者的0
只是获得了一个新名称:null
。更有趣的是,在面向对象中我们还有指向类实例的指针,这些指针可以是null
以及表示未决定的或尚未定义的*。
除了紧接着true
和false
的第三个伪布尔undecided
,我们还有另一个用于数组和对象的undecided
。它们之间有什么联系?请看下面的代码片段:
val b:Boolean = ... // some condition
if(b) {
... // do something
} else {
... // do something else
}
这里我们分支讨论是否满足某些条件。由于可以用null
来表达它们的对象还没有被定义,在许多情况下你会有一个扩展版本:
val instance = ... // some object
val b:Boolean = ... // some condition
if(instance == null) {
... // do something
} else if(b) {
... // do something else
} else {
... // do something else
}
在这里,我们基于某事是真还是假,以及某事是否未定义来做出决定。现在,如果我们在一种虚构的计算机语言中有第三个布尔值undecided
,它可以读作
val b:Boolean = ... // some three-valued condition
ifundecided(b) {
... // do something
} if(b) {
... // do something else
} else {
... // do something else
}
这两个构造,一个虚构的三值布尔和一个null
对象引用,表达了相同的代码。这是两个犹豫不决的人相遇的地方。因为在 Kotlin 和我所知道的任何其他语言中,都不存在第三个布尔值,我们必须继续使用null
来实现这个目的。
null
有一个严重的问题:你还记得解引用操作符.
是做什么的吗?它从.
的左边取物体,用右边瞄准一个属性或函数。很自然地,对于未决定或null
的对象,这种取消引用是没有意义的。不幸的是,许多计算机语言在这里都不太好,如果我们试图取消对null
的引用,或者至少中断程序流并指示一个无效的程序流活动,就会崩溃。这种可空性给程序带来了不稳定性,困扰了几代开发人员。但是好处大于问题,所以仅仅避免可空性从来没有被认为是一个真正的选择。
Kotlin 内部如何处理可空性
Kotlin 引入了一些关于可空性的新概念,允许使用可空性,但避免了大多数相关的陷阱。首先,我们注意到默认情况下,Kotlin 不允许null
值在你的应用中偷偷摸摸。类似这样的东西
var p:SomeType = ...
...
p = null
任何类型的属性都不允许使用。这同样适用于构造函数和函数调用:
class A(var p:SomeType) ...
A(null) // does not compile
fun f(p:SomeType) { ... }
f(null) // does not compile
有了这样的不可空属性,通过.
的解引用将总是成功的。另一方面,如果我们希望一个属性、构造函数参数或函数参数可以为空,我们必须加上一个问号(?)到类型:
var p:SomeType? = ...
p = null // OK
class A(var p:SomeType?) ...
A(null) // OK
fun f(p:SomeType?) { ... }
f(null) // OK
注意
因为您必须添加一些东西来允许可空性,所以 Kotlin 稍微倾向于非可空性。事实上,在很多情况下你可以避免使用null
值,如果是这样,你很有可能有一个好的应用设计。
对于这种可空类型,Kotlin 知道通过.property
或.function()
的解引用可能会失败,并禁止使用它们:
var p:SomeType? = ...
...
p.property // does not compile
p.function() // does not compile
如果值碰巧不是null
,那么这也是被禁止的。
那么我们如何使用这样的可空属性呢?答案是我们必须使用 Kotlin 提供的空安全操作符之一。因此,对于解引用.
,有一个空安全变量?.
,可以用于可空属性:
var p:SomeType? = ...
...
p?.property // OK
p?.function() // OK
不同的是,如果p
是null
,p?.property
本身求值为null
,p?.function()
中的函数不会被调用,调用也求值为null
。
var p:SomeType? = null
val res:TypeOfProperty? = p?.property // -> null
val res2:TypeOfFunct? = p?.function() // -> null
// ... and function() not invoked
另一个被设计成零安全的操作员是埃尔维斯操作员?:
。我们已经知道这个了。如果那个不是null
,它就评估到它的左边,否则就评估到它的右边。
var p:String? = "Hello"
var s1 = p?:"default" // -> "Hello"
p = null
var s2 = p?:"default" // -> "default"
Kotlin 不能总是知道一个属性是否可以为空。在这种情况下,使用!!
操作符可能会有所帮助,它也被称为 not null 断言操作符。它取它的左边,不管它是否能评估为null
,都假定它不能是null
。如果你的应用需要别人写的程序,你可能偶尔会用到它。对于用其他语言编写并且没有应用 Kotlin 的空检查机制的库来说尤其如此。当然,如果你试图通过使用.
取消引用它,而这个值意外地是null
,你的应用将会崩溃。尽一切可能避免这种情况,或者知道该怎么做。
警告
使用!!
你基本上绕过了 Kotlin 的空检查机制,所以尽量避免它。
var p:String? = ...
// for whatever reason we know that p cannot be null
val len = p!!.length
// valid, because the !! indicates it cannot be null
// If it accidentally _is_ null, we'll crash here.
顺便说一下,如果你应用!!
,Kotlin 是相当聪明的。在同一个函数的后续语句中,它记得我们应用了这个断言,并继续假设值不能是null
。你可以写作
var p:String? = ...
val len = p!!.length
val intVal = p.toInt()
在这里,最后一个语句只编译,因为在这一行之前的某个地方有!!
。*
十一、处理相等性
在同一性和相等性之间有着明显的区别。如果两个事物实际上是相同的,那么它们就是相同的。如果你今天早上买了一支白蜡烛,姑且称之为 A,你购物袋里的白蜡烛和今天下午放在烛台上的白蜡烛是一样的,因此完全相同(假设这是你拥有的唯一一支蜡烛)。现在假设你从同一个制造商那里买了第二支相同型号的蜡烛 B。除了你有时会听到的一些语言错误,这两根蜡烛是不相同的。蜡烛 A 和 B 不一样,但是等于。这是因为它们具有相同的特征:相同的颜色、相同的重量、相同的直径和相同的长度。但是,打住:这不一定是真的。制造商称这种蜡烛重 300 克,但是高精度天平告诉我们蜡烛 A 重 300.00245 克,蜡烛 B 重 299.99734 克,但是如果你用厨房秤,蜡烛 A 和 B 的重量是一样的。因此,你可以看到,相等性取决于严格,它是相对的。
同一性和相等性之间的比较给我们上了重要的一课:同一性适用于相同的事物,而相等性是相对的,取决于某种定义。
Kotlin 的同一性
在 Kotlin 中有一个恒等运算符===
和它的反义词!==
。在 Kotlin,同一性代表参照同一性,这意味着如果两个变量指向同一个对象,或者引用同一个对象,它们被认为是相同的:
data class A(val x:Double)
val a = A(7.0)
val b = A(7.0)
val c = a
val aIdenticalToC = a === c // -> true
val aIdenticalToB = a === b // -> false
实际上,您可能不会经常使用标识。在大多数情况下,让不同的变量指向同一个对象无论如何都不是好的编码风格,此外,同一性对于不同的程序流来说没有太大的不同。最后,尽管两个对象中的所有属性都具有相同的值,但是这两个对象的比较结果都为 false,这是令人困惑的,并且会影响代码的可读性。因此,同一性检查的实际用途是有限的。
注意
在数据库环境中,还有另一个同一性的概念。那里通常有一个用于数据记录的数字 ID 字段。这个字段被用作相应对象标识的代理,而不是语言的引用标识===
。在本章中,我们不讨论这种数据库类型的同一性。
Kotlin 的相等性
对于等式,Kotlin 提供了比较运算符==
,以及它的反义词!=
。除了标识之外,一个对象必须告诉它是否等于其他对象。如果不显式地这样做,将使用相等检查的基本实现,这又回到了同一性检查。
对数字、布尔值、字符和字符串的相等性检查做了显而易见的事情:如果字符串包含完全相同的字符,则它们相等;如果字符包含相同的字母,则它们相等;如果数字和布尔值具有相同的值,则它们相等。
等于和哈希代码
类处理相等性检查的方式由两个函数控制:fun equals(otherObject:Any?): Boolean
和fun hashCode(): Int
。如果您的类需要相等检查,您必须实现这两个。我们需要两个函数来进行相等性检查,这似乎有点奇怪。为什么只有equals()
用于相等性检查是不够的?原因在于性能,精确的思路后面再讲。
首先,我们声明,如果我们为一些a1
和a2
编写a1 == a2
作为类A
的实例,函数equals()
在类A
上被调用,并且只有当它返回true
时,比较的结果也是true
。对于==
等式检查,那么equals()
函数实际上就足够了。
对于地图,情况就不同了。例如,如果我们有一个映射,将某个类A
的实例映射到任何对象
class A(val v:Int) {
override fun hashCode():Int {
return ...
}
override fun equals(other:Any?):Boolean {
return ...
}
}
val m = mapOf(A(7) to 8, A(8) to 9)
然后执行查找,如
val searchKey:A = ...
m[searchKey]
实际情况是这样的:
-
通过对其调用
hashCode()
来计算searchKey
的散列码。 -
[]
操作符(或get()
函数)应用一种非常快速的算法,根据整数散列键找到一个条目。 -
对于在散列关键字查找期间找到的条目,对所有可能的条目调用
equals()
。如果equals()
找到了精确的条目,[]
操作符返回该条目的相应值。 -
如果哈希键查找失败或者所有后续的
equals()
检查失败,那么[]
操作符也会失败并返回null.
我们观察到两件事:
-
只有当哈希代码查找成功时,
equals()
才会被调用。 -
为了使这个过程有意义,对于
hashCode()
函数,以下条件必须为真:(1)如果a == b
,我们也需要a.hashCode() == b.hashCode()
。②如果说a != b
,在大多数情况下我们也应该有a.hashCode() != b.hashCode()
。如果(1)不为真,地图查找功能将失败,如果(2)不为真,我们将不得不经常调用equals()
。
作为一个例子,考虑类
class Person(val lastName:String,
val firstName:String,
val birthday:String,
val gender:Char)
我们基于所有属性实现了一个equals()
函数:
class Person(val lastName:String,
val firstName:String,
val birthday:String,
val gender:Char) {
override fun equals(other:Any?):Boolean {
if(other == null) return false
if(other !is Person) return false
if(lastName != other.lastName) return false
if(firstName != other.firstName) return false
if(birthday != other.birthday) return false
if(gender != other.gender) return false
return true
}
}
如果提供比较的对象other
是null
或者不是Person
的实例,fun equals()
中的前两行返回null
。你会在几乎所有的equals()
实现中发现类似的代码行,尽管说你会在任何地方发现它们是夸张的;出于某种奇怪的原因,我们可能会接受与null
或其他类型的比较。
因为如果other
不是类型Person
我们就已经完成了,从第三行开始,Kotlin 知道other
是Person
的一个实例。这种自动类型检测有时被称为智能转换。接下来是对所有属性的逐步比较,只有当它们都匹配时,我们才返回true
。
对于一个hashCode()
函数,你可能会想到很多算法,在网上你也会找到一些关于它的想法。幸运的是,我们不必在这方面花费太多的脑力;包java.util
中的对象Objects
为此提供了一个方便的函数,我们可以写:
class Person(val lastName:String,
val firstName:String,
val birthday:String,
val gender:Char) {
override fun equals(other:Any?):Boolean {
if(other == null) return false
if(other !is Person) return false
if(lastName != other.lastName) return false
if(firstName != other.firstName) return false
if(birthday != other.birthday) return false
if(gender != other.gender) return false
return true
}
override fun hashCode(): Int {
return Objects.hash(super.hashCode(),
lastName, firstName, birthday, gender)
}
}
对于这种明显的情况,即等式依赖于检查是否相等的所有属性,Kotlin 有一个捷径。我们已经讲过:数据类。它们完全基于所有属性实现了一个equals()
和一个hashCode()
函数。对于Person
类,我们可以删除显式的equals()
和hashCode()
函数,只需编写
data class Person(val lastName:String,
val firstName:String,
val birthday:String,
val gender:Char)
练习 1
如果两个变量a
和b
相同,下列哪一项是正确的?
-
a
和b
指的是同一个物体。 -
a == b
必然产生true
。 -
a !== b
必然产生false
。
练习 2
如果两个变量a
和b
相等,a == b
,下列哪一项是正确的?
-
a.equals(b)
一定是真的。 -
a != b
必然产生false
。 -
a.hashCode() == b.hashCode()
一定是真的。
十二、回到数学:函数式编程
如果你看一下本书到目前为止给出的例子和练习,你会发现我们在两种编程风格之间波动:
[statement1] // do something
[statement2] // do something
[statement3] // do something
...
和
object.
doSomething1().
doSomething2().
doSomething3().
...
第一种风格是关于命令性地告诉程序必须做什么的序列,而第二种风格是关于在函数调用链中顺序地对对象应用函数。正因为如此,第一种风格也被称为命令式编程,第二种被称为函数式编程。函数式编程经常意味着使用函数作为其他函数的参数,这些函数被称为高阶函数。此外,函数式编程倾向于处理不可变的对象。
当使用命令式编程风格时,下面的观察变得很清楚:
-
我们有一系列语句,包括
if/else
、when
结构和循环。显然,语句的顺序很重要。 -
每个语句执行一个可识别的程序活动,命令式编程乍一看会产生易于理解的程序。
-
各种语句可以处理各种不同的对象。
-
每个语句可能会也可能不会改变它所嵌入的对象的状态,以及更多相关对象的状态。显然,对于各种结构构造,如循环和条件分支,对于所有涉及的对象,状态和状态转换的复杂性没有真正的限制。
-
语句包括一些函数调用,这些函数调用做了一些与它们的主要职责无关的意外事情。这种附属活动经常被称为副作用。因此,这些副作用可能是可预见的,也可能是不可预见的,可能会导致错误的程序活动。
相比之下,函数式编程具有以下特点:
-
功能构造主要指单个对象或单个对象集合。但是,根据函数参数,其他对象或集合可能会进入函数调用链。
-
函数式编程包括将函数作为函数参数来处理。因此,与命令式编程相比,它允许更高的抽象。
-
通过将函数调用结果作为参数或输入传递给其他函数,函数程序部分允许无状态编程风格,避免了复杂的状态转换。
由于函数不引用也不改变对象状态,我们也回到了一个更像数学的函数概念。请记住,在数学中,函数有一个输入并从中产生一个输出,忽略任何可能影响计算的“状态”。面向对象使用稍微改变的函数概念,其中对象的状态对函数调用的结果起着重要作用。因此,函数式编程将函数的概念转移到更像数学的语义上。图 12-1 显示了命令式和函数式编程的比较。
图 12-1
函数式编程与命令式编程
在这一点上,我们并不偏爱这两种编程模式中的任何一种,并且通过观察每种编程风格的特征,您可以看到两者都有各自的优点和缺点。我们继续这种态度,但是指出根据情况,函数构造可以导致更优雅和更稳定的程序。Kotlin 允许这两种风格,对于每个任务,由您决定哪种范式最适合您的需求。
在本章的其余部分,我们将加深对函数构造的了解,这样你就有了一个改进的工具集来编写好的软件。
Kotlin 和函数式编程
虽然 Kotlin 是一种成熟的命令式语言,但它也允许使用函数式编程风格,因为它具有以下特性:
-
Kotlin has a function type declaration:
([parameters]) -> [result-type]
where
[parameters]
is a comma-separated list of function parameter types. For example,val f : (Int,String) -> String = ...
不能省略
-> [result-type]
,所以如果一个函数没有返回任何东西,你就写-> Unit
。 -
函数是一等公民:任何变量都可以有内置类型,可以是任何类的实例,也可以是函数。通过允许函数作为参数,函数可以是高阶函数。
val f1 = { -> Log.d("LOG", "Hello Kotlin") } val f2 = { i:Int, s:String -> "${i}: ${s}" } ... fun ff(fun1: (Int,String) -> String):String { return fun1(7, "Hello") } ff(f2) ff( { i:Int,s:String -> "${i}- ${s}" } )
-
Kotlin has anonymous lambda functions; these are function literals that can be used as function invocation parameters. For example:
val f = { i:Int, s:String -> i.toString() + ": " + s } fun fun1(p: (Int,String) -> String) { p(42, "Hello") } fun1 { i:Int, s:String -> i.toString() + ": " + s }
在这里,Kotlin 推断出
f
必须是类型(Int, String) -> String
-
Kotlin 的标准库有很多针对对象、数组和集合的高阶函数。
-
函数调用
function({ [lambda-function] })
可以缩写为function { [lambda-function] }
-
函数调用
function(par1, par2, ..., { [lambda-function] })
可以缩写为function(par1, par2, ...) { [lambda-function] }
-
在函数类型
([parameters]) -> [result-type]
中,参数通常是ParType
的形式。一个特殊的接收器类型符号显示为A.(ParType)
。在这种情况下,类型A
是接收器类型,在类A
的实例中调用的函数意味着在函数规范this
中引用该实例。本章有一个专门的章节来讨论这种接收器类型符号。 -
Kotlin 变量可以是不可变的:
val s = ...
。不可变变量有助于避免状态处理并减少意外的副作用。 -
来自单例对象的函数可以通过在前面加上两个冒号(
::)
)来作为对象本身进行寻址。例如,如果你想使用来自object X { fun add(a:Int,b:Int):Int = a+b }
的函数add()
,你可以写object X { fun add(a:Int, b:Int): Int = a + b } ... val f : (Int,Int) -> Int = X::add
-
来自类的函数可以通过在前面加上两个冒号(
::
)作为接收者类型的对象来寻址。比如:class X { fun add(a:Int, b:Int): Int = a + b } ... val f : X.(Int,Int) -> Int = X::add
-
来自实例的函数可以通过在前面加上两个冒号(
::)
)作为对象来寻址。比如:class X { fun add(a:Int, b:Int): Int = a + b } ... val x1 = X() val f : (Int,Int) -> Int = x1::add
没有名字的函数:λ函数
我们知道正常的函数看起来像
fun functionName([parameters]): ReturnType {
...
}
或者
fun functionName([parameters]): ReturnType = ...
如果函数可以简化为一个表达式。以这种方式声明的函数由functionName
标识。问题是:不识别函数名,怎么可能有函数呢?对于答案,我们看包含数据的变量;我们在这里写道
val varName = 37
=
右侧的值也没有标识名。我们只需要变量名来处理数据。如果我们看看赋给变量的函数,
val f = { i:Int, s:String -> i.toString() + ": " + s }
我们可以看到,{ ... }
构造也没有标识名;函数被分配给变量使用。这样的函数是匿名的,通常被称为 lambda 函数。
注意
带有这种匿名函数的表达式有时也被称为λ演算。
这同样适用于作为参数传递给其他函数的函数:
ff( { i:Int,s:String -> "${i}- ${s}" } )
这里我们又有一个没有名字的函数,或者 lambda 函数。
要调用 lambda 函数,您需要编写以下代码之一:
[lambda_function].invoke([parameters])
lambda_function
Lambda 函数可以有结果。与通过某个return
语句返回值的普通函数相反,lambda 函数的结果是最后一行计算的结果。前面的例子
val f = { i:Int, s:String -> i.toString() + ": " + s }
因此,借助 lambda 函数的最后一行,返回参数Int
的字符串表示,加上参数:
,加上参数String
。
在只有一个参数的 lambda 函数中,为了简洁起见,可以省略参数声明,而可以使用特殊标识符it
来引用参数。因此,以下两个语句是等效的:
{ par ->
... // do something with 'par' }
{
... // do something with 'it' }
练习 1
编写一个 lambda 函数:一个接受一个s:String
和一个num:Int
并输出一个包含s
的num
副本的字符串的函数。
练习 2
重写
val f : (String) -> String = { s:String -> s + "!" }
要用it
来代替。
如果 lambda 函数有一个或多个定义中不需要的参数,可以使用下划线通配符(_)
作为参数名:
val f : (String, Int) -> String = { s:String, _ ->
// the Int parameter is not used
s + "!"
}
再次循环
在第九章中我们了解到我们可以通过写data.forEach(...)
或data.forEachIndexed(...)
来迭代数组或集合(集合,列表)的元素:
val arr = arrayOf("Joe", "Isabel", "John" }
arr.forEach { name ->
Log.d("A name: ${name}")
}
arr.forEachIndexed { i,s ->
Log.d("Name #${i}: ${name}")
}
这里的Log
来自包android.util
,所以您必须导入它:
import android.util.Log
虽然乍一看,forEach
或forEachIndexed
后面的{ }
看起来像是一个语句块,但是我们可以通过查看->
看到,实际上forEach
和forEachIndexed
实际上都是带有一个 lambda 函数作为参数的函数。那么,也有可能写出arr.forEach({ ... })
或arr.forEachIndexed({ ... });
括号可以省略,就像 Kotlin 中的情况一样,如果它们只是用花括号括起来的话。
在 Android Studio 中,我们还可以查看任何函数调用的源代码。为此,例如,将光标放在forEach
上,然后按 Ctrl+b。Android Studio 随后会打开该函数的源代码并显示如下:
public inline fun <T> Array<out T>.forEach(
action: (T) -> Unit): Unit {
for (element in this) action(element)
}
这里我们再次看到,在forEach
之后的是一个作为函数参数的函数。
注意
按 Ctrl+B 是了解 Kotlin 幕后情况的好方法。广泛使用它来理解 Kotlin 结构和功能。
因为forEach
和forEachIndexed
是函数而不是语言结构,它们可以直观地应用于任何看起来包含可以迭代的东西的对象。这包括数组和集合,它们是对数组和集合应用其他函数的结果。因此,我们可以在以循环结束的函数链中包含过滤器和映射,如
originalCollection.
filter([filter_function]).
map([mapping_function]).
take(37).
forEach { elem ->
...
}
在开始循环之前,我们首先应用一个过滤器,然后一个映射,然后减少到第一个37
元素。我们可以看到,由于函数被允许作为函数参数,我们可以实现一个函数链,并避免中间变量作为数据持有者。
接收器的功能
被认为是函数对象并嵌入在上下文中的函数,例如,类中的函数,被称为具有接收器类型的函数。您将它们声明如下:
val f : ReceiverType.([parameters]) = ...
这样一个函数就像是类ReceiverType
的成员函数,在函数实现内部,你可以使用指向实例的this,
。例如,在
class A {
var d:Double = 0.0
fun plus(x:Double) = d + x
}
val f : A.(Double) -> Double =
{ x:Double -> this.d - x }
fun A.minus(x:Double) = f
函数f
就是这样一个带有接收器类型的函数。我们用它来用一个minus()
函数扩展类A
,并且f
实现内部的this.d
指向接收者类型内部的属性d
,在本例中为A
。
在上一节中,我们已经注意到,对类内部函数的直接引用自动就是这样一个具有接收器类型的函数,因为它只在类的环境中工作:
class X {
fun add(a:Int, b:Int): Int = a + b
}
...
val f : X.(Int,Int) -> Int = X::add
内嵌函数
请看这段代码:
class A {
fun function1(i:Int, f:(Int) -> String): String {
return f(i)
}
fun function2() {
val a = 7
val s = function1(8) {
i -> "8 + a = " + (i+a) }
}
}
在对function1()
的调用中,我们以 lambda 函数i -> ...
的形式传递一个函数对象。这个函数对象必须在运行时创建,此外,编译器必须允许将本地属性a
传递给该对象。这带来了显著的性能损失。更准确地说,Kotlin 编译器会产生类似这样的结果:
public class A {
public String function1(int i,
Function1<? super Integer, String> f) {
return f.invoke(i);
}
public void function2() {
int a = 7;
String s2 = this.function1(8,
new Function1<Integer, String>(a){
final int $a;
public String invoke(int i) {
return "8 + a = " + (i + this.$a);
}
{
this.$a = n;
super(1);
}
});
}
}
这是 Java 语言代码,但是不用深入细节,我们看到通过new Function1(...)
一个函数对象必须被实例化,并且在它里面属性a
的副本将被创建。
如果这种性能损失造成了问题,那么function1()
可以被内联:
class A {
inline fun function1(i:Int, f:(Int) -> String): String
{
return f(i)
}
fun function2() {
val a = 7
val s = function1(8) {
i -> "8 + a = " + (i+a) }
}
}
这是什么意思?它基本上是说,每当使用内联函数时,不会发生实际的函数调用,而是将函数代码复制到使用该函数的地方。再次查看编译器输出,这次我们得到
public class A {
public String function1(int i,
Function1<? super Integer, String> f) {
return f.invoke(i);
}
public void function2() {
int a = 7;
int i$iv;
int i = i$iv = 8;
String s2 = "8 + a = " + (i + a);
}
}
您可以从内部function2()
看到,内联函数function1
没有被调用;相反,这个片段
int i$iv;
int i = i$iv = 8;
String s2 = "8 + a = " + (i + a);
替换函数调用。没有发生对象实例化,因此与没有内联的变体相比,这段代码运行得更快。
使用内联函数会产生一些不寻常的特性。例如,return
语句的行为与内联函数不同。此外,可以只内嵌专用的 lambda 函数参数,而让其他函数创建函数对象。此外,内联函数支持一种特殊的类型参数,称为具体化类型参数 、,允许在运行时访问该类型的参数。这里不赘述;如果你感兴趣,请参考在线 Kotlin 文档中的函数。
过滤
如果你有一些对象的列表,比如一个data class Employee(val firstName:String, val lastName:String, val ssn:String, val yearHired:Int)
的实例,在算法中你经常需要根据一些标准提取列表成员。使用命令式编程风格,这通常会产生如下代码片段:
data class Employee(val firstName:String,
val lastName:String,
val ssn:String,
val yearHired:Int)
val employees = listOf(
Employee("John", "Smith", "123-12-0001", 1987),
Employee("Linda", "Thundergaard", "123-12-0002", 1987),
Employee("Lewis", "Black", "123-12-0003", 1977),
Employee("Evans", "Brightsmith", "123-12-0004", 1991)
)
val before1990 = mutableListOf<Employee>()
for(empl in employees) {
if(empl.yearHired < 1990) before1990.add(empl)
}
... // do something with before1990
这段代码看起来非常容易理解,并且似乎充分解决了过滤任务,但是如果我们更仔细地观察它,就会发现有几个问题。
-
在开始循环之前,我们需要在单独的语句中创建接收列表。结果列表创建与循环分离;代码并不阻止我们在列表创建和循环之间添加更多的语句,例如,因为未来的需求。这种分离可能会引入复杂的状态转换,从而使程序不稳定。
-
在循环内部,结果列表只是一个局部变量;循环存在的唯一目的是填充结果列表,但是代码并不阻止我们在那里做其他事情,最终降低代码的可读性。
-
如果列表变得非常长,我们可能会尝试在循环内部并行化代码;也就是说,让几个进程同时进行过滤。这很容易导致并发问题,因为
before1990
变量只是一个普通的局部属性。让几个进程同时访问同一个集合经常会导致数据一致性失败。 -
有了更复杂的过滤标准,我们可能会在循环中各种
if-else/when
分支的复杂堆叠中结束。
对几乎所有这些问题的补救措施包括切换到功能代码:
data class Employee(val firstName:String,
val lastName:String,
val ssn:String,
val yearHired:Int)
val employees = listOf(
Employee("John", "Smith", "123-12-0001", 1987),
Employee("Linda", "Thundergaard", "123-12-0002", 1987),
Employee("Lewis", "Black", "123-12-0003", 1977),
Employee("Evans", "Brightsmith", "123-12-0004", 1991)
)
val before1990 = employees.filter {
it.yearHired < 1990 }.toList()
... // do something with before1990
这里我们可以避免在filter()
的参数中写emp -> ...
,因为只有一个函数参数,并且我们使用自动的it
变量。在filter()
之后,我们可以插入更多的过滤器,或者一个映射函数,就像我们在第九章中看到的那样。
练习 3
通过应用一个只允许名字以 l 开头的雇员通过的过滤器,创建另一个列表startsWithL
。注意:String
有一个startsWith()
函数可以用于这个目的。
十三、关于类型安全:泛型
泛型是一个术语,用来表示一组允许我们向类型添加类型参数的语言特性。例如,考虑一个简单的类,它具有以Int
对象的形式添加元素的功能:
class AdderInt {
fun add(i:Int) {
...
}
}
另一个用于String
对象:
class AdderString {
fun add(s:String) {
...
}
}
除了在add()
函数内部发生的事情之外,这些类看起来非常相似,所以我们可以考虑一个语言特性来抽象要添加的元素的类型。这样的语言特性存在于 Kotlin 中,它被称为泛型。相应的结构如下:
class Adder<T> {
fun add(toAdd:T) {
...
}
}
其中T
是类型参数。在这里,除了T
,任何其他名称都可以用于类型参数,但是在许多项目中,您经常会发现T
、R
、S
、U
、A
或B
作为类型参数名称。
为了实例化这样的类,编译器必须知道该类型。要么必须显式指定类型,如
class Adder<T> {
fun add(toAdd:T) {
...
}
}
val intAdder = Adder<Int>()
val stringAdder = Adder<String>()
或者编译器必须能够推断类型,如
class Adder<T> {
fun add(toAdd:T) {
...
}
}
val intAdder:Adder<Int> = Adder()
val stringAdder:Adder<String> = Adder()
注意
泛型是编译时构造。在编译器生成的代码中,不会出现泛型信息。这种效应通常被称为型擦除。
我们已经在书中多次使用了这种通用类型。您可能还记得,作为两个数据元素的持有者,我们讨论过参数化的Pair
类型:
val p1 = Pair<String, String>("A", "B")
val p2 = Pair<Int,String>(1, "A")
当然,我们也谈到了各种集合类型,例如:
val l1: List<String> = listOf("A","B","C")
val l2: MutableList<Int> = mutableListOf(1, 2, 3)
到目前为止,我们只是照原样接受了泛型,没有进一步解释它们。毕竟写List<String>
,我们说的一串的推演是显而易见的。
一旦我们开始更彻底地审视收藏品,这个故事就变得有趣了。问题是:如果我们有一个MutableList<Any>
和一个MutableList<String>
,它们是如何关联的?我们可以写val l:MutableList<Any> = mutableListOf<String>("A", "B")
吗?或者换句话说,MutableList<-String>
是MutableList<Any>
的子类吗?事实并非如此,在本章的剩余部分,我们将深入讨论泛型,并试图理解类型关系。
简单泛型
首先,让我们解决基本问题。要对类或接口进行类型参数化,可以在类型名后的尖括号内添加一个逗号分隔的形式类型参数列表:
class TheClass<[type-list]> {
[class-body]
}
interface TheInterface<[type-list]> {
[interface-body]
}
在类或接口内部,包括任何构造函数和init{}
块,你可以像其他类型一样使用类型参数。例如:
class TheClass<A, B>(val p1: A, val p2: B?) {
constructor(p1:A) : this(p1, null)
init {
var x:A = p1
...
}
fun function(p: A) : B? = p2
}
练习 1
类似于Pair
类,创建一个可以保存四个数据元素的类Quadruple
。使用示例Int
、Int
、Double
和String
类型元素创建一个实例。
声明方差异
如果我们谈论泛型,术语方差表示在赋值中使用更具体或更不具体类型的能力。知道了Any
比String
更不具体,方差就出现在以下问题中:是否可能出现以下情况之一:
class A<T> { ... }
var a = A<String>()
var b = A<Any>()
a = b // variance?
... or ...
b = a // variance?
为什么这对我们很重要?如果我们看看类型安全,这个问题的答案就变得很清楚了。考虑下面的代码片段:
class A<T> {
fun add(p:T) { ... }
}
var a = A<String>()
var b = A<Any>()
b = a // variance?
b.add(37)
将37
添加到A<Any>
不会造成问题,因为任何类型都是Any
的子类。然而,因为b
通过b = a
指向了A<String>
的一个实例,我们会得到一个运行时错误,因为37
不是一个字符串。Kotlin 编译器认识到了这个问题,不允许使用b = a
赋值。
同样,分配a = b
也会带来一个问题。这一点更加明显,因为a
只适用于String
元素,不能像b
那样处理Int
类型的值。
class A<T> {
fun extract(): T = ...
}
var a = A<String>()
var b = A<Any>()
a = b // variance?
val extracted:String = a.extract()
最后一条语句中的a.extract()
可以同时计算为Any
和String
类型,因为b
和现在的a
可以包含Int
对象,但是a
不允许包含Int
对象,因为它只能处理String
元素。因此 Kotlin 也不允许a = b
。
我们能做什么?不允许任何差异可能是一种选择,但这太苛刻了。同样,查看分配了b = a
的第一个样本,我们可以看到写入b
导致了错误。读书怎么样?考虑一下这个:
class A<T> {
fun extract(): T = ...
}
var a = A<String>()
var b = A<Any>()
b = a // variance?
val extracted:String = b.extract()
就类型而言,最后一个操作是安全的,所以我们实际上在这里应该不会有问题。
完全相反的情况,取a = b
样本并应用写操作而不是读操作,如
class A<T> {
fun add(p:T) { ... }
}
var a = A<String>()
var b = A<Any>()
a = b // variance?
a.add("World")
应该也不成问题。我们可以给a
和b
添加字符串。
为了使这种差异成为可能,Kotlin 允许我们向通用参数添加一个差异注释。如果我们将out
注释添加到类型参数中,第一个带有b = a
的示例会编译:
class A<out T> {
fun extract(): T = ...
}
var a = A<String>()
var b = A<Any>()
b = a // variance? YES!
val extracted:String = b.extract()
// OK, because we are reading!
如果我们将in
注释添加到类型参数中,第二个带有a = b
的示例将会编译:
class A<in T> {
fun add(p:T) { ... }
}
var a = A<String>()
var b = A<Any>()
a = b // variance? YES!.add("World")
// OK, because we are writing!
因此,通过将in
或out
variance 注释添加到类型参数中,并限制类操作只允许泛型类型的输入或泛型类型的输出,在 Kotlin 中就有可能出现差异!如果两者都需要,可以使用不同的构造,如本章后面的“类型投影”一节所述。
注意
类的out
方差也被称为协方差,而in
方差被称为方差 。
名称声明方差异源于在类的声明中声明in
或out
差异。其他语言,比如 Java,使用一种不同类型的方差,这种方差在使用类时生效,因此被称为使用方方差。
不可变集合的差异
因为不可变集合不能被写入,Kotlin 自动使它们协变。如果您愿意,可以考虑将 Kotlin 的out
variance 注释隐式添加到不可变集合中。
由于这个事实,一个List<SomeClass>
可以被分配给一个List<SomeClassSuper>
,其中SomeClassSuper
是SomeClass
的超类。例如:
val coll1 = listOf("A", "B") // immutable
val coll2:List<Any> = coll1 // allowed!
类型投影
在上一节中,我们看到对于out
样式变化,相应的类不允许使用泛型类型作为函数参数,对于in
样式变化,我们相应地不能使用返回泛型类型的函数。当然,如果我们在一个类中需要两种功能,这是不令人满意的。Kotlin 也有这类需求的答案。它被称为型投影,因为它的目标是在使用一个类的不同函数时的方差,所以它是使用方方差的 Kotlin 等价物。
想法如下:我们仍然使用in
和out
方差注释,但是我们没有为整个类声明它们,而是将它们添加到函数参数中。我们稍微改写了上一节的示例,并添加了in
和out
方差注释:
class Producer<T> {
fun getData(): Iterable<T>? = null
}
class Consumer<T> {
fun setData(p:Iterable<T>) { }
}
class A<T> {
fun add(p:Producer<out T>) { }
fun extractTo(p:Consumer<in T>) { }
}
add()
函数中的out
表示我们需要一个产生T
对象的对象,extractTo()
函数中的in
表示一个消耗T
对象的对象。让我们看一些客户端代码:
var a = A<String>()
var b = A<Any>()
var inputStrings = Producer<String>()
var inputAny = Producer<Any>()
a.add(inputStrings)
a.add(inputAny) // FAILS!
b.add(inputStrings) // only because o "out"
b.add(inputAny)
var outputAny = Consumer<Any>()
var outputStrings = Consumer<String>()
a.extractTo(outputAny) // only because of "in" a.extractTo(outputStrings)
b.extractTo(outputAny)
b.extractTo(outputStrings) // FAILS!
你可以看到a.add(inputAny)
失败了,因为inputAny
产生了各种各样的对象,而a
只能接受String
对象。类似地,b.extractTo(outputStrings)
失败,因为b
包含任何类型的对象,而outputStrings
只能接收String
对象。到目前为止,这与方差无关。这个故事对b.add(inputStrings)
来说变得有趣了。允许将字符串添加到A<Any>
的行为当然是有意义的,但是它只在我们将out
投影添加到函数参数时才起作用。类似地,a.extractTo(outputAny)
虽然肯定是可取的,但只是因为in
投影才起作用。
恒星投影
如果您有一个带有in
或out
方差注释的类或接口,您可以使用特殊的通配符*
,其含义如下:
-
对于
out
差异标注,*
表示out Any?
。 -
对于
in
差异标注,*
表示in Nothing
。
记住Any
是任何类的超类,Nothing
是任何类的子类。
例如:
interface Interf<in A, out B> {
...
}
val x:Interf<*, Int> = ...
// ... same as Interf<in Nothing, Int>
val y:Interf<Int, *> = ...
// ... same as Interf<Int, out Any?>
如果您对类型一无所知,但仍然希望满足类或接口声明规定的差异语义,则可以使用星号通配符。
通用函数
Kotlin 中的函数也可以是泛型的,这意味着它们的参数或它们的一些参数可以具有泛型类型。在这种情况下,通用类型指示符必须作为逗号分隔的列表添加到function
关键字之后的尖括号中。泛型类型也可以出现在函数的返回类型中。这里有一个例子。
fun <A> fun1(par1:A, par2:Int) {
...
}
fun <A, B> fun2(par1:A, par2:B) {
...
}
fun <A> fun3(par1:String) : A {
...
}
fun <A> fun4(par1:String) : List<A> {
...
}
要调用这样的函数,原则上必须在尖括号中的函数名称后指定具体类型:
fun1<String>("Hello", 37)
fun2<Int, String>(37, "World")
val s:String = fun3<String>("A")
然而,正如 Kotlin 中经常出现的情况,如果 Kotlin 可以推断类型,则可以省略类型参数。
通用约束
到目前为止,对于泛型类型标识符在实例化期间可以映射到的类型没有任何限制。因此,在class TheClass<T>
中,T
通用类型可以是任何东西,TheClass<Int>
、TheClass<String>
、TheClass<Any>
或其他任何东西。但是,可以将类型限制为某个类或接口或其子类型之一。为了这个目标,你写道
<T : SpecificType>
印度历的 7 月
class <T : Number> { ... }
它将T
限制在一个Number
或它的任何子类中,比如Int
或Double
。
这很有用。例如,考虑一个允许我们向Double
属性添加内容的类。
class Adder<T> {
var v:Double = 0.0
fun add(value:T) {
v += value.toDouble()
}
}
你明白为什么这个代码是非法的吗?我们说value
的类型是T
,但是类不知道在实例化过程中T
是什么,所以不清楚T.toDouble()
函数是否实际存在。因为我们知道在编译之后所有的类型都被删除了,编译器没有机会检查是否有一个toDouble()
,因此它将代码标记为非法。如果你查看 API 文档,你会发现Int
、Long
、Short
、Byte
、Float
和Double
都是kotlin.Number
的子类,它们都有一个toDouble()
函数。如果我们有办法说T
是Number
或者它的子类,我们就可以使代码合法。
Kotlin 确实有一种方法来限制泛型类型,它读起来是<T : SpecificType>
。因为T
然后被限制在SpecificType
或者它在类型层次结构中更低的任何子类型,这也被称为upper type bound
。为了使我们的Adder
类合法,我们所要做的就是写
class Adder<T : Number> {
var v:Double = 0.0
fun add(value:T) {
// T is a Number, so it _has_ a toDouble()
v += value.toDouble()
}
}
这种类型约束也可以添加到泛型函数中,所以我们实际上可以将Adder
类重写为:
class Adder {
var v:Double = 0.0
fun <T:Number> add(value:T) {
v += value.toDouble()
}
}
这具有特别的优点,即在实例化期间不需要解析泛型类型。
val adder = Adder()
adder.add(37)
adder.add(3.14)
adder.add(1.0f)
请注意,与类继承不同,类型界限可以多次声明。这在尖括号内是不可能发生的,但是有一个特殊的构造来处理这种情况。
class TheClass<T> where T : UpperBound1,
T : UpperBound2, ...
{
...
}
或者
fun <T> functionName(...) where T : UpperBound1,
T : UpperBound2, ...
{
...
}
对于一般函数。
你可能不得不习惯的一点是,泛型类可能出现在冒号(:
)的两边,这是完全可以接受的
class TheClass <T : Comparable<T>> {
...
}
来表示 T 必须是Comparable
的子类。
练习 2
用类型参数T
和合适的类型绑定编写一个泛型类Sorter
,它有一个属性val list:MutableList<T>
和一个函数fun add(value:T)
。每次调用函数时,必须将参数添加到列表中,并且必须根据列表属性的自然排序顺序对其进行排序。
十四、添加提示:注解
注解用于向代码中添加元信息。那是什么意思?考虑以下类:
class Adder {
fun add(a:Double, b:Double) = a + b
}
class Subtractor {
fun subtract(a:Double, b:Double) = a - b
}
如果我们有一个更大的算术计算项目,其中各种操作由像这里的Adder
和Subtractor
这样的类来处理,我们可以有这样的东西
val eng = CalculationEngine()
...
eng.registerAdder(Adder::class, "add") eng.registerSubtractor(Subtractor::class, "subtract")
...
用于注册特定的低级操作。
然而,我们可以遵循一种不同的方法,操作者以某种方式向框架宣布他们的能力。他们可以通过特殊的文档标记来做到这一点,例如
/**
* @Operator: ADDING
* @Function: add
*/
class Adder {
fun add(a:Double, b:Double) = a + b
}
/**
* @Operator: SUBTRACTING
* @Function: subtract
*/
class Subtractor {
fun subtract(a:Double, b:Double) = a - b
}
然后,一些解析器可以查看源代码,找出各种操作符需要哪些类和函数。
注意
一个框架是为软件提供脚手架结构的类、接口和单例对象的集合。框架本身并不是一个可执行的程序,而是一个软件项目使用框架来建立一个标准化的结构。因此,使用特定框架的不同项目表现出相似的结构,如果开发人员知道一个嵌入到特定框架中的项目,那么理解使用相同框架的其他项目将会更容易。
这种让类向程序声明自己的方法经常在服务器环境中使用,在这种环境中,程序需要能够通过网络与客户机通信。
然而,这种方法有一个问题。因为元信息是从文档内部呈现的,所以编译器不可能检查标签的正确性。关于编译器,文档的内容是完全不重要的,并且应该是不重要的,因为这是语言规范所说的。
Kotlin 中的注解
这就是注解进入游戏的地方。它们正是为了这种任务而存在的:不干涉类的主要职责,而是为程序或框架提供元信息,用于维护或注册目的。注解如下所示:
@AnnotationName
或者
@AnnotationName(...)
如果有参数。许多语言元素都可以用这样的注解来标记:文件、类、接口、单例对象、函数、属性、lambdas、语句,甚至其他注解。前面的计算引擎示例的运算符类可以读作
@Operator(ADDING)
class Adder {
@OperatorFunction
fun add(a:Double, b:Double) = a + b
}
@Operator(SUBTRACTING)
class Subtractor {
@OperatorFunction
fun subtract(a:Double, b:Double) = a - b
}
现在编译器的情况更好了。因为注解是语言的一部分,编译器可以检查它们是否存在,拼写是否正确,是否提供了正确的参数。
在接下来的部分中,我们首先讨论注解特征,然后讨论 Kotlin 提供的注解。然后,我们将介绍如何构建和使用我们自己的注解。
注解特征
注解由注解类声明,如下所示:
annotation class AnnotationName
我们将在后面的章节中介绍如何构建我们自己的注解。现在我们提到声明,因为注解有它们自己的注解描述的特征,这些注解是元注解:
@Target(...)
@Retention(...)
@Repeatable
@MustBeDocumented
annotation class AnnotationName
您可以按任意顺序使用它们的任意组合,如果没有指定,它们都有默认值。我们在这里描述它们,包括可能的参数。
-
@Target(...)
Here you specify the possible element types to which the annotation can be applied. The parameter is a comma-separated list of any of the following (all of them are fields of the enumeration
kotlin.annotation.AnnotationTarget
):-
CLASS
:所有的类、接口、单例对象和注解类。 -
ANNOTATION_CLASS
:仅注解类。 -
PROPERTY
:属性。 -
FIELD
:作为属性的数据持有者的字段。请注意,通过 getters 和 setters 获得的属性不一定需要字段。然而,如果有一个字段,这个注解目标指向那个字段。您将它与PROPERTY
目标一起放在属性声明的前面。 -
LOCAL_VARIABLE
:任意局部变量(函数内的val
或var
)。 -
VALUE_PARAMETER
:函数或构造函数参数。 -
CONSTRUCTOR
:一级或二级建造师。如果您想要注解一个主构造函数,您必须使用添加了constructor
关键字的符号;例如,class Xyz @MyAnnot constructor(val p1:Int, ...).
-
FUNCTION
:函数(不包括构造函数)。 -
PROPERTY_GETTER
:属性 getters。 -
PROPERTY_SETTER
:财产设定者。 -
TYPE
:类型注解,如val x: @MyAnnot Int = ...
-
EXPRESSION
:语句(必须包含一个表达式)。 -
FILE
:文件标注。您必须在package
声明之前指定这一点,另外在@
和注解名之间添加一个file:
,如@file:AnnotationName
。 -
我们还没有讨论类型别名。它们只是类型的新名称,如在
typealias ABC = SomeClass<Int>
中。这种注解类型适用于这样的typealias
声明。
如果未指定,目标为
CLASS
、PROPERTY
、LOCAL_VARIABLE
、VALUE_PARAMETER
、CONSTRUCTOR
、FUNCTION
、PROPERTY_GETTER
和PROPERTY_SETTER
。 -
-
@Retention(...)
This specifies where the annotation information goes during compilation and whether it is visible using one of the following (all are fields from the enumeration class
kotlin.annotation.AnnotationRetention
):-
SOURCE
:注解仅存在于源代码中;编译器会移除它。 -
BINARY
:注解存在于编译后的类、接口或单例对象中。使用反射在运行时查询注解是不可能的。 -
RUNTIME
:注解存在于编译后的类、接口或单例对象中,在运行时无法使用反射查询注解。
默认值为运行时。
-
-
@Repeatable
如果您想让注解不止出现一次,请添加此选项。
-
@MustBeDocumented
如果您希望注解显示在公共 API 文档中,请添加此选项。
您可以看到,对于类、接口、单例对象、属性和本地属性,如果您希望注解在编译后的文件中明显显示,您不必指定特殊的特征。
应用注解
通常,注解写在要应用注解的元素的前面。这个故事变得有点复杂,因为元素的含义并不总是很清楚。考虑这个例子:
class Xyz {
@MyAnnot var d1:Double = 1.0
}
这里我们有四个可以应用注解的元素:属性、属性 getter、属性 setter 和数据字段。出于这个原因,Kotlin 以在@
和注解名之间写一个qualifier:
的形式引入了使用站点目标。以下是可用的使用站点目标:
-
file
We know that a Kotlin file can contain properties and functions outside classes, interfaces, and singleton objects. For an annotation applying to such a file, you write
@file:AnnotationName
in front of the package declaration. For example:@file:JvmName("Foo") package com.xyz.project ...
将内部创建的类命名为
Foo
。 -
property
批注与属性相关联。注意,如果使用 Java 访问 Kotlin 类,这个注解对 Java 是不可见的。
-
field
注解与属性后面的数据字段相关联。
-
get
注解与属性 getter 相关联。
-
set
注解与属性 setter 相关联。
-
receiver
注解与扩展函数或属性的接收器参数相关联。
-
param
注解与构造函数参数相关联。
-
setparam
注解与属性 setter 参数相关联。
-
delegate
注解与存储委托实例的字段相关联。
如果没有指定 use-site 目标,@Target
元注解用于查找要注解的元素。如果有几种可能,排名是param > property > field
。
以下代码显示了各种注解应用示例(为简单起见,所有注解都没有参数,并假定指定了正确的@Target
):
// An annotation applying to a file (the implicit
// internal class generated)
@file:Annot
package com.xyz.project
...
// An annotation applying to a class, a singleton
// object, or an interface
@Annot class TheName ...
@Annot object TheName ...
@Annot interface TheName ...
// An annotation applying to a function
@Annot fun theName() { ... }
// An annotation applying to a property
@property:Annot val theName = ...
@Annot var theName = ...
class SomeClass(@property:Annot var param:Type, ...) ...
// An annotation applying to a function parameter
f(@Annot p:Int, ...) { ... }
// An annotation applying to a constructor
class TheName @annot constructor(...) ...
// An annotation applying to a constructor parameter
class SomeClass(@param:Annot val param:Type, ...) ...
// An annotation applying to a lambda function
val f = @annot { par:Int -> ... }
// An annotation applying to the data field
// behind a property
@field:Annot val theName = ...
class SomeClass(@field:Annot val param:Type, ...) ...
// An annotation applying to a property setter
@set:Annot var theName = ...
var theName = 37 @Annot set(...) { ... }
class SomeClass(@set:Annot var param:Type, ...) ...
// An annotation applying to a property getter
@get:Annot var theName = ...
var theName = 37 @Annot get() = ...
class SomeClass(@get:Annot var param:Type, ...) ...
// An annotation applying to a property setter
// parameter
var theName:Int = 37
set(@setparam:Annot p:String) { })
// An annotation applying to a receiver
@receiver:Annot fun String.xyz() { }
// An annotation applying to a delegate
class Derived(@delegate:Annot b: Base) : Base by b
要使用注解作为注解参数,您不需要添加一个@
前缀:
@Annot(AnotherAnnot)
带数组参数的批注
使用数组作为注解构造函数参数很容易:只需在注解声明中使用vararg
限定符,在注解实例化中使用逗号分隔的参数列表:
annotation class Annot(vararg val params:String)
...
@Annot("A", "B", "C", ...) val prop:Int = ...
如果您需要使用包含在项目中的 Java 库中的带有单个数组参数的注解,该参数会自动转换成一个vararg
参数,所以您基本上可以这样做:
@field:JavaAnnot("A", "B", "C", ...) val prop:Int = ...
如果注解有几个命名参数,其中一个或几个是数组,则使用特殊的数组文字符号:
@Annot(param1 = 37, arrParam = [37, 42, 6], ...)
阅读注解
要读取保留类型为SOURCE
的注解,您需要一个特殊的注解处理器。请记住,对于SOURCE
类型的注解,Kotlin 编译器会在编译过程中移除注解,因此在这种情况下,我们必须在编译器开始工作之前安装一些软件来查看源代码。大多数源类型注解处理发生在较大的服务器框架项目中;在这里,注解被用来生成一些合成的 Kotlin 或 Java 代码,这些代码将类粘合在一起,以模拟复杂的数据库结构。有一个特殊的插件用于此目的,KAPT,它允许包含这样的源类型注解预处理程序。
您可以在在线 Kotlin 文档中找到更多关于 KAPT 用法的信息。在本节的剩余部分,我们将讨论RUNTIME
保留类型注解处理。
为了读取由 Kotlin 编译器编译并最终由运行时引擎执行的字节码中的注解,使用了反射 API 。我们将在本书的后面讨论反射 API 这里我们只提到注解处理方面。
注意
要使用反射,kotlin-reflect.jar
必须在类路径中。这意味着你必须在你的模块的build.gradle
文件的依赖部分添加implementation "org.jetbrains.kotlin: kotlin-reflect:$kotlin_version"
。
要获取最基本元素的注解,请参见表 14-1 ,该表描述了如何获取注解或注解列表。
表 14-1。
按元素标注
|元素
|
阅读注解
|
| --- | --- |
| 类、单例对象和接口 | 使用TheName::class.annotations
要获得kotlin.Annotation
对象的列表,您可以进一步研究。例如,您可以使用属性.annotationClass
来获取每个注解的类。如果您有一个属性,并且首先需要获取相应的类,请使用property::class.annotations
要阅读某个注解,请使用val annot = TheName::class.findAnnotation<AnnotationType>()
在这里用注解的类名代替AnnotationType
。例如,您可以从这里通过annot?.paramName
读取注解的参数。 |
| 性能 | 使用val prop = ClassName::propertyNameval annots = prop.annotationsval annot = prop.findAnnotation<AnnotationType>()
通过名称获取一个属性,并从中获得一个注解列表或搜索某个注解。 |
| 菲尔茨 | 要访问字段的注解,请使用val prop = ClassName::propertyNameval field = prop.javaFieldval annotations = field?.annotations
|
| 功能 | 要通过名称访问非重载功能,请写入TheClass::functionName
。如果您有几个函数使用相同的名称,但参数不同,您可以编写val funName = "functionName" // <- choose your ownval pars = listOf(Int::class) // <- choose your ownval function = TheClass::class. declaredFunctions.filter { it.name == funName } ?.find { f -> val types = f.valueParameters.map{ it.type.jvmErasure} types == pars``}
一旦有了这个函数,您就可以使用.annotations
来查看注解列表,或者使用.findAnnotation<AnnotationType>()
来搜索某个注解。 |
内置注解
Kotlin 从一开始就提供了一些注解。表 14-2 显示了一些通用注解。
图 14-1。
在 Android Studio 中隐藏注解
表 14-2。
内置注解:常规
|注解名称
|
包裹
|
目标
|
描述
|
| --- | --- | --- | --- |
| Deprecated
| 我的锅 | 类、批注类、函数、属性、构造函数、属性设置器、属性获取器、类型别名 | 接受三个参数:message:String
、replaceWith:ReplaceWith = ReplaceWith("")
和level:DeprecationLevel = DeprecationLevel.WARNING
将元素标记为已弃用。DeprecationLevel
为字段枚举:WARNING
、ERROR
、HIDDEN
|
| ReplaceWith
| 我的锅 | — | 需要两个参数:expression:String
和vararg imports:String
。使用它来指定@Deprecated
中的替换代码片段。 |
| Suppress
| 我的锅 | 类、批注类、函数、属性、字段、局部变量、值参数、构造函数、属性设置器、属性获取器、类型、类型别名、表达式、文件 | 接受一个 vararg 参数:names:String
。保留类型为SOURCE
。使用它来禁止编译器警告。names
参数是一个逗号分隔的警告消息标识符列表。不幸的是,找到编译器警告标识符的详尽列表并不容易,但 Android Studio 有所帮助:一旦出现编译器警告,相应的构造就会突出显示,当光标在其上时按 Alt+Enter 允许我们生成相应的 suppress 注解。参见图 14-1 (使用箭头键在菜单中导航)。 |
自定义注解
要定义自己的简单注解,您需要编写
@Target(...)
@Retention(...)
@Repeatable
@MustBeDocumented
annotation class AnnotationName
对于注解的注解(即元注解),注意它们都是可选的,顺序是自由的。有关它们的含义,请参阅本章前面的“注解特征”一节。
如果需要带参数的批注,可以在声明中添加一个主构造函数:
[possibly meta-annotations]
annotation class AnnotationName(val p1:Type1, val p2:Type2, ...)
其中允许以下参数类型:对应于原始类型的类型(即,Byte
、Short
、Int
、Long
、Char
、Float
、Double
)、字符串、类、枚举、其他注解以及它们的数组。您可以添加vararg
来获得可变数量的参数。注意,对于用作其他注解的参数的注解,参数注解的@
被省略。
作为一个例子,我们以类Calculator
的形式启动一个计算引擎。我们引入一个注解来避免被0.0
除。注解如下:
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class NotZero()
对于类和两个操作符divide
和multiply
,我们写:
class Calculator {
enum class Operator(val oper:String) {
MULTIPLY("multiply"),
DIVIDE("divide")
}
fun operator(oper:Operator,
vararg params:Double): Double {
val f = Calculator::class.declaredFunctions.
find { it.name == oper.oper }
f?.valueParameters?.forEachIndexed { ind, p ->
p.findAnnotation<NotZero>()?.run {
if (params[ind] == 0.0)
throw RuntimeException(
"Parameter ${ind} not unequal 0.0")
}
}
val ps = arrayOf(this@Calculator,
*(params).toList().toTypedArray<Any>())
return (f?.call(*ps) as Double?) ?: 0.0
}
fun multiply(p1:Double, p2:Double) : Double {
return p1 * p2
}
fun divide(p1:Double, @NotZero p2:Double) : Double {
return p1 / p2
}
}
operator()
功能的作用如下:
-
它查找对应于第一个参数的函数。
Calculator::class.declaredFunctions
列出了Calculator
类的所有直接声明的函数。这意味着它也不研究超类。find
选择divide()
或multiply()
。 -
从函数中,我们通过
.valueParameters
遍历参数。对于每个参数,我们可以看到它是否有关联的注解NotZero
。如果是,我们检查实际参数,如果是0.0
,我们抛出一个异常。 -
如果没有抛出异常,我们调用函数。
arrayOf()
表达式将接收器对象和函数参数连接成一个Array<Any>
。
@NotZero
注解确保在调用Calculator.operator()
时检查参数。要使用计算器,您可以这样写:
Calculator().
operator(Calculator.Operator.DIVIDE,
1.0, 1.0)
要查看注解是否有效,请使用第二个参数0.0
尝试另一个调用。
练习 1
对于Calculator
示例,添加一个新的注解@NotNegative
和一个新的平方根操作sqrt()
。请确保此运算符不允许使用负参数。注:实际平方根通过java.lang.Math.sqrt()
计算。
十五、使用 Java 和 Kotlin APIs
Kotlin 有一个语言内核,可以处理类、对象、属性、函数、结构构造等等。到目前为止,我们已经讨论了很多。我们偶尔会提到并使用术语 Kotlin 标准库,但没有明确说明它实际上是什么。在现实生活中,图书馆是一个可以获得大量信息的地方。无论什么时候你需要知道一些事情,你都可以去那里,试着找一本书,告诉你事情是什么或者它们是如何工作的,或者你必须做些什么来实现一些事情。对于一门计算机语言来说,一个库是类似的东西:一个有很多类和函数的存储库,你可以用它们来完成特定的任务。我们已经讨论过集合,集合由库类和函数管理。
API 与库携手并进。API 更关注库的外部面;也就是说,如何从外部使用一个库,而不必了解其内部功能。
你能想到的库的例子有很多;例如,数学、化学、物理、生物学、社会学、加密标准、web 服务、用户界面、声音处理和图形,仅举几个例子,而写一本关于所有这些的书是不可能的。不过,区分 Kotlin 附带的基本库和可以按需添加的外部库是有意义的。仅仅查看内置库是一个更可行的任务,在这一章中,我们将查看 Kotlin 附带的库。
注意,在这样一本书里列出一个库必须提供的所有类和函数是不可能的,也是不可取的。除了非常简单的库之外,任何库中都有太多的。然而,我们可以尝试描述这些库,展示如何使用它们,并列出最重要的类和函数。这发生在随后的章节。
Kotlin 和 Java 库
在我们开始研究不同的 API 之前,我们需要讨论一下 Kotlin 库的来源。Kotlin 位于 JVM 之上,Kotlin 开发人员做了很好的工作,允许 Kotlin 和 Java 之间轻松的互操作。这包括使用 Java APIs 和库的能力。Java 已经存在了 20 多年,不难想象已经有了一些非常好的 Java 库,Kotlin 没有必要重做一切。Kotlin 所做的是包含一些已经包含在 Java 发行版中的库,然后使用它的类扩展机制在几个地方扩展或重新定义它们。
使用在线资源
对于 Kotlin 中包含的任何 API,拥有官方 API 文档总是一个好主意。去的地方是 https://kotlinlang.org/
。在那里你会找到一个学习链接,让你到语言和标准库参考手册。如果这个链接过时了,在你喜欢的搜索引擎里搜索“kotlin 编程语言”就能找到。
正如已经指出的,Kotlin 与 Java 有很强的关系;将 Java 标准模块合并到 Kotlin 中特别容易。Android 平台包括各种 Java APIs,如果使用 Android Studio 进行开发,您不必做任何事情就可以使用它们。我们在本书中一直使用的 API level 28 具有以下来自 Java 8 的 Java APIs:
-
java.beans
-
java.io
-
java.lang
-
java.math
-
java.net
-
Java . 9 版
-
java.security
-
java.sql
-
java.text
-
java.time
-
java.util
-
javax.crypto
-
javax 微版 khronos
-
javax.net
-
javax .安全性
-
javax.sql
-
javax.xml
在 Oracle 网站上,您可以找到 Java 库的 API 文档。这个链接更准确地说是 https://docs.oracle.com/javase/8/docs/api/
,但是如果这个链接过时了,在网上搜索“java 8 api”会很容易把你带到这些页面。
对于我们在以下章节中描述的 API,我们不再考虑它们是来自 Kotlin 还是 Java。如果您感兴趣,通过查看import
语句,通常很容易看出这些类和接口来自哪里。如果它们以java.
或javax.
开头,那么类和接口来自 Java,否则它们来自 Kotlin。
制作文档的本地副本
在 Android Studio 中,一旦你在任何类或接口名称上按下 Ctrl+B,你将被带到 Java 或 Kotlin 源代码。如果您第一次这样做,Android Studio 可能需要从互联网上下载源代码,但之后您会将源代码本地存储在您的 Android Studio 安装中。
如果您想在您的 PC 上拥有 API 文档的本地副本,那么对于 Java 来说,在 Oracle 下载网站上很容易找到相应的链接。对于 Kotlin,进入 https://github.com/JetBrains/kotlin/releases
,选择一个版本,然后下载源代码作为压缩存档。
你也可以从你的 Android 工作室获取源代码。确保通过在任何 Kotlin 标准库类上按 Ctrl+B 来下载源代码,然后转到STUDIO-INST/plugins/Kotlin/kotlinc/lib
。在那里你会找到一个文件kotlin-stdlib-sources.jar
。这是一个 ZIP 存档。您可以从存档中提取所有文件,并将其保存在 PC 上的任何位置。
十六、集合 API
我们已经在第九章中讨论了集合,即列表、集合和映射。然而,集合 API 是广泛的,包含了比我们在第九章中描述的更多的类和接口。对于 Java,API 甚至被称为集合框架。在这一章中,我们不要求详尽无遗,我们修改我们已经知道的,也讨论一些更有趣的集合接口、类和函数。
不幸的是,没有什么像java.collections
包。关于 Java,集合 API 是分散的,其主要部分位于java.util
包中。
注意
在本章中,我们将展示一种指定泛型类型参数的方法。在显而易见的地方,为了简洁起见,它们没有被示出。在所有情况下,我们使用E
作为列表或集合的元素类型,使用K
和V
作为映射的键和值。
接口
尽管 Java 已经有了集合、列表和映射的接口,Kotlin 也有自己的接口。这主要源于 Kotlin 需要区分可变和不可变的集合和映射。对于大多数用例,您可以只使用 Kotlin 版本,如果您尝试使用 Java 变体,编译器甚至会警告您。不过,也不禁止使用 Java 变体,这样做可能有原因。表 16-1 提供了一个概述。
表 16-1。
集合接口
|连接
|
描述
|
| --- | --- |
| kotlin.collections.Iterable
| iterable,或者可以在循环中迭代的东西。任何 iterable 都可以在一个for( x in a )
循环中使用,所以如果你提供了自己的实现这个接口的类,你就可以在循环中使用它。所有集合(即列表和集合)都是可迭代的。 |
| kotlin.collections.MutableIterable
| 与Iterable
相同,但是另外支持移除当前迭代的元素。 |
| java.lang.Iterable
| iterable 的 Java 变体;除非有充分的理由,否则不要使用它。 |
| kotlin.collections.Collection
| 一个通用的不可变集合接口。这是Iterable
的一个子接口。 |
| kotlin.collections.MutableCollection
| 一个通用的可变集合接口。这是MutableIterable
的一个子接口。 |
| java.util.Collection
| 集合接口的 Java 变体;除非有充分的理由,否则不要使用它。 |
| java.util.Deque
| 两头排队。使用它来实现或使用队列或堆栈。你可以把元素放在开头和结尾,两边都可以读取和撤回元素。deques 可用的函数数量有点多;通常情况下,您可以对以下设置感到满意:size()
: Int
得到尺寸。addFirst(element:E)
向队列头添加一个元素。addLast(element:E)
向队列尾部添加一个元素。removeFirst()
: E
获取并删除队列头的元素(如果队列为空,抛出异常)。removeLast()
: E
获取并删除队列尾部的元素(如果队列为空,抛出异常)。getFirst()
: E
检索但不删除队列头的元素(如果队列为空,则抛出异常)。getLast()
: E
检索但不删除队列尾部的元素(如果队列为空,则抛出异常)。对此没有 Kotlin 变体;德克总是易变的。 |
| java.util.Queue
| 一端排队的队伍。通常你可以使用一个双端的队列来代替。对此没有 Kotlin 变体;队列总是可变的。 |
| kotlin.collections.List
| 不可变列表。 |
| kotlin.collections.MutableList
| 可变列表。 |
| java.util.List
| 列表的 Java 变体;除非有充分的理由,否则不要使用它。 |
| kotlin.collections.Set
| 不变的集合。 |
| kotlin.collections.MutableSet
| 可变集合。 |
| java.util.Set
| 集合的 Java 变体;除非有充分的理由,否则不要使用它。 |
| java.util.SortedSet
| 其元素按自然排序顺序排列的集合。 |
| java.util.NavigableSet
| 一个可以在两个方向上迭代的SortedSet
。 |
| kotlin.collections.Map
| 不变的地图。 |
| kotlin.collections.MutableMap
| 可变地图。 |
| java.util.Map
| 地图的 Java 变体;除非有充分的理由,否则不要使用它。 |
| java.util.SortedMap
| 其关键字按自然排序顺序排序的地图。 |
| java.util.NavigableMap
| 一个可以在两个方向上迭代的SortedMap
。 |
请注意,所有这些接口都有必须在尖括号之间指定的泛型类型,除非 Kotlin 编译器可以推断出这些类型。对于地图,我们需要两个类型参数;其他人都需要一个。
仔细观察这个表,您可能会注意到两个有点奇怪的结构:一个以SortedSet
形式表示的排序集合和一个以SortedMap
形式表示的排序映射。这些语言结构在某些情况下会有所帮助,但在数学中却没有直接的对应。在数学中,集合和地图都是无序的!在你的代码中,最好不要在可以避免的地方使用它们。如果使用它们,算法不应该强烈依赖元素的顺序。这当然是个人喜好问题;把它当作一个暗示或建议。
班级
表 16-2 列出了实现集合和映射接口的类。
表 16-2。
集合类
|班级
|
描述
|
| --- | --- |
| kotlin.collections.ArrayList
| 可变和不可变列表的列表实现。 |
| java.util.ArrayList
| 除非你有充分的理由,否则不要使用它的 Java 变体。 |
| kotlin.collections.HashSet
| 可变和不可变集合的集合实现。 |
| java.util.HashSet
| 一个HashSet
的 Java 变种;除非有充分的理由,否则不要使用它。 |
| kotlin.collections.LinkedHashSet
| 可变和不可变集合的集合实现。因为集合元素相互链接,所以迭代顺序与插入顺序相同。 |
| java.util.LinkedHashSet
| 一个LinkedHashSet
的 Java 变种;除非有充分的理由,否则不要使用它。 |
| kotlin.collections.HashMap
| 可变和不可变映射的映射实现。 |
| java.util.HashMap
| 一个HashMap
的 Java 变种;除非有充分的理由,否则不要使用它。 |
| kotlin.collections.LinkedHashMap
| 可变和不可变映射的映射实现。因为地图元素相互链接,所以迭代顺序与插入顺序相同。 |
| java.util.LinkedHashMap
| 一个LinkedHashMap
的 Java 变种;除非有充分的理由,否则不要使用它。 |
| java.util.ArrayDeque
| 一个Deque
实现。 |
| java.util.EnumSet
| 枚举元素的专用java.util.Set
实现。 |
| java.util.LinkedList
| 一个带有链表元素的java.util.List
实现。 |
| java.util.PriorityQueue
| 一个java.util.Queue
实现,根据元素的自然顺序或根据构造期间传入的比较器定义的顺序,在某个位置插入元素。 |
| java.util.Stack
| java.util.List
的后进先出(LIFO)实现。 |
| java.util.TreeSet
| 一个java.util.Set
实现,其中的元素按照它们的自然顺序进行排序,或者按照构造期间传入的比较器进行排序。 |
| java.util.concurrent.ArrayBlockingQueue
| 一种固定大小的队列(先进先出表)。如果试图在队列已满时添加元素或试图在队列为空时移除元素,则阻止这两种情况。 |
| java.util.concurrent.ConcurrentLinkedDeque
| 允许对元素进行并发访问的 deque 实现。 |
| java.util.concurrent.ConcurrentLinkedQueue
| 允许对元素进行并发访问的队列实现。 |
| java.util.concurrent.ConcurrentSkipListSet
| 允许对元素进行并发访问的NavigableSet
实现。 |
| java.util.concurrent.CopyOnWriteArrayList
| 允许对元素进行并发访问的java.util.List
实现。每次写操作都会产生完整列表的新副本。 |
| java.util.concurrent.CopyOnWriteArraySet
| 允许对元素进行并发访问的java.util.Set
实现。每个写操作都会产生完整集合的新副本。 |
| java.util.concurrent.DelayQueue
| 一个java.util.Queue
实现,其中元素必须是java.util.concurrent.Delayed
的子类。仅当延迟到期时,才允许删除元素。 |
| java.util.concurrent.LinkedBlockingQueue
| 可选地具有固定大小的队列(先进先出列表)。如果试图在队列已满时添加元素或试图在队列为空时移除元素,则阻止这两种情况。 |
| java.util.concurrent.PriorityBlockingQueue
| 一个java.util.Queue
实现,根据元素的自然顺序或根据构造期间传入的比较器定义的顺序,在某个位置插入元素。可能会阻止检索操作,直到元素可用。 |
| java.util.concurrent.SynchronousQueue
| 一个java.util.Queue
实现,其中插入操作只有在元素被并发请求时才是可能的。否则,插入操作会阻塞并等待。 |
注意,在属性声明中,通常希望为属性类型使用一个接口,但只为实例化使用一个类。这样我们表达的是属性做了什么,而不是它是如何做的。
var l:MutableList<String> = ArrayList()
// ... = ArrayList<String>() is unnecessary, because
// Kotlin can infer the type.
发电机功能
Kotlin 在自己的集合类中提供了数百个函数,为 Java 的集合类添加了扩展函数,此外还为我们提供了许多顶级函数。这一节和接下来的几节列出了 Kotlin 和 Java 中最重要的集合函数,但并不详尽。
表 16-3 显示了可以用来创建集合的顶级生成器函数。除非另有说明,否则返回的集合和映射只是包kotlin.collections
中的类的实例。
表 16-3。
集合生成器
|功能
|
描述
|
| --- | --- |
| emptyList<E>()
| 创建给定元素类型的不可变空列表。 |
| listOf<E>(...)
| 创建作为参数给定的元素的不可变列表;例如,listOf(1, 2, 3)
|
| mutableListOf<E>(...)
| 创建作为参数给出的元素的可变列表;例如,mutableListOf(1, 2, 3)
|
| listOfNotNull<E>(...)
| 创建作为参数给定的元素的不可变列表,但是过滤掉null
有值的参数;例如,listOfNotNull(1, 2, null, 3)
|
| List<E>(size: Int, init: (index: Int) -> E)
| 创建由 lambda 函数计算的不可变列表,该函数作为第二个参数给出。请注意,尽管名称以大写字母开头,但这是一个函数。 |
| MutableList<E>(size: Int,init: (index: Int) -> E)
| 创建一个可变列表,由 lambda 函数计算,作为第二个参数给出。请注意,尽管名称以大写字母开头,但这是一个函数。 |
| emptySet<E>()
| 创建一个不可变的空集。 |
| setOf<E>(...)
| 创建作为参数给定的不可变元素集;例如,setOf(1, 2, 3)
|
| mutableSetOf<E>(...)
| 创建作为参数给定的可变元素集;例如,mutableSetOf(1, 2, 3)
|
| emptyMap<K,V>()
| 创建一个不可变的空映射。 |
| mapOf<K,V>()
| 创建作为参数给出的Pair
元素的不可变映射;例如,mapOf(1 to "A", 2 to "B")
|
| mutableMapOf<K,V>(...)
| 创建作为参数给出的Pair
元素的可变映射;例如,mutableMapOf(1 to "A", 2 to "B")
|
对于 Kotlin 来说通常是这样,如果 Kotlin 可以推断类型,那么可以省略类型参数。所以你可以写
listOf(1, 5, 7, 9)
而 Kotlin 知道这是一个List<Int>
。
集合和地图设置器和移除器
表 16-4 向你展示了如何向可变集合或映射添加元素,以及如何移除它们。
表 16-4。
集合变异函数
|为
|
功能
|
描述
|
| --- | --- | --- |
| 列表,集合 | add(element:E)
| 在列表末尾添加一个元素,或将一个元素添加到集合中。 |
| 列表 | set(index:Int, element:E)
| 覆盖给定索引处的元素。要覆盖的元素必须存在。 |
| 列表 | list[index] = value
| 同set()
|
| 列表,集合 | addAll(elements: Collection<E>) addAll(elements: Array<out E>)
| 将作为参数提供的数组或集合中的所有元素添加到列表的尾部,或将元素添加到集合中。 |
| 地图 | put(key:K, value:V)
| 将键/值对放入映射中。如果该键已经存在,该值将被覆盖。 |
| 地图 | map[key:K] = value:V
| 同put()
。 |
| 地图 | putIfAbsent(key:K, value:V)
| 将键/值对放入映射中,但前提是键以前不存在。 |
| 地图 | set(key:K, value:V)
| 同put()
。 |
| 地图 | putAll(from: Map<out K,V>)
| 对作为函数参数提供的地图中的所有元素执行put()
。 |
| 列表,集合 | remove(element:E)
| 从集合或列表中移除给定的元素。 |
| 列表,集合 | removeIf { (E) -> Boolean }
| 移除 lambda 函数返回true
的所有元素。如果至少删除了一个元素,则返回true
。 |
| 列表,集合 | removeAll( elements:Collection<E>) removeAll(elements:Array<out T>)
| 从列表或集合中移除包含在所提供的集合或数组参数中的所有元素。 |
| 列表,集合 | removeAll { (E) -> Boolean }
| 同removeIf()
。 |
| 地图 | remove(key:K)
| 移除给定键处的元素(如果存在)。返回前一个值,如果不存在则返回null
。 |
| 地图 | remove(key:K, value:V)
| 如果元素存在并具有给定值,则移除给定键处的元素。如果元素被移除,则返回true
。 |
| 列表,集合 | retainAll( elements:Collection<E>)
| 更改给定的集合或列表,并使其仅保留那些也在给定的参数集合内的元素。 |
| 地图、列表、集合 | clear()
| 移除所有元素。 |
确定性吸气器
表 16-5 中列出了从集合和映射中检索元素的确定性 getters。
表 16-5。
吸气剂
|为
|
功能
|
描述
|
| --- | --- | --- |
| 列表 | get(index:Int)
| 检索指定索引处的元素。 |
| 列表 | getOrNull(index:Int)
| 检索指定索引处的元素,如果索引越界,则检索null
。 |
| 列表 | list[index:Int]
| 同get()
。 |
| 列表 | first()
| 返回第一个元素。 |
| 目录 | firstOrNull()
| 返回第一个元素,如果列表为空,则返回null
。 |
| 列表 | last()
| 返回最后一个元素。 |
| 列表 | lastOrNull()
| 返回最后一个元素,如果列表为空,则返回null
。 |
| 列表,集合 | random()
| 从列表或集合中返回一个随机元素。 |
| 地图 | get(key:K)
| 返回给定键的值,如果不存在则返回null
。 |
| 地图 | map[key:K]
| 同get()
。 |
| 地图 | getOrDefault(key:K, defaultValue:V)
| 返回给定键的值,如果不存在则返回defaultValue
。 |
| 地图 | getOrElse(key:K, defaultValue: (K) -> V)
| 返回给定键的值,如果该值不存在,则返回作为第二个参数提供的 lambda 函数的结果。 |
| 地图 | getOrPut(key:K, defaultValue: () -> V)
| 返回键key
的值。但是,如果键还不存在,调用 lambda 函数并将结果作为该键的值放入映射中。在后一种情况下,返回新值。 |
| 列表,集合 | single()
| 如果内部只有一个元素,则检索单个元素。否则将引发异常。 |
| 列表,集合 | singleOrNull()
| 如果内部只有一个元素,则检索单个元素。否则返回null
。 |
| 列表 | drop(n:Int)
| 返回一个不可变的列表,其中包含原始列表中的元素,并删除前n
个元素。 |
| 列表 | dropLast(n:Int)
| 返回一个不可变列表,其中包含原始列表中的元素,并从末尾删除了n
个元素。 |
| 列表 | slice(indices:IntRange)
| 返回一个不可变列表,其中包含 range 参数给定索引处的元素。 |
| 列表 | take(n:Int)
| 返回一个不可变列表,其中包含原始列表中的第一个n
元素。 |
| 列表 | takeLast(n:Int)
| 返回一个不可变列表,其中包含原始列表中的最后一个n
元素。 |
集合和地图特征
采集和映射特性见表 16-6 。
表 16-6。
特征
|接收器
|
功能
|
描述
|
| --- | --- | --- |
| 地图、列表、集合 | Size
| 集合或地图的大小。 |
| 地图、列表、集合 | count()
| 同size
。 |
| 地图、列表、集合 | isEmpty()
| 如果为空,则返回true
。 |
| 地图、列表、集合 | isNotEmpty()
| 如果不为空,则返回true
。 |
| 列表,集合 | count((E) -> Boolean)
| 计算给定谓词 lambda 函数返回的元素数true
。 |
| 地图 | count((K,V) -> Boolean)
| 计算给定谓词 lambda 函数返回的元素数true
。 |
| 列表,集合 | indices
| 作为IntRange
的有效索引。 |
| 列表 | lastIndex
| 最后一个有效索引。 |
遍历集合和地图
对于遍历集合和地图,您可以使用表 16-7 中所示的结构之一。
表 16-7。
横越
|为
|
建造
|
描述
|
| --- | --- | --- |
| 列表、设置、实现Iterable
| for( i in x ) { ... }
| 一个语言结构,i
是循环变量并接收元素。 |
| 地图 | for( me in x ) { ... }
| 一个语言结构,me
是循环变量,接收Map.Element<K,V>
元素。您可以通过me.key
获取密钥,通过me.value
获取值 |
| 地图 | for( (k,v) in x ) { ... }
| 语言结构,k
和v
是循环变量,接收每个 map 元素的键和值。 |
| 列表、设置、实现Iterable
| x.forEach { i -> ... }
| 遍历x
的所有元素,i
接收每个元素。 |
| 列表、设置、实现Iterable
| x.onEach { i -> ... }
| 与forEach()
相同,但之后返回迭代列表、集合或 iterable。 |
| 列表、设置、实现Iterable
| x.forEachIndexed { ind, i -> ... }
| 遍历x
的所有元素,i
接收每个元素。Ind
是指数变量(0,1,2,...). |
| 地图 | x.forEach { me -> ... }
| 遍历地图x
,me
的所有元素,类型为Map.Element<K,V>
。您可以通过me.key
获取密钥,通过me.value
获取值 |
| 地图 | x.forEach { k,v -> ... }
| 遍历 map 的所有元素x
,k
是键,v
是每个元素的值。 |
转换
将一个集合或地图转换成另一个集合或地图的可能性是无限的。表 16-8 显示了从地图中提取关键字和值的函数。
表 16-8。
提取关键字和值
|建造
|
返回
|
描述
|
| --- | --- | --- |
| map.keys
| MutableSet<K>
| 作为一个集合从地图中获取关键字。 |
| map.values
| MutableCollection<V>
| 将地图中的值作为集合获取。 |
在本节中,我们分别使用list
、set
、map
、coll
和iter
来表示List
、Set
、Map
、Collection
和Iterable
类型的变量。请记住,列表或集合是一个集合,任何集合都是可迭代的。
表 16-9 显示了在逐个元素的基础上转换集合或映射元素的各种函数。
表 16-9。
变换:贴图
|建造
|
返回
|
描述
|
| --- | --- | --- |
| iter.map(transform: (E) -> R)
| List<R>
| 根据给定的 lambda 函数转换所有集合或任何其他 iterable 的条目。返回一个不可变列表。 |
| iter.mapIndexed( transform: (Int, E) -> R)
| List<R>
| 根据给定的 lambda 函数转换所有集合或任何其他 iterable 的条目。lambda 函数获取索引(0,1,2,...)作为它的第一个参数。返回一个不可变列表。 |
| map.map(transform: (Map.Entry<K,V>) -> R)
| List<R>
| 根据提供给每个 map 元素的 lambda 函数的结果创建一个新的不可变列表。 |
| map.mapKeys( transform: (Map.Entry<K,V>) -> R))
| Map<R,V>
| 使用从提供的 lambda 函数派生的键创建一个新的不可变映射。 |
| map.mapValues( transform: (Map.Entry<K, V>) -> R))
| Map<K,R>
| 使用从提供的 lambda 函数派生的值创建一个新的不可变映射。 |
有关更改列表排序顺序或将列表或集合转换为排序列表的功能描述,请参见表 16-10 。
表 16-10
变换:重新排序
|建造
|
返回
|
描述
|
| --- | --- | --- |
| list.asReversed()
| List<E>
或MutableList<E>
| 反转列表的迭代顺序,而不更改列表。保持原始列表的可变性。请注意,对结果列表的更改会反映在原始列表中。 |
| list.reverse()
| Unit
| 就地反转可变列表。原始列表会发生变化。 |
| iter.reversed()
| List<E>
| 返回一个新的不可变列表,其中原始集合或 iterable 中的元素的排序顺序相反。 |
| iter.distinct()
| List<E>
| 返回一个新的不可变列表,该列表只包含原始集合或 iterable 中的不同元素。 |
| iter.distinctBy( selector: (E) -> K)
| List<E>
| 返回一个新的不可变列表,该列表只包含原始集合中的不同元素。对于等式检查,使用来自所提供的 lambda 函数的结果。 |
| list.shuffle()
| Unit
| 将可变列表中的元素随机打乱。 |
| iter.shuffled()
| List<E>
| 返回一个不可变列表,其中包含原始集合或 iterable 中随机打乱的元素。 |
| list.sort()
| Unit
| 根据自然排序顺序对可变列表进行就地排序。这些元素必须实现Comparable
接口。sortDescending()
函数以相反的顺序排序。 |
| list.sortBy(selector: (E) -> R?)
| Unit
| 根据选择器结果的自然排序顺序对可变列表进行就地排序。选择器结果必须实现Comparator
接口。sortDescending()
函数以相反的顺序排序。 |
| iter.sorted()
| List<E>
| 返回一个新的不可变列表,其中的元素按照自然排序顺序排序。这些元素必须实现Comparable
接口。sortedDescending()
函数以相反的顺序排序。 |
| iter.sortedBy( selector: (E) -> R?)
| List<E>
| 返回一个新的不可变列表,其中的元素按照选择器结果的自然排序顺序排序。选择器结果必须实现Comparable
接口。sortedByDescending()
函数以相反的顺序排序。 |
| list.sortWith( comparator: Comparator<in E>
| Unit
| 根据作为参数给出的比较器对可变列表进行就地排序。选择器结果必须实现Comparator
接口。sortDescending()
函数以相反的顺序排序。 |
| iter.sortedWith( comparator: Comparator<in E>
| List<E>
| 返回一个新的不可变列表,其中的元素根据作为参数给出的比较器进行排序。 |
有几个函数可以用来收集子列表或子映射的元素;即作为列表或映射元素的列表和映射(见表 16-11 )。
表 16-11
变换:展平
|建造
|
返回
|
描述
|
| --- | --- | --- |
| iter.flatten(...)
| List<E>
| 这里的iter
是一个Iterable<Iterable<E>>
,例如,包含集合的集合就是这种情况。返回一个新的不可变列表,其中所有元素都连接在一个列表中。 |
| iter.flatMap( transform: (E) -> Iterable<R>)
| List<R>
| 将 transform 函数应用于原始集合或 iterable 中的所有元素,并返回一个类似于 list 或 set 的Iterable
,返回一个单一的不可变列表,其中所有转换结果元素串联在一起。 |
| map.flatMap( transform: (Map.Entry<K,V>) -> Iterable<R>)
| List<R>
| 将 transform 函数应用于原始 map 中的所有元素,并返回一个类似于 list 或 set 的Iterable
,返回一个不可变的列表,将所有转换结果元素连接在一起。 |
通过将元素与新映射的键或值相关联,可以将列表和集合转换为映射。表 16-12 显示了这样的关联函数。
表 16-12
转换:关联
|建造
|
返回
|
描述
|
| --- | --- | --- |
| iter.associate( transform: (E) -> Pair<K, V>)
| Map<K,V>
| 给定输入列表或集合或 iterable,transform
lambda 函数应该返回一个用于返回的 map 中的新元素的Pair<K,V>
。 |
| iter.associateBy( keySelector: (E) -> K)
| Map<K,E>
| 给定输入列表或集合或 iterable,keySelector
lambda 函数用于为返回的 map 中的新元素创建一个键。该值是原始元素。 |
| iter.associateBy( keySelector: (E) -> K, valueTransform: (E) -> V )
| Map<K,V>
| 给定输入列表或集合或 iterable,keySelector
lambda 函数用于为返回的 map 中的新元素创建一个键。这些值将取自valueTransform
调用结果。 |
| iter.associateWith( valueSelector: (E) -> V)
| Map<E,V>
| 给定输入列表或集合或 iterable,valueTransform
lambda 函数用于为返回的 map 中的新元素创建值。原始元素作为一个键被使用。 |
练习 1
给出一个类data class Employee(val lastName:String, val firstName:String, val ssn:String)
和一个列表
val l = listOf(
Employee("Smith", "Eve", "012-12-5678"),
Employee("Carpenter", "John", "123-06-4901"),
Employee("Cugar", "Clara", "034-00-1111"),
Employee("Lionsgate", "Peter", "965-11-4561"),
Employee("Disney", "Quentin", "888-12-3412")
)
从 SSN 排序的列表中获取一个新的不可变列表。
练习 2
给定练习 1 中的员工列表,创建一个不可变的映射,将 SSN 映射到员工。
练习 3
的产量是多少
listOf(listOf(1, 2), listOf(3, 4)).flatten()
练习
的产量是多少
listOf(listOf(1, 2), listOf(3, 4)).
flatMap { it.map { it.toString() } }
过滤
与转换密切相关的是过滤功能。它们用于根据某种标准获得新的集合或地图。表 16-13 列出了过滤功能。
表 16-13
过滤
|功能
|
描述
|
| --- | --- |
| iter.filter( predicate: (E) -> Boolean)
| 返回一个新的不可变列表,只包含那些匹配给定谓词的元素。 |
| iter.filterNot( predicate: (E) -> Boolean)
| 返回一个新的不可变列表,只包含那些与给定谓词不匹配的元素。 |
| iter.filterIndexed( predicate: (index:Int, T) -> Boolean)
| 返回一个新的不可变列表,只包含那些匹配给定谓词的元素。lambda 函数检索索引(0,1,2,...)作为第一个参数。 |
| map.filter( predicate: (Map.Entry<K,V>) -> Boolean)
| 返回一个新的不可变映射,只包含那些匹配给定谓词的元素。 |
| map.filterNot( predicate: (Map.Entry<K,V>) -> Boolean)
| 返回一个新的不可变映射,只包含那些与给定谓词不匹配的元素。 |
在本节中,我们分别使用list
、set
、map
、coll
和iter
来表示List
、Set
、Map
、Collection
和Iterable
类型的变量。请记住,列表或集合是一个集合,任何集合都是可迭代的。
练习 5
给定练习 1 中的雇员列表,创建一个新的不可变列表,只包含以 0 开头的 SSN。提示:String.startsWith(...)
检查字符串是否以某些字符开头。
改变可变性
你可以在表 16-14 中看到,可变映射和列表的转换通常会返回不可变的映射或集合。如果您需要一个可变的地图或集合,Kotlin 可以帮助您。
表 16-14
改变模式
|功能
|
描述
|
| --- | --- |
| list.toMutableList()
| 将不可变列表转换为可变列表。 |
| set.toMutableSet()
| 将不可变集合转换为可变集合。 |
| map.toMutableMap()
| 将不可变映射转换为可变映射。 |
| mutableList.toList()
| 将可变列表转换为不可变列表。 |
| mutableSet.toSet()
| 将可变集合转换为不可变集合。 |
| mutableMap.toMap()
| 将可变映射转换为不可变映射。 |
元素检查
要检查集合或地图的任何或所有元素是否满足某个标准,您可以使用表 16-15 中描述的函数之一。在本节中,我们分别使用list
、set
、map
、coll
和iter
来表示List
、Set
、Map
、Collection
和Iterable
类型的变量。请记住,列表或集合是一个集合,任何集合都是可迭代的。
表 16-15
检查
|功能
|
描述
|
| --- | --- |
| iter.any(predicate: (E) -> Boolean)
| 如果任何元素满足谓词,则返回true
。 |
| iter.all(predicate: (E) -> Boolean)
| 如果所有元素都满足谓词,则返回true
。 |
| iter.none(predicate: (E) -> Boolean)
| 如果没有元素满足谓词,则返回true
。 |
| map.any(predicate: (Map.Entry<K,V>) -> Boolean)
| 如果任何元素满足谓词,则返回true
。 |
| map.all(predicate: (Map.Entry<K,V>) -> Boolean)
| 如果所有元素都满足谓词,则返回true
。 |
| map.none(predicate: (Map.Entry<K,V>) -> Boolean)
| 如果没有元素满足谓词,则返回true
。 |
练习 6
为列表listOf(1, 2, 3, 4)
创建一个检查,查看是否所有元素都大于0
。
查找元素
为了从一个集合或地图中找到特定的元素,你可以使用表 16-16 中显示的函数之一,我们也在其中添加了包含检查。
表 16-16
发现
|为
|
功能
|
描述
|
| --- | --- | --- |
| 列表,可重复项 | indexOf(element:E)
| 确定列表或 iterable 中元素的索引(Int
),如果找不到该元素,则确定1
。 |
| 列表,可重复项 | find(predicate: (e) -> Boolean)
| 返回谓词 lambda 函数返回true
的第一个元素,如果没有匹配的元素,则返回null
。 |
| 列表,可重复项 | findLast(predicate: (e) -> Boolean)
| 返回谓词 lambda 函数返回true
的最后一个元素,如果没有匹配的元素,则返回null
。 |
| 列表 | binarySearch( element: E?, fromIndex: Int = 0, toIndex: Int = size)
| 在列表中执行快速二分搜索法。列表必须根据元素的自然顺序进行排序,因此必须实现Comparable
接口。如果找到元素,则返回索引,或者返回−insertion_point
-1,其中insertion_point
是元素将被插入的索引,以保持列表的排序顺序。 |
| 列表 | binarySearch( element: E?, comparator: Comparator<in E>, fromIndex: Int = 0, toIndex: Int = size)
| 与binarySearch()
相同,但使用为比较元件提供的比较器。 |
| 列表、集合、可重复项 | contains(element: E)
| 如果列表、集合或 iterable 包含指定的元素,则返回true
。 |
| 地图 | contains(key: K)
| 如果映射包含指定的键,则返回true
。 |
| 地图 | containsKey(key: K)
| 与地图的contains()
相同。 |
| 地图 | containsValue(value: V)
| 如果映射包含指定的值,则返回true
。 |
练习 7
给定一个包含Int
s 的列表l
,找到一种不使用if
的单表达式方式,如果列表包含42
则抛出异常。提示:使用find()
或contains()
,也可能是takeIf()
和?.run
。
聚集、折叠和缩减
聚合器从集合中推导出总和、最大值、最小值或平均值。这些在表 16-17 中列出。
在本节中,我们分别使用list
、set
、map
、coll
和iter
来表示List
、Set
、Map
、Collection
和Iterable
类型的变量。请记住,列表或集合是一个集合,任何集合都是可迭代的。
表 16-17
聚集
|为
|
功能
|
描述
|
| --- | --- | --- |
| 一组数字(Byte
、Short
、Int
、Long
、Float
、Double
) | sum()
| 总结了元素。类型Byte
和Short
产生一个Int
值的和;所有其他元素都产生与元素相同的结果类型。 |
| 任何集合或可迭代的 | sumBy( selector: (E) -> Int)
| 对每个元素应用 lambda 函数后,对元素求和。产生一个Int
号。 |
| 任何集合或可迭代的 | sumByDouble( selector: (E) -> Double)
| 对每个元素应用 lambda 函数后,对元素求和。产生一个Double
号。 |
| 一组数字(Byte
、Short
、Int
、Long
、Float
、Double
) | average
| 计算所有元素的平均值,作为一个Double.
|
| 实现Comparable
的元素集合 | max()
| 返回最大值。 |
| 任何集合或可迭代的 | maxBy( selector: (E) -> R)
| 返回应用selector
后的最大值(必须返回一个Comparable
)。 |
| 任何地图 | maxBy( selector: (Entry<K, V>) -> R)
| 返回应用selector
后的最大值(必须返回一个Comparable
)。 |
| 任何集合或可迭代的 | maxWith( comparator: Comparator<in E>)
| 根据提供的比较器返回最大值。 |
| 任何地图 | maxWith( comparator: Comparator<in Map.Entry<K,V>)
| 根据提供的比较器返回最大值。 |
| 实现Comparable
的元素集合 | min()
| 返回最小值。 |
| 任何集合或可迭代的 | minBy( selector: (E) -> R)
| 返回应用selector
后的最小值(必须返回一个Comparable
)。 |
| 任何地图 | minBy( selector: (Entry<K, V>) -> R)
| 返回应用selector
后的最小值(必须返回一个Comparable
)。 |
| 任何集合或可迭代的 | minWith( comparator: Comparator<in E>)
| 根据提供的比较器返回最小值。 |
| 任何地图 | minWith( comparator: Comparator<in Map.Entry<K,V>)
| 根据提供的比较器返回最小值。 |
化简获取集合或可迭代对象的第一个元素,将其存储在一个变量中,然后对集合或可迭代对象中的所有其他元素重复应用一个操作。例如,如果运算是加法,最后你得到集合的和:
start with: (1, 2, 3)
take 1st element: (1), remains (2, 3)
take next element, apply "+": (1+2), remains (3)
take next element, apply "+": (1+2+3), done.
result is 1+2+3 = 6
从右开始的缩减以相反的顺序遍历集合;也就是说,它首先获取最后一个元素,将运算符应用于倒数第二个元素,依此类推。缩减功能如表 16-18 所示。
表 16-18
减低
|功能
|
返回
|
描述
|
| --- | --- | --- |
| <S, E : S> iter<E>.reduce( operation: (acc: S, E) -> S)
| S
| 在集合或 iterable 上减少。操作 lambda 函数接收当前累加器值和当前迭代的元素。 |
| <S, E : S> iter<E>.reduceIndexed( operation: (index: Int, acc: S, E) -> S)
| S
| 与reduce()
相同,但是该操作另外接收当前迭代索引(0,1,2,...). |
| <S, E : S> list<E>.reduceRight( operation: (E, acc: S) -> S)
| S
| 从右侧减少。请注意,这对 iterables 不起作用,因为没有什么像正确的迭代。 |
| <S, E : S> list<E>.reduceRight- Indexed( operation: (index: Int, E, acc: S)-> S)
| S
| 与reduceRight()
相同,但是该操作另外接收当前迭代索引(0,1,2,...). |
请注意,尽管迭代遍历了类型为E
的元素,但操作函数也允许计算超类型E
。这就是类型规范中的E : S
所代表的意思。在这种情况下,累加器和总结果将与这个超类型具有相同的类型。
一个折就是减的老大哥。缩减从集合或 iterable 的第一个元素开始,然后使用其余的元素来更新它,而折叠则使用一个专用的折叠累加器对象,该对象逐步接收所有迭代的元素,因此可以更新其状态。因为累加器对象可以有任何合适的类型,所以折叠比还原更强大。折叠功能列于表 16-19 中。
表 16-19
可折叠的
|功能
|
返回
|
描述
|
| --- | --- | --- |
| iter.fold( initial: R, operation: (acc: R, E) -> R)
| R
| 在集合或 iterable 上折叠。第一个参数接收累加器对象。操作 lambda 函数接收当前累加器对象和当前迭代的元素。 |
| iter.foldIndexed( initial: R, operation: (index:Int, acc: R, E) -> R)
| R
| 与fold()
相同,但是该操作另外接收当前迭代索引(0,1,2,...). |
| list.foldRight( initial: R, operation: (E, acc: R) -> R)
| R
| 折叠列表,从最后一个对象开始,以相反的顺序迭代。第一个参数接收累加器对象。操作 lambda 函数接收当前累加器对象和当前迭代的元素。 |
| list.foldRightIndexed( initial: R, operation: (index:Int, E, acc: R) -> R)
| R
| 与foldRight()
相同,但是该操作另外接收当前迭代索引(0,1,2,...). |
练习 8
给出一个类data class Parcel(val receiverId:Int, val weight:Double)
和一个列表
val l = listOf( Parcel(1267395, 1.45),
Parcel(1515670, 0.46),
Parcel(8345674, 2.50),
Parcel(3418566, 1.47),
Parcel(3491245, 3.04)
)
不使用for
或while
循环计算重量总和。
连接
有时,您需要的不是一个完整的折叠操作,即一个对象从迭代中接收所有元素,而是创建一个集合或 iterable 的字符串表示,将所有元素的字符串表示连接起来。虽然这可以通过fold()
实现,但是 Kotlin 提供的一个专用连接函数有几个额外的特性,即一个前缀和一个后缀,以及一个限制和一个截断指示符。你用
fun <E> Iterable<E>.joinToString(
separator: CharSequence = ", ",
prefix: CharSequence = "",
postfix: CharSequence = "",
limit: Int = -1,
truncated: CharSequence = "...",
transform: (E) -> CharSequence = null
): String
集合或 iterable 上,具有以下特征:
-
如果您指定了分隔符,此分隔符将用于分隔输出字符串中的各项。否则将使用
,
。 -
如果指定前缀,它将用作输出字符串的前缀。否则将不使用任何内容。
-
如果指定了后缀,它将被用作输出字符串的后缀。否则将不使用任何内容。
-
如果指定了限制,则用于构造输出字符串的元素数量将受到限制。否则将使用
1
,这表示没有限制。 -
如果指定截断字符串,它将用于表示由于超出限制(如果给定)而导致的截断。否则将使用
...
。 -
如果指定一个转换函数,它将用于从每个元素创建一个字符串。否则将使用
null
,这意味着toString()
将应用于每个元素。
分组
分组是基于某种标准将一个列表分割成子列表。设想一个雇员列表,每个雇员都有一个employer
字段,您希望为每个雇主创建一个列表。通过编写几行代码,这并不难做到,但是因为这是一个重复的任务,所以有一些标准的库函数可以帮助我们。分组相关功能见表 16-20 。
表 16-20
分组
|功能
|
返回
|
描述
|
| --- | --- | --- |
| <E, K> iter.groupBy( keySelector: (E) -> K)
| Map<K, List<E>>
| 基于由keySelector
函数计算的密钥进行分组。 |
| <E, K> iter.groupBy( keySelector: (E) -> K, valueTransform: (E) -> V)
| Map<K, List<V>>
| 基于由keySelector
函数计算的键进行分组,但也通过valueTransform
函数转换值。 |
| <E, K> iter.groupingBy( keySelector: (E) -> K)
| Grouping<E,K>
| 基于由keySelector
函数计算的关键字准备分组。创建一个特殊的Grouping
对象,可用于进一步的操作。 |
| grouping.aggregate( operation: (key: K, accumulator: R?, element: E, first: Boolean) -> R)
| Map<K, R>
| 获取来自groupingBy()
的结果,并使用原始键构建地图。至于值,使用operation
来累加每个组的值(例如,累加器可以是一个列表)。 |
| grouping.eachCount()
| Map<K, Int>
| 返回每组元素计数的映射。 |
| grouping.fold( initialValueSelector: (key: K, element: E) -> R, operation: (key: K, accumulator: R, element: E) -> R)
| Map<K, R>
| 获取来自groupingBy()
的结果,并使用原始键构建地图。至于数值,使用operation
累加各组的数值。每组的初始累加器由initialValueSelector
函数构造。 |
在本节中,我们分别使用list
、set
、map
、coll
和iter
来表示List
、Set
、Map
、Collection
和Iterable
类型的变量。请记住,列表或集合是一个集合,任何集合都是可迭代的。
拉链
如果你有两个相关的列表,想把它们放在一起,Kotlin 提供了一个压缩功能,可以帮助你。比方说,您有一个雇员列表和另一个尚未注册的一年的年薪列表。两个列表大小相同,每个索引指向一对匹配的雇员和薪水。在命令式编程风格中,您应该编写类似这样的代码来获取更新的员工列表:
class Employee {
...
fun setSalary(year:Int, salary:Double) {}
}
val employees = ... // list
val newSalaries = ... // list
val newYear = 2018
val newEmployees = mutableListOf<Employee>()
for(ind in employees.indices) {
val e = employees[ind]
val sal = newSalaries[ind]
e.setSalary(newYear, sal)
newEmployees.add(e)
}
我们可以使用标准库提供的 zipping 函数以函数式的方式重写它:
<E, R> Iterable<E>.zip(
other: Iterable<R>
): List<Pair<E, R>>
这给了我们:
val employees = ... // list
val newSalaries = ... // list
val newYear = 2018
val newEmployees = employees.zip(newSalaries).
map{ p ->
p.first.setSalary(newYear, p.second)
p.first
}
这里的zip()
给出了一个Pair
的列表,每个列表包含一个Employee
和一份薪水(例如Double
)。map()
调查每一对,并相应地更新员工。
还有一个相反的操作,从一个列表创建两个列表,恰当地称为解压。
<E, R> Iterable<Pair<E, R>>.unzip():
Pair<List<E>, List<R>>
更准确地说,这是这种解压缩操作的第二部分;您将首先使用映射函数创建一个Pair
列表;例如:
list.map { item ->
Pair(item.something, item.somethingElse)
}.unzip()
开窗术
对于用户界面编程,你经常需要将一个列表分割成给定大小的块。例如,用户界面显示大小为 10 的块,并提供向前翻页和向后翻页按钮来显示较长列表的下一个或前一个块。为此,标准库提供了一个窗口功能(见表 16-21 )。
表 16-21
开窗术
|功能
|
返回
|
描述
|
| --- | --- | --- |
| <E> iterable.windowed( size: Int, step: Int = 1, partialWindows: Boolean = false )
| List<List<E>>
| 创建 iterable 或集合的窗口视图。每个块都有大小size
,step
表示每个块的索引偏移量(通常设置step
= size
)。如果你想在最后允许更小的块,你必须将partialWindows
设置为true
。 |
| <E, R> iterable.windowed( size: Int, step: Int = 1, partialWindows: Boolean = false, transform: (List<E>) -> R)
| List<R>
| 与windowed()
相同,但是提供了一个transform
函数来作用于每个块。 |
顺序
序列是延迟求值的集合。我们的意思是,除了从kotlin.collections
包中收集数据之外,没有大量的数据保存在内存中。因此,如果您创建一个大小为 1,000,000 的集合,将有 1,000,000 项以对象引用或原语的形式分配到内存中。然而,一个大小为 1,000,000 的序列只是表明我们有一些东西可以迭代 1,000,000 次,而不需要所有与之相关的值。序列接口、类、函数都有自己的包:kotlin.sequences
。
序列暴露了很多我们从集合中已经知道的函数。您可以使用forEach()
、应用过滤器、执行映射、使用缩减、执行折叠等等。这里我们没有全部展示;相反,我们列出了几个更重要的,让你开始。有关更多信息,请参考 Kotlin 文档。
要创建一个给定值列表的序列,可以使用sequenceOf()
函数;例如:
sequenceOf(1, 2, 7, 5)
或者,你可以拿任何Iterable
(集合或列表或范围,或任何集合)来写
iter.asSequence()
要创建不依赖于现有集合或数组的真正序列,有几种可能性。其中最简单的可能是使用函数generateSequence()
,如
// Signature:
// fun <T : Any> generateSequence(
// nextFunction: () -> T?
// ): Sequence<T>
var iterVar = 0
val seq = generateSequence {
iterVar++
}
这里我们所要做的就是提供一个函数来生成下一个序列值。这种方法的缺点是我们有一个状态,即迭代属性iterVar
,在generateSequence()
周围的某个地方。这是干净代码的反模式思维。前来救援的是generateSequence()
的另一个变种:
fun <T : Any> generateSequence(
seed: T?,
nextFunction: (T) -> T?
): Sequence<T>
// or
fun <T : Any> generateSequence(
seedFunction: () -> T?,
nextFunction: (T) -> T?
): Sequence<T>
在这里,我们可以直接或通过一个生成器函数提供一个种子,nextFunction()
lambda 接收当前的迭代器值,并返回下一个迭代器值。一个非常简单的序列(0,1,2,...)这样写道
val seq = generateSequence(
seed = 0,
nextFunction = { curr -> curr + 1 }
)
// example usage:
seq.take(10).forEach { i ->
// i will have values 0, 1, 2, ..., 9
...
}
迭代变量不一定是一个Int
,甚至根本不是一个数字。作为一个例子,考虑斐波纳契数列1
、1
、2
、3
、5
、8
,...其中每一项都是前两项的总和。这可以通过Pair
来处理,顺序如下
val seqFib = generateSequence(
seed = Pair(1,1),
nextFunction = { curr ->
Pair(curr.second, curr.first + curr.second)
}
)
// example usage
seqFib.take(10).map { it.second }.forEach {
Log.e("LOG", "fib: " + it)
}
nextFunction
以一个pair(1,1)
开始,接着是一个pair(1,2)
、pair(2,3)
、pair(3,5)
,以此类推。示例用法片段中的映射提取并显示每对中的第二个值。有趣的是,对于更高的数字,每对中第二个成员与第一个成员的比例接近于黄金比例 0.5 · (1 +
) = 1.6180339887
:
val p = seqFib.take(40).last
val gr = p.second * 1.0 / p.first
// = 1.618033988749895
一种更灵活、更复杂的方法是使用另一个序列生成函数:sequence()
。它的签名是
fun <T> sequence(
block: suspend SequenceScope<T>.() -> Unit
): Sequence<T>
该函数实际上以如下方式实例化了一个kotlin.sequences.Sequence
对象:
Sequence { iterator(block) }
其中iterator()
创建并返回SequenceBuilderIterator
的实例。lambda 函数前面的这个SequenceBuilderIterator
和suspend
确保序列可以在并行执行环境中使用。我们将在本书的后面讨论并发执行。我们现在需要知道的是,由于具有接收器规范SequenceScope<T>.() -> Unit
的λ,关于block
λ函数,我们在SequenceScope
对象的环境中起作用。为了让这个构造做一些合理的事情,从内部block
你必须至少调用一个
yieldAll([some implementation of Iterable])
// or
yieldAll([some implementation of Iterator])
// or
yieldAll([some implementation of Sequence])
作为一个例子,考虑这个:
val sequence = sequence {
// This is an iterable:
yieldAll(1..10 step 2)
}
// Usage example:
sequence.take(8).forEach {
Log.e("LOG", it.toString())
}
// -> 1, 3, 5, 7, 9
经营者
对于 iterables,包括像集合和列表这样的所有集合,以及映射,有两个操作符,如表 16-22 所示,您可以使用它们中的两个来组合。
表 16-22
经营者
|操作数
|
操作员
|
操作数
|
返回
|
| --- | --- | --- | --- |
| Iterable(集合、列表、集合) | intersect
| Iterable(集合、列表、集合) | 创建一个新的不可变的Set
,它包含两个操作数中包含的所有元素。 |
| Iterable(集合、列表、集合) | union
| Iterable(集合、列表、集合) | 创建一个新的不可变的Set
,它包含一个或两个操作数中包含的所有元素。 |
| Iterable(集合、列表、集合) | +
| E | 返回一个新的不可变的List
,将左操作数中的所有元素追加到右操作数中。 |
| Iterable(集合、列表、集合) | +
| 可迭代,数组,序列 | 返回一个新的不可变的List
,将左操作数中的所有元素追加到右操作数中的所有元素。 |
| Iterable(集合、列表、集合) | - | E
| 返回一个新的不可变的List
,包含左操作数中的所有元素,如果左操作数中存在右操作数,则减去右操作数。 |
| Iterable(集合、列表、集合) | - | 可迭代,数组,序列 | 返回一个新的不可变的List
,它包含左操作数中的所有元素,减去右操作数中也存在于左操作数中的所有元素。 |
| 地图 | +
| Pair<K,V>
| 返回一个新的不可变映射,包含左操作数和右操作数的所有条目。如果该键以前存在,则该项会被覆盖。 |
| 地图 | +
| Iterable< Pair<K,V>>, Array<out Pair<K, V>>, Sequence< Pair<K,V>>, Map<out K, V>
| 返回一个新的不可变映射,包含左操作数中的所有条目,以及右操作数中的所有元素。如果右操作数中的任何键也存在于左操作数中,则相应的条目会被右操作数覆盖。 |
| 地图 | -
| K
| 返回一个新的不可变映射,其中包含左操作数的所有条目,但右操作数指定的键已被删除(如果存在)。 |
| 地图 | -
| Iterable<K>, Array<out K>, Sequence<K>
| 返回一个新的不可变映射,其中包含左操作数的所有条目,但右操作数指定的所有键都被删除(仅针对左操作数中存在的键)。 |
因为很多其他的操作符像*
、/
、%
等等都是未定义的,我们知道我们可以通过操作符重载来定义它们,你可以为集合和地图设计你自己的操作符来实现很多事情。只要确保你提供了好的文档,这样其他人就可以理解他们在做什么。
十七、更多 API
本章收集了一些你可以在应用中使用的 API。首先,我们有用于数学计算的数学 API。对于日期和时间处理,包括不同时间表示之间的转换,以及解析和格式化日期和时间,我们描述了日期和时间 API。对于输入和输出,Android 归结为文件处理,我们给出了输入和输出 API 的概述。为了动态获取类成员信息,使用了反射 API 这不是面向对象的突出部分,但在某些情况下会有帮助,所以我们包括了一篇关于反射的论文。正则表达式提供了一个非常强大的方法来研究和操作字符串中的模式,所以我们用一个正则表达式结构的概览来结束这一章。
数学应用编程接口
Kotlin 允许你从包java.lang
中导入包Math
import java.lang.Math
这可以像一个单例对象一样使用,并且有很多数学函数,比如sin()
、cos()
、tan()
等等。您可以在 Java API 文档中查找它们。Kotlin 在kotlin.math
包中提供了其中一些的副本,所以在大多数情况下你可以不使用java.lang
导入。例如,正弦函数是作为kotlin.math
包中的一个类外函数提供的,所以要使用它,您可以编写
import kotlin.math.sin
...
val x = sin(1.562)
许多其他功能也是如此。表 17-1 包括一个非详尽的列表。要获得完整的列表,请参阅网站上的 Kotlin 官方文档。
表 17-1。
Kotlin 数学函数
|功能
|
描述
|
| --- | --- |
| sin()
、cos()
、tan()
| 正弦、余弦和正切函数。与Math.sin()
、Math.cos()
和Math.tan()
相同,但另外允许一个Float
作为自变量。 |
| asin()
、acos()
、atan()
、atan2()
| 反正弦、反余弦和反正切函数。函数atan2()
接受两个对应于(x, y)
坐标的参数。与Math.asin()
、Math.acos()
、Math.atan()
和Math.atan2()
相同,但另外允许Float
s 作为自变量。 |
| sinh()
、cosh()
、tanh()
| 双曲正弦、余弦和正切函数。与Math.sinh()
、Math.cosh()
和Math.tanh()
相同,但另外允许一个Float
作为自变量。 |
| asinh()
、acosh()
、atanh()
| 反双曲正弦、余弦和正切函数。与Math.asinh()
、Math.acosh()
和Math.atanh()
相同,但另外允许一个Float
作为自变量。 |
| abs()
| 一个数的绝对值。 |
| floor()
,ceil()
| 对于Float
或Double
,下一个整数值的上下限值。该类型保持不变,因此您必须添加.toInt()
或.toLong()
来将其转换为整数类型。与Math.floor()
和Math.ceil()
相同,但另外允许一个Float
作为自变量。 |
| round()
| 向上舍入到最接近的整数。该类型保持不变,因此您必须添加.toInt()
或.toLong()
来将其转换为整数类型。与Math.round()
相同,但另外允许一个Float
作为参数。 |
| exp()
,log()
| 指数函数和对数。与Math.exp()
和Math.log()
相同,但另外允许一个Float
作为参数。 |
| pow()
| xy
幂函数(两个参数)。与Math.pow()
相同,但另外允许一个Float
作为参数。 |
| sqrt()
| 平方根。与Math.sqrt()
相同,但另外允许一个Float
作为参数。 |
| min()
,max()
| 两个数的最小值和最大值。 |
| sign()
| 符号函数。负值返回-1.0,0.0 返回 0.0,正数返回 1.0。与Math.sign()
相同,但另外允许一个Float
作为参数。 |
同一个包kotlin.math
包含几个扩展属性。例如,你可以写
import kotlin.math.absoluteValue
...
val x:Double = -3.5
val y = x.absoluteValue // -> 3.5
这种扩展的完整列表包括用于数字绝对值的.absoluteValue
(Double
、Float
、Int
或Long
)。常数E
和PI
是自然对数和π(π)的底数。属性.sign
返回一个数字(Double
、Float
、Int
或Long
)的符号,.ulp
返回一个Float
或Double
的最后一位的单位(这是两个数字之间的最小可测距离)。
日期和时间 API,API 级别 25 或更低
Kotlin 没有单独的日期和时间 API,这就是为什么在 Kotlin 文档中找不到任何关于如何处理日期和时间的信息。但是,您可以使用 Java 中的日期和时间 API,它包含在 Android 中,可由 Kotlin 访问。
注意
Java 8 中的日期和时间 API 发生了很大的变化。Android API 最高 25 版本不使用 Java 8,但后来的 API 版本使用;这就是为什么我们需要描述两个日期和时间 API。本节适用于所有 Android API 级别,因此引用了较旧的 Java 7 日期和时间 API。
从 Java 第 7 版借用的日期和时间 API 以下列表达式为中心:
import java.util.Date
import java.util.GregorianCalendar
import java.text.SimpleDateFormat
val timeMillis:Long = System.currentTimeMillis()
val d = Date()
val cal = GregorianCalendar()
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
第一个,System.currentTimeMillis()
,表达了绝对时间的观念。更准确地说,这是自 1970 年 1 月 1 日 00:00:00 UTC 以来的毫秒数。这是低级信息,通常用作数据库条目的可靠时间戳。在性能测量过程中,您还会看到它对程序部分的快速计时:
val t1 = System.currentTimeMillis()
...
Log.d("LOG", "Calculation took " +
(System.currentTimeMillis() - t1) + "ms")
Date
类是绝对时间的一个薄薄的包装。它将其表示为一个对象,并提供一个简单的toString()
实现,以人类可读的格式输出时间:
import java.util.Date
...
val d = Date() // current time
Log.d("LOG", d.toString())
// -> s.th. like
// Sun Jan 13 10:12:26 GMT+01:00 2019
一个Date
实例给出了从 1970-01-01 00:00:00 UTC 到它的当前值所经过的毫秒数。要获得那个数字——它是一个Long
类型的数字——使用它的time
属性:
import java.util.Date
...
val d = Date() // current time
val tm = d.time // ms since 1970-01-01T00:00:00 UTC
类给了我们工具来处理月、周、时区、一天中的时间、一小时中的分钟、一分钟中的秒钟以及所有这些东西。
import java.util.Date
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.TimeZone
...
val cal = GregorianCalendar()
// <- will hold the current time
cal.timeZone = TimeZone.getTimeZone("US/Hawaii")
// Note: TimeZone.getAvailableIDs().forEach {
// Log.e("LOG","!!! " + it) }
// shows a list
// Set to current time
cal.time = Date()
// Set to 2018-02-01T13:27:44
cal.set(2018, Calendar.FEBRUARY, 1, 13, 27 ,44)
val month = cal.get(Calendar.MONTH)
val hour = cal.get(Calendar.HOUR_OF_DAY)
SimpleDateFormat
类帮助我们生成人类可读的日期和时间的字符串表示,并允许我们将这样的字符串表示转换回Date
实例:
import java.util.Date
import java.text.SimpleDateFormat
import java.util.Locale
...
val d = Date() // now
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
Log.d("LOG", sdf.format(d)) // -> 2019-01-13 13:41
val loc = Locale("en")
val sdf2 = SimpleDateFormat("yyyy-MMM-dd HH:mm", loc)
Log.d("LOG", sdf2.format(d)) // -> 2019-Jan-13 13:41
val d2:Date = sdf.parse("2018-12-12 17:13")
Log.d("LOG", d2.toString())
// -> Wed Dec 12 17:13:00 GMT+01:00 2018
这些示例使用通过查询操作系统检索的时区。您也可以在SimpleDateFormat
对象上设置时区,如下所示:
import java.text.SimpleDateFormat
import java.util.Date
import java.util.TimeZone
...
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
sdf.timeZone = TimeZone.getTimeZone("US/Hawaii")
val d:Date = sdf.parse("2018-12-12 17:13")
Log.d("LOG", d.toString())
// -> Thu Dec 13 04:13:00 GMT+01:00 2018
顺便说一下,Date.toString()
隐式地使用它通过查询操作系统得到的时区(在我的例子中是欧洲/柏林)。
警告
Date
和SimpleDateFormat
都是而不是线程安全的;您不能在不同的线程之间共享它们的实例。
有关所有这些日期和时间 API 接口和类以及相关接口和类的详细信息,请参考 Oracle 的 Java 文档。确保不要使用高于 7 的 Java 版本的文档。我们将在下一节讨论与 Java 8 相关的日期和时间 API。
日期和时间 API,API 级别 26 或更高
注意
本节针对从 26 (Android 8.0)开始的 Android API 级别,因此指的是 Java 8 日期和时间 API。
从 Android API level 26 (Android 8.0)开始,一些新的日期和时间相关的接口和类可用。您可以继续使用上一节中描述的旧 API,但是新 API 包含一些我们在本节中概述的改进。
注意
截至 2019 年初,使用 API 级别 26 或更高的设备数量并不是很高。在开始开发 API 水平超过 25 的产品之前,您应该咨询一下发行调查。
只有在模块的build.gradle
文件中将minSdkVersion
设置为 26 或更大时,才能使用新的 API:
android {
...
defaultConfig {
...
minSdkVersion 26
...
}
...
}
新的接口和类驻留在包java.time
中。对于本节的其余部分,我们通常省略相应的导入。
当地日期和时间
本地日期和时间从观察者的上下文中得到描述,并且基本上使用来自java.time
包的以下类:
-
LocalDate
这个类对应于格式
yyyy-MM-dd
(例如 2018-11-27)的日期表示,并且不考虑一天中的时间。 -
LocalTime
这个类对应于格式
HH:mm:ss
(例如 21:27:55)的时间表示,而不考虑日期。 -
LocalDateTime
LocalDate
和LocalTime
的组合,可能用yyyy-MM-ddTHH:mm:ss
来表示(T
是字面意思)。
格式指示符yyyy
、HH
等在java.time.DateTimeFormatter
的 API 文档中有描述。
这三者都包含生成对象实例的工厂方法。这包括获取当前日期和时间:
import java.time.*
// current day in the default time zone
val ld1 : LocalDate = LocalDate.now()
// "Now" corresponds to different days in different
// time zones. The following allows us to specify a
// different time zone
val z2 = ZoneId.of("UTC+01")
val ld2 : LocalDate = LocalDate.now(z2)
val ld3 = LocalDate.of(2018, Month.MARCH, 27)
val ld4 = LocalDate.of(2018, 3, 27) // the same
val lt1 : LocalTime = LocalTime.now()
val lt2 = LocalTime.now(z2) // different time zone
val lt3 = LocalTime.of(23, 27, 55) // 23:27:55
val ldt1 = LocalDateTime.now()
val ldt2 = LocalDateTime.now(z2)
val ldt3 = LocalDateTime.of(2018, Month.APRIL, 23, 23, 44, 12)
// <- 2018-04-23T23:44:12
请注意,尽管能够添加时区规范来进一步指定“现在”对应的时间,但该信息决不会以某种方式存储在日期和时间对象中。根据定义,本地日期和时间与时区无关。
我们可以解析字符串来获得LocalDate
、LocalTime
和LocalDateTime
的实例:
import java.time.*
import java.time.format.*
// Parse ISO-8601
val ld1 = LocalDate.parse("2019-02-13")
// Parse other formats. For the format specification,
// see API documentation of class DateTimeFormatter.
val formatter1 = DateTimeFormatter.ofPattern("yyyy MM dd")
val ld2 = LocalDate.parse("2019 02 13", formatter1)
val lt1 = LocalTime.parse("21:17:23")
val lt2 = LocalTime.parse("21:17:23.3734")
val formatter2 = DateTimeFormatter.ofPattern("HH|mm|ss")
val lt3 = LocalTime.parse("21|17|23", formatter2)
val ldt1 = LocalDateTime.parse("2019-02-13T21:17:23")
val ldt2 = LocalDateTime.parse("2019-02-13T21:17:23.3734")
val formatter3 = DateTimeFormatter.ofPattern("yyyy.MM.dd.HH.mm.ss")
val ldt3 = LocalTime.parse("2019.04.23.17.45.23", formatter3)
我们可以定制自己的LocalDate
、LocalTime
和LocalDateTime
实例的字符串表示:
import android.util.Log
import java.time.*
import java.time.format.*
val s1 = LocalDate.now().format(
DateTimeFormatter.ofPattern("yyyy|MM|dd"))
Log.d("LOG","s1 = ${s1}") // -> 2019|01|14
val s2 = LocalDate.now().format(
DateTimeFormatter.ISO_LOCAL_DATE)
Log.d("LOG","s2 = ${s2}") // -> 2019-01-14
val s3 = LocalTime.now().format(
DateTimeFormatter.ofPattern("HH mm ss"))
Log.d("LOG","s3 = ${s3}") // -> 14 46 20
val s4 = LocalTime.now().format(
DateTimeFormatter.ISO_LOCAL_TIME)
Log.d("LOG","s4 = ${s4}") // 14:46:20.503
val s5 = LocalDateTime.now().format(
DateTimeFormatter.ofPattern(
"yyyy MM dd - HH mm ss"))
Log.d("LOG","s5 = ${s5}") // -> 2019 01 14 - 14 46 20
val s6 = LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME)
Log.d("LOG","s6 = ${s6}") // -> 2019-01-14T14:46:20.505
您可以使用LocalDate
、LocalTime
和LocalDateTime
实例执行时间运算:
import java.time.*
import java.time.temporal.*
val ld = LocalDate.now()
val lt = LocalTime.now()
val ldt = LocalDateTime.now()
val ld2 = ld.minusDays(7L)
val ld3 = ld.plusWeeks(2L)
val ld4 = ld.with(ChronoField.MONTH_OF_YEAR, 11L)
val lt2 = lt.plus(Duration.of(2L, ChronoUnit.SECONDS))
val lt3 = lt.plusSeconds(2L) // same
val ldt2 = ldt.plusWeeks(2L).minusHours(2L)
从LocalDateTime
我们可以计算自 1970-01-01:00:00:00 UTC 以来经过的秒数,类似于旧 API 中的System.currentTimeMillis()
函数:
import java.time.*
val ldt : LocalDateTime = ...
val secs = ldt.toEpochSecond(ZoneOffset.of("+01:00"))
注意,要获得纪元秒,更好的解决方案是取一个ZonedDateTime
。我们稍后将讨论分区日期和时间。
瞬间
瞬间是时间线上的瞬间点。在需要唯一的绝对时间戳的情况下使用它,例如,在数据库中注册事件等。精确的定义有点复杂;关于介绍,请阅读java.time.Instant
的 API 文档。
例如,您可以通过查询系统时钟、指定自 1970-01-01T00:00:00Z 以来经过的时间、解析时间字符串或从其他日期和时间对象中获取一个Instant
:
import java.time.*
val inz1 = Instant.now() // default time zone
// Specify time zone
val inz2 = Instant.now(Clock.system(
ZoneId.of("America/Buenos_Aires")))
val secondsSince1970 : Long = 1_000_000_000L
val nanoAdjustment : Long = 300_000_000 // 300ms
val inz3 = Instant.ofEpochSecond(
secondsSince1970, nanoAdjustment)
// "Z" is UTC ("Zulu" time)
val inz4 = Instant.parse("2018-01-23T23:33:14.513Z")
// Uniform converter, for the ZonedDateTime class
// see below
val inz5 = Instant.from(ZonedDateTime.parse("2019-02-13T21:17:23+01:00[Europe/Paris]"))
偏移日期和时间
偏移日期和时间类似于Instant
s,加上 UTC/格林威治时间的附加时间偏移。对于这样的偏移日期和时间,我们有两个类,OffsetTime
和OffsetDateTime
,您可以获得如下实例:
import java.time.*
import java.time.format.DateTimeFormatter
// Get now ------------------------------------------
// System clock, default time zone
val ot1 = OffsetTime.now()
val odt1 = OffsetDateTime.now()
// Use a different clock
val clock:Clock = ...
val ot2 = OffsetTime.now(clock)
val odt2 = OffsetDateTime.now(clock)
// Use a different time zone
val ot3 = OffsetTime.now(
ZoneId.of("America/Buenos_Aires"))
val odt3 = OffsetDateTime.now(
ZoneId.of("America/Buenos_Aires"))
// From time details --------------------------------
val ot4 = OffsetTime.of(23, 17, 3, 500_000_000,
ZoneOffset.of("-02:00"))
val odt4 = OffsetDateTime.of(
1985, 4, 23, // 19685-04-23
23, 17, 3, 500_000_000, // 23:17:03.5
ZoneOffset.of("+02:00"))
// Parsed -------------------------------------------
val ot5 = OffsetTime.parse("16:15:30+01:00")
val odt5 = OffsetDateTime.parse("2007-12-03T17:15:30-08:00")
val ot6 = OffsetTime.parse("16 15 +00:00",
DateTimeFormatter.ofPattern("HH mm XXX"))
val odt6 = OffsetDateTime.parse("20181115 - 231644 +02:00",
DateTimeFormatter.ofPattern("yyyyMMdd - HHmmss XXX"))
// From other objects -------------------------------
val lt = LocalTime.parse("16:14:27.235")
val ld = LocalDate.parse("2018-05-24")
val inz = Instant.parse("2018-01-23T23:33:14.513Z")
val ot7 = OffsetTime.of(lt, ZoneOffset.of("+02:00"))
val odt7 = OffsetDateTime.of(ld, lt, ZoneOffset.of("+02:00"))
val ot8 = OffsetTime.ofInstant(inz, ZoneId.of("America/Buenos_Aires"))
val odt8 = OffsetDateTime.ofInstant(inz, ZoneId.of("America/Buenos_Aires"))
val zdt = ZonedDateTime.of( // see below
2018, 2, 27, // 2018-02-27
23, 27, 33, 0, // 23:27:33.0
ZoneId.of("Pacific/Tahiti"))
val odt9 = zdt.toOffsetDateTime()
// uniform converter
val ot10 = OffsetTime.from(zdt)
val odt10 = OffsetDateTime.from(zdt)
使用偏移日期和时间,您可以使用与本地日期和时间基本相同的方式进行运算和格式化。此外,对于转换操作,我们有
import java.time.*
val ot = OffsetTime.parse("16:15:30+01:00")
val lt : LocalTime = ot.toLocalTime()
val odt = OffsetDateTime.parse("2007-12-03T17:15:30-08:00")
val ldt : LocalDateTime = odt.toLocalDateTime()
val lt2 : LocalTime = odt.toLocalTime()
val ld2 : LocalDate = odt.toLocalDate()
val ot2 : OffsetTime = odt.toOffsetTime()
val zdt : ZonedDateTime = odt.toZonedDateTime()
// see below for class ZonedDateTime
分区日期和时间
如果我们不关心用户的位置,本地日期和时间是很好的。如果我们让世界各地的不同实体、用户、计算机或设备输入日期和时间,我们需要添加时区信息。这就是类ZonedDateTime
的用途。
注意,这与带有固定时间偏移信息的日期和时间不同,与OffsetDateTime
的情况不同。时区包括夏令时等需要考虑的因素。
与LocalDateTime
类似,ZonedDateTime
现在有了获得的工厂方法:
import java.time.*
// Get "now" using the system clock and the default
// time zone from your operating system.
val zdt1 = ZonedDateTime.now()
// Get "now" using a time zone. To list all available
// predefined zone IDs, try
// Log.d("LOG", ZoneId.getAvailableZoneIds().
// joinToString { it + "\n" })
val z2 = ZoneId.of("UTC+01")
val zdt2 = ZonedDateTime.now(z2)
// Get "now" using an instance of Clock
val clock3 = Clock.systemUTC()
val zdt3 = ZonedDateTime.now(clock3)
我们还可以使用详细的时间信息获得一个ZonedDateTime
,并解析时间戳的字符串表示以获得一个ZonedDateTime
:
import java.time.*
val z4 = ZoneId.of("Pacific/Tahiti")
val zdt4 = ZonedDateTime.of(
2018, 2, 27, // 2018-02-27
23, 27, 33, 0, // 23:27:33.0
z4)
// The 7th par is nanoseconds, so for
// 23:27:33.5 you have to enter
// 500_000_000 here
val localDate = LocalDate.parse("2018-02-27")
val localTime = LocalTime.parse("23:44:55")
val zdt5 = ZonedDateTime.of(localDate, localTime,
ZoneId.of("America/Buenos_Aires"))
val ldt = LocalDateTime.parse("2018-02-27T23:44:55.3")
val zdt6 = ZonedDateTime.of(ldt,
ZoneId.of("America/Buenos_Aires"))
val inz = Instant.parse("2018-01-23T23:33:14.513Z")
val zdt7 = ZonedDateTime.ofInstant(inz,
ZoneId.of("America/Buenos_Aires"))
val zdt8 = ZonedDateTime.parse(
"2018-01-23T23:33:14Z[America/Buenos_Aires]")
一个ZonedDateTime
允许像plusWeeks(weeks:Long)
和minusDays(days:Long)
这样的操作用增加或减少给定的时间来构建一个新的实例。这适用于Years
、Months
、Weeks
、Days
、Hours
、Minutes
、Seconds
或Nanos
中的任何一种。
对于不同的时间段有不同的 getter 函数:getYear()
、getMonth()
、getMonthValue()
、getDayOfMonth()
、getHour()
、getMinute()
、getSecond()
和getNano()
,以及其他一些函数。要得到时区,写getZone()
。
要解析日期和时间字符串并将ZonedDateTime
转换为字符串,请编写:
import java.time.*
import java.time.format.DateTimeFormatter
val zdt1 = ZonedDateTime.parse(
"2007-12-03T10:15:30+01:00[Europe/Paris]")
val formatter = DateTimeFormatter.ofPattern(
"HH:mm:ss.SSS")
// See DateTimeFormatter API docs for more options
val str = zdt1.format(formatter)
ZonedDateTime
和LocalDateTime
之间的连接通过
import java.time.*
val ldt = LocalDateTime.parse("2018-02-27T23:44:55.3")
val zdt = ZonedDateTime.of(ldt,
ZoneId.of("America/Buenos_Aires"))
val ldt2 = zdt.toLocalTime()
持续时间和周期
持续时间是两个实例之间的物理时间跨度。周期与此类似,但只处理年、月和日,并考虑日历系统。有特殊的Duration
和Period
类用于处理持续时间和周期:
import java.time.*
import java.time.temporal.ChronoUnit
val ldt1 = LocalDateTime.parse("2018-01-23T17:23:00")
val ldt2 = LocalDateTime.parse("2018-01-24T16:13:10")
val ldt3 = LocalDateTime.parse("2020-01-24T16:13:10")
// Getting a duration: ------------------------------
val d1 = Duration.between(ldt1, ldt2)
// Note: this works also for Instant and ZonedDateTime
// objects
val d2 = Duration.of(27L, ChronoUnit.HOURS) // 27hours
val d3 = Duration.ZERO.
plusDays(3L).
plusHours(4L).
minusMinutes(78L)
val d4 = Duration.parse("P2DT3H4M")
// <- 2 days, 3 hours, 4 minutes
// For more specifiers, see the API documentation
// of Duration.parse()
// Getting a period: --------------------------------
val ld1 = LocalDate.parse("2018-04-23")
val ld2 = LocalDate.parse("2018-08-16")
val p1 = Period.between(ld1, ld2)
// Note, end date not inclusive
val p2 = Period.of(2, 3, -1)
// <- 2 years + 3 months - 1 day
val p3 = Period.parse("P1Y2M-3D")
// <- 1 year + 2 months - 3 days
// For more specifiers, see the API documentation
// of Period.parse()
您可以对Duration
或Period
类的实例执行算术计算:
import java.time.*
// Duration operations: ------------------------------
val d = Duration.parse("P2DT3H4M")
// <- 2 days, 3 hours, 4 minutes
val d2 = d.plusDays(3L)
// also: .minusDays(33L)
// or .plusHours(2L) or .minusHours(1L)
// or .plusMinutes(77L) or .minusMinutes(7L)
// or .plusSeconds(23L) or .minusSeconds(5L)
// or .plusMillis(11L) or .minusMillis(55L)
// or .plusNanos(1000L) or .minusNanos(5_000_000L)
val d3 = d.abs() // make positive
val d4 = d.negated() // swap sign
val d5 = d.multipliedBy(3L) // three times as long
val d6 = d.dividedBy(2L) // half as long
// Period operations: --------------------------------
val p = Period.of(2, 3, -1)
// <- 2 years + 3 months - 1 day
val p2 = p.normalized()
// <- possibly adjusts the year to make the month lie
// inside [-11;+11]
val p3 = p.negated()
val p4 = p.minusYears(11L)
// also: .plusYears(3L)
// or .minusMonths(4L) or .plusMonths(2L)
// or .minusDays(40L) or .plusDays(5L)
val p5 = p.multipliedBy(5) // 5 times as long
您可以使用持续时间和周期向LocalDate
、LocalTime
、LocalDateTime
、ZonedDateTime
和Instant
对象添加或从中减去时间量。
import java.time.*
val d = Duration.parse("P2DT3H4M")
val p = Period.of(2, 3, -1)
// <- 2 years + 3 months - 1 day
val ld = LocalDate.parse("2018-04-23")
val lt = LocalTime.parse("17:13:12")
val ldt = LocalDateTime.of(ld, lt)
val zdt = ZonedDateTime.parse(
"2007-12-03T10:15:30+01:00[Europe/Paris]")
val inz = Instant.parse("2018-01-23T23:33:14.513Z")
// ---- Using a LocalDate
val ld2 = ld.plus(p) // or .minus(p)
// val ld3 = ld.plus(d) // -> exception
// val ld4 = ld.minus(d) // -> exception
// ---- Using a LocalTime
val lt2 = lt.plus(d) // or .minus(d)
// val lt3 = lt.minus(p) // -> exception
// val lt4 = lt.plus(p) // -> exception
// ---- Using a LocalDateTime
val ldt2 = ldt.plus(d) // or .minus(d)
val ldt3 = ldt.plus(p) // or .minus(p)
// ---- Using a ZonedDateTime
val zdt2 = zdt.plus(d) // or .minus(d)
val zdt3 = zdt.plus(p) // or .minus(p)
// ---- Using an Instant
val inz2 = inz.plus(d) // or .minus(d)
// val inz3 = inz.minus(p) // -> exception
// val inz4 = inz.plus(p) // -> exception
请注意,有些操作是不允许的,会导致异常。这些在前面的清单中被注释掉了。例外的原因是时间概念中可能的精度损失或不匹配。有关详细信息,请参见 API 文档。
时钟
一个Clock
位于日期和时间 API 的深处。对于许多(如果不是大多数)应用,您可以很好地处理本地日期和时间、偏移和分区日期和时间以及瞬间。对于测试和特殊情况,可能有必要调整时钟使用以使变为:
import java.time.*
val clock : Clock = ...
val ldt = LocalDateTime.now(clock)
val zdt = ZonedDateTime.now(clock)
val inz = Instant.now(clock)
除了覆盖抽象的Clock
类,Clock
本身提供了几个函数来调整时钟的使用。这两个特别有趣:
-
这是一个总是返回同一时刻的时钟。
-
Clock.offset(baseClock:Clock, offsetDuration:Duration)
:返回一个新的时钟,该时钟是从基础时钟加上指定的持续时间得到的。
然而,如果您重写了时钟,您必须至少实现来自Clock
基类的抽象函数。下面是一个时钟的例子,它总是返回相同的时刻,并且不关心时区:
import java.time.*
val myClock = object : Clock() {
override fun withZone(zone: ZoneId?): Clock {
// Supposed to return a copy of this clock
// with a different time zone
return this
}
override fun getZone(): ZoneId {
// Supposed to return the zone ID
return ZoneId.of("Z")
}
override fun instant(): Instant {
// This is the engine of the clock. It must
// provide an Instant
return Instant.parse("2018-01-23T23:33:14Z")
}
}
... use myClock
练习 1
创建一个时钟ClockTwiceAsFast
,用构造函数从 UTC 系统时钟获取时间。在此之后,时钟应该运行两倍的速度。忽略区域信息。要证明它正在以预期的方式运行,请使用
import java.time.*
val myClock = ClockTwiceAsFast()
Log.d("LOG", LocalDateTime.now(myClock).format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME))
Thread.sleep(1000L)
Log.d("LOG", LocalDateTime.now(myClock).format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME))
输入和输出
在 Android 环境中,你可能不会经常使用输入和输出。你的应用的用户看不到一个控制台println("Hello World")
会打印到那里,而且你的应用产生的任何日志都不应该被最终用户看到。此外,为了保存和读取任何类型的数据,您可以使用内置的数据库。
话虽如此,但如果您绝对需要,您仍然可以读取和写入文件以进行输入和输出。在 Android 中,最好使用位于指定文件系统空间中的文件,这些文件可以被你的应用访问。你通过写作做到这一点
import java.io.File
// We are inside an Activity or other Context!
val dataDir:File = getFilesDir()
尽管这个清单中的类命名为File
,但是dataDir
对应于一个目录,而不是狭义的数据文件。本节的其余部分假设您已经预先考虑了代码片段val dataDir = getFilesDir()
。
Kotlin 的文件处理严重依赖于 Java 接口和类,并为一些 Java 类添加了扩展。还有几个类外函数在包kotlin.io
中定义。不用导入kotlin.io
;它是默认导入的,因此这个包中的所有类扩展都是默认启用的。
创建一些测试文件
为了有一些文件让您开始试验 I/O API,请运行以下命令一次:
dataDir.resolve("a.txt").takeIf{ !it.exists() }.appendText("Hello World A")
dataDir.resolve("b.txt").takeIf{ !it.exists() }.appendText("Hello World B")
File(dataDir,"dir1").mkdirs()dataDir.resolve("dir1").resolve("a.txt").
takeIf{ !it.exists() }.appendText("Hello World dir1-A")
我们稍后将讨论这些功能。
文件名
为了获得最大的互操作性,您应该将文件名限制为仅包含 A–Z、A–Z、0–9、_、-和。同样,为了表明文件file
位于目录dir
中,写下dir/file
。要指定文件系统的根目录,请使用/
。
注意
斜线(/
)是 Android 上的文件系统分隔符。其他操作系统使用不同的分隔符。如果你想真正精通多种语言,你可以写"dir" + File.separator + "file".
运行时引擎将为它工作的操作系统选择合适的分隔符。
要对给定目录中的文件fileName
进行寻址,您可以使用
val someDir:File = ...
val file:File = someDir.resolve("fileName")
它适用于真实的文件和子目录。
列出目录
要列出应用文件存储中的文件,请写入
dataDir.walk().maxDepth(1).forEach { file ->
Log.d("LOG", file.toString())
}
这显示了数据目录的直接内容。如果您运行前面的小准备代码,日志输出将如下所示:
/data/user/0/multipi.pspaeth.de.multipi/files
/data/user/0/multipi.pspaeth.de.multipi/files/instant-run
/data/user/0/multipi.pspaeth.de.multipi/files/a.txt
/data/user/0/multipi.pspaeth.de.multipi/files/b.txt
/data/user/0/multipi.pspaeth.de.multipi/files/dirs1
/data/user/0/multipi.pspaeth.de.multipi/files/dir1
multipi.pspaeth.de.multipi
恰好是我运行代码的示例应用,在第二行中,instant-run
属于默认安装的 Android 目录。当然,您可以将walk()
应用到任何其他目录,只要确保您拥有适当的文件系统访问权限。maxDepth(1)
将遍历限制在目录的直接子目录。省略它将递归遍历所有内容,包括目录中的文件、目录中的文件、目录中的文件等等。
walk()
和maxDepth()
都返回类FileTreeWalk
的一个实例。这个类是一个Sequence
,模仿了Iterable
的所有功能,所以你可以应用过滤器、映射、折叠、分组以及我们在第九章中研究过的其他过程。如果需要真正的Iterable
也可以写asIterable()
(一个Sequence
本身不继承Iterable
)。
注意
Sequence
接口存在的原因是序列可能会被迭代多次,而对于Iterable
的实现来说却不是这样。
例如,要递归列出dataDir
中的所有真实文件,忽略目录,您可以应用如下过滤器:
dataDir.walk().filter { it.isFile() }.forEach {
file ->
Log.d("LOG", file.toString())
}
您可以使用相同的过滤程序仅列出具有特定结尾的文件:
dataDir.walk().filter { it.endsWith(".txt") }.
forEach {
file ->
Log.d("LOG", file.toString())
}
还有一个函数startsWith("someString")
查看文件名是否以某个字符串开头。您还可以根据正则表达式检查名称:
dataDir.walk().filter {
it.name.matches(".*invoice\\d\\d.*\\.txt")
}.forEach {
file ->
Log.d("LOG", file.toString())
}
这将匹配任何文件名包含添加了两个数字的invoice
,并以.txt
结尾的文件。
写入文件
要向文件中写入或追加文本,可以使用
val file = dataDir.resolve("a.txt")
// or any other file
// Write to the file
file.writeText("In the house, there was no light")
// Append to the file
file.appendText("\nIn the house, there was no light")
注意writeText(text:String)
和appendText(text:String)
使用 UTF-8 字符集。如果需要不同的字符集,可以添加一个java.nio.charset.Charset
的实例作为第二个参数:writeText( "...", Charsets.ISO_8859_1 )
(Charsets 是一个 Kotlin 类:kotlin.text.Charsets
)。
为了获得更低的级别,也可以将原始字节从ByteArray
写入文件:
val file = dataDir.resolve("a.txt")
val bytes = byteArrayOf(27, 34, 13, 47, 50)
// Write to the file
file.writeBytes(bytes)
// Append to the file
file.appendBytes(bytes)
注意
如果您需要对大型文件或许多细粒度文件操作进行繁重的文件处理,Kotlin 提供了更多的扩展可以帮助您,您还可以使用大量的 Java 文件处理类和方法。因为在 Android 上你有一个内置的快速数据库来处理这样的用例,我不认为你会经常使用这样特殊的文件处理,但是你可以自由地探索 Kotlin 和 Java 文档。
从文件中读取
要从文件中读取,您必须决定是要将整个文件读入内存,是要逐行读取文本文件,还是要逐块读取包含二进制数据的文件。
要将一个中等大小的文本文件作为一个整体读入一个属性,请这样写(我们再次假设您从本章开始就运行了那个小的准备程序):
val file = dataDir.resolve("a.txt")
val fileText:String = file.readText()
这里使用了 UTF 8 字符集。要读取不同字符集的文件,请添加一个参数:
val file = dataDir.resolve("a.txt")
val fileText:String = file.readText(
Charsets.ISO_8859_1)
如果您没有文本文件,但是有一些原始字节数据的文件,要从文件中读取字节,请使用以下命令:
val file = dataDir.resolve("a.txt")
val fileBytes:ByteArray = file.readBytes()
将文本文件作为一个整体读入属性对于小的文本文件来说当然是有意义的。要处理较大的文本文件,您也可以逐行读取它们:
val file = dataDir.resolve("a.txt")
val allLines = file.readLines()
allLines.forEach { ln ->
// do something with the line (a String)
}
文档说你不应该对大文件这样做。在内部,文件被读入一个包含所有行的大列表。不过,多达 100,000 行的文件实际上不会造成问题。如果您的目标是从 API 级别 26 开始的 Android 设备,还有一种更有效的方法将行读入流:
val file = dataDir.resolve("a.txt")
// Only API level > 25
file.bufferedReader.use {
it.lines().forEach { ln ->
// do something with the line (a String)
}
}
这一次没有使用列表;lambda 函数准确接收当前读取的行。use
是文件系统资源在使用后正确关闭所必需的。
按块读取二进制数据文件有助于处理大型二进制文件:
import java.io.File
...
val file = dataDir.resolve("a.txt")
// Buffer size implementation dependent
file.forEachBlock{ buffer:ByteArray, bytesRead:Int ->
// do something with the buffer
}
// Or, if you want to prescribe the buffer size
file.forEachBlock(512) { buffer, bytesRead ->
// do something with the buffer
}
删除文件
删除你写的文件或目录
import java.io.File
...
val file:File = ...
val wasDeleted:Boolean = file.delete()
这对文件和目录都有效;但是,该目录不得包含任何文件。要删除一个目录及其所有内容,包括其他目录,您可以使用以下命令:
import java.io.File
...
val file:File = ...
val wasDeleted:Boolean = file.deleteRecursively()
如果在删除内容时发生了任何事情,例如,由于缺少访问权限而无法删除某个文件,那么您将得到一个部分删除的文件结构。也可以在应用中处理文件,并在应用终止时请求自动删除:
import java.io.File
...
val file:File = ...
file.deleteOnExit()
如果你的应用中有几个deleteOnExit()
,删除会以相反的顺序进行。注意,对于普通的delete()
调用,对目录也可以这样做,但是它们必须是空的。
使用临时文件
如果你需要临时文件,它更容易使用
import java.io.File
...
val prefix = "tmpFile"
val suffix = ".tmp"
val tmpFile:File = File.createTempFile(prefix, suffix)
tmpFile.deleteOnExit()
... use tmpFile
与手动创建临时文件相比。
这将使用由您的操作系统提供的目录,特别是临时文件,它将通过在文件名中添加一些随机但唯一的字符来确保该文件不存在。对于前缀和后缀,您可以使用您想要的,但前缀必须至少有三个字符长。如果您使用null
作为后缀,默认情况下会使用.tmp
。
如果您想为临时文件提供自己的目录,只需添加一个表示该目录的File
作为createTempFile()
的第三个参数。
更多文件操作
使用我们已经知道的函数复制一个文件是相对容易的:file2.writeBytes( file1.readBytes() )
。还有一个库函数,使它更有表现力,还增加了一些选项:
import java.io.File
...
val file1:File = ...
val file2:File = ...
f1.copyTo(f2) // f2 must not exist
f1.copyTo(f2, true) // overwrite if necessary
// To fine-tune performance, you can tweak the
// buffer size
f1.copyTo(f2, bufferSize = 4096)
copyTo()
函数返回目标文件。
另一个标准库函数提供了递归复制完整目录(包括所有子目录及其文件)的能力:
import java.io.File
...
val dir1:File = ...
val dir2:File = ...
f1.copyRecursively(f2) // f2 must not exist
f1.copyRecursively(f2, true) // overwrite if necessary
// To fine-tune error handling, you can add a handler.
// Otherwise an IOException gets thrown.
f1.copyRecursively(f2, onError = {
file:File, ioException:IOException ->
// do something.
// What to do now? Just skip this file, or
// terminate the complete function?
OnErrorAction.SKIP // or .TERMINATE
})
重命名文件是通过
import java.io.File
...
val file1:File = ...
val file2:File = ...
file1.renameTo(file2)
File
类有更多的函数告诉我们文件的细节:
import java.io.File
import java.util.Date
...
val file = dataDir.resolve("a.txt")
val log = { msg:String -> Log.d("LOG", msg) }
log("Name: " + file.name)
log("The file exists: " + file.exists())
log("You can read the file: " + file.canRead())
log("You can write to the file: " + file.canWrite())
log("Is a directory: " + file.isDirectory())
log("Is a real file: " + file.isFile())
log("Last modified: " + Date(file.lastModified()))
log("Length: " + file.length())
注意
如果你需要更多的细节,java.nio
包包含了更多的类和函数,这些类和函数提供了关于文件的更多信息。
读取 URL
文件 API 包含非常方便的函数来读取互联网 URL 的内容。只管写
import java.net.URL
import kotlin.concurrent.thread
thread {
val contents:String =
URL("http://www.example.com/something.txt").
readText()
val isoContents =
URL("http://www.example.com/something.txt").
readText(Charsets.ISO_8859_1)
val img:ByteArray =
URL("http://www.example.com/somepic.jpg").
readBytes()
}
注意
在 Android 上,您必须请求互联网访问权限才能工作。在AndroidManifest.xml
文件的manifest
元素内添加<uses-permission android:name = "android.permission.INTERNET"/>
。
在 Android 上,这必须在后台线程中运行。这就是为什么我将读取操作包装在一个thread{ }
结构中。这很容易,但在一个严肃的应用中,你应该使用 Android 的一个真正的后台执行功能,例如一个IntentService
。这意味着要做更多的工作。更多细节请参考 Android 文档。
这只是一种非常简单的访问互联网资源的方式。要获得更多选项,请使用专用软件,例如 Apache HttpClient 库。
使用反射
反射就是将类视为对象。这怎么可能呢?我们了解到对象是类的实例。不过,我们也了解到,对象是可识别的单元,它们通过属性来描述某些东西,并提供了使用函数对属性进行操作的方法。
诀窍是:类也是可识别的单元,如果你想描述它们,你需要解释它们的属性和功能的本质。反射就是这样:类是描述它们所引用的类的属性和功能的对象。此外,我们还可以动态地查找一个类实现的接口,以及可能的超类。
注意
Kotlin 反射不是标准库的一部分。你必须加上
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
(一行)到你的应用模块的build.gradle
文件的依赖部分。
我们从一个简单的类开始,它扩展了一些基类,实现了一些任意的接口,此外还有一个构造函数、两个属性和一个函数:
import android.util.Log
open class MyBase(val baseProp:Int)
class MyClass(var prop1:Int) :
java.io.Serializable, MyBase(13) {
var prop2:String
get() = "Hi"
set(value) { /* ignore */ }
init {
Log.d("LOG", "Hello from init")
}
fun function(i:Int):Int {
return prop1 * i
}
}
val instance = MyClass(42)
我们首先注意到有一个用于描述类对象(不是类实例)的Class
类。是从包java.lang
来的。然而,与 Java 相比,Kotlin 表现出一些特性,这使得 Kotlin 有必要拥有自己的类 class。它叫KClass
,你可以在kotlin.reflect
包里找到。他们彼此关系密切。我们为MyClass
取KClass
:
val clazz = MyClass::class
// We can also get it from instances
val clazz = instance::class
如果您需要的话,仍然可以从这里获得 Java 类:val javaClass = clazz.java
一旦我们有了一个KClass
对象,我们就可以自省这个类,这让我们可以显示构造函数、属性和函数:
import android.util.Log
import kotlin.reflect.*
import kotlin.reflect.full.*
Log.d("LOG", "**** constructors")
clazz.constructors.forEach { c ->
Log.d("LOG", c.toString())
}
// show only our own properties
Log.d("LOG", "**** declaredMemberProperties") clazz.declaredMemberProperties.forEach { p ->
Log.d("LOG", p.toString())
}
// show also inherited properties
Log.d("LOG", "**** memberProperties")
clazz.memberProperties.forEach { p ->
Log.d("LOG", p.toString())
}
// show only our own functions
Log.d("LOG", "**** declaredFunctions")
clazz.declaredFunctions.forEach { f ->
Log.d("LOG", f.toString())
}
// show also inherited functions
Log.d("LOG", "**** functions")
clazz.functions.forEach { f ->
Log.d("LOG", f.toString())
}
如果使用查找过滤器,我们可以获得特定的属性或功能:
val p1: KProperty1<out MyClass, Any?> =
clazz.declaredMemberProperties.find {
it.name == "prop1" }!!
val f1: KFunction<*> =
clazz.declaredFunctions.find {
it.name == "function" }!!
有了KProperty1
和KFunction
实例,你可以做一些有趣的事情,比如发现它是私有的还是公共的,它是最终的还是开放的,或者一个属性是属于cons
还是属于lateinit
。对于函数,我们可以确定参数类型和返回类型,等等。请参考这些类的 API 文档来查看所有细节。
我们可以调用实际实例的函数,或者从实际实例中获取和设置属性:
...
val instance = MyClass(42)
val p1: KProperty1<out MyClass, Any?>? =
clazz.declaredMemberProperties.find {
it.name == "prop1" }!!
val p1Mutable: KMutableProperty1<out MyClass, Any?> =
p1 as KMutableProperty1
// getting
val prop1Val = p1.getter.call(instance)
// setting
p1Mutable.setter.call(instance, 55)
// invoking
val f1: KFunction<*> =
clazz.declaredFunctions.find {
it.name == "function" }!!
val res = f1.call(instance, 44) as Int
我们可以获取该类继承的超类和接口:
// Only directly declared superclasses and interfaces
clazz.superclasses.forEach { sc ->
Log.d("LOG", sc.toString())
}
// All superclasses and interfaces
clazz.allSuperclasses.forEach { sc ->
Log.d("LOG", sc.toString())
}
要动态创建实例,我们必须区分无参数构造函数和带参数的构造函数:
val clazz : KClass = ...
// If we have a no-arg primary constructor
val instance1 = clazz.createInstance()
// Otherwise for the primary constructor
val instance2 = clazz.primaryConstructor?.call(
[parameters]
)
// Otherwise
val instance3 = clazz.constructors.
find { [criterion] }!!.
call( [parameters] )
警告
不要错误地将反射在普通类、属性和函数使用上的改进视为对所有属性和函数访问使用反射是编写程序的更好方式的标志。使用反射,你会得到相当大的性能下降,你会失去表达性和简洁性,你还会发展出某种程度上的“围绕”面向对象。小心使用反射。
正则表达式
正则表达式试图给出以下问题的答案:
-
字符串是否包含某种字符模式?例如,我们想知道字符串
invoice 2018-01-01-A4536
是否包含以A
开头的子串。或者同一个字符串是否包含任何日期yyyy-MM-dd
。我们想在这里变得非常多才多艺;模式应该允许我们指定字符类,如字母、小写字母、大写字母、数字、字符枚举、空格、重复等等。 -
如何以模式的形式在分隔符处分割字符串?例如,我们有一个字符串
A37 | Q8 | 156-WE
,我们想在|
处对其进行分割,以得到一个字符串数组[ "A37 ", " Q8 ", " 156-WE" ]
。对于拆分标记,还应该可以指定一个更长的字符串或模式。 -
给定一个模式,我们如何从一个字符串中提取某些子字符串?例如,我们有一个字符串
The invoice numbers are X-23725, X-7368 and X-71885
,我们想提取所有的发票号码X-<some digits>
来得到一个数组[ "X-23725", "X-7368", "X-71885" ]
。 -
如何用其他字符串替换一个字符串中的某些模式?例如,我们有一个字符串
For version v1.7 it is possible, ... another advantage of version v.1.7 is that ...
,我们想用LEOPARD
替换所有出现的v<digit>.<digit>
。
模式
在讨论如何实现正则表达式操作之前,我们先研究一下可用于这些操作的模式。模式是具有正则表达式结构的字符串,如表 17-2 所示。您可以在普通字符串中输入带有转义反斜杠()的模式:因此模式^\w{3}
(开头三个单词字符)必须作为^\\w{3}
输入。您可以使用 raw 字符串来避免转义:
val patStr = "^\\w{3}$" // exactly 3 word chars
val patStr2 = """^\w{3}$""" // the same
注表 17-2 并不详尽;它显示了最常用的结构。要获得完整的参考资料,请查阅java.util.regex.Pattern
的 Java API 文档。
表 17-2。
正则表达式模式
|建造
|
比赛
|
| --- | --- |
| x
| 任何字符 x |
| \\
| 反斜杠字符\ |
| \X
| 文字 X,如果 X 代表模式构造 |
| \n
| 换行符 |
| \r
| 回车符 |
| [abc]
| a、b 或 c 中的任何一个 |
| [^abc]
| 除了 a、b 或 c 之外的任何东西 |
| [A-Z]
| 介于 A 和 Z 之间的任何东西 |
| [0-9a-z]
| 0 和 9 之间或 a 和 z 之间的任何值 |
| .
| 任何字符 |
| \d
| 任何数字[0–9] |
| \D
| 任何非数字[⁰–9] |
| \s
| 空白字符 |
| \s
| 非白人角色 |
| \w
| 一个单词字符[A–Z _ A–Z _ 0–9] |
| \W
| 非文字字符[^\w] |
| ^
| 一行的开始 |
| $
| 一行的结尾 |
| \b
| 单词边界 |
| \B
| 非单词边界 |
| xy
| 一个 x 后面跟着一个 y |
| x|y
| 不是 x 就是 y |
| (p)
| 任何子模式 p 作为一个组 |
量词用于声明模式结构的重复。量词有三种类型:
-
Greedy :在模式匹配期间,模式将尽可能多地消耗字符串,而不会阻碍后续的模式部分。
-
勉强:在模式匹配期间,模式将只消耗必要的字符串。
-
所有格:在模式匹配过程中,模式会消耗尽可能多的字符串,而不考虑后续的模式部分。
贪婪和不情愿的量词用得最多,而所有格量词或多或少只适用于特殊情况。为了理解其中的区别,请考虑输入字符串012345abcde
和模式\d+.*
。这里的*
表示零次或多次贪婪,+
表示一次或多次贪婪。如果我们执行匹配,\d+
将消耗尽可能多的数字(即所有数字,012345
)。作为任何字符匹配器的.*
将匹配剩余的abcde
。如果我们使用不情愿的模式\d+?.*?
,那么\d+?
将匹配尽可能多的数字。因为凭借+
的\d+?
匹配器乐于一个数字出现一次,并且.*?
匹配器能够匹配任意数量的字符,所以\d+?
将乐于匹配0
,并且.*?
匹配器将消耗剩余的12345abcde
。
不太重要的所有格量词的功能最好用输入字符串012345abcde
和所有格模式.*+de
来描述。这里的.*+
匹配器能够将字符串从头到尾匹配一遍。因为它不关心模式的其余部分,它会消耗所有字符。然而,de
需要已经消耗的弦部分de
;因此,它没有匹配的内容,整个正则表达式匹配将会失败。量词列于表 17-3 中。
表 17-3。
正则表达式量词
|建造
|
类型
|
比赛
|
| --- | --- | --- |
| X?
| 贪婪的 | x 一次或者根本不要。 |
| X*
| | x 零次或更多次。 |
| X+
| | x 一次或多次。 |
| X{n}
| | x 正好是n
次。 |
| X{n,}
| | X n
次或更多次。 |
| X{n,m}
| | x 次n
到m
次。 |
| X??
| 不情愿的 | x 一次或者根本不要。 |
| X*?
| | x 零次或更多次。 |
| X+?
| | x 一次或多次。 |
| X{n}?
| | x 正好是n
次。 |
| X{n,}?
| | X n
次或更多次。 |
| X{n,m}?
| | x 次n
到m
次。 |
| X?+
| 所有格 | x 一次或者根本不要。 |
| X*+
| | x 零次或更多次。 |
| X++
| | x 一次或多次。 |
| X{n}+
| | x 正好是n
次。 |
| X{n,}+
| | X n
次或更多次。 |
| X{n,m}+
| | x 次n
到m
次。 |
确定匹配
要查看字符串是否匹配给定的正则表达式,可以使用以下函数:
val re = Regex("^\\w{3}$") // exactly 3 word chars
val matches1 = "Hello".matches(re) // -> false
val matches2 = "abc".matches(re) // -> true
练习 2
写一个字符串扩展函数,允许我们写
"Hello" % ".*ll.*"
代替
"Hello".matches(Regex(".*ll.*"))
提示:运算符%
写成.rem()
。
Regex
类具有允许指定一个或多个选项的构造函数:
Regex(pattern:String, option:RegexOption)
Regex(pattern:String, options:Set<RegexOption>)
RegexOption
是一个包含以下成员的enum class
(完整列表见 API 文档):
-
IGNORE_CASE
:使用它来执行不区分大小写的匹配。 -
DOT_MATCHES_ALL
:如果你想让.
图案也包含换行符,使用此选项。 -
MULTILINE
:如果你想让^
和$
考虑换行符,使用这个。 -
COMMENTS
:允许正则表达式模式下的注释。
如果添加了RegexOption.COMMENTS
标志,就可以向正则表达式模式添加注释。如果正则表达式更复杂,这是非常宝贵的。举例来说,请考虑以下情况:
val re1 = Regex("^A(/|_)\\d{4}$")
// This is the same:
val ENDS = "$"
val re2 = Regex("""
^ # begins with
A # an "A"
(/|_) # a "/" or a "_"
\d{4} # 4 digits
$ENDS # ends here
""", RegexOption.COMMENTS)
(忽略多空格警告。)我们必须在这里添加笨拙的val ENDS = "$"
,以避免$
导致的字符串插值。您可以看到空格被忽略(如果您需要在模式中包含空格,请使用\s
)并且#
开始一行注释。
拆分字符串
将正则表达式周围的字符串拆分为您编写的分隔符
val re = Regex("\\|")
// <- use "\" escape to get a "|" as a literal
val s = "ABC|12345|_0_1"
val split: List<String> = s.split(re)
// -> "ABC", "12345", "_0_1"
// limit to at most 37 splits
val split37 = s.split(re, limit = 37)
注意
为了将一个包含换行符的大字符串拆分成多个行,出于性能原因,您可能不想使用正则表达式。使用lines()
函数要简单得多,它可以应用于任何字符串:val s = "big string... "; s.lines().forEach { ln -> ... }
提取子字符串
在字符串中寻找模式并实际提取它们是通过Regex
类的函数实现的:
// a number pattern
val re = Regex("""
-? # possibly a "-"
\d+ # one or more digits
(
\. # a dot
\d+ # one or more digits
)? # possibly
""", RegexOption.COMMENTS)
val s = "x = 37.5, y = 3.14, z = -100.0"
val firstNumber:MatchResult? = re.find(s)
// start at a certain index instead:
// val firstNumber = re.find(s, 5)
val notFound = firstNumber == null
firstNumber?.run {
val num = groupValues[0]
// do something with num...
}
val allNumbers:Sequence<MatchResult> = re.findAll(s)
allNumbers.forEach { mr ->
val num = mr.groupValues[0]
// do something with num...
}
如果我们想将每个模式匹配分配给一个本地属性,这是没问题的。然而,还有更多:我们可以获得匹配的组,它们属于由( )
对定义的子模式。考虑稍微重写的数字匹配器:
val re = Regex("""
(
(
-? # possibly a "-"
\d+ # one or more digits
)
(
\. # a dot
(
\d+ # one or more digits
)
)? # possibly
)
""", RegexOption.COMMENTS)
它仍然匹配相同的模式,但是通过不同的( )
组引入了子模式。如果我们将这种模式应用于一个数字,例如,3.14
,为了便于说明,我们可以添加相应的组,这样我们就得到了((
-3)(.(14)))
。这样的群体很容易在MatchResult
中独立解决:
// The pattern from the last listing compressed
val re = Regex("""((-?\d+)(\.(\d+))?)""")
val s = "x = 37.5, y = 3.14, z = -100.0"
val firstNumber:MatchResult? = re.find(s)
val notFound = firstNumber == null
firstNumber?.run {
val (num, nf, f1, f2) = destructured
// <- "37.5", "37", ".5", "5"
// the same:
// val num = groupValues[1]
// val nf = groupValues[2]
// val f1 = groupValues[3]
// val f2 = groupValues[4]
val wholeMatch = groupValues[0] // 37.5
// ...
}
val allNumbers:Sequence<MatchResult> = re.findAll(s)
allNumbers.forEach { mr ->
val (num, nf, f1, f2) = mr.destructured
// the same:
// val num = mr.groupValues[1]
// val nf = mr.groupValues[2]
// val f1 = mr.groupValues[3]
// val f2 = mr.groupValues[4]
val wholeMatch = mr.groupValues[0]
// ... wholeMatch is: 37.5, 3.14 or -100.0
// ... num is: 37.5, 3.14 or -100.0
// ... nf is: 37, 3, -100
// ... f1 is: .5, .14, .0
// ... f2 is 5, 14, 0
}
您可以看到,在MatchResult
实例的groupValues
属性中,索引 0 元素总是引用整个匹配,而所有其他索引都引用( )
组。destructured
属性从第一个( )
组开始。只是因为我们添加了一个包含一切的大包围( )
,所以destructured
的第一个成员包含了与groupValues[0]
相同的字符串。
警告
属性虽然易于使用,但最多只能处理十个组。属性groupValues
可能是无限的。
取代
替换字符串中的模式类似于查找模式。我们有一个函数replaceFirst()
,它只替换模式的第一次出现,还有一个函数replace()
,它替换所有出现的模式:
// again the number pattern:
val re = Regex("""((-?\d+)(\.(\d+))?)""")
val s = "x = 37.5, y = 3.14, z = -100.0"
// replace the first number by 22.22
val s2 = re.replaceFirst(s, "22.22")
// -> "x = 22.22, y = 3.14, z = -100.0"
// replace all numbers by 22.22
val s3 = re.replace(s, "22.22")
// -> "x = 22.22, y = 22.22, z = 22.22"
不过,这两个替换函数还有更多功能。用 lambda 函数替换第二个参数,我们可以在替换过程中施展真正的魔法(仅针对replace()
;对于replaceFirst()
,使用适当的等效物):
// again the number pattern:
val re = Regex("""((-?\d+)(\.(\d+))?)""")
val s = "x = 37.5, y = 3.14, z = -100.0"
// double all numbers
val s2 = re.replace(s, { mr:MatchResult ->
val theNum = mr.groupValues[1].toDouble()
(theNum * 2).toString() // <- replacement
})
// -> "x = 75.0, y = 6.28, z = -200.0"
// zero all fractions
val s3 = re.replace(s, { mr:MatchResult ->
val (num, nf, f1, f2) = mr.destructured
nf + ".0" // <- replacement
})
// -> "x = 37.0, y = 3.0, z = -100.0"
十八、并行工作:多线程
现代计算机和现代智能手机有几个能够并行工作的 CPU。你可能会想到几个应用同时运行,但是并发性并不简单;你可以让几个“演员”在一个应用中并行工作,显著加快程序的执行速度。我故意说“演员”,因为简单地说几个 CPU 并行工作只涵盖了故事的一部分。事实上,软件开发人员更喜欢把线程、看作是潜在的可以彼此独立运行的程序序列。哪个 CPU 实际运行一个线程是留给操作系统管理的进程调度。我们采用线程概念,并从操作系统进程处理和硬件执行内部抽象出来。
在一个应用中,几个线程同时运行通常被称为多线程。多年来,多线程一直是 Java 的重要组成部分,您可以在包java.lang
和java.util.concurrent
以及子包中找到 Java 的相关接口和类。这些也包含在 Kotlin for Android 中。然而,Kotlin 对多线程有自己的想法,并引入了一种叫做协程的技术。你可以使用这两种特性,在这一章中我们将讨论这两种特性。
Java 方式的基本多线程
无需任何进一步的准备,当你启动一个 Kotlin(或 Java)应用时,程序就会在主线程中运行。但是,您可以在主线程运行的同时定义并启动其他线程。
注意
在 Android 开发环境中,Kotlin 可以自动使用 Java 多线程类。
Java 中最重要的多线程相关类是java.util.Thread
。您可以使用它的构造函数创建一个线程,但是 Kotlin 有一个简化线程创建的函数:thread()
。它的概要是这样的:
fun thread(
start: Boolean = true,
isDaemon: Boolean = false,
contextClassLoader: ClassLoader? = null,
name: String? = null,
priority: Int = -1,
block: () -> Unit
)
例如,您可以按如下方式使用它:
val thr:Thread = thread(start = true) {
... do something ...
}
thread()
函数使用以下特征创建一个Thread
:
-
如果没有显式地指定
start
参数来读取false
,那么Thread.start()
函数会在线程创建后立即被调用。 -
如果将
isDaemon
设置为true
,当主线程完成工作时,正在运行的线程不会阻止运行时引擎关闭。然而,在 Android 环境中,当系统决定关闭或暂停一个应用时,非守护化线程不会使应用继续活跃,所以这个标志对 Android 没有明显的影响。 -
如果您希望线程使用不同于系统类装入器的类装入器,那么指定一个单独的类装入器是一项高级功能。在本书中,我们不讨论类加载问题;通常,在 Android 环境中,您可以安全地忽略类加载问题。
-
如果出现问题,为线程指定单独的名称有助于故障排除。线程的名称可以显示在日志文件中。
-
指定优先级给了系统一个提示,告诉它一个线程相对于其他线程应该如何优先。数值范围从
Thread.MIN_PRIORITY
到Thread.MAX_PRIORITY
。默认值为Thread.NORM_PRIORITY
。对于您的第一次实验,您不必关心这个值。 -
block
包含线程运行时执行的语句。无论block
做什么,运行多长时间,thread()
函数总是立即退出。
Android 应用最基本的线程示例可能是这样的(请记住,作为最后一个调用参数的函数可以放在括号外):
// inside an activity:
override fun onCreate(savedInstanceState: Bundle?) {
...
thread {
while(true) {
Thread.sleep(1000L)
Log.e("LOG", Date().toString())
}
}
}
对于您的实验,您可以使用我们在前面章节中开发的NumberGuess
示例应用。这个线程开始一个无限循环(while( true ){ }
),每次迭代休眠 1000 毫秒,然后将当前日期和时间写入日志控制台。thread()
函数返回Thread
实例,所以如果我们以后需要用线程做更多的事情,我们也可以写
val thr:Thread = thread {
while(true) {
Thread.sleep(1000L)
Log.e("LOG", Date().toString())
}
}
由于缺省值start = true
,线程立即在后台开始工作。然而,如果你想自己开始线程,你写
val thr = thread(start = false) {
while(true) {
Thread.sleep(1000L)
Log.e("LOG", Date().toString())
}
}
...
thr.start()
到目前为止,这听起来很容易,不是吗?不过,我们在本书后面的章节中讨论多线程是有原因的。考虑以下示例:
val l = mutableListOf(1,2,3)
var i = 0
thread {
while(true) {
Thread.sleep(10L)
i++
if(i % 2 == 0) { l.add(i) }
else { l.remove(l.first()) }
}
}
thread {
while(true) {
Thread.sleep(1000L)
Log.e("LOG", l.joinToString())
}
}
这里我们让一个线程每 10 毫秒改变一个列表,另一个线程将列表打印到日志控制台。
一旦你开始这样做,在你的应用崩溃之前应该不会超过几毫秒。发生了什么事?日志上写着(缩写):
2018-12-29 09:40:52.570 14961-14983/
android.kotlin.book.numberguess
E/AndroidRuntime: FATAL EXCEPTION: Thread-5
Process: android.kotlin.book.numberguess, PID: 14961
java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.next(...)
at ...CollectionsKt.joinTo(...)
at ...CollectionsKt.joinToString(...)
at ...CollectionsKt.joinToString...
at ...MainActivity$onCreate$2.invoke...
at ...MainActivity$onCreate$2.invoke...
at ...ThreadsKt....run()
重要的部分是在java.util.ConcurrentModificationException
和java.util.ArrayList$Itr.next(...)
的两条线。后者表示当我们遍历列表时发生了一些事情。这个迭代需要为joinToString()
函数构造字符串。主要线索来自异常名:
ConcurrentModificationException
它基本上是说,当另一个线程修改一个列表时,我们正在遍历它,这就是问题所在:如果我们让几个线程同时修改一个列表的结构并遍历它,我们就会有一个列表数据不一致的问题。
当我们谈论多线程时,出现的另一个问题是我们需要找到一种聪明的方法来同步线程。例如,一个线程需要等待另一个线程完成一些工作,然后才能开始运行。
这两个问题——数据一致性和同步——使多线程成为一种艺术,直到现在还没有找到最终的通用解决方案。这就是为什么,关于多线程,新的想法不断诞生,几种方法同时存在,所有这些方法相对于其他方法都有各自的优点和缺点。
在讨论 Java 和 Kotlin 遵循的高级方法之前,我们先完成对 Java 的基本多线程解决方案的研究,这样我们就对问题领域有了一个了解。如果我们再次考虑并发修改异常的例子,如果我们能够避免多个线程同时在一个共享列表上工作,不是更好吗?这是可能的,我们可以这样做的方法是将相关的代码示例包装在synchronized(){ }
块中,如下所示:
val l = mutableListOf(1,2,3)
var i = 0
thread {
while(true) {
Thread.sleep(10L)
i++
synchronized(l) {
if(i % 2 == 0) { l.add(i) }
else { l.remove(l.first()) }
}
}
}
thread {
while(true) {
Thread.sleep(1000L)
synchronized(l) {
Log.e("LOG", l.joinToString())
}
}
}
在这里,所有访问列表的线程中的synchronized(l)
块确保当另一个线程在同一列表的任何其他synchronized
块中时,没有访问列表的线程可以进入synchronized
中的代码。相反,最先到达的线程让所有其他线程等待,直到它完成了它的synchronized
块。
也可以向synchronized
指令添加更多参数。只是用逗号分隔的列表,如
synchronized(l1, l2) {
...
}
其中同步确保让多个线程在l1
和l2
上工作是安全的。
我们仍然需要一种方法让一个线程等待另一个线程完成它的工作。为此目的,join
指令存在。假设您想要实现以下目标:
val l = mutableListOf(1,2,3)
var i = 0
val thr1 = thread {
for(i in 1..100) {
l.add(i)
Thread.sleep(10)
}
}
thread {
// Here we want to wait until thread thr1 is done.
// How can this be achieved?
...
Log.e("LOG", l.joinToString())
}
现在,告诉第二个线程显式地等待线程thr1
通过thr1.join()
完成它的工作:
val l = mutableListOf(1,2,3)
var i = 0
val thr1 = thread {
...
}
thread {
thr1.join()
Log.e("LOG", l.joinToString())
}
现在,thr1.join()
之后的指令仅在线程thr1
完成其工作后开始。
表 18-1 中列出了这些关键字和函数,以及 Java 方式的基本多线程的更有趣的函数和构造。
表 18-1
Java 方式的基本多线程
|构造/功能
|
描述
|
| --- | --- |
| thread(...)
| 创建并可能启动一个线程。参数包括:start
:施工后立即开始螺纹。默认:true
。isDaemon
:如果true
,当主线程完成其工作时,正在运行的线程不会阻止运行时引擎关闭。在 Android 中没有影响。默认:false
。contextClassLoader
:指定不同的类加载器。默认为null
,表示系统类加载器。对于 Android,你通常使用默认设置。name
:线程的名称。显示在日志文件中。默认:使用带有连续编号的默认字符串。priority
:指定一个优先级给系统一个提示,告诉它一个线程相对于其他线程的优先级。可能值:在Thread.MIN_PRIORITY
和Thread.MAX_PRIORITY
之间,默认为Thread.NORM_PRIORITY
。block
:包含线程的代码。如果你不需要任何特殊的参数,你只需要写thread { [thread_code] }
。 |
| synchronized( object1, object2, ...) { }
| 只有当当前没有其他线程在参数列表中至少有一个相同对象的synchronized
块中执行时,才会进入{ }
块。否则,线程将处于等待状态,直到其他相关的synchronized
模块完成它们的工作。 |
| Thread.sleep(millis: Long)
| 让当前线程等待指定的毫秒数。可以被中断,在这种情况下,语句立即终止,并抛出一个InterruptedException
。 |
| Thread.sleep(millis: Long, nanos:Int)
| 与Thread.sleep(Long)
相同,但使该功能额外休眠nanos
纳秒。 |
| thread.join()
| 让当前线程等待,直到线程thread
完成其工作。 |
| thread.interrupt()
| 当前线程中断线程thread
。被中断的线程被终止并抛出一个InterruptedException
。被中断的线程必须支持中断。它通过调用像Thread.sleep()
这样的可中断方法或者通过定期检查自己的Thread.interrupted
标志来查看它是否应该退出。 |
| @Volatile var varName = ...
| 仅适用于类或对象属性。将支持字段(属性后面的数据)标记为volatile
。运行时引擎(Java 虚拟机)确保 volatile 变量的更新立即传达给所有线程。否则在这种情况下线程间的状态可能会不一致。与synchronized
块相比,性能开销更小。 |
| Any.wait()
| 仅从synchronized
块内部。挂起同步,以便其他线程可以继续工作。同时,它让这个线程等待一段不确定的时间,直到notify()
或notifyAll()
被调用。 |
| Any.wait( timeout:Long )
| 与wait()
相同,但最多等待指定的毫秒数。 |
| Any.wait( timeout:Long, nanos:Int )
| 与wait()
相同,但最多等待指定的毫秒和纳秒数。 |
| Any.notify()
| 仅从synchronized
块内部。唤醒一个正在等待的线程。一旦当前线程离开它的synchronized
块,等待线程就开始工作。 |
| Any.notifyAll()
| 仅从synchronized
块内部。唤醒所有等待的线程。一旦当前线程离开它的synchronized
块,等待线程就开始工作。 |
对于类java.lang.Thread
的所有其他函数,请参考 API 文档。
Java 方式的高级多线程
将synchronized
块和join
函数分散在你的代码中会带来几个问题:首先,它使你的代码难以理解;对于重要的程序来说,理解多线程状态处理绝非易事。第二,拥有几个线程和synchronized
块可能会导致死锁:一些线程 A 等待线程 B,而线程 B 正在等待线程 A。第三,编写太多的join
函数来收集线程的计算结果可能会导致太多的线程只是等待,从而削弱多线程的优势。第四,使用synchronized
块进行任何集合处理也可能导致太多线程等待。
在 Java 发展历史的某个时刻,引入了高级的多线程结构,即java.util.concurrent
包和子包中的接口和类。在本节中,我们并不要求完整,而是涵盖了其中的一些构造,因为它们也包含在 Kotlin 中,您可以根据自己的意愿任意使用它们。
特殊并发集合
仅仅为了适当的并发可访问性,或者为了线程安全,而将任何列表或集合访问封装到synchronized
块中,会给人一种不满意的感觉。如果集合和地图对您的应用很重要,那么考虑多线程似乎不值得。幸运的是,java.util.concurrency
包包含一些列表、集合和映射实现,有助于避免将所有东西都放入synchronized
块中。
-
CopyOnWriteArrayList
:一个列表实现,其中任何变异操作都发生在完整列表的一个新副本上。同时,任何迭代都精确地使用迭代器创建时的列表状态,所以不会发生ConcurrentModificationException
。复制完整的列表代价很高,所以这种实现通常只在读操作远远多于写操作的情况下有用。然而,在这种情况下,线程安全不需要synchronized
块。 -
CopyOnWriteArraySet
:集合实现,其中任何变异操作都发生在完整集合的新副本上。我们之前针对CopyOnWriteArrayList
所说的也适用于CopyOnWriteArraySet
实例。 -
ConcurrentLinkedDeque
:线程安全的Deque
,其中迭代操作是弱一致的,这意味着读取元素反映了迭代器创建时或创建后某个时间点的队列状态。没有ConcurrentModificationException
会被扔。 -
ConcurrentLinkedQueue
:线程安全的Queue
实现。前面针对ConcurrentLinkedDeque
所做的关于线程安全的讨论也适用于这个类。不会扔出ConcurrentModificationException
。 -
ConcurrentSkipListSet
:线程安全的Set
实现。迭代操作是弱一致的,意味着读元素反映了集合在迭代器创建时或创建后的状态。没有ConcurrentModificationException
会被扔。除了 API 文档建议的类型规范,元素必须实现Comparable
接口。 -
ConcurrentSkipListMap
:线程安全的Map
实现。迭代操作是弱一致的,这意味着读取元素反映了迭代器创建时或创建后某个时刻的映射状态。没有ConcurrentModificationException
会被扔。除了 API 文档建议的类型规范,这些键必须实现Comparable
接口。
锁
在本章前面的“Java 方式的基本多线程”一节中,我们了解到synchronized
块确保不同的线程不能同时处理不同的程序部分:
val obj = ...
thread {
synchronized(obj) {
... synchronized code
}
}
这样的synchronized
块是一个语言构造;然而,我们可以通过使用如下的lock
对象以更加面向对象的方式实现同样的事情:
import java.util.concurrent.lock.*
...
val lock:Lock = ...
...
lock.lock()
try {
... synchronized code
} finally {
lock.unlock()
}
更准确地说,synchronized
在所谓的重入锁中有它的对等物,相应的锁类相应地读为ReentrantLock
。因此,在前面的代码中,我们将使用
val lock:Lock = ReentrantLock()
作为一个Lock
实现。
名称重入锁来自锁被同一个线程多次获取的能力,因此当线程已经通过lock.lock()
获取锁并试图在unlock()
发生之前再次获取同一个锁时,它不会陷入等待状态。
与synchronized
相比,Lock
有更多的选择。例如,使用Lock
可以避免在当前线程最近进入中断状态时试图锁定,或者在等待锁定时试图锁定。这可以通过写作来实现
val lock:Lock = ReentrantLock()
...
try {
lock.lockInterruptibly()
} catch(e: InterruptedException) {
... do things if we were interrupted
return
}
try {
... synchronized code
} finally {
lock.unlock()
}
您还可以首先检查锁,看它是现在被获取还是在实际被获取之前的一段时间内被获取。相应的代码如下所示
val lock:Lock = ReentrantLock()
...
if(lock.tryLock()) {
try {
... synchronized code
} finally {
lock.unlock()
}
} else {
... no lock acquired
... do other things
}
或者在等待特定时间量的变体中:
...
if(lock.tryLock(time:Long, unit:TimeUnit)) {
// lock was acquired within that time span
...
} else {
...
}
一个不同的锁接口叫做ReadWriteLock
。与普通的Lock
相比,它能够区分读写操作。在几个线程能够以只读方式使用变量而没有任何问题的情况下,这可能是有帮助的,而写操作必须阻止读操作,此外还必须被限制到单个线程。相应的实现读作ReentrantReadWriteLock
。它的使用细节可以在 API 文档中找到。
原子变量类型
考虑以下示例:
class Counter {
var c = 0
fun increment() { c++ }
fun decrement() { c-- }
}
因为运行时引擎(Java 虚拟机 JVM)在内部将c++
分解为(1)获取c
的值,(2)增加我们刚刚检索到的值,以及(3)将更改后的值写回到c
,所以可能会发生以下情况:
Thread-A calls increment
Thread-B calls decrement
Thread-A retrieves c
Thread-B retrieves c
Thread-A increments its version of c
Thread-A updates c, c is now +1
Thread-B decrements its version of c
Thread-B updates c, c is now -1
线程 A 的工作因此完全丢失了。这种效应通常被称为螺纹干涉。
我们在上一节中看到,通过synchronized
进行同步有助于:
class Counter {
var c = 0
fun increment() { synchronized(c){ c++ } }
fun decrement() { synchronized(c){ c-- } }
}
凭借synchronized
对c
的更新现在不再受其他线程的影响。然而,我们可能有不同的解决方案。如果我们有一个以原子方式处理修改和检索的变量类型,并且没有其他线程干扰和破坏一致性的机会,我们可以减少 ?? 带来的开销。这样的原子数据类型确实存在,它们被称为AtomicInteger
、AtomicLong
和AtomicBoolean
。都是来自java.util.concurrent.atomic
包。
使用一个AtomicInteger
我们可以去掉synchronized
块。对于Counter
类的解决方案将如下所示:
import java.util.concurrent.atomic.*
...
class Counter {
var c:AtomicInteger = AtomicInteger(0)
fun increment() { c.incrementAndGet() }
fun decrement() { c.decrementAndGet() }
}
注意
包java.util.concurrent.atomic
有一些特殊用例的原子类型。如果你有兴趣的话,可以看看文档。
遗嘱执行人、期货和可赎回权
在java.util.concurrent
包中,你会发现一些在更高层次上处理多线程的接口和类。下面的列表显示了对高级多线程很重要的主要接口和类。
-
Callable
这是可以被另一个线程调用并返回结果的东西。
-
Runnable
这个不在
java.util.concurrent
包里,在java.lang
包里。它是可以被调用的东西,可能被另一个线程调用。不返回任何结果。 -
Executors
这是一个重要的实用程序类,用于获取
ExecutorService
和ScheduledExecutorService
实现。 -
ExecutorService
这是一个对象接口,允许调用
Runnable
或Callable
并收集它们的结果。 -
ScheduledExecutorService
这是一个对象接口,允许调用
Runnable
或Callable
并收集它们的结果。调用在一段延迟之后发生,或者以重复的方式发生。 -
Future
这是一个可以用来从
Callable.
获取结果的对象 -
ScheduledFuture
这是一个可以用来从提交给
ScheduledExecutorService
的Callable
中获取结果的对象。
这些接口和类的主要使用模式如下:
-
从单例对象
Executors
中使用一个以new
开始的函数来获得一个ExecutorService
或ScheduledExecutorService
。将其保存在属性中;出于我们的目的,我们称之为srvc
或schedSrvc
。 -
对于需要同时完成的注册任务,对于
srvc
使用以invoke
或submit
开头的任何功能,对于schedSrvc
使用以schedule
开头的任何功能。 -
等待终止,由来自
ExecutorService
或ScheduledExecutorService
的合适函数发出信号,或者由您在上一步骤中可能收到的Future
或ScheduledFuture
发出信号。
如您所见,这些接口、类和函数主要编排线程及其计算结果。他们不控制共享数据的使用;为此,您需要遵循前面几节中介绍的技术。
作为一个例子,我们开发了一个计算π的多线程程序。想法很简单:从[0; 1]×[0; 1]
平面获得一对随机数。计算到原点的距离,并计算距离小于1.0
和距离大于等于1.0
的点的数量。称所有点的个数n
和四分之一单位圆内的点数p
。因为一个[0; 1] × [0; 1]
平面的面积是1.0
,但四分之一单位圆内区域的面积是π /4
,所以我们有
=
π /4
或π = 4 ·
(见图 18-1 )。
图 18-1
圆周率计算
注意
这肯定不是计算π的最聪明的方法,但它很容易理解,并且您可以轻松地在多个线程之间分配工作负载。
在 Android Studio 中,启动一个新的应用,并按照第一章中为您的第一个 Kotlin 应用所述进行操作,相应地重命名应用和包。对于活动,使用以下元素创建布局:
图 18-2
Pi 用户界面
-
任何标签,如图 18-2 所示。
-
在
Processors
标签旁边有一个 ID 为@+id/procs
的TextView
。 -
在
Iterations
标签旁边有一个 ID 为@+id/iters
的EditText
。添加属性android:text="1000000"
。 -
在
Threads
标签旁边有一个 ID 为@+id/threads
的EditText
。添加属性android:text="4"
。 -
在
Cumul Iters
标签旁边有一个 ID 为@+id/cumulIters
的TextView
。 -
在
Current Pi
标签旁边有一个 ID 为@+id/pi
的TextView
。 -
在
Calc Time
标签旁边有一个 ID 为@+id/calcTime
的TextView
。 -
一个带有文本
CALC
和属性android:onClick="calc"
的Button
。 -
一个带有文本
RESET
和属性android:onClick="reset"
的Button
。
我们把设计的细节留给你。对于实际的计算,列表中显示的视图 id 和 onClick 处理程序很重要。计算并不太复杂,所以我们在活动类中做所有的事情。对于更复杂的项目,您应该将计算外包给一个或多个专门的计算类。在我们的例子中,让活动类阅读
class MainActivity : AppCompatActivity() {
var points = 0L
var insideCircle = 0L
var totalIters = 0L
override
fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
savedInstanceState?.run {
points = getLong("points")
insideCircle = getLong("insideCircle")
totalIters = getLong("totalIter")
}
val cores = Runtime.getRuntime().
availableProcessors()
procs.setText(cores.toString())
}
override
fun onSaveInstanceState(outState: Bundle?) {
super.onSaveInstanceState(outState)
outState?.run {
putLong("points",points)
putLong("insideCircle",insideCircle)
putLong("totalIter", totalIters)
report()
}
}
fun calc(v:View) {
val t1 = System.currentTimeMillis()
val nThreads = threads.text.toString().
takeIf { it != "" }?.toInt()?:1
val itersNum = iters.text.toString().
takeIf { it != "" }?.toInt()?:10000
val itersPerThread = itersNum / nThreads
val srvc = Executors.newFixedThreadPool(nThreads)
val callables = (1..nThreads).map {
object : Callable<Pair<Int,Int>> {
override fun call(): Pair<Int, Int> {
var i = 0
var p = 0
(1..itersPerThread).forEach {
val x = Math.random()
val y = Math.random()
val r = x*x + y*y
i++
if(r < 1.0) p++
}
return Pair(i, p)
}
}
}
val futures = srvc.invokeAll(callables)
futures.forEach{ f ->
val p = f.get()
points += p.first
insideCircle += p.second
}
val t2 = System.currentTimeMillis()
calcTime.setText((t2-t1).toString())
report()
}
fun reset(v:View) {
points = 0
insideCircle = 0
report()
}
private fun report() {
cumulIters.setText(points.toString())
if(points > 0) {
val pipi = 1.0 * insideCircle / points * 4
pi.setText(pipi.toString())
} else {
pi.setText("")
}
}
}
其特点如下:
-
该类将
points
中的总点数、insideCircle
中的四分之一单位圆内的点数和totalIters
中的总迭代次数作为状态。 -
在
onSaveInstanceState()
和onCreate()
中,我们确保当 Android 决定暂停应用时,状态得到保存和恢复。 -
同样在
onCreate()
中,我们确定设备拥有的 CPU 数量,并将其写入用户界面。 -
在
reset()
中,算法被重新初始化。 -
在
report()
里面,我们根据前面的公式计算π,并写入用户界面。 -
多线程发生在
calc()
内部。我们从用户界面读取要使用的线程数和迭代数,在线程间平均分配迭代数,从Executors
获取线程池,定义并注册计算算法,最终从所有线程收集结果。 -
在
calc()
结束时,我们确定计算所需的时间,并将其写入用户界面。
您可以试验一下线程和迭代次数,看看多线程的影响。在大多数设备上,您应该可以看到在一个线程和两个或更多线程上运行的明显区别。顺便说一下,随着数字的累积,多次按下 CALC 按钮可以提高计算π的精度。
练习 1
实施多线程π计算应用,如本节所述。
Kotlin·科特雷普
Kotlin 对如何处理多线程有自己的想法。它使用了在旧的计算机语言中已经存在了一段时间的概念,协程。这里实现的思想是编写函数,这些函数可以在内部程序流中的某些位置挂起,稍后再恢复。这是以一种非抢先的方式发生的,这意味着在以多线程方式运行程序的过程中,程序流上下文不会被操作系统切换,而是被语言构造、库调用或两者切换。
缺省情况下,Kotlin 中不包含协程。要安装它们,打开“app”模块的build.gradle
文件并添加到“dependencies”部分:
implementation
'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.0'
(一行)。
在我们继续讨论 Kotlin 的协程之前,我们首先提供一个扩展的词汇表,帮助您习惯协程编程。您可以快速浏览它,或者现在完全跳过它,稍后再回来,因为在这个列表之后,我们会给出对协程更全面的介绍。
- 协程作用域:任何协程功能都在协程作用域内运行。作用域就像多线程集合周围的括号,作用域可以有一个定义作用域层次结构的父作用域。作用域层次的根既可以是
GlobalScope
也可以通过函数runBlocking { }
获得,其中在{ }
块中输入了一个新的阻塞作用域。这里的阻塞意味着runBlocking()
调用只有在所有包含的作用域完成它们的工作后才结束。因为CoroutineScope
是一个接口,所以您也可以定义任何类来产生协程作用域。一个非常突出的例子是让一个活动也代表一个协程作用域:
class MyActivity : AppCompatActivity(),
CoroutineScope by MainScope() {
...
override fun onDestroy() {
super.onDestroy()
cancel() // CoroutineScope.cancel
}
...
}
CoroutineScope by MainScope()
指的是委托:任何CoroutineScope
函数都被转发给MainScope
的一个实例,这是一个专门为用户界面活动定制的作用域。在这里,由于作用域层次结构根上的cancel()
,层次结构中任何当前活动的作用域都会在活动的onDestroy()
中关闭。
-
协程上下文:
CoroutineContext
对象是与协程范围相关联的数据容器。它包含协程作用域正常工作所必需的对象。在范围层次结构中没有任何进一步干预的情况下,范围子级从它们的父级继承上下文。 -
全局范围:
GlobalScope
是一个单例对象,其生存期由应用整体决定。虽然使用全局作用域作为最重要的协程的基础很有诱惑力,但为了正确构建应用的多线程方面,通常不建议您使用它。请使用专用的协程生成器。 -
作业:作业是一个可能在自己的线程中运行的任务。
-
协程构建器:这些是以阻塞或非阻塞方式启动协程的函数。建筑商的例子有:
-
runBlocking()
:定义并运行一个阻塞协程。如果在协程作用域中使用了runBlocking()
,那么由runBlocking{ }
定义的块就会产生一个新的子作用域。 -
launch()
:定义并运行一个非阻塞的协程。它不能在协程范围之外使用。它立即返回一个Job
对象,并在后台运行它的块。 -
async()
:定义并运行一个返回值的非阻塞协程。它不能在协程范围之外使用。它立即返回一个Deferred
对象,并在后台运行它的块。 -
coroutineScope()
:除了一个新的Job
对象之外,这将创建一个新的范围,其上下文继承自外部范围。它调用新范围中的指定块。 -
supervisorScope()
:这创建了一个带有SupervisorJob
的新作用域。它调用新范围中的指定块。主管作业是一些特殊的作业,它们可以彼此独立地失败。
-
它们都期望 lambda 函数作为最后一个参数,作为要运行的指令块。因为这样的参数可以在括号外注明,所以您最常使用它们,就像在launch { ..instructions.. }
中一样。
-
加入:如果得到调用属性:
val job = launch{ ... }
中launch()
的结果,可以稍后调用job.join()
来阻止程序执行,直到作业完成。 -
暂停功能:关键字
suspend
是 Kotlin 语言中唯一一个与协程相关的关键字。所有其他协程材质都可以通过协程库获得。您需要将suspend
添加到您希望从作用域内部调用并且希望能够与协程一起使用的函数中。例如,suspend fun theFun() { }
。将suspend
视为协程范围转发器。 -
取消:一旦你有了一个
Job
对象,你可以在它上面调用cancel()
来发出取消作业的信号。注意,作业通常不会立即退出工作,所以您必须通过join()
调用等待实际终止,或者使用cancelAndJoin()
。您必须确保您想要取消的协同程序是可取消的。为了达到这个目的,你可以让代码调用一个类似于yield()
的挂起函数,或者你可以定期检查你在协程作用域中自动拥有的isActive
属性。 -
超时:为您使用的语句块显式指定超时
withTimeout(3000L) { // milliseconds ... code here }
一旦达到超时限制,就会抛出一个TimeoutCancellationException
,它是CancellationException
的子类,如果不使用定制的异常处理程序,就会被忽略。withTimeout()
的一个变体读作withTimeoutOrNull()
;如果没有发生超时,它不抛出TimeoutCancellationException
,而是返回其块中最后一个表达式的值,否则返回null
:
-
协程异常处理程序:协程对于如何处理异常有自己的想法。例如,如果您没有在协程上下文中提供单独的
CoroutineException
Handler
,当取消一个作业时,会抛出一个CancellationException
,但是这个被忽略了。如果您需要在作业被取消后执行清理操作,您仍然可以将代码包装在一个try { } finally { }
块中:runBlocking { val job = launch { try { ... do work } finally { ... cleanup if canceled } } ... job.cancelAndJoin() }
-
延迟:使用
delay(timeMillis)
指定临时暂停。这里的 API 文档提到了非阻塞挂起,因为运行在协程后面的线程实际上被允许做其他工作。延迟后,程序流程可以继续执行delay
函数后面的指令。 -
屏蔽 : 你用
runBlocking { ... }
启动
{ }
块内语句的阻塞执行。您通常在程序的 main 函数中应用它,以获得可用于协程的第一个协程作用域。 -
协程调度程序:
CoroutineDispatcher
的实例是协程上下文的一部分,您可以在每个作用域的属性coroutineContext
中找到。runBlocking { val ctx:CoroutineContext = coroutineContext ... }
val res = withTimeoutOrNull(3000L) {
... code here
"Done."
}
// -> res is null if timeout happened
调度程序控制协程在哪个线程中运行。这可能是一个特定的线程、一个线程池,或者调用者的线程(直到第一个暂停点),如果使用了一个不受限制的调度程序(不是用于一般用途)。
-
结构化并发:这描述了反映在由
{ ... }
构造的层次结构所描述的结构中的作业并发特征的依赖性。Kotlin 的协同程序强烈支持建立并发的结构化并发风格。 -
通道:通道提供了一种在协程之间传递数据流的方法。从 Kotlin 版本 1.3 开始,这个 API 被认为是实验性的。查看官方文档以了解该 API 的当前状态。
-
Actor:Actor 既是协程启动器,也是通道端点。从 Kotlin 版本 1.3 开始,这个 API 被认为是实验性的。查看官方文档以了解该 API 的当前状态。
以下段落概述了基本和高级协程使用模式。
基本协程
关于协程,需要知道的最重要的事情是,在使用多线程的协程方式之前,我们需要一个协程作用域。为了简单起见,如果我们有这样一个结构就好了:
openScope {
// Scope now automatically available
...
}
Kotlin 知道如何通过接收器的功能来做到这一点。例如,看看与协程相关的函数runBlocking()
。在源代码中,您会发现:
fun <T> runBlocking(context: CoroutineContext =
EmptyCoroutineContext,
block: suspend CoroutineScope.() -> T): T
{
// code to run block in a blocking thread
...
}
在block: suspend CoroutineScope.() -> T
中,你可以看到块在扩展CoroutineScope
的对象内部运行。这样的CoroutineScope
是一个带有类型CoroutineContext
的val
名为coroutineContext
的接口。有关上下文的详细信息,请参见下文。
警告
接口可以有val
s。我们在面向对象的介绍性章节中没有提到这个特性,在运行时引擎的发展过程中,它主要是因为技术原因而引入的。在这里,它被用来简化协程处理。但是不鼓励在应用的接口中使用val
s 和var
s,因为变量通常属于实现方面,而不属于声明方面,这正是接口的用途。小心使用接口中的变量!
如果我们已经在协程中运行,您可以选择使用现有的作用域,或者生成新的作用域,如下所示:
-
runBlocking { ... }
这进入了一个新的阻塞范围。这里的阻塞意味着
runBlocking()
调用只有在{ ... }
lambda 中的所有活动完成工作后才会返回。runBlocking()
可以从协程作用域内部和外部启动,尽管不鼓励在协程作用域内部使用它。在这两种情况下,都会创建一个新的上下文,其中包括使用当前正在运行的线程来执行作业。 -
runBlocking(context:CoroutineContext) { ... }
这与
runBlocking()
相同,但具有由参数给出的基本上下文。 -
GlobalScope
不鼓励使用这种方法。如果您希望使用与应用本身及其生命周期相关的范围,请使用这个 singleton 对象。例如,您可以使用
GlobalScope.launch{ ... }
或GlobalScope.async{ ... }
。通常你应该从runBlocking{ ... }
开始。不显式地使用GlobalScope
可以改善你的应用的结构。 -
coroutineScope { ... }
这创建了一个新的协程作用域,它从外部协程作用域继承上下文;也就是说,
coroutineScope()
被调用的范围。但是,它会覆盖作业,并使用从其 lambda 函数参数的内容({ ... }
的内容)派生的自己的作业。此函数只能从作用域内部调用。使用coroutineScope()
是结构化并发的一个突出例子:一旦{ ... }
中的任何一个孩子失败,所有其他的孩子也会失败,最终整个coroutineScope()
都会失败。 -
supervisorScope { ... }
这与
coroutineScope()
相同,但是让其子作用域彼此独立运行。特别是,如果任何一个子项被取消,其他子项和主管范围不会被取消。 -
launch { ... }
这定义了后台作业。当由
{ ... }
lambda 定义的后台作业开始在后台工作时,launch()
调用立即返回。launch()
返回类Job
的一个实例。您可以使用Job
中的join()
功能等待作业完成。 -
async { ... }
这与
launch()
相同,但允许后台作业产生结果。为此,launch()
返回类Deferred
的一个实例。您可以使用它的await()
函数来检索结果;当然,这意味着等待作业完成。 -
Implement CoroutineScope
在您的任何类中,您都可以实现类
CoroutineScope
:class MyClass : CoroutineScope { ... }
。这种方法的问题是,因为CoroutineScope
只是一个接口,我们需要通过用可感知的对象填充协程上下文来实现协程功能。一个简单的方法是使用委托:class MyClass : CoroutineScope by MainScope() { ... }
,它将所有协程构建器委托给一个MainScope
对象。这一点对于用户界面特别有用。一旦这样做了,我们就可以在MyClass
内部的任何地方自由地使用launch()
和async()
这样的构建器,以及cancel()
这样的控制函数。
launch()
函数有几个默认参数。其完整的概要如下:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job
您可以使用context
参数来设置上下文名称,例如,如
launch(context = coroutineContext +
CoroutineName("CoRou1")) {
...
)
start
参数可以用来调整协程启动的方式。详见 API 文档(在 Android Studio 中输入“CoroutineStart”,然后按 Ctrl+B)。
async()
函数的默认参数与launch()
相同,因此您也可以调整async()
的启动特性。
考虑下面的示例代码。对于 Android,你可以在活动的onCreate()
函数中直接测试它。
runBlocking {
// This starts in the current thread.
// We are now inside a coroutine scope. This means
// we have a
// val coroutineContext:CoroutineContext
// for the context. The runBlocking() ends
// after all work is done.
Log.d("LOG", "1\. Started inside runBlocking()")
coroutineScope {
Log.d("LOG", "2\. coroutineScope()")
delay(500L)
Log.d("LOG", "3\. coroutineScope()")
coroutineScope {
Log.d("LOG", "4\. coroutineScope() II")
// If you add this, both coroutineScope()
// fail and runBlocking() prematurely ends:
// throw CancellationException("4.")
// Also, because runBlocking transports the
// exception to the outside world, (15.)
// below will not be reached.
}
Log.d("LOG", "5\. inner done")
}
val job1 = launch {
// This runs in the background, so
// (8.) happens before (7.)
Log.d("LOG", "6\. inside launch()")
delay(500)
Log.d("LOG", "7\. done with launch()")
}
Log.d("LOG", "8\. returned from launch()")
val deferr1 = async {
// This runs in the background as well, but it
// returns something
Log.d("LOG", "9\. inside async()")
delay(500)
Log.d("LOG", "10\. done with async()")
"Result"
}
Log.d("LOG", "11\. returned from async()")
job1.join()
Log.d("LOG", "12\. launch finish")
val res1 = deferr1.await()
Log.d("LOG", "13\. async finish")
Log.d("LOG", "14\. End of runBlocking()")
}
Log.d("LOG", "15\. Returned from runBlocking()")
它具有以下特点。
- 运行代码时,日志将显示如下内容:
1\. Started inside runBlocking()
2\. coroutineScope()
3\. coroutineScope() - 0.5secs later
4\. coroutineScope() II
5\. inner done
8\. returned from launch()
11\. returned from async()
6\. inside launch()
9\. inside async()
7\. done with launch()
10\. done with async()
12\. launch finish
13\. async finish
14\. End of runBlocking()
15\. Returned from runBlocking()
项目 6、9、7 和 10 可能以不同的顺序显示,因为它们属于后台处理。
-
外部的
runBlocking()
在协程作用域层次结构中引入了一个根。 -
只有当它的所有子进程都完成了它们的工作或者它们的工作被取消时,这个
runBlocking()
才会返回。 -
如果抛出了一个
CancellationException
(取消对throw
的注释以查看其发生的情况),它将在作用域层次结构中向上传输,因此不会到达 15。 -
async()
和launch()
都引入了异步性(并发性);他们立即返回,而他们的{ ... }
lambda 在后台工作。 -
job1.join()
和deferr1.await()
同步后台作业;两者都等待相应的作业完成。
协程上下文
一个CoroutineContext
将协程作用域的状态保存为一组上下文元素。虽然CoroutineContext
没有实现您通常在这种情况下使用的普通的Set
、List
或Map
接口,但是您仍然可以通过这些方法之一获得它的元素。
-
coroutineContext[Job]
这将检索包含组成协程的指令的
Job
实例。 -
coroutineContext[CoroutineName]
可选地,这检索协程的名称。您可以通过
coroutineContext + CoroutineName("MyFancyCoroutine")
将名称指定为协程构建器(如launch()
或async()
)调用的第一个参数。 -
coroutineContext[CoroutineExceptionHandler]
这是一个可选的专用异常处理程序。我们稍后将讨论异常。
-
coroutineContext[ContinuationInterceptor]
这个内部项保存了一个对象,该对象负责在一个协程被挂起并恢复工作后,正确地继续它的工作。
尽管像runBlocking()
、launch()
或async()
这样的作用域构建器会产生一个新的协程上下文,该上下文被转发给从内部调用的其他协程函数,但是您可以通过使用
withContext(context: CoroutineContext) {
...
}
作为一个参数,你可以自由构建自己的上下文,或者使用+
来改变当前上下文的专用元素。例如,临时设置您编写的协程名称
... we are inside a coroutine scope
withContext(context = coroutineContext +
CoroutineName("TmpName")) {
... here we have a scope with a tweaked context
}
同样,您可以改变或重新定义其他上下文元素。
一个delay()
做什么
乍一看,delay(timeMillis:Long)
函数与 Java 中使用并发的基本Thread.sleep(millis:Long)
函数有相同的用法:让程序流等待一段时间,然后才能继续执行delay()
或sleep()
语句之后的指令。然而,这两者之间有一个主要的区别:函数Thread.sleep()
实际上阻塞了当前线程,让其他线程去做它们的工作,而delay()
调用一个挂起函数,它不阻塞当前线程,而是在指定的时间过去后调度程序流的恢复。
从一个用例的角度来看,您使用这两者的目的是一样的:仅在指定的时间过去之后继续程序流。然而,知道对于协程来说,线程不会被阻塞,有助于调整并发性以获得最大的稳定性和性能。
什么是暂停功能?
挂起函数是这样一种函数,它可能会也可能不会立即执行,或者一旦调用开始就被挂起,然后最终结束。它不会阻塞一个线程,即使它或它的一部分被挂起。
从编码的角度来看,如果从协程中提取函数,您必须使自己的函数可挂起:
runBlocking {
...
launch {
...
}
}
转换为
runBlocking {
...
doLaunch()
}
suspend fun doLaunch() {
launch {
...
}
}
在内部,suspend
关键字导致为协程上下文添加一个隐藏参数。
等待工作
将工作分派给几个并发执行的协程是故事的一部分。首先,如果协程计算了一些东西,在协程完成它们的工作之后,我们需要确保在继续程序流程之前,我们能够收集到结果。其次,我们必须确保程序作为一个大的状态机处于一致的状态,然后我们才能在协程完成后继续做更多的工作。这里我们说的是结果采集和协同或同步。
对于同步,为了确保一个Job
或一个Deferred
已经完成了它的工作,使用join()
,如
val job = launch { ... }
val deferr = async { ... }
job.join() // suspend until job finished
deferr.join() // suspend until deferr finished
我们也可以对Deferred
这样做,因为它是Job
的子类。在这两种情况下,它确保作业的所有协程子作业也完成了它们的工作。然而,对于Deferred
,我们实际上想要得到计算的结果,这将我们引向协程结果收集。你通过写作做到这一点
val deferr1 = async { ... }
val deferr2 = async { ... }
val deferr1Res = deferr1.await()
val deferr2Res = deferr2.await()
再次,await()
函数调用暂停程序流,直到Deferred
完成它们的工作。再一次,async
乔布斯的子女也将完成他们的工作。
对于Deferred
,还有一个函数getCompleted()
,您可以使用它来获得一个已经计算好的结果:
val deferr1Res = deferr1.getCompleted()
不过,在这里您必须确保Deferred
确实完成了它的计算,否则您将得到一个IllegalStateException
。你可以读取isCompleted
属性来检查一个Deferred
或者一个Job
是否已经完成。
在具有父子关系的协同程序的分层设置中,协同程序库确保子进程将在父进程退出之前完成工作,因此我们不必编写
runBlocking {
val job1 = launch {
}
job1.join() // unnecessary!
}
加入将自动发生。
取消协程
要取消任何作业,调用Job
或Deferred
对象上的cancel()
功能。
val job = launch { ... }
val deferr = async { ... }
...
job.cancel() // or deferr.cancel()
取消并不意味着作业立即停止工作。相反,它被标记并在可行的时间停止工作。
-
在取消的作业中,任何挂起函数的调用都将导致作业结束执行。一个例子是
delay()
函数中的delay();
,将进行取消检查,如果作业被取消,作业将立即退出。 -
如果没有挂起函数调用或调用次数不够,您可以使用
yield()
函数启动这样的取消检查。 -
在您的代码中,您可以定期检查
isActive
属性是否给出了false
。如果是这种情况,您知道作业已被取消,您可以完成作业执行。
因为取消通常不会导致任务立即终止,所以您必须附加一个join()
:
val job = launch { ... }
...
job.cancel()
job.join()
另一种选择是使用
val job = launch { ... }
...
job.cancelAndJoin()
结合了这两者。
在本章后面的“异常处理”一节中,我们将讨论取消对协程作用域层次结构的影响。
超时设定
您可以通过以下方式为协程内部的指令指定超时
withTimeout(1000L) { // milliseconds
...
}
如果达到超时限制,就会抛出一个TimeoutCancellationException
(CancellationException
的子类),或者
val res = withTimeoutOrNull(1000L) { // milliseconds
...
[result expression]
}
它不会抛出异常,而是在经过的时间超过给定时间时将null
赋给结果。由于 Kotlin 惯用的?:
操作符用于null
值处理,我们也可以抛出自己的异常,如
withTimeoutOrNull(1000L) { // milliseconds
...
"OK"
} ?: throw Exception("Timeout Exception")
分配器
协程调度程序实际上告诉作业在哪里以及如何运行。更准确地说,它描述了协程在哪个线程中运行,以及如何创建或查找线程(例如,从线程池中)。您可以通过以下方式获得当前的调度程序
coroutineContext[ContinuationInterceptor]
如果您不想使用像launch()
或async()
这样的构建器所使用的缺省值,您可以显式地指定一个调度器。记住,我们可以给launch()
或async()
上下文作为第一个参数。如果我们有一个调度员,那么,我们可以写
val myDispatcher = ...
runBlocking {
val job = launch(coroutineContext + myDispatcher) {
...
}
job.join()
}
您不必自己开发这样的调度程序,因为有些调度程序是由协程库提供的:
-
Dispatchers.Default
如果上下文不包含调度程序,这是默认的调度程序。它使用至少有两个线程的线程池,最大线程数是当前设备拥有的 CPU 数减去
1
。但是,您可以通过在应用的早期(在构建任何协程之前)编写System.setProperty( "kotlinx.coroutines.default.parallelism", 12 )
来覆盖这个数字。 -
Dispatchers.Main
This is a dispatcher tied to user interface processing. For Android, if you want to use the main dispatcher, you must add library
kotlinx-coroutinesandroid
to the dependencies section insidebuild.gradle
. If you route your coroutines structure likeclass MyClass : CoroutineScope by MainScope() { ... }
Dispatchers.Main
会自动使用。 -
Dispatchers.IO
这是一个专门为阻塞 IO 功能定制的调度程序。它类似于
Dispatchers.Default
调度程序,但是如果需要的话,会创建多达64
个线程。 -
newSingleThreadContext("MyThreadName)"
这将启动一个专用的新线程。您应该通过在最后应用
close()
来结束使用它,或者将由newSingleThreadContext()
函数调用返回的实例存储在某个全局位置以便重用。 -
Dispatchers.Unconfined
这不是一般用途。非受限调度程序是一种使用周围上下文线程的调度程序,直到调用第一个挂起函数。它从线程中第一个被使用的挂起函数开始恢复。
异常处理
在协程执行期间,我们基本上有三种异常,除非采取进一步的预防措施,否则将会发生以下情况:
-
对于
CancellationException
异常和launch()
:记住取消异常发生在对Job
元素显式调用cancel()
时。如果一个CancellationException
被抛出,它将导致当前协程的退出,但不会导致任何一个父协程的退出;他们只会忽略它。层次的根协程同样会忽略异常,因此在协程机制之外,这样的异常不会被检测到。 -
对于
CancellationException
异常和async()
:除了launch()
,通过调用Deferred
元素上的cancel()
来取消Deferred
作业不会导致异常被忽略。相反,我们必须对异常做出反应,这将出现在await()
函数中。 -
对于
TimeoutCancellationException
异常:如果withTimeout( timeMillis:Long ) { ... }
超时,抛出TimeoutCancellationException
。这是CancellationException
的一个子类,没有特殊处理,所以正常的取消异常也适用于超时。 -
任何其他异常:正常异常会导致协程层次结构中任何正在运行的作业立即退出,并且也会被根协程抛出。例如,如果您希望出现这样的异常,您必须将一个根
runBlocking()
包装到一个 try-catch 子句中。当然,您可以在作业中添加 try-catch 子句,以便尽早捕获这种异常。
要了解取消异常会发生什么,以及它是如何在协程层次结构中传播的,请尝试下面的代码:
var l1:Job? = null
var l11:Job? = null
var l111:Job? = null
runBlocking {
Log.d("LOG", "A")
l1 = launch {
Log.d("LOG", "B")
l11 = launch {
Log.d("LOG", "C")
delay(1000L)
Log.d("LOG", "D")
l111 = launch {
Log.d("LOG", "E")
delay(1000L)
Log.d("LOG", "F")
delay(1000L)
Log.d("LOG", "G")
}
delay(2500L)
Log.d("LOG", "H")
}
delay(1000L)
Log.d("LOG", "I")
}
Log.d("LOG", "X1")
delay(1500L)
Log.d("LOG", "X2")
l111?.cancel()
Log.d("LOG", "X3")
}
如果运行此命令,日志记录将如下所示:
10:05:31.295: A
10:05:31.295: X1
10:05:31.299: B
10:05:31.301: C
10:05:32.300: I
10:05:32.302: D
10:05:32.302: E
10:05:32.796: X2
10:05:32.796: X3
10:05:34.802: H
我们观察到以下特征:
-
runBlocking()
不向外界转发取消异常。因此,这个异常是一个有点“预期”的异常。 -
标签
X1
在A
之后立即到达。这并不奇怪,因为所有的launch()
调用都会导致后台处理。 -
标签
B
和C
在A
之后不久到达,因为除了后台处理启动之外,没有指定延迟。 -
标签
I
在1
秒后到达,因为delay(1000L)
就在它前面。这时候标签C
后的延迟已经差不多过去了。几毫秒后到达D
和E
。 -
当标签
E
到达时,在X1
之后的延迟还没有完全过去,但是半秒钟后X2
到达,我们在作业l111
上触发取消。那时我们正处于E
后的delay(1000L)
中间。 -
由于取消,
E
后的延迟立即退出,作业l111
提前退出。标签F
和G
因此永远不会被触及。 -
l111
的父协同程序继续它们的工作,它们只是忽略了作业l111
的取消。这就是为什么稍后标签H
到达。 -
标签
X3
出现在H
之前。我们知道runBlocking()
继续它的工作,而任何非取消的孩子仍然在运行。作业l111
被取消,但是作业l11
和l1
都没有被取消,所以H
和I
都到达。
如果在后一个示例中用l11.cancel()
替换l111.cancel()
,将产生以下输出:
11:40:35.893: A
11:40:35.894: X1
11:40:35.894: B
11:40:35.896: C
11:40:36.896: I
11:40:36.898: D
11:40:36.899: E
11:40:37.394: X2
11:40:37.395: X3
这里我们可以看到父作业l11
和其子作业(作业l111
)都被取消了;标签F
、G
和H
永远也到不了。
练习 2
在前面的例子中,删除了cancel()
语句,并在标签E
之后的delay()
中添加了一个0.5
秒的超时。你还能指望什么Will
测井与cancel()
语句的测井不同?
如果您希望确保一段代码不能被取消,尽管它包含挂起的函数调用,您可以将它包装到一个特殊的新上下文中:
...
withContext(NonCancellable) {
// uncancellable code here
...
}
...
如果您需要定制异常处理,可以使用构建器调用显式注册一个ExceptionHandler
:
val handler = CoroutineExceptionHandler {
_, exception ->
Log.e("LOG", "Caught $exception")
}
runBlocking(handler) {
...
}
或者
val handler = ...
runBlocking {
...
launch(coroutineContext + handler) {
...
}
}
注意,尽管以大写字母开头,但CoroutineExceptionHandler()
实际上是一个函数调用。如果你想写一个处理异常的类,也有一个使用相同名字CoroutineExceptionHandler
的接口。
这种异常处理程序只处理协程不会捕获的异常。我们知道对于launch()
作业来说,CancellationException
不会在协程层次结构中向上传送;在这种情况下,对于这种特殊的异常类型,也不会调用异常处理程序。
如果您不想要所有那些异常传播的东西,您可以使用一个主管作业,如
// we are inside a coroutine scope
val supervisor = SupervisorJob()
withContext(coroutineContext + supervisor) {
// the coroutines hierarchy here
...
}
或者你使用一个supervisor
范围:
// we are inside a coroutine scope
supervisorScope {
// the coroutines hierarchy here
...
}
一个管理程序导致所有协程相互独立地处理它们的异常。然而,没有孩子会比父母活得长。
十九、使用外部库
外部库是通用的接口和类的集合,因此可以在各种项目中重用。您还不会找到很多 Kotlin 库,但是因为 Kotlin 很容易与 Java 类和接口连接,所以在您的项目中,您可以使用其他开发人员和开发团队发布的一个或多个 Java 库。
外部库的示例领域包括编码和解码、压缩、CSV 文件处理、电子邮件、高级数学和统计、数据库、扩展日志记录工具、XML 和 JSON 文件处理等等。在第二十章中,你会学到更多关于 XML 和 JSON 的知识。
这一章的其余部分将讨论如何在你的 Android 项目中添加外部库,如果你添加了外部 Java 库,将深入探讨与可空性相关的特性,并描述如何构建你自己的库。
添加外部库
添加外部库的第一步是指定库的来源。可以加载或包含库的地方被称为库。一旦你开始一个新的 Android 项目,项目的build.gradle
脚本包含两个地方的库,在buildscript
部分和allprojects
部分:
buildscript {
...
repositories {
google()
jcenter()
}
...
}
allprojects {
...
repositories {
google()
jcenter()
}
...
}
...
应用依赖项使用来自allprojects
部分的存储库。来自buildscript
部分的存储库转而引用构建过程的插件和依赖项。我们想要添加应用库,而不是调整构建过程,所以我们应该看看allprojects
部分。这里可以指定以下存储库:
-
这是一个储存库,从这里可以加载 Android 特有的库。对于 Android 项目来说,这总是包括在内并且总是必要的,但是它通常不是您查找特定于应用的库的地方。换句话说,这不是我们用来存放外部库的地方。
-
mavenCentral()
:这是位于https://repo1.maven.org/maven2
的原始 Maven 仓库。在谈到 Maven 构建系统时,大多数开发人员首先想到的是这个用于添加库的存储库。然而,对于 Android 来说,第一选择是使用jcenter
来代替。 -
jcenter()
:在http://jcenter.bintray.com
引用一个替代的 Maven 库。通常偏向于jcenter
而不是mavenCentral
不会有什么坏处,但是在很多情况下两者都可以工作,甚至有可能两者都指定。不同之处可能表现在下载库的不同性能,以及不同的“最新”库版本。jcenter
人声称他们的库比mavenCentral
更大更快。 -
mavenLocal()
:无论你如何使用 Maven 作为构建系统,在你的开发机器上,一个缓存将会被构建起来,并永久地填充你从任何 Maven 库(包括jcenter
)下载的库。此外,如果您创建了一个 Maven 库项目并安装了它,那么这个库将会出现在这个缓存中,即使您从未打算将它上传到一个官方的公共存储库中。mavenLocal()
存储库在缓存中查找库依赖关系。请注意,您通常会在 PC 用户的home
文件夹中的.m2
下找到该缓存。 -
maven { url '
http://example.com/maven'
}
:您可以使用它来添加一个定制的 Maven 资源库。如果您使用私人或公司的 Maven 存储库,这将非常方便。注意,google()
和jcenter()
库只是maven { url '
https://dl.google.com/dl/android/maven2/
' }
和maven { url '
https://jcenter.bintray.com/
' }
的快捷方式。 -
ivy { url '
http://example.com/ivy'
}
:您可以使用它来添加一个 Apache Ivy 存储库。
在大多数情况下,使用默认设置就可以了:如图所示使用google()
和jcenter()
。您可以尝试这些方法,并在必要时添加新的存储库。
随着存储库的建立,我们现在可以以依赖关系的形式添加实际的库。这在模块的build.gradle
文件中效果最好。对于一个新项目,该文件的dependencies
部分可能是这样的:
dependencies {
implementation
fileTree(dir: 'libs', include: ['*.jar'])
implementation
"org.jetbrains.kotlin:kotlin-stdlib-jdk7:
$kotlin_version"
implementation
"com.android.support:appcompat-v7:28.0.0"
implementation
"com.android.support.constraint:
constraint-layout:1.1.3"
testImplementation
"junit:junit:4.12"
androidTestImplementation
"com.android.support.test:runner:1.0.2"
androidTestImplementation
"com.android.support.test.espresso:
espresso-core:3.0.2"
}
(每一项占一行。)细节这里没什么意思;你需要知道的是,对于新的外部库,我们必须添加另一行以implementation
开头的代码。这个新条目的精确语法遵循以下格式:
implementation "MAVEN_GROUP_ID:MAVEN_ARTIFACT_ID:VERSION"
或者
implementation 'MAVEN_GROUP_ID:MAVEN_ARTIFACT_ID:VERSION'
这个组 ID、工件 ID 和版本的三元组也被称为 Maven 坐标。
一个等效的方法是使用一个参数化的表单(写在一行上,不换行):
implementation group: "MAVEN_GROUP_ID",
name: "MAVEN_ARTIFACT_ID",
version "VERSION"
同样,你也可以使用单引号。
这最好用一个例子来解释。假设您想要添加 Apache Commons 数学库,它允许复杂的数学计算。我们首先需要确定库的 Maven 坐标。有几种方法可以得到这些坐标。
-
该库可能有自己的网站,例如,Maven 坐标可以在下载中找到。
-
直接查看存储库的网站,并使用那里提供的搜索工具。
-
使用您选择的搜索引擎,输入一个搜索字符串,比如“apache commons math maven”
在大多数情况下,您将获得形式为 XML 字符串的 Maven 坐标
<!-- https://mvnrepository.com/artifact/
org.apache.commons/commons-math3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-math3</artifactId>
<version>3.2</version>
</dependency>
在dependency
元素中,您可以看到组 ID、工件 ID 和版本。因此,通过查看标记名,可以很容易地从 XML 中推导出 Gradle 等价物。这里的转录是
implementation "org.apache.commons:commons-math3:3.2"
或者
implementation group: "org.apache.commons",
name : "commons-math3",
version : "3.2"
(一行)。
注意
有时你也会得到 Gradle 语法。即使上面写着compile 'org.apache.commons:commons-math3:3.2'
,也不要用compile
而是用implementation
来代替。compile
关键字属于旧版本的 Gradle。
Android Studio 随后会要求您同步您的项目。这样做,然后你就可以在你的代码中使用这个新的库了。
依赖性管理
库可以依赖于其他库。这种情况经常发生,如果我们不得不手动添加一个库公开的所有依赖项,这可能会变成一场真正的噩梦。例如,另一个名为 Apache Commons Configuration 的 Apache Commons 库在 2.4 版中依赖于另外三个库,而这三个库又可能依赖于其他库,以此类推。幸运的是,Maven 会自动解析这种依赖关系,包括所有可传递的依赖关系,因此我们不需要做任何事情。
我们在这里提到这一点,是为了让你意识到这样的依赖和传递性依赖会极大地破坏我们的应用。例如,如果您添加了一个大小为 100 kb 的可疑库,由于依赖关系,它可能很容易膨胀到几兆字节。在现代设备上,这几乎不会造成真正的问题,但知道为什么应用文件在某些情况下会变得如此之大还是有好处的。
未解析的本地依赖项
如果您使用 Android Studio 创建一个新项目,模块的build.gradle
文件的dependencies
部分的第一行如下所示
fileTree(dir: 'libs', include: ['*.jar'])
这意味着你放入libs
文件夹的任何.jar
文件都将作为库添加到你的应用中。没有自动的依赖关系解析会发生,你必须自己下载库。这与 Maven 的依赖包含方法有些冲突,所以尽量避免使用这种技术。
外部库和可空性
我们知道,在 Kotlin 中,属性的可空性在提高程序稳定性方面起着重要的作用。如果我们包括外部 Java 库,情况就不同了。作为一种语言,Java 并不像 Kotlin 那样精确区分可空变量和不可空变量。为了能够使用外部 Java 库,Kotlin 假设所有函数调用参数和函数返回值都可以为空。
如果外部库的 API 文档说函数返回值不能是null
,那么告诉 Kotlin 这个事实的唯一方法就是使用 Elvis 操作符,如果返回值是null
则抛出一个异常:
val res = javaObject.function() ?:
throw Exception("Cannot happen")
创建自己的图书馆
您可以用任何想要的方式创建一个库,包括使用命令行或其他 ide,比如 Eclipse。在这一节中,我们将介绍如何使用 Android Studio 创建和使用库。
在 Android Studio 中,一个库项目实际上不仅仅是一个可以包含在其他项目中的.jar
文件。它几乎是一个独立的应用,因为它可以包括 Android 配置文件和描述用户界面的文件。但是,没有人阻止我们使用 Android 库来定义可以在其他项目中使用的接口和类。
作为一个例子,我们定义了一个名为 StringRegex 的库,它通过一个运算符函数来扩展String
类,用于检查正则表达式匹配,因此我们可以编写
val s = "The big brown fox jumps over the creek."
val containsOnlyLetters = s % "[A-Za-z ]*"
// -> false because of the "."
为了定义这个扩展函数,我们重载了%
操作符rem()
。代码是这样的
package org.foo.stringregex
operator fun String.rem(re:String):Boolean =
this.matches(Regex(re))
当然,你可以使用不同的东西。
我们首先在 Android Studio 中开始一个新的库项目。为此,请转到文件➤新➤新项目,并选择添加无活动。作为项目名,输入StringRegexApp
,作为包名,输入org.foo.stringregex
。作为保存位置,输入您喜欢的任何内容。让你选择 Kotlin 作为语言设置,作为一个最低的 API 级别,选择任何你认为合适的。在新的 Android Studio 项目窗口中,打开文件➤新➤新模块。选择 Java 库。输入StringRegex
作为库名。其他设置在这里并不重要。项目视图现在看起来类似于图 19-1 。
图 19-1
StringRegex Android 库
删除 Java 类 MyClass,因为我们不需要它。在包org.foo.stringregex
中,创建一个新的 Kotlin 文件。单击鼠标右键,然后选择“新建➤·Kotlin 文件/类”。作为名称输入stringregex
并作为种类选择文件。
Android Studio 可能会显示一条警告,说明 Kotlin 未配置。如果是这种情况,单击 Configure,从菜单中选择 Java with Gradle,在询问您要启用 Kotlin 的模块的对话框中,选择 All modules。
注意
对于 Android Studio 3.3,Kotlin 配置向导中有一个 bug。在 Stringregex 模块的build.gradle
文件中,您可能需要注释掉plugins
中的插件版本:
id 'org.jetbrains.kotlin.jvm' //version '1.3.20'
打开stringregex
文件,输入前面清单中显示的扩展功能代码。您现在可以关闭窗口了,因为我们将从一个客户端项目中引用它。
从 Android Studio 中打开的任何应用中,您可以选择我们为这本书创建的应用之一,打开settings.gradle
文件,并添加两条语句。
include ':StringRegex'
project(':StringRegex').projectDir =
new File('../StringRegexApp/StringRegex')
其中,File()
中的字符串必须指向我们刚刚创建的库模块。我们仍然需要声明模块依赖关系。为此,打开客户端应用模块的build.gradle
文件,在dependencies
部分添加
implementation project(":StringRegex")
这个过程可以根据你的需要对任意多个引用这个库的应用重复。
现在,您可以导入扩展函数,并在客户端代码中使用它。
import org.foo.stringregex.rem
...
val s = "The big brown fox jumps over the creek."
val containsOnlyLetters = s % "[A-Za-z ]*"
// -> false because of the "."
要查看或使用从库中生成的.jar
文件,请在操作系统的文件系统浏览器中转到StringRegexApp / StringRegex / build / libs.
二十、XML 和 JSON
在第十九章中,我们学习了如何在我们的 Android 项目中包含外部库。Kotlin 的标准库中没有专门的 XML 和 JSON 处理类,所以为了完成 XML 和 JSON 相关的任务,我们使用适当的外部库,并以扩展函数的形式添加一些方便的函数。
注意
XML 和 JSON 都是结构化数据的格式规范。如果您的 Android 应用与外部世界通信以接收或发送标准化格式的数据,您将会经常使用它们。
本章假设您有一个示例应用,可以用来测试所提供的代码片段。使用你喜欢的任何应用或我们在本书中开发的应用之一。例如,您可以添加一些示例代码,为 activity 的onCreate()
函数内部的测试提供Log
输出,或者您可以使用一个使用 Android 测试方法的测试类。选择最适合您需求的方法。
XML 处理
XML 文件最简单的形式如下:
<?xml version="1.0" encoding="UTF-8"?>
<ProbeMsg>
<TimeStamp>2016-10-30T19:07:07Z</TimeStamp>
<ProbeId>1A6G</ProbeId>
<ProbeValue ScaleUnit="cm">37.4</ProbeValue>
<Meta>
<Generator>045</Generator>
<Priority>-3</Priority>
<Actor>P. Rosengaard</Actor>
</Meta>
</ProbeMsg>
注意
XML 允许更复杂的结构,如模式验证和名称空间。在本章中,我们只描述 XML 标签、属性和文本内容。您可以自由扩展本章中介绍的示例和实用程序函数,以包含这些扩展功能。
对于 XML 处理,使用以下范例之一或组合。
-
DOM 模型:完整的树处理:在文档对象模型(DOM)中,XML 数据被视为一个整体,由内存中的树状结构表示。
-
SAX:基于事件的处理:在这里,XML 文件被解析,每个元素或属性都会触发一个适当的事件。事件由回调函数接收,回调函数必须向 SAX 处理器注册。这种“告诉我你在做什么”的处理方式通常被称为推送解析。
-
StAX:基于流的处理:在这里,您执行诸如“给我下一个 XML 元素”之类的操作。与 SAX 不同,在 SAX 中我们有一个推送解析,对于 StAX,我们告诉解析器它必须做什么:“我告诉你你做什么。”这因此被称为拉解析。
在 Android 上,你通常处理小到中等大小的 XML 文件。因此,在本章中我们使用 DOM。对于读取,我们首先解析完整的 XML 文件,并将数据存储在内存中的 DOM 树中。在这里,像删除、更改或添加元素这样的操作很容易完成,并且发生在内存中;因此它们非常快。为了编写,我们从内存中取出完整的 DOM 树,并从中生成一个 XML 字符流,也许将结果写回到一个文件中。
对于 XML 处理,我们添加了 Java 参考实现 Xerces 作为外部库。在 Android Studio 中,打开模块的build.gradle
文件,在dependencies
部分添加:
implementation 'xerces:xercesImpl:2.12.0'
注意
Xerces 还实现了 SAX 和 StAX APIs,尽管我们将只使用它的 DOM 实现。
读取 XML 数据
借助 Xerces 实现,我们可以使用的 DOM 实现已经包含了读取 XML 元素所需的一切。然而,我们将添加几个扩展函数,大大提高 DOM API 的可用性。为此,创建一个包com.example.domext
,或者您也可以使用任何其他合适的包名。在这个包中添加一个 Kotlin 文件dom.kt
,其内容如下:
package com.example.domext
import org.apache.xerces.parsers.DOMParser
import org.w3c.dom.Document
import org.w3c.dom.Node
import org.xml.sax.InputSource
import java.io.StringReader
import java.io.StringWriter
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
fun parseXmlToDOM(s:String) : Document {
val parser: DOMParser = DOMParser()
return parser.let {
it.parse(InputSource(StringReader(s)))
it.document
}
}
fun Node.fetchChildren(withText:Boolean = false) =
(0..(this.childNodes.length - 1)).
map { this.childNodes.item(it) }.
filter { withText || it.nodeType != Node.TEXT_NODE }
fun Node.childCount() = fetchChildren().count()
fun Node.forEach(withText:Boolean = false,
f:(Node) -> Unit) {
fetchChildren(withText).forEach { f(it) }
}
operator fun Node.get(i:Int) = fetchChildren()[i]
operator fun Node.invoke(s:String): Node =
if(s.startsWith("@")) {
this.attributes.getNamedItem(s.substring(1))
}else{
this.childNodes.let { nl ->
val iter = object : Iterator<Node> {
var i: Int = 0
override fun next() = nl.item(i++)
override fun hasNext() = i < nl.length
}
iter.asSequence().find { it.nodeName == s }!!
}
}
operator fun Node.invoke(vararg s:String): Node =
s.fold(this, { acc, s1 -> acc(s1) })
fun Node.text() = this.firstChild.nodeValue
fun Node.name() = this.nodeName
fun Node.type() = this.nodeType
这些都是org.w3c.dom.Node
的包级函数和扩展函数,具有以下特点:
-
在 DOM API 中,树中的每个元素(例如,从本节开始的 XML 数据中的
ProbeValue
)都由一个Node
实例表示。 -
我们添加了一个
parseXmlToDOM(s:String)
包级函数,将 XML 字符串转换为Document
。 -
我们给
Node
添加了一个fetchChildren()
函数,它返回一个节点的所有非文本子节点,这些子节点忽略了文本元素。如果添加with- Text=true
作为参数,元素的文本节点会包含在子列表中,即使它们只包含空格和换行符。例如,在本节开头的 XML 数据中,节点Meta
有三个子节点:Generator
、Priority
和Actor
。使用withText=true
,它们之间的空格和换行符也将被返回。 -
我们给
Node
添加了一个childCount()
函数,它计算一个节点的子元素的数量,不考虑文本元素。官方的 DOM API 没有为此提供功能。 -
我们给
Node
添加了一个forEach()
函数,允许我们以 Kotlin 的方式遍历一个节点的子节点。最初的 DOM API 没有提供这样的迭代器,因为它只有函数和属性hasChild- Nodes()
、childNodes.length
和childNodes.item(index:Int)
来遍历子元素。如果添加withText=true
作为参数,元素的文本节点将包含在子列表中,即使它们只包含空格和换行符。 -
我们为
Node
添加了一个get(i:Int)
函数,以从元素中获取某个子元素,而不考虑文本节点。 -
我们重载了
Node
的invoke
运算符,属于括号()
。带有String
参数的第一个变量通过名称导航到一个孩子:node("cn")
=node
→名为“cn”的孩子如果参数以一个@
开始,属性被寻址:node("@an")
=node
→属性名为“an”在后一种情况下,您仍然需要调用text()
来获得字符串形式的属性值。 -
重载的
invoke
操作符的第二个变体允许我们指定几个字符串,从一个子元素导航到另一个子元素,等等。 -
我们向
Node
添加函数:首先,text()
获取元素的文本内容,然后name()
给出节点名,然后type()
计算节点类型(可能的值参见Node
类的常量属性)。
警告
为了简单起见,本节中显示的 DOM 处理代码片段没有以合理的方式处理异常。在将代码用于生产项目之前,必须添加适当的错误处理。
这个片段提供了如何使用 API 和扩展的例子。
import ...
import com.example.domext.*
...
val xml = """<?xml version="1.0" encoding="UTF-8"?>
<ProbeMsg>
<TimeStamp>2016-10-30T19:07:07Z</TimeStamp>
<ProbeId>1A6G</ProbeId>
<ProbeValue ScaleUnit="cm">37.4</ProbeValue>
<Meta>
<Generator>045</Generator>
<Priority>-3</Priority>
<Actor>P. Rosengaard</Actor>
</Meta>
</ProbeMsg>"""
try {
// Parse the complete XML document
val dom = parseXmlToDOM(xml)
// Access an element
val ts = dom("ProbeMsg")("TimeStamp").text()
Log.d("LOG", ts) // 2001-11-30T09:08:07Z
// Access an attribute
val uni = dom("ProbeMsg")("ProbeValue")("@ScaleUnit")
Log.d("LOG", uni.text()) // cm
// Simplified XML tree navigation
val uni2 = dom("ProbeMsg","ProbeValue","@ScaleUnit")
Log.d("LOG", uni2.text()) // cm
// Iterate through an element's children
dom("ProbeMsg")("Meta").forEach { n ->
Log.d("LOG", n.name() + ": " + n.text())
// Generator: 045
// Priority: -3
// Actor: P. Rosengaard
}
}catch(e:Exception) {
Log.e("LOG", "Cannot parse XML", e)
}
...
改变 XML 数据
一旦我们在内存中有了 XML 树的 DOM 表示,我们就可以添加元素了。虽然我们可以使用 DOM API 已经提供的函数,但是 Kotlin 允许我们提高表达能力。为此,将以下代码添加到我们的扩展文件dom.kt
(我不添加新的导入;按 Alt+Enter 让 Android Studio 帮你添加必要的导入):
fun prettyFormatXml(document:Document): String {
val format = OutputFormat(document).apply { lineWidth = 65
indenting = true
indent = 2
}
val out = StringWriter()
val serializer = XMLSerializer(out, format)
serializer.serialize(document)
return out.toString()
}
fun prettyFormatXml(unformattedXml: String) =
prettyFormatXml(parseXmlToDOM(unformattedXml))
fun Node.toXmlString():String {
val transformerFact = TransformerFactory.newInstance()
val transformer = transformerFact.newTransformer()
transformer.setOutputProperty(OutputKeys.INDENT, "yes")
val source = DOMSource(this)
val writer = StringWriter()
val result = StreamResult(writer)
transformer.transform(source, result)
return writer.toString()
}
operator fun Node.plusAssign(child:Node) {
this.appendChild(child)
}
fun Node.addText(s:String): Node {
val doc = ownerDocument
val txt = doc.createTextNode(s)
appendChild(txt)
return this
}
fun Node.removeText() {
if(hasChildNodes() && firstChild.nodeType == Node.TEXT_NODE)
removeChild(firstChild)
}
fun Node.updateText(s:String) : Node { removeText()
return addText(s)
}
fun Node.addAttribute(name:String, value:String): Node {
(this as Element).setAttribute(name, value)
return this
}
fun Node.removeAttribute(name:String) {
this.attributes.removeNamedItem(name)
}
这是我们在这种情况下的描述
-
功能
prettyFormatXml( document: Document )
和prettyFormatXml( unformattedXml: String )
是主要用于诊断目的的实用功能。给定一个Document
或者一个无格式的 XML 字符串,它们创建一个漂亮的字符串。 -
扩展函数
Node.toXmlString()
从当前节点开始创建 XML 子树的字符串表示。如果对Document
这样做,整个 DOM 结构都将被转换。 -
我们重载
Node
的plusAssign
操作符(对应于+=
)来添加一个子节点。 -
我们为
Node
添加了一个addText()
扩展,用于向节点添加文本内容。 -
我们为
Node
添加了一个removeText()
扩展,用于从节点中删除文本内容。 -
我们为
Node
添加了一个updateText()
扩展,用于改变节点的文本内容。 -
我们为
Node
添加了一个addAttribute()
扩展,用于向节点添加属性。 -
我们为
Node
添加了一个removeAttribute()
扩展,用于从节点中删除属性。 -
我们为
Node
添加了一个updateAttribute()
扩展,用于改变节点的属性。
例如,这些函数的用例包括以下代码片段。首先,我们向给定的节点添加一个元素加属性:
val xml = """<?xml version="1.0" encoding="UTF-8"?>
<ProbeMsg>
<TimeStamp>2016-10-30T19:07:07Z</TimeStamp>
<ProbeId>1A6G</ProbeId>
<ProbeValue ScaleUnit="cm">37.4</ProbeValue>
<Meta>
<Generator>045</Generator>
<Priority>-3</Priority>
<Actor>P. Rosengaard</Actor>
</Meta>
</ProbeMsg>"""
try {
val dom = parseXmlToDOM(xml)
val msg = dom("ProbeMsg")
val meta = msg("Meta")
// Add a new element to "meta".
meta += dom.createElement("NewMeta").
addText("NewValue").
addAttribute("SomeAttr", "AttrVal")
Log.d("LOG", "\n\n" + prettyFormatXml(dom))
}catch(e:Exception) { Log.e("LOG", "XML Error", e)
}
为此,我们还使用了来自Document
类的createElement()
函数。最后,这段代码将修改后的 XML 写入日志控制台。
以下代码示例解释了如何更改和移除属性和元素:
val xml = """<?xml version="1.0" encoding="UTF-8"?>
<ProbeMsg>
<TimeStamp>2016-10-30T19:07:07Z</TimeStamp>
<ProbeId>1A6G</ProbeId>
<ProbeValue ScaleUnit="cm">37.4</ProbeValue>
<Meta>
<Generator>045</Generator>
<Priority>-3</Priority>
<Actor>P. Rosengaard</Actor>
</Meta>
</ProbeMsg>"""
try {
val dom = parseXmlToDOM(xml)
val msg = dom("ProbeMsg")
val ts = msg("TimeStamp")
val probeValue = msg("ProbeValue")
// Update an attribute and the text contents of
// an element.
probeValue.updateAttribute("ScaleUnit", "dm")
ts.updateText("1970-01-01T00:00:00Z")
Log.d("LOG", "\n\n" + prettyFormatXml(dom))
// Remove an attribute
probeValue.removeAttribute("ScaleUnit")
Log.d("LOG", "\n\n" + prettyFormatXml(dom))
// Removing a node means removing it from
// its parent node.
msg.removeChild(probeValue)
Log.d("LOG", "\n\n" + prettyFormatXml(dom))
}catch(e:Exception) {
Log.e("LOG", "XML Error", e)
}
创建新的 DOM
如果您需要从头开始编写 XML 文档的 DOM 表示,首先创建一个Document
实例。这个没有公共构造函数;相反,你应该写:
val doc = DocumentBuilderFactory.
newInstance().newDocumentBuilder().newDocument()
从这里,您可以像前面描述的那样添加元素。注意,要查看我们的prettyFormatXml()
实用函数的任何输出,您必须向doc
添加至少一个子元素。
练习 1
向dom.kt
文件添加一个createXmlDocument()
函数,以简化文档创建。
JSON 处理
JavaScript 对象表示法(JSON)是 XML 的小姐妹。与使用 XML 格式的数据相比,用 JSON 格式编写的数据需要更少的空间。此外,JSON 数据几乎自然地映射到浏览器环境中的 JavaScript 对象,因此 JSON 在最近几年获得了相当大的关注。
Kotlin 的标准库不知道如何处理 JSON 数据,所以,类似于 XML 处理,我们添加一个合适的外部库。从几种可能性来看,我们使用广泛采用的杰克逊图书馆。要将它添加到 Android 项目中,在模块的build.gradle
文件中的dependencies
部分添加
implementation
'com.fasterxml.jackson.core:jackson-core:2.9.8'
implementation
'com.fasterxml.jackson.core:jackson-databind:2.9.8'
(在两行上,删除换行符)。
JSON 处理有几种范例。最常用的是带有特定于 JSON 的对象的树状结构,以及带有各种半自动转换机制的 Kotlin 和 JSON 对象之间的映射。我们将映射方法留给您进一步的研究;它包含几个非常复杂的特性,主要是 JSON 集合映射。杰克逊的主页给了你更多的信息。相反,我们描述处理 JSON 数据的内存树表示的机制。
对于本节的其余部分,我们使用以下 JSON 数据来解释示例中使用的函数:
val json = """{
"id":27,
"name":"Roger Rabbit",
"permanent":true,
"address":{
"street":"El Camino Real",
"city":"New York",
"zipcode":95014
},
"phoneNumbers":[9945678, 123456781],
"role":"President"
}"""
JSON 助手函数
用于 JSON 处理的 Jackson 库包含了编写、更新和删除 JSON 元素所需的全部内容。这个库非常广泛,包含了大量的类和函数。为了简化开发并包含 Kotlin 好东西,我们使用了一些包级函数和扩展函数来提高 JSON 代码的可读性。这些最好放在某个包com.whatever.ext
中的 Kotlin 文件json.kt
中。
我们从导入开始,添加一个invoke
操作符,这样我们可以很容易地从一个节点获取一个子节点,并添加一个remove
和一个forEach
函数,用于删除一个节点并遍历节点的子节点:
import com.fasterxml.jackson.core.JsonFactory
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.*
import java.io.ByteArrayOutputStream
import java.math.BigInteger
operator fun JsonNode.invoke(s:String) = this.get(s)
operator fun JsonNode.invoke(vararg s:String) =
s.fold(this, { acc, s -> acc(s) })
fun JsonNode.remove(name:String) {
val on = (this as? ObjectNode)?:
throw Exception("This is not an object node")
on.remove(name) }
fun JsonNode.forEach(iter: (JsonNode) -> Unit ) {
when(this) {
is ArrayNode -> this.forEach(iter)
is ObjectNode -> this.forEach(iter)
else -> throw Exception("Cannot iterate over " +
this::class)
}
}
接下来,我们为asText()
添加简单的别名text()
,以简化文本提取:
fun JsonNode.text() = this.asText()
另一个迭代器遍历对象节点的子节点。这一次,我们还会考虑孩子们的名字:
fun JsonNode.forEach(iter: (String, JsonNode) -> Unit ) {
if(this !is ObjectNode)
throw Exception(
"Cannot iterate (key,val) over " + this::class)
this.fields().forEach{
(name, value) -> iter(name, value) }
}
为了编写一个对象节点的子节点,我们定义了一个put()
函数,因此我们可以编写node.put( "childName", 42 )
:
// Works only if the node is an ObjectNode!
fun JsonNode.put(name:String, value:Any?) : JsonNode {
if(this !is ObjectNode)
throw Exception("Cannot put() on none-object node")
when(value) {
null -> this.putNull(name)
is Int -> this.put(name, value)
is Long -> this.put(name, value)
is Short -> this.put(name, value)
is Float -> this.put(name, value)
is Double -> this.put(name, value)
is Boolean -> this.put(name, value)
is String -> this.put(name, value)
is JsonNode -> this.put(name, value)
else -> throw Exception(
"Illegal value type: ${value::class}")
}
return this
}
为了向数组对象追加一个值,我们定义了一个add()
函数,它适用于各种类型:
// Add a value to an array, works only if this is an
// ArrayNode
fun JsonNode.add(value:Any?) : JsonNode {
if(this !is ArrayNode)
throw Exception("Cannot add() on none-array node")
when(value) {
null -> this.addNull()
is Int -> this.add(value)
is Long -> this.add(value)
is Float -> this.add(value)
is Double -> this.add(value)
is Boolean -> this.add(value)
is String -> this.add(value)
is JsonNode -> this.add(value)
else -> throw Exception(
"Illegal value type: ${value::class}")
}
return this
}
对于 JSON 对象创建,我们定义了各种createSomething()
样式的函数,并且我们还添加了几个类似 Kotlin 的构建函数:
// Node creators
fun createJsonTextNode(text:String) = TextNode.valueOf(text)
fun createJsonIntNode(i:Int) = IntNode.valueOf(i)
fun createJsonLongNode(l:Long) = LongNode.valueOf(l)
fun createJsonShortNode(s:Short) = ShortNode.valueOf(s)
fun createJsonFloatNode(f:Float) = FloatNode.valueOf(f)
fun createJsonDoubleNode(d:Double) = DoubleNode.valueOf(d)
fun createJsonBooleanNode(b:Boolean) = BooleanNode.valueOf(b)
fun createJsonBigIntegerNode(b: BigInteger) = BigIntegerNode.valueOf(b)
fun createJsonNullNode() = NullNode.instance
fun jsonObjectNodeOf(
children: Map<String,JsonNode> = HashMap()) :
ObjectNode {
return ObjectNode(JsonNodeFactory.instance, children)
}
fun jsonObjectNodeOf(
vararg children: Pair<String,Any?>) :
ObjectNode {
return children.fold(
ObjectNode(JsonNodeFactory.instance), { acc, v ->
acc.put(v.first, v.second)
acc
})
}
fun jsonArrayNodeOf(elements: Array<JsonNode> =
emptyArray()) : ArrayNode {
return ArrayNode(JsonNodeFactory.instance,
elements.asList())
}
fun jsonArrayNodeOf(elements: List<JsonNode> =
emptyList()) : ArrayNode {
return ArrayNode(JsonNodeFactory.instance,
elements)
}
fun jsonEmptyArrayNode() : ArrayNode {
return ArrayNode(JsonNodeFactory.instance)
}
fun jsonArrayNodeOf(vararg elements: Any?) : ArrayNode {
return elements.fold(
ArrayNode(JsonNodeFactory.instance), { acc, v ->
acc.add(v)
acc
})
}
扩展函数toPrettyString()
和toJsonString()
可以用来生成任何 JSON 节点的字符串表示:
// JSON output as pretty string
fun JsonNode.toPrettyString(
prettyPrinter:PrettyPrinter? =
DefaultPrettyPrinter()) : String {
var res:String? = null
ByteArrayOutputStream().use { os ->
val gen = JsonFactory().createGenerator(os).apply {
if(prettyPrinter != null) this.prettyPrinter = prettyPrinter
}
val mapper = ObjectMapper()
mapper.writeTree(gen, this)
res = String(os.toByteArray())
}
return res!!
}
// JSON output as simple string
fun JsonNode.toJsonString() : String =
toPrettyString(prettyPrinter = null)
所有这些扩展函数的主要思想是通过向基本节点类JsonNode
添加 JSON 对象相关和 JSON 数组相关的函数来提高简洁性,并在运行时执行类强制转换。虽然它使 JSON 代码更小,更有表现力,但运行时出现异常的风险也增加了。
读取和写入 JSON 数据
要读入 JSON 数据,您只需编写:
val json = ... // see section beginning
val mapper = ObjectMapper()
val root = mapper.readTree(json)
从这里我们可以研究 JSON 元素,遍历并获取 JSON 对象成员,并提取 JSON 数组元素:
try {
val json = ... // see section beginning
val mapper = ObjectMapper()
val root = mapper.readTree(json)
// see what we got
Log.d("LOG", root.toPrettyString())
// type of the node
Log.d("LOG", root.nodeType.toString())
// <- OBJECT
// is it a container?
Log.d("LOG", root.isContainerNode.toString())
// <- true
root.forEach { k,v ->
Log.d("LOG",
"Key:${k} -> val:${v} (${v.nodeType})")
Log.d("LOG",
" <- " + v::class.toString())
}
val phones = root("phoneNumbers")
phones.forEach { ph ->
Log.d("LOG", "Phone: " + ph.text())
}
Log.d("LOG", "Phone[0]: " + phones[0].text())
val street = root("address")("street").text()
Log.d("LOG", "Street: " + street)
Log.d("LOG", "Zip: " + root(“address”, “zipcode”).asInt())
}catch(e:Exception) {
Log.e("LOG", "JSON error", e)
}
以下代码片段展示了如何通过添加、更改或删除节点或 JSON 对象成员来改变 JSON 树。
// add it to the "try" statements from the
// last listing
// remove an entry
root("address").remove("zipcode")
Log.d("LOG", root.toPrettyString())
// update an entry
root("address").put("street", "Fake Street 42")
Log.d("LOG", root.toPrettyString())
root("address").put("country", createJsonTextNode("Argentina"))
Log.d("LOG", root.toPrettyString())
// create a new object node
root.put("obj", jsonObjectNodeOf(
"abc1" to 23,
"abc2" to "Hallo",
"someNull" to null
))
Log.d("LOG", root.toPrettyString())
// create a new array node
root.put("arr", jsonArrayNodeOf(
23,
null,
"Hallo"
))
Log.d("LOG", root.toPrettyString())
// write without spaces or line breaks
Log.d("LOG", root.toJsonString())
创建新的 JSON 树
要在内存中创建新的 JSON 树,可以使用:
val root = jsonObjectNodeOf()
从那里,您可以像前面描述的那样添加 JSON 元素。
练习 2
创建一个 JSON 文档,对应于:
{
"firstName": "Arthur",
"lastName": "Doyle",
"dateOfBirth": "03/04/1997",
"address": {
"streetAddress": "21 3rd Street",
"city": "New York",
"state": "NY",
"postalCode": "10021-1234"
},
"phoneNumbers": [
{
"type": "home",
"number": "212 555-1234"
},
{
"type": "mobile",
"number": "123 456-7890"
}
],
"children": [],
"spouse": null
}