Маленькие тонкости java.lang.String
перебирая накопленный материал по java.lang.String решил сделать небольшую подборку примеров из эффективного (и не очень) использования.
Любое преобразование строки порождает новую строку
Это один из главных мифов о строках. На деле это не всегда так. Предположим, что у нас есть строка содержащая только строчные буквы:
jshell> var str = "str"; jshell> System.out.println(str.toLowerCase() == str);
Иными словами, здесь вызов toLowerCase() вернул строку, на которой он был вызван. И хотя это поведение не описано в документации, код StringLatin1.toLowerCase() не оставляет сомнений (здесь и далее приведён код из https://hg.openjdk.java.net/jdk/jdk/):
public static String toLowerCase(String str, byte[] value, Locale locale) < if (locale == null) < throw new NullPointerException(); >int first; final int len = value.length; // Now check if there are any characters that need to be changed for (first = 0 ; first < len; first++) < int cp = value[first] & 0xff; // no need to check Character.ERROR if (cp != CharacterDataLatin1.instance.toLowerCase(cp)) < break; >> if (first == len) return str; //
В целом это поведение логично: зачем городить новую строку, если в исходной не произошло никаких изменений. Это же справедливо для прочих методов, меняющих строку лишь частично, например, String.trim() и String.strip() :
// обратите внимание: в отличии от strip() // для trim() явно прописан возврат this /** * * @return a string whose value is this string, with all leading * and trailing space removed, or this string if it * has no leading or trailing space. */ public String trim() < String ret = isLatin1() ? StringLatin1.trim(value) : StringUTF16.trim(value); return ret == null ? this : ret; >/** * @return a string whose value is this string, with all leading * and trailing white space removed * * @see Character#isWhitespace(int) * * @since 11 */ public String strip()
В данной связи иногда возникают разные нехорошие соблазны:
boolean isUpperCase = name.toUpperCase().equals(name);
По-хорошему это нужно переписать с использованием всяких StringUtils , которые познаково перебирают строку и проверяют регистр (соответствующая проверка есть в «Сонаре»). Но зачем тащить лишнюю зависимость/импорт в проект/класс, если мы уже знаем, что name.toUpperCase() вернёт name , когда вся строка в верхнем регистре? Вместо этого мы можем написать
boolean isUpperCase = name.toUpperCase() == name; //вредный совет
Однако, может получится так, что в следующей реализации String.toUpperCase() будет всегда возвращать новую строку и наш код поломается. Также этот код будет работать медленнее (иногда сильно, иногда не очень) чем o.a.c.l.StringUtils.isAllUpperCase() .
boolean eq = aString.toUpperCase().equals(anotherString);
boolean eq = aString.equalsIgnoreCase(anotherString);
Возможно, что уже скоро «Идея» научится предупреждать нас об этом, а пока это делает «Сонар».
Ещё про String.toLowerCase()
Вообще String.toLowerCase() / String.toUpperCase() довольно интересный метод, а его поведение может быть весьма неожиданным. Возьмём этот код:
boolean isEmpty = someStr.toLowerCase().isEmpty();
он несколько искусственный, но имеет право на жизнь. Присмотревшись к нему становится понятно, что преобразование регистра избыточно для определения пустой/непустой строки. Если строка пуста, то мы получим её же на выходе и isEmpty() вернёт true . В противном случае вернётся false , т. к. для этого достаточно 1 знака в строке, а его регистр не важен.
Получается, что вместо кода выше можно написать:
boolean isEmpty = someStr.isEmpty();
и смысл выражения не изменится. Посмотрев же внутрь String.isEmpty() найдём там незамысловатую реализацию:
Держа в уме описанное ранее преобразование может возникнуть соблазн превратить
int len = someStr.toLowerCase().length();
Не, ну а чё? Была у нас строка
Длина вроде не изменилась, а значит преобразование хорошее, годное. Но это только на первый взгляд. На второй — не очень. Дело в том, что методы toLowerCase() / toUpperCase() можно вызывать как с явным указанием локали, так и без оной. Во втором случае берётся локаль по умолчанию, с некоторыми из которых возможны нюансы. Например, вот этот тест проходит:
Когда запускаешь его впервые, то встаёт вопрос: «Это вообще как?» Была строка из 1 заглавной буквы, потом мы из неё сделали строчную букву и её размер утроился (в байтах — вырос вообще в 6 (!) раз). Но и это прописано в документации:
/** * Converts all of the characters in this to lower * case using the rules of the given . Case mapping is based * on the Unicode Standard version specified by the * class. Since case mappings are not always 1:1 char mappings, the resulting * may be a different length than the original . */ public String toLowerCase(Locale locale) < //. >
//StringLatin1 public static String toLowerCase(String str, byte[] value, Locale locale) < // . String lang = locale.getLanguage(); if (lang == "tr" || lang == "az" || lang == "lt") < // . return toLowerCaseEx(str, value, first, locale, true); >//. >
Так что утверждение о том, что длина строки зависит от регистра знаков не столь бредовое, каким кажется 🙂
Подстроки
В некоторых случаях взятие подстроки размером 1 — String.substring(n, n+1) — является бессмысленным, например, при сравнении с литералом, длина которого также равна 1. Иными словами код:
boolean startsWithUnderline = message.substring(0, 1).equals("_");
boolean startsWithUnderline = message.charAt(0) == '_';
Второй вариант не только короче и проще для понимания, но ещё и позволяет обойтись без создания лишнего объекта. Это же упрощение справедливо для склеивания:
String s = "xxx" + name.substring(n, n + 1);
String s = "xxx" + name.charAt(n);
В этом случае относительный прирост будет не столь ощутим, т. к. расходы на сложение никуда не исчезают. Опять же, взятие подстроки из одного знака может быть отловлено статическим анализатором.
Ещё один интересный случай — это выделение подстроки фиксированной длины и сравнение её с постоянной:
boolean startsWithUrl = content.substring(index, index + 4).equals("url(");
Это выражение можно превратить в
boolean startsWithUrl = content.startsWith("url(", index);
тем самым упростив и сделав его более памятесберегающим. Этот шаблон уже распознаётся, в более сложных случаях нужно поработать руками (и головой):
private String findPerClause(String str)
Данный метод более громоздкий и требует чуть больше времени, чтобы в него вчитаться, но логика в нём довольно простая:
из строки(остаётся только то, что в скобках) --> остаётся только то, что в скобках
Метод предполагает, что строка всегда оканчивается скобкой, поэтому их содержимое можно выделить одной подстрокой:
private String findPerClause(String str)
Помните, что далеко не всякое выделение подстроки можно выбросить из кода:
int idx = path.substring(2).indexOf('/');
Может показаться, что раз существует перегруженный метод String.indexOf(int ch, int fromIndex) , то можно превратить код выше в такой:
избавившись от выделения подстроки. На деле такое преобразование верно только тогда, когда ‘/’ точно найден в строке и от индекса мы отняли 2, т. к. отсчёт теперь ведётся не с начала строки:
int idx = name.indexOf('/', 2); if (pos != -1)
В итоге код получается более громоздким, что делает замену целесообразной только в очень горячих местах.
Закончим раздел небольшим улучшением в JDK. Дело в том, что
возвращает всегда пустую строку при условии, что n не выходит за границы допустимого:
// String public String substring(int beginIndex, int endIndex) < int length = length(); checkBoundsBeginEnd(beginIndex, endIndex, length); int subLen = endIndex - beginIndex; if (beginIndex == 0 && endIndex == length) < return this; >return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen) : StringUTF16.newString(value, beginIndex, subLen); > // StringLatin1 public static String newString(byte[] val, int index, int len)
При равных значениях beginIndex и endIndex переменная subLen будет равна 0, а метод StringLatin1.newString() вернёт пустую строку. Получается, что код можно переписать вот так:
// StringLatin1 public static String newString(byte[] val, int index, int len) < if (len == 0) < return ""; >return new String(Arrays.copyOfRange(val, index, index + len), LATIN1); >
Это позволит не выделять доппамять и одновременно упростить методы StringLatin1.stripLeading() / stripTrailing() и их двойников в StringUTF16 . Все изменения по ссылке.
С последними методами есть нюанс, а именно изменение поведения для пустого массива:
// в StringLatin1 сейчас так public static String stripLeading(byte[] value) < int left = indexOfNonWhitespace(value); if (left == value.length) < return ""; >return (left != 0) ? newString(value, left, value.length - left) : null; >
При value.length == 0 этот код вернёт пустую строку. Так как проверка left == value.length переезжает в newString , то теперь для описанного случая изменённый код
public static String stripLeading(byte[] value)
будет возвращать null ! Проследив исполнение до String.stripLeading() вроде как можно выдохнуть, ведь в этом случае вернётся this , что в нашем случае пустая строка. Повезло, пользователь не заметит разницы. Но пришла беда откуда не ждали:
// до boolean b= new String("").stripLeading() == ""; // true // после boolean b= new String("").stripLeading() == ""; // false !
From a compatibility point of view I think this should be fine, as
the identity of the returned empty string isn’t specified.
Хорошо курочить стандартную библиотеку!
Проверяем, есть ли толк от изменений:
@Warmup(iterations = 10, time = 1) @Measurement(iterations = 10, time = 1) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) @Fork(value = 3, jvmArgsAppend = ) public class SubstringBenchmark < private static final String str = "Tolstoy"; @Benchmark public String substring() < return str.substring(1, 1); >>
до Mode Score Error Units substring avgt 5.8 ± 0.066 ns/op substring:·gc.alloc.rate avgt 4325.9 ± 47.259 MB/sec substring:·gc.alloc.rate.norm avgt 40.0 ± 0.001 B/op substring:·gc.churn.G1_Eden_Space avgt 4338.8 ± 86.555 MB/sec substring:·gc.churn.G1_Eden_Space.norm avgt 40.1 ± 0.647 B/op substring:·gc.churn.G1_Survivor_Space avgt 0.0 ± 0.003 MB/sec substring:·gc.churn.G1_Survivor_Space.norm avgt ≈ 10⁻⁴ B/op substring:·gc.count avgt 557.0 counts substring:·gc.time avgt 387.0 ms после substring avgt 2.4 ± 0.172 ns/op substring:·gc.alloc.rate avgt 0.0 ± 0.001 MB/sec substring:·gc.alloc.rate.norm avgt ≈ 10⁻⁵ B/op substring:·gc.count avgt ≈ 0 counts
Конечно, String.substring(n, n) будет выполнятся нечасто, но прирост есть и для такого копеечного изменения он неплох.
Когда нужно помнить про основное правило
Несмотря на то, что заметку я начал с развенчания мифа о строках, о цене их преобразования нужно помнить, особенно в циклах и особенно тогда, когда раз за разом воспроизводится одна и та же строка. Например, это код раньше жил в спринговом AnnotationMetadataReadingVisitor-е:
MultiValueMap getAllAnnotationAttributes(String annotationName, boolean classValAsStr) < // . String annotatedElement = "class '" + getClassName() + "'"; for (AnnotationAttributes raw : attributes) < for (Map.Entryentry : convertClassValues( "class '" + getClassName() + "'", classLoader, raw, classValAsStr).entrySet()) < allAttributes.add(entry.getKey(), entry.getValue()); >> return allAttributes; >
Выражение «class ‘» + getClassName() + «‘» будет одним и тем же и нам совсем не хочется в двойном цикле создавать одну и ту же строку, поэтому её лучше создать 1 раз за пределами цикла. Раньше отлов таких примеров был делом случая: этот я нашел удачно провалившись внутрь исходников во время отладки своего приложения. Теперь благодаря IDEA-230889 это можно автоматизировать. Разумеется, далеко не всегда создание новой строки в цикле независимо от прохода, но даже в этих случаях можно выделить те, в которых есть некая выносимая постоянная часть:
// org.springframework.beans.factory.support.BeanDefinitionReaderUtils public static String uniqueBeanName(String beanName, BeanDefinitionRegistry registry) < String int counter = -1; // Increase counter until the id is unique. while (counter == -1 || registry.containsBeanDefinition(id)) < counter++; + GENERATED_BEAN_NAME_SEPARATOR + counter; >return id; >
Тут префикс beanName + GENERATED_BEAN_NAME_SEPARATOR всегда один и тот же, поэтому может быть вынесен наружу.
На этом всё, пишите свои примеры в комментариях — обмозгуем.