Как стать автором
Обновить
153.85
Skillfactory
Онлайн-школа IT-профессий

Компилятор C# 10, .NET 6 и интерполяция строк

Время на прочтение24 мин
Количество просмотров18K
Автор оригинала: Stephen Toub

К старту курса по разработке на C# делимся материалом из блога .NET о том, как компилятор C# 10 и .NET 6 упрощают программирование, как они обращаются с форматированием, а также о причинах конкретных решений команды .NET. И это далеко не всё. За подробностями приглашаем под кат.


Обработка текста — это сердце множества приложений и сервисов. Для .NET это означает много, очень много System.String. Создание String столь фундаментально, что с момента выхода .NET Framework 1.0 есть огромное количество способов создать строку. Теперь их ещё больше. Распространены API для создания строк, конструкторы String, StringBuilder, переопределения ToString… Вспомогательные методы String — Join или Concat, Create и Replace. И один из самых мощных API создания строк в .NET — String.Format. String.Format имеет множество перегрузок. Их все объединяет возможность предоставления "строки формата" и соответствующих аргументов. Такая строка — простой текст и плейсхолдеры, то есть элементы формата. Они заполняются аргументами, предоставленными операцией форматирования:

string.Format("Hello, {0}! How are you on this fine {1}?", name, DateTime.Now.DayOfWeek), // вызванный в четверг с именем "Stephen", выведет "Hello, Stephen! How are you on this fine Thursday?".

Можно определить спецификатор формата: string.Format("{0} in hex is 0x{0:X}", 12345), тогда вернётся строка "12345 in hex is 0x3039".

Благодаря своим возможностям String.Format — рабочая лошадка. В C# 6 даже добавили синтаксис «интерполяции строки», позволяющий с помощью символа $ помещать аргументы прямо в строку. Перепишем пример выше:

$"Hello, {name}! How are you on this fine {DateTime.Now.DayOfWeek}?"

Для интерполированной строки компилятор волен генерировать любой код, который сочтёт лучшим. Главное — тот же результат. И чтобы добиваться результата, у компилятора есть разнообразные механизмы. К примеру, если написать:

const string Greeting = "Hello";
const string Name = "Stephen";
string result = $"{Greeting}, {Name}!";

Компилятор увидит, что все составляющие интерполированной строки — строковые литералы, и сгенерирует IL-код с единственным литералом:

string result = "Hello, Stephen!";

А если написать так:

public static string Greet(string greeting, string name) => $"{greeting}, {name}!";

Компилятор увидит, что все элементы формата заполнены строками и сгенерирует вызов String.Concat:

public static string Greet(string greeting, string name) => string.Concat(greeting, ", ", name);

В общем случае генерируется вызов String.Format. Если написать так:

public static string DescribeAsHex(int value) => $"{value} in hex is 0x{value:X}";

Компилятор вернёт код, похожий на вызов string.Format выше:

public static string DescribeAsHex(int value) => string.Format("{0} in hex is 0x{1:X}", value, value);

Примеры с константной строкой и String.Concat приближены к настолько хорошему выводу, на какой компилятор только может рассчитывать. В иных случаях со String.Format возникают ограничения, в том числе такие:

  • Чтобы найти литеральные элементы текста, элементы формата, их спецификаторы и выравнивания, строку формата необходимо разобрать. В случае интерполяции строк компилятор уже сделал это, чтобы сгенерировать String.Format, но разбор приходится повторять на каждом вызове.

  • Эти API принимают аргументы типа System.Object. Любые типы значений упаковываются в боксы, чтобы передать их как аргументы.

  • Некоторые перегрузки String.Format принимают до трёх отдельных аргументов. Для большего их числа есть дженерик-перегрузка, принимающая params Object[]. То есть если аргументов больше трёх, то выделяется массив.

  • Чтобы извлечь строку для вставки, нужно воспользоваться методом ToString объекта-аргумента. ToString метод не только включает виртуальную и интерфейсную диспетчеризацию (Object.ToString) или (IFormattable.ToString), но и выделяет временную строку.

  • Во всех этих механизмах элементом формата может быть только то, что передаётся как System.Object. ref-структуры, такие как Span<char> и ReadOnlySpan<char>, использовать нельзя. Но именно они всё чаще используются как повышающие производительность за счёт представления фрагментов текста без выделения памяти. Это касается слайсинга большой строки на Span<char> или текста, отформатированного в выделенной стеком (или в переиспользуемом буфере) памяти. Жаль, что нельзя использовать их в таких операциях конструирования больших строк.

Язык и компилятор C# поддерживают таргетинг System.FormattableString — эффективного кортежа строки формата и массива аргументов Object[], передаваемых в String.Format. Это позволяет использовать синтаксис интерполяции строк, не ограничиваясь System.String. Код может взять FormattableString с её данными и сделать с ней что-нибудь особенное.

Например, метод FormattableString.Invariant принимает FormattableString и передаёт данные вместе с CultureInfo.InvariantCulture в String.Format, чтобы выполнить форматирование с InvariantCulture, а не CurrentCulture. Полезно, но добавляет накладных расходов: все эти объекты должны быть созданы до того, как с ними что-то будет сделано. Помимо выделения памяти FormattableString добавляет собственные накладные расходы, такие как дополнительные вызовы виртуальных методов.

В C# 10 и .NET 6 все эти и другие проблемы решаются с помощью обработчиков интерполированных строк!

Строки, но быстрее

«Понижение» в компиляторе — это процесс, при котором компилятор переписывает высокоуровневую или сложную конструкцию в конструкцию проще или эффективнее:

int[] array = ...;
foreach (int i in array)
{
    Use(i);
}

Вместо генерации кода с перечислением:

int[] array = ...;
using (IEnumerator<int> e = array.GetEnumerator())
{
    while (e.MoveNext())
    {
        Use(e.Current);
    }
}

компилятор генерирует краткий и быстрый код, как будто вы работали с индексом массива:

int[] array = ...;
for (int i = 0; i < array.Length; i++)
{
    Use(array[i]);
}

C# 10 устраняет упомянутые пробелы поддержки интерполированных строк. Язык позволяет «спускаться» не только до константной строки, вызова String.Concat или String.Format, но и до серии добавлений к билдеру, аналогично применению Append в StringBuilder. Такие билдеры называются «обработчиками интерполированных строк», и .NET 6 содержит обработчик типа System.Runtime.CompilerServices, чтобы компилятор использовал его напрямую:

namespace System.Runtime.CompilerServices
{
    [InterpolatedStringHandler]
    public ref struct DefaultInterpolatedStringHandler
    {
        public DefaultInterpolatedStringHandler(int literalLength, int formattedCount);
        public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, System.IFormatProvider? provider);
        public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, System.IFormatProvider? provider, System.Span<char> initialBuffer);

        public void AppendLiteral(string value);

        public void AppendFormatted<T>(T value);
        public void AppendFormatted<T>(T value, string? format);
        public void AppendFormatted<T>(T value, int alignment);
        public void AppendFormatted<T>(T value, int alignment, string? format);

        public void AppendFormatted(ReadOnlySpan<char> value);
        public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null);

        public void AppendFormatted(string? value);
        public void AppendFormatted(string? value, int alignment = 0, string? format = null);
        public void AppendFormatted(object? value, int alignment = 0, string? format = null);

        public string ToStringAndClear();
    }
}

Пример использования:

public static string FormatVersion(int major, int minor, int build, int revision) =>
    $"{major}.{minor}.{build}.{revision}";

До C# 10 сгенерированный код аналогичен следующему:

public static string FormatVersion(int major, int minor, int build, int revision)
{
    var array = new object[4];
    array[0] = major;
    array[1] = minor;
    array[2] = build;
    array[3] = revision;
    return string.Format("{0}.{1}.{2}.{3}", array);
}

Увидеть некоторые из упомянутые расходы можно через профайлер выделения памяти. Я поработаю с .NET Object Allocation Tracking в Performance Profiler в Visual Studio на таком коде:

for (int i = 0; i < 100_000; i++)
{
    FormatVersion(1, 2, 3, 4);
}

public static string FormatVersion(int major, int minor, int build, int revision) =>
    $"{major}.{minor}.{build}.{revision}";

Вот результаты:

Видите выделенную строку? Выполняется боксинг всех четырёх целых чисел; ожидаемую строку с результатом дополняет массив object[]. Но C# 10 нацелен на .NET 6, и компилятор генерирует код, эквивалентный этому:

public static string FormatVersion(int major, int minor, int build, int revision)
{
    var handler = new DefaultInterpolatedStringHandler(literalLength: 3, formattedCount: 4);
    handler.AppendFormatted(major);
    handler.AppendLiteral(".");
    handler.AppendFormatted(minor);
    handler.AppendLiteral(".");
    handler.AppendFormatted(build);
    handler.AppendLiteral(".");
    handler.AppendFormatted(revision);
    return handler.ToStringAndClear();
}

Вот что мы видим:

Боксинг и выделения массивов устранены.

Что же делает компилятор?

  • Создаёт DefaultInterpolatedStringHandler, передавая два значения: количество символов в литеральных частях интерполированной строки и количество элементов формата в ней. Обработчик может использовать эту информацию: например, предположить, сколько места требуется всей операции форматирования, и занять достаточно большой начальный буфер из ArrayPool.Shared.

  • Генерирует серию вызовов, чтобы добавить элементы интерполированной строки, вызывая AppendLiteral для константных частей строки и одну из перегрузок AppendFormatted — для элементов формата.

  • Вызывает метод обработчика ToStringAndClear, чтобы извлечь строку и вернуть в пул любые ресурсы ArrayPool.Shared.

Вернувшись к списку проблем с string.Format, мы увидим, как они решаются:

  • Строки формата, которую нужно разбирать в рантайме, больше нет: компилятор разобрал её во время компиляции и сгенерировал соответствующую последовательность вызовов.

  • Обработчик предоставляет дженерик-метод AppendFormatted<T>, поэтому типы значений не подвергаются боксингу для добавления. Последствия? Например, если T — тип значения, то код внутри AppendFormatted<T> специализируется для этого конкретного типа. Любые выполняемые этим методом проверки интерфейса или диспетчеризация виртуального метода/интерфейса могут быть девиртуализированы и, возможно, даже заинлайнены.

Много лет мы рассматривали возможность добавить дженерик-перегрузки String.Format, например. Format<T1, T2>(string format, T1 arg, T2 arg), чтобы помочь избежать боксинга, но такой подход также приводит к разрастанию кода: каждый вызов с уникальным набором значений аргументов дженерик-типа приведёт к созданию специализации дженерика на месте вызова. Мы можем решить сделать так в будущем, но подход ограничивает разрастание кода за счёт того, что каждому T нужна только одна специализация AppendFormatted<T>, а не комбинация всех пройденных в конкретном месте вызова T от T1 до T3.

  • Теперь на каждый элемент формата выполняется один AppendFormatted. Искусственного ограничения на то, когда мы должны использовать и выделять массив для передачи более трёх аргументов, больше нет.

  • Компилятор привяжет к конкретному типу любой метод AppendFormatted, принимающий тип, совместимый с типом форматируемых данных. Предоставив AppendFormatted(ReadOnlySpan<char>), Span теперь можно использовать в элементах формата интерполированных строк.

А как насчёт выделения промежуточных строк, которые ранее могли быть результатом вызова object.ToString или IFormattable.ToString для элементов формата? .NET 6 предоставляет интерфейс ISpanFormattable, реализованный многими типами в библиотеках ядра, который был внутренним.

public interface ISpanFormattable : IFormattable
{
    bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider);
}

Дженерик-перегрузки AppendFormatted<T> в DefaultInterpolatedStringHandler проверяют, реализует ли тип T этот интерфейс. Если это так, то они используют данный тип для форматирования не во временную System.String, а напрямую в поддерживающий обработчик буфер.

Из-за оптимизации, выполняемой компилятором, специализации дженерика проверка интерфейса может выполняться при компиляции кода сборки, поэтому диспетчеризации интерфейса для таких типов нет. Выполнив простой контрольный тест, можно увидеть влияние этого факта на производительность:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

[MemoryDiagnoser]
public class Program
{
    static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args);

    private int major = 6, minor = 0, build = 100, revision = 7;

    [Benchmark(Baseline = true)]
    public string Old()
    {
        var array = new object[4];
        array[0] = major;
        array[1] = minor;
        array[2] = build;
        array[3] = revision;
        return string.Format("{0}.{1}.{2}.{3}", array);
    }

    [Benchmark]
    public string New()
    {
        var builder = new DefaultInterpolatedStringHandler(3, 4);
        builder.AppendFormatted(major);
        builder.AppendLiteral(".");
        builder.AppendFormatted(minor);
        builder.AppendLiteral(".");
        builder.AppendFormatted(build);
        builder.AppendLiteral(".");
        builder.AppendFormatted(revision);
        return builder.ToStringAndClear();
    }
}

На моей машине код приводит к таким результатам:

Метод

Среднее время

Коэффициент

Выделено

Старый

109.93 нс

1.00

192 б

Новый

69.95 нс

0.64

40 б

NewStack

48.57 нс

0.44

40 б

Простая перекомпиляция позволяет сократить выделение памяти почти в 5 раз и повысить пропускную способность на 40%. Но можно сделать лучше…

Компилятор не просто знает, как при понижении интерполированной строки неявно использовать DefaultInterpolatedStringHandler. Он знает, как выбрать действие на основе того, чему что-то присваивается. «Нацелить» интерполированную строку на «обработчик интерполированной строки», то есть на тип, реализующий известный компилятору шаблон. Шаблон реализуется DefaultInterpolatedStringHandler.

То есть метод может иметь параметр DefaultInterpolatedStringHandler. Когда интерполированная строка передаётся как аргумент этого параметра, компилятор сгенерирует ту же конструкцию и вызовы Append, чтобы создать и заполнить обработчик до его передачи методу.

Чтобы заставить компилятор передать другие аргументы в конструктор обработчика, метод может воспользоваться атрибутом [InterpolatedStringHandlerArgument(…)], если предусмотрен соответствующий конструктор. Кроме конструкторов из примеров, DefaultInterpolatedStringHandler предоставляет ещё два конструктора. Первый для управления форматированием также принимает IFormatProvider?, а второй — Span<char>.

Последний можно использовать как временное пространство для операции форматирования. Это пространство обычно выделяется в стеке или берётся из буфера массива многократного использования, к которому легко получить доступ. Не требуется, чтобы обработчик всегда занимал ArrayPool. Иными словами, написать вспомогательный метод можно так:

public static string Create(
    IFormatProvider? provider,
    Span<char> initialBuffer,
    [InterpolatedStringHandlerArgument("provider", "initialBuffer")] ref DefaultInterpolatedStringHandler handler) =>
    handler.ToStringAndClear();

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

public static string FormatVersion(int major, int minor, int build, int revision) =>
    Create(null, stackalloc char[64], $"{major}.{minor}.{build}.{revision}");

Компилятор понижает это значение до эквивалента:

public static string FormatVersion(int major, int minor, int build, int revision)
{
    Span<char> span = stackalloc char[64];
    var handler = new DefaultInterpolatedStringHandler(3, 4, null, span);
    handler.AppendFormatted(major);
    handler.AppendLiteral(".");
    handler.AppendFormatted(minor);
    handler.AppendLiteral(".");
    handler.AppendFormatted(build);
    handler.AppendLiteral(".");
    handler.AppendFormatted(revision);
    return Create(null, span, ref handler);
}

Теперь, когда можно начать с выделенного стеком пространства буфера, ArrayPool здесь не понадобится:

Метод

Среднее время

Коэффициент

Выделено

Старый

109.93 нс

1.00

192 б

Новый

69.95 нс

0.64

40 б

NewStack

48.57 нс

0.44

40 б

Конечно, мы не призываем всех самостоятельно создавать такой метод Create. В .NET 6 этот метод предоставлен в System.String:

public sealed class String
{
    public static string Create(
        IFormatProvider? provider,
        [InterpolatedStringHandlerArgument("provider")] ref DefaultInterpolatedStringHandler handler);

    public static string Create(
        IFormatProvider? provider,
        Span<char> initialBuffer,
        [InterpolatedStringHandlerArgument("provider", "initialBuffer")] ref DefaultInterpolatedStringHandler handler);
}

Значит, этот код можно написать без кастомных хелперов:

public static string FormatVersion(int major, int minor, int build, int revision) =>
    string.Create(null, stackalloc char[64], $"{major}.{minor}.{build}.{revision}");

А как насчёт аргумента IFormatProvider?? DefaultInterpolatedStringHandler может передавать его в вызовы AppendFormatted. И это означает, что данные перегрузки string.Create предоставляют прямую и гораздо более эффективную альтернативу FormattableString.Invariant. Допустим, в нашем примере форматирования захочется использовать InvariantCulture. Раньше можно было написать так:

public static string FormatVersion(int major, int minor, int build, int revision) =>
    FormattableString.Invariant($"{major}.{minor}.{build}.{revision}");

А теперь — так:

public static string FormatVersion(int major, int minor, int build, int revision) =>
    string.Create(CultureInfo.InvariantCulture, $"{major}.{minor}.{build}.{revision}");

Или, если задействовать память на стеке:

public static string FormatVersion(int major, int minor, int build, int revision) =>
    string.Create(CultureInfo.InvariantCulture, stackalloc char[64], $"{major}.{minor}.{build}.{revision}");

разница в производительности больше:

Метод

Среднее время

Коэффициент

Выделено

Старый

124.94 нс

1.00

224 б

Новый

48.19 нс

0.39

40 б

Передать можно далеко не только CultureInfo.InvariantCulture. DefaultInterpolatedStringHandler для поставляемого IFormatProvider поддерживает те же интерфейсы, что и String.Format, поэтому могут использоваться даже реализации, поставляющие ICustomFormatter. Допустим, я хочу изменить код, чтобы вывести все целочисленные значения в шестнадцатеричном формате:

public static string FormatVersion(int major, int minor, int build, int revision) =>
    $"{major:X}.{minor:X}.{build:X}.{revision:X}";

Теперь, когда спецификаторы формата предоставлены, компилятор ищет не AppendFormatted, принимающий только Int32. Он ищет метод, способный принимать и форматируемое Int32, и спецификатор формата строки. Подходящая перегрузка есть в DefaultInterpolatedStringHandler:

public static string FormatVersion(int major, int minor, int build, int revision)
{
    var handler = new DefaultInterpolatedStringHandler(3, 4);
    handler.AppendFormatted(major, "X");
    handler.AppendLiteral(".");
    handler.AppendFormatted(minor, "X");
    handler.AppendLiteral(".");
    handler.AppendFormatted(build, "X");
    handler.AppendLiteral(".");
    handler.AppendFormatted(revision, "X");
    return handler.ToStringAndClear();
}

Компилятор разобрал строку формата на отдельные серии вызовов Append, но также разобрал спецификатор формата, который станет аргументом AppendFormatted. А если, развлекаясь, мы захотим вывести компоненты в двоичном формате? Спецификатора, который даст двоичное представление Int32, просто нет. Но означает ли это, что синтаксис интерполированных строк использовать невозможно? Напишем небольшую реализацию ICustomFormatter:

private sealed class ExampleCustomFormatter : IFormatProvider, ICustomFormatter
{
    public object? GetFormat(Type? formatType) => formatType == typeof(ICustomFormatter) ? this : null;

    public string Format(string? format, object? arg, IFormatProvider? formatProvider) =>
        format == "B" && arg is int i ? Convert.ToString(i, 2) :
        arg is IFormattable formattable ? formattable.ToString(format, formatProvider) :
        arg?.ToString() ??
        string.Empty;
}  

и передадим в String.Create:

public static string FormatVersion(int major, int minor, int build, int revision) =>
    string.Create(new ExampleCustomFormatter(), $"{major:B}.{minor:B}.{build:B}.{revision:B}");

Изящно.

Замечание о перегрузках

Интересно отметить перегрузки AppendFormatted, открытые в обработчике. Первые четыре из них — дженерики и подходят для подавляющего большинства типов входных данных, которые возможно передать как элементы формата:

public void AppendFormatted<T>(T value);
public void AppendFormatted<T>(T value, string? format);
public void AppendFormatted<T>(T value, int alignment);
public void AppendFormatted<T>(T value, int alignment, string? format);

К примеру, когда задаётся int, эти перегрузки позволяют использовать такие элементы формата:

$"{value}" // formats value with its default formatting
$"{value:X2}" // formats value as a two-digit hexademical value
$"{value,-3}" // formats value consuming a minimum of three characters, left-aligned
$"{value,8:C}" // formats value as currency consuming a minimum of eight characters, right-aligned

Сделав аргументы выравнивания и формата необязательными, мы могли задействовать всё только с помощью самой длинной перегрузки. Чтобы определить, к какой из AppendFormatted привязываться, компилятор использует обычное разрешение перегрузки. И если бы у нас была только AppendFormatted(T value, int alignment, string? format), то она работала бы нормально. Но есть две причины, почему мы так не сделали:

  1. Необязательные параметры в конечном счёте динамически генерирует значения по умолчанию в IL как аргументы. Это раздувает места вызова, а учитывая, как часто используются интерполированные строки, размер кода в этих местах хотелось минимизировать.

  2. Иногда есть преимущества в смысле качества кода. Когда реализация этих методов может принимать значения format и alignment по умолчанию, результирующий код может стать последовательнее. Итак, для дженерик-перегрузок, которые представляют из себя большинство случаев аргументов интерполированных строк, мы добавили все четыре комбинации.

Конечно, есть вещи, которые сегодня в виде дженериков представить невозможно. Наиболее заметны в этом плане ref-структуры. Учитывая важность Span<char> и ReadOnlySpan<char> (первый неявно конвертируется во второй), обработчик предоставляет перегрузки:

public void AppendFormatted(ReadOnlySpan<char> value);
public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null);

В случае ReadOnlySpan<char> span = "hi there".Slice(0, 2) перегрузки позволяют воспользоваться такими элементами формата:

$"{span}" // outputs the contents of the span
$"{span,4}" // outputs the contents of the span consuming a minimum of four characters, right-aligned

Последнее можно сделать с помощью метода AppendFormatted, который принимал бы только выравнивание, но передача выравнивания — относительно редкое явление, поэтому мы решили оставить одну перегрузку, которая могла бы принимать выравнивание и формат. format и Span игнорируется, но отсутствие этой перегрузки может привести к тому, что компилятор выдаст ошибку. Поэтому перегрузка существует для единобразия.

Получается такой код:

public void AppendFormatted(object? value, int alignment = 0, string? format = null);

Перегрузка на основе object вместо дженерика нужна, когда компилятор не может определить лучший тип для дженерика, а значит, не сможет привязать его, если предлагать только универсальный тип:

public static T M<T>(bool b) => b ? 1 : null; // error

Выше компилятор не сможет определить тип для представления результата этого выражения, код не скомпилируется. Но если написать так:

public static object M(bool b) => b ? 1 : null; // ok

Код скомпилируется, ведь и 1, и null можно преобразовать в object. Таким образом, чтобы обрабатывать эти крайние случаи, мы предоставляем перегрузку AppendFormatted для object. Случаи довольно редкие, поэтому как запасной вариант мы добавили только самую длинную перегрузку с необязательными параметрами.

А если попытаться передать строку с выравниванием и форматом, возникает проблема. Компилятору нужно выбрать между T, object и ReadOnlySpan<char>; string неявно конвертируется и в object, и в ReadOnlySpan<char> (определена неявная операция приведения). Поэтому тип не однозначен. Чтобы решить проблему, мы добавили перегрузку string, принимающая необязательные выравнивание и формат. И другую, оптимизированную для строк перегрузку, которая принимает только string:

public void AppendFormatted(string? value);
public void AppendFormatted(string? value, int alignment = 0, string? format = null);

Интерполяция внутри Span

До сих пор мы наблюдали, как создание строк с интерполяцией в C# становится быстрее и эффективнее с точки зрения памяти. С помощью String.Create мы добились некоторого контроля над интерполяцией строк. Но новая интерполяция в C# выходит далеко за рамки создания экземпляров String. Синтаксис интерполяции строк поддерживается при форматировании в произвольные цели.

Одним из самых интересных и значимых достижений в .NET за последние годы стало распространение Span. ReadOnlySpan<char> и Span<char> позволили значительно повысить производительность обработки текста. Форматирование здесь — ключевой момент…

Многие типы .NET для вывода представления в буфер теперь имеют символьные методы TryFormat, а не ToString, создающий эквивалент в новом экземпляре строки. Интерфейс ISpanFormattable с методом TryFormat стал публичным, поэтому практика распространится. К примеру, я реализую тип Point и хочу реализовать ISpanFormattable:

public readonly struct Point : ISpanFormattable
{
    public readonly int X, Y;

    public static bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
    {
        ...
    }
}

Как же реализовать TryFormat? Например, форматируя каждый компонент, нарезая Span по мере продвижения и в целом проделывая это вручную, например:

public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
    charsWritten = 0;
    int tmpCharsWritten;

    if (!X.TryFormat(destination, out tmpCharsWritten, format, provider))
    {
        return false;
    }
    destination = destination.Slice(tmpCharsWritten);

    if (destination.Length < 2)
    {
        return false;
    }
    ", ".AsSpan().CopyTo(destination);
    tmpCharsWritten += 2;
    destination = destination.Slice(2);

    if (!Y.TryFormat(destination, out int tmp, format, provider))
    {
        return false;
    }
    charsWritten = tmp + tmpCharsWritten;
    return true;
}

Прекрасно, хотя требует нетривиального объёма кода. Жаль, что я не могу использовать синтаксис интерполяции, чтобы выразить намерение и заставить компилятор сгенерировать логически эквивалентный код:

public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider) =>
    destination.TryWrite(provider, $"{X}, {Y}", out charsWritten);

На самом деле такое возможно. Благодаря поддержке компилятором пользовательских обработчиков интерполированных строк, в C# 10 и .NET 6 код выше «просто работает».

.NET 6 содержит новые методы расширения класса MemoryExtensions:

public static bool TryWrite(
    this System.Span<char> destination,
    [InterpolatedStringHandlerArgument("destination")] ref TryWriteInterpolatedStringHandler handler,
    out int charsWritten);

public static bool TryWrite(
    this System.Span<char> destination,
    IFormatProvider? provider,
    [InterpolatedStringHandlerArgument("destination", "provider")] ref TryWriteInterpolatedStringHandler handler,
    out int charsWritten);

Структура этих методов должна выглядеть знакомо. Как параметр они принимают «обработчик», которому приписывается атрибут [InterpolatedStringHandlerArgument]. Этот атрибут ссылается на другие параметры сигнатуры.

Тип TryWriteInterpolatedStringHandler разработан из-за требований компилятора к обработчику интерполированных строк. Он должен иметь:

  • Атрибут [InterpolatedStringHandler].

  • Конструктор, принимающий два параметра — int literalLength и int formattedCount. Если параметр обработчика имеет атрибут InterpolatedStringHandlerArgument, то конструктор должен иметь параметр для каждого именованного аргумента этого атрибута соответствующих типов и в правильном порядке. Последним опциональным параметром конструктора может быть out bool.

  • Методы AppendLiteral(string) и AppendFormatted, который поддерживает все типы элементов формата, переданные в интерполированной строке. Эти методы могут не возвращать ничего (void) или, опционально, возвращать bool.

В результате тип TryWriteInterpolatedStringHandler имеет форму, похожую на форму DefaultInterpolatedStringHandler:

[InterpolatedStringHandler]
public ref struct TryWriteInterpolatedStringHandler
{
    public TryWriteInterpolatedStringHandler(int literalLength, int formattedCount, Span<char> destination, out bool shouldAppend);
    public TryWriteInterpolatedStringHandler(int literalLength, int formattedCount, Span<char> destination, IFormatProvider? provider, out bool shouldAppend);

    public bool AppendLiteral(string value);

    public bool AppendFormatted<T>(T value);
    public bool AppendFormatted<T>(T value, string? format);
    public bool AppendFormatted<T>(T value, int alignment);
    public bool AppendFormatted<T>(T value, int alignment, string? format);

    public bool AppendFormatted(ReadOnlySpan<char> value);
    public bool AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null);

    public bool AppendFormatted(object? value, int alignment = 0, string? format = null);

    public bool AppendFormatted(string? value);
    public bool AppendFormatted(string? value, int alignment = 0, string? format = null);
}

При таком типе вызов подобен предыдущему:

public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider) =>
    destination.TryWrite(provider, $"{X}, {Y}", out charsWritten);

И вот код после понижения:

public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
    var handler = new TryWriteInterpolatedStringHandler(2, 2, destination, provider, out bool shouldAppend);
    _ = shouldAppend &&
        handler.AppendFormatted(X) &&
        handler.AppendLiteral(", ") &&
        handler.AppendFormatted(Y);
    return destination.TryWrite(provider, ref handler, out charsWritten);
}

Здесь происходят очень интересные вещи. Мы видим out bool из конструктора TryWriteInterpolatedStringHandler. Компилятор использует этот bool, чтобы решить, нужно ли делать любой из последующих вызовов Append: если bool — false, он замыкается и вообще не вызывает Append.

В подобной ситуации это ценно: конструктору передаётся и literalLength, и Span<char> destination, в который он будет записывать. Когда конструктор видит, что длина литерала больше длины целевого Span, он знает, что интерполяция не удастся.

В отличие от DefaultInterpolatedStringHandler, который способен расти до произвольной длины, TryWriteInterpolatedStringHandler получает предоставленный пользователем диапазон, который должен содержать все записанные данные. Так зачем делать лишнюю работу?

Конечно, возможно, что литералы помещаются, а литералы плюс форматированные элементы — нет. Поэтому каждый метод Append здесь возвращает bool, указывающий, успешна ли операция. Если это не так из-за нехватки места, то компилятор снова может сократить все последующие операции. Важно отметить, что это замыкание позволяет не только избежать работы, которая выполнялась бы последующими методами Append. Не вычисляется и содержимое элемента формата. Представьте, что X и Y в этих примерах — дорогие вызовы методов. Условное вычисление означает, что бесполезной работы возможно избежать. О преимуществах подхода поговорим позже.

После выполнения (или невыполнения) всего форматирования обработчик передаётся исходному методу, который был вызван кодом разработчика. Затем реализация этого метода отвечает за заключительную работу. В нашем случае из обработчика извлекается информация о том, сколько символов записано и была ли операция успешной. Эта информация возвращается вызывающей стороне.

Интерполяция внутри StringBuilder

StringBuilder — один из основных способов создания String со множеством методов изменения экземпляра String до того, как получить неизменямую строку. Методы StringBuilder включают несколько перегрузок AppendFormat, например:

public StringBuilder AppendFormat(string format, params object?[] args);

Они работают так же, как string.Format, но не создают новую строку, а записывают данные в StringBuilder. Рассмотрим вариант предыдущего примера FormatVersion, изменённый для добавления к билдеру:

public static void AppendVersion(StringBuilder builder, int major, int minor, int build, int revision) =>
    builder.AppendFormat("{0}.{1}.{2}.{3}", major, minor, build, revision);

Это работает, но вызывает те же проблемы, что и string.Format. Кто-то, для кого важны эти промежуточные затраты (особенно если этот человек работает с пулом и повторно использует экземпляр StringBuilder), может предпочесть написать его вручную:

public static void AppendVersion(StringBuilder builder, int major, int minor, int build, int revision)
{
    builder.Append(major);
    builder.Append('.');
    builder.Append(minor);
    builder.Append('.');
    builder.Append(build);
    builder.Append('.');
    builder.Append(revision);
}

Видно, к чему это приведёт. Но в .NET 6 появились перегрузки StringBuilder:

public StringBuilder Append([InterpolatedStringHandlerArgument("")] ref AppendInterpolatedStringHandler handler);
public StringBuilder Append(IFormatProvider? provider, [InterpolatedStringHandlerArgument("", "provider")] ref AppendInterpolatedStringHandler handler);

public  StringBuilder AppendLine([InterpolatedStringHandlerArgument("")] ref AppendInterpolatedStringHandler handler);
public  StringBuilder AppendLine(System.IFormatProvider? provider, [InterpolatedStringHandlerArgument("", "provider")] ref AppendInterpolatedStringHandler handler)

С их помощью можно переписать AppendVersion, сохраняя общую эффективность отдельных вызовов Append:

public static void AppendVersion(StringBuilder builder, int major, int minor, int build, int revision) =>
    builder.Append($"{major}.{minor}.{build}.{revision}");

Компилятор транслирует код выше в отдельные вызовы Append, напрямую добавив каждый вызов в обёрнутый обработчиком StringBuilder:

public static void AppendVersion(StringBuilder builder, int major, int minor, int build, int revision)
{
    var handler = new AppendInterpolatedStringHandler(3, 4, builder);
    handler.AppendFormatted(major);
    handler.AppendLiteral(".");
    handler.AppendFormatted(minor);
    handler.AppendLiteral(".");
    handler.AppendFormatted(build);
    handler.AppendLiteral(".");
    handler.AppendFormatted(revision);
    builder.Append(ref handler);
}

Новые перегрузки StringBuilder имеют преимущество: они действительно перегружают существующие Append и AppendLine. При передаче неконстантной интерполированной строки в метод с несколькими перегрузками, одна из которых принимает строку, а другая — допустимый обработчик интерполированной строки, компилятор предпочтёт перегрузку с обработчиком.

Это означает, что после перекомпиляции все существующие вызовы StringBuilder.Append и StringBuilder.AppendLine, которым в настоящее время передается интерполированная строка, станут лучше. Все отдельные компоненты будут добавляться к билдеру напрямую, без временной строки.

Debug.Assert без оверхеда

Одна из трудностей в работе с Debug.Assert — желание предоставить множество полезных деталей в сообщении Assert. Но детали иногда бесполезны. Конечная цель Debug.Assert — уведомить, когда произошло что-то, что произойти не должно. Интерполяция строк позволяет легко добавить множество деталей к сообщению Assert:

Debug.Assert(validCertificate, $"Certificate: {GetCertificateDetails(cert)}");

И так же легко получить оверхед. Хотя Debug.Assert создан "только" для отладки, его влияние на производительность, например, тестов, может оказаться огромным, причём накладные расходы сильно снижают производительность разработчика, увеличивают количество ресурсов на непрерывную интеграцию, замедляют её и так далее. Здорово было бы работать с прекрасным синтаксисом интерполяции строк, избегая явно ненужных расходов. И это возможно.

Помните условность выполнения в примере со Span, где обработчик мог передавать значение bool, чтобы сообщить компилятору, следует ли замыкаться? Мы используем это преимущество через новые перегрузки Assert (WriteIf и WriteLineIf) в Debug:

[Conditional("DEBUG")]
public static void Assert(
    [DoesNotReturnIf(false)] bool condition,
    [InterpolatedStringHandlerArgument("condition")] AssertInterpolatedStringHandler message);

Когда Debug.Assert вызывается с интерполированным строковым аргументом, компилятор предпочтёт новую перегрузке со String. Для вызова наподобие (Debug.Assert(validCertificate, $"Certificate: {GetCertificateDetails(cert)}")) компилятор сгенерирует такой код:

var handler = new AssertInterpolatedStringHandler(13, 1, validCertificate, out bool shouldAppend);
if (shouldAppend)
{
    handler.AppendLiteral("Certificate: ");
    handler.AppendFormatted(GetCertificateDetails(cert));
}
Debug.Assert(validCertificate, handler);

Строка GetCertificateDetails(cert) вообще не создаётся, если конструктор обработчика установит shouldAppend в false. А он сделает это, когда переданное Boolean validCertificate окажется true. А дорогостоящая работа Assert не выполняется, когда значение по определению true. Очень круто.

Эта же техника, вероятно, окажется бесценной в смысле дополнительных API, например связанных с логированием. Может, вам захочется рассчитать сообщение, которое записывается в лог, только если логирование включено и установлено на достаточно высокий уровень.

Что дальше?

Такая интерполяция строк работает с версии .NET 6 Preview 7. Мы будем рады отзывам, и в частности о том, где ещё вы хотели бы видеть поддержку пользовательских обработчиков. Самые вероятные кандидаты — места, где данные предназначены для чего-то, кроме строки. Или места, где поддержка условного выполнения подходит целевому методу естественным образом.

Продолжить изучение программирования вы сможете на наших курсах:

Узнайте подробности здесь.

Профессии и курсы
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 27: ↑25 и ↓2+25
Комментарии60

Публикации

Информация

Сайт
www.skillfactory.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия
Представитель
Skillfactory School