java.lang.CharSequence только на первый взгляд кажется незатейливым интерфейсом из трех методов, но при детальном рассмотрении открывает нам несколько интересных нюансов.
Интерфейс реализуют такие java-классы как String, StringBuffer, StringBuilder, GString (groovy) и не только.

TL;DR если добавить этот интерфейс в класс, он получит часть свойств строки и появится ряд возможностей — сравнения со строками (например, String.contentEquals), использования различных строковых API (например, Pattern.matcher), а также в местах автоматического определения поведения в зависимости от типа (например, биндинг параметров запроса в jdbc).

Кроме того, этот подход упростит проведение ряда рефакторингов по усилению системы типов в приложении — в первую очередь замены объектов типа String на специализированные обертки или enum-константы.

Строковые скаляры


Для добавления ограничений на формат значения, а также усиления type safety, вместо строк могут использоваться специальные скалярные обертки. Для понимания рассмотрим пример — пусть ID клиента является строкой, соответствующей регулярному выражению. Его класс-обертка будет выглядеть примерно так:

public final class ClientId {

    private final String value;

    private ClientId(String value) {
        this.value = value;
    }

    /**
     * ...
     * @throws IllegalArgumentException ...
     */
    public static ClientId fromString(String value) throws IllegalArgumentException {
        if (value == null || !value.matches("^CL-\\d+$")) {
            throw new IllegalArgumentException("Illegal ClientId format [" + value + "]");
        }
        return new ClientId(value);
    }

    public String getValue() {
        return value;
    }

    public boolean eq(ClientId that) {
        return this.value.equals(that.value);
    }

    @Override
    public boolean equals(Object o) {
        if (o instanceof String) {
            // гарантированно делаем что-то не то (ложный false)
            // из-за контрактов equals не можем сравнивать посимвольно
            throw new IllegalArgumentException("You should not check equals with String");
        }
        return o instanceof ClientId && eq((ClientId) o);
    }

    @Override
    public int hashCode() {
        return value.hashCode();
    }

    @Override
    public String toString() {
        return value;
    }
}

Такой объект теряет свойства строки и, чтобы их вернуть, нужно делать вызов getValue() или toString(). Но можно поступить иначе — подмешать в наш класс CharSequence.

Рассмотрим интерфейс (java 8):

public interface DefaultCharSequence extends CharSequence {

    @Override
    default int length() {
        return toString().length();
    }

    @Override
    default char charAt(int index) {
        return toString().charAt(index);
    }

    @Override
    default CharSequence subSequence(int start, int end) {
        return toString().subSequence(start, end);
    }
}

Если добавить его в наш класс, т.е.

public final class ClientId implements DefaultCharSequence {

то появится ряд возможностей.

Например, теперь можно будет писать так:

ClientId clientId = ClientId.fromString("CL-123");
String otherClientId = ...;
// NOTE: equals в данном случае даст неверный результат, об этом ниже
if (otherClientId.contentEquals(clientId)) {
    // do smth
}

Сравнение строк


Сравнение строк — одна из наиболее часто используемых операций работы со строками. Наиболее стандартный вариант — вызов String.equals(otherString). Первая проблема, с которой мы можем столкнуться — это null-safety, традиционно она решается flip-ом объекта с аргументом, если один из них-константа: STRING_CONSTANT.equals(value). Если любой из аргументов может быть null, на помощь придет java.util.Objects.equals(o1, o2).

В реалиях сложных и больших проектов нас поджидает еще одна проблема equals — слабый typesafety аргумента (любой Object). На практике это означает, что в качестве аргумента equals можно передать любой объект (например, Integer или Enum), компилятор на это даже не даст warning, вызов просто вернет false. Резонно заметить, что такую ошибку легко выявить на этапе разработки — тут и IDE подскажет и на первых тестах это будет выявлено. Но когда проект вырастает в размерах, превращается в legacy и при этом продолжает развиваться, рано или поздно может возникнуть ситуация, когда STRING_CONSTANT превратится из String в Enum или Integer. Если покрытие тестами недостаточно высокое, equals начнет давать ложный false.

Это тоже можно решить
Это можно выявить постфактум ручным запуском Code Analyze, либо использованием инструментов наподобие Sonar. В IDEA этот code analyze называется «equals() between objects of inconvertible types»
, но хорошие практики — они про предотвращение, а не про борьбу с последствиями.

Для усиления проверки типов на этапе компиляции вызов equals можно заменить на String.contentEquals(CharSequence), либо на org.apache.commons.lang3.StringUtils.equals(CharSequence, CharSequence)
Оба эти варианта хороши тем, что теперь мы можем сравнивать String с нашим ClientId без дополнительных преобразований, в случае последнего — в т.ч. еще и null-safe.

Рефакторинги


Описанная выше ситуация может показаться несколько надуманной, но это решение пришло в результате различных рефакторингов legacy-кода. Типовая правка, о которой в данном случае идет речь — замена объектов типа String на классы-обертки или enum-константы. Классы-обертки могут использоваться для целого ряда типовых immutable-строк — номера договоров, карт, телефонов, пароли, хеш-суммы, названия конкретных типов элементов и пр. Класс-обертка помимо проверки формата значения может добавлять специфичные методы работы с ним. Если подходить к такому рефакторингу не очень осторожно, можно наткнуться на ряд проблем — первая из которых — небезопасный equals.

Ограничение есть для классов-оберток, которые оборачивают не String, а например, числовые значения. В таком случае, вызов toString может быть относительно дорогим (для того же последовательного вызова charAt для всей строки) — здесь потенциально можно использовать ленивое закешированное String-представление.

Биндинг аргументов в запросы jdbc


Сразу уточню, что речь в данном случае идет о spring-jdbc, биндинг через JdbcTemplate/NamedParameterJdbcTemplate
Мы можем передать объект класса ClientId в биндинге значения параметра, т.к. он реализует CharSequence:


public Client getClient(ClientId clientId) {
    return jdbcTemplate.queryForObject(
            "SELECT * FROM clients " +
                    "WHERE clientId_id = ?",
            (row, rowNum) -> mapClient(row),
            clientId
    );
}

Если рассматривать данный код как переделанный из изначальной декларации getClient(String clientId), то, что касается использования переданного значения, то здесь все останется без изменений.

Как это работает
org.springframework.jdbc.core.StatementCreatorUtils.setValue определит тип аргумента сначала как CharSequence (см. isStringValue()), потом сделает преобразование в toString, а сам биндинг в PreparedStatement превратится в ps.setString(paramIndex, inValue.toString());

Заключение


Я использую этот метод в своих проектах уже несколько месяцев и пока не столкнулся с какими-либо проблемами.

API, которое использует CharSequence вместо String достаточно богато — достаточно сделать find usages для CharSequence. Особое внимание можно уделить библиотеке Android — там его особенно много, но здесь я боюсь что-либо советовать, т.к. на нем данный метод еще не проверял.

Буду рад получить фидбек по вопросу — что вы думаете по этому поводу, какой профит/грабли здесь есть и есть ли вообще смысл использовать подобные практики.

Комментарии (4)


  1. CyberSoft
    09.01.2018 17:31

    Хотя мне не приходилось использовать CharSequence, его метод subSequence можно использовать в качестве view над оригинальным объектом, чтобы не плодить ещё. String так и делает.


    1. Don_Rumata
      09.01.2018 18:45

      Если я правильно понял, о чем вы, то нет — String так не делает начиная, кажется, с 7ой версии Java. При вызове substring он скопирует обозначенный кусок массива символов.


      1. seregamorph Автор
        09.01.2018 18:47

        Все так, но то речь про String, а собственная реализация CharSequence действительно может делать и view оригинального объекта (если вдруг по каким-то причинам это принципиально в рамках проекта).
        Что касается примера в статье в интерфейсе DefaultCharSequence — там идет сначала преобразование в String.


      1. CyberSoft
        09.01.2018 19:02

        Пардон, и правда…

        тот самый конструктор
            public String(char value[], int offset, int count) {
                ...
                this.value = Arrays.copyOfRange(value, offset, offset+count);
            }