我看到自己经常遇到以下问题。我有某种标记接口(为简单起见,让我们使用java.io.Serializable
)和几个包装器(适配器、装饰器、代理......)。但是,当您将 Serializable 实例包装在另一个实例(不可序列化)中时,您会失去功能。可以通过 List 实现实现的 java.util.RandomAccess 也会出现同样的问题。有没有很好的 OOP 方式来处理它?
4 回答
这是最近关于 Guava 邮件列表的讨论——我的回答涉及到这个相当基本的问题。
http://groups.google.com/group/guava-discuss/browse_thread/thread/2d422600e7f87367/1e6c6a7b41c87aac
它的要点是: 当你希望你的对象被包装时,不要使用标记接口。(嗯,这很笼统——你怎么知道你的对象不会被客户端包装?)
例如,一个ArrayList
. RandomAccess
显然,它实现了。然后你决定为List
对象创建一个包装器。哎呀!现在当你包装时,你必须检查被包装的对象,如果是 RandomAccess,你创建的包装器也应该实现RandomAccess!
这工作“很好”......如果你只有一个标记界面!但是如果被包装的对象可以是可序列化的呢?如果它是,比如说,“不可变的”(假设你有一个类型来表示它)怎么办?还是同步的?(同样的假设)。
正如我在对邮件列表的回答中还指出的那样,这种设计缺陷也体现在良好的旧java.io
包装中。假设您有一个接受InputStream
. 你会直接读它吗?如果它是一个昂贵的流,并且没有人愿意BufferedInputStream
为你包装它怎么办?哦,这很容易!您只需检查stream instanceof BufferedInputStream
,如果没有,您自己包装它!但不是。流可能在链的某处有缓冲,但您可能会得到它的包装器,它不是 BufferedInputStream 的实例。因此,“此流已缓冲”的信息丢失了(也许您必须悲观地浪费内存来再次缓冲它)。
如果您想正确地做事,只需将功能建模为对象。考虑:
interface YourType {
Set<Capability> myCapabilities();
}
enum Capability {
SERIALIAZABLE,
SYNCHRONOUS,
IMMUTABLE,
BUFFERED //whatever - hey, this is just an example,
//don't throw everything in of course!
}
编辑:应该注意的是,我使用枚举只是为了方便。可以通过一个接口Capability
和一组开放的对象来实现它(可能是多个枚举)。
因此,当您包装这些对象时,您将获得一组功能,您可以轻松决定保留哪些功能,删除哪些功能,添加哪些功能。
很明显,这确实有它的缺点,所以它只能在您真正感受到包装器隐藏以标记接口表示的功能的痛苦的情况下使用。例如,假设您编写的一段代码采用 List,但它必须是 RandomAccess AND Serializable。使用通常的方法,这很容易表达:
<T extends List<Integer> & RandomAccess & Serializable> void method(T list) { ... }
但在我描述的方法中,你所能做的就是:
void method(YourType object) {
Preconditions.checkArgument(object.getCapabilities().contains(SERIALIZABLE));
Preconditions.checkArgument(object.getCapabilities().contains(RANDOM_ACCESS));
...
}
我真的希望有一种比任何一种方法都更令人满意的方法,但从前景来看,它似乎不可行(至少不会引起组合类型的爆炸)。
编辑:另一个缺点是,如果没有每个功能的显式类型,我们没有自然的地方来放置表达此功能提供的方法的方法。这在本次讨论中并不太重要,因为我们讨论的是标记接口,即不通过附加方法表达的功能,但为了完整起见,我提到它。
PS:顺便说一下,如果你浏览一下 Guava 的集合代码,你真的可以感受到这个问题造成的痛苦。是的,一些优秀的人试图将它隐藏在好的抽象背后,但潜在的问题仍然是痛苦的。
如果您感兴趣的接口都是标记接口,您可以让所有包装类实现一个接口
public interface Wrapper {
boolean isWrapperFor(Class<?> iface);
}
其实现如下所示:
public boolean isWrapperFor(Class<?> cls) {
if (wrappedObj instanceof Wrapper) {
return ((Wrapper)wrappedObj).isWrapperFor(cls);
}
return cls.isInstance(wrappedObj);
}
这就是它在java.sql.Wrapper
. 如果接口不只是一个标记,而是实际上有一些功能,你可以添加一个方法来展开:
<T> T unwrap(java.lang.Class<T> cls)
有几个选择,虽然没有一个很好
如果在编译时知道被包装的对象是否也实现了接口,则让包装器实现接口。如果直到运行时才知道被包装的对象是否会实现接口,则可以使用工厂方法来创建包装器。这意味着您可以为实现的接口的可能组合拥有单独的包装类。(使用一个接口,您需要 2 个包装器,一个有,一个没有。对于 2 个接口,需要 4 个包装器,依此类推。)
从包装器中公开包装的对象,以便客户端可以遍历链并使用
instanceof
. 这打破了封装。有一个专用的方法来检索接口,由包装器和被包装的对象实现。例如
asSomeInterface()
。包装器委托给被包装的对象,或者在被包装的对象周围创建一个代理以保留封装。为每个接口创建一个包装器类 - 包装器像往常一样实现 - 它实现接口并委托给该接口的另一个实现。一个包装对象可以实现多个接口,因此通过使用动态代理将代理实现的接口方法委托给适当的包装实例,将多个包装实例组合成一个逻辑实例。代理实现的接口集必须没有任何共同的方法签名。
微软将聚合(维基百科)加入到他们的组件对象模型(COM)中。大多数人似乎都没有使用它,但对 COM 对象实现者来说却相当复杂,因为每个对象都必须遵守一些规则。包装对象通过让包装对象知道它们是包装器来封装,必须维护指向包装器的指针,该指针在instanceof
为公开的公共接口实现 QueryInterface (松散地)时使用 - 包装对象返回在包装器上实现的接口,而不是比它自己的实现。
我还没有看到一个干净、易于理解/实现和正确封装的解决方案。COM 聚合可以工作并提供完整的封装,但它是您为实现的每个对象支付的成本,即使它从未在聚合中使用。
对于这样的人,RandomAccess
您无能为力。当然,您可以进行instanceof
检查并创建相关类的实例。类的数量随着标记呈指数增长(尽管您可以使用java.lang.reflect.Proxy
),并且您的创建方法需要了解所有标记。
Serializable
还不错。如果间接类实现Serializable
了,那么如果目标类是,则整体将是可序列化的,如果Serializable
不是,则不是。