为什么拷贝对象如此可怕

本文将深入探讨对象的拷贝问题,并讨论如何编写正确的对象拷贝代码。此外,本文还将讨论代码的扩展性以及在 Java 和 C++ 中的拷贝构造函数和克隆方法。

C++ 和 Java 中的拷贝构造函数问题

在面向对象程序设计中,我们用类来构建概念模型,并在应用程序中创建和使用这些类的实例(对象)。我们经常需要在运行时拷贝对象。那么,如何拷贝对象呢?在某些编程语言中,比如 C++,程序员无需进行任何额外的操作就能拷贝对象。在默认情况下,我们可以通过拷贝来创建两个 “相等” 的对象,以 Person 类为例:

代码清单 1 Person 类
1
2
3
4
5
6
7
8
9
10
11
12
public class Person {
private:
Brain * pBrain;
int age;
public:
Person(Brain * pBrain, int age) {
this.pBrain = pBrain;
this.age = age;
}

...
};
C++

代码清单 1 Person 类

下面是一个拷贝 Person 对象的示例:

代码清单 2 拷贝 Person 对象
1
2
Person sam(new Brain(), 1);
Person bob = sam;
C++

代码清单 2 拷贝 Person 对象

上面代码的问题在于 sambob 最终都指向同一个 Brain!这就是著名的浅拷贝。这是 C++ 的默认行为。当然,作为 Person 类的使用者,你希望 sambob 这两个 Person 对象拥有各自的 Brain,而不是共享同一个 Brain。解决这个问题的一个方法是在运行时深拷贝对象,但这也会带来一些问题。请看下面这个例子:

代码清单 3 Person 类
1
2
3
4
5
6
7
8
9
10
11
12
public class Person {
private:
Brain * pBrain;
int age;
City * pCityOfResidence;
public:
Person(Brain * pBrain, int age, ...) {
this.pBrain = pBrain;
this.age = age;
...
}
};
C++

代码清单 3 聚合对象和关联对象

在这个例子中,如果 C++ 运行时要深拷贝每个对象,那么就会存在两个不同的 Brain 对象。一个是 sam 的,另一个是 bob 的。然而我们最终也会为 City 对象创建两个副本!这肯定不是我们想要的结果。我们只想拷贝 Brain 对象,但不想拷贝 City 对象,为什么呢?

这个问题的答案不在代码本身,而在代码之外。在面向对象程序设计中,对象之间至少有两种关系,分别是聚合(aggregation)和关联(association)。关联表示对象之间存在关联关系;而聚合则是一种所有权关系,表示一个对象拥有另一个对象。在上面这个例子中,City 是 Person 的关联对象,而 Brain 是 Person 的聚合对象。我们现在只想深拷贝聚合对象,至于关联对象,我们暂时还不清楚要如何处理它。在代码层面,指针(即 Java 中的引用)被用来表示关联和聚合关系,因此代码与对象模型的语义不匹配。

C++ 采用了错误的方法来解决这个问题。也就是说,我们在默认情况下会浅拷贝对象,让对象指针指向同一个对象。有经验的 C++ 程序员会告诉你,C++ 的这一默认拷贝方法已经造成了大量时间和金钱的浪费。在 C++ 中,通常推荐的解决方案是(记住)编写一个拷贝构造函数!Person 类的开发者需要肩负起编写正确的拷贝构造函数来拷贝 Person 对象的责任。

Java 解决这个问题的方法是:如果你尝试拷贝 Person 对象,那么将导致运行时异常。Java 就此给出的理由是:我无法单纯从代码判断这是聚合关系还是关联关系,所以我不做任何假设。我会把这个问题留给类的开发者。开发这个类的程序员必然知道这是聚合关系还是关联关系。如何在 Java 中解决这个问题呢?你应该为 Person 类编写一个拷贝构造器(拷贝构造函数)吗?然而,无论是在 C++ 还是在 Java 中,编写拷贝构造器都是一个糟糕的主意,为什么呢?

不要提供公共拷贝构造器

虽然接下来的代码是用 Java 写的,但同样适用于 C++。让我们来看看用 Java 编写的 Person 类和 Brain 类。

代码清单 4 Person 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Person {
private Brain brain;
private int age;

public Person(Brain brain, int age) {
this.brain = brain;
this.age = age;
}

public Person(Person person) {
age = person.age;

// 我们假设 Brain 具有拷贝构造器。
brain = new Brain(person.brain);
}

public String toString() {
return "This is person with " + brain;
}
}

JAVA

代码清单 4 Person 类

代码清单 5 Brain 类
1
2
3
4
5
6
7
8
public class Brain {
public Brain() {}

public Brain(Brain brain) {
// 假设这里正确地拷贝了 Brain 对象。
...
}
}
JAVA

代码清单 5 Brain 类

Person 类有一个拷贝构造器来拷贝对象。现在让我们尝试执行以下的代码片段:

1
2
3
4
Person sam = new Person(new Brain(), 1);
Person bob = new Person(sam);
System.out.println(sam);
System.out.println(bob);
JAVA

上面的代码片段的输出如下所示:

1
2
This is person with Brain@3fbdb0
This is person with Brain@3e86d0
TEXT

可以看到两个 Person 对象都拥有了各自的 Brain 对象!问题解决了吗?还没有,目前 Person 类的拷贝构造器的实现的正确性取决于 Brain 类。如果我们有一个 SmarterBrain 类,它是 Brain 的子类。然而,我们有个用户编写了下面这段代码:

1
2
3
4
Person sum = new Person(new SmarterBrain(), 1);
Person bob = new Person(sam);
System.out.println(sam);
System.out.println(bob);
JAVA

这段代码的输出为:

1
2
This is person with SmarterBrain@3e86d0
This is person with Brain@50169
TEXT

这并不符合我们的预期。sam 拥有 SmarterBrain,而 bob 最终只有一个普通的 Brain。Person 类是不可扩展的,因为它无法支持新的大脑类型。这违背了开闭原则(Open-Closed Principle,OCP)。开闭原则是由 Bertrand Meyer 提出的。该原则规定软件实体(类、模块、函数等等)应该对扩展开放,对修改封闭。这意味着一个软件实体允许在不改变它的源代码的前提下变更它的行为。

让我们尝试修复它

其中一种方法是像下面这样编写 Person 类的拷贝构造器:

代码清单 6 Person 类的拷贝构造器
1
2
3
4
5
6
7
8
9
public Person(Person another) {
age = another.age;

if (another.brain instanceof SmarterBrain) {
brain = new SmarterBrain((SmarterBrain)another.brain);
} else {
brain = new Brain(another.brain);
}
}
JAVA

代码清单 6 Person 类的拷贝构造器

这段代码依赖于 Java 的运行时类型识别(Run-time type identification,RTTI)。如果再引入另外一种新的大脑类型(Brain 的派生类),则还是需要修改代码。一种好的拷贝对象的方法是让对象自己拷贝自己。换句话说,与其由 Person 创建一个 Brain 对象,我们为什么不直接让 Brain 对象自己拷贝自己呢?换句话说,也就是让 Brain 对象创建一个它自己的副本。这种方法的优点是 Person 对象不需要关心 Brain 的真实类型。这种方法基于原型模式。

在 Java 中,Object 类提供了 clone() 方法的默认实现(默认执行浅拷贝)。不过 Object 中的 clone() 方法是一个保护(protected)方法,并且 clone() 的默认实现会检查类是否实现了 Cloneable 接口。这样,除非类的开发者重写(override)并公开 clone() 方法,否则任何人都不可能拷贝对象。下面的代码展示了 Brain 类、Brain 的子类、以及 Person 类。

代码清单 7 Person 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Person implements Cloneable {
private Brain brain;
private int age;

public Person(Brain brain, int age) {
this.brain = brain;
this.age = age;
}

public String toString() {
return "This is person with " + brain;
}

public Object clone() {
Person another = null;

try {
// 浅拷贝。
another = (Person)super.clone();
// 深拷贝。
another.brain = (Brain)brain.clone();
} catch (CloneNotSupportedException e) {
// 理论上不会抛出异常。这是为了取悦编译器。
}

return another;
}
}
JAVA

代码清单 7 Person 类

代码清单 8 Brain 类
1
2
3
4
5
6
7
8
9
10
11
public class Brain implements Cloneable {
public Brain() {}

public Object clone() throws CloneNotSupportedException {
/*
* 浅拷贝。
* 对于 Brain 类来说,只要进行浅拷贝即可。
*/
return super.clone();
}
}
JAVA

代码清单 8 Brain 类

代码清单 9 SmarterBrain 类
1
2
3
4
5
6
7
8
9
10
11
12
public class SmarterBrain extends Brain {
public SmarterBrain() {}

public Object clone() throws CloneNotSupportedException {
SmarterBrain another = (SmarterBrain)super.clone();

// 这里是深拷贝的代码逻辑。
...

return another;
}
}
JAVA

代码清单 9 SmarterBrain 类

基于上面这些代码,让我们尝试运行以下代码片段:

1
2
3
4
Person sam = new Person(new SmarterBrain(), 1);
Person bob = (Person)sam.clone();
System.out.println(sam);
System.out.println(bob);
JAVA

其运行结果如下所示:

1
2
This is person with SmarterBrain@50169
This is person with SmarterBrain@1fcc69
TEXT

问题解决了吗

虽然上面的方法看起来运行良好且具有可扩展性,但 clone() 方法也带来了一些问题!第一个问题是:被克隆的对象不会调用构造器。因此,作为编写 clone() 方法的程序员,你有责任确保所有的字段都已经正确赋值。下面是一个可能出错的示例。假设有一个类,它使用静态 int 字段来记录该类型的对象的总数。在构造器中,你可以增加计数;但是,由于克隆对象的时候没有调用构造器,因此计数字段将无法真实反应对象的数量!此外,如果类有final字段,那么也无法在 clone() 方法中给这些字段赋值。这就给正确初始化对象的 final 字段带来了问题。如果 final 字段引用的是对象的某些内部状态,那么克隆的对象最终就会共享同一内部状态,这对于可变对象来说肯定是不正确的。以下面的类为例:

代码清单 10 不能正确地拷贝 final 字段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Person implements Cloneable {
private final Brain brain;
private int age;

public Person(Brain brain, int age) {
this.brain = brain;
this.age = age;
}

public String toString() {
return "This is person with " + brain;
}

public Object clone() {
try {
// 浅拷贝。
Person another = (Person)super.clone();
/*
* 我们要深拷贝。
* 错误:不能给 another.brain 赋值。
*/
another.brain = (Brain)brain.clone();
} catch (CloneNotSupportedException e) {
// 理论上不会抛出 CloneNotSupportedException 异常。
}
}
}
JAVA

代码清单 10 无法拷贝 final 字段

对于这些问题,Joshua Bloch 在《Effective Java》中的第 13 条进行了详细讨论。

Joshua Bloch 在结束关于克隆的讨论时说道:“…… 您最好提供一些拷贝对象的替代方法,或者干脆不提供这种功能。” 他又接着说道:“一种好的拷贝对象的方法是提供一个拷贝构造器。”

我同意他 “干脆不提供拷贝功能” 这个观点。不过,他关于提供拷贝构造器的建议可能会导致上文讨论的种种问题!

最终解决方案

解决这个问题的办法是同时实现 clone() 方法和一个保护拷贝构造器!让我们修改 Person 类来实现这一点。

代码清单 11 同时实现 clone() 方法和一个保护拷贝构造器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Person implements Cloneable {
/**
* 因为对象一旦被创建,我不希望 brain 字段被修改,所以 brain 是 final 的。
*/
private final Brain brain;
private int age;

public Person(Brain brain, int age) {
this.brain = brain;
this.age = age;
}

protected Person(Person another) {
Brain refBrain = null;
try {
refBrain = (Brain)another.brain.clone();
} catch (CloneNotSupportedException e) {

}

brain = refBrain;
age = another.age;
}

public String toString() {
return "This is person with " + brain;
}

public Object clone() {
return new Person(this);
}
}
JAVA

代码清单 11 同时实现 clone() 方法和一个保护拷贝构造器

现在假设需要从 Person 派生出一个子类:

代码清单 12 Person 的派生类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SkilledPerson extends Person {
private String theSkills;

public SkilledPerson(Brain brain, int age, String theSkills) {
super(brain, age);
this.theSkills = theSkills;
}

protected SkilledPerson(SkilledPerson another) {
super(another);
theSkills = another.theSkills;
}

public Object clone() {
return new SkilledPerson(this);
}

public String toString() {
return "SkilledPerson: " + super.toString();
}
}
JAVA

代码清单 12 Person 的派生类

让我们尝试执行以下代码:

代码清单 12 User 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class User {
public static void play(Person p) {
Person another = (Person)p.clone();
System.out.println(p);
System.out.println(another);
}

public static void main(String[] args) {
Person sam = new Person(new Brain(), 1);
play(sam);
SkilledPerson bob = new SkilledPerson(new SmarterBrain(), 1, "Writer");
play(bob);
}
}
JAVA

代码清单 12 User 类

输出为:

1
2
3
4
This is Person with Brain@1fcc69
This is Person with Brain@253498
SkilledPerson: This is person with SmarterBrain@1fef6f
SkilledPerson: This is person with SmarterBrain@209f4e
TEXT

上面的例子展示了如何依靠构造函数安全地实现拷贝。这对于类中包含 final 字段的情况尤为适用。请注意,如果我们对对象的数量进行计数,这里实现的克隆将能正确地计数对象的数量。

总结

通过拷贝构造器来复制对象往往会导致代码不可扩展。使用 clone() 方法(原型模式的应用)是实现这一目标的更好方法。不过,使用 Java 提供的 clone() 方法也会有一些问题。最好的办法是提供一个 protected(保护的,非公开的)拷贝构造器,并从 clone() 方法中调用该拷贝构造器。这样,我们就能将创建对象的任务委托给类实例本身,从而既提供了可扩展性,又能使用保护拷贝构造器安全地创建对象。


为什么拷贝对象如此可怕
http://example.com/2024/12/15/为什么拷贝对象如此可怕/
作者
Komorebi
发布于
2024年12月15日
许可协议