命令行

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

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

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

https://gitee.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs109/img/8ee7fbd3323cb0be00748a9f288563b6.jpg

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

https://gitee.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs109/img/6ab888605046829de7684eef8201acda.jpg

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

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 ni 是奇数}\ n_i/2 & \text{如果 n i n_i ni 是偶数} \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。对象的类型决定了你可以对对象做什么。当你使用一个对象时,你应该把对象看作一个黑盒子,你不需要知道对象内部发生了什么。你只需要知道对象做了什么,而不需要知道它是如何实现功能的。

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

数据类

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

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

>>> 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 对象的两个名称:

https://gitee.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs109/img/ea003541d3ff7c76977d7e885234543f.jpg

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

局部变量

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

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

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

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 激活记录和堆,就在函数返回之前:

https://gitee.com/OpenDocCN/cs-notes-zh/raw/master/docs/kaist-cs109/img/f679cbdbf43030026dfb2e3a84416e25.jpg

垃圾回收

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)。

  • 寻找函数的不动点。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐