Skip to content
📖 Auto 版 Think Python本书目录

类与对象 ​

到此为止,我们已经定义了表示一天中的时间和一年中的某天的类,并创建了这些类的对象。我们还定义了创建、修改以及对这些对象进行计算的方法。

在本章中,我们将继续面向对象编程(OOP)的探索,定义表示几何对象的类,包括点、线和矩形。我们将编写创建和修改这些对象的方法。

我们将使用这些类来演示 OOP 主题,包括对象身份与等价、浅拷贝与深拷贝以及多态。

创建 Point ​

在计算机图形学中,屏幕上的位置通常用 x-y 平面中的一对坐标来表示。按照惯例,点 (0, 0) 通常表示屏幕的左上角,(x, y) 表示从原点向右 x 个单位、向下 y 个单位的点。

在 Auto 中有几种表示点的方式:

  • 可以将坐标分别存储在两个变量 x 和 y 中。

  • 可以将坐标存储为列表或元组的元素。

  • 可以创建一个新类型来将点表示为对象。

在面向对象编程中,最惯用的方式是创建一个新类型。为此,我们从 Point 的类定义开始。

auto
type Point {
    x: float,
    y: float,
}

fn init(&self, x: float = 0.0, y: float = 0.0) {
    self.x = x
    self.y = y
}

fn to_string(&self) -> str {
    return f"Point({self.x}, {self.y})"
}

init 方法将坐标作为参数并赋值给属性 x 和 y。to_string 方法返回 Point 的字符串表示。

现在我们可以这样实例化和显示一个 Point 对象:

auto
let start = Point(0.0, 0.0)
print(start)  // Point(0, 0)

如往常一样,程序员自定义类型用一个框表示,类型名在外面,属性在里面。

通常,程序员自定义类型是可变的,所以我们可以编写一个 translate 方法,接受两个数字 dx 和 dy,并将它们加到属性 x 和 y 上。

auto
fn translate(&self, dx: float, dy: float) {
    self.x += dx
    self.y += dy
}

这个方法将 Point 从平面中的一个位置移动到另一个位置。它就地修改现有对象。

创建 Line ​

现在让我们定义一个表示两点之间线段的类。如往常一样,我们从 init 方法和 to_string 方法开始。

auto
type Line {
    p1: Point,
    p2: Point,
}

fn init(&self, p1: Point, p2: Point) {
    self.p1 = p1
    self.p2 = p2
}

fn to_string(&self) -> str {
    return f"Line({self.p1}, {self.p2})"
}

有了这两个方法,我们就可以实例化和显示一个 Line 对象:

auto
let line = Line(start, end)
print(line)  // Line(Point(0, 0), Point(300, 150))

当我们调用 print 并传递 line 作为参数时,print 会在 line 上调用 to_string。to_string 方法使用 f-string 创建 line 的字符串表示。

f-string 中有两个花括号中的表达式 self.p1 和 self.p2。当这些表达式被求值时,结果是 Point 对象。然后,当它们被转换为字符串时,会调用 Point 类中的 to_string 方法。

这就是为什么当我们显示一个 Line 时,结果包含了 Point 对象的字符串表示。

这是组合(composition)的一个例子 -- 一个 Line 由两个 Point 对象组成。组合是将简单对象组合成更复杂对象的一种方式。

Point 和 Line 类(组合)

工作原理

Point 类型有 x 和 y 字段,带有 init 构造函数和 to_string 方法。__eq__ 方法基于坐标定义等价性,translate 就地修改点。Line 类型演示了组合 -- 它包含两个 Point 对象作为字段。Line 的 __eq__ 方法以任意顺序检查等价性(因为从 A 到 B 的线与从 B 到 A 的线相同)。midpoint 方法返回线段中点处的一个新 Point。

等价与身份 ​

假设我们创建两个坐标相同的点。

auto
let p1 = Point(200.0, 100.0)
let p2 = Point(200.0, 100.0)

如果我们使用 == 运算符比较它们,并且我们定义了 __eq__,它会检查属性是否相等。

auto
print(p1 == p2)  // true

但 is 运算符仍然表明它们是不同的对象。

auto
print(p1 is p2)  // false

不可能重写 is 运算符 -- 它总是检查对象是否是同一个。但对于程序员自定义类型,你可以重写 == 运算符,使其检查对象是否等价。你可以定义等价的含义。

创建 Rectangle ​

现在让我们定义一个表示矩形的类。为了简单起见,我们假设矩形是垂直或水平的,不是倾斜的。

我们应该使用什么属性来指定矩形的位置和大小?至少有两种可能:

  • 可以指定矩形的宽度和高度以及一个角的位置。

  • 可以指定两个对角。

让我们实现第一种方式。以下是类定义。

auto
type Rectangle {
    width: float,
    height: float,
    corner: Point,
}

fn init(&self, width: float, height: float, corner: Point) {
    self.width = width
    self.height = height
    self.corner = corner
}

fn to_string(&self) -> str {
    return f"Rectangle({self.width}, {self.height}, {self.corner})"
}

Rectangle 类型有三个字段:width、height 和 corner,其中 corner 是一个 Point 对象。这是组合的另一个例子。

现在我们可以实例化一个 Rectangle 对象,使用一个 Point 作为左上角的位置。

auto
let corner = Point(30.0, 20.0)
let box = Rectangle(100.0, 50.0, corner)
print(box)  // Rectangle(100, 50, Point(30, 20))

修改矩形 ​

现在让我们考虑两个修改矩形的方法:grow 和 translate。

grow 接受两个数字 dwidth 和 dheight,并将它们加到矩形的 width 和 height 属性上。

auto
fn grow(&self, dwidth: float, dheight: float) {
    self.width += dwidth
    self.height += dheight
}

translate 接受两个数字 dx 和 dy,并将矩形在 x 和 y 方向上移动给定的距离。

auto
fn translate(&self, dx: float, dy: float) {
    self.corner.translate(dx, dy)
}

深拷贝 ​

当我们使用 copy 复制一个 Rectangle 时,它复制了 Rectangle 对象,但没有复制它包含的 Point 对象。因此两个 Rectangle 对象是不同的,如预期的那样,但它们的 corner 属性指向同一个对象。

auto
let box2 = copy(box1)
print(box1 is box2)                  // false
print(box1.corner is box2.corner)    // true (共享!)

copy 所做的称为浅拷贝(shallow copy),因为它复制了对象但没有复制它包含的对象。结果是,改变一个 Rectangle 的 width 或 height 不会影响另一个,但改变共享的 Point 的属性会影响两者!

这种行为令人困惑且容易出错。例如,如果我们平移 box2,两个矩形似乎都移动了:

auto
box2.translate(30.0, 20.0)
print(box1)  // Rectangle(100, 50, Point(60, 40))  <-- bug!

幸运的是,Auto 提供了 deepcopy,它不仅复制对象,还复制它引用的对象,以及这些对象引用的对象,以此类推。这种操作称为深拷贝(deep copy)。

auto
let box4 = deepcopy(box3)
print(box3.corner is box4.corner)  // false (独立的!)

因为 box3 和 box4 是完全独立的对象,我们可以修改一个而不影响另一个。

带有浅拷贝和深拷贝的 Rectangle 类

工作原理

Rectangle 类型演示了与 Point 的组合。grow 方法改变宽度和高度,而 translate 委托给 Point 的 translate 方法。关键的教训是 copy(浅拷贝)和 deepcopy 之间的区别:浅拷贝共享嵌套对象,因此修改共享的 Point 会影响两个矩形。深拷贝创建所有嵌套对象的独立副本,使两个矩形完全分离。

多态 ​

在前面的例子中,我们使用了 Line 和 Rectangle 对象。我们可以做更有趣的事情:创建一个包含不同类型对象的列表,这些对象都提供一个 draw 方法,然后遍历列表并在每个对象上调用 draw。

auto
let shapes = [line1, line2, box3, box4]

for shape in shapes {
    shape.draw()
}

第一次和第二次循环时,shape 引用一个 Line 对象,所以当 draw 被调用时,运行的是 Line 类中定义的方法。

第三次和第四次循环时,shape 引用一个 Rectangle 对象,所以当 draw 被调用时,运行的是 Rectangle 类中定义的方法。

在某种意义上,每个对象知道如何绘制自己。这个特性称为多态(polymorphism)。这个词来自希腊语,意为"多种形态"。在面向对象编程中,多态是不同类型提供相同方法的能力,这使得可以通过在不同类型的对象上调用相同的方法来执行许多计算(如绘制形状)。

多态:不同类型,相同接口

工作原理

通过将 Line、Rectangle 和 Circle 对象放入一个列表并在每个对象上调用 draw() 来演示多态。即使对象是不同的类型,它们都提供 draw 方法。当在循环中调用 shape.draw() 时,Auto 根据对象的实际类型分派到正确的方法。draw_shapes 函数不需要知道列表中有什么类型 -- 它只调用 draw(),每个对象处理自己的绘制。

Python 程序员注意:

Auto 的多态与 Python 的工作方式相同。两种语言都使用动态分派,在运行时根据对象的类型调用正确的方法。

调试 ​

在本章中,我们遇到了一个微妙的 bug,因为我们创建了一个被两个 Rectangle 对象共享的 Point,然后修改了这个 Point。

一般来说,有两种方法可以避免这类问题:你可以避免共享对象,或者避免修改它们。

要避免共享对象,可以使用深拷贝,正如我们在本章中所做的那样。

要避免修改对象,可以考虑用创建新对象的纯函数替换像 translate 这样的非纯函数。例如,下面是一个 translated 版本,它创建一个新的 Point 而不修改其属性。

auto
fn translated(&self, dx: float = 0.0, dy: float = 0.0) -> Point {
    return Point(self.x + dx, self.y + dy)
}

创建一个新对象比修改现有对象需要更多时间,但这种差异在实践中很少重要。避免共享对象和非纯函数的程序通常更容易开发、测试和调试 -- 而最好的调试是你不需要做的调试。

术语表 ​

浅拷贝: 不复制嵌套对象的拷贝操作。

深拷贝: 同时复制嵌套对象的拷贝操作。

组合: 定义一个包含其他类型对象作为字段的能力。

多态: 方法或运算符处理多种类型对象的能力。

练习 ​

练习 ​

为 Line 类编写一个 __eq__ 方法,如果 Line 对象引用等价的 Point 对象(顺序不限),则返回 true。

练习 ​

编写一个名为 midpoint 的 Line 方法,计算线段的中点并将结果作为 Point 对象返回。

练习 ​

编写一个名为 midpoint 的 Rectangle 方法,找到矩形中心的点并将结果作为 Point 对象返回。

练习 ​

编写一个名为 Circle 的类定义,具有属性 center 和 radius,其中 center 是 Point 对象,radius 是一个数字。包含 init、to_string 和 draw 方法。然后通过将 Circle 添加到形状列表中并全部绘制来演示多态。

基于 MIT 许可发布。