为了更好地理解数据类,让我们首先了解上述三个函数,并看看当我们重写它们时需要做什么。
重写 equals()
引用相等性
两位父亲在当地公园相遇,他们的女儿跟在身后,此时他们每个人都不小心松开了女儿的手。
这两个女孩都叫 Fiona。然而,尽管她们有相同的名字她们是两个不同的>女孩,所以当父亲们转过身来时,重要的是每个人都要找到自己的>女儿,而不是他看到的第一个叫 Fiona 的女孩!
类似地,在 Kotlin 中,有时我们想检查一个对象是否是我们想要的实例。为此,我们可以使用相等运算符,即两个相邻的等号 ==。让我们通过创建一个类来表示这两个女孩,并实例化每个对象来演示这一点。
class Child(val name: String)
val fiona1 = Child("Fiona")
val fiona2 = Child("Fiona")
println(fiona1 == fiona2) // false
当在这些对象上使用相等运算符时,我们看到它们不相等。换句话说,它们是两个不同的 Child 对象。这正是我们想要的!毕竟,这两个女孩是不同的孩子,尽管她们都叫同一个名字。
当然,如果我们把一个 Child 对象赋值给两个不同的变量,那么相等运算符会表明这两个变量是相等的,因为它们都引用了同一个对象实例。
val fiona1 = Child("Fiona")
val fiona2 = fiona1
println(fiona1 == fiona2) // true
在上面的代码中,fiona1 和 fiona2 都被赋值为同一个对象,所以它们是相等的。
这种相等性有时被称为引用相等性,1因为只要两个变量引用同一个对象实例,它们就会被视为相等。默认情况下,当我们将相等运算符用于比较两个对象时,Kotlin 使用引用相等性。
值相等性
第二天,两位父亲再次在公园相遇。这次,他们每个人同时不小心松开了一张五美元钞票。
当他们转过身来时,他们看着地上的钞票,但不确定哪张钞票属于哪个人。
然而,每张钞票的价值相同——都是五美元。与前一天的情况不同,当时对每位父亲来说,捡起他自己的女儿很重要,但在这种情况下,每个人捡起的是不是他掉落的那张钞票并不重要。只要他们每人捡起一张五美元钞票就可以了,因为两张钞票是相等的。换句话说,有些东西只要具有相同的特征就可以互换。
类似地,在 Kotlin 中,当对象根据其属性值而不是其身份被视为相等时,这通常被称为值相等性。2
既然我们认为两张五美元钞票彼此相等,我们可能希望 DollarBill 类具有值相等性而不是引用相等性。以下代码与上面的清单 15.1大致相同,但使用 DollarBill 类而不是 Child 类。
class DollarBill(val amount: Int)
val bill1 = DollarBill(5)
val bill2 = DollarBill(5)
// We want this to be true!
println(bill1 == bill2) // false正如你所看到的,当我们运行这段代码时,得到的是 false。我们如何才能让这段代码打印出 true?
在底层,当我们使用相等运算符时,它调用 equals() 函数来确定两个对象是否被视为相等。从 Any 类传递下来的 equals() 实现将检查两个变量是否引用同一个对象,这就是引用相等性是默认值的原因。因此,如果我们希望相等运算符对特定类表现不同,我们就需要在该类中重写 equals() 函数。
对于这个 DollarBill 类,实现这一点的一种简单方法是手动委托 equals() 调用到 amount 属性。让我们尝试在 DollarBill 类中重写这个函数。请注意,在 Any 类中,这个函数的参数类型是 Any?,返回类型是 Boolean,所以我们在这里的 equals() 函数中也使用相同的类型。3
class DollarBill(val amount: Int) {
override fun equals(other: Any?) =
amount.equals(other.amount)
}嗯,这不管用。问题是 other 参数的类型是 Any?,这样 Kotlin 中的任何两个对象都可以相互比较,即使它们的类型不匹配。由于 other 可能实际上不是一个 DollarBill 对象,它不一定有一个名为 amount 的属性。
要解决这个问题,我们首先需要检查 other 的类型是否是 DollarBill。
- 如果是,我们就可以使用智能转换来比较
amount值。 - 如果不是,我们就返回
false。
class DollarBill(val amount: Int) {
override fun equals(other: Any?) =
if (other is DollarBill) amount.equals(other.amount) else false
}做了这个修改后,只要满足以下条件,两个 DollarBill 对象就会彼此相等……
- 它们都是
DollarBill的实例。 - 它们都有相同的
amount属性值。
当我们再次运行代码时,我们会看到 bill 和 bill2 现在被视为相等了!
val bill1 = DollarBill(5)
val bill2 = DollarBill(5)
println(bill1 == bill2) // true因此,通过重写 equals() 函数,我们能够为我们的 DollarBill 类赋予值相等性而不是引用相等性!但是,当我们尝试将此类与某些集合类型一起使用时,会出现另一个问题。这就引出了 hashCode() 函数。
重写 hashCode()
小 Fiona 正在开始收集当代美元钞票。为了完成她的收藏,她需要每种七种面额的一张钞票:1 美元、2 美元、5 美元、10 美元、20 美元、50 美元和 100 美元。
你可能还记得第 8 章,多个对象可以存储在称为 Set 的集合类型中,这保证了它的每个元素都是唯一的。换句话说,如果你试图向一个集合添加它已经包含的对象,什么都不会改变。
我们可以用 Set 来跟踪 Fiona 的美元钞票。
val denominations = mutableSetOf<DollarBill>()Fiona 目前已经收集了三种不同面额的钞票:1 美元、2 美元和 5 美元。我们可以把这些钱加到她的收藏中,然后打印出数量,以确认里面有三种不同的物品。
val denominations = mutableSetOf<DollarBill>()
denominations.add(DollarBill(1))
denominations.add(DollarBill(2))
denominations.add(DollarBill(5))
println(denominations.size) // 3完美!
有一天,Fiona 发现了一张一美元钞票,但不记得是否已经收集过。当我们尝试将第二张一美元钞票添加到集合中时会发生什么?
val denominations = mutableSetOf<DollarBill>()
denominations.add(DollarBill(1))
denominations.add(DollarBill(2))
denominations.add(DollarBill(5))
denominations.add(DollarBill(1)) // duplicate entry!
println(denominations.size) // 4哎呀!而不是拒绝重复的,denominations 集合愉快地将其作为第四个元素包含了!为什么会出现这种情况?毕竟,我们在 清单 15.5 中已经重写了 DollarBill 类的 equals() 函数!
Set 本身只是一个接口,当我们调用 mutableSetOf() 时,它返回一个名为 LinkedHashSet 的 Set 实现。
LinkedHashSet 不主要使用 equals() 函数来确定它是否已经包含一个对象。相反,它首先使用 hashCode() 函数,只有当集合中已经存在具有相同哈希码的对象时才会调用 equals()。4
这就是为什么我们应该在重写 equals() 的任何时候都重写 hashCode()。在我们的例子中,由于我们已经将 equals() 委托给了 amount 属性,我们也可以对 hashCode() 做同样的事情。
class DollarBill(val amount: Int) {
override fun equals(other: Any?) =
if (other is DollarBill) amount.equals(other.amount) else false
override fun hashCode() = amount.hashCode()
}简单!做了这个修改后,当我们再次运行 清单 15.9 时,集合将正确地忽略重复的一美元钞票,结果大小为 3 而不是 4。
val denominations = mutableSetOf<DollarBill>()
denominations.add(DollarBill(1))
denominations.add(DollarBill(2))
denominations.add(DollarBill(5))
denominations.add(DollarBill(1)) // duplicate entry!
println(denominations.size) // 3 - Success!嗯,我们的 DollarBill 类几乎在做我们想要的一切。在我们了解数据类如何让我们的生活更轻松之前,让我们重写 Any 类的第三个也是最后一个函数 toString()。
重写 toString()
虽然我们经常使用 println() 函数,但我们还没有仔细研究过它。我们知道,我们可以向这个函数传递一个参数,它会把参数打印到屏幕上。我们经常向它传递一个字符串,像这样:
println("Hello, Kotlin!")但是,我们不限于传递字符串;我们可以传递任何对象给这个函数!例如,我们可以将 DollarBill 类的一个实例传递给它。
println(DollarBill(100))当我们运行这个时,会看到如下输出:
DollarBill@64
当我们将一个对象传递给 println() 函数时,它会调用该对象的 toString() 函数。默认情况下,此函数返回如上所示的字符串。这个字符串有三个部分:
- 类的名称
- 一个
@符号 - 对象哈希码的十六进制5
你可能还记得,DollarBill 类在 清单 15.10 中将 hashCode() 调用委托给了 amount 整数。对于整数,哈希码就是整数本身的值。因此,如果我们为整数变量赋值 100,它的哈希码也是 100。但是,由于 toString() 将该哈希码转换为十六进制,它显示为 64。
这个类中的 toString() 函数如果能显示类的名称和 amount 属性的值(以十进制而不是十六进制表示)会更有帮助。当我们这样做时,最好包含一个标签来指示该数字代表什么(即”amount”)。
让我们重写 toString() 函数并返回一个包含这些内容的字符串。
class DollarBill(val amount: Int) {
override fun equals(other: Any?) =
if (other is DollarBill) amount.equals(other.amount) else false
override fun hashCode() = amount.hashCode()
override fun toString() = "DollarBill(amount=$amount)"
}做了这个修改后,当我们用 DollarBill 对象调用 println() 时,会得到更有用的输出。
println(DollarBill(100))DollarBill(amount=100)
太棒了!
嗯,我们现在对我们的 DollarBill 类做了很多修改。让我们回顾一下我们所做的所有事情。
- 我们使得两个
DollarBill实例彼此相等,只要它们具有相同的amount值。换句话说,我们赋予了它值相等性而不是引用相等性。 - 我们使得它们在集合和映射中也被视为相等。
- 我们创建了一个更有用的
toString()实现。
我们通过重写 Any 类的三个函数——equals()、hashCode() 和 toString()——实现了所有这些功能。既然我们理解了为什么可能要重写这些函数,现在终于可以看看数据类如何让我们的生活更轻松了!
数据类简介
在我们做了所有修改之后,这是我们最终的代码:
class DollarBill(val amount: Int) {
override fun equals(other: Any?) =
if (other is DollarBill) amount.equals(other.amount) else false
override fun hashCode() = amount.hashCode()
override fun toString() = "DollarBill(amount=$amount)"
}DollarBill 的例子相当简单。毕竟,它只有一个单一属性。如果我们尝试为包含多个属性的类实现相同的功能,会发生什么?实现 toString() 是很直接的。然而,equals() 会更困难一些,而 hashCode() 会更复杂!
好消息是,无论我们的类有一个属性还是十二个属性,Kotlin 都可以自动完成我们在本章中已经做的所有事情——重写 equals()、hashCode() 和 toString()——我们只需要声明我们的类是一个数据类。为此,我们只需在类声明前添加 data 关键字,像这样:
data class DollarBill(val amount: Int)在这段代码中,我们没有为 equals()、hashCode() 或 toString() 提供重写。事实上,这个 DollarBill 根本没有类体!然而,仅用一行代码,这个类就做到了 清单 15.16 中所有的事情!
val bill1 = DollarBill(100)
val bill2 = DollarBill(100)
bill1 == bill2 // true
mutableSetOf(bill1, bill2).size // 1
println(bill1) // DollarBill(amount=100)同样,DollarBill 类只包含一个单一属性,但数据类可以轻松地为具有多个属性的类提供结构相等性和良好的 toString() 结果。例如,这是一个具有三个属性的 Address 类。
data class Address(
val street: String,
val city: String,
val postalCode: String
)正如接下来的代码清单所演示的,Address 的实例使用值相等性,并且它们打印出来也很好看。
val address1 = Address("123 Maple Ave", "Berrytown", "56789")
val address2 = Address("123 Maple Ave", "Berrytown", "56789")
address1 == address2 // true
mutableSetOf(address1, address2).size // 1
println(address1) // Address(street=123 Maple Ave, city=Berrytown, postalCode=56789)我们已经看到数据类给了我们很多能力,自动生成有用的 equals()、hashCode() 和 toString() 实现。然而,超能力还不止于此!数据类还包括一个名为 copy() 的函数,我们接下来会看到。
复制数据类
数据类的属性可以用 val 或 var 声明,但 Kotlin 开发者倾向于主要将数据类用于不可变数据。换句话说,在数据类中只使用 val 属性是很常见的。
如你所知,当一个类有一个可变属性时,我们可以简单地为其分配一个新值。例如,这里我们有一个表示一本书的数据类。它的 title 属性是只读的,但其 price 属性是可变的。
data class Book(val title: String, var price: Int)当书籍价格上涨时,我们可以直接设置它的新值。
val book = Book("The Malt Shop Caper", 18)
// The price just went up!
book.price = 20当 price 属性用 var 声明时,更改它的值是足够简单的。但是用 val 声明的属性呢?
当然,我们不能更改 val 属性的值,但我们可以创建整个对象的一个副本,在新副本中替换新值。这种方法类似于我们在第 8 章中使用的方法,当时我们通过使用加号运算符将元素添加到现有列表来创建新列表。
让我们更新 Book 类,使其 price 属性用 val 声明。
data class Book(val title: String, val price: Int)做了这个修改后,当价格上涨时,我们可以创建一个名为 newBook 的新变量,它与原书具有相同的标题,但具有新价格。
val book = Book("The Malt Shop Caper", 18)
// The price just went up!
val newBook = Book(book.title, 20)现在,用只有两个属性来做这件事是足够简单的,但当我们添加更多属性时,制作副本变得更加乏味。为了演示这一点,让我们向 Book 类再添加四个属性。
data class Book(
val title: String,
val price: Int,
val author: String,
val width: Int,
val height: Int,
val isbn: String,
)
val book = Book("The Malt Shop Caper", 18, "Slim Chancery", 6, 9, "020516918K")
// The price just went up!
val newBook = Book(book.title, 20, book.author, book.height, book.width, book.isbn)这是很多样板代码,当将值从旧对象传递到新对象时,很容易不小心搞混一些东西。在上面的代码中,你注意到高度和宽度值被交换了吗?
为了避免所有这些样板代码并减少这类错误的可能性,数据类有一个强大的函数叫做 copy()。对于上面的书的例子,我们可以直接调用原始 book 的 copy(),而不是手动将每个属性传递给 Book 构造函数,并给它一个新值来代替 price,像这样。
val newBook = book.copy(price = 20)copy() 函数为数据类中的每个属性都有一个参数。由于我们的 Book 数据类有 6 个属性,它的 copy() 函数共有 6 个参数,每个参数都默认为属性的当前值。
调用 copy() 函数时,只需为你想要更改的任何属性包含命名参数,而为你想要保持不变的任何属性省略参数。在 清单 15.26 中,我们只提供了 price 参数,所以 newBook 的价格将是 20,但所有其他属性将与原始 book 对象中的值相同。
正如你所看到的,copy() 函数是处理不可变类的一种难以置信的强大方式,它允许我们的代码有效地更改数据,而实际上不改变单个属性!
数据类还提供了另一个功能,即解构其属性的能力,让我们深入了解一下!
解构
当我们有很多都涉及某个概念的值时,通常将它们放在一起是有意义的。例如,如果我们有标题、价格、作者、宽度、高度和 ISBN,我们通常不想将它们作为单独的变量来处理。相反,我们希望将它们分组到一个结构中,比如 Book 类。
这是我们之前使用的Book数据类,它将上面提到的所有变量组合在一起。
data class Book(
val title: String,
val price: Int,
val author: String,
val width: Int,
val height: Int,
val isbn: String,
)当我们像这样将值组装成一个类时,这些不同的值都彼此关联并且共同代表书籍的概念,这是很清楚的。这通常使开发者更容易理解——例如,title 代表书的标题而不是电影的标题,这很清楚。这也使我们方便地将所有这些属性从一个函数传递到另一个函数——而不是将每个属性作为单独的参数传递,我们可以直接将整个 Book 对象传递。6
所以 again,将关联值放在一起到一个结构中通常是有意义的。
然而,有时将单个值从结构中分离出来也是有意义的,这样它们就存储在单独的变量中。
这被称为解构。当然,我们可以手动完成。例如,在下面的代码中,我们将 book 对象的所有六个属性提取到单独的变量中,其中一些名称与原始属性不同。
val title = book.title
val cost = book.price
val author = book.author
val widthInInches = book.width
val heightInInches = book.height
val isbn = book.isbn在处理数据类时,我们可以使用解构赋值来自动将每个属性提取到变量中,而不必手动提取每个属性。为此,不要声明单个变量名,而是声明多个变量名(用逗号分隔),并将它们全部放在括号中,像这样:
val (title, cost, author, widthInInches, heightInInches, isbn) = book这与清单 15.28中的代码做的是同样的事情,但全部在一个赋值语句中完成。请注意,这些值将根据属性在数据类主构造函数中出现的顺序进行赋值。对于上面的清单 15.29,这意味着title变量将被赋值为book.title的值,cost变量将被赋值为book.price的值,以此类推。
解构赋值中输出的每个值——如 title、price、author 等——称为组件。7
最后,请注意在使用解构赋值时我们不必为所有组件赋值。例如,如果只需要从book对象中取出 title 和 price,可以选择在括号中只包含两个变量名。
val (title, cost) = book解构与标准库
解构不仅适用于我们自己的数据类。Kotlin 标准库中的一些类型也可以被解构。例如,在第 9 章中,我们使用了一个名为Pair的类,它可以与解构赋值一起使用。
val association = "Nail" to "Hammer"
val (hardware, tool) = association使用解构最常见的方式之一是与lambda 参数一起使用。例如,假设我们有一个包含硬件和工具的映射,而不是单独的关联。
val toolbox = mapOf(
"Nail" to "Hammer",
"Bolt" to "Wrench",
"Screw" to "Screwdriver"
)当我们遍历这些工具时,lambda 参数有一个名为Map.Entry的类型,它有两个属性——key和value。这就是我们在清单 9.20中使用它的方式。
toolbox.forEach { entry ->
println("Use a ${entry.value} on a ${entry.key}")
}Map.Entry对象可以被解构,因此我们可以使用括号和两个变量名来替代使用entry作为这个 lambda 的参数,像这样:
toolbox.forEach { (hardware, tool) ->
println("Use a $tool on a $hardware")
}当我们这样做时,这个 lambda 的参数将被解构为两个不同的变量——第一个是条目的键,第二个是条目的值。在上面的代码中,键将被赋值给名为 hardware 的变量,值将被赋值给名为 tool 的变量。
这里的解构是个好主意,因为 hardware 和 tool 是与我们的代码解决的问题相关的术语。我们可以说这些术语处于问题域或业务域。将这些术语与诸如条目、键和值等术语进行对比,后者更专注于我们用来实现解决方案的数据结构。我们可以说那些术语处于技术域。
现在让我们考虑这样一种情况:我们只想打印出工具箱中的工具,而不是相应的硬件。当然,我们可以这样做:
toolbox.forEach { (hardware, tool) ->
println("Found a $tool")
}在这里,hardware 变量与 lambda 无关。一个值被赋给它,但我们没有使用它,所以它只是噪音。换句话说,当我们看这段代码时,我们必须读取它,但它不影响代码的工作方式。在这种情况下,当其中一个组件不需要时,我们可以用下划线 _ 代替不相关变量的名称,像这样:
toolbox.forEach { (_, tool) ->
println("Found a $tool")
}做了这个更改后,我们不再需要关心一个未使用的 hardware 变量。
解构非数据类
解构并不局限于数据类。任何对象都可以被解构,只要它的类包含正确的函数。秘诀是添加名为 component1()、component2()、component3() 等的函数。这些被称为 componentN() 函数。例如,这里是本章开头来自 清单 15.1 的 Child 类,但这个类增加了一个孩子年龄的新属性。
class Child(val name: String, val age: Int)如果我们想给这个类添加解构的能力,不必将其转换为数据类。相反,我们可以简单地添加名为component1()和component2()的函数,每个函数返回其中一个属性。
class Child(val name: String, val age: Int) {
operator fun component1() = name
operator fun component2() = age
}有了这个,我们现在可以使用解构来提取孩子的姓名和年龄。
val children = listOf(
Child("Fiona", 5),
Child("Jack", 7)
)
children.forEach { (name, age) ->
println("$name is $age years old.")
}请注意,我们必须在 清单 15.38 中的两个函数上包含 operator 修饰符。这是我们第一次创建运算符函数。当一个函数包含 operator 修饰符时,它仍然可以像任何其他函数一样调用,但它也有某种特殊用途——而它所服务的特定目的取决于函数的名称。当一个函数的命名如 清单 15.38 中所示(即像 componentN()),这种特殊用途是该函数将在对象被解构时使用。
我们甚至可以创建一个扩展函数,为一个没有源代码的类添加解构能力。例如,通过一点独创性,我们可以创建自己的扩展函数来将 Double 解构为其整数和小数部分。
operator fun Double.component1() = toString().split(".").first().toInt()
operator fun Double.component2() = toString().split(".").last().toInt()
val (integral, fractional) = 108.245
println(integral) // 108
println(fractional) // 245所以,解构在某些情况下是有帮助的,数据类是轻松为我们的类添加解构能力的一种方式!
数据类的局限性
正如我们在本章中所看到的,数据类给了我们很多便利!
- 它们为值相等性创建
equals()和hashCode()函数。 - 它们创建美观的
toString()输出。 - 它们使创建对象的副本变得容易。
- 它们可以用于解构赋值。
但是,当我们声明一个类为数据类时,我们也会放弃一些东西,让我们快速看一下这些。
数据类与继承
数据类的第一个也是最重要的缺点是它不能被另一个类扩展。换句话说,你不能将 abstract 或 open 修饰符添加到数据类。但是,数据类本身可以扩展另一个类。
尽管数据类可以扩展另一个类,但如果那个超类需要构造函数参数,事情会很快变得复杂,这是因为第二个缺点,它与构造函数参数有关。
构造函数参数
第二个缺点是数据类中的所有构造函数参数必须是属性参数。换句话说,每一个都必须用 val 或 var 声明。这意味着不可能添加一个仅传递给超类构造函数的构造函数参数。
open class Product(val id: String)
data class Book(id: String, val title: String) : Product(id)此外,数据类的主构造函数必须至少有一个参数。
最后,虽然数据类可能有不属于其构造函数的属性,但这些属性不会被包含在任何生成的函数中。例如,在下面的代码中,serialNumber 属性不在主构造函数中。
data class DollarBill(val amount: Int) {
var serialNumber: String? = null
}像serialNumber这样的属性不会在equals()、hashCode()或toString()中被考虑,因为它是在类的函数体中声明的,而不是在主构造函数中。它不能使用copy()调用,也不会用于解构赋值。为了演示这一点,下面的代码展示了两个DollarBill对象如何被认为是相等的,即使它们具有不同的serialNumber值。
val bill1 = DollarBill(5).apply { serialNumber = "QB12345678T" }
val bill2 = DollarBill(5).apply { serialNumber = "IE87654321C" }
println(bill1 == bill2) // true, despite different serial numbers尽管有这些缺点,数据类还是非常有用的,特别是当处理主要是为持有属性而设计的不可变类时。
总结
在本章中,我们学习了关于数据类和解构的全部内容,包括:
- 引用相等性和值相等性之间的区别。
- 如何手动重写
equals()和hashCode()来实现值相等性。 - 如何手动重写
toString(),使我们的类与println()配合良好。 - 如何声明数据类,这样我们就不必手动重写
equals()、hashCode()或toString()。 - 如何使用
copy()函数来更轻松地基于另一个对象的值创建对象。 - 如何在处理数据类时使用解构赋值。
- 如何增强非数据类以启用解构赋值。
- 如何使用扩展函数来启用解构赋值。
- 数据类的局限性。
在下一章中,我们将介绍另一个类修饰符,它将使我们可以穷举接口或抽象类的每一个可能的子类。下一章见!
感谢James Lorenzen审阅本章!
-
这有时也称为恒等相等性。 ↩︎
-
你也可能听到过称为内容相等性或结构相等性。 ↩︎
-
在重写函数时,我们通常使用与超类中同一函数指定的相同参数类型和返回类型。但是,Kotlin 也允许你指定更通用的参数类型或更具体的返回类型。这个特性称为型变,我们将在第 19 章学习泛型时进一步了解型变是如何工作的。 ↩︎
-
我们大多数人在使用数字时,每个数字有十个可能的值——0 到 9。由于有十个可能的值,这有时被称为十进制或十进制数系。十六进制是一种每个数字最多可以有 16 个值的进位制,所以也称为 Base-16。在 0-9 之后是 A、B、C、D、E 和 F。例如,十六进制中的”C”代表十进制中的 12,而十六进制中的 10 代表十进制的 16。 ↩︎
-
虽然这样很方便,但也值得考虑函数实际需要多少个对象值。当你传递的值超过函数所需时,这称为印记耦合,它会限制函数的可重用性。例如,如果函数只需要一个标题,最好只传递标题而不是整个书籍对象,因为这样它就不只能处理书籍标题——也许它还能处理电影和歌曲标题。 ↩︎
-
”组件”这个术语含义广泛,可以指软件开发和系统架构学科中的许多东西。这里,我们只是在解构这个非常狭窄的上下文中使用这个术语。 ↩︎