Маленькие тонкости java.lang.String
Приветствую,
перебирая накопленный материал по java.lang.String
решил сделать небольшую подборку примеров из эффективного (и не очень) использования.
Любое преобразование строки порождает новую строку
Это один из главных мифов о строках. На деле это не всегда так. Предположим, что у нас есть строка содержащая только строчные буквы:
var str = "str";
Теперь этот код
jshell> var str = "str";
jshell> System.out.println(str.toLowerCase() == str);
выведет
true
Иными словами, здесь вызов 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; // <-- фактически возврат this
//...
}
В целом это поведение логично: зачем городить новую строку, если в исходной не произошло никаких изменений. Это же справедливо для прочих методов, меняющих строку лишь частично, например, 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() {
String ret = isLatin1() ? StringLatin1.strip(value)
: StringUTF16.strip(value);
return ret == null ? this : ret;
}
В данной связи иногда возникают разные нехорошие соблазны:
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()
найдём там незамысловатую реализацию:
public boolean isEmpty() {
return value.length == 0;
}
Держа в уме описанное ранее преобразование может возникнуть соблазн превратить
int len = someStr.toLowerCase().length();
в
int len = someStr.length();
Не, ну, а чё? Была у нас строка
String s = "Ку!";
а стала
String s = "ку!";
Длина вроде не изменилась, а значит преобразование хорошее, годное. Но это только на первый взгляд. На второй — не очень. Дело в том, что методы toLowerCase()
/ toUpperCase()
можно вызывать как с явным указанием локали, так и без оной. Во втором случае берётся локаль по умолчанию, с некоторыми из которых возможны нюансы. Например, вот этот тест проходит:
@Test
void toLowerCase() {
String str = "\u00cc"; // Ì
assert str.length() == 1;
String strLowerCase = str.toLowerCase(new Locale("lt"));
assert strLowerCase.length() == 3; // i̇̀
}
Когда запускаешь его впервые, то встаёт вопрос: «Это вообще как?» Была строка из 1 заглавной буквы, потом мы из неё сделали строчную букву и её размер утроился (в байтах — вырос вообще в 6 (!) раз). Но и это прописано в документации:
/**
* Converts all of the characters in this {@code String} to lower
* case using the rules of the given {@code Locale}. Case mapping is based
* on the Unicode Standard version specified by the {@link java.lang.Character Character}
* class. Since case mappings are not always 1:1 char mappings, the resulting
* {@code String} may be a different length than the original {@code String}.
*/
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) {
str = str.substring(str.indexOf('(') + 1);
str = str.substring(0, str.length() - 1);
return str;
}
Данный метод более громоздкий и требует чуть больше времени, чтобы в него вчитаться, но логика в нём довольно простая:
из строки(остаётся только то, что в скобках)
-->
остаётся только то, что в скобках
Метод предполагает, что строка всегда оканчивается скобкой, поэтому их содержимое можно выделить одной подстрокой:
private String findPerClause(String str) {
int beginIndex = str.indexOf('(') + 1;
int endIndex = str.length() - 1;
return str.substring(beginIndex, endIndex);
}
Помните, что далеко не всякое выделение подстроки можно выбросить из кода:
int idx = path.substring(2).indexOf('/');
Может показаться, что раз существует перегруженный метод String.indexOf(int ch, int fromIndex)
, то можно превратить код выше в такой:
int idx = path.indexOf('/', 2);
избавившись от выделения подстроки. На деле такое преобразование верно только тогда, когда '/'
точно найден в строке и от индекса мы отняли 2, т. к. отсчёт теперь ведётся не с начала строки:
int idx = name.indexOf('/', 2);
if (pos != -1) {
idx -= 2;
}
В итоге код получается более громоздким, что делает замену целесообразной только в очень горячих местах.
Закончим раздел небольшим улучшением в JDK. Дело в том, что
someStr.substring(n, n);
возвращает всегда пустую строку при условии, что 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) {
return new String(Arrays.copyOfRange(val, index, index + len), LATIN1);
}
При равных значениях 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) {
int left = indexOfNonWhitespace(value);
return (left != 0) ? newString(value, left, value.length - left) : null;
}
будет возвращать null
! Проследив исполнение до String.stripLeading()
вроде как можно выдохнуть, ведь в этом случае вернётся this
, что в нашем случае пустая строка. Повезло, пользователь не заметит разницы. Но пришла беда откуда не ждали:
// до
boolean b= new String("").stripLeading() == ""; // true
// после
boolean b= new String("").stripLeading() == ""; // false !
Чёрт, неужели всё пропало?
Хорошо курочить стандартную библиотеку!
Проверяем, есть ли толк от изменений:
@Warmup(iterations = 10, time = 1)
@Measurement(iterations = 10, time = 1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(value = 3, jvmArgsAppend = {"-Xms4g", "-Xmx4g", "-XX:+UseParallelGC"})
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.Entry entry : 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 id = beanName;
int counter = -1;
// Increase counter until the id is unique.
while (counter == -1 || registry.containsBeanDefinition(id)) {
counter++;
id = beanName + GENERATED_BEAN_NAME_SEPARATOR + counter;
}
return id;
}
Тут префикс beanName + GENERATED_BEAN_NAME_SEPARATOR
всегда один и тот же, поэтому может быть вынесен наружу.
На этом всё, пишите свои примеры в комментариях — обмозгуем.