Текстовые блоки в Java

Введение
В данной статье архитектор Java Brian Goetz подробно рассказывает о появившейся в Java новой фиче языка — текстовые блоки.
В Java SE 13 текстовые блоки были представлены в качестве предварительной функции языка. Целью их создания стало желание разработчиков уменьшить трудности объявления и использования многострочных строковых литералов в Java.
Впоследствии текстовые блоки были усовершенствованы во втором предварительном релизе с небольшими изменениями и должны стать постоянной функцией языка в Java SE 15 (сентябрь 2020 г.).
Текстовые блоки представляют собой строковые литералы, которые могут состоять из множества строк текста:
String address = """
        25 Main Street
        Anytown, USA, 12345
        """;
В этом простом примере переменная address будет содержать двухстрочную строку с разделителями после каждой строки.
До появления текстовых блоков Java-программисты писали строковые литералы так:
String address = "25 Main Street\n" +
        "Anytown, USA, 12345\n";

или

String address = "25 Main Street\nAnytown, USA, 12345\n";
Каждый Java-разработчик знает, как громоздко могут выглядеть подобные строковые литералы. Кроме этого такой код более подвержен ошибкам (легко забыть поставить \n) и труден для чтения (синтаксис языка смешивается с содержимым строки). Поскольку в текстовом блоке обычно отсутствуют escape-символы и другие лингвистические помехи, то это позволяет языку «не путаться под ногами», а также повышает читаемость содержимого строки.
Наиболее часто используемым экранирующим символом в строковых литералах является символ новой строки (\n), который больше не требуется в текстовых блоках. Следующим по популярности идет символ двойная кавычка («), которую необходимо экранировать, поскольку она конфликтует с кавычками, обрамляющими строковый литерал. Текстовые блоки также устраняют необходимость в ней, т.к. одиночная кавычка не конфликтует с ограничителем текстового блока в виде тройных кавычек.
1. Почему было выбрано такое забавное название?
Кто-то мог бы подумать, что эту функцию языка нужно было бы назвать «многострочный строковый литерал» (и, возможно, именно так ее будут называть многие люди). Но мы выбрали другое имя, текстовые блоки, чтобы подчеркнуть тот факт, что текстовый блок — это не просто несвязанный набор строк, а скорее двумерный блок текста, встроенный в программу Java.
Чтобы проиллюстрировать, что мы подразумеваем под «двумерным», давайте возьмем немного более структурированный пример, представляющий собой фрагмент XML (те же соображения применимы и к строкам на каком-то другом «языке», таком как SQL, HTML, JSON или даже Java, которые встраиваются как литералы в программу):
void m() {
    System.out.println("""
                       <person>
                           <firstName>Bob</firstName>
                           <lastName>Jones</lastName>
                       </person>
                       """);
}
Что же автор этих строк ожидает увидеть напечатанным? Несмотря на то, что мы не можем читать его мысли, кажется маловероятным, что цель состояла в том, чтобы XML-код имел отступ в 23 пробела. Гораздо более вероятно, что эти 23 пробела существует исключительно для выравнивания текстового блока относительно другого кода. С другой стороны, автор почти наверняка предполагал, что вторая строка XML-кода должна иметь отступ на четыре пробела больше, чем первая. Кроме того, даже если ему действительно нужно ровно 23 пробела, то что произойдет, если программа изменится? Мы не хотим, чтобы отступ при выводе изменялся только потому, что исходный код был переформатирован.
Из этого примера мы видим, что существует задача разделения пробелов на те, которые используются для форматирования отступов внутри строковых литералов и те, которые нужны для форматирования литерала относительно другого кода (традиционные строковые литералы не имеют этой проблемы).
Одним из способов решения этой задачи является использование библиотечного метода String: stripIndent, который мы можем применить к многострочным строковым литералам. Но т.к. это настолько распространенная проблема, то Java пошла дальше, и теперь случайные отступы автоматически удаляются во время компиляции.
Чтобы разделить случайные и необходимые отступы, мы можем мысленно нарисовать наименьший по размерам прямоугольник вокруг строкового литерала, и обработать его содержимое как двумерный блок текста. Этот «магический прямоугольник» позволяет задать границу относительно которой любые отступы в начале каждой строки литерала будут считаться сдвигом внутри этого литерала, при этом любые отступы за пределами прямоугольника — игнорируются.
Аналогия с «магическим прямоугольником» может помочь в понимании того, как работают текстовые блоки. При этом следует добавить, что баланс побочных и основных отступов можно регулировать, используя положение закрывающего разделителя (""") относительно строкового литерала.
2. Детали
В текстовом блоке в качестве открывающего и закрывающего разделителя используются тройные кавычки ("""). Содержимое текстового блока должно начинаться с новой строки, а не на строке, где находится открывающий разделитель и продолжаться до закрывающего разделителя:
// Ошибка
String name = """Pat Q. Smith""";

// Ошибка
String name = """red
                 green
                 blue
                 """;

// OK
String name = """
        red
        green
        blue
        """;
Обработка содержимого блока во время компиляции имеет три фазы:
  • Нормализация ограничителей строки. Все разделители строк заменяются принудительно символом LF (\u000A). Это предотвращает внесение изменений, принятых в той или иной операционной системе (windows использует CR + LF для завершения строк, в то время как системы Unix используют только LF. Могут использоваться и другие схемы)

  • Удаление всех случайных начальных и конечных пробелов. Случайные пробел определяется следующим образом:

    • вычисляется набор непустых определяющих строк, полученных на предыдущем шаге, а также последняя строка (содержащая закрывающий разделитель), даже если она пустая;

    • вычисляется общий пробельный префикс для всех определяющих строк;

    • удаляется общий пробельный префикс из каждой определяющей строки

  • Интерпретация escape-последовательностей в контенте. Текстовые блоки используют тот же набор escape-последовательностей, что и строковые и символьные литералы. Это означает, что такие последовательности, как \n, \t, \s и \<eol>, не влияют на обработку пробелов (две новые escape-последовательности были добавлены в набор как часть JEP 368: \s для явного пробела и \<eol> в качестве индикатора продолжения)
В нашем XML-примере пять определяющих строк: четыре строки содержат XML-код; одна строка содержит закрывающий разделитель. В данном блоке будут удалены все пробелы из первой и последней строки. Две средние строки будут иметь отступ в четыре пробела. Все строки имеют отступ, по крайней мере, настолько же пробелов, сколько содержится в первой строке. Достаточно часто это и есть то, что нами ожидается, но иногда мы можем не захотеть убрать все ведущие отступы. Например, если требуется, чтобы весь блок был с отступом в четыре пробела, то это можно сделать, переместив закрывающий разделитель влево на четыре пробела:
void m() {
    System.out.println("""
                       <person>
                           <firstName>Bob</firstName>
                           <lastName>Jones</lastName>
                       </person>
                   """);
}
Поскольку последняя строка также является определяющей, то общий пробельный префикс теперь считается от начала закрывающего разделителя. Все пробелы перед ним — удаляются из каждой строки. В итоге остается общий отступ для всего блока в четыре пробела. Мы также можем управлять отступами программно, с помощью метода String:indent, который принимает многострочную строку и выравнивает в ней каждую строчку на фиксированное количество пробелов:
void m() {
    System.out.println("""
                       <person>
                           <firstName>Bob</firstName>
                           <lastName>Jones</lastName>
                       </person>
                       """.indent(4));
}
3. Встроенные выражения
В Java строковые литералы (и текстовые блоки) не поддерживают интерполяцию выражений (подстановку переменных), как некоторые другие языки. Исторически строковые выражения создавались с помощью обычной конкатенации строк (+). В Java 5 был добавлен String:format для форматирования строк в стиле «printf».
На основе проведенного глобального анализа, связанного с пробелами, мы пришли к выводу, что определить нужные отступы при объединении текстовых блоков с помощью конкатенации, может быть непростой задачей. Но текстовый блок рассматривается как обычная строка, поэтому мы все еще можем использовать String:format для параметризации строкового выражения. Кроме того, мы можем использовать новый метод String:formatted, который является экземпляром версии String:format:
String person = """
                <person>
                    <firstName>%s</firstName>
                    <lastName>%s</lastName>
                </person>
                """.formatted(first, last);
(К сожалению, этот метод также нельзя назвать форматным, поскольку мы не можем перегрузить статические методы и методы экземпляров с одинаковыми именами и списками параметров.
4. Прецеденты и история
Хотя строковые литералы, в некотором смысле, являются «тривиальной» функцией, они используются достаточно часто, тем самым накапливая небольшие раздражения. Поэтому неудивительно, что отсутствие многострочных строк было одной из самых распространенных жалоб на Java в последние годы. Кроме того, многие другие языки имеют несколько видов строковых литералов для различных вариантов использования.
Легко сказать «нам нужны многострочные строки», но когда мы исследовали другие языки, то обнаружили самые разнообразные подходы как по синтаксису, так и по целям (и, конечно, сравнительно широкий спектр мнений разработчиков о «правильном» способе сделать это). Хоть и не существует двух одинаковых языков, но для большинства функций, которые являются общими (например, циклы), обычно существует несколько принятых подходов, из которых выбирают языки. Согласитесь, что как-то необычно найти пятнадцать различных интерпретаций функции в пятнадцати языках, но это именно то, что мы нашли, когда дело дошло до многострочных и необработанных строковых литералов.
В следующей таблице показаны (некоторые из) вариантов строковых литералов в разных языках. В каждом случае '…' считается содержимым строкового литерала, в котором поддерживаются или не поддерживаются escape-последовательности и интерполяции. 'xxx' представляет выбранный пользователем одноразовый номер, который гарантированно не конфликтует с содержимым строки. '##' представляет переменное количество символов '#' (которое может быть нулевым).
Условные обозначения:
  • esc — некоторая степень обработки escape-последовательности, где escape-символы обычно взяты из стиля языка C (например, \n)
  • Interp — некоторая поддержка интерполяции переменных или произвольных выражений
  • span — многострочные строки могут быть выражены простым соединением нескольких исходных строк
  • here — «here-doc» (синтаксис занесения в переменную одно- или (часто) многострочного свободно форматированного текста «как есть»), где последующие строки, вплоть до строки, которая содержит только выбранный пользователем одноразовый номер, обрабатываются как тело строкового литерала
  • prefix — форма префикса действительна для всех других форм строковых литералов и для краткости опущена
  • delim — разделитель можно настраивать в некоторой степени, будь то путем включения однократно используемого номера (C++), различного количества символов # (Rust, Swift) или замены фигурных скобок для других совпадающих скобок (Ruby)
  • strip — некоторая степень поддержки удаления случайных отступов
В то время, как эта таблица дает представление о разнообразии подходов к строковым литералам, на самом деле она лишь поверхностно отражает разнообразие тонкостей того, как языки интерпретируют строковые литералы. Хотя большинство языков используют escape-последовательности, вдохновившись языком C, они различаются в зависимости от того, какие именно последовательности они поддерживают (например, \unnnn) и поддерживают ли вообще.
Наиболее очевидными различиями в разных языках является выбор типа разделителей и то, как разные разделители сигнализируют о различных формах строковых литералов (с экранированием или без него, с одной или несколькими строками, с интерполяцией или без нее, с выбором кодировок символов и т. д.). Но, читая между строк мы видим, как эти синтаксические варианты часто отражают философские различия в конструкции языка — как сбалансировать различные цели, такие как простота, выразительность и удобство для пользователя.
Неудивительно, что скриптовые языки (bash, Perl, Ruby, Python) сделали «выбор пользователя» своим первым приоритетом для литералов, которые могут различаться разными способами выражения одного и того же. Но в целом, языки совершенно не волнует, как они побуждают пользователей думать о строковых литералах, сколько форм они используют и насколько ортогональны эти формы.
Мы также видим несколько подходов к строкам, состоящим из нескольких строк. Некоторые (например, Javascript и Go) обрабатывают окончания строк, как просто еще один символ, позволяющий всем формам строковых литералов занимать несколько строк. Одни (например, C++) рассматривают их как особый случай «сырых» строк, другие (например, Kotlin) делят строки на «простые» и «сложные». а третьи предлагают так много вариантов, что они не поддаются даже этим простым классификациям. Точно так же они различаются в своей интерпретации «необработанной строки» и т. д.
Несмотря на широкий спектр подходов и мнений, с точки зрения баланса устоявшегося дизайна и выразительности, в этом обзоре есть явный «победитель»: Swift. Ему удается поддерживать экранирование, интерполяцию и истинное несовершенство с помощью одного гибкого механизма (как в однолинейном, так и в многострочном вариантах).
Неудивительно, что у новейшего языка в группе самая чистая история, так как он имеет выигрыш в ретроспективе и может учиться на успехах и ошибках других (ключевым нововведением здесь является то, что escape-разделитель изменяется синхронно с разделителем строки, что позволяет избежать необходимости выбирать между «приготовленным» и «сырым» режимами). Хотя Java не могла принять подход Swift полностью из-за существующих языковых ограничений, подход Java черпал вдохновение из хорошей работы, проделанной сообществом Swift, насколько это было возможным.
5. Дорога почти взята
Текстовые блоки не были первой попыткой реализации этой функции — первой итерацией были необработанные строковые литералы. Как и необработанные строки Rust, они использовали разделитель переменного размера (любое количество символов кавычек) и вообще не интерпретировали содержимое. Это предложение было отозвано после того, как оно было полностью спроектировано и прототипировано, т.к. чувствовалось, что оно слишком «прибито сбоку». У него было слишком мало общего с традиционными строковыми литералами, и, следовательно, если бы мы хотели расширить его возможности в будущем, у нас бы ничего не вышло.
Одним из основных возражений против подхода JEP 326 является то, что необработанные строки работали во всех отношениях иначе, чем традиционные строковые литералы: различные символы-разделители, отличающиеся от фиксированных разделителей; одиночные-многострочные; экранирующие и не экранирующие. Как обычно, кто-то захочет получить какую-то другую комбинацию выбора и будут призывы к другим формам, ведущие нас по пути, который выбрал Bash. Кроме того, он ничего не сделал для решения проблемы «случайного отступа», которая, очевидно, станет источником хрупкости в программах Java. Основываясь на этом опыте, текстовые блоки имеют гораздо больше общего с традиционными строковыми литералами (синтаксис разделителя, escape-язык). Различия только в одном важном аспекте — является ли строка одномерной последовательностью символов или двумерным текстовым блоком.
6. Руководство по стилю
Используйте текстовые блоки, когда это улучшает ясность кода. Конкатенация, экранированные символы новой строки и экранирующие кавычки запутывают содержимое строкового литерала, и текстовые блоки «сворачивают с дороги», поэтому их содержимое становится более очевидным, но синтаксически они тяжелее традиционных строковых литералов. Используйте их там, где выигрыш оплачивает дополнительные расходы. Если строка помещается на одной строке и не содержит экранированных символов новой строки, лучше придерживаться традиционных строковых литералов.
Избегайте встроенных текстовых блоков в сложных выражениях. Хоть текстовые блоки и являются строковыми выражениями и, следовательно, могут использоваться везде, где ожидается строка, вместо вложения их в сложные выражения, лучше вынести их в отдельную переменную. В следующем примере текстовый блок разбивает поток кода при чтении, заставляя читателей мысленно переключать передачи:
String poem = new String(Files.readAllBytes(Paths.get("jabberwocky.txt")));
String middleVerses = Pattern.compile("\\n\\n")
                             .splitAsStream(poem)
                             .match(verse -> !"""
                                   ’Twas brillig, and the slithy toves
                                   Did gyre and gimble in the wabe;
                                   All mimsy were the borogoves,
                                   And the mome raths outgrabe.
                                   """.equals(verse))
                             .collect(Collectors.joining("\n\n"));
Если поместить текстовый блок в отдельную переменную, программисту, читающему код, будет проще следить за ходом вычислений:
String firstLastVerse = """
        ’Twas brillig, and the slithy toves
        Did gyre and gimble in the wabe;
        All mimsy were the borogoves,
        And the mome raths outgrabe.
        """;
String poem = new String(Files.readAllBytes(Paths.get("jabberwocky.txt")));
String middleVerses = Pattern.compile("\\n\\n")
                             .splitAsStream(poem)
                             .match(verse -> !firstLastVerse.equals(verse))
                             .collect(Collectors.joining("\n\n"));
Избегайте смешивания пробелов и табуляции в отступе текстового блока. Алгоритм удаления случайного отступа вычисляет общий префикс пробела и, следовательно, все равно будет работать, если строки имеют отступ, состоящий из комбинации пробелов и табуляций. Однако, это может привести к ошибкам, поэтому лучше избегать их смешивания — используйте один или другой вариант.
Выравнивайте текстовые блоки относительно других строк кода. Поскольку случайные пробелы автоматически удаляются, мы должны воспользоваться этим, чтобы облегчить чтение кода. Хотя мы могли бы написать:
void printPoem() {
    String poem = """
’Twas brillig, and the slithy toves
Did gyre and gimble in the wabe;
All mimsy were the borogoves,
And the mome raths outgrabe.
""";
    System.out.print(poem);
Поскольку мы не хотим, чтобы в наших строках были начальные отступы, большую часть времени мы должны писать:
void printPoem() {
    String poem = """
            ’Twas brillig, and the slithy toves
            Did gyre and gimble in the wabe;
            All mimsy were the borogoves,
            And the mome raths outgrabe.
            """;
    System.out.print(poem);
}
т.к. такой вариант уменьшает когнитивную нагрузку на программиста.
Не считайте обязательным выравнивать текст по открывающему разделителю. Мы можем выбрать выравнивание содержимого текстового блока по открывающему разделителю:
String poem = """
              ’Twas brillig, and the slithy toves
              Did gyre and gimble in the wabe;
              All mimsy were the borogoves,
              And the mome raths outgrabe.
              """
Это может показаться привлекательным, но может быть громоздким, если строки длинные или разделитель начинается далеко от левого края. Но эта форма отступа не требуется — чаще всего вам придется писать так:
String poem = """
        ’Twas brillig, and the slithy toves
        Did gyre and gimble in the wabe;
        All mimsy were the borogoves,
        And the mome raths outgrabe.
        """;
Когда текстовый блок содержит встроенную тройную кавычку, экранируйте только первую кавычку. Хоть и допустимо экранировать каждую кавычку, но в этом нет необходимости. Требуется экранирование только первой цитаты:
String code = """
        String source = \"""
                String message = "Hello, World!";
                System.out.println(message);
                \""";
        """;
Рассмотрите разбиение очень длинных строк с помощью \. Наряду с текстовыми блоками мы получаем две новые escape-последовательности, \s (для литерального пространства) и \<newline> (индикатор продолжения строки.) Если у нас есть литералы с очень длинными строками, мы можем использовать \<newline> для разрыва строки в исходном коде. Во время обработки текстового блока (при компиляции) данный разрыв будет удален.
Заключение
Строковые литералы в программах Java не ограничиваются короткими строками, такими как «да» и «нет», они часто соответствуют целым «программам» в структурированных языках, таких как HTML, SQL, XML, JSON или даже Java. Возможность сохранения двумерной структуры этой встроенной программы без необходимости переписывать её с помощью escape-символов и других языковых вкраплений менее подвержена ошибкам и приводит к более читаемому коду.
Оригинал статьи «Java Feature Spotlight: Text Blocks»
Оцените статью, если она вам понравилась!