Статьи

Как сопоставить различные типы значений с использованием обобщенных данных Java

Иногда средний разработчик сталкивается с ситуацией, когда ему приходится отображать значения произвольных типов в определенном контейнере. Однако API коллекции Java обеспечивает только параметризацию контейнера. Что ограничивает безопасное использование типа HashMap например, одним типом значения. Но что, если вы хотите смешать яблоки и груши?

К счастью, существует простой шаблон проектирования, который позволяет отображать различные типы значений с использованием обобщенных типов Java, которые Джошуа Блох описал как безопасный гетерогенный контейнер в своей книге « Эффективная Java» (второе издание, пункт 29).

В последнее время я наткнулся на некоторые не совсем подходящие решения по этой теме, и у меня возникла идея объяснить проблемную область и проработать некоторые аспекты реализации в этом посте.

Типы отличительных значений карты с использованием обобщенных данных Java

Для примера рассмотрим, что вы должны предоставить некоторый контекст приложения, который позволяет привязывать значения произвольных типов к определенным ключам. Простая не типичная безопасная реализация с использованием ключей String поддерживаемых HashMap может выглядеть так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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 может использоваться в программе:

1
2
3
4
5
6
Context context = new Context();
Runnable runnable = ...
context.put( "key", runnable );
 
// several computation cycles later...
Runnable value = ( Runnable )context.get( "key" );

Недостаток этого подхода можно увидеть в шестой строке, где необходим нисходящий бросок. Очевидно, это может привести к ClassCastException в случае, если пара ключ-значение была заменена другим типом значения:

01
02
03
04
05
06
07
08
09
10
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 :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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 );
  }
 
  [...]
}

Опять же, базовое использование может выглядеть так:

1
2
3
4
5
6
Context context = new Context();
Runnable runnable = ...
context.put( "key", runnable, Runnable.class );
 
// several computation cycles later...
Runnable value = context.get( "key", Runnable.class );

На первый взгляд этот код может создать иллюзию большего сохранения типов, поскольку он избегает броска вниз в шестой строке. Но выполнение следующего фрагмента возвращает нас к земле, поскольку мы все еще сталкиваемся ClassCastException сценарием ClassCastException во время назначения в строке десять:

01
02
03
04
05
06
07
08
09
10
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

Так что пошло не так?

Прежде всего, приведение к Context#get в Context#get типа T неэффективно, поскольку стирание типа заменяет неограниченные параметры статическим приведением к Object . Но что более важно, реализация не использует информацию о типе, предоставляемую Context#put качестве ключа. Максимум это служит лишним косметическим эффектом.

Typesafe гетерогенный контейнер

Хотя последний вариант Context работал не очень хорошо, он указывает в правильном направлении. Вопрос в том, как правильно параметризовать ключ? Чтобы ответить на этот вопрос, взгляните на урезанную реализацию в соответствии с типом безопасного гетерогенного контейнера, описанного Блохом.

Идея состоит в том, чтобы использовать тип class качестве самого ключа. Поскольку Class является параметризованным типом, он позволяет нам сделать методы типа Context безопасными, не прибегая к непроверенному приведению к T Объект Class используемый таким образом, называется токеном типа.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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 было заменено эффективным динамическим вариантом. И вот как контекст может использоваться клиентами:

01
02
03
04
05
06
07
08
09
10
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 );

На этот раз клиентский код будет работать без проблем приведения классов, поскольку невозможно заменить определенную пару ключ-значение на другую с другим типом значения.


Там, где есть свет, должна быть тень, там, где есть тень, должен быть свет. Нет тени без света и нет света без тени …

Харуки Мураками

Блох упоминает два ограничения этого паттерна. «Во-первых, злонамеренный клиент может легко повредить безопасность типов […], используя объект класса в его сыром виде». Чтобы обеспечить инвариант типа во время выполнения, в Context#put можно использовать динамическое приведение.

1
2
3
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 . Следовательно, Блох указывает на то, что не существует удовлетворительного обходного пути для такого рода ограничений.

Но что, если вам нужно хранить две записи одного и того же типа значения? Хотя создание новых расширений типов только для целей хранения в безопасном контейнере может быть вполне приемлемым, это не является лучшим решением для проектирования. Использование пользовательской реализации ключа может быть лучшим подходом.

Несколько записей контейнера одного типа

Чтобы иметь возможность хранить несколько записей контейнера одного и того же типа, мы могли бы изменить класс Context чтобы использовать пользовательский ключ. Такой ключ должен предоставлять информацию о типе, которая нам нужна для безопасного поведения типа, и идентификатор для различения объектов фактических значений.

Реализация простого ключа с использованием экземпляра String качестве идентификатора может выглядеть следующим образом:

01
02
03
04
05
06
07
08
09
10
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 :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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 следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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 — вернет null при get . Что не то, что мы хотим.

К счастью, это может быть легко решено с помощью соответствующей реализации equals и hashCode Key . Это позволяет поиску HashMap работать как положено. Наконец, можно создать фабричный метод для создания ключа, чтобы минимизировать шаблон (полезно в сочетании со статическим импортом):

1
2
3
public static  Key key( String identifier, Class type ) {
  return new Key( identifier, type );
}

Вывод

«Обычное использование обобщений, примером которых являются API-интерфейсы сбора, ограничивает вас фиксированным числом параметров типа на контейнер. Вы можете обойти это ограничение, поместив параметр типа на ключ, а не на контейнер. Вы можете использовать объекты Class качестве ключей для таких безопасных разнородных контейнеров »(Joshua Bloch, Item 29, Effective Java).

Принимая во внимание эти заключительные замечания, больше нечего добавить, кроме как пожелать вам удачи в смешивании яблок и груш…