🔤 Розділ 12 · Питання #16

В чому особливість методу replace() vs replaceAll()

Обидва методи — і replace(), і replaceAll() — замінюють всі знайдені входження в рядку на нове значення.


🟢 Junior Level

Обидва методи — і replace(), і replaceAll() — замінюють всі знайдені входження в рядку на нове значення.

Головна відмінність між ними полягає в тому, як саме інтерпретується шуканий шаблон:

  • replace() виконує літеральну (текстову) заміну. Він шукає точний збіг символів або підрядка. Спецсимволи (крапка, зірочка, дужки) сприймаються як звичайний текст.
  • replaceAll() виконує заміну на основі регулярного виразу (Regex). Шуканий аргумент компілюється як шаблон регулярного виразу, а в рядку заміни символи $ та \ мають спеціальне значення (посилання на групи).

Суть у 30 секундах

Найпідступніша помилка на співбесідах — вважати, що replace() змінює лише перше входження, а replaceAll() — усі. Обидва методи змінюють ВСІ входження! Якщо вам потрібно замінити лише перший знайдений фрагмент, для цього існує окремий метод replaceFirst().

String text = "a.b.c.d";

// 1. Літеральна заміна: крапка — це просто символ '.'
System.out.println(text.replace(".", "/")); 
// Виведе: "a/b/c/d"

// 2. Заміна за регулярним виразом: крапка — це "будь-який символ" у Regex!
System.out.println(text.replaceAll(".", "/")); 
// Виведе: "///////" (кожен символ замінено на '/')

// 3. Заміна тільки першого входження:
System.out.println(text.replaceFirst("\\.", "/")); 
// Виведе: "a/b.c.d"

🟡 Middle Level

Сигнатури та відмінності методів

У класі java.lang.String представлено 4 методи для заміни:

Метод Що приймає на пошук Що приймає на заміну Що замінює Продуктивність
replace(char, char) Символ char Символ char Усі входження ⚡ Найшвидший (прямий скан масиву байтів)
replace(CharSequence, CharSequence) Літеральний рядок Літеральний рядок Усі входження 🚀 Дуже швидкий (без regex-рушія в Java 9+)
replaceAll(String, String) Регулярний вираз Рядок підстановки (з групами $) Усі входження 🐢 Повільний (компіляція Pattern/Matcher)
replaceFirst(String, String) Регулярний вираз Рядок підстановки (з групами $) Перше входження 🐢 Повільний (компіляція Pattern/Matcher)

Пастки методу replaceAll()

  1. Неекрановані спецсимволи в regex: Виклик str.replaceAll("100$", "200$") не знайде рядок "100$", оскільки в регулярних виразах знак $ означає кінець рядка. Для літерального пошуку потрібне екранування: Pattern.quote("100$").
  2. Падіння при символі $ у рядку підстановки (replacement):
    String price = "Cost: PRICE";
    // ПОМИЛКА: викине IllegalArgumentException: No group with name 100!
    String result = price.replaceAll("PRICE", "$100"); 
    

    У replaceAll символ $ використовується для підстановки захоплених груп ($1, $2). Якщо за знаком $ стоїть число, рушій намагається знайти відповідну regex-групу. Рішення: Для безпечного вставлення довільного тексту в replaceAll рядок заміни огортають у Matcher.quoteReplacement("$100").


🔴 Senior Level

Еволюція реалізації: як влаштований replace() в Java 8 проти Java 9+

До Java 9 метод replace(CharSequence, CharSequence) мав прихований оверхед:

  • Усередині він викликав: Pattern.compile(target.toString(), Pattern.LITERAL).matcher(this).replaceAll(Matcher.quoteReplacement(replacement.toString())) Хоча пошук був літеральним (Pattern.LITERAL), JVM створювала об’єкти Pattern та Matcher, витрачаючи пам’ять і процесорний час на прогін через скінченний автомат.

Починаючи з Java 9 (включаючи Java 17 та 21): Метод replace(CharSequence, CharSequence) було повністю переписано без використання java.util.regex:

  1. Перевіряє перше входження через швидкий пошук indexOf(target).
  2. Оптимізація відсутності збігів: Якщо підрядок не знайдено, метод негайно повертає this без жодної алокації в купі!
  3. Точний розрахунок розміру: Якщо збіги є, метод заздалегідь обчислює точну довжину підсумкового масиву байтів: \(\text{newLength} = \text{oldLength} + \text{matchCount} \times (\text{replacementLength} - \text{targetLength})\)
  4. Виділяє рівно один масив byte[] потрібного розміру з урахуванням coder (Latin-1 / UTF-16) і безпосередньо копіює недоторкані фрагменти та рядки підстановки. У результаті replace() у сучасних версіях Java працює в 5–10 разів швидше, ніж replaceAll().
// OpenJDK String.java (спрощено)
public String replace(CharSequence target, CharSequence replacement) {
    String trgStr = target.toString();
    String replStr = replacement.toString();
    int i = indexOf(trgStr);
    if (i < 0) {
        return this; // Швидке повернення без виділення пам'яті!
    }
    // Прямий розрахунок та збирання масиву байтів без Pattern/Matcher
    return isLatin1()
        ? StringLatin1.replace(value, trgStr.value, replStr.value)
        : StringUTF16.replace(value, trgStr.value, replStr.value);
}

Безпека: вразливість ReDoS (Regular Expression Denial of Service)

Використання replaceAll() з динамічним користувацьким введенням як шаблону пошуку несе критичну вразливість ReDoS:

  • Якщо зловмисник передасть спеціально сформований регулярний вираз із катастрофічним відкатом (Catastrophic Backtracking), наприклад (a+)+$, потік сервера зависне зі 100% утилізацією ядра CPU на кілька хвилин або годин.
  • Метод replace() принципово захищений від ReDoS, оскільки виконує детермінований лінійний пошук за час $O(N)$.

4 Tricky Questions

1. Чому виклик str.replaceAll("PRICE", "$100") викидає IllegalArgumentException і як це виправити?

Відповідь: У методі replaceAll другий аргумент (replacement) не є простим текстом. Символ $ зарезервований для механізму зворотних посилань на групи захоплення (Capturing Groups). Коли рушій регулярних виразів зустрічає $100, він намагається знайти в regex захоплену круглими дужками групу з номером 100. Оскільки в регулярному виразі "PRICE" дужок і груп немає, JVM викидає java.lang.IllegalArgumentException: No group with name 100 або IndexOutOfBoundsException. Способи вирішення:

  1. Якщо не потрібен regex — використовувати str.replace("PRICE", "$100") (безпечна літеральна заміна).
  2. Якщо regex необхідний — екранувати рядок заміни через str.replaceAll("PRICE", Matcher.quoteReplacement("$100")).

2. Чи створює str.replace("target", "replacement") новий об’єкт у купі, якщо підрядок "target" не було знайдено у вихідному рядку?

Відповідь: У сучасних версіях Java (Java 9, 11, 17, 21) — ні, не створює. Метод replace() першим кроком запускає пошук через indexOf(target). Якщо індекс першого збігу < 0, метод повертає посилання this (вихідний об’єкт). Перевірка str == str.replace("absent", "new") поверне true, а споживання пам’яті купи дорівнюватиме 0 байтів. Примітка: Метод replaceAll(), навпаки, створює об’єкти Pattern та Matcher навіть у тому випадку, якщо жодного збігу не буде знайдено.

3. Як замінити ТІЛЬКИ ПЕРШЕ входження звичайного текстового рядка без інтерпретації його як регулярного виразу?

Відповідь: У класі String немає перевантаження replaceFirst(CharSequence, CharSequence) — метод replaceFirst(String regex, String replacement) приймає виключно регулярний вираз. Є два надійні шляхи:

  1. Екранування обох аргументів:
    String result = str.replaceFirst(
        Pattern.quote(target), 
        Matcher.quoteReplacement(replacement)
    );
    
  2. Ручна заміна через indexOf (найпродуктивніший варіант без алокацій regex):
    public static String replaceFirstLiteral(String source, String target, String replacement) {
        int index = source.indexOf(target);
        if (index == -1) return source;
        return source.substring(0, index) + replacement + source.substring(index + target.length());
    }
    

4. Що відбудеться при виклику text.replaceAll("(?i)apple", "orange")?

Відповідь: Відбудеться регістронезалежна заміна слова apple в усіх можливих варіантах написання (Apple, APPLE, aPple тощо) на слово orange. Префікс (?i) у синтаксисі Java Regex вмикає прапорець Pattern.CASE_INSENSITIVE (регістронезалежний режим) для всього наступного шаблону. Метод replace() реалізувати подібне одним рядком не здатний, оскільки працює строго з літералами з урахуванням регістру.


🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«І replace(), і replaceAll() замінюють всі знайдені збіги в рядку. Різниця полягає в типі пошуку: replace() виконує швидкий літеральний пошук точного тексту без використання регулярних виразів, а replaceAll() компілює перший аргумент у регулярний вираз (Pattern). Крім того, у другому аргументі replaceAll() символи $ та \ слугують для зворотних посилань на regex-групи і вимагають екранування через Matcher.quoteReplacement(). Для заміни тільки одного входження використовується replaceFirst()».

Ключові тези для Senior-інтерв’ю

  1. Обидва міняють ВСЕ: replace замінює не перше входження, а абсолютно всі входження.
  2. Java 9+ оптимізація: У Java 9+ replace(CharSequence, CharSequence) працює без Pattern.LITERAL, використовуючи прямий скан масиву byte[], і повертає this при 0 збігів.
  3. Спецсимволи в replacement: У replaceAll символ $ означає посилання на групу; передача сирих рядків (наприклад, цін $50) без Matcher.quoteReplacement() призводить до падіння в рантаймі.
  4. Захист від ReDoS: replace() є детермінованим і невразливим до атак типу «відмова в обслуговуванні» через регулярні вирази.
  5. Fast Char Replacement: Метод replace(char, char) — найшвидший спосіб заміни поодиноких символів (однопрохідний цикл масивом байтів).

Червоні прапорці (Чого говорити категорично не можна)

  • ❌ Стверджувати: «replace() замінює лише перше входження, а replaceAll() — усі» (головний маркер некомпетентності).
  • ❌ Використовувати replaceAll() для заміни простих фіксованих рядків (наприклад, крапок чи слешів), забуваючи про накладні витрати компіляції regex та екранування.
  • ❌ Передавати неперевірене користувацьке введення безпосередньо в replaceAll() (ризик ReDoS-атаки).
  • ❌ Вважати, що replace() працює повільніше за replaceAll().

Пов’язані питання