安全性 vs.可复用性,如何进行平衡?


本文描述了我在做软件构造Lab3中遇到的关于“表示泄漏”与“可复用性”的问题


通过与老师和同学的交流,本问题目前得到了一个不错的解决方案,本文为提出问题后的第一次更新。如果读者对此问题有新的见解,欢迎在评论区提出

问题描述

Vertex是mutable的

抽象类Vertex中包含这样一个方法:

1
public abstract void fillVertexInfo (String[] args);

实验手册中对它的描述为:

为特定应用中的具体节点添加详细属性信息,所需的信息参见表 3 的最后一列,参数的次序与表 3 保持一致。因为不同的 Vertex 子类型的属性不同,故该操作可设计为 abstract,在具体子类里实现之。

这就说明Vertex类是mutable的,因为我们可以通过调用fillVertexInfo()来改变Vertex中的域。而且实验手册中也明确指明Vertex是mutable的,以下是实验手册中的原话:

这是一个 mutable 的 ADT,请务必注意安全性!

Edge中防止rep exposure

抽象类Edge中包含一个域Collection vertices用来表示该边中包含的所有顶点,具体采用何种Collection留给自己设计,我采用了List<Vertex>

下面我们看一下对Edge中两个方法的实现,注意这两个实现都存在着潜在的bug

我只是用这两个方法来说明问题,其实还有几个方法也存在着类似的问题

1. addVertices()方法(传入可变类型引发的问题)

1
public abstract boolean addVertices(List<Vertex> vertices);

实验手册对它的描述是:

如果是超边,vertices.size()>=2,该函数添加 vertices 中的所有节点到该超边; 如果是有向边,vertices.size()=2,该操作将vertices中的第一个元素作为source,将第二个元素作为target;如果是无向边,vertices.size()=2,无需考虑次序;如果是loop,vertices.size()=1。

现在我们假设这个方法不是abstract的并且暂时不考虑这个方法的返回值,让我们看一下对这个方法的一种存在潜在bug的实现

1
2
3
public boolean addVertices(List<Vertex> vertices) {
this.vertices.addAll(vertices);
}

分析:
在这种实现方式中,我们直接将参数vertices中的引用添加到了this.vertices中,也就是说,这个操作执行完之后,this.vertices中新添加的Vertex与参数vertices中的Vertex指向了同一个对象,这就造成了内存别名。关于由此引发的潜在bug,可以参见MIT的讲义Mutability & Immutability中举出的两个问题

也可以直接看我对这篇讲义的翻译可变性与不可变性

2. vertices()方法(返回可变类型引发的问题)

1
2
3
4
public Set<Vertex> vertices() {
// defensive copy
new HashSet(this.vertices);
}

分析:
在这种实现方式中,我们进行了防御式拷贝,实现者为了证明自己做到了这一点,还特意将它写在了注释中。然而仔细分析一下,这种防御式拷贝仅是一种浅拷贝,我们确实创造了一个新的Set,与this.vertices指向了不同的内存地址,但是它们内部的元素呢?

为了说明这个问题,我写了一个演示程序,通过运行结果就可以看出由此引发的严重问题
demo

问题的解决

为具体的Vertex类实现“为防御式拷贝服务的构造器”

如果我们想要避免上文所指出的潜在bug(这种潜在的bug造成了严重的表示泄漏),就要给每一个具体的Vertex对象进行防御式拷贝,而这又需要给每一个具体的Vertex类实现可以进行防御式拷贝的接口(或者是构造器)

注意,这种构造器只能在具体的子类中实现,因为具体的子类中有一些超类中没有也无法访问的域(即具体子类的属性,比如Person类的sex和age)

举一个例子,我可以在Person中实现一个为防御式拷贝服务的构造器:

1
2
3
4
5
6
7
8
9
/**
* Creator used for defensive copy. It will create a copy without any coupling to the original object.
* @param aPerson another Person instance that wants to make defensive copy
*/
public Person(Person aPerson) {
super(aPerson.getLabel());
this.sex = aPerson.sex;
this.age = aPerson.age;
}

在Edge中进行防御式拷贝

然后在Edge中进行防御式拷贝以防止内存泄漏:

注意这里也只能在具体的Edge中进行,因为只有具体的Edge才可以知道Vertex的具体类型

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public boolean addVertices(List<Vertex> vertices) {
if(super.addVertices(vertices)) {
for(Vertex v : vertices) {
// defensive copy
this.vertices.add(new Person((Person)v));
}
return true;
}
else {
return false;
}
}

安全性和复用性造成的冲突

为了防止上文所指出的rep exposure, 我们必须进行防御式拷贝,但是这种防御式拷贝使我们多写了很多代码,最糟糕的是,这让我们无法利用复用性,比如说vertices()方法,如果在超类Edge中实现,我们在每个子类中都可以复用这个方法,但不幸的是,就像我在前面所指出的那样,在超类中的实现方式会造成rep exposure:

1
2
3
public Set<Vertex> vertices() {
new HashSet(this.vertices);
}

更严重的问题是,在超类中无法进行防御式拷贝,因为在超类中不知道Vertex的具体类型(这个我已经在前面指出),所以我们必须在每个子类中都实现这个方法,即使它们的实现方式都大同小异,例如WordEdge和FriendConnection的实现:

  1. WordEdge

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override
    public Set<Vertex> vertices(){
    Set<Vertex> setOfVertices = new HashSet<>();
    for(Vertex v :this.vertices) {
    // defensive copy
    setOfVertices.add(new Word((Word)v));
    }
    return setOfVertices;
    }
  2. FriendConnection

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override
    public Set<Vertex> vertices(){
    Set<Vertex> setOfVertices = new HashSet<>();
    for(Vertex v :this.vertices) {
    // defensive copy
    setOfVertices.add(new Person((Person)v));
    }
    return setOfVertices;
    }

正如我们所看到的,这两段代码几乎一模一样,区别仅在于Vertex的具体类分别为Word和Person,但为了进行防御式拷贝,我们只能将它们实现在具体的子类而不是超类中。这严重违反可复用性的原则!

另一个问题是,为了进行防御式拷贝,我们无法使用诸如List中的addAll()等方法,而必须要自己写一个将vertices中的对象全部进行防御式拷贝的方法,这也不符合DRY原则

所以最终的问题是:我们怎样在安全性与可复用性之间做出平衡?

第一次更新

更新说明:

本次更新重构了Vertex类防御式拷贝的方式,提高了复用性,最令人激动的是,利用这种方式,客户端不必知道Vertex的具体类型,而且也不必借助于反射的机制(至于为什么应在这里尽量避免反射机制,可以参见“《Effective Java》第53条: 接口优先于反射机制”)

重构Vertex类防御式拷贝的方式

回想一下我们之前实现Vertex类“为防御式拷贝服务的构造器”的方式,我们必须在每个Vertex的具体子类中定义一个专用的构造器,然后依次拷贝各个域,就像Person类这样:

Person
1
2
3
4
5
6
7
8
9
/**
* Creator used for defensive copy. It will create a copy without any coupling to the original object.
* @param aPerson another Person instance that wants to make defensive copy
*/
public Person(Person aPerson) {
super(aPerson.getLabel());
this.sex = aPerson.sex;
this.age = aPerson.age;
}

根据评论区提出的想法,我们可以借用已经在Vertex所有子类中都实现的fillVertexInfo(String[] args)方法来为具体子类中的特有域赋值,这样可以将一些具有共性的操作抽象到超类中,提高了复用性,实现方式如下:

超类Vertex
1
2
3
4
5
6
7
8
9
/**
* Creator used for defensive copy. It will create a copy without any coupling to the original object.
* @param aVertex another Vertex instance that wants to make defensive copy
*/
public Vertex(Vertex aVertex) {
this.label = aVertex.label;
fillVertexInfo(aVertex.args);
checkRep();
}
子类Person
1
2
3
4
5
6
7
/**
* Creator used for defensive copy. It will create a copy without any coupling to the original object.
* @param aPerson another Person instance that wants to make defensive copy
*/
public Person(Person aPerson) {
super(aPerson);
}

正如我在评论区所指出的,由于运行时java虚拟机知道Vertex的具体类型,所以它知道调用哪一个子类的fillVertexInfo(String args[])方法

利用工厂方法模式封装防御式拷贝的实现

上面的实现方式有什么问题呢?

  1. 客户端必须要知道具体哪种Vertex类型需要进行防御式拷贝,就像我在上文指出的客户端Edge类在对Vertex进行防御式拷贝时遇到的问题,这使得可复用性严重降低
  2. 我们把防御式拷贝的具体操作留给了客户端,客户端在进行防御式拷贝时必须要借助于new操作符,而且有时还需要进行类型强转。根据“能够提供的操作不要留给客户端去做”的原则,我们希望Vertex类能够提供一个简单、封装好的方法来提供防御式拷贝的需求

下面将描述这种实现方式

首先,在Vertex类中定义一个抽象方法:

超类Vertex
1
2
3
4
5
/**
* Creates a new Vertex which is the copy of this Vertex. The copy will have no any coupling to this Vertex;
* @return a new Vertex which has same attributes as this Vertex
*/
public abstract Vertex defensiveCopy();

然后在具体的子类中利用我们之前已实现的构造器实现这个方法:

子类Person
1
2
3
4
@Override
public Vertex defensiveCopy() {
return new Person(this);
}

接下来客户端对Vertex的防御式拷贝就变得很容易,更令人激动的是,即使我们不知道Vertex的具体类型,这个操作也可以在超类中完成。比如Edge中的Set<Vertex> vertices()方法:

Edge
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Get all of the vertices included in this edge.
* @return the set of vertices in this edge
*/
public Set<Vertex> vertices() {
Set<Vertex> copySet = new HashSet<>();

for(Vertex v : vertices) {
// defensive copy to avoid rep exposure
copySet.add(v.defensiveCopy());
}
checkRep();
return copySet;
}

现在我们可以看到这种实现方式的优点:

  1. 客户端只需要调用特定的防御式拷贝函数,而不用去操心具体的实现
  2. 我们可以直接调用被声明为Vertex类型的对象的defensiveCopy(),因为它在Vertex类中已经定义了,尽管是抽象的,但它的具体子类都实现了这个方法,而Java虚拟机又知道当前Vertex对象的具体类型,所以它知道该调用哪个子类的defensiveCopy()方法。解决了这个问题以后,复用性可以得到很大的提高
  3. 我们将程序的运行时状态交给Java虚拟机来管理,而不用通过反射机制去获得这些信息

现在通过这种方式,安全性和复用性都得到了不错的保障,而且在未来的程序设计中我们依然可以沿用这种思想提高程序质量

参考文档

  1. http://web.mit.edu/6.031/www/sp17/classes/09-immutability/
  2. 《Effective Java》
  3. Design Patterns: Elements of Reusable Object-Oriented Software