В работе программиста довольно часто некоторые задачи или их составляющие могут повторяться. Поэтому сегодня хотелось бы затронуть тему, которая часто встречается в повседневной работе любого Java-разработчика. Если нужен только ответ: чтобы заменить часть строки в Java, у класса String есть четыре метода. replace(char, char) и replace(CharSequence, CharSequence) ищут буквально, символ или подстроку; replaceFirst и replaceAll ищут по регулярному выражению и меняют первое или все совпадения. Сама строка при этом не меняется: каждый вызов возвращает новую. Ниже все четыре с примерами, потом Pattern и Matcher, потом замена через StringBuilder и честный замер, что быстрее. Предположим, что вам из некоторого метода приходит некоторая строка. И все в ней вроде бы хорошо, но есть какая-то мелочь, которая вас не устраивает. Например, не подходит разделитель, и вам нужен какой-то другой (или вовсе не нужен). Что можно сделать в такой ситуации? Естественно, воспользоваться методами replace класса String.

Кратко

  • У String четыре метода замены: replace(char, char), replace(CharSequence, CharSequence), replaceFirst(String, String) и replaceAll(String, String).
  • Два первых ищут буквально и меняют все вхождения; два последних принимают регулярное выражение, и неверный шаблон это PatternSyntaxException.
  • String неизменяем: любой replace возвращает новую строку, исходная остается прежней.
  • Для сложных шаблонов ту же замену делают Pattern и Matcher из java.util.regex; с Java 9 Matcher.replaceAll принимает лямбду и может вычислять замену для каждого совпадения.
  • StringBuilder.replace(start, end, str) меняет символы по индексам прямо в объекте, без создания новой строки.
  • Самописная замена на StringBuilder обгоняет replaceAll в микробенчмарке в основном потому, что replaceAll на каждый вызов компилирует регулярное выражение.
Молодой человек в очках сидит в компьютерном зале 80-х с клавиатурой ZX Spectrum на плече и перчаткой Power Glove на руке, вокруг ламповые мониторы

Java string replace

У объекта типа String есть четыре вариации метода замены replace:
  • replace(char, char);
  • replace(CharSequence, CharSequence);
  • replaceFirst(String, String);
  • replaceAll(String, String).
Предназначение всех этих методов одно: замена части строки на другую строку. Отличаются они тем, что ищут и сколько заменяют; для сравнения в таблицу добавлен и метод StringBuilder, до которого мы дойдем в конце статьи.
Метод Что ищет Регулярное выражение Что заменяет
replace(char, char) один символ нет все вхождения
replace(CharSequence, CharSequence) подстроку, буквально нет все вхождения
replaceFirst(String, String) совпадение с шаблоном да только первое
replaceAll(String, String) совпадение с шаблоном да все вхождения
StringBuilder.replace(int, int, String) диапазон индексов нет один диапазон, меняет объект на месте
Давайте рассмотрим их подробнее.

1. replace(char, char)

String replace(char oldChar, char newChar) заменяет все вхождения символа первого аргумента oldChar вторым, newChar. В этом примере мы заменим запятую на точку с запятой:

String value = "In JavaRush, Diego the best, Diego is Java God".replace(',', ';');
System.out.println(value);
Вывод в консоль:
In JavaRush; Diego the best; Diego is Java God

2. replace(CharSequence, CharSequence)

Заменяет каждую подстроку строки, которая соответствует указанной последовательности символов, на последовательность символов замены. Регулярные выражения тут не участвуют, поиск буквальный: в документации так и написано, «literal target sequence». Это и есть метод для обычной замены подстроки; replaceAll ниже нужен, когда без шаблона не обойтись.

String value = "In JavaRush, Diego the best, Diego is Java God".replace("Java", "Rush");
System.out.println(value);
Вывод:
In RushRush, Diego the best, Diego is Rush God

3. replaceFirst(String, String)

String replaceFirst(String regex, String replacement) заменяет первую подстроку, которая соответствует указанному регулярному выражению, замещающей строкой. При использовании недопустимого регулярного выражения можно словить PatternSyntaxException (что не есть гуд). В этом примере давайте заменим имя робота-чемпиона:

String value = "In JavaRush, Diego the best, Diego is Java God".replaceFirst("Diego", "Amigo");
System.out.println(value);
Вывод в консоль:
In JavaRush, Amigo the best, Diego is Java God
Как мы видим, изменилось только первое вхождение "Diego", ну а дальнейшие остались за бортом, то есть нетронутыми.

4. replaceAll(String, String)

String replaceAll(String regex, String replacement) заменяет в строке все подстроки, подходящие под шаблон regex, на replacement. В качестве первого аргумента regex возможно использование регулярного выражения. В качестве примера попробуем выполнить предыдущую замену с именами, но уже новым методом:

String value = "In JavaRush, Diego the best, Diego is Java God".replaceAll("Diego", "Amigo");
System.out.println(value);
Вывод в консоль:
In JavaRush, Amigo the best, Amigo is Java God
Как мы видим, произошла замена всех вхождений на необходимые. Думаю, Амиго будет доволен =)

Регулярные выражения

Выше было сказано, что есть возможность замены по регулярному выражению. Для начала проясним для себя, что же такое регулярное выражение? Регулярные выражения это формальный язык для поиска и осуществления манипуляций с подстроками в тексте, основанный на использовании метасимволов (символов-джокеров). Проще говоря, это шаблон, состоящий из символов и метасимволов, задающий правило поиска. Например:
  • \D: шаблон, описывающий любой нецифровой символ;
  • \d: определяет любой цифровой символ, который также можно описать как [0-9];
  • [a-zA-Z]: шаблон, описывающий латинские буквы от a до z, и строчные, и заглавные.
Рассмотрим применение в методе replaceAll класса String:

String value = "In JavaRush, Diego the best, Diego is Java God".replaceAll("\\s[a-zA-Z]{5}\\s", " Amigo ");
System.out.println(value);
Вывод в консоль:
In JavaRush, Amigo the best, Amigo is Java God
\\s[a-zA-Z]{5}\\s описывает слово из 5 латинских букв, окруженное пробелами. Соответственно, этот шаблон и заменяется на переданную нами строку.

Java regex replace

В основном для использования регулярных выражений в Java применяют возможности пакета java.util.regex. Ключевыми классами являются:
  1. Pattern: класс, предоставляющий скомпилированный вариант регулярного выражения.
  2. Matcher: данный класс интерпретирует шаблон и определяет совпадения в пришедшей ему строке.
Как правило эти два класса работают в связке. Итак, как же будет выглядеть наш предыдущий объект, но уже с помощью Matcher и Pattern:

Pattern pattern = Pattern.compile("\\s[a-zA-Z]{5}\\s");
Matcher matcher = pattern.matcher("In JavaRush, Diego the best, Diego is Java God");
String value = matcher.replaceAll(" Amigo ");
System.out.println(value);
И вывод у нас будет все тем же:
In JavaRush, Amigo the best, Amigo is Java God
У связки есть и то, чего нет у String.replaceAll: скомпилированный Pattern можно создать один раз и использовать многократно, а с Java 9 у Matcher.replaceAll появилась версия, которая принимает лямбду и вычисляет замену для каждого совпадения отдельно: например, matcher.replaceAll(m -> m.group().toUpperCase()) переведет каждое найденное слово в верхний регистр. Более детально с регулярными выражениями можно ознакомиться в этой статье.

Альтернатива replaceAll

Спору нет, методы replace у String весьма впечатляющие, но нельзя не учитывать тот факт, что String это immutable объект, то есть он не может быть изменен после своего создания. Поэтому когда мы заменяем некоторые части строки с помощью методов replace, мы не меняем объект String, а каждый раз создаем новый, с необходимым содержимым. Но каждый раз создавать новый объект довольно долго, не правда ли? Особенно когда вопрос не в паре объектов, а в паре сотен, а то и тысяч. Волей-неволей начинаешь задумываться об альтернативах. И какие у нас есть альтернативы?Демотиватор: мышь прогрызла прямой ход через стенки лабиринта, подпись «Альтернатива есть всегда»Хм... Когда речь заходит о String и его свойстве immutable, сразу вспоминаешь об альтернативах, но не immutable, а именно о StringBuilder/StringBuffer. Как мы помним, эти классы фактически не различаются за исключением того, что StringBuffer оптимизирован под использование в многопоточной среде, поэтому в условиях однопоточного использования StringBuilder работает несколько быстрее. Исходя из этого, сегодня мы и будем использовать StringBuilder.

Метод replace у StringBuilder

У данного класса есть много интересных методов, но конкретно сейчас нас интересует replace. StringBuilder replace(int start, int end, String str) заменяет символы в подстроке этой последовательности на символы в указанной строке. По документации, подстрока начинается с индекса start и продолжается до символа с индексом end - 1 или до конца последовательности, если такого символа не существует. Давайте рассмотрим пример:

StringBuilder strBuilder = new StringBuilder("Java Rush");
strBuilder.replace(5, 9, "God");
System.out.println(strBuilder);
Вывод:
Java God
Как вы видите, мы указываем промежуток, в который хотим записать строку, и записываем подстроку поверх того, что имеется в промежутке.

Свой replaceAll на StringBuilder

Так вот, с помощью StringBuilder воссоздадим аналог метода replaceAll. Как это будет выглядеть:

public static String customReplaceAll(String str, String oldStr, String newStr) {

   if ("".equals(str) || "".equals(oldStr) || oldStr.equals(newStr)) {
       return str;
   }
   if (newStr == null) {
       newStr = "";
   }
   final int oldStrLength = oldStr.length();
   final int newStrLength = newStr.length();
   StringBuilder builder = new StringBuilder(str);

   int from = 0;
   int index;
   while ((index = builder.indexOf(oldStr, from)) != -1) {
       builder.replace(index, index + oldStrLength, newStr);
       from = index + newStrLength;
   }
   return builder.toString();
}
На первый взгляд страшно, но немного разобравшись можно понять, что все не так уж и сложно и вполне себе логично. У нас есть три аргумента:
  • str: строка, в которой мы хотим заменить некоторые подстроки;
  • oldStr: представление подстрок, которые будем заменять;
  • newStr: то, на что мы будем заменять.
Первый if нам необходим, чтобы проверить входящие данные, и если строка str или oldStr пусты, или же новая подстрока newStr равна старой oldStr, то выполнение метода будет бессмысленным. Поэтому возвращаем первоначальную строку str. Далее проверяем newStr на null, и если это так, то преобразуем в более удобный для нас формат пустой строки, "". После у нас идет объявление необходимых нам переменных:
  • длины подстроки oldStr;
  • длины замены newStr;
  • объект StringBuilder из общей строки;
  • позиция from, с которой ведется очередной поиск (в начале это 0).
Запускаем цикл, который ищет вхождения одно за другим, пока они не кончатся. С помощью метода класса StringBuilder indexOf узнаем индекс первого вхождения интересующей нас подстроки, начиная с позиции from. С сожалением, хотелось бы отметить, что indexOf не работает с регулярными выражениями, поэтому наш итоговый метод будет работать только с вхождениями строк(( Если этот индекс у нас равен -1, то данных вхождений в текущем объекте StringBuilder больше нет, поэтому выходим из цикла с интересующим результатом: он содержится в нашем StringBuilder, который мы преобразуем к String с помощью toString. Если -1 пришел с первой же попытки, значит подстроки, которую нужно заменить, в общей строке не было изначально, и на выходе получится строка с тем же содержимым. Далее у нас и идет использование вышеописанного метода replace для StringBuilder с использованием найденного индекса вхождения для обозначения координат заменяемой подстроки. После замены сдвигаем from на позицию сразу за вставленным текстом. Это важная деталь: если бы следующий поиск начинался раньше, метод заглянул бы внутрь только что вставленной замены, и запрос вроде «заменить a на aa» превратился бы в бесконечное разрастание строки. Данный цикл отработает столько раз, сколько будет найдено подстрок, которые нужно заменить, и закончится ровно тогда, когда indexOf вернет -1. Никакого верхнего предела по длине строки ему для этого не нужно.

Проверяем тестом

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

@Test
public void customReplaceAllTest() {
   String str = "qwertyuiop__qwertyuiop__";

   String firstCase = Solution.customReplaceAll(str, "q", "a");
   String firstResult = "awertyuiop__awertyuiop__";
   assertEquals(firstResult, firstCase);

   String secondCase = Solution.customReplaceAll(str, "q", "ab");
   String secondResult = "abwertyuiop__abwertyuiop__";
   assertEquals(secondResult, secondCase);

   String thirdCase = Solution.customReplaceAll(str, "rtyu", "*");
   String thirdResult = "qwe*iop__qwe*iop__";
   assertEquals(thirdResult, thirdCase);

   String fourthCase = Solution.customReplaceAll(str, "q", "");
   String fourthResult = "wertyuiop__wertyuiop__";
   assertEquals(fourthResult, fourthCase);

   String fifthCase = Solution.customReplaceAll(str, "uio", "");
   String fifthResult = "qwertyp__qwertyp__";
   assertEquals(fifthResult, fifthCase);

   String sixthCase = Solution.customReplaceAll(str, "", "***");
   assertEquals(str, sixthCase);

   String seventhCase = Solution.customReplaceAll("", "q", "***");
   assertEquals("", seventhCase);

   String eighthCase = Solution.customReplaceAll("ab", "a", "xa");
   assertEquals("xab", eighthCase);
}
Можно разбить на 8 отдельных тестов, каждый из которых будет отвечать за свой тестовый случай. Восьмой случай проверяет как раз ту ловушку, о которой шла речь выше: замена содержит искомую подстроку, и метод не должен найти ее внутри только что вставленного текста. Обратите внимание и на порядок аргументов у assertEquals: первым идет ожидаемое значение, вторым фактическое, иначе при падении теста сообщение покажет их наоборот. Запустив его мы увидим, что он зеленый, то есть успешный. Ну вот, вроде и все.

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

Хотя постойте, выше мы говорили, что данный метод будет значительно быстрее, чем replaceAll у String. Что ж, давайте посмотрим:

String str = "qwertyuiop__qwertyuiop__";
long firstStartTime = System.nanoTime();

for (long i = 0; i < 10000000L; i++) {
   str.replaceAll("tyu", "#");
}

double firstPerformance = System.nanoTime() - firstStartTime;

long secondStartTime = System.nanoTime();

for (long i = 0; i < 10000000L; i++) {
   customReplaceAll(str, "tyu", "#");
}

double secondPerformance = System.nanoTime() - secondStartTime;

System.out.println("Performance ratio  - " +  firstPerformance / secondPerformance);
Далее, этот код был запущен трижды, и мы получили следующие результаты: Вывод в консоль:
Performance ratio  - 5.012148941181627
 
Performance ratio  - 5.320637176017641
 
Performance ratio  - 4.719192686500394
Как мы видим, в среднем наш метод производительнее, чем классический replaceAll класса String в 5 раз! Что ж и напоследок давайте запустим эту же проверку, но, так сказать, вхолостую. Другими словами, в том случае когда совпадения найдено не будет. Заменим строку для поиска с "tyu" на "--". При трех запусках были получены следующие результаты: Вывод в консоль:
Performance ratio  - 8.789647093542246
 
Performance ratio  - 9.177105482660881
 
Performance ratio  - 8.520964375227406
В среднем производительность для случаев, когда совпадений не было найдено, выросла в 8.8 раз! Теперь честная оговорка про эти цифры. Мы сравнивали свой метод с replaceAll, а replaceAll по документации это Pattern.compile(regex).matcher(str).replaceAll(repl), то есть на каждый из десяти миллионов вызовов заново компилируется регулярное выражение. Наш метод ищет подстроку буквально, поэтому честная пара для него не replaceAll, а replace(CharSequence, CharSequence): он тоже обходится без регулярных выражений, а с Java 9 переписан на прямой поиск без Pattern (задача JDK-8058779). Скорее всего, встроенный replace в этом же замере окажется не медленнее самописного. Да и сам замер грубый: без прогрева JIT и с результатом, который никуда не записывается. Так что цифры выше стоит воспринимать как иллюстрацию того, сколько стоит регулярное выражение на каждый вызов, а не как точное измерение. Вывод для рабочего кода: для буквальной замены берите String.replace, а replaceAll оставьте для случаев, когда без шаблона не обойтись.

Вопросы и ответы

Чем replace отличается от replaceAll в Java?

Тем, что они ищут. replace ищет буквально: символ или подстроку ровно в том виде, как вы ее передали, и меняет все вхождения. replaceAll принимает регулярное выражение, поэтому точка, скобка или знак плюс в нем это спецсимволы. Если шаблон не нужен, берите replace: он проще, быстрее и не бросает PatternSyntaxException.

Почему replaceAll бросает PatternSyntaxException?

Потому что первый аргумент это регулярное выражение, и в нем есть синтаксическая ошибка: незакрытая скобка, одиночная звездочка в начале, непарный квантификатор. Проверьте шаблон отдельно через Pattern.compile или уйдите от регулярных выражений на replace(CharSequence, CharSequence), если нужна обычная подстрока.

Как заменить точку или другой спецсимвол через replaceAll?

Экранировать его в шаблоне двумя обратными слэшами: str.replaceAll("\\.", ",") заменит точки на запятые, а без экранирования точка означает «любой символ» и заменится вся строка. Второй способ: обернуть искомое в Pattern.quote(). Третий и самый простой: если шаблон не нужен, вызвать str.replace(".", ","). Отдельная ловушка в строке замены: там $ и обратный слэш тоже спецсимволы, их гасит Matcher.quoteReplacement().

Меняет ли replace исходную строку?

Нет. String неизменяем, поэтому все четыре метода возвращают новую строку, а исходная остается прежней. Частая ошибка: вызвать str.replace("a", "b"); и не присвоить результат, тогда замена просто теряется. Правильно str = str.replace("a", "b");.

Как заменить подстроку без учета регистра?

Регулярным выражением с флагом (?i) в начале: str.replaceAll("(?i)diego", "Amigo") заменит и «Diego», и «DIEGO», и «diego». У replace такого режима нет, он сравнивает символы точно.

Как заменить символы по позиции, а не по значению?

Через StringBuilder.replace(start, end, str): указываете начальный индекс (включительно), конечный (не включая) и новую строку, и метод перезаписывает этот диапазон прямо в объекте. Для неизменяемой String то же самое собирают из кусков: str.substring(0, start) + newPart + str.substring(end). Надпись Mission passed! Respect + в стиле GTA на ярко-зеленом фоне