有时,一般的开发人员会遇到这样的情况,即他必须在特定容器内映射任意类型的值。 但是,Java集合API仅提供与容器相关的参数化。 例如,这将HashMap
的类型安全使用限制为单个值类型。 但是,如果您想混合苹果和梨怎么办?
幸运的是,有一个简单的设计模式允许使用Java泛型映射不同的值类型, 约书亚·布洛赫(Joshua Bloch)在他的著作《 有效的Java》 (第二版,第29项)中将其描述为类型安全的异构容器 。
最近,在有关该主题的一些不太合适的解决方案中遇到了麻烦,这使我有了解释问题域的想法,并在这篇文章中详细介绍了一些实现方面。
使用Java泛型映射不同的值类型
出于示例考虑,您必须提供某种类型的应用程序上下文,该上下文允许将任意类型的值绑定到某些键。 使用由HashMap
支持的String
键的简单非类型安全实现可能如下所示:
public class Context {
private final Map<String,Object> values = new HashMap<>();
public void put( String key, Object value ) {
values.put( key, value );
}
public Object get( String key ) {
return values.get( key );
}
[...]
}
以下代码片段显示了如何在程序中使用此Context
:
Context context = new Context();
Runnable runnable = ...
context.put( "key", runnable );
// several computation cycles later...
Runnable value = ( Runnable )context.get( "key" );
这种方法的缺点可以在需要下浇的第六行看到。 显然,如果键值对已被其他值类型替换,则可能导致ClassCastException
:
Context context = new Context();
Runnable runnable = ...
context.put( "key", runnable );
// several computation cycles later...
Executor executor = ...
context.put( "key", executor );
// even more computation cycles later...
Runnable value = ( Runnable )context.get( "key" ); // runtime problem
由于相关的实现步骤可能在您的应用程序中分散开来,因此很难跟踪此类问题的原因。 为了改善这种情况,将值不仅绑定到其键而且还绑定到其类型似乎是合理的。
我在采用这种方法的几种解决方案中看到的常见错误或多或少归结为以下Context
变体:
public class Context {
private final <String, Object> values = new HashMap<>();
public <T> void put( String key, T value, Class<T> valueType ) {
values.put( key, value );
}
public <T> T get( String key, Class<T> valueType ) {
return ( T )values.get( key );
}
[...]
}
同样,基本用法可能如下所示:
Context context = new Context();
Runnable runnable = ...
context.put( "key", runnable, Runnable.class );
// several computation cycles later...
Runnable value = context.get( "key", Runnable.class );
乍一看,此代码可能会产生一种幻想,即可以节省更多类型,因为它避免了第六行的转换。 但是运行下面的代码片段会使我们脚踏实地,因为在第十行的分配过程中,我们仍然遇到ClassCastException
场景:
Context context = new Context();
Runnable runnable = ...
context.put( "key", runnable, Runnable.class );
// several computation cycles later...
Executor executor = ...
context.put( "key", executor, Executor.class );
// even more computation cycles later...
Runnable value = context.get( "key", Runnable.class ); // runtime problem
那么出了什么问题?
首先,类型T
Context#get
中的向下强制转换无效,因为类型擦除使用静态强制转换为Object
来替换无界参数。 但是更重要的是,该实现不使用Context#put
提供的类型信息作为键。 至多它只是多余的美容效果。
类型安全的异构容器
尽管最后一个Context
变体效果不佳,但它指出了正确的方向。 问题是如何正确设置密钥? 为了回答这个问题,请看Bloch描述的根据类型安全的异构容器模式精简的实现。
这个想法是使用class
类型作为键本身。 由于Class
是参数化类型,它使我们能够使Context
类型的方法安全,而无需诉诸于T
的未经检查的转换。 以这种方式使用的Class
对象称为类型令牌。
public class Context {
private final Map<Class<?>, Object> values = new HashMap<>();
public <T> void put( Class<T> key, T value ) {
values.put( key, value );
}
public <T> T get( Class<T> key ) {
return key.cast( values.get( key ) );
}
[...]
}
请注意,如何使用有效的动态变体替换Context#get
实现中的向下转换。 这就是客户端可以使用上下文的方式:
Context context = new Context();
Runnable runnable ...
context.put( Runnable.class, runnable );
// several computation cycles later...
Executor executor = ...
context.put( Executor.class, executor );
// even more computation cycles later...
Runnable value = context.get( Runnable.class );
这次,客户端代码将可以正常工作而不会产生类转换问题,因为不可能用具有不同值类型的一对键值对进行交换。
Bloch提到了此模式的两个限制。 “首先,恶意客户端可以通过使用原始形式的类对象来轻易破坏类型安全性[...]。” 为了确保类型在运行时不变,可以在Context#put
使用动态转换。
public <T> void put( Class<T> key, T value ) {
values.put( key, key.cast( value ) );
}
第二个限制是该模式不能在不可更改的类型上使用(请参阅第25条,有效的Java)。 这意味着您可以通过类型安全的方式存储诸如Runnable
或Runnable[]
类的值类型,但不能存储List<Runnable>
。
这是因为List<Runnable>
没有特定的类对象。 所有参数化类型都引用相同的List.class
对象。 因此,Bloch指出,对于这种限制没有令人满意的解决方法。
但是,如果您需要存储两个相同值类型的条目怎么办? 虽然可以想象仅将新类型的扩展名存储在类型安全的容器中,但这听起来并不是最佳的设计决策。 使用自定义键实现可能是更好的方法。
多个相同类型的容器条目
为了能够存储相同类型的多个容器条目,我们可以将Context
类更改为使用自定义键。 这样的键必须提供类型安全行为所需的类型信息,以及用于区分实际值对象的标识符。
使用String
实例作为标识符的朴素键实现可能如下所示:
public class Key<T> {
final String identifier;
final Class<T> type;
public Key( String identifier, Class<T> type ) {
this.identifier = identifier;
this.type = type;
}
}
同样,我们使用参数化的Class
作为类型信息的挂钩。 调整后的Context
现在使用参数化的Key
而不是Class
:
public class Context {
private final Map<Key<?>, Object> values = new HashMap<>();
public <T> void put( Key<T> key, T value ) {
values.put( key, value );
}
public <T> T get( Key<T> key ) {
return key.type.cast( values.get( key ) );
}
[...]
}
客户端将使用以下版本的Context
:
Context context = new Context();
Runnable runnable1 = ...
Key<Runnable> key1 = new Key<>( "id1", Runnable.class );
context.put( key1, runnable1 );
Runnable runnable2 = ...
Key<Runnable> key2 = new Key<>( "id2", Runnable.class );
context.put( key2, runnable2 );
// several computation cycles later...
Runnable actual = context.get( key1 );
assertThat( actual ).isSameAs( runnable1 );
尽管此代码片段有效,但实现仍有缺陷。 Key
实现在Context#get
用作查找参数。 使用用相同的标识符和类初始化的两个不同的Key
实例(一个实例与put一起使用,另一个实例与get一起使用)将在get
上返回null
。 这不是我们想要的。
幸运的是,可以通过使用适当的equals
和Key
hashCode
实现轻松解决此问题。 这使HashMap
查找可以按预期工作。 最后,可以提供一种工厂创建密钥的方法,以最小化样板(与静态导入结合使用):
public static Key key( String identifier, Class type ) {
return new Key( identifier, type );
}
结论
'以集合API为例,泛型的正常使用将每个容器的类型参数限制为固定数量。 您可以通过将类型参数放在键而不是容器上来解决此限制。 您可以将Class
对象用作此类类型安全的异构容器的键(Joshua Bloch,有效Java,第29项)。