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 · 102+ 1 · 101+ 4 · 100。在十六进制系统中我们相应地有 0x13D 的意思2 · 162+ 3 · 161+ 13 · 160。字母 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()实现是一个好主意。