KAIST-CS109-编程实践教程-全-

KAIST CS109 编程实践教程(全)

命令行

当我开始编程时,还没有图形显示。所有的计算机输入和输出都是使用文本完成的。

计算机由许多用户共享。每个用户从终端连接到计算机(称为终端,因为它是从计算机到用户的连接的终点)。

最早的终端看起来像是打印机和键盘的组合。(当我上中学时,有时会被允许玩一个使用这样的打印终端的计算机。)

一个打印终端

后来,打印终端被能够显示 25x80 字符矩阵的 CRT 显示器所取代(包括 ASCII 字母、数字和一些特殊图形字符)。

带 CRT 显示器的终端

用户通过键盘键入命令与计算机交互。计算机通过在终端上“打印”命令的输出来回应。

Kotlin 函数 println(“print line”)(或 Python 中的 print,或 C 中的 printf)的名称来源于很久以前的一段时间,输出确实是打印在纸上的。

第一批家用计算机,如 Commodore C-64,仍然是基于文本的。当 IBM PC 在 20 世纪 80 年代初问世时,它使用了基于文本的操作系统(MS-DOS),直到大约 10 年后 Windows 3.1 出现。

在大约 25 年前广泛传播的图形用户界面中,用户通过鼠标点击与计算机交互。这看起来比编写文本命令更容易,但也更受限制。您可以用文本表达命令,执行相当复杂的操作,这些操作手动操作需要您点击数百次鼠标才能完成。因此,命令行在软件开发中仍然被广泛使用,并且在一些其他地方也是如此:例如,旅行代理人使用的标准界面是命令行界面。

在本课程中,我们将从命令行运行和调试 Kotlin 程序(至少在课程开始时是这样)。

在 Windows 中启动命令行,请按下 Windows+R 键(即按住 Windows 键并按“R”键)。您应该会看到一个文本字段,您可以在其中输入命令。输入 cmd 来启动 Windows 命令行。

(如果您使用的是 Mac OSX 或 Linux,您可以简单地打开“终端”程序。但是,下面列出的许多命令是不同的。例如,您应该说 ls 而不是 dir。)

就像在本教程中一样,请不要只是阅读。现在就试一试。

以下是最重要的命令列表:

  • 显示当前目录中的文件的命令是 dir,

  • 显示当前日期的命令是 date /t,

  • 显示当前时间的命令是 time /t,

  • 显示消息的命令是 echo message,

  • 清屏的命令是 cls,

  • 显示或更改当前目录的命令是 cd,

  • 创建新目录的命令是 mkdir,

  • 显示文件内容的命令是 type,

  • 删除文件的命令是 del,

  • 更改文件名的命令是 rename,

  • 打印最常用命令的帮助命令是 help。

您通常可以通过输入命令名称后跟/?来获取有关某个命令的帮助,例如像这样:

C:\Users\otfried\Documents>rename /?
Renames a file or files.

RENAME [drive:][path]filename1 filename2.
REN [drive:][path]filename1 filename2.

Note that you cannot specify a new drive or path for your destination file.
C:\Users\otfried\Documents>

请立即尝试,并熟悉通过命令行与计算机进行交互。在键入文件名的前一个或两个字符后,您可以使用 Tab 键来完成文件名。您还可以使用上下键来重复以前的命令。

Kotlin 简介

在这一节中,我们快速介绍了 Kotlin 的基本特性。如果你以前有编程经验,无论是在 Python、C 还是 Java 中,这些内容都足够让你入门,并且可以让你编写你的第一个 Kotlin 脚本。(如果你以前从未编程过,则需要先学习基本的编程知识,然后再回来。)

  • 运行 Kotlin

  • 语法

  • Kotlin 使用静态类型

  • val 变量和 var 变量

  • 一些基本数据类型

  • 字符串和字符串插值

  • 编写函数

  • 对对子和三元组

  • 列表

  • 命令行参数

  • 从终端读取

  • when 表达式

  • 一个更大的示例

增量测试

当你写程序时,很容易写完整个程序,然后开始调试它。

一般来说,这是非常糟糕的策略。最好在编写每个函数后立即测试每个函数。

只有第一个函数正确工作后,才继续处理下一个函数。

在 Kotlin 中,我们可以使用交互模式来交互式测试函数。我们在交互模式中使用 :load 命令。同样的命令允许我们在对代码进行更改后重新加载它:

$ ktc
Welcome to Kotlin version 1.0.1-2 (JRE 1.7.0_95-b00)
Type :help for help, :quit for quit
>>> :load triangle.kts
>>> triangle(3)
*
**
***
>>> triangle(5)
*
**
***
****
*****

一个完整的例子:科拉茨问题

让我们以一个例子,所谓的科拉茨问题,来练习增量测试。

考虑遵循以下规则的整数序列:

[ n_{i+1} = \left{ \begin{array}{ll} 3n_i + 1 & \text{如果 \(n_i\) 是奇数}\ n_i/2 & \text{如果 \(n_i\) 是偶数} \end{array} \right. ]

如果提供一个起始值 (n_0),这将确定一个完整的序列。以下是一些示例,起始值为 5、34、7 和 672:

5 16 8 4 2 1
34 17 52 26 13 40 20 10 5 16 8 4 2 1
7 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1
672 336 168 84 42 21 64 32 16 8 4 2 1

你可以注意到所有四个示例序列都达到了数字 1(然后当然序列开始循环:1 4 2 1 4 2 1 4 2 1...)。

有人猜测这总是成立:对于任何起始值,序列都会到达 1。

我们想对这个猜想进行一些实验,例如实验性地确定哪个起始值给出了长链。因此,我们希望编写函数,给定起始值后,可以打印出整个序列,并可以打印出达到 1 之前的步数。

让我们从基本函数开始:给定 (n_i),计算下一个数字 (n_{i+1})。我创建了一个文件 collatz.kts,内容如下:

fun next(n: Int): Int = 
  if (n % 2 == 0)
    n / 2
  else
    3 * n + 1

我们通过加载文件并检查它是否正确处理奇偶情况来测试此函数:

$ ktc
Welcome to Kotlin version 1.0.1-2 (JRE 1.7.0_95-b00)
Type :help for help, :quit for quit
>>> :load collatz.kts
>>> next(1)
4
>>> next(4)
2
>>> next(2)
1
>>> next(5)
16
>>> next(16)
8
>>> next(52)
26
>>> next(123417432)
61708716
>>> next(123417431)
370252294

看起来函数工作正常,所以下一步是编写一个函数,从给定的起始值开始打印整个序列。我们将函数 collatz 添加到我们的文件中:

fun collatz(n0: Int) {
  var n = n0
  while (n != 1) {
    print(n)
    print(" ")
    n = next(n)
  }
}

我重新加载文件并在一个例子上进行测试:

>>> :load collatz.kts
>>> collatz(5)
5 16 8 4 2

然后我注意到我的错误:循环没有打印最终的 1。所以我把我的函数改成了如下形式(collatz.kts):

fun collatz(n0: Int) {
  var n = n0
  while (n != 1) {
    print(n)
    print(" ")
    n = next(n)
  }
  println(1)
}

然后我可以继续通过重新加载文件并重试来进行测试:

>>> :load collatz.kts
>>> collatz(5)
5 16 8 4 2 1
>>> collatz(16)
16 8 4 2 1
>>> collatz(17)
17 52 26 13 40 20 10 5 16 8 4 2 1
>>> collatz(27)
27 82 41 124 62 31 94 47 142 71 214 107 322 161 484 242 121 364 182 91
274 137 412 206 103 310 155 466 233 700 350 175 526 263 790 395 1186
593 1780 890 445 1336 668 334 167 502 251 754 377 1132 566 283 850 425
1276 638 319 958 479 1438 719 2158 1079 3238 1619 4858 2429 7288 3644
1822 911 2734 1367 4102 2051 6154 3077 9232 4616 2308 1154 577 1732
866 433 1300 650 325 976 488 244 122 61 184 92 46 23 70 35 106 53 160
80 40 20 10 5 16 8 4 2 1

现在看起来工作得很好。

下一个函数将计算从给定的起始值开始到达 1 所经历的步骤数:

fun collatzCount(n0: Int): Int {
  var n = n0
  var count = 0
  while (n != 1) {
    n = next(n)
    count += 1
  }
  return count
}

我通过将结果与 collatz 的输出进行比较来测试这一点:

>>> :load collatz.kts
>>> collatz(5)
5 16 8 4 2 1
>>> collatzCount(5)
5
>>> collatz(16); collatzCount(16)
16 8 4 2 1
4

最后,我将编写一个函数,它尝试所有从 2 到给定最大值 (n) 之间的起始值,并报告具有最长序列的起始值(collatz3.kts):

fun findMax(n: Int) {
  var maxCount = 0
  var maxStart = 1
  for (i in 2 .. n) {
    val count = collatzCount(i)
    if (count > maxCount) {
      maxCount = count
      maxStart = i
    }
  }
  println("Starting at $maxStart needs $maxCount steps.")
}

这是测试此函数的结果:

>>> :load collatz3.kts
>>> findMax(100)
Starting at 97 needs 118 steps.
>>> findMax(500)
Starting at 327 needs 143 steps.
>>> findMax(1000)
Starting at 871 needs 178 steps.
>>> findMax(2000)
Starting at 1161 needs 181 steps.
>>> findMax(4000)
Starting at 3711 needs 237 steps.
>>> findMax(10000)
Starting at 6171 needs 261 steps.
>>> findMax(20000)
Starting at 17647 needs 278 steps.
>>> findMax(40000)
Starting at 35655 needs 323 steps.
>>> findMax(80000)
Starting at 77031 needs 350 steps.

单元测试

对于大型程序,通常为每个函数或类编写一个单独的测试程序是很常见的。这些测试程序称为单元测试。

有些程序员甚至在编写代码之前就编写测试!

即使程序完成后,单元测试仍然很有用。所有软件都需要维护。每当对软件进行更改时,我们可以再次运行单元测试,以确保我们没有破坏任何东西。

在许多软件项目中,单元测试是自动化的,并且每晚运行,以确保白天进行的更改没有引入任何新的错误。

我们不会要求您在 CS109 中编写单元测试,但您应该养成测试函数的习惯。通常可以通过手动交互式测试。但是,当您需要超过两三行代码来测试一个函数时,编写额外的测试函数是有意义的。将它们保留在您的代码中,这样以后在进行更改时可以再次使用它们来检查您的程序。

数字表示

让我们再看看我的 Collatz 代码。记住,猜想是 Collatz 序列总是以一结束。我们用一些较大的数字来测试这个:

$ ktc
>>> :load collatz3.kts
>>> findMax(100000)
Starting at 77031 needs 350 steps.
>>> findMax(110000)
Starting at 106239 needs 353 steps.
>>> findMax(113000)
Starting at 106239 needs 353 steps.
>>> findMax(114000)

此时程序似乎陷入了无限循环!

但这意味着我们没有达到数字一——我们成功找到了 Collatz 猜想的一个反例吗?让我们尝试找到导致无限序列的起始值。这是一个新的函数来做到这一点(collatz4.kts):

fun collatzBounded(n0: Int, steps: Int): Int {
  var n = n0
  var count = 0
  while (n != 1 && count < steps) {
    n = next(n)
    count += 1
  }
  return count
}

fun findLong(n: Int, steps: Int) {
  for (i in 2 .. n) {
    val count = collatzBounded(i, steps)
    if (count >= steps) { 
      println("Starting at $i needs $count steps.")
    }
  }
}

让我们试试:

>>> :load collatz4.kts
>>> findLong(114000, 1000)
Starting at 113383 needs 1000 steps.
>>> findLong(114000, 10000)
Starting at 113383 needs 10000 steps.
>>> findLong(114000, 100000)
Starting at 113383 needs 100000 steps.
>>> findLong(114000, 1000000)
Starting at 113383 needs 1000000 steps.

从 113383 开始似乎会导致一个无限序列!让我们打印出这个序列中的前几个数字。我在 collatzBounded 中添加了一个打印语句(collatz5.kts):

fun collatzBounded(n0: Int, steps: Int): Int {
  var n = n0
  var count = 0
  while (n != 1 && count < steps) {
    print("$n ")
    n = next(n)
    count += 1
  }
  println()
  return count
}

输出是这样的:

>>> collatzBounded(113383, 200)
113383 340150 170075 510226 255113 765340 382670 191335 574006 287003
861010 430505 1291516 645758 322879 968638 484319 1452958 726479
2179438 1089719 3269158 1634579 4903738 2451869 7355608 3677804
1838902 919451 2758354 1379177 4137532 2068766 1034383 3103150 1551575
4654726 2327363 6982090 3491045 10473136 5236568 2618284 1309142
654571 1963714 981857 2945572 1472786 736393 2209180 1104590 552295
1656886 828443 2485330 1242665 3727996 1863998 931999 2795998 1397999
4193998 2096999 6290998 3145499 9436498 4718249 14154748 7077374
3538687 10616062 5308031 15924094 7962047 23886142 11943071 35829214
17914607 53743822 26871911 80615734 40307867 120923602 60461801
181385404 90692702 45346351 136039054 68019527 204058582 102029291
306087874 153043937 459131812 229565906 114782953 344348860 172174430
86087215 258261646 129130823 387392470 193696235 581088706 290544353
871633060 435816530 217908265 653724796 326862398 163431199 490293598
245146799 735440398 367720199 1103160598 551580299 1654740898
827370449 -1812855948 -906427974 -453213987 -1359641960 -679820980
-339910490 -169955245 -509865734 -254932867 -764798600 -382399300
-191199650 -95599825 -286799474 -143399737 -430199210 -215099605
-645298814 -322649407 -967948220 -483974110 -241987055 -725961164
-362980582 -181490291 -544470872 -272235436 -136117718 -68058859
-204176576 -102088288 -51044144 -25522072 -12761036 -6380518 -3190259
-9570776 -4785388 -2392694 -1196347 -3589040 -1794520 -897260 -448630
-224315 -672944 -336472 -168236 -84118 -42059 -126176 -63088 -31544
-15772 -7886 -3943 -11828 -5914 -2957 -8870 -4435 -13304 -6652 -3326
-1663 -4988 -2494 -1247 -3740 -1870 -935 -2804 -1402 -701 -2102 -1051
-3152 -1576 -788 -394 200

这是什么?为什么有负数?最后一个正数是 827370449,所以让我们看看发生了什么:

>>> next(827370449)
-1812855948

哎呀!我们下一个函数的定义是错的吗?

>>> 827370449 * 3
-1812855949

不,看来 Kotlin 的算术出了问题!

原来你必须小心处理 Int 整数对象。Int 整数有 32 位,因此最大可能的 Int 值是 (2^{31} - 1)。你也可以将这个最大值表示为 Int.MAX_VALUE。但 (3 \cdot 827370449) 比这个值大。我们可以通过长算术来计算该值进行检查。Long 是具有 64 位的整数。你可以使用 toLong() 方法将整数转换为 Long,或者在字面整数后面简单地写一个 L:

>>> next(827370449)
-1812855948
>>> 827370449 * 3
-1812855949
>>> Int.MAX_VALUE
2147483647
>>> 827370449 * 3
-1812855949
>>> 827370449.toLong() * 3
2482111347
>>> 827370449L * 3
2482111347
>>> Int.MAX_VALUE
2147483647
>>> Long.MAX_VALUE
9223372036854775807

数字是如何表示的

整数使用固定数量的位表示,如下所示:

Long 64 位
Int 32 位
Short 16 位
字节 8 位

对这些类型的算术是通过使用固定长度的寄存器的硬件执行的。例如,想象一下我们有一种具有四位的整数类型,并且我们执行一些加法:

 0010 = 2
+0111 = 7
---------
 1001 = 9

 1001 = 9
+0111 = 7
---------
 0000 = 0

由于寄存器只有四位,当你相加 (9 + 7) 时发生的溢出无法表示,结果不是 (10000 = 16) 而是 (0000 = 0)。

换句话说,整数加法和减法实际上是模 (2^{k}) 加法和减法,其中 (k) 是位数。对于我们的四位数据类型,加法是模 16,因此 (9 + 7 = 0)。

这实际上很方便,因为它允许我们使用负数而不需要任何额外的硬件:例如,由于 (-1 \equiv 15)(模 16),我们可以通过添加 (15) 来减去一个数:

 0101 = 5
+1111 = 15 = -1
---------------
 0100 = 4

 0111 = 7
+1100 = 12 = -4
---------------
 0011 = 3

当结果为 (1111) 时,如何确定输出表示什么数字。当结果为 (1111) 时,是表示 (15) 还是表示 (-1)?

标准惯例是说当第一位是一时,数字为负。换句话说,(1000 ... 1111) 是 (-8) 到 (-1),而 (0000 ... 0111) 是 (0) 到 (7):

0111 = 7
0110 = 6
0101 = 5
0100 = 4
0011 = 3
0010 = 2
0001 = 1
0000 = 0
1111 = -1
1110 = -2
1101 = -3
1100 = -4
1011 = -5
1010 = -6
1001 = -7
1000 = -8

这很方便,因为检测一个数字是否为负数非常容易,但这只是一种约定。一些编程语言(如 C 和 C++)也有无符号整数,其中(0000 ... 1111)表示(0)到(15)。原则上,我们也可以说(1100 ... 1111)表示(-4)到(-1),而(0000 ... 1011)表示(0)到(11)。

在我们上面的例子中,我们进行了乘法运算(3 * 827370449 = 2482111347)。在二进制中,这是

11 * 00110001010100001010101111010001 = 
     10010011111100100000001101110011

结果的第一位是(1),因此被视为负数。

您可以在这里了解更多关于数字表示的信息。

数据类

对象是面向对象编程的基础。在 Kotlin 中,每个数据都是一个对象。每个对象都有一个类型,比如 Int、Double、String、Pair、List或 MutableList。对象的类型决定了你可以对对象做什么。当你使用一个对象时,你应该把对象看作一个黑盒子,你不需要知道对象内部发生了什么。你只需要知道对象做了什么,而不需要知道它是如何实现功能的。

一个类定义了一个新类型的对象。你也可以把一个类看作是对象的“蓝图”。一旦你定义了一个类,你就可以根据蓝图创建对象。

数据类

类的常见用途是定义具有多个属性的对象。例如:

  • 平面上的一个点有一个(x)-坐标和一个(y)-坐标。根据应用程序的不同,坐标可以是整数,也可以是浮点数。

  • 一个日期有年、月和日。

  • 一个学生对象至少有一个姓名、一个学号和一个专业。

  • 一张扑克牌(比如在黑杰克中)有一个花色(梅花、黑桃、红心或方块)和一个点数(2,3,…,10, J, Q, K, A)。

    一副扑克牌

这样一个简单的类被实现为一个数据类:

>>> data class Point(val x: Int, val y: Int)

Point 表示一个二维点。它有两个字段,即 x 和 y。我们可以这样创建 Point 对象:

>>> var p = Point(2, 5)
>>> p
Point(x=2, y=5)

注意到 Point(2, 5)看起来像一个函数调用,实际上它是对 Point 类构造函数的调用。

一旦我们有了一个 Point 对象,我们可以使用点语法访问它的字段:

>>> p.x
2
>>> p.y
5

我们也可以使用 println 打印 Point 对象。

>>> println(p)
Point(x=2, y=5)

我们可以使用==和!=比较两个 Point 对象。只有当它们的所有字段都相等时,两个点才相等。


>>> val q = Point(7, 19)
>>> val r = Point(2, 5)
>>> p == r
true
>>> p == q
false
>>> p != q
true

现在让我们看看上面的其他示例:一个日期对象可以这样定义:

>>> data class Date(val year: Int, val month: Int, val day: Int)
>>> val d = Date(2016, 4, 23)
>>> d.month
4
>>> d.day
23

一个学生对象可能看起来像这样:

>>> data class Student(val name: String, val id: Int, val dept: String)
>>> val s = Student("Otfried", 13, "CS")
>>> s.id
13

一个黑杰克卡片对象可能看起来像这样:

>>> data class Card(val face: String, val suit: String)
>>> val c = Card("Ace", "Diamonds")
>>> c.suit
Diamonds
>>> println(c)
Card(face=Ace, suit=Diamonds)

不可变和可变对象,引用和堆

在构造后状态无法更改的对象称为不可变(不可更改)。不可变对象的方法不会修改对象的状态。在 Kotlin 中,所有数字类型、字符串和元组都是不可变的。我们上面定义的 Point、Date、Student 和 Card 类都是不可变的。

实际上,如果我们尝试更改 Point 的坐标,我们会收到一个错误:

>>> p.x = 7
java.lang.IllegalAccessError: tried to access field ...

换句话说,一旦创建了一个 Point 对象,其字段就不能被修改。

可以定义一个可变的 case 类:我们需要在字段名称前面加上 var 关键字:

>>> data class MPoint(var x: Int, var y: Int)
>>> val p = MPoint(3, 5)
>>> p
MPoint(x=3, y=5)
>>> p.x = 7
>>> p
MPoint(x=7, y=5)

请注意,即使我们将 p 定义为 val 变量,我们仍然可以更改点 p 的(x)-坐标。请记住,这只意味着名称 p 将始终指向相同的对象。可以更改此对象内部的字段。

可变对象可能导致棘手的错误。考虑以下代码:

>>> val p = MPoint(3, 5)
>>> val q = p
>>> q.x = 7
>>> q
MPoint(x=7, y=5)

此时的 p 的值是多少?令人惊讶的是,p 也发生了变化:


>>> p
MPoint(x=7, y=5)

MutableList 对象当然是可变的,因此它们也可能出现相同的效果:

>>> val a = mutableListOf(1, 2, 3, 4)
>>> val b = a
>>> a[2] = 99
>>> b
[1, 2, 99, 4]

(再次注意,即使我们已将 a 定义为 val 变量,仍然可以更改 a 的内容。)

引用和堆

为什么会发生这种情况?要理解这一点,我们需要了解变量如何存储对象。

所有对象都存储在运行时系统的一个区域中,称为堆。对象不能存在于其他任何地方。

变量只是堆上对象的名称。您可以将变量视为对堆上对象的引用。该引用唯一指示堆上的对象。(如果您学过 C,可以将此引用视为指针。实际上,它可能并不真正是内存地址。)

赋值操作(如上面的val q = pval b = a)在堆上为对象创建一个新名称。p 和 q 实际上是同一个 MPoint 对象的两个不同名称,a 和 b 是同一个 MutableList 对象的两个名称:

具有多个名称的对象

对于不可变对象,这种问题永远不会发生,因此最好在可能的情况下使用不可变对象。

局部变量

现在我们知道所有对象都存储在堆中,您可能想知道变量名称,即引用,存储在哪里。

作为对象字段(或列表中的元素)的引用存储在堆中的该对象内部。

大多数其他引用都是某个函数或方法的局部变量。它们存储在称为激活记录或函数的堆栈帧的一部分内存中。激活记录在每次调用函数时都会自动创建。例如,这个函数

fun test(m: Int) {
  val k = m + 27
  val s = "Hello World"
  val a = listOf( s.length, k, m )
}

有四个局部变量,分别是 m、k、s 和 a。(方法的参数是局部变量,唯一的区别是运行时系统在调用方法时会自动将参数值复制到变量中。)

下面显示了在调用 test(13) 时的 test 激活记录和堆,就在函数返回之前:

测试结果(13)

垃圾回收

Kotlin 对象是由垃圾回收的:如果运行时系统内存不足,它将检查堆上的所有对象。如果一个对象不再有任何指向它的引用,那么这个对象就不再有用,将被删除。很难预测垃圾回收会在何时发生。如果只运行一个小程序,则可能根本不会发生垃圾回收。

垃圾回收使程序员不必担心内存管理。还有其他一些语言不提供自动垃圾回收。例如,在 C++ 中,程序员负责内存管理。C 或 C++ 程序经常出现错误,即创建但从未销毁对象,因此越来越多的未使用和无法使用的对象填满堆。这样的程序被称为含有内存泄漏。

随机数生成

在游戏中,我们经常需要生成随机数。我们首先在全局变量中设置一个随机数生成器:

  val random = java.util.Random()

然后,要在范围 0 到 k-1 内生成一个随机整数,我们调用

  val random_number = random.nextInt(k)

你也可以使用随机数生成器在范围(0.0 \leq x < 1.0)内生成一个随机的双精度数。

  val x = random.nextDouble()

数组和二维数组

数组是存储许多元素的最基本数据类型,并且直接由虚拟机实现(相比之下,(可变)列表是在库中实现的,使用数组来存储元素)。

你可以将数组看作是一个固定大小的可变列表,即你无法添加或删除元素。一旦创建,数组的大小保持不变。

像列表一样,数组可以通过列出元素来创建:

>>> val a = arrayOf(1, 2, 3, 4, 5)

它们支持大多数可用于可变列表的方法:

>>> a.size
5
>>> a[0] = 99
>>> a[3]
4
>>> a.joinToString()
99, 2, 3, 4, 5
>>> println(a)
[Ljava.lang.Integer;@16d48c9

请注意,直接打印一个数组不会像列表或可变列表那样漂亮地列出元素。

当你想要创建一个大数组,或者一个大小你已经计算过的数组时,你无法列出初始元素。相反,你提供元素的数量,以及一个计算元素值的代码片段。这段代码可以使用魔术变量 it,它给出元素的索引:

>>> val a = Array(10) { 0 }
>>> a.joinToString()
0, 0, 0, 0, 0, 0, 0, 0, 0, 0
>>> val b = Array(13) { it * it }
>>> b.joinToString()
0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144
>>> val s = Array(8) { "" }
>>> s.joinToString()
, , , , , , , 

通常没有充分的理由使用数组而不是(可变)列表。然而,提供 Kotlin 脚本的命令行参数的 args 变量实际上是一个数组,类型为 Array

二维数组

另一个使用数组的机会是当你需要一个二维存储区域时,比如在游戏中表示一个棋盘。一个二维数组使用两个索引,通常称为行和列,来访问(m \times n)个元素。它通过为每一行创建一个数组(这些数组由列号索引),然后创建一个数组(由行号索引)来存储行来实现。(你也可以用列表做同样的事情,但会浪费相当多的存储空间。)

以下示例创建了一个有 5 行 8 列的二维数组,填充为零:

>>> val b = Array(5) { Array(8) { 0 } }

b 的类型是 Array<Array>。我们使用行号和列号访问它的元素:

>>> b[2][7] = 9
>>> b[0][5] = 13

由于 b 本身是存储行的数组,我们可以通过 b.size 获得行数。列数是 b 的每个元素的长度,比如 b[0].size:

>>> b.size
5
>>> b[0].size
8

你需要编写一个函数来漂亮地显示棋盘。对于调试,类似以下的内容已经足够好了:

>>> b.joinToString(separator="\n", transform={ it.joinToString() } )
0, 0, 0, 0, 0, 13, 0, 0
0, 0, 0, 0, 0, 0, 0, 0
0, 0, 0, 0, 0, 0, 0, 9
0, 0, 0, 0, 0, 0, 0, 0
0, 0, 0, 0, 0, 0, 0, 0

可空变量

在 Java 和 Scala 中,类型为(T)的变量要么包含对正确类型对象的引用,要么包含特殊值 null。如果值为 null,则意味着该变量当前不引用任何对象。

程序员使用 null 作为特殊标记,例如表示发生错误,或者无法找到某些请求的信息。当随后的代码不检查这种特殊情况时,会出现问题,因为对具有值 null 的变量调用任何操作都将失败。由于变量不引用任何对象,因此无法调用任何方法!结果是 NullPointerException,这是一个常常难以找到的错误。

Kotlin 通过不允许 Int、String 等类型的变量为 null 来帮助我们避免这个问题。如果尝试将变量设置为 null,编译器会报错:

>>> val s: String = null
error: null can not be a value of a non-null type kotlin.String

有时,确实希望允许 null,要么因为想使用 null 来指示特殊情况或错误,要么因为调用一些使用 null 的 Java 函数。在这种情况下,需要通过在类型后面放置问号来指示变量是可空的:

>>> var s: String? = null
>>> println(s)
null
>>> s = "Hello World"
>>> println(s)
Hello World
>>> s = null
>>> println(s)
null
>>> s = "I'm nullable"
>>> println(s)
I'm nullable

由于 s 的类型是 String?,它允许具有值 null。

然而,每当我们想调用对象 s 的 String 方法时,我们必须小心:如果 s == null,则调用方法会失败。因此,Kotlin 禁止在不先检查 null 的情况下调用可空变量的方法:

>>> s.length
error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type kotlin.String?

我们可以手动测试是否为 null:

>>> fun printlen(s: String?) {
...   if (s == null)
...     println("Null string")
...   else
...     println(s.length)
... }
>>> printlen("Hello")
5
>>> printlen(null)
Null string

请注意编译器认识到在 else 部分 s 的值不可能为 null,因此调用 s.length 是可以的。

Kotlin 提供了一些很好的快捷方式来更轻松地处理可空变量。首先,我们可以使用 ?. 运算符。如果对象存在,则调用方法,否则不调用方法,结果为 null:

>>> s
I'm nullable
>>> s?.length
12
>>> s = null
>>> s?.length
null

如果我们不喜欢返回 null 作为值,可以使用"Elvis operator" ?:. 如果左侧不为 null,则返回左侧,否则返回右侧。现在我们可以将上面的函数 printlen 重写如下:

>>> fun printlen(s: String?) {
...   println(s?.length ?: "Null string")
... }
>>> printlen("Hello world")
11
>>> printlen(null)
Null string

最后,有时你有一个类型为 String?的变量,但你知道(因为文档或者因为是你仔细分析过的自己的代码)该变量永远不会为 null。在这种情况下,你可以向编译器保证一切正常:

>>> val s: String? = "Hello World"
>>> val t: String = s
error: type mismatch: inferred type is kotlin.String? but kotlin.String was expected
>>> val t : String = s!!

第一个赋值失败,因为 s 的类型为 String?,因此可能为 null,因此不允许将其赋值给 t(类型为 String)。在第二个赋值中,我使用 !! 运算符向编译器保证一切正��。

!! 运算符也可以用于在你确信变量不为 null 时调用方法:

>>> s.length
error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type kotlin.String?
>>> s!!.length
11

如果你的承诺是错误的,而实际上 s 为 null,那么在这一点上将会发生异常:

>>> var s: String? = null
>>> s!!.length
kotlin.KotlinNullPointerException

一个返回可空类型的标准 Kotlin 函数的例子是 readLine():它返回 String?,即输入字符串,或者当输入结束时为 null(例如因为你正在从文件重定向输入)。

下面的简短脚本展示了这一点(reverse.kts):

fun reverser() {
  var line: String? = readLine()
  while (line != null) {
    println(line.reversed())
    line = readLine()
  }
}

println("Enter lines to be reversed:")
reverser()

如果我们在从脚本文件本身重定向输入的情况下运行脚本,它会在最后一行正确地停止:

$ kts reverse.kts < reverse.kts 
Enter lines to be reversed:
{ )(resrever nuf
)(eniLdaer = ?gnirtS :enil rav  
{ )llun =! enil( elihw  
))(desrever.enil(nltnirp    
)(eniLdaer = enil    
}  
}

)":desrever eb ot senil retnE"(nltnirp
)(resrever

集合

集合是用于存储元素集合的数据类型。集合中没有顺序,并且所有元素必须是不同的(因此不能在集合中有多个相同元素的副本)。换句话说,集合是 Kotlin 中集合数学概念的实现。

集合在许多问题中自然而然地出现:拼写正确的单词形成一个集合。质数形成一个集合。

当然,我们可以简单地使用列表(或数组)来存储形成集合的项目集合。但这不是自然的,通常也不高效:列表是一个索引序列,其中元素具有排序,同一个元素可能出现多次,并且只能通过逐个查看所有元素来搜索元素。

幸运的是,Kotlin 使用的 Java 标准库为我们提供了一个非常好的集合数据类型。更准确地说,集合是一个参数化数据类型,因此有 Set、Set 等。

让我们创建一些集合:

>>> val s = setOf(2, 3, 5, 7, 9)
>>> s
[2, 3, 5, 7, 9]
>>> val w = setOf("CS109", "is", "wonderful")
>>> w
[CS109, is, wonderful]
>>> val e = emptySet<String>()
>>> e
[]

请注意,对于空集,您必须指示元素的类型,因为 Kotlin 无法从元素本身推断出类型。您还可以通过使用它们的 toSet() 方法将其他集合(列表、数组、范围)转换为集合。

请记住,您写入元素的顺序并不重要,一个元素不能出现多次:

>>> val s2 = setOf(9, 9, 5, 7, 3, 5, 3, 2)
>>> s == s2
true
>>> val w2 = setOf("wonderful", "is", "CS109")
>>> w == w2
true

  • 和 - 运算符可用于向集合添加元素,并从集合中移除元素。结果是一个新的集合。可以添加已经在集合中的元素,也可以移除不在集合中的元素。
>>> s + 11
[2, 3, 5, 7, 9, 11]
>>> s - 7
[2, 3, 5, 9]
>>> s - 6
[2, 3, 5, 7, 9]
>>> s + 7
[2, 3, 5, 7, 9]
>>> s + 7 == s
true

集合的标准数学操作——并集、交集、差集和包含关系——可以(大多数情况下)使用集合方法实现:

  • (|s|) 是 s.size

  • (s \cup t) 是 s + t

  • (s \setminus t) 是 s - t

  • (x \in s)? 是 s.contains(x) 或 x in s

  • (s \subseteq t)? 是 t.containsAll(s)

  • (s \cap t) 没有方法,但可以实现为 s.filter { it in t }。

以下是一些例子:

>>> val a = (1 .. 10).toSet()
>>> a
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> val b = (1 .. 10 step 2).toSet()
>>> b
[1, 3, 5, 7, 9]
>>> val c = (1 .. 5).toSet()
>>> c
[1, 2, 3, 4, 5]
>>> a.containsAll(b)
true
>>> b.containsAll(a)
false
>>> a.containsAll(c)
true
>>> a - b
[2, 4, 6, 8, 10]
>>> b + c
[1, 3, 5, 7, 9, 2, 4]
>>> b.filter { it in c }
[1, 3, 5]

集合支持许多其他操作,其中一些您已经从列表中熟悉,例如:

  • s.size 是集合的大小;

  • s.isEmpty() 与 s.size == 0 相同;

  • s.isNotEmpty() 与 s.size != 0 相同;

  • s.max()、s.min()、s.sum() 返回集合中元素的最大值、最小值和总和;

  • s.sorted() 返回一个 s 中元素按排序顺序排列的列表;

  • s.joinToString() 返回一个将 s 的所有元素连接在一起的字符串(具有与列表相同的选项);

  • s.toList() 和 s.toMutableList() 返回与集合相同的元素的(可变)列表。

作为使用集合的第一个示例,这里是一个简单的拼写检查器。它使用了包含 113809 个英语单词的文件 words.txt,每行一个单词。

我们读取文件并立即将其转换为集合。然后我们允许用户从终端输入单词。我们检查单词是否在拼写正确的单词集合中,并报告这一点(spell.kts)。

import org.otfried.cs109.readString

val fname = "words.txt"

val words = java.io.File("words.txt").useLines { it.toSet() }

while (true) {
  val w = readString("Enter a word> ").trim()
  if (w == "")
    break
  if (w in words) 
    println("$w is a word")
  else
    println("Error: $w is not a word")
}

这里是一个示例运行:

$ kts spell.kts
Enter a word> lovely
lovely is a word
Enter a word> lovly
Error: lovly is not a word
Enter a word> wierd
Error: wierd is not a word
Enter a word> weird
weird is a word
Enter a word> supercede
Error: supercede is not a word
Enter a word> supersede
supersede is a word
Enter a word> 

你也可以使用集合来衡量两个文本之间的相似性(例如对网络文档进行分类)。考虑每个文本的单词集合,并比较它们的并集大小与交集大小。

可变集合

我们上面看到的所有集合都是不可变的:没有办法改变集合的内容。集合操作如并集和交集实际上会返回一个新的集合对象。

有时使用可变集合更有效或更方便(但请记住这些更危险)。Java 标准库提供了一个可变集合数据类型,在 Kotlin 中称为 MutableSet。我们使用 add 添加元素,并使用 remove 删除元素:

>>> val s = mutableSetOf(1, 2, 3, 4)
>>> s
[1, 2, 3, 4]
>>> s.add(9)
true
>>> s
[1, 2, 3, 4, 9]
>>> s.add(13)
true
>>> s
[1, 2, 3, 4, 9, 13]
>>> s.remove(2)
true
>>> s
[1, 3, 4, 9, 13]
>>> s.remove(12)
false
>>> s
[1, 3, 4, 9, 13]

集合的一个经典应用是埃拉托斯特尼筛法来计算质数。这里是一个实现(sieve.kts):

fun sieve(n: Int): Set<Int> {
  var s = (2 .. n).toMutableSet()
  val sqrtn = Math.sqrt(n.toDouble()).toInt()
  for (i in 2 .. sqrtn) {
    if (i in s) {
      var k = i * i
      while (k <= n) {
      	s.remove(k)
	k += i
      }
    }
  }
  return s
}

val num = if (args.size == 1) args[0].toInt() else 1000

val primes = sieve(num)

for (i in primes)
  print("$i ")

println()

运行的输出:

$ kts sieve.kts 500
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97
101 103 107 109 113 127 131 137 139 149 151 157 163 167 173 179 181
191 193 197 199 211 223 227 229 233 239 241 251 257 263 269 271 277
281 283 293 307 311 313 317 331 337 347 349 353 359 367 373 379 383
389 397 401 409 419 421 431 433 439 443 449 457 461 463 467 479 487
491 499

集合还有许多其他应用。例如,在迷宫中找路时,你可以将已经看到的位置存储在一个集合中。同样地,在实现电脑游戏时,你可以将已经评估过的游戏位置存储在一个集合中。

异常

如果你和我一样,你会经常看到你的程序以错误或异常消息终止。以下是一些异常消息的示例:

>>> val a = 3
>>> a / 0
java.lang.ArithmeticException: / by zero
>>> val s = "abc"
>>> s.toInt()
java.lang.NumberFormatException: For input string: "abc"
>>> java.io.File("test.txt").forEachLine { println(it) }
java.io.FileNotFoundException: test.txt (No such file or directory)
>>> val s = Array<Int>(100000000) { 0 }
java.lang.OutOfMemoryError: Java heap space

错误和异常

诸如 OutOfMemoryError 之类的错误表示严重的失败,继续程序毫无意义。

然而,其他异常仅仅表示程序中的一个意外或异常条件。例如,程序输入数据中的错误可能会导致异常。这些错误可以被处理:我们说异常被处理或捕获。

例如,NumberFormatException 可能表示用户输入了一个不正确的数字,正确的响应是打印一个错误消息并要求新的输入。

FileNotFoundException 意味着我们尝试打开的文件不存在。根据情况,正确的响应可能是尝试不同的文件名,要求用户提供不同的文件名,或者简单地跳过读取文件。

捕获异常

以下代码要求用户输入一个数字。readString 函数返回一个字符串,因此我们必须使用 toInt()方法将其转换为整数。如果字符串不是一个数字,比如"abc"或"123ab",那么 toInt()方法会抛出一个异常。我们可以通过将关键部分放在 try 块中,并添加一个 catch 块来处理我们感兴趣的异常来捕获异常(catch1.kts):

import org.otfried.cs109.readString

val str = readString("Enter a number> ")

try {
  val x = str.toInt()
  println("You said: $x")
} 
catch (e: NumberFormatException) {
  println("'$str' is not a number")
}

如果 try 块正常执行,则 catch 子句将被跳过。但是,如果在 try 块内的某个地方(包括直接或间接调用的任何方法)抛出异常,则 try 块的执行立即停止,并在第一个与异常匹配的 catch 子句中继续执行。这里,“匹配”意味着异常与 case 中列出的异常类型相同。

catch 块中的代码称为异常处理程序。

在我们上面的示例中,如果字符串 str 不代表一个整数(例如,如果它是"abc"),那么 str.toInt 会抛出 NumberFormatException 异常。try 块被终止(特别是,没有值被赋给 x),并且执行继续在 NumberFormatException 的 catch 子句中。以下是一些示例运行:

$ kts catch1.kts
Enter a number> 17
You said: 17
$ kts catch1.kts
Enter a number> abc
'abc' is not a number

异常与错误代码

老的编程语言如 C 没有异常,因此所有错误或异常情况都需要通过错误代码来处理。在 C++中,错误代码也仍然广泛使用,例如为了与 C 兼容。

str.toInt()这样简单而优雅的方法在没有异常的情况下是不可能的。我们将不得不返回两个结果:一个布尔值来指示转换是否成功,以及 Int 值本身。

因此,异常使我们能够集中精力于str.toInt()的基本含义:它接受一个字符串,并返回一个数字。但是异常的真正威力只在下一节中显现…

深入了解异常

异常的好处是你也可以捕获在 try 块中调用的函数内部抛出的异常。

回到我们的数字转换示例,这里是一个在单独函数中转换字符串的版本(catch2.kts):

fun test(s: String): Int = (s.toDouble() * 100).toInt()

fun show(s: String) {
  try {
    println(test(s))
  }
  catch (e: NumberFormatException) {
    println("Incorrect input")
  }
}

函数 test(s)将字符串转换为双精度浮点数,然后将其四舍五入到两位小数并返回整数。

当发生转换错误时,这发生在 test(s)内部,但我们仍然可以在 show(s)函数中捕获这个错误:

>>> :load catch2.kts
>>> show("123.456")
12345
>>> show("123a456")
Incorrect input

当异常发生时(我们说异常被抛出),正常的执行流程会被中断,然后在最近(最内层,最近的)捕获块继续,其中捕获了这种类型的异常(也就是说,有一个正确类型的异常处理程序)。

让我们更详细地看一下,并考虑以下程序(except1.kts):

import org.otfried.cs109.readString

fun f(n: Int) {
  println("Starting f($n) ... ")
  g(n)
  println("Ending f($n) ... ")
}

fun g(n: Int) {
  println("Starting g($n) ... ")
  val m = 100 / n
  println("The result is $m")
  println("Ending g($n) ... ")
}

fun main() {
  while (true) {
    val s = readString("Enter a number> ")
    if (s == "")
      return
    try {
      println("Beginning of try block")
      val n = s.toInt()
      f(n)
      println("End of try block")
    }
    catch (e: NumberFormatException) {
      println("Please enter a number!")
    }
    catch (e: ArithmeticException) {
      println("I can't handle this value!")
    }
  }
}

main()

这是一个运行示例:

$ kts except1.kts 
Enter a number> 25
Beginning of try block
Starting f(25) ... 
Starting g(25) ... 
The result is 4
Ending g(25) ... 
Ending f(25) ... 
End of try block
Enter a number> 0
Beginning of try block
Starting f(0) ... 
Starting g(0) ... 
I can't handle this value!
Enter a number> abc
Beginning of try block
Please enter a number!
Enter a number>

对于输入值"25",我们看到 try 块的开始和结束以及函数 f 和 g。对于输入值"abc",toInt 方法抛出异常,因此不调用 f。对于输入值"0",函数 g 内部的除法抛出 ArithmeticError。正如你所看到的,执行立即在异常处理程序中继续,而不是完成函数 g、f 或 try 块。

抛出异常

到目前为止,我们只捕获了在某些库函数内部抛出的异常。但是你也可以自己抛出异常。例如,假设我们的函数 g(n)应该只处理非负数。如果参数为负数,我们可以通过抛出 IllegalArgumentException 来确保这一点。整个脚本现在看起来像这样(except2.kts):

import org.otfried.cs109.readString

fun f(n: Int) {
  println("Starting f($n) ... ")
  g(n)
  println("Ending f($n) ... ")
}

fun g(n: Int) {
  println("Starting g($n) ... ")
  if (n < 0)
    throw IllegalArgumentException()
  println("The value is $n")
  println("Ending g($n) ... ")
}

fun main() {
  while (true) {
    val s = readString("Enter a number> ")
    if (s == "")
      return
    try {
      println("Beginning of try block")
      val n = s.toInt()
      f(n)
      println("End of try block")
    }
    catch (e: NumberFormatException) {
      println("Please enter a number!")
    }
    catch (e: IllegalArgumentException) {
      println("I can't handle this value!")
    }
  }
}

main()

请注意,异常是对象,并且像任何其他对象一样通过调用它们的构造函数创建。

再次,我们用不同的输入运行它:

$ kts except2.kts 
Enter a number> 25
Beginning of try block
Starting f(25) ... 
Starting g(25) ... 
The value is 25
Ending g(25) ... 
Ending f(25) ... 
End of try block
Enter a number> abc
Beginning of try block
Please enter a number!
Enter a number> -17
Beginning of try block
Starting f(-17) ... 
Starting g(-17) ... 
I can't handle this value!

异常通常用于检测输入数据中的错误。

我们可以在程序中适当的位置捕获异常并打印错误消息,或以其他方式处理问题。

当你调试程序时,可能会困惑于某些异常的来源。在这种情况下,使用异常对象的 printStackTrace()方法可能很有用。它会打印出导致异常抛出的方法链。

如果我们将主函数更改如下(except3.kts):

fun main() {
  while (true) {
    val s = readString("Enter a number> ")
    if (s == "")
      return
    try {
      println("Beginning of try block")
      val n = s.toInt()
      f(n)
      println("End of try block")
    }
    catch (e: NumberFormatException) {
      println("Please enter a number!")
    }
    catch (e: IllegalArgumentException) {
      e.printStackTrace()
    }
  }
}

那么我们可以看到这个过程:

$ kts except3.kts 
Enter a number> 35
Beginning of try block
Starting f(35) ... 
Starting g(35) ... 
The value is 35
Ending g(35) ... 
Ending f(35) ... 
End of try block
Enter a number> -17
Beginning of try block
Starting f(-17) ... 
Starting g(-17) ... 
java.lang.IllegalArgumentException
	at Except3.g(except3.kts:16)
	at Except3.f(except3.kts:9)
	at Except3.main(except3.kts:29)
	at Except3.<init>(except3.kts:41)
        ... many omitted lines ...
Enter a number> abc
Beginning of try block
Please enter a number!
Enter a number> 

我们可以看到 IllegalArgumentException 是在函数 g(脚本的第 16 行)中抛出的,该函数被函数 f 调用,后者又被主函数调用。

断言

断言是在程序执行过程中测试的条件。如果条件为真,则不会发生任何特殊情况。但是,如果条件为假,则会抛出 AssertionError 异常。该语句:

assert(condition)

如果条件为假,则抛出 AssertionError。

您还可以在断言中包含一条消息。

断言的目的是检测程序中的错误。(与上面使用异常的目的相比,异常的目的是检测输入数据中的错误。)

考虑以下代码:

  ... code A computing string s ...
  assert(s.nonEmpty(), "s is empty!")
  ... code B using string s ...

如果代码 A 正确,则由 A 计算得到的字符串 s 不能是空的。我们通过断言验证这一点确实是真的,即 s 不为空。

此断言的目的是保护代码 B。如果没有断言,可能会发生以下情况:代码 A 中存在错误,因此 s 为空。这会导致代码 B 中发生一些奇怪的崩溃,因此我们开始调试代码 B。有了断言,立即清楚问题出在代码 A。

因此,断言的目的是保护代码片段免受彼此的影响,并隔离问题。

Require

require(condition) 语句是断言的一种特殊形式。它的工作方式与 assert 完全相同,但如果条件为假,则会抛出 IllegalArgumentException。

当您需要一个断言来测试方法的参数是否正确时很有用:在这种情况下,您应该使用 require(condition) 而不是 assert(condition)。

require 的目的是保护您的函数免受使用非法参数调用的影响。如果没有它,您可能会花很长时间尝试调试函数,而实际上问题是由于给函数传递的参数值不正确引起的。

读取和写入文件

在读取或写入文件时,很多问题可能会出现。文件可能不存在,我们可能没有写入权限,硬盘可能已满,或者有人可能弹出我们正在读取的光盘。这意味着任何进行文件输入/输出的严肃代码都需要考虑捕获异常。

以下简单的脚本会打印文本文件中的所有行以及每行的长度。如果文件不存在或者您没有读取文件的权限,将会抛出异常。您可以捕获异常,打印错误消息,并继续执行,而不是让程序崩溃(read1.kts):

val fd = java.io.File("project.txt")

try {
  fd.forEachLine {
    println("${it.length} $it")
  }
} 
catch (e: java.io.FileNotFoundException) {
  println("Project file does not exist!")
}
catch (e: java.io.IOException) {
  println("Error reading project file!")
}

如果 forEachLine 方法无法打开文件,则会抛出 FileNotFoundException。循环不会被执行;执行直接跳转到打印消息给用户的异常处理程序。

FileNotFoundException 是 IOException 的一种特殊情况,因此异常符合两个 catch 子句。但是,只有一个 catch 子句会被执行 - 第一个匹配的子句。如果第一个不存在,则会执行第二个 catch 子句。

可能发生的情况是我们可以打开文件,但是forAllLines方法仍然抛出异常(例如,因为磁盘故障)。这通常会生成某种类型的IOException。这将导致第二个捕获子句执行。异常处理程序通常用于从错误中恢复并清理类似打开文件的松散端。

注意,并非每个可能发生的异常都需要一个捕获子句。你可以捕获一些异常,让其他异常传播。

StringBuilder

我们经常希望从小片段构建一个大字符串。例如,考虑列表和集合的 joinToString 方法。让我们为列表实现一个简单版本(join1.kts):

fun join(l: List<Int>): String {
  var s = ""
  for (e in l) {
    if (s.isEmpty())
      s = e.toString()
    else
      s = s + ", " + e.toString()
  }
  return s
}

这是可行的,可以从这个测试中看出:

>>> :load join1.kts
>>> val s = (1..10).toList()
>>> join(s)
1, 2, 3, 4, 5, 6, 7, 8, 9, 10

但是,请记住字符串对象是不可变的。当将另一个数字添加到当前字符串 s 时,必须创建一个全新的字符串,并且所有字符都从旧字符串复制到新字符串中。当列表长度很大时,这可能是一个缓慢的操作。让我们编写一些代码来测量我们的函数 join 的运行时间(join2.kts):

import java.lang.System.currentTimeMillis

fun join(l: List<Int>): String {
  var s = ""
  for (e in l) {
    if (s.isEmpty())
      s = e.toString()
    else
      s = s + ", " + e.toString()
  }
  return s
}

val n = args[0].toInt()
val a = (1 .. n).toList()

val t0 = currentTimeMillis()
val s = join(a)
val t1 = currentTimeMillis()

println("Creating a string with ${a.size} integers took ${t1 - t0} milliseconds")

输出是

$ kts join2.kts 100
Creating a string with 100 integers took 1 milliseconds
$ kts join2.kts 1000
Creating a string with 1000 integers took 3 milliseconds
$ kts join2.kts 10000
Creating a string with 10000 integers took 529 milliseconds
$ kts join2.kts 20000
Creating a string with 20000 integers took 1728 milliseconds
$ kts join2.kts 40000
Creating a string with 40000 integers took 6680 milliseconds
$ kts join2.kts 80000
Creating a string with 80000 integers took 27939 milliseconds

注意代码如何变得越来越慢:将元素数量翻倍会导致运行时大约增加四倍。

要解决这个问题,我们需要一个可变字符串类型:一个我们可以在末尾添加更多字符而不必复制所有内容的对象。Java 库提供了数据类型 StringBuilder,它本质上是一个可变的字符串。一旦字符串完全构建完成,我们可以通过调用它的 toString()方法将其转换为普通字符串。

这是新的 join 函数(join3.kts):

fun join(l: List<Int>): String {
  var s = StringBuilder()
  for (e in l) {
    if (s.isEmpty())
      s.append(e.toString())
    else {
      s.append(", ")
      s.append(e.toString())
    }
  }
  return s.toString()
}

而且这样要快得多:

$ kts join3.kts 80000
Creating a string with 80000 integers took 35 milliseconds
$ kts join3.kts 1000000
Creating a string with 1000000 integers took 113 milliseconds

请注意,即使是一百万个数字现在也不是问题。

StringBuilder 对象支持大多数字符串方法,并且具有一些其他方法,可以将它们转换为可变字符串:

  • s.append(ch)附加字符 ch;

  • s.append(t)附加字符串 t;

  • s.append(n)附加数字 n 的字符串表示形式(可以是任何数字类型,如 Int、Double、Long 等);

  • s.append(x)附加任何对象的字符串表示形式(通过调用它们的 toString()方法);

  • s.delete(i, j)从索引 i(包括)到 j(不包括)删除字符;

  • s.insert(i, t)在索引 i 处插入字符串 t;

  • s.toString()以普通的、不可变的字符串形式返回内容。

映射

什么是映射?

为了比较不同的作者,或在网络搜索中识别出一个好的匹配项,我们可以使用文档的直方图。它包含了所有使用过的单词,以及每个单词被使用的频率。

换句话说,给定一个输入文本,我们想要计算一个映射

[ \textit{words} \rightarrow \mathbb{N} ]将一个单词 (w) 映射到其在文本中出现的次数。

因此,我们需要一个数据类型,它可以存储(单词,计数)对,即(String,Int)对。它应该支持以下操作:

  • 插入一个新的对(给定单词和计数),

  • 给定一个单词,找到当前计数,

  • 更新单词的计数,

  • 枚举容器中的所有对。

此数据类型称为映射或字典。映射实现了从某种键类型到某种值类型的映射。

Java 库提供了一个参数化数据类型 Map<K,V>,其中 K 是键类型,V 是值类型。我们可以将其视为 (K,V) 对的容器。

我们可以像下面这样创建这样一个映射:

>>> val m1 = mapOf(Pair("A", 3), Pair("B", 7))
>>> m1
{A=3, B=7}

与每个映射都要编写 Pair 不同,我们可以使用小型实用函数 to。它仅将两个元素组合成一对,并使得创建映射的语法更加美观:

>>> 23 to 19
(23, 19)
>>> "CS109" to "Otfried"
(CS109, Otfried)
>>> val m = mapOf("A" to 7, "B" to 13)
>>> m
{A=7, B=13}

我们可以使用好看的数学语法 (m[x]) 访问映射键 (x) 由映射 (m) 映射的结果:

>>> m["A"]
7
>>> m["B"]
13
>>> m["C"]
null

请注意,请求不存在于映射中的键将返回 null 值。这意味着映射访问的结果类型实际上是 V?(参见可空类型)。

这意味着您必须先检查结果是否为 null,然后才能对其进行任何操作:

>>> m["B"] + 7
error: infix call corresponds to a dot-qualified call 'm["B"].plus(7)'
which is not allowed on a nullable receiver 'm["B"]'. Use ?.-qualified
call instead

或者,您可以使用 getOrElse 方法:如果键不在映射中,则使用提供的代码计算默认值:

>>> m.getOrElse("B") { 99 }
13
>>> m.getOrElse("C") { 99 }
99
>>> m.getOrElse("B") { 99 } + 7
20
>>> m.getOrElse("C") { 99 } + 7
106

getOrElse 的结果类型是(非可空)值类型 V,因此不需要检查是否为 null。

我们可以使用 in 运算符检查是否为给定键定义了映射:

>>> "C" in m
false
>>> "A" in m
true

您可以通过 m.size 确定映射 m 中的条目数,使用 m.isEmpty() 确定映射是否没有映射,您可以使用 for 循环遍历映射的所有条目:

>>> val m = mapOf("A" to 7, "B" to 13)
>>> m.size
2
>>> for ((k, v) in m)
...   println("$k -> $v")
A -> 7
B -> 13

可变映射

我们经常需要一个可变映射,可以添加、更新和删除映射。我们使用赋值语句的左侧的 m[key] 语法来添加或更改映射。使用 m.remove(key) 删除映射。

>>> val m = mutableMapOf("A" to 7, "B" to 13)
>>> m
{A=7, B=13}
>>> m["C"] = 13
>>> m
{A=7, B=13, C=13}
>>> m.remove("A")
7
>>> m
{B=13, C=13}
>>> m["B"] = 42
>>> m
{B=42, C=13}

另一个有用的方法是 getOrPut。如果给定的键存在于映射中,则从映射中返回该键的值。否则,它执行给定的代码片段,将值存储在映射中(对于给定的键),并返回该值:

>>> m.getOrPut("B") { 99 }
42
>>> m
{B=42, C=13}
>>> m.getOrPut("D") { 99 }
99
>>> m
{B=42, C=13, D=99}

计算单词直方图

这是我的第一个直方图程序尝试(histogram1.kts):

fun histogram(fname: String): Map<String, Int> {
  val file = java.io.File(fname)
  val hist = mutableMapOf<String, Int>()

  file.forEachLine {
    if (it != "") {
      val words = it.split(Regex("[ ,:;.?!<>()-]+"))
      for (word in words) {
      	if (word == "") continue
	val upword = word.toUpperCase()
	hist[upword] = hist.getOrElse(upword) { 0 } + 1
      }
    }
  }
  return hist
}

if (args.size != 1) {
  println("Usage: kotlinc -script histogram1.kts <file name>")
  kotlin.system.exitProcess(1)
}

val fname = args[0]
val hist = histogram(fname)
println(hist)

函数 histogram 创建一个从字符串到整数的空映射 hist。然后它查看文件中的所有单词,将它们转换为大写。使用 getOrElse,我们获取单词的当前映射,如果单词尚未存在,则为零。我们将数字增加一,并将新数字存储在映射中。

当我在文本文件text.txt上运行它时,我得到这个输出:

$ kts histogram1.kts text.txt {WHEN=2, I=3, STARTED=1,
    PROGRAMMING=1, THERE=1, WERE=2, NO=1, GRAPHICAL=2, DISPLAYS=2,
    ALL=1, COMPUTER=7, INPUT=1, AND=2, OUTPUT=2, WAS=5, DONE=1,
    USING=1, TEXT=1, A=9, SHARED=1, BY=4, MANY=1, USERS=2, EACH=1,
    USER=2, CONNECTED=1, TO=3, THE=14, FROM=2, TERMINAL=3, WHICH=1,
    IS=2, CALLED=1, THIS=1, WAY=1, BECAUSE=1, IT=1, ENDPOINT=1, OF=4,
    CONNECTION=1, EARLIEST=1, TERMINALS=1, LOOKED=1, LIKE=1,
    COMBINATION=1, PRINTER=1, WITH=4, KEYBOARD=2, IN=1, MIDDLE=1,
    SCHOOL=1, SOMETIMES=1, ALLOWED=1, PLAY=1, THAT=2, USED=1, SUCH=1,
    PRINTING=2, PRINTERS=1, LATER=1, REPLACED=1, CRT=1, COULD=1,
    TYPICALLY=1, DISPLAY=1, MATRIX=1, 25X80=1, CHARACTERS=2, ASCII=1,
    ALPHABET=1, LETTERS=1, DIGITS=1, SOME=1, SPECIAL=1, INTERACTED=1,
    TYPING=1, COMMAND=2, ON=2, RESPONDED=1}

输出并不是很漂亮,所以我应该使用更好的方式来打印映射。这可以通过迭代映射的内容来完成,如下所示(histogram2.kts):

fun printHistogram(h: Map<String, Int>) {
  for ((word, count) in h)
    println("%20s: %d".format(word, count))
}

输出要好得多:

$ kts histogram2.kts text.txt 
                WHEN: 2
                   I: 3
             STARTED: 1
         PROGRAMMING: 1
               THERE: 1
                WERE: 2
                  NO: 1
           GRAPHICAL: 2
            DISPLAYS: 2
                 ALL: 1
            COMPUTER: 7
               INPUT: 1
                 AND: 2
              OUTPUT: 2
                 WAS: 5
                DONE: 1
               USING: 1
                TEXT: 1
                   A: 9
              SHARED: 1
                  BY: 4
                MANY: 1
               USERS: 2
                EACH: 1
                USER: 2
           CONNECTED: 1
                  TO: 3
                 THE: 14
                FROM: 2
            TERMINAL: 3
               WHICH: 1
                  IS: 2
              CALLED: 1
                THIS: 1
                 WAY: 1
             BECAUSE: 1
                  IT: 1
            ENDPOINT: 1
                  OF: 4
          CONNECTION: 1
            EARLIEST: 1
           TERMINALS: 1
              LOOKED: 1
                LIKE: 1
         COMBINATION: 1
             PRINTER: 1
                WITH: 4
            KEYBOARD: 2
                  IN: 1
              MIDDLE: 1
              SCHOOL: 1
           SOMETIMES: 1
             ALLOWED: 1
                PLAY: 1
                THAT: 2
                USED: 1
                SUCH: 1
            PRINTING: 2
            PRINTERS: 1
               LATER: 1
            REPLACED: 1
                 CRT: 1
               COULD: 1
           TYPICALLY: 1
             DISPLAY: 1
              MATRIX: 1
               25X80: 1
          CHARACTERS: 2
               ASCII: 1
            ALPHABET: 1
             LETTERS: 1
              DIGITS: 1
                SOME: 1
             SPECIAL: 1
          INTERACTED: 1
              TYPING: 1
             COMMAND: 2
                  ON: 2
           RESPONDED: 1

这仍然不完美,因为很难找到我们要找的单词。如果列表是排序的话会更好。我们可以通过将映射转换为排序映射来实现这一点(histogram3.kts):

fun printHistogram(h: Map<String, Int>) {
  val s = h.toSortedMap()
  for ((word, count) in s)
    println("%20s: %d".format(word, count))
}

现在输出变得更好了:

$ kts histogram3.kts text.txt 
               25X80: 1
                   A: 9
                 ALL: 1
             ALLOWED: 1
            ALPHABET: 1
                 AND: 2
               ASCII: 1
             BECAUSE: 1
                  BY: 4
              CALLED: 1
          CHARACTERS: 2
         COMBINATION: 1
             COMMAND: 2
            COMPUTER: 7
           CONNECTED: 1
          CONNECTION: 1
               COULD: 1
                 CRT: 1
              DIGITS: 1
             DISPLAY: 1
            DISPLAYS: 2
                DONE: 1
                EACH: 1
            EARLIEST: 1
            ENDPOINT: 1
                FROM: 2
           GRAPHICAL: 2
                   I: 3
                  IN: 1
               INPUT: 1
          INTERACTED: 1
                  IS: 2
                  IT: 1
            KEYBOARD: 2
               LATER: 1
             LETTERS: 1
                LIKE: 1
              LOOKED: 1
                MANY: 1
              MATRIX: 1
              MIDDLE: 1
                  NO: 1
                  OF: 4
                  ON: 2
              OUTPUT: 2
                PLAY: 1
             PRINTER: 1
            PRINTERS: 1
            PRINTING: 2
         PROGRAMMING: 1
            REPLACED: 1
           RESPONDED: 1
              SCHOOL: 1
              SHARED: 1
                SOME: 1
           SOMETIMES: 1
             SPECIAL: 1
             STARTED: 1
                SUCH: 1
            TERMINAL: 3
           TERMINALS: 1
                TEXT: 1
                THAT: 2
                 THE: 14
               THERE: 1
                THIS: 1
                  TO: 3
           TYPICALLY: 1
              TYPING: 1
                USED: 1
                USER: 2
               USERS: 2
               USING: 1
                 WAS: 5
                 WAY: 1
                WERE: 2
                WHEN: 2
               WHICH: 1
                WITH: 4

地图是如何工作的?

地图是使用哈希表实现的,这允许极快的插入、删除和搜索,但不保持键的任何顺序。(来 CS206 学习哈希表。)

一个发音词典

让我们构建一个真正的“字典”,一个将单词映射到其他单词的字典。在这种情况下,我们想要构建一个将英语单词映射到它们的发音的映射(英语发音是如此不可预测,有一个工具来帮助这将是很好的)。

我们将使用数据文件cmudict.txt。这里有这个文件的一些示例行:

## Date: 9-7-94
##
...
ADHERES AH0 D HH IH1 R Z
ADHERING AH0 D HH IH1 R IH0 NG
ADHESIVE AE0 D HH IY1 S IH0 V
ADHESIVE(2) AH0 D HH IY1 S IH0 V
...

格式大致如下:以#开头的行是注释。其他行以大写字母开头,然后是特定格式的发音(音素之间用空格分隔,我们不会详细介绍)。

一些单词有多个正确的发音,参见上面的“adhesive”。正如您所看到的,额外的发音在���词后的括号中用数字表示。这里是另一个具有三个发音的单词的例子(并且部分不同含义):

MINUTE  M IH1 N AH0 T
MINUTE(2)  M AY0 N UW1 T
MINUTE(3)  M AY0 N Y UW1 T

这里有一个函数,它读取文件并构建映射。它简单地忽略了额外的发音,并且只使用每个单词的第一个发音(请注意,即使它在内部使用可变映射,它返回一个不可变映射):(cmudict1.kts):

fun readPronounciations(): Map<String,String> {
  val file = java.io.File("cmudict.txt")
  var m = mutableMapOf<String, String>()
  file.forEachLine {
    l ->
      if (l[0].isLetter()) {
        val p = l.trim().split(Regex("\\s+"), 2)
        val word = p[0].toLowerCase()
        if (!("(" in word))
	  m[word] = p[1]
      }
  }
  return m
}

这是对函数的一个快速测试:

>>> val m = readPronounciations()
>>> m["minute"]
M IH1 N AH0 T
>>> m["knight"]
N AY1 T
>>> m["night"]
N AY1 T
>>> m["weird"]
W IH1 R D

现在让我们将我们的映射用于一些用途。英语有许多同音词:它们听起来相同,比如“be”和“bee”,或“sewing”和“sowing”。我们想找到一些更多的例子。有多少不同的单词具有相同的发音会是最大的数量?

为了确定这一点,我们需要为相反的方向创建一个字典:将发音映射到单词。由于可能有几个具有相同发音的单词,这将是一个 Map<String, Set>,即从字符串到字符串集合的映射。

我们编写一个通用函数来计算地图的反函数:

fun reverseMap(m: Map<String, String>): Map<String,Set<String>> {
  var r = mutableMapOf<String,MutableSet<String>>()
  for ((word, pro) in m) {
    val s = r.getOrElse(pro) { mutableSetOf<String>() }
    s.add(word)
    r[pro] = s
  }
  return r
}

我们用一些例子进行测试:

>>> val r = reverseMap(m)
>>> r[m["knight"]]
[knight, night, nite]
>>> r[m["weird"]]
[weird]
>>> r[m["minute"]]
[minot, minott, minute, mynatt]
>>> r[m["be"]]
[b, b., be, bea, bee]

现在我们使用反向映射来显示所有至少有一定数量同音词的单词:

fun showHomophones(k: Int) {
  val m = readPronounciations()
  var r = reverseMap(m)
  for ((pro, words) in r) {
    if (words.size >= k) {
      print("$pro (${words.size} words):")
      println("  " + words.joinToString(separator=" "))
    }
  }
}

这是(k = 10)的输出结果:

>>> showHomophones(10)
OW1 (10 words):  au aux eau eaux o o' o. oh ow owe
S IY1 (10 words):  c c. cea cie sci sea see si sie sieh
S IY1 Z (11 words):  c.'s c.s cees saez sea's seas sease sees seese seize sies
K EH1 R IY0 (10 words):  carey carie carrie cary cheri kairey kari kary kerrey kerry
F R IY1 Z (10 words):  freas frease frees freese freeze freis frese friese frieze friis
SH UW1 (11 words):  hsu schoo schou schue schuh shew shiu shoe shoo shu shue
L AO1 R IY0 (11 words):  laurie laury lawrie lawry lorey lori lorie lorrie lorry lory lowrie
M EY1 Z (10 words):  mae's maes mais maize mase may's mayes mays mayse maze
R OW1 (10 words):  reaux rheault rho ro roe roh rohe row rowe wroe

让我们尝试另一个谜题:英语中有些单词如果去掉第一个字母会发音相同:"knight"和"night"就是一个例子。让我们试着找出所有这样的单词:

fun findWords() {
  val m = readPronounciations()
  for ((word, pro) in m) {
    val ord = word.substring(1)
    if (pro == m[ord])
      println(word)
  }
}

这是输出结果:

>>> findWords()
ai
ailes
aisle
aisles
ar
eau
eaux
ee
eerie
eide
eiden
eike
eiler
eiseman
eisenberg
el
em
en
eng
es
eudy
eula
eury
ex
extra
gnats
gnu
herb
hmong
hour
hours
hwan
hwang
knab
knabb
knack
knapp
knapper
knauer
knaus
knauss
knave
kneale
knebel
knee
kneece
kneed
kneel
kneer
knees
knell
kneller
kness
knew
knicely
knick
knicks
knies
kniess
knight
knight's
knightly
knights
knill
knipp
knipper
knipple
knit
knobbe
knoble
knock
knode
knoell
knoles
knoll
knope
knot
knoth
knots
knott
knuckles
knut
knuts
llama
llana
llanes
llano
llewellyn
lloyd
mme
mmonoxide
ngo
ourso
pfahl
pfarr
pfeffer
pfister
pfizer
pfohl
pfund
psalter
psalters
pty
scent
scents
schau
whole
whorton
wrack
wracked
wracking
wrage
wrap
wrapped
wrappers
wrath
wrather
wray
wreck
wrecker
wren
wrench
wrenn
wrest
wresting
wriggle
wright
wright's
wrights
wring
wringer
wringing
wrisley
wrist
wriston
write
writer
writes
wrobel
wroe
wrona
wrote
wroten
wrought
wrubel
wruck
wrung
wrye
yu

你认识这些单词中的多少个?

在英语中有一个单词,你可以去掉第一个字母或第二个字母,它的发音仍然相同。(换句话说,如果整个单词是 XYABCDE,那么 YABCDE 和 XABCDE 的发音与 XYABCDE 相同。)

你能想出这是哪个单词吗?

高阶函数和函数字面量

这是一个计算从 (a) 到 (b) 的整数和的函数(higher1.kts):

fun sumInt(a: Int, b: Int): Int {
  var s = 0
  for (i in a .. b)
    s += i
  return s
}

这里是计算从 (a) 到 (b) 的整数立方和的代码:

fun sumCubes(a: Int, b: Int): Int {
  var s = 0
  for (i in a .. b)
    s += i * i * i
  return s
}

注意 sumInt 和 sumCubes 几乎相同——它们只在每次循环迭代中添加到 s 的表达式上有所不同。

这两个函数是计算表达式的特殊情况

[ \sum_{i=a}^b f(i) ]对于不同选择的函数 (f)。

如果数学有一种表示这种常见表达式的符号,那么计算机科学也应该有。我们应该能够编写一个以函数 (f) 作为参数的函数 sum。

在 Kotlin 中,我们可以这样做:(higher2.kts):

fun sum(a: Int, b: Int, f: (Int) -> Int): Int {
  var s = 0
  for (i in a..b) 
    s += f(i)
  return s
}

注意三个参数的类型:a 和 b 是整数,但 f 有一个更有趣的类型:(Int) -> Int。这种类型表示从整数到整数的函数。一般来说,表示 (A, B, ...) -> R 的符号表示一个接受类型为 A、B 等的参数并返回类型为 R 的结果的函数。

要调用函数 sum,我们需要为参数 f 提供一个参数值。现代编程语言如 Kotlin(以及 Scala、Swift、Java 自 Java 8 以来,以及 C++自 C++11 以来)使得可以定义一个函数而无需给它命名。这被称为函数字面量、匿名函数或 lambda。

注意对于其他基本对象,我们经常定义"无名称"对象:我们不需要为每个字符串或每个整数命名。例如,我们不必写成这样:

>>> val str: String = "Hello CS109"
>>> val a: Int = 13
>>> println(str); println(a)

相反,我们只需写成:

>>> println("Hello CS109"); println(13)

直接写在程序中的对象的值称为字面量。例如,写入 1234 是一个整数字面量,写入"CS109"是一个字符串字面量。

函数字面量的语法包含包含参数的大括号,一个右箭头,以及一些计算结果的代码。例如,将整数提升到其立方的函数写为

{ x: Int -> x * x * x }

计算两个整数的和的函数字面量如下所示:

{ a: Int, b: Int -> a + b }

执行函数字面量的效果是创建一个函数对象(不给它命名):

>>> { x: Int -> x * x * x }
kotlin.jvm.functions.Function1<java.lang.Integer, java.lang.Integer>

一个函数对象存在于堆上,就像其他对象一样。我们可以为其指定一个名称,或将其引用存储在集合或另一个对象中。而且,由于它是一个函数对象,我们可以像调用函数一样调用它。

这里我们创建一个函数对象并立即使用一个参数调用它:

>>> { x: Int -> x * x * x }(3)
27

这里我们为函数对象指定一个名称,并使用它来使用不同的参数调用它:

s
>>> val f = { x: Int -> x * x * x }
>>> f(3)
27
>>> f(7)
343
>>> f(-30)
-27000

这里我们在列表中存储了几个函数对象:

>>> val g = listOf({ x: Int -> x * x },
...                { x: Int -> x * x * x },
...                { x: Int -> x * x * x * x })
>>> g0
4
>>> g1
8
>>> g2
16

回到我们的函数 sum,现在我们可以使用它来计算整数的和以及立方的和:

>>> :load higher2.kts
>>> sum(1, 100, { x: Int -> x } )
5050
>>> sum(1, 100, { x: Int -> x * x * x } )
25502500

实际上,在这种情况下,编译器可以自动确定函数文字中参数的类型。编译器知道 sum 的最后一个参数是类型为 (Int) -> Int 的函数,因此它知道作为参数编写的任何函数文字的类型应该是此类型。因此,我们可以在函数文字中省略类型:

>>> sum(1, 100, { x -> x } )
5050
>>> sum(1, 100, { x -> x * x * x } )
25502500

此外,末尾的括号闭合混乱了一点,因此 Kotlin 有一个很好的约定:如果函数调用中的最后一个参数是函数文字,则可以在函数调用的括号之后写它:

>>> sum(1, 100) { x -> x } 
5050
>>> sum(1, 100) { x -> x * x * x } 
25502500

最后,另一个常规经常简化代码:当函数文字只有一个参数时,我们可以省略参数和右箭头,并在函数文字中使用魔术名称 it 作为参数:(higher3.kts):

>>> sum(1, 100) { it }
5050
>>> sum(1, 100) { it * it * it }
25502500

像 sum 这样的函数被称为高阶函数,因为它们接受另一个函数对象作为参数:高阶函数是一个作用于其他函数的“元函数”。

高阶函数使我们能够自然地表达一个函数是问题输入的一部分的想法,例如:

  • 打印给定函数的函数值表(参见table.kts)。

  • 对函数进行数值积分(参见table.kts)。

  • 寻找函数的不动点。

集合的高阶方法

在处理集合(如数组或列表)时,我们经常使用 for 循环对集合的所有元素执行某些操作:

>>> val C = listOf("Introduction to Programming",
...                "Programming Practice",
...                "Data Structures",
...                "Programming Principles",
...                "Algorithms",
...                "Programming Languages")
>>> C
[Introduction to Programming, Programming Practice, Data Structures, Programming Principles, Algorithms, Programming Languages]
>>> for (e in C)
...   println(e)
Introduction to Programming
Programming Practice
Data Structures
Programming Principles
Algorithms
Programming Languages

此循环的有趣部分是打印语句。现代编程语言使得可以编写集中在这部分而不是循环上的代码。

我们可以使用集合的 forEach 方法而不是编写 for 循环。这是一个高阶方法,即它将函数对象作为其参数。

上面的循环改为:

>>> C.forEach { s: String -> println(s) }
Introduction to Programming
Programming Practice
Data Structures
Programming Principles
Algorithms
Programming Languages

正如我们在前一节中学到的,这可以简化一些,因为 Scala 编译器知道 forEach 的参数必须是类型为(String)-> Unit 的函数对象。因此,我们可以省略参数的类型。

>>> C.forEach { s -> println(s) }
Introduction to Programming
Programming Practice
Data Structures
Programming Principles
Algorithms
Programming Languages

此外,只有一个参数,因此我们可以用魔术参数名 it 替换它。


>>> C.forEach { println(it) }
Introduction to Programming
Programming Practice
Data Structures
Programming Principles
Algorithms
Programming Languages

这里是另一个示例,我们使用 forEach 来计算集合元素的总和:

>>> val l = listOf(15, 39, 22, 98, 37, 19, 5)
>>> l
[15, 39, 22, 98, 37, 19, 5]
>>> var sum = 0
>>> l.forEach { sum += it }
>>> sum
235

再次使用了我们用于函数字面值 x:Int -> sum += x 的快捷语法。

任何,所有,计数和查找

经常需要遍历集合的元素以确定以下属性之一:

  • 集合是否包含具有某种属性的元素?

  • 所有元素都具有该属性吗?

  • 有多少元素具有该属性?

  • 找到具有该属性的元素。

这可以很好地使用高阶方法 any,all,count 和 find 来完成。它们都需要将元素映射到布尔值的函数对象。

以下是一些示例:

>>> val C = listOf("Introduction to Programming",
...                "Programming Practice",
...                "Data Structures",
...                "Programming Principles",
...                "Algorithms",
...                "Programming Languages")
>>> C.count { "Programming" in it }
4
>>> C.count { "Programming" !in it }
2
>>> C.all { " " in it }
false
>>> C.any { " " !in it }
true
>>> C.any { "Algo" in it }
true
>>> C.all { "A" in it.toUpperCase() }
true
>>> C.find { " " !in it }
Algorithms

过滤集合

我们经常希望从集合中挑选出满足特定条件的所有元素组成的子集。高阶方法 filter 和 filterNot 可以做到这一点。同样,它们的参数是将元素映射到布尔值的函数对象。

这里有一些基本示例,使用上面的列表:

>>> C.filter { " " in it }
[Introduction to Programming, Programming Practice, Data Structures, Programming Principles, Programming Languages]
>>> C.filterNot { " " in it }
[Algorithms]

请注意,filterNot 与 filter 执行相同的操作,但颠倒了条件的意义。

这里有一些使用我们的 113809 个英语单词文件的有趣过滤器:

>>> val words = java.io.File("words.txt").useLines { it.toSet() }
>>> words.filter { "issis" in it }
[missis, missises, narcissism, narcissisms, narcissist, narcissists]
>>> words.filter { it.length > 20 }
[counterdemonstrations, hyperaggressivenesses, microminiaturizations]
>>> words.filterNot { "a" in it || "e" in it || "o" in it || "u" in it || "i" in it }
[by, byrl, byrls, bys, crwth, crwths, cry, crypt, crypts, cwm, cwms,
 cyst, cysts, dry, dryly, drys, fly, flyby, flybys, flysch, fry,
 ghyll, ghylls, glycyl, glycyls, glyph, glyphs, gym, gyms, gyp, gyps,
 gypsy, hymn, hymns, hyp, hyps, lymph, lymphs, lynch, lynx, my,
 myrrh, myrrhs, myth, myths, nth, nymph, nymphs, phpht, pht, ply,
 pry, psst, psych, psychs, pygmy, pyx, rhythm, rhythms, rynd, rynds,
 sh, shh, shy, shyly, sky, sly, slyly, spry, spryly, spy, sty, stymy,
 sylph, sylphs, sylphy, syn, sync, synch, synchs, syncs, syzygy, thy,
 thymy, try, tryst, trysts, tsk, tsks, tsktsk, tsktsks, typp, typps,
 typy, why, whys, wry, wryly, wych, wynd, wynds, wynn, wynns, xylyl,
 xylyls, xyst, xysts]

过滤是埃拉托斯特尼筛法的本质,因此很自然地我们可以使用 filter 来简洁地编写它(primes.kts):

val n = args[0].toInt()
val sqrtn = Math.sqrt(n.toDouble()).toInt()

var s = (2 .. n).toList()

while (true) {
  val k = s.first()
  if (k > sqrtn)
     break
  print("$k ")
  s = s.filter { it % k != 0 }
}

println(s.joinToString(separator=" "))

转换集合

另一个常见的操作是通过某种方式转换现有集合的每个元素来创建新集合。

这可以使用高阶函数 map 来完成。它的参数是将集合的元素映射到其转换值的函数对象。新集合可以具有相同或不同的类型:

>>> C
[Introduction to Programming, Programming Practice, Data Structures, Programming Principles, Algorithms, Programming Languages]
>>> C.map { it.length }
[27, 20, 15, 22, 10, 21]
>>> C.map { it.toUpperCase() }
[INTRODUCTION TO PROGRAMMING, PROGRAMMING PRACTICE, DATA STRUCTURES,
  PROGRAMMING PRINCIPLES, ALGORITHMS, PROGRAMMING LANGUAGES] 
>>> C.map { it + " " + it }
[Introduction to Programming Introduction to Programming, Programming
  Practice Programming Practice, Data Structures Data Structures,
  Programming Principles Programming Principles, Algorithms
  Algorithms, Programming Languages Programming Languages] 

对集合进行排序

我们已经看到,可以使用其 sorted 方法对列表进行排序。这总是使用元素类型的自然排序来对元素进行排序。

如果我们想要使用不同的排序方式进行排序,我们可以使用高阶方法 sortedWith(返回一个新列表)或 sortWith(对给定的可变列表进行排序)。它们接受一个 java.util.Comparator 对象作为参数,用于处理列表元素之间的比较。

我们可以从将两个元素(a)和(b)映射到一个 Int 的函数对象中创建一个 Comparator 对象。如果元素(a)应该在所需的排序顺序中出现在元素(b)之前,则函数应返回负整数,如果(a)应该在(b)之后出现,则返回正整数,如果在排序顺序中认为(a)和(b)相等,则返回零。

这里是一个例子:

>>> val words = java.io.File("words.txt").useLines { it.toSet() }
>>> words.sortedWith(java.util.Comparator { a: String, b: String -> b.length - a.length }).take(10)
[counterdemonstrations, hyperaggressivenesses, microminiaturizations,
  counterdemonstration, counterdemonstrators, hypersensitivenesses,
  microminiaturization, representativenesses, anticonservationist,
  comprehensivenesses] 

它按照单词列表中元素的长度降序排列(然后我们只显示前 10 个元素)。

请注意,每个可比较类型已经有一个 compareTo(a, b)方法,如果(a > b),则返回正整数,如果(a < b),则返回负整数,如果(a = b),则返回零:

>>> 17.compareTo(19)
-1
>>> 17.compareTo(13)
1
>>> 17.compareTo(17)
0
>>> "CS109".compareTo("Otfried")
-12

当构建我们自己的 Comparator 对象时,我们可以利用这一点,例如像这样:

>>> words.sortedWith(java.util.Comparator<String> { a, b -> -a.length.compareTo(b.length) }).take(10)
[counterdemonstrations, hyperaggressivenesses, microminiaturizations,
  counterdemonstration, counterdemonstrators, hypersensitivenesses,
  microminiaturization, representativenesses, anticonservationist,
  comprehensivenesses] 

实际上,我们甚至可以更简单地做到这一点:有 sortedBy 和 sortBy 方法,它们接受一个将列表元素映射到另一个可比较类型 R 的函数对象。然后按照 R 上的顺序对列表进行排序:

>>> words.sortedBy { -it.length }.take(10)
[counterdemonstrations, hyperaggressivenesses, microminiaturizations,
  counterdemonstration, counterdemonstrators, hypersensitivenesses,
  microminiaturization, representativenesses, anticonservationist,
  comprehensivenesses] 

我们已经按照它们的负长度对单词进行了排序,因此最长的单词排在第一位。如果您觉得否定有点不合逻辑,您可以像这样写:

>>> words.sortedByDescending { it.length }.take(10)
[counterdemonstrations, hyperaggressivenesses, microminiaturizations,
  counterdemonstration, counterdemonstrators, hypersensitivenesses,
  microminiaturization, representativenesses, anticonservationist,
  comprehensivenesses] 

实际上,这个更长,但可能更容易理解。

更多高阶方法

Kotlin 集合有许多更高阶的方法。以下是一些示例:

findLast 找到满足给定属性的列表中的最后一个元素(请记住 find 会找到第一个元素):

>>> val l = listOf(13, 7, 2, 19, 20, 1, 17, 35, 9)
>>> l.find { it > 15 }
19
>>> l.findLast { it > 15 }
35

retainAll 类似于 filter,但它修改了 MutableList 本身,丢弃所有不满足属性的元素(而 filter 总是返回一个新列表):

>>> val l = mutableListOf(13, 7, 2, 19, 20, 1, 17, 35, 9)
>>> l.filter { it > 15 }
[19, 20, 17, 35]
>>> l
[13, 7, 2, 19, 20, 1, 17, 35, 9]
>>> l.retainAll { it > 15 }
true
>>> l
[19, 20, 17, 35]

takeWhile 和 takeLastWhile 返回一个新列表,其中包含满足属性的给定列表的元素的开头(或结尾)。类似地,dropWhile 和 dropLastWhile 返回剩余的元素:

>>> val l = listOf(13, 7, 2, 19, 20, 1, 17, 35, 9)
>>> l.takeWhile { it > 5 }
[13, 7]
>>> l.dropWhile { it > 5 }
[2, 19, 20, 1, 17, 35, 9]
>>> l.takeLastWhile { it > 5 }
[17, 35, 9]
>>> l.dropLastWhile { it > 5 }
[13, 7, 2, 19, 20, 1]

groupBy 是一个有用的操作:它将一个函数对象应用于集合的每个元素,以获得该元素的“键”。然后返回一个将这些键映射到原始元素列表的映射:

>>> val l = listOf(13, 7, 2, 19, 20, 1, 17, 35, 9)
>>> l.groupBy { it % 3 }
{1=[13, 7, 19, 1], 2=[2, 20, 17, 35], 0=[9]}
>>> val m = words.groupBy { it.length }
>>> m[20]
[counterdemonstration, counterdemonstrators, hypersensitivenesses, microminiaturization, representativenesses]
>>> m[21]
[counterdemonstrations, hyperaggressivenesses, microminiaturizations]
>>> m[22]
null

flatMap 是一个非常强大的操作:它将给定的函数对象应用于集合的每个元素。函数对象的结果应该是一个列表(或集合,或数组)。flatMap 的结果是将所有这些列表按照原始列表的顺序组合成一个列表。

fold 和 reduce 或其他一些强大的操作略微超出了本节的范围。稍后再了解它们!

编译程序

我们用 Kotlin、Java、C++ 或 C 等高级编程语言编写程序。编译器将源代码转换为对象代码(机器代码)。

对于 C 和 C++,习惯上编译为本机机器代码。它可以直接由处理器执行。本机机器代码对于不同的处理器、操作系统有所不同,并且可能依赖于库版本。因此,为 Windows 编译的程序无法在 Mac OS 上运行,为 iOS 编译的程序无法在 Windows 上运行,并且为 Mac OS 10.9 编译的程序可能无法在 Mac OS 10.10 上运行。

Java 和 Kotlin 通常被翻译为 JVM(Java 虚拟机)的对象代码。计算机上需要一个 Java 运行时环境来执行程序。完全相同的对象代码可以在任何系统上工作。JVM 在服务器上被广泛使用。

到目前为止,我们已经将我们的程序编写为 Kotlin 脚本。脚本适用于快速编程任务。每次运行脚本时,它都会被重新编译,然后执行。

对于较大的程序,最好编写一个应用程序。它可以由许多单独的源文件组成,这些文件可以单独编译(因此您只需编译您已更改的源文件)。一旦编译完成,应用程序启动速度比脚本快得多。

编译

让我们从一个小例子开始。我们创建一个源文件 point.kt(注意文件扩展名 kt,与我们用于脚本的扩展名 kts 不同):

data class Point(val x: Int, val y: Int) {
  override fun toString(): String = 
    "[%d,%d]".format(x,y)
}

我已经创建了一个只包含此文件的新目录:

$ dir
point.kt

我现在将使用 Kotlin 编译器 ktc 编译 point.kt:

$ ktc point.kt
$ dir classes
Point.class

正如您所见,classes 子目录中创建了一个新文件,即 Point.class。扩展名为 class 的文件正好包含 JVM 的一个类定义。

现在我们可以从交互模式(或脚本)中使用这个类:

$ ktc
Welcome to Kotlin version 1.0.1-2 (JRE 1.7.0_95-b00)
Type :help for help, :quit for quit
>>> val p = Point(7, 13)
>>> p
[7,13]

请注意,在这个交互式会话中,我没有定义 Point 类。Kotlin 会自动找到它:当它看到当前未定义的 Point 时,它会寻找名为 Point 的类或对象的定义。因此,它会在其类路径上检查所有目录,以查找具有名称 Point.class 的文件。由于我使用 -cp 选项将子目录 classes 添加到类路径中,Kotlin 会在那里找到该文件并从该文件加载类定义。请注意,源文件 point.kt 不会被使用——我们可以删除它,仍然可以使用 Point.class 中的 Point 类。

什么可以被编译

当你编写脚本时,可以在文件中放置任意的 Kotlin 命令。例如,这个文件 hello.kt 作为脚本完全没有问题:

// This cannot be compiled

println("Hello World")

但是,它无法被编译:

$ ktc hello.kt
hello.kt:3:1: error: expecting a top level declaration

正如编译器已经告诉我们的那样,源文件只能包含顶级声明。这包括使用 fun 定义的函数定义、使用 class 定义的类定义以及使用 val 和 var 定义的全局变量的定义。

应用

这引出了一个问题:如果我们只允许放置声明,那么我们如何运行程序中的任何代码?答案可以追溯到 1970 年代初期:一个应用程序通过一个名为 main 的特殊函数启动。它必须接受一个类型为 Array 的参数,该参数将在程序启动时接收命令行参数。

所以下面的源文件可以被编译 hello-app.kt

fun main(args: Array<String>) {
  println("Hello World")
}

我们对其进行编译:

$ ktc hello-app.kt 
$ dir classes
Hello_appKt.class  META-INF

请注意,已经为类 Hello_appKt 创建了一个类文件(还有一个名为 META_INF 的新子目录,其中包含编译器的信息——我们完全可以忽略这一点)。

要运行我们的程序,我们需要"运行类" Hello_appKt:

$ kt Hello_appKt
Hello World

请记住,要运行程序,您需要提供类名,而不是源文件名。(事实上,根本不需要源文件来运行程序!)

这是一个完整应用程序的小例子:number-game.kt

import org.otfried.cs109.readString

var secret = 0

val random = java.util.Random()

fun answerGuess(guess: Int) {
  if (guess == secret)
    println("You got it")
  else if (guess > secret)
    println("Too big")
  else if (guess < secret)
    println("Too small")
}

fun main(args: Array<String>) {
  secret = random.nextInt(100)
  var guess = -1
  while (guess != secret) {
    guess = readString("Guess my number> ").toInt()
    answerGuess(guess)
  }
}

我们可以编译并运行程序:

$ ktc number-game.kt 
$ dir classes
META-INF  Number_gameKt.class
$ kt Number_gameKt
Guess my number> 17
Too small
Guess my number> 68
Too big
Guess my number> 43
Too big
Guess my number> 32
Too big
Guess my number> 25
Too big
Guess my number> 20
Too big
Guess my number> 19
You got it

再次注意,要运行程序,你必须提供类名 Number_gameKt(这是 Kotlin 根据源文件 number-game.kt 自动创建的)。

多个源文件

一个更大的应用程序将由几个源文件组成,定义了许多函数和类。至少一个源文件必须定义一个返回 Unit 的函数 main(args: Array)。这个函数是程序的起点。程序是通过表示包含主函数的源文件的类启动的。

编译这样一个应用程序的最简单方法是一次编译所有文件:

$ ktc *.kt

然而,当程序变得更大时,只重新编译已更改或依赖已更改部分是有意义的。实现这一点的最简单方法是使用构建工具,如 gradle 或 maven,或者集成开发环境(IDE)如 IntelliJ(由与 Kotlin 创建者相同的公司制作,因此对 Kotlin 的支持最佳)。 这个 教程 将帮助您入门。

为分发编译

想象一下,你写了一个不错的程序,想要把它送给朋友或者发布到你的网站上。显然,你不希望他们为了运行你的程序而必须安装 Kotlin 编译器。所以你必须将你的程序打包成这样一种形式,只需 JVM 安装(大多数计算机已经有了)即可。

我们需要做两件事:首先,不再创建大量的类文件,我们将它们全部打包成一个 jar 文件。其次,我们将 Kotlin 库添加到这个包中,这样它就可以在没有安装 Kotlin 的情况下运行:

$ kotlinc -d number-game.jar -include-runtime number-game.kt 

编译器将创建一个新文件 number-game.jar。您可以直接在 Java 虚拟机上运行它:

$ java -jar number-game.jar 
Guess my number> 17
Too big
Guess my number> 13
Too big
Guess my number> 4
Too big
Guess my number> 1
Too small
Guess my number> 2
You got it

这将在任何已安装 JVM 的计算机上运行。如果您的程序具有图形用户界面,您可能也可以通过单击 jar 文件来启动它。

带有方法的类

对象是面向对象编程的基础。在 Scala 中,每个数据都是一个对象。对象的类型决定了你可以对对象做什么。类可以包含数据(对象的状态)和方法(你可以对对象做什么)。你可以把类看作是对象的蓝图。一旦你定义了一个类,你就可以使用关键字 new 从蓝图创建对象。

将对象视为一个原子单位。客户端(这是指使用对象的程序代码)不关心对象的实现,它们只使用公开的方法和字段。这里的“公开”意味着它们由类提供给客户端使用。

一致性

日期由三个属性组成:年、月和日,它们都是整数。因此,我们可以将日期存储为一个三元组 Triple<Int, Int, Int>,但是如果它们表示年-月-日,或者日-月-年,我们可能会感到困惑。最好创建一个具有三个属性的数据类:

data class Date(val year: Int, val month: Int, val day: Int)

然而,这两种方法都不能保证我们的日期对象是一致的。一致性意味着属性只取合法的、有意义的值,并且每个属性的值与其他属性的值一致。例如,一个日期值为 31 是与一个月值为 3 一致的,但与一个月值为 4 的不一致。一个日期值为 29 和一个月值为 2 只有在年值表示闰年时才一致。使用上面定义的数据类,我们没有任何限制,可以犯这样的错误:

>>> data class Date(val year: Int, val month: Int, val day: Int)
>>> val d = Date(2017, 13, -5)
>>> d
Date(year=2017, month=13, day=-5)

为了确保我们的日期对象始终保持一致,我们可以在对象的构造函数中添加四个 require 语句。它由关键字 init 指示,后跟在每次创建此类型的对象时执行的语句(days1.kt):

val monthLength = intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)

data class Date(val year: Int, val month: Int, val day: Int) {
  init {
    require(1901 <= year && year <= 2099)
    require(1 <= month && month <= 12)
    require(1 <= day && day <= monthLength[month - 1])
    require(month != 2 || day <= 28 || (year % 4) == 0)
  }
}

如果值正常,什么都不会发生。否则,require 会抛出一个异常,我们立即就知道出了什么问题。由于日期对象是不可变的,因此在对象构造时保证的一致状态永远不会被破坏和变得不一致。

如果对象保证它们的状态是一致的,那将会很有帮助。这样可以使得与对象一起工作的函数能够进行无错误检查的操作,并简化调试,因为当出现问题时我们会很快注意到。

要使用我的新的日期对象,我必须编译定义,然后可以交互式地测试它:

>>> val d1 = Date(2017, 3, 17)
>>> d1
Date(year=2017, month=3, day=17)
>>> d1.year
2017
>>> d1.day
17
>>> var d2 = Date(2017, 2, 29)
java.lang.IllegalArgumentException: Failed requirement
	at Date.<init>(days1.kt:24)

方法

在过去,我们有函数接受特定类型的参数,比如一个函数 date2num ,它将一个日期表示为年、月和日,转换为从 1901 年 1 月 1 日开始的日期索引。

在面向对象编程中,我们更喜欢将适用于特定类型的函数定义为该类型的方法。其中一个优点是它清晰地记录了给定类型可用的函数(例如,要找出可以对字符串执行的操作,您可以查看 String 类的方法列表)。然而,主要优点将是隐藏或保护对象内部信息的可能性,正如我们稍后将看到的那样。

暂时,让我们添加方法来将 Date 对象转换为日期索引,并返回日期的星期几 (days3.kt):

val monthLength = intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
val weekday = arrayOf("Monday", "Tuesday", "Wednesday", "Thursday",
		      "Friday", "Saturday", "Sunday")

data class Date(val year: Int, val month: Int, val day: Int) {
  init {
    require(1901 <= year && year <= 2099)
    require(1 <= month && month <= 12)
    require(1 <= day && day <= monthLength[month - 1])
    require(month != 2 || day <= 28 || (year % 4) == 0)
  }

  // returns the number of days since 1901/01/01 (day 0)
  fun dayIndex(): Int {
    val fourY = (365 + 365 + 365 + 366)
    val yearn = year - 1901
    var total = 0
    total += fourY * (yearn / 4)
    total += 365 * (yearn % 4)
    for (m in 0 until month - 1)
      total += monthLength[m]
    total += day - 1
    if (year%4 != 0 && month > 2)
      total -= 1
    return total
  }

  fun dayOfWeek(): String = weekday[(dayIndex() + 1) % 7]
}

方法看起来像函数定义,但放置在类的主体内。方法的主体可以引用对象的字段名称:注意在方法 dayIndex 中使用了年、月和日字段。

只有当我们有可用于引用该类型对象的引用时,才能调用方法,因此每当执行方法时,总会有一个“当前”对象。字段名称指的是此“当前”对象的字段:

$ ktc days3.kt 
$ ktc
Welcome to Kotlin version 1.0.1-2 (JRE 1.8.0_74-b02)
Type :help for help, :quit for quit
>>> var d1 = Date(2017, 4, 16)
>>> var d2 = Date(2000, 1, 1)
>>> d1.year
2017
>>> d2.month
1
>>> d1.dayIndex()
42474
>>> d2.dayIndex()
36159
>>> d1.dayOfWeek()
Sunday
>>> d2.dayOfWeek()
Saturday

打印漂亮的日期

Kotlin 或 Java 中的每个对象都可以转换为字符串,这就是在交互模式或使用 println 时发生的情况:首先使用其 toString 方法将对象转换为字符串:

>>> d1
Date(year=2017, month=4, day=16)
>>> d1.toString()
Date(year=2017, month=4, day=16)
>>> println(d1)
Date(year=2017, month=4, day=16)

由于我们将 Date 定义为数据类,编译器提供了一个合理的 toString 方法,显示类名和每个字段的值。

在生产质量的代码中,我们可能希望有一个漂亮的日期表示。我们可以通过重写 toString 方法的默认定义来实现这一点,就像这样 (days4.kt):

override fun toString(): String = 
  "%s, %s %d, %d".format(dayOfWeek(), monthname[month-1], day, year)

注意关键字 override。这是必需的,因为每个对象已经有一个 toString 方法。因此,我们不是添加一个新方法,而是重写了之前的定义。

有了这个方法,我们的对象看起来更漂亮了:

$ ktc days4.kt 
$ ktc
Welcome to Kotlin version 1.0.1-2 (JRE 1.8.0_74-b02)
Type :help for help, :quit for quit
>>> val d1 = Date(2017, 4, 16)
>>> d1
Sunday, April 16, 2017
>>> d1.toString()
Sunday, April 16, 2017
>>> println(d1)
Sunday, April 16, 2017

运算符就是方法

让我们添加一个方法来计算两个日期之间的天数。由于我们已经有了 dayIndex(),这个实现非常容易 (days5.kt):

  fun diff(rhs: Date): Int = dayIndex() - rhs.dayIndex()

它表现良好:

$ ktc days5.kt 
$ ktc
Welcome to Kotlin version 1.0.1-2 (JRE 1.8.0_74-b02)
Type :help for help, :quit for quit
>>> var d1 = Date(1993, 7, 9)
>>> var d2 = Date(2017, 4, 9)
>>> d2.diff(d1)
8675

但如果我们能够写 d2 - d1 来表示两个日期之间的差异,而不是不那么美观的 d2.diff(d1),那不是很好吗?

在 Kotlin 中,与 Java 不同,这是可能的。事实上,在 Kotlin 中,像 + 这样的运算符和像 take 这样的方法之间根本没有区别——它们都被实现为方法。

要定义减法运算符 -,我们只需将我们的 diff 方法重命名为 minus,并添加关键字 operator (days6.kt):

  operator fun minus(rhs: Date): Int = dayIndex() - rhs.dayIndex()

我们现在可以计算日期了:

$ ktc days6.kt 
$ ktc
Welcome to Kotlin version 1.0.1-2 (JRE 1.8.0_74-b02)
Type :help for help, :quit for quit
>>> val birth = Date(1993, 7, 9)
>>> val today = Date(2017, 4, 17)
>>> today - birth
8683

定义自己的运算符可能很有趣,但不要过分——只有在代码更易读时才有用!

重载

Scala,像 Java 和 C++一样,但与 C 不同,允许方法名的重载:允许有不同的方法具有相同的名称,只是在它们接受的参数类型上有所不同。这里是一个简单的例子(overloading.kts):


fun f(n: Int) {
  println("Int " + n)
}

fun f(s: String) {
  println("String " + s)
}

f(17)
f("CS109")

编译器正确确定 f(17)是对第一个函数的调用,而 f("CS109")是对第二个函数的调用。

我们可以利用重载来为我们的 Date 类添加更多运算符。我们将允许将一定数量的天数加或减到日期上以获得一个新日期(days7.kt):

val monthLength = intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
val weekday = arrayOf("Monday", "Tuesday", "Wednesday", "Thursday",
		      "Friday", "Saturday", "Sunday")
val monthname = arrayOf("January", "February", "March",
		        "April", "May", "June",
		        "July", "August", "September",
		        "October", "November", "December")

data class Date(val year: Int, val month: Int, val day: Int) {
  init {
    require(1901 <= year && year <= 2099)
    require(1 <= month && month <= 12)
    require(1 <= day && day <= monthLength[month - 1])
    require(month != 2 || day <= 28 || (year % 4) == 0)
  }

  // returns the number of days since 1901/01/01 (day 0)
  fun dayIndex(): Int {
    val fourY = (365 + 365 + 365 + 366)
    val yearn = year - 1901
    var total = 0
    total += fourY * (yearn / 4)
    total += 365 * (yearn % 4)
    for (m in 0 until month - 1)
      total += monthLength[m]
    total += day - 1
    if (year%4 != 0 && month > 2)
      total -= 1
    return total
  }

  fun num2date(n: Int): Date {
    val fourY = (365 + 365 + 365 + 366)
    var year = 1901 + (n / fourY) * 4
    var day = n % fourY 
    if (day >= 365 + 365 + 365 + 59) {
      year += 3
      day -= 365 + 365 + 365
    } else {
      year += (day / 365)
      day = day % 365
      if (day >= 59)
	day += 1
    }
    var month = 1
    while (day >= monthLength[month-1]) {
      day -= monthLength[month-1]
      month += 1
    }
    return Date(year, month, day+1)
  }

  fun dayOfWeek(): String = weekday[(dayIndex() + 1) % 7]

  override fun toString(): String = 
    "%s, %s %d, %d".format(dayOfWeek(), monthname[month-1],
			   day, year)

  operator fun minus(rhs: Date): Int = dayIndex() - rhs.dayIndex()

  operator fun plus(n: Int): Date = num2date(dayIndex() + n)
  operator fun minus(n: Int): Date = num2date(dayIndex() - n)

}

请注意,Date 类定义了两个减号运算符。编译器根据右侧的类型选择我们需要的那一个:

$ ktc days7.kt 
$ ktc
Welcome to Kotlin version 1.0.1-2 (JRE 1.8.0_74-b02)
Type :help for help, :quit for quit
>>> val birth = Date(1992, 8, 21)
>>> val baekil = birth + 100
>>> baekil
Sunday, November 29, 1992
>>> val today = Date(2017, 4, 19)
>>> today - birth
9007
>>> today - 9007
Friday, August 21, 1992
>>> birth + 9007
Wednesday, April 19, 2017

一个真正的程序

既然我们现在有了一个不错的日期类,让我们写一个程序来使用它(days.kt):

val digits = "0123456789"

// if s is not a legal date, or is not in range, 
// then throws IllegalArgumentException
fun getDate(s: String): Date {
  if (s.length != 10 || s[4] != '/' || s[7] != '/')
    throw IllegalArgumentException()
  for ((i, ch) in s.withIndex()) {
    if (i != 4 && i != 7 && ch !in digits)
      throw IllegalArgumentException()
  }
  val year = s.substring(0, 4).toInt()
  val month = s.substring(5, 7).toInt()
  val day = s.substring(8).toInt()
  return Date(year, month, day)
}

fun main(args: Array<String>) {
  try {
    if (args.size == 1) {
      val d = getDate(args[0])
      println("$d is a ${d.dayOfWeek()}")
    } else if (args.size == 2) {
      val d1 = getDate(args[0])
      val d2 = getDate(args[1])
      println("There are ${d2 - d1} days between $d1 and $d2")
    } else if (args.size == 3) {
      val d1 = getDate(args[0])
      val sign = if (args[1] == "-") -1 else +1
      val dist = args[2].toInt()
      val d2 = d1 + sign * dist
      println("$d1 ${args[1]} $dist days = $d2")
    } else {
      System.err.println("Must have one, two, or three arguments")
    }
  }
  catch (e: NumberFormatException) {
    System.err.println("Illegal number")
  }
  catch (e: IllegalArgumentException) {
    System.err.println("Illegal date")
  }
}

如在前一节中所解释的,程序通过主函数启动,该函数以参数 args 的形式接收命令行参数。

我们编译程序:

$ ktc days.kt 

编译器为包含主函数的源文件生成一个类 DaysKt,因此这是我们需要调用来运行程序的类:

$ kt DaysKt 2015/a3/04
Illegal date
$ kt DaysKt 2016/04/26
Tuesday, April 26, 2016 is a Tuesday
$ kt DaysKt 2016/04/26 2017/01/01
There are 250 days between Tuesday, April 26, 2016 and Sunday, January 1, 2017
$ kt DaysKt 2016/04/26 + 250
Tuesday, April 26, 2016 + 250 days = Sunday, January 1, 2017
$ kt DaysKt 2016/04/26 - 100
Tuesday, April 26, 2016 - 100 days = Sunday, January 17, 2016

更多关于类的信息

到目前为止,我们所有的类都是数据类,因为编译器为这些类提供了一些很好的支持:一个合理的toString方法,相等性测试,它们可以用作集合中的元素或映射中的键。

数据类是为具有一些基本属性的对象而设计的,比如数字和字符串,并且对它们的定义有一些限制。在本节中,我们将使用一般的类—我们将在最后回顾它们的区别。

构造函数参数

在创建对象时,我们经常为构造函数提供参数,就像我们创建日期时一样:

  >>> var d = Date(2012, 4, 9)

参数2012, 4, 9对应于在类声明中类名后的括号中出现的参数yearmonthday。构造函数参数可以是val字段、var字段或类的构造的参数。

这里是一个不可变的Point类。两个类参数都是val字段:

class Point(val x: Int, val y: Int) {
  override fun toString(): String = "(%d, %d)".format(x, y)
}

这里有一个可变的矩形类Rect来存储一个与坐标轴平行的矩形。类参数是var字段:

class Rect(var corner: Point, var width: Int, var height: Int) {
  init { require(width > 0 && height > 0) }
  override fun toString(): String = "[%d ~ %d, %d ~ %d]".format(corner.x, 
      corner.x + width, corner.y, corner.y + height)
}

第一个类参数是一个Point对象,所以我应该像这样创建矩形对象:

>>> var r = Rect(Point(10, 20), 50, 20)
>>> r
[10 ~ 60, 20 ~ 40]

假设我不喜欢这个,我想要能够通过给出四个数字来创建矩形,就像这样:

>>> var r = Rect(10, 20, 50, 20)
>>> r
[10 ~ 60, 20 ~ 40]

我仍然想要能够将左上角存储为Point对象,但是因为类参数现在看起来不同了,我不能在类参数列表中定义corner字段。相反,我需要像这样做:

class Rect(x: Int, y: Int, var width: Int, var height: Int) {
  var corner = Point(x, y)
  init { require(width > 0 && height > 0) }
  override fun toString(): String = "[%d ~ %d, %d ~ %d]".format(corner.x, 
      corner.x + width, corner.y, corner.y + height)
}

现在corner字段在类的主体中被定义。类参数xy只是Rect类型对象构造方法的参数,它们不是类的字段:

>>> r.corner
(10, 20)
>>> r.width
50
>>> r.height
20
>>> r.x
error: unresolved reference: x
>>> r.y
error: unresolved reference: y

所以类的每个valvar在类的主体中的出现都定义了一个类的字段。每个字段都必须用一个初始值进行初始化。当对象被构造时,将使用此值。

隐私性

让我们定义一个累加器,一个从零开始的计数器,我们可以给它加上一个值(accum1.kts):

class Accumulator {
  var sum = 0
  fun add(n: Int) {
    sum += n
  }
}

这次类本身没有类参数,所以在创建Accumulator对象时不需要给出任何参数:

>>> var acc1 = Accumulator()
>>> acc1
Line46$Accumulator@747f281
>>> acc1.add(7)
>>> acc1.add(13)
>>> acc1.sum
20

(注意,由于Accumulator不是数据类,它默认有一个相当丑陋的toString方法。我们需要重写toString以获得更好的输出。)

不太好的是,我们可能会意外修改Accumulator对象的sum字段:

>>> var acc2 = Accumulator()
>>> acc2.add(7)
>>> acc2.add(23)
>>> acc2.sum = 0 // Oops
>>> acc2.add(19)
>>> acc2.sum
19

使用累加器类的程序员在这里犯了一个错误,并将acc2.sum设为零—所以现在最终结果是错误的。

这是一个展示隐私重要性的例子。一个客户端——也就是使用我们类的代码——应该将对象视为黑匣子。客户端不应该需要或想要知道对象是如何实现的,只能使用对象提供的方法与其通信。我们的 Accumulator 对象应该有两个操作:将一个数字添加到当前总和中,以及读取当前总和。不应该可以修改当前总和。

我们可以通过禁止客户端代码访问存储当前总和的字段来实现这一点。为此,我们将该字段声明为 private。然而,这意味着我们根本无法访问它,因此我们必须添加一个新方法来读取求和的当前值(accum2.kts):

class Accumulator {
  private var current = 0
  fun add(n: Int) {
    current += n
  }
  fun sum(): Int = current
}

这是我们正确使用它的方式:

>>> var acc1 = Accumulator()
>>> acc1.add(7)
>>> acc1.add(13)
>>> acc1.sum()
20

但是现在看看当我们犯一个错误并尝试更改总和时会发生什么:

>>> acc1.sum = 0
error: function invocation 'sum()' expected
error: variable expected

由于 sum 不是一个字段而是一个返回值的方法,我们无法对其进行赋值。

即使我们试图直接更改字段 current,编译器也会捕捉到这个编程错误:

>>> acc1.current = 0
error: cannot access 'current': it is 'private' in 'Accumulator'

实际上,甚至查看 current 的值都是被禁止的(这就是为什么我们需要 sum 方法):

>>> acc1.current
error: cannot access 'current': it is 'private' in 'Accumulator'

private 关键字意味着该成员只能从类内部的方法中访问。你可以用它来修饰字段和方法。因此,私有方法是只能从同一类中的其他方法调用的方法。

一个二十一点游戏

为了看到一个更有趣的例子,让我们来编写一个二十一点游戏。我们首先需要一个代表扑克牌的类。记住,有四种花色,分别是梅花、黑桃、红心和方块,以及 13 个面值,即 2 到 10、J、Q、K 和 A。

一副扑克牌

由于这是一个简单的类,我们应该将其制作成一个数据类。无论如何,我们都会重写 toString 以获得一个漂亮的字符串表示(blackjack1.kt):

val Suits = arrayOf("Clubs", "Spades", "Hearts", "Diamonds")
val Faces = arrayOf("2", "3", "4", "5", "6", "7", "8", "9", "10", 
		    "Jack", "Queen", "King", "Ace")

data class Card(val face: String, val suit: String) {
  init {
    require(suit in Suits)
    require(face in Faces)
  }

  override fun toString(): String {
    val a = if (face == "Ace" || face == "8") "an " else "a "
    return a + face + " of " + suit
  }

  fun value(): Int = when(face) {
    "Ace" -> 11
    "Jack" -> 10
    "Queen" -> 10
    "King" -> 10
    else -> face.toInt()
  }
}

注意 value 方法,它返回卡片的点数。

>>> val c1 = Card("Ace", "Diamonds")
>>> val c2 = Card("Jack", "Spades")
>>> val c3 = Card("8", "Hearts")
>>> c1
an Ace of Diamonds
>>> c2
a Jack of Spades
>>> c3
an 8 of Hearts
>>> val hand = listOf(c1, c2, c3)
>>> hand
[an Ace of Diamonds, a Jack of Spades, an 8 of Hearts]
>>> for (c in hand)
...   println("Card $c has value ${c.value()}")
Card an Ace of Diamonds has value 11
Card a Jack of Spades has value 10
Card an 8 of Hearts has value 8

接下来我们需要一个 Deck 类,它存储整副牌,并允许我们从牌堆中抽取牌(blackjack2.kt):

class Deck {
  private val cards = mutableListOf<Card>()

  init {
    generateDeck()
    shuffleDeck()
  }  

  private fun generateDeck() {
    for (suit in Suits) {
      for (face in Faces) {
	cards.add(Card(face, suit))
      }
    }
  }

  private fun shuffleDeck() {
    for (i in 1 .. 52) {
      // 0..i-2 already shuffled
      val j = random.nextInt(i)
      val cj = cards[j]
      cards[j] = cards[i-1]
      cards[i-1] = cj
    }
  }

  fun draw(): Card {
    assert(!cards.isEmpty())
    return cards.removeAt(cards.lastIndex)
  }
}

注意 Deck 没有类参数,因此可以简单地通过 Deck() 来创建它,不需要任何参数。让我们仔细看看类的前几行:

  private val cards = mutableListOf<Card>()

  init {
    generateDeck()
    shuffleDeck()
  }  

第一行定义了一个名为 cards 的 val 字段。该字段用一个空的可变列表进行初始化。因此,在 Deck 对象可以被使用之前,这个列表必须填充实际的卡片。这是通过两个方法调用 generateDeck() 和 shuffleDeck() 来完成的。它们都是 Deck 的私有方法:也就是说,它们是 Deck 的方法,但不能被客户端代码直接调用。它们纯粹是为了类的内部使用(在我们的例子中是在构造函数中使用,以设置一个正确洗牌的 52 张二十一点卡片的牌堆)。第一个方法 generateDeck 用 52 张卡片填充数组,第二个方法 shuffleDeck 将它们重新排列成随机顺序。

最后,draw 方法不是私有的:它是为了让 Deck 的客户端从牌堆中抽取一张牌而设计的。

这是使用 Deck 类的一个例子:

>>> val deck = Deck()
>>> for (i in 1 .. 10)
...   println(deck.draw())
a King of Spades
a 3 of Diamonds
a Jack of Hearts
a 7 of Hearts
a 10 of Diamonds
an Ace of Spades
a Queen of Clubs
a 4 of Clubs
a 2 of Clubs
a Jack of Clubs

现在我们有一个可工作的牌堆,我们可以编写客户端代码,也就是我们可以实现完整的二十一点游戏。这里是实现一个游戏的 main 函数(你可以下载整个程序 blackjack-game.kt)。请注意,由于我们已经隐藏了存储卡片、洗牌和管理牌堆的所有复杂性在 Card 和 Deck 类中,因此阅读这段代码是多么容易。

// Play one round of Blackjack
//  Returns 1 if player wins, -1 if dealer wins, and 0 for a tie.
fun blackjack(): Int {
  val deck = Deck()

  // initial cards
  var player = mutableListOf(deck.draw())
  println("You are dealt " + player.first())
  var dealer = mutableListOf(deck.draw())
  println("Dealer is dealt a hidden card")

  player.add(deck.draw())
  println("You are dealt " + player.last())
  dealer.add(deck.draw())
  println("Dealer is dealt " + dealer.last())
  println("Your total is ${handValue(player)}")

  // player's turn to draw cards
  var want = true
  while (want && handValue(player) < 21) {
    want = askYesNo("Would you like another card? (y/n) ")
    if (want) {
      player.add(deck.draw())
      println("You are dealt " + player.last())
      println("Your total is ${handValue(player)}")

      // if the player's score is over 21, the player loses immediately.
      if (handValue(player) > 21) {
	println("You went over 21! You lost!")
	return -1
      }
    }
  }

  println("The dealer's hidden card was " + dealer.first())
  while (handValue(dealer) < 17) {
    dealer.add(deck.draw())
    println("Dealer is dealt " + dealer.last())
  }
  println("The dealer's total is ${handValue(dealer)}")

  // summary
  val player_total = handValue(player)
  val dealer_total = handValue(dealer)
  println("\nYour total is $player_total")
  println("The dealer's total is $dealer_total")

  if (dealer_total > 21) {
    println("The dealer went over 21! You win!")
    return 1
  } else if (player_total > dealer_total) {
    println("You win!")
    return 1
  } else if (player_total < dealer_total) {
    println("You lost!")
    return -1
  } else {
    println("You have a tie!")
    return 0
  }
}

要编译游戏,我们需要 Date 和 Deck 类(来自 blackjack2.kt)以及游戏函数(来自 blackjack-game.kt),所以我们可以这样编译:

$ ktc blackjack2.kt blackjack-game.kt 

我们通过 blackjack-game.kt 中的 main 函数来运行游戏,就像这样:

$ kt Blackjack_gameKt
Welcome to Blackjack!

You are dealt an 8 of Spades
Dealer is dealt a hidden card
You are dealt a 5 of Clubs
Dealer is dealt a Queen of Hearts
Your total is 13
Would you like another card? (y/n) y
You are dealt a 9 of Diamonds
Your total is 22
You went over 21! You lost!

Play another round? (y/n) n

那么数据类呢?

在之前的章节中,我们已经学习了数据类。在本章中,我们开始讨论普通类。那么它们有��么区别呢?

简短的答案是:数据类适用于没有隐藏状态的小对象。我们的 Deck 类就是一个具有隐藏状态的对象的例子,即牌堆上的当前卡片。

长答案是,数据类与普通类在以下方面有所不同:

  • 对于普通类,toString 方法只会打印类名和一些十六进制字符串。对于数据类,它会打印类名和所有字段的值。如果我们想要一个漂亮的 toString 方法用于普通类,我们需要重写 toString 方法并自己定义它。

  • 对于普通类,相等运算符 == 和 != 只检查两个引用是否指向同一个对象。对于数据类,如果它们的所有字段都相等,则两个对象是相等的:

    >>> class Normal(val x: Int)
    >>> val n1 = Normal(7)
    >>> val n2 = Normal(7)
    >>> val n3 = n1
    >>> n1 == n2
    false
    >>> n1 == n3
    true
    >>> n2 == n3
    false
    >>> data class Data(val x: Int)
    >>> val d1 = Data(7)
    >>> val d2 = Data(7)
    >>> val d3 = d1
    >>> d1 == d2
    true
    >>> d1 == d3
    true
    >>> d2 == d3
    true
    
    

    如果你想重新定义普通类的 == 和 != 的含义,你必须定义一个 equals 方法。

  • 不可变数据类对象可以作为集合的元素或映射的键使用。对于普通类,只有在你定义 equals 和 hashCode 方法时才能起作用,但这超出了本教程的范围。

  • 多个数据类字段可以一次性提取:

    >>> data class Date(val year: Int, val month: Int, val day: Int)
    >>> val d = Date(2012, 9, 12)
    >>> d
    Date(year=2012, month=9, day=12)
    >>> fun extract(date: Date) {
    ...   val (y, m, d) = date
    ...   println("$y/$m/$d")
    ... }
    >>> extract(d)
    2012/9/12
    
    

    赋值语句val (y, m, d) = date有效是因为date是一个数据类对象。对于普通类,你需要定义componentX方法。

  • 数据类自动拥有一个复制方法,允许你制作副本并选择性地更改一些字段,例如像这样:

    >>> d
    Date(year=2012, month=9, day=12)
    >>> val d1 = d.copy(month = 12)
    >>> d1
    Date(year=2012, month=12, day=12)
    
    

    对于普通的类,你需要手动定义这样一个方法。

使用 Junit 进行测试

我们之前已经讨论过对函数进行增量测试。现在我们将为我们的对象和方法创建更系统化的测试。

要使用 JUnit,您必须按照安装说明中所述安装它。

JUnit 是一个允许我们编写测试套件的 Java 库。测试套件是一个包含任意数量测试的类,检查我们代码的正确性。

一个简单的测试套件

让我们从一个简单的例子开始(test1.kt):

import org.junit.Assert.*
import org.junit.Test

public class AdditionTest {

  @Test
  fun onePlusOne() {
    assertEquals("1 + 1 must be 2", 2, 1 + 1)
    assertNotEquals("1 + 1 must not be 3", 3, 1 + 1)
  }
} 

我们编译这个类:

$ ktc test1.kt

如果您现在检查 classes 目录的内容,您会在那里找到一个新文件 AdditionTest.class。我们可以按以下方式运行测试套件:

$ kttest AdditionTest
JUnit version 4.12
.
Time: 0.004

OK (1 test)

输出结果显示一切都顺利进行,所有测试都通过了。

为了演示测试失败的情况,我将更改测试中的第二个断言如下:

  assertNotEquals("1 + 1 must not be 2", 2, 1 + 1)

再次编译并运行测试:

$ ktc test1.kt 
$ kttest AdditionTest
JUnit version 4.12
.E
Time: 0.005
There was 1 failure:
1) onePlusOne(AdditionTest)
java.lang.AssertionError: 1 + 1 must not be 2\. Actual: 2
	at org.junit.Assert.fail(Assert.java:88)

检查异常是否被抛出

要检查我们的代码是否正确处理需要抛出异常的输入,我们需要编写一些测试,指示实际上期望抛出异常。这是通过在测试上进行注解完成的(test2.kt):

import org.junit.Assert.*
import org.junit.Test

public class ArithmeticTest {

  @Test
  fun onePlusOne() {
    assertEquals("1 + 1 must be 2", 2, 1 + 1)
    assertNotEquals("1 + 1 must not be 3", 3, 1 + 1)
  }

  @Test(expected = ArithmeticException::class)
  fun divisionByZero() {
    @Suppress("UNUSED_VARIABLE")
    val result = 1 / 0
  }
}

这是输出结果:

$ ktc test2.kt 
test2.kt:14:18: warning: division by zero
$ kttest ArithmeticTest
JUnit version 4.12
..
Time: 0.004

OK (2 tests)

如果我们将 1/0 更改为 1/1,以便不会抛出异常,测试套件将正确检测到错误。

$ kttest ArithmeticTest
JUnit version 4.12
..E
Time: 0.006
There was 1 failure:
1) divisionByZero(ArithmeticTest)
java.lang.AssertionError: Expected exception: java.lang.ArithmeticException
	at org.junit.internal.runners.statements.ExpectException.evaluate(ExpectException.java:32)

测试我们自己的类

对于多项式计算器项目,我已经编写了一个在polynomial.kt中实现的类 Polynomial。

现在我们将为 Polynomial 类编写一个测试套件。这比进行交互式测试要好得多,因为当测试失败时,您可以修改代码并重新运行所有测试。如果您需要向您的类添加功能或以任何方式更改它们,拥有您以前使用的所有测试非常有用:您可以再次运行它们以确保您没有破坏任何内容。

创建测试套件以对所有有趣的类进行测试是一个好习惯。您可以使用测试套件来确保您实现的类是正确的。更重要的是,它让您相信,当您进行更改时,您不会意外地破坏类的某些部分。

这是 Polynomial 类的一个测试套件。请注意,它仅使用 Polynomial 类进行测试(polytest.kt):

import org.junit.Assert.*
import org.junit.Test

public class PolynomialTest {

  @Test
  fun creatingPolynomials() {
    val p1 = Polynomial(3)
    assertEquals(p1.degree(), 0)
    assertEquals(p1.toString(), "3")
    val p2 = Polynomial(-1, 3, -4, 0, -6)
    assertEquals(p2.degree(), 4)
    assertEquals(p2.toString(), "-6 * X⁴ - 4 * X² + 3 * X - 1")
    val p3 = Polynomial(0, 0, 1)
    assertEquals(p3.degree(), 2)
    assertEquals(p3.toString(), "X²")
    val p0 = Polynomial(0)
    assertEquals(p0.degree(), -1)
  }

  @Test
  fun additionAndSubtraction() {
    val p1 = Polynomial(3)
    val p2 = Polynomial(-1, 3, -4, 0, -6)
    val p3 = Polynomial(5, 0, 4, 0, -6)

    val q1 = p1 + p2
    val q2 = p2 - p3
    assertEquals(q1.toString(), "-6 * X⁴ - 4 * X² + 3 * X + 2")
    assertEquals(q2.degree(), 2)
    assertEquals(q2.toString(), "-8 * X² + 3 * X - 6")
  }

  @Test
  fun multiplication() {
    val p1 = Polynomial(3)
    val p2 = Polynomial(-1, 3, -4, 0, -6)
    val p3 = Polynomial(0, 0, 5)
    val p4 = Polynomial(2, -4, 6, 8)

    val q1 = p1 * p2
    val q4 = p2 * p3
    val q5 = p2 * p4
    assertEquals(q1.toString(), "-18 * X⁴ - 12 * X² + 9 * X - 3")
    assertEquals(q4.degree(), 6)
    assertEquals(q4.toString(), "-30 * X⁶ - 20 * X⁴ + 15 * X³ - 5 * X²")
    assertEquals(q5.toString(), "-48 * X⁷ - 36 * X⁶ - 8 * X⁵ - 12 * X⁴ + 26 * X³ - 26 * X² + 10 * X - 2")
  }

  @Test
  fun power() {
    val p1 = Polynomial(3)
    val p3 = Polynomial(0, 0, 5)

    val q2 = p1 pow 5
    val q3 = p3 pow 5
    assertEquals(q2.degree(), 0)
    assertEquals(q2.coeff(0), 3*3*3*3*3)
    assertEquals(q3.degree(), 10)
    assertEquals(q3.toString(), "3125 * X¹⁰")
  }

@Test
  fun creatingPolynomialsUsingX() {
    assertEquals(X.toString(), "X")
    val p4 = -1 * (X pow 5) + 3 * (X pow 3) - (X pow 2) + 5
    assertEquals(p4.toString(), "-X⁵ + 3 * X³ - X² + 5")
    val p5 = (X - 1) * (X - 3) * (X + 5) pow 2
    assertEquals(p5.toString(), "X⁶ + 2 * X⁵ - 33 * X⁴ - 4 * X³ + 319 * X² - 510 * X + 225")
  }

  @Test
  fun evaluation() {
    val p1 = Polynomial(3)
    val p2 = Polynomial(-1, 3, -4, 0, -6)
    val p3 = Polynomial(0, 0, 1)
    val p4 = -1 * (X pow 5) + 3 * (X pow 3) - (X pow 2) + 5
    val p5 = (X - 1) * (X - 3) * (X + 5) pow 2

    val eps = 1.0e-9 // floating point precision

    assertEquals(3.0, p1(5.0), eps)
    assertEquals(-1.0, p2(0.0), eps)
    assertEquals(4.0, p3(2.0), eps)
    assertEquals(2.0, p4(-1.0), eps)    
    assertEquals(0.0, p5(-5.0), eps)
  }

  @Test
  fun derivatives() {
    @Suppress("UNUSED_VARIABLE")
    val p1 = (X - 1) * (X - 3) * ((X + 5) pow 2)
    /*
    val q1 = p1.derivative()
    assertEquals(q1.degree(), 3)
    assertEquals(q1.toString(), "4 * X³ + 18 * X² - 24 * X - 70")
    val q2 = q1.derivative()
    assertEquals(q2.degree(), 2)
    assertEquals(q2.toString(), "12 * X² + 36 * X - 24")
    */
  }
}

要运行测试套件,我们当然首先必须编译 Polynomial 类,然后编译 PolynomialTest 类,最后我们可以运行测试套件:

$ ktc polynomial.kt
$ ktc polytest.kt
$ kttest PolynomialTest
JUnit version 4.12
.......
Time: 0.03

OK (7 tests)

当你对多项式类进行更改时,你可以在每次更改后运行测试套件,以确保一切仍然正常。

从测试套件开始

我们可以再进一步。我们可以将测试套件视为我们要创建的类的正确行为的可执行规范。因此,我们不是先编写代码,然后使用测试套件进行测试,而是首先列出我们的代码必须满足的测试列表以确保正确。然后我们逐个实现对象的方法。每次更改后,几个测试将正确通过。当所有测试都通过时,我们就完成了实现。

让我们回到我们的多项式对象,并假设它还没有给出:我们应该从头开始实现它。

这次我们从测试套件 polytest.kt 开始。在这里,我们立即遇到一个问题:由于 PolynomialTest 使用 Polynomial 对象,因此在没有 Polynomial 类的情况下无法编译 polytest.kt。

因此,我们将创建一个空的 Polynomial 类框架,显示所有方法及其类型。使用这个框架,我们不仅可以编译测试套件,而且现在还有了我们计划实现的方法的完整文档。

因此,我的多项式类的第一个版本看起来像这样 (polynomial1.kt):

@Suppress("UNUSED_PARAMETER")
class Polynomial(coeffs: Array<Int>) {

  constructor(vararg coeffs: Int) : this(coeffs.toTypedArray()) { }

  fun degree(): Int = TODO()

  fun coeff(i: Int): Int = TODO()

  override fun toString(): String = TODO()

  operator fun plus (rhs: Polynomial): Polynomial = TODO()

  operator fun plus(rhs: Int) = this + Polynomial(rhs)

  operator fun minus (rhs: Polynomial): Polynomial = TODO()

  operator fun minus(rhs: Int) = this - Polynomial(rhs)

  operator fun times (rhs: Polynomial): Polynomial = TODO()

  infix fun pow (ex: Int): Polynomial = TODO()

  operator fun invoke(x: Double): Double = TODO()
}

operator fun Int.times(rhs: Polynomial) = Polynomial(this) * rhs

val X = Polynomial(0, 1)

我已经定义了我计划实现的类的所有公共方法,包括参数类型和结果类型。每个方法都定义为返回 TODO()。这段代码已经可以编译:

$ ktc polynomial1.kt

现在我们可以编译测试套件而不会出现任何错误:

$ ktc polytest.kt

我们运行我们的测试套件:

$ kttest PolynomialTest
JUnit version 4.12
.E.E.E.E.E.E.E
Time: 0.026
There were 7 failures:
1) creatingPolynomials(PolynomialTest)
kotlin.NotImplementedError: An operation is not implemented.
	at Polynomial.degree(polynomial1.kt:31)
2) power(PolynomialTest)
kotlin.NotImplementedError: An operation is not implemented.
	at Polynomial.pow(polynomial1.kt:31)
3) additionAndSubtraction(PolynomialTest)
kotlin.NotImplementedError: An operation is not implemented.
	at Polynomial.plus(polynomial1.kt:31)
4) multiplication(PolynomialTest)
kotlin.NotImplementedError: An operation is not implemented.
	at Polynomial.times(polynomial1.kt:31)
5) evaluation(PolynomialTest)
kotlin.NotImplementedError: An operation is not implemented.
	at Polynomial.pow(polynomial1.kt:31)
6) creatingPolynomialsUsingX(PolynomialTest)
kotlin.NotImplementedError: An operation is not implemented.
	at Polynomial.toString(polynomial1.kt:31)
7) derivatives(PolynomialTest)
kotlin.NotImplementedError: An operation is not implemented.
	at Polynomial.minus(polynomial1.kt:31)

FAILURES!!!
Tests run: 7,  Failures: 7

当然,所有测试都失败,因为我们的类还没有做任何事情。特别是,如果尝试调用函数 TODO,它会引发 NotImplementedError。(TODO 是一个具有结果类型 Nothing 的特殊函数。由于 Nothing 是每种 Kotlin 类型的子类型,因此可以写成 TODO() 而不是任何类型。但是,没有 Nothing 类型的对象,因此该函数必须引发异常。)

现在我们已经准备好构建我们的多项式类。我首先添加一个构造函数,实现 degree 和 coeff,并编写一个 toString 方法 (polynomial2.kt):

@Suppress("UNUSED_PARAMETER")
class Polynomial(coeffs: Array<Int>) {

  constructor(vararg coeffs: Int) : this(coeffs.toTypedArray()) { }

  private val c = createCoeffs(coeffs)

  private fun createCoeffs(a: Array<Int>): List<Int> {
    var s = a.lastIndex
    while (s >= 0 && a[s] == 0)
      s -= 1
    return a.take(s+1)
  }

  fun degree(): Int = c.lastIndex

  fun coeff(i: Int): Int = if (i < c.size) c[i] else 0

  override fun toString(): String {
    var s = StringBuilder()
    var plus = ""
    var minus = "-"
    for (i in degree() downTo 0) {
      if (c[i] != 0) {
	var e = c[i]
	s.append(if (e > 0) plus else minus)
	plus = " + "; minus = " - "
	e = Math.abs(e)
	if (i == 0)
	  s.append(e)
	else {
	  if (e != 1) {
	    s.append(e)
	    s.append(" * ")
	  }
	  if (i > 1) {
	    s.append("X^")
	    s.append(i)
	  } else 
	    s.append("X")
	}
      }
    }
    return s.toString()
  }

  operator fun plus (rhs: Polynomial): Polynomial = TODO()

  operator fun plus(rhs: Int) = this + Polynomial(rhs)

  operator fun minus (rhs: Polynomial): Polynomial = TODO()

  operator fun minus(rhs: Int) = this - Polynomial(rhs)

  operator fun times (rhs: Polynomial): Polynomial = TODO()

  infix fun pow (ex: Int): Polynomial = TODO()

  operator fun invoke(x: Double): Double = TODO()
}

operator fun Int.times(rhs: Polynomial) = Polynomial(this) * rhs

val X = Polynomial(0, 1)

我们编译新的 Polynomial 类并再次运行测试套件:

$ ktc polynomial2.kt 
$ kttest PolynomialTestJUnit version 4.12
..E.E.E.E.E.E
Time: 0.031
There were 6 failures:
1) power(PolynomialTest)
kotlin.NotImplementedError: An operation is not implemented.
	at Polynomial.pow(polynomial2.kt:66)
...

第一个测试现在已经通过:我们可以正确构造多项式对象。只剩下六个失败的测试。我可以继续实现缺失的方法,在每次更新代码后运行测试套件。逐步构建一个正确且完全实现的类。

请注意,我们不需要再次编译测试套件:只需在开始时使用空的 Polynomial 框架进行编译即可!(当然,您可能会发现后来���要添加一些更多的测试,如果修改了 polytest.kt,则当然必须再次编译。)

枚举

枚举(enum)是一种具有有限值集合的类型,例如 "yes"、"no"、"maybe",或者 "north"、"south"、"east"、"west"。

枚举的定义如下:

>>> enum class Answer { YES, NO, MAYBE }
>>> enum class Direction { NORTH, SOUTH, EAST, WEST }

Answer 和 Direction 是类型名称,类型的值写为 Answer.YES 或 Direction.SOUTH:

>>> var a: Answer = Answer.YES
>>> val b = Answer.NO
>>> val dir = Direction.EAST

枚举值具有很好的 toString 方法,也可以通过 name 属性获取它们的名称:

>>> a
YES
>>> a.name
YES
>>> b
NO
>>> dir
EAST

但是,在内部,枚举被表示为整数。您可以使用 ordinal 属性获得整数。枚举值可以进行比较(按照声明的顺序):

>>> a.ordinal
0
>>> b.ordinal
1
>>> dir.ordinal
2
>>> a < Answer.MAYBE
true
>>> dir < Direction.SOUTH
false

使用枚举类的 valueOf 方法,可以将字符串转换为枚举值:

>>> Answer.valueOf("YES")
YES
>>> Direction.valueOf("WEST")
WEST
>>> Direction.valueOf("NOWHERE")
java.lang.IllegalArgumentException: No enum constant Line2.Direction.NOWHERE

也可以获取所有可能值的列表:

>>> for (e in Answer.values()) println(e)
YES
NO
MAYBE

读取和写入文本文件

读取文本文件

读取文本文件最简单的方法是使用 File 的 readText 方法:它简单地将整个文件作为一个字符串返回:

>>> java.io.File("text.txt").readText()
When I started programming, there were no graphical displays. 
All computer input and output was done using text.

如果你想将每行读取为一个 List,每行一个元素,你可以使用 readLines()方法:

>>> val fname = "words.txt"
>>> val list = java.io.File(fname).readLines()
>>> list.take(10)
[aa, aah, aahed, aahing, aahs, aal, aalii, aaliis, aals, aardvark]

如果你想将行存储在一个集合中,或者如果你想进行一些额外的过滤或处理,以下方法更有效率:

>>> val words = java.io.File(fname).useLines { it.toSet() }
>>> words.take(10)
[aa, aah, aahed, aahing, aahs, aal, aalii, aaliis, aals, aardvark]
>>> val some = java.io.File(fname).useLines { it.filter { it.length > 10}.map {it.toUpperCase() }.toList() }
>>> some.take(10)
[ABANDONMENT, ABANDONMENTS, ABBREVIATED, ABBREVIATES, ABBREVIATING,
 ABBREVIATION, ABBREVIATIONS, ABDICATIONS, ABDOMINALLY,
 ABERRATIONS]

如果你需要对文件的每一行进行更复杂的处理,File 类的 forEachLine 方法很方便。它接受一个函数对象作为参数,该函数对象接受一个字符串参数。该方法依次为文件的每一行调用此函数对象:

>>> java.io.File(fname).forEachLine { print(it); print(' ') }
aa aah aahed aahing aahs aal aalii aaliis aals aardvark aardvarks
aardwolf aardwolves aas aasvogel aasvogels aba abaca abacas abaci
aback abacus abacuses abaft abaka abakas abalone abalones abamp
abampere abamperes abamps abandon abandoned ...

注意魔术名 it 的使用,代表每行的内容。

如果你需要做一些更复杂的事情,可能需要重置文件的读取到文件的开头,跳过文件的部分内容等等,使用 BufferedReader 是方便的(readfile1.kts):

fun readFile(fname: String) {
  val reader = java.io.File(fname).bufferedReader()
  var line = reader.readLine()
  while (line != null) {
    println(line)
    line = reader.readLine()
  }
  reader.close()
}

注意 readLine 返回一个 String?,当你完成时必须关闭读取器。

通过将读取代码包装在 use 块中,关闭读取器会自动进行(即使我们从函数中提前返回或抛出异常)(readfile2.kts):

fun readFile(fname: String) {
  val reader = java.io.File(fname).bufferedReader()
  reader.use {
    reader ->
      var line = reader.readLine()
      while (line != null) {
        println(line)
        line = reader.readLine()
      }
  }
}

写入文本文件

要写入文本文件,请创建 PrintWriter。它支持 print 和 println 方法,其工作方式就像打印到终端一样。不要忘记关闭 PrintWriter,否则你的文件会不完整:(writefile1.kts):

val out = java.io.File("test.txt").printWriter()

for (i in 1 .. 10)
  out.println("$i: ${i * i}")

out.close()

同样,我们可以通过使用 use 来使关闭文件自动化:(writefile2.kts):

java.io.File("test.txt").printWriter().use {
  out ->
    for (i in 1 .. 10)
      out.println("$i: ${i * i}")
}

图像处理

要处理诸如照片之类的图像,我们需要能够将照片文件加载到图像对象中,读取和设置图像的像素值,并将图像保存回照片文件。

我们使用类 java.awt.image.BufferedImage 存储图像数据。你可以通过以下方式创建这样的对象:

  import java.awt.image.BufferedImage

  val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)

或者你可以使用以下方式从文件加载图像:

  import javax.imageio.ImageIO

  val photo1 = ImageIO.read(java.io.File("photo.jpg"))

此调用的结果是一个 BufferedImage 对象。你可以使用以下方法找到此图像的宽度和高度:

  println("Photo size is ${photo1.width}, ${photo1.height}")

你可以使用以下方式将图像对象保存到文件中:

  ImageIO.write(photo1, "jpg", java.io.File("test.jpg"))

或者,如果你更喜欢 PNG 格式:

  ImageIO.write(photo1, "png", java.io.File("test.png"))

BufferedImage 对象的像素可以使用 getRGB 和 setRGB 方法读取和更改,见下文。

示例脚本

以下小脚本读取一个名为 photo.jpg 的文件,获取其尺寸,创建一个新的大小相同的空图像,将旧图像 img 中的像素复制到新图像 out 中(通过水平镜像),在照片上对角画一条红线,最后将新图像保存在一个名为 test.jpg 的文件中(image.kts):

import java.io.File
import javax.imageio.ImageIO
import java.awt.image.BufferedImage

fun phototest(img: BufferedImage): BufferedImage {
  // obtain width and height of image
  val w = img.width
  val h = img.height

  // create new image of the same size
  val out = BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)

  // copy pixels (mirror horizontally)
  for (x in 0 until w)
    for (y in 0 until h)
      out.setRGB(x, y, img.getRGB(w - x - 1, y) and 0xffffff)

  // draw red diagonal line
  for (x in 0 until Math.min(h, w))
    out.setRGB(x, x, 0xff0000)

  return out
}

fun test() {
  // read original image, and obtain width and height
  val photo1 = ImageIO.read(File("photo.jpg"))

  val photo2 = phototest(photo1) 

  // save image to file "test.jpg"
  ImageIO.write(photo2, "jpg", File("test.jpg"))
}

test()

颜色

方法 getRGB(x: Int, y: Int): Int 返回位置 (x, y) 处像素的颜色,方法 setRGB(x: Int, y: Int, color: Int) 设置此像素的颜色。

颜色以整数对象表示。红色、绿色和蓝色三个组件被"打包"成一个整数。每个组件有 8 位,因此其值可以在 0 和 255 之间。打包的颜色是一个 32 位整数,其位如下所示:

  tttt tttt rrrr rrrr gggg gggg bbbb bbbb

前 8 位要么为零,要么表示像素的"透明度"。我们不会使用这些透明度位。接下来的 8 位表示红色,再接下来的 8 位表示绿色,最后的 8 位表示蓝色。这就是为什么这种表示称为"RGB"。

给定值范围在 0 到 255 之间的红色、绿色和蓝色分量,我们可以像这样将它们打包在一起:

  val color = (red * 65536) + (green * 256) + blue

给定一个打包的整数颜色,我们可以按如下方式提取三个分量:

  val red = (color and 0xff0000) / 65536
  val green = (color and 0xff00) / 256
  val blue = (color and 0xff)

(这里的与运算符是按位与运算符。它确保只使用我们感兴趣的位。)

绘制图像

要绘制内容,首先需要一个画布来绘制。我们将使用BufferedImage,一个位图图像,您可以通过提供所需的像素宽度和高度来创建它。

然后我们使用我编写的一个小包来绘制这个图像。(当然,您也可以使用标准的 java.awt 函数来执行此操作。但是 java.awt 界面有点过时,使用 cs109ui 包的优点是您可以以后在 Javascript 和 Android 上重用完全相同的绘图代码。)

绘制完成后,您可以使用 javax.imageio.ImageIO.write 函数将图像保存到文件中。

下面是一个完整的示例,展示了一些绘图功能(drawing.kts):

import java.awt.image.BufferedImage
import org.otfried.cs109ui.ImageCanvas
import org.otfried.cs109.Color
import org.otfried.cs109.DrawStyle

// Size of image
val w = 500
val h = 500

// create an image
val image = BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)

// get ImageCanvas for the image
val g = ImageCanvas(image)

// clear background
g.clear(Color.WHITE)

// draw two filled circles
g.setColor(Color.RED)
g.drawCircle(50.0, 50.0, 20.0)  // FILL is the default
g.setColor(Color.ORANGE)
g.drawCircle(250.0, 400.0, 20.0)

// draw an unfilled circle with a pen of width 3
g.setColor(Color.MAGENTA)
g.setLineWidth(3.0)
g.drawCircle(415.0, 50.0, 15.0, DrawStyle.STROKE)

// draw a filled and an unfilled Rectangle
g.setColor(Color.CYAN)
g.drawRectangle(20.0, 400.0, 50.0, 20.0, DrawStyle.FILL)
g.drawRectangle(400.0, 400.0, 50.0, 20.0, DrawStyle.STROKE)

// draw a line
g.setLineWidth(1.0)   // reset to default
g.setColor(Color(0, 0, 255)) // same as Color.BLUE
g.beginShape()
g.moveTo(50.0, 50.0)
g.lineTo(250.0, 400.0)
g.drawShape(DrawStyle.STROKE)

// draw a non-convex quadrilateral:
g.save()              // save current coordinate system
g.translate(360.0, 260.0) // move origin to here
g.rotate(-30.0)           // rotate 30 degrees counter-clockwise
g.beginShape()
g.moveTo(0.0, 0.0)
g.lineTo(30.0, -40.0)
g.lineTo(60.0, 0.0)
g.lineTo(30.0, -100.0)
g.closePath()
g.drawShape()
g.restore()           // restore current coordinate system

// draw some text
g.setColor(Color(0, 128, 0)) // a darker green
g.setFont(20.0, "Batang")
g.drawText("Hello World!", 155.0, 225.0)
g.drawText("안녕 하세요", 175.0, 245.0)

// done with drawing
g.done()

// write image to a file
javax.imageio.ImageIO.write(image, "png", java.io.File("drawing.png"))

生成的绘图如下:

生成的绘图

颜色

使用 org.otfried.cs109.Color 对象指定颜色。有一些预定义的颜色:

  • Color.BLACK

  • Color.BLUE

  • Color.CYAN

  • Color.DARK_GRAY

  • Color.GRAY

  • Color.GREEN

  • Color.LIGHT_GRAY

  • Color.MAGENTA

  • Color.ORANGE

  • Color.PINK

  • Color.RED

  • Color.WHITE

  • Color.YELLOW

要创建另一种颜色,请提供其红色、绿色和蓝色组件,作为介于 0 和 255 之间的整数:

val color = Color(0, 200, 0)  // slightly darker green

您还可以从 24 位整数构造颜色:

val color = Color(0x00c800)  // slightly darker green

绘图样式

每个形状都可以用两种方式绘制:作为填充形状绘制,或者使用“笔”沿着形状的轮廓绘制(或两者兼而有之)。

您可以使用以下常量之一选择绘图样式

  • DrawStyle.STROKE 以绘制形状的轮廓(在这种情况下,线宽很重要);

  • DrawStyle.FILL 以填充形状(这是默认值,始终可以省略);

  • DrawStyle.STROKE_AND_FILL 来执行两者。

文本字体和对齐方式

要绘制文本,您需要使用 setFont 在 ImageCanvas 上设置字体。您可以像这样提供一个点大小:

  canvas.setFont(16.0)

或者使用标准名称之一"Monospaced"、"Serif"或"SansSerif",或系统中安装的实际字体的名称设置不同的字体:

  canvas.setFont(16.0, "Serif")

调用 drawText 时,您可以选择将文本对齐到其左边缘、右边缘或水平中心。对齐方式通过常量进行选择

  • TextAlign.LEFT(这是默认值,可以省略);

  • TextAlign.CENTER;

  • TextAlign.RIGHT。

ImageCanvas

org.otfried.cs109ui.ImageCanvas 对象执行所有绘图。您可以使用其 width 和 height 属性来查询画布的大小(但是您当然也可以检查原始的 BufferedImage)。

ImageCanvas 的基本方法包括:

  • 清除(clear)函数清除整个画布为指定的颜色;

  • setColor(color: Color) 设置未来绘图操作的颜色(有关 Color 类型,请参见上文);

  • setLineWidth(width: Double)设置轮廓绘制的笔宽度;

  • drawRectangle(x: Double, y: Double, width: Double, height: Double, s: DrawStyle) 绘制矩形;

  • drawCircle(x: Double, y: Double, radius: Double, s: DrawStyle) 在 ((x,y)) 处以半径 (r) 绘制一个圆;

  • setFont(size: Double, face: String) 设置文本绘制操作的字体(参见上面的示例);

  • drawText(text: String, x: Double, y: Double, a: TextAlign) 在位置 ((x, y)) 绘制对齐的字符串;

  • done() 在完成绘制时建议调用此方法。

更复杂的形状

要绘制除了矩形和圆之外的形状,您需要使用 ImageCanvas 的 shape 方法:

描述形状时,沿着轮廓走。首先,调用 beginShape(),然后调用 moveTo(x, y) 前往轮廓的起点。现在,通过一系列的 lineTo(x, y) 调用来描述形状的边界。您可以通过调用 closePath() 来完成轮廓,这将返回到起点(您在调用 moveTo(x, y) 时设置的位置)。对于填充形状,通常要调用 closePath,对于轮廓(描边)形状,这取决于您是否希望该形状是封闭曲线。

最后,调用 drawShape 来绘制形状。

变换

您可以修改坐标系统以便更容易地指定您的对象:

  • translate(x: Double, y: Double) 使点 ((x, y)) 成为新坐标系的原点;

  • rotate(degrees: Double) 顺时针旋转坐标系;

  • scale(sx: Double, sy: Double) 缩放 (x) 和 (y) 方向的坐标系统(因此调用 scale(2.0, 2.0) 会使您的绘图尺寸加倍)。

通常,您只想临时修改坐标系,并在完成绘图的某个部分时返回到原始坐标系(请参阅上面代码中的四边形)。通过在更改坐标系之前调用 save(),并在想要返回到先前设置时调用 restore() 来实现这一点。

透明度

通常,后绘制的形状会覆盖先前绘制的形状(计算机图形学中的“画家模型”)。

但是,您可以调用 setAlpha(alpha) 来设置未来绘制操作的透明度。当 alpha == 255 时,绘图是不透明的并完全覆盖以前绘制的内容。当 alpha == 128 时,您以 50% 透明度进行绘制:结果是原始颜色的一半与新绘制颜色的一半的混合。

测量文本

方法 textWidth(text: String) 返回在当前变换和字体设置下绘制文本的宽度。

CS109UI 模块

我编写了一个简单的模块叫做 cs109ui,它使得可以编写具有简单图形用户界面的程序,而无需了解 Swing 库或担心基于事件的编程。我们在CS109中的几个项目中使用这个模块。

基本用法

要使用该模块,你需要按照 installation page 中的说明进行安装。

该模块创建一个窗口。你可以通过绘制图像(使用这里解释的操作)来填充这个窗口,然后调用 show。

setTitle 命令设置窗口的标题。

这是一个基本示例(uitest1.kt)。函数 draw 绘制到一个 java.awt.image.BufferedImage(与 drawing example 中的绘图代码完全相同)。

import org.otfried.cs109ui.*
import org.otfried.cs109ui.ImageCanvas
import org.otfried.cs109.Color
import org.otfried.cs109.DrawStyle

import java.awt.image.BufferedImage

fun draw(image: BufferedImage) {
  // get ImageCanvas for the image
  val g = ImageCanvas(image)

  // clear background
  g.clear(Color.WHITE)

  // draw two filled circles
  g.setColor(Color.RED)
  g.drawCircle(50.0, 50.0, 20.0)  // FILL is the default
  g.setColor(Color.ORANGE)
  g.drawCircle(250.0, 400.0, 20.0)

  // draw an unfilled circle with a pen of width 3
  g.setColor(Color.MAGENTA)
  g.setLineWidth(3.0)
  g.drawCircle(415.0, 50.0, 15.0, DrawStyle.STROKE)

  // draw a filled and an unfilled Rectangle
  g.setColor(Color.CYAN)
  g.drawRectangle(20.0, 400.0, 50.0, 20.0, DrawStyle.FILL)
  g.drawRectangle(400.0, 400.0, 50.0, 20.0, DrawStyle.STROKE)

  // draw a line
  g.setLineWidth(1.0)   // reset to default
  g.setColor(Color(0, 0, 255)) // same as Color.BLUE
  g.beginShape()
  g.moveTo(50.0, 50.0)
  g.lineTo(250.0, 400.0)
  g.drawShape(DrawStyle.STROKE)

  // draw a non-convex quadrilateral:
  g.save()              // save current coordinate system
  g.translate(360.0, 260.0) // move origin to here
  g.rotate(-30.0)           // rotate 30 degrees counter-clockwise
  g.beginShape()
  g.moveTo(0.0, 0.0)
  g.lineTo(30.0, -40.0)
  g.lineTo(60.0, 0.0)
  g.lineTo(30.0, -100.0)
  g.closePath()
  g.drawShape()
  g.restore()           // restore current coordinate system

  // draw some text
  g.setColor(Color(0, 128, 0)) // a darker green
  g.setFont(20.0, "Batang")
  g.drawText("Hello World!", 155.0, 225.0)
  g.drawText("안녕 하세요", 175.0, 245.0)

  // done with drawing
  g.done()
}

fun main(args: Array<String>) {
  setTitle("CS109 UI Test #1")
  val image = BufferedImage(500, 500, BufferedImage.TYPE_INT_RGB)
  draw(image)
  show(image)
}

我们编译并运行程序:

$ ktc uitest1.kt
$ kt Uitest1Kt

程序会打开一个像这样的新窗口:

uitest1.kt 的屏幕截图

请注意,尽管主函数已经返回,程序尚未终止。要结束程序,你必须手动使用鼠标关闭窗口。

更新显示

你可以通过绘制一个新图像并再次调用 show 来更改窗口的内容。(你可以绘制到之前的相同图像或使用新图像。)

在窗口变化之间等待一段时间,你可以执行一些简单的动画,比如让对象在窗口内闪烁或移动。

这里有一个简单的示例,展示了一个闪烁的正方形。首先,正方形以红色出现一秒钟,然后以蓝色出现一秒钟。五秒后程序会自动终止(uitest2.kt):

import org.otfried.cs109ui.*
import org.otfried.cs109ui.ImageCanvas
import org.otfried.cs109.Color
import org.otfried.cs109.DrawStyle

import java.awt.image.BufferedImage

fun draw(image: BufferedImage, color: Color) {
  val g = ImageCanvas(image)
  g.clear(Color.WHITE)
  g.setColor(color)
  g.drawRectangle(100.0, 100.0, 300.0, 300.0)
  g.done()
}

fun showWait(image: BufferedImage, color: Color, ms: Int) {
  draw(image, color)  // draw rectangle
  show(image)
  waitForMs(ms)       // wait ms milliseconds
}

fun main(args: Array<String>) {
  setTitle("CS109 UI Blinking Rectangle")

  val image = BufferedImage(500, 500, BufferedImage.TYPE_INT_RGB)

  showWait(image, Color.WHITE, 500)  // 0.5 sec white picture
  showWait(image, Color.RED, 1000)   // 1 sec red rectangle
  showWait(image, Color.WHITE, 500)  // 0.5 sec white picture
  showWait(image, Color.BLUE, 1000)  // 1 sec blue rectangle
  showWait(image, Color.WHITE, 5000) // 5 secs white picture  

  close() // close window and terminate program
}

同样,你可以使用以下命令运行:

$ ktc uitest2.kt
$ kt Uitest2Kt

现在你可以编写有趣的程序,比如Simon project:你可以使用终端进行文本输出和文本输入,使用窗口进行图形输出。

动画

通过快速更新窗口,我们可以编写一些简单的平滑动画。这里有一个示例,让一个红色球在屏幕上平滑移动(uitest-animation.kt):

import org.otfried.cs109ui.*
import org.otfried.cs109ui.ImageCanvas
import org.otfried.cs109.Color
import org.otfried.cs109.DrawStyle

import java.awt.image.BufferedImage

fun draw(image: BufferedImage, x: Double, y: Double) {
  val g = ImageCanvas(image)
  g.clear(Color.WHITE)
  g.setColor(Color.RED)
  g.drawCircle(x, y, 40.0)
  g.done()
}

fun main(args: Array<String>) {
  setTitle("CS109 UI Animation test")

  val image = BufferedImage(500, 500, BufferedImage.TYPE_INT_RGB)

  var x = 30.0
  var y = 30.0
  while (x < 500.0) {
    draw(image, x, y)
    x += 2
    y += 1
    show(image)
    waitForMs(10)
  }
}

键盘输入

如果我们想要更进一步,我们可以通过窗口与用户进行所有交互。我们可以使用 drawString 函数在窗口中显示文本。下一步是允许用户通过在窗口中按键来控制程序。

这是使用 waitKey 函数完成的。它会等待用户按下一个键,然后返回所按下的字符。这里有一个简单的测试程序(uitest3.kt):

import org.otfried.cs109ui.*
import org.otfried.cs109ui.ImageCanvas
import org.otfried.cs109.Color
import org.otfried.cs109.DrawStyle

import java.awt.image.BufferedImage

fun draw(image: BufferedImage, color: Color) {
  val g = ImageCanvas(image)
  g.clear(Color.WHITE)
  g.setColor(color)
  g.drawRectangle(100.0, 100.0, 300.0, 300.0)
  g.done()
}

fun main(args: Array<String>) {
  setTitle("CS109 UI Keyboard Input Test")

  val image = BufferedImage(500, 500, BufferedImage.TYPE_INT_RGB)

  draw(image, Color.RED)
  show(image)

  println("Now press some keys inside the CS109 UI windows")
  println("Pressing 'q' will terminate the program")

  while (true) {
    val ch = waitKey()
    println("Got character $ch")
    if (ch == 'q')
      close()  // close window and terminate program
  }
}

窗口出现后,尝试在窗口上聚焦时键入键。您应该在终端上看到消息“Got character”。按下“q”键将终止程序。

对话框

我们可以通过使用额外的弹出窗口(通常称为“对话框”)使我们的程序更加专业。

最简单的方法只是向用户显示一条消息。用户必须按下“确定”才能继续程序。例如,使用这段代码

  showMessage("This is a message")

我们将得到这个弹出窗口:

showMessage 窗口

稍微有趣的是,我们可以询问一个是/否问题,用户可以通过按下两个按钮中的一个来决定:

  val yesno: Boolean = askYesNo("Do you like this?")

它看起来像这样:

askYesNo 窗口

askYesNo 函数将用户的选择作为布尔值返回。

最后,我们可以要求用户输入一个字符串:

  val name: String = inputString("What is your name?")

它看起来像这样:

inputString 窗口

再次,该函数返回用户输入的字符串。(如果用户按下“取消”或关闭弹出窗口,则返回空字符串。)

高级功能

在模块中还有一些更多的可能性:从窗口接收鼠标按钮点击,另外两个对话框,以及设置用户输入的超时。当这些对项目有需要时,我会记录这些内容,但你也可以随时查看示例代码

编译成 JavaScript 并在浏览器中运行

Kotlin 自带一个编译器,将 Kotlin 源代码编译成 JavaScript,准备好包含在网页中。因此,你可以创建完全在 Web 浏览器中运行的应用程序。

为了帮助你入门,这里有一些提示和一个示例项目。

我们首先创建一个新目录。我们将在此目录中创建三个文件:

  • canvas.html:浏览器加载的网页;

  • canvas.js:从网页加载的 JavaScript 代码;

  • jscanvas.js:我们将使用的 org.otfried.cs109js.JsCanvas 类的 JavaScript 代码;

  • kotlin.js:JavaScript 的 Kotlin 标准库。

让我们从最后一个开始:kotlin.js 包含在 Kotlin 编译器附带的 kotlin-jslib.jar 文件中。用你喜欢的工具提取 kotlin.js 并将其放在你的新目录中(jar 文件只是被简单压缩的存档)。

文件 jscanvas.js 也很容易获得:它包含在你之前安装过的档案 cs109-jslib.jar 中。

这是一个示例 HTML 文件(canvas.html):

<html>
<head>
<meta charset="utf-8">
<script src="kotlin.js"></script>
<script src="jscanvas.js"></script>
<script src="canvas.js"></script>
</head>
<body onload="javascript:Kotlin.modules['canvas'].canvas.start();">
<canvas width="600" height="300" id="canvas"></canvas>
<div id="text">This is a fun paragraph. </div>
</body>
</html>

在文档的头部,我们加载两个库和我们自己的 JavaScript 代码 canvas.js(我们仍然需要生成)。在 body 中,我们包含两个元素:一个 HTML 画布,这是一个我们可以从 JavaScript 自由绘制的矩形区域,和一个文本分区(稍后我们可以从 JavaScript 中更新)。

现在从你的浏览器加载此文件:你应该看到一个空白的白色矩形和下面的文本。

此时,我们应该在 Web 浏览器中打开 JavaScript 控制台。这是 JavaScript 的所有输出,以及任何错误消息,将会显示的地方。在 Firefox 中,使用菜单、开发者、Web 控制台。在 Safari 中,转到设置并勾选“显示开发菜单”复选框,然后转到新出现的开发菜单并打开控制台。

到目前为止,控制台应该只显示一个错误消息:

TypeError: Kotlin.modules.canvas is undefined

这是 body 标签中 onload 属性的响应。在这里,我们指示 JavaScript 在网页完全加载后(这一点很重要,因为我们希望所有 HTML 元素在代码运行时都存在)在 canvas.js 脚本中执行包 canvas 中的函数 start。

剩下的是编写这个 start 函数并生成 canvas.js。这里是一个第一个示例(canvas1.kt):

package canvas

import org.otfried.cs109js.JsCanvas
import org.otfried.cs109.Color

import kotlin.browser.document
import org.w3c.dom.*

fun start() {
  println("Hello World from Javascript")

  val canvas = JsCanvas("canvas")
  canvas.clear(Color.GREEN)

  val text = document.getElementById("text")
  text?.appendChild(document.createTextNode("Was du hier liest ist kein Gedicht.")) 
}

我们需要通过以下方式将该文件编译成 JavaScript:

$ kotlinc-js -output canvas.js canvas1.kt 

kotlinc-js 是 Kotlin 到 JavaScript 的编译器。我指定输出文件名(在从 JavaScript 调用时对应于模块 Kotlin.modules['canvas'])。开始处的包声明将 start 函数放在 canvas 包中,因此需要从 JavaScript 中调用它作为

Kotlin.modules['canvas'].canvas.start();

这正是我们在 onload 属性中编写的内容。

现在重新加载网页,画布矩形会变成绿色,底部的文本也会改变。在 JavaScript 控制台中,你应该会看到 println 语句的输出。

让我们通过添加一些颜色、文本和透明度来使我们的绘图更加有趣(canvas2.kt):

package canvas

import org.otfried.cs109js.JsCanvas
import org.otfried.cs109.Color

object Controller {
  val canvas = JsCanvas("canvas")
  var x = 30.0
  var y = 50.0
  var alpha = 45.0

  fun draw() {
    canvas.clear(Color.WHITE)
    canvas.setAlpha(48)
    canvas.setColor(Color.GREEN)
    canvas.drawRectangle(10.0, 10.0, 100.0, 100.0)
    canvas.setAlpha(255) // opaque
    for (i in 0 .. 5) {
      for (j in 0 .. 5) {
        canvas.setColor(Color(Math.floor(255-42.5*i), Math.floor(255-42.5*j), 0))
        canvas.drawRectangle(150.0 + j*25.0, i*25.0, 25.0, 25.0)
      }
    }
    canvas.translate(x, y)
    canvas.setAlpha(128)
    canvas.rotate(alpha)
    canvas.setColor(Color.RED)
    canvas.setFont(32.0)
    canvas.drawText("Lovely", 0.0, 0.0)
  }
}

fun start() {
  println("Canvas2 starting...")
  Controller.draw()
}

我们再次编译:

$ kotlinc-js -output canvas.js canvas2.kt 

现在重新加载网页,画布应该包含一个透明的绿色正方形,在上面有一些半透明的红色文本,右侧还有一个漂亮的颜色图案。

到目前为止都很顺利,现在让我们添加一些交互性。当你在画布上点击鼠标时,文本应该移动到这个位置。按下键盘上的 'a'、's'、'w' 和 'z' 键可以用来移动文本,按下 'j' 和 'k' 键可以旋转它(canvas3.kt):

package canvas

import org.otfried.cs109js.JsCanvas
import org.otfried.cs109.Color

import kotlin.browser.window
import org.w3c.dom.events.*

object Controller {
  val canvas = JsCanvas("canvas")
  var x = 30.0
  var y = 50.0
  var alpha = 45.0

  fun draw() {
    canvas.save()
    canvas.clear(Color.WHITE)
    canvas.setAlpha(48)
    canvas.setColor(Color.GREEN)
    canvas.drawRectangle(10.0, 10.0, 100.0, 100.0)
    canvas.setAlpha(255) // opaque
    for (i in 0 .. 5) {
      for (j in 0 .. 5) {
        canvas.setColor(Color(Math.floor(255-42.5*i), Math.floor(255-42.5*j), 0))
        canvas.drawRectangle(150.0 + j*25.0, i*25.0, 25.0, 25.0)
      }
    }
    canvas.translate(x, y)
    canvas.setAlpha(128)
    canvas.rotate(alpha)
    canvas.setColor(Color.RED)
    canvas.setFont(32.0)
    canvas.drawText("Lovely", 0.0, 0.0)
    canvas.restore()
  }

  fun keyDown(e: Event) {
    val ek = e as KeyboardEvent
    var k = ek.key
    if (k === undefined)
      k = "${ek.keyCode.toChar().toLowerCase()}"
    when (k) {
    "a" -> x -= 3
    "s" -> x += 3
    "w" -> y -= 3
    "z" -> y += 3
    "j" -> alpha += 10.0
    "k" -> alpha -= 10.0
    else -> return
    }
    draw()
    e.preventDefault()
  }

  fun mouseDown(e: Event) {
    val em = e as MouseEvent
    x = em.offsetX
    y = em.offsetY
    draw()
  }
}

fun start() {
  println("Canvas3 starting...")
  println("Active keys are aswzjk")
  Controller.draw()
  window.addEventListener("keydown", { Controller.keyDown(it) }, true)
  window.addEventListener("mousedown", { Controller.mouseDown(it) }, true)
}

我们再次编译:

$ kotlinc-js -output canvas.js canvas3.kt 

重新加载网页。现在你应该能够点击画布并看到文本移动到这个位置。当你按下其中一个活动键时,文本应该移动和旋转。

最后,让我们给我们的绘图添加一些动画效果。按下 'g' 键开始动画,并再次暂停。现在的代码还会调整画布大小以填满整个浏览器窗口,只留下一点空间给滚动条和底部的文本(canvas4.kt):

package canvas

import org.otfried.cs109js.JsCanvas
import org.otfried.cs109.Color

import kotlin.browser.window
import org.w3c.dom.events.*

object Controller {
  val canvas = JsCanvas("canvas")
  // change canvas size to fill entire browser window
  init {
    canvas.canvas.width = window.innerWidth.toInt() - 20
    canvas.canvas.height = window.innerHeight.toInt() - 50
  }

  var x = 30.0
  var y = 50.0
  var alpha = 45.0
  var animate = false
  var timeStamp = 0.0

  fun draw() {
    canvas.save()
    canvas.clear(Color.WHITE)
    canvas.setAlpha(48)
    canvas.setColor(Color.GREEN)
    canvas.drawRectangle(10.0, 10.0, 100.0, 100.0)
    canvas.setAlpha(255) // opaque
    for (i in 0 .. 5) {
      for (j in 0 .. 5) {
        canvas.setColor(Color(Math.floor(255-42.5*i), Math.floor(255-42.5*j), 0))
        canvas.drawRectangle(150.0 + j*25.0, i*25.0, 25.0, 25.0)
      }
    }
    canvas.translate(x, y)
    canvas.setAlpha(128)
    canvas.rotate(alpha)
    canvas.setColor(Color.RED)
    canvas.setFont(32.0)
    canvas.drawText("Lovely", 0.0, 0.0)
    canvas.restore()
    if (animate)
      window.requestAnimationFrame { animate(it) }
  }

  fun animate(s: Double) {
    val delta = s - timeStamp
    timeStamp = s
    x += delta / 2.0
    if (x > canvas.width)
      x = 0.0
    alpha += 0.3 * delta
    if (alpha >= 360.0)
      alpha = 0.0
    draw()
  }

  fun keyDown(e: Event) {
    val ek = e as KeyboardEvent
    var k = ek.key
    if (k === undefined)
      k = "${ek.keyCode.toChar().toLowerCase()}"
    when (k) {
    "a" -> x -= 3
    "s" -> x += 3
    "w" -> y -= 3
    "z" -> y += 3
    "j" -> alpha += 10.0
    "k" -> alpha -= 10.0
    "g" -> {
        if (!animate)
          timeStamp = window.performance.now()
        animate = !animate
    }
    else -> return
    }
    draw()
    e.preventDefault()
  }

  fun mouseDown(e: Event) {
    val em = e as MouseEvent
    x = em.offsetX
    y = em.offsetY
    draw()
  }
}

fun start() {
  println("Canvas3 starting...")
  println("Active keys are aswzjk and g for animation")
  Controller.draw()
  window.addEventListener("keydown", { Controller.keyDown(it) }, true)
  window.addEventListener("mousedown", { Controller.mouseDown(it) }, true)
}

现在你可以编写自己的 web 应用程序了。或者尝试一下 我的实现 的 2048 游戏。移动键是 'u'、'd'、'l' 和 'r'。

直接使用 Javascript Canvas API

我的 JsCanvas 类很方便,因为你可以使用相同的绘图代码来绘制位图和 CS109 Android 框架,当然,你也可以从 HTML 文件中移除 jscanvas.js 文件,并直接使用 JavaScript 函数来绘制到画布上。查看 canvas-nojscanvas.kt 可以看到没有 JsCanvas 的最新版本是什么样子的。你可能会发现 Mozilla 文档 有所帮助:虽然它是为 JavaScript 设计的,但其中很多代码在 Kotlin 中也可以不变地使用。

Android 迷你应用框架

Android 软件开发工具包非常复杂,超出了本教程的范围。相反,我们将看到如何仅使用我们迄今学到的工具,使用“KAIST CS109 迷你应用框架”为 Android 编写基于画布的应用程序。

  • 第一个示例

  • 绘图

  • 轻点、双击和轻扫

  • 对话框

  • 定时器和动画

  • 菜单

  • 使用手机传感器

参考资料

posted @ 2026-02-20 16:42  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报