К старту курса по разработке на 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. Мы будем рады отзывам, и в частности о том, где ещё вы хотели бы видеть поддержку пользовательских обработчиков. Самые вероятные кандидаты — места, где данные предназначены для чего-то, кроме строки. Или места, где поддержка условного выполнения подходит целевому методу естественным образом.

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

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

Профессии и курсы

Комментарии (57)


  1. teleport_future
    05.01.2022 19:05

    Мне или кажется или язык с данным синтаксическим "сахаром" настолько усложняется, что скоро специалистов разбирающихся в таких ньюансах языка надо будет искать под микроскопом и при этом, если он выучит еще и JavaScript (или не дай бог выпьет чего нибудь горяченького), то или все забудет по С# или сойдет с ума? Может такие элементарные вещи как "работа со строками" лучше самостоятельно писать в виде функций, и "овцы", как говорится целы и алгоритмы работают именно так, как нужно разработчику. После языка "С" просто "дико" читать данные инсинуации на тему оверхеда в операции простейшего сложения строк, который, оказывается, накидывает "сверху" компилятор. Куда катится мир? Не слишком ли много заморочек для простой глобализации языков?


    1. Dotarev
      05.01.2022 19:13
      +6

      Заголовок статьи:

      (Компилятор C#) & (.Net 6) => $"интерполяция строк работает быстрее".

      Статья о том, <i>почему</i> быстрее. Не обязательно знать нюансы, чтобы воспользоваться. Но поможет, если надо оптимизировать критические секции.


      1. teleport_future
        05.01.2022 20:29

        Да, это я понимаю и сама статья хороша, я ее добавил в закладки.


    1. lair
      06.01.2022 00:20
      +6

      Может такие элементарные вещи как "работа со строками" лучше самостоятельно писать в виде функций

      Нет, не лучше. Элементарные вещи должны быть в общей библиотеке, чтобы программисты не бегали самостоятельно по граблям.


    1. KvanTTT
      06.01.2022 02:49
      +5

      Просмотрел статью. А что нового и непонятного в этом синтаксисе? В нюансах разбираться не обязательно - это просто оптимизации, без которых код будет работать как раньше. С другой стороны, профессионалы оценят и заиспользуют zero allocation фичи при необходимости.


  1. teleport_future
    05.01.2022 19:17
    -3

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

    Что предлагают авторы данной статьи? ни ужели кто то мешает написать так:

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

    (как то уже совсем позабыли, по моему, про очень легковесную операцию сравнения, которая не стоит вообще ни какого процессорного времени)


    1. teleport_future
      05.01.2022 21:57

      C .Net я с самого его появления в 2003 году. Начинал с первой версии (когда был в командировке в Москве, то купил одну из первых версий в переходе вместе с MSDN). Помню ошибки были даже в Visual Studio. Причем весьма серьезные. На мой взгляд, данный проект только сейчас достиг зрелости. Но все же, он по моему переусложнен. При всем при этом, иногда приходится доставать древний "паяльник" и включать "usafe", например для работы с массивами обрабатывая изображения, так как исходные классы страдают от плохой производительности и требуется работа через указатели.


    1. nsinreal
      06.01.2022 00:58
      -1

      Ваше предложение — срань, еще и стилевые гайды нарушает.


      1. teleport_future
        06.01.2022 02:58
        -2

        Вы стилевые гайды можете себе оставить. Оно работает и что самое важное без копания по библиотекам .Net. Время - это деньги между прочим и разбор того что написали в библиотеках и то как это можно "изящно" обойти потратив выходные дни, дорого дается. В таком случае и учебные материалы от разработчика должны быть соответствующие, а не как сейчас в MSDN, без исходников и пояснений. Там просто ни чего не описывают. Только такие статьи и наводят порядок в том дерьме что вы называете "стилевые гайды" и стили программрования. Программист не разработчик синтаксиса языка. Вы что то путаете в своей жизни. Мне этот инструмент нужен что бы упростить жизнь, а не усложнить ее до крайней степени. Проблема библиотек в том, что иногда проще свое написать, чем разбираться в них. Вам нужна универсальность и мультиязычность? Вам нужна оптимизация скорости? Это Ваши проблемы, вот и решайте их. Мне же достаточно поставить "заглушку" и я не ни чего от этого не потеряю, а даже выйграю значительные временные и ресурсы и самое важное не потрачу энергию на разбор чужих библиотек (если их не "забыли" на GitHub выложить, как это было раньше у Мелкоссофта).


        1. lair
          06.01.2022 10:10

          Оно работает и что самое важное без копания по библиотекам .Net

          … а оно работает консистентно с остальными местами, где Debug.Assert написан?


    1. lair
      06.01.2022 10:39
      +1

      ни ужели кто то мешает написать так:
      if (debug) Debug.Assert(validCertificate, $"Certificate: {GetCertificateDetails(cert)}");

      Никто не мешает, только результат будет другой.


    1. KvanTTT
      06.01.2022 15:09
      +2

      Ну вообще это лишний if в коде, а если этих ассертов много, то даже такие мелочи могут раздражать.


      Кстати, компилятор/JIT может даже выкинуть операцию сравнения как и блок под ней, если debug — это константа.


  1. OkunevPY
    05.01.2022 21:59
    -1

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


    1. eternum
      06.01.2022 00:07
      +3

      Сахар, вроде, никогда не нужен был, чтобы создавать эффективный код.


    1. lair
      06.01.2022 00:17
      +8

      "Сахар" нужен не для того, чтобы создавать эффективный код, а для того, чтобы создавать читаемый код.


      1. teleport_future
        06.01.2022 03:25
        -6

        Вы наверное "издеваетесь", когда пишите слово "читаемый". Да что бы понять как обойти проблему сложения двух строк интерпритатором автор статьи ушатал 100 часов времени чтобы написать статью и еще столько же времени продирался сквозь логику классов и слава богу что у него были исходники. Давно ли Мелкософт публиковать их начал? Насколько помню, но в 2003 году когда я уже писал под NET ни каких исходников тогда ни кто не выкладывал.

        Пишу вам ответ и мне смешно - сложили две строки и описали это как проблему вселенского масштаба в статье на 1000 строк.

        Еще и логирование в таком же ключе начали объяснять. Ну вообще весело все у разработчиков .Net. Вы не задавались вопросом: "Может проще NLog поставить из репозитория?"

        Понятно что это у них такая концепция уже и мне кажется с этим надо что то делать. Китайцы не в пример проще все делают и развитие у них прет.


        1. lair
          06.01.2022 10:09
          +5

          Пишу вам ответ и мне смешно — сложили две строки и описали это как проблему вселенского масштаба в статье на 1000 строк.

          О какой проблеме вы говорите? Статья о форматировании, а не о конкатенации (хотя конкатенация — тоже штука веселая).


          Вы не задавались вопросом: "Может проще NLog поставить из репозитория?"

          Проще чем что?


          PS


          автор статьи ушатал 100 часов времени чтобы написать статью и еще столько же времени продирался сквозь логику классов

          Что-то я не думаю, что Стивен Тауб потратил 100 часов на продирание через логику этих классов...


          1. teleport_future
            06.01.2022 21:12
            -3

            Как говорится вместе с "водой" выплеснули и ребенка. Чрезмерное написание "велосипедов" превращает язык в помойку и сахар не помогает в данном случае. Кажется я уж описал суть проблемы- это дикие временные потери на разбор синтаксического сахара.


            1. lair
              06.01.2022 21:17

              А где вы видите "помойку" в коде?


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

              Или?


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


              1. teleport_future
                06.01.2022 21:25
                -2

                Почитайте статью выше, длиной в 100500 строк, о том как сделать быстрое сложение текста и переменных приведенных к типу STRING.


                1. lair
                  06.01.2022 21:28
                  +2

                  Почитайте статью выше длиной в 100500 строк, о том как сделать быстрое сложение текста и переменных приведенных к типу STRING в одну строку.

                  Там такого нет. Вы точно читаете ту же статью, что и я?


                  (или вы не отличаете код, который пишет программист, от кода, который генерит компилятор?)


                  1. teleport_future
                    06.01.2022 23:38

                    https://docs.microsoft.com/ru-ru/dotnet/core/compatibility/core-libraries/6.0/stringbuilder-append-evaluation-order

                    По второму вопросу, написал тест Debug.Assert в консольном приложении - все работает при наличии условного оператора проверки истинности, ничего не вызывается, как я и предполагал (проблема высосана из пальца). Я Вам уже писал что NLog, так же все решает.


                    1. lair
                      07.01.2022 00:18

                      https://docs.microsoft.com/ru-ru/dotnet/core/compatibility/core-libraries/6.0/stringbuilder-append-evaluation-order

                      А можно пожалуйста из обсуждаемой статьи пример? Я не понимаю, о чем вы.


                      По второму вопросу, написал тест Debug.Assert в консольном приложении — все работает при наличии условного оператора проверки истинности, ничего не вызывается

                      То есть вы проверили, что если выражение под ассертом истинно, то сборка параметров строки ассерта не вызывается?


                      (я уж молчу про то, что в релизе ваш ассерт не вызовется никогда, и не важно, что у вас там во флаге)


                      Я Вам уже писал что NLog, так же все решает.

                      А при чем тут NLog, и что вообще он решает? Я им перестал пользоваться много лет назад, ушел на Serilog, и не жалею.


                      1. teleport_future
                        07.01.2022 12:54

                        А можно пожалуйста из обсуждаемой статьи пример? Я не понимаю, о чем вы.

                        Данное описание из документации сделано по материалам исходной статьи. Изначально статья была видимо "багрепортом" (есть ссылка из документации на нее). Это описание дает самую суть и глубже погружаться лично для себя не вижу смысла по следующей причине изложенной ниже.

                        Прирост производительности "мизерный" и для массовой обработки строк он не годится, потому что другие способы контактенации строк дают прирост не в 3 раза, а в 10-100 раз. Поверьте мне - я находил способы увеличить производительность "массовой" обработки строк на значительно большую величину чем, как в статье, в 3 раза (это "ни о чем" на самом деле). Посмотрите вокруг: глобализация она такая - от нее страдают все. Не только в программировании :)

                        Логика во всем этом простая - проблема работы со строками у Microsoft существует и она ни как не решается. В статье описали "мусор" который не годится для работы с текстовыми данными в большом объеме. Вот и "все". Трехкратное увеличение производетельности пусть положат себе в "карман" и ни кому не показывают. Хотя,... предпринятые шаги уже вселяют надежду и может раберутся и с этой "проблемой" со временем.


                      1. lair
                        07.01.2022 13:40
                        +1

                        Изначально статья была видимо "багрепортом"

                        Нет, не была.


                        Данное описание из документации сделано по материалам исходной статьи.

                        Я продолжаю вас не понимать. Вы писали, что есть какая-то "помойка в коде" и синтаксический сахар, на который уходят дикие временные потери.


                        Можете привести хотя бы один пример этого?


                        Прирост производительности "мизерный" и для массовой обработки строк он не годится, потому что другие способы контактенации строк дают прирост не в 3 раза, а в 10-100 раз.

                        Только в статье речь не о конкатенации строк, а о форматировании.


                        Логика во всем этом простая — проблема работы со строками у Microsoft существует и она ни как не решается.

                        Какая проблема? Где?


                        Трехкратное увеличение производетельности пусть положат себе в "карман" и ни кому не показывают.

                        Вам не надо — вы не пользуйтесь. А мне увеличение производительности без потери читаемости очень пригодится.


                      1. teleport_future
                        07.01.2022 14:33
                        -1

                        Вы из геймдева не можете "вылезти"? Мне постоянно приходилось работать с огромными массивами данных и по вашим комментариям сразу видно что вы "нюхали пороху" при массовой обработке строк.

                        Вам не надо — вы не пользуйтесь. А мне увеличение производительности без потери читаемости очень пригодится

                        Да, вы угадали, там где я работаю мне это не надо (дичайше низкая производительность), а в тех местах где я буду ЭТО применять, мне изменение производительности в 3 раза абсолютно ни чего не даст на фоне общей производительности всего исполняемого кода приложения.

                        На этом раскланиваюсь и завершаю полемику.


                      1. lair
                        07.01.2022 14:36
                        +1

                        Вы из геймдева не можете "вылезти"?

                        Не могу. Никогда им не занимался, поэтому и не могу.


                        Мне постоянно приходилось работать с огромными массивами данных

                        Это все прекрасно, но какое это имеет отношение к тому, что в статье?


                      1. teleport_future
                        07.01.2022 16:19

                        Это все прекрасно, но какое это имеет отношение к тому, что в статье?

                        в реальной жизни все эти слова "попахивают":

                        • окажется бесценной в смысле дополнительных API

                        • по определению true. Очень круто.

                        • может взять FormattableString с её данными и сделать с ней что-нибудь особенное

                          и далее пишем правду: Много лет мы рассматривали возможность добавить дженерик-перегрузки String.Format, например. Format<T1, T2>(string format, T1 arg, T2 arg), чтобы помочь избежать боксинга

                          То есть весь этот синтаксический сахар с автоматическим приведением типов через интерфейсы просто на корню убивает производительность элементарных операций.


                      1. lair
                        07.01.2022 16:24
                        +1

                        в реальной жизни все эти слова "попахивают":

                        Что там попахивает-то? Конкретно?


                        То есть весь этот синтаксический сахар с автоматическим приведением типов через интерфейсы просто на корню убивает производительность элементарных операций.

                        Гм. А почему убивает, если боксинг наоборот убрали?


                        Вообще, что за "автоматическое приведение типов через интерфейсы" вы конкретно имеете в виду в этом случае?


                      1. teleport_future
                        07.01.2022 16:32

                        Вообще, что за "автоматическое приведение типов через интерфейсы" вы конкретно имеете в виду в этом случае?

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

                        Народ уже вовсю возмущается концепцией DI из Net Core. Вы этого не замечали?, а я видел.


                      1. lair
                        07.01.2022 16:35

                        Да все вместе — и приведение типов и вызов перегруженных методов

                        Не понимаю. Что конкретно не так? Где конкретно проблема с производительностью?


                        Не абстрактно "где-то там в системе медленно", а в том коде, который приводится в статье — где там проблемы из-за приведения типов и/или перегруженных методов?


                        Откуда вообще в .NET проблема с производительностью на вызове перегруженных методов?


                        Вы посмотрите на это "издалека": создали такой "велосипед", что для сложения двух строк в одну бъются головой об стену

                        Нет, никто не бьется для этого головой об стену.


                        str1 + str2

                        Все. Какие проблемы? Где конкретно?


                        пишут статьи с радостными "вздохами" размером с простыню.

                        Статья — не про сложение строк, а про форматирование. Я столько раз это повторил, но вы продолжаете это игнорировать.


                        Народ уже вовсю возмущается концепцией DI из Net Core. Вы этого не замечали?, а я видел.

                        Нет, не замечал. Что конкретно за "концепция DI из Net Core"?


                      1. teleport_future
                        07.01.2022 16:55

                        Статья — не про сложение строк, а про форматирование.

                        Может я чего то не понимаю, но по моему это те же штаны, но только в "профиль"

                        Все. Какие проблемы? Где конкретно?

                        Вот здесь самое начало и вы что то видимо пропустили читая эту "простынь":

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

                        В итоге:

                        string result = "Hello, Stephen!"; (str1 + "," + str2 + "!")

                        Нет, не замечал. Что конкретно за "концепция DI из Net Core"?

                        Вот здесь: https://metanit.com/sharp/aspnet6/4.1.php


                      1. lair
                        07.01.2022 17:01

                        Может я чего то не понимаю, но по моему это те же штаны, но только в "профиль"

                        Не понимаете, да. Форматирование включает в себя сложение строк, но внутри себя сложнее. Например, надо строку форматирования распарсить, а все, что не строки, превратить в строку.


                        Вот здесь самое начало и вы что то видимо пропустили читая эту "простынь":

                        И где здесь проблема? Я вижу только умный компилятор, который полностью выкидывает рантайм-вычисления в пользу строки, которую можно собрать во время компиляции. И?


                      1. teleport_future
                        07.01.2022 17:22

                        И где здесь проблема? Я вижу только умный компилятор, который полностью выкидывает рантайм-вычисления в пользу строки, которую можно собрать во время компиляции. И?

                        во время компиляции переменные не вычислить или я опять ошибаюсь? Пример то "синтетический" и там явно специально написали CONST, что не следовало делать, что бы показать всю тупиздну подхода


                      1. lair
                        07.01.2022 17:28

                        Пожалуйста, перечитайте статью (лучше — в оригинале). Вы явно ее не поняли.


                        Там последовательно усложняющийся пример: сначала интерполяция из констант (которая вычисляется во время компиляции), потом интерполяция из строк (которая превращается компилятором в более эффективную конкатенацию), а потом интерполяция не из строк, которая интереснее всего.


                      1. teleport_future
                        07.01.2022 17:26

                        Не понимаете, да. Форматирование включает в себя сложение строк, но внутри себя сложнее. Например, надо строку форматирования распарсить, а все, что не строки, превратить в строку.

                        а я вам о чем все это время пишу?

                        Почитайте статью выше, длиной в 100500 строк, о том как сделать быстрое сложение текста и переменных приведенных к типу STRING.


                      1. lair
                        07.01.2022 17:28

                        а я вам о чем все это время пишу?

                        А я не понимаю, о чем вы пишете. Явно не о том, о чем статья.


                      1. teleport_future
                        07.01.2022 17:49

                        Статья намекает на решение проблем с производительностью в самом тяжелом месте, а по факту "пшик". Да красиво, да оригинально, но я ни когда не поставлю себе строковые функции, так как приложение мгновенно "умрет" на массовых операциях с текстом.


                      1. lair
                        07.01.2022 18:01

                        Статья намекает на решение проблем с производительностью в самом тяжелом месте

                        Где?


                        но я ни когда не поставлю себе строковые функции

                        Что значит "не поставлю"? Какие функции?


                      1. lair
                        07.01.2022 17:08

                        вот здесь: https://metanit.com/sharp/aspnet6/4.1.php

                        Так это самый обычный Dependency Injection, ничего специфичного для Core там нет.


                        (и если быть точным, это не в Net Core, а в ASP.NET Core)


                      1. teleport_future
                        07.01.2022 17:59
                        -1

                        (и если быть точным, это не в Net Core, а в ASP.NET Core)

                        Microsoft давным давно сами запутались что есть ASP. Каких только технологий я уже не видел под капотом этого термина...


                      1. lair
                        07.01.2022 18:02

                        Да и хрен с ним. В чем проблема с DI-то?


                      1. teleport_future
                        07.01.2022 18:15

                        В чем проблема с DI-то?

                        Если найду, то напишу в личку


                      1. teleport_future
                        07.01.2022 18:04
                        -1

                        (и если быть точным, это не в Net Core, а в ASP.NET Core)

                        Вот правильная ссылка (себя не забудьте поминусовать за неправильное упоминание технологий)

                        https://ru.wikipedia.org/wiki/ASP.NET


                      1. lair
                        07.01.2022 18:24

                        Нет, это не правильная ссылка. ASP.NET уже устарел. Сейчас актуален именно ASP.NET Core (и именно в нем есть описанный по ссылке DI).


                      1. mvv-rus
                        08.01.2022 07:17

                        Таки позанудствую.
                        В общем случае это неверно. Посмотрите на пространство имен, в котором определена основная часть DI: это — Microsoft.Extensions (сейчас для современных версий .NET это, по факту, часть стандартной библиотеки, и живет код из этого пространства имен в ее репозитории), а не Microsoft.AspNetCore (ну, а IServiceProvider вообще определен в System и существует с незапаметных времен: в старину он ЕМНИП использовался не для DI, а для визуального программирования — в связке с компонентами, которые в Дизайнере мышкой можно было двигать).
                        И Generic Host может использоваться для асинхронного выполнения любой нагрузки, зарегистрированной в контейнере DI как реализация IHostedService. Веб-приложение — внутренний класс GenericWebHostService — туда добавляет метод расширения ConfigureWebHost для IHostBuilder (или ConfigureWebHostDefaults, который его вызывает). Не будете вызывать эти методы — ваше приложение с DI к ASP.NET Core никакого отношения иметь не будет.
                        Правда, я не знаю, где, кроме ASP.NET Core этот шаблон Generic Host реально используется. Но при желании — можно.


                      1. lair
                        08.01.2022 09:32

                        Если занудствовать, то речь шла о противопоставлении ASP.NET Core обычному ASP.NET. Из этих двух, конкретный DI на основе IServiceProvider "есть" только в Core, а в ванильном ASP.NET его надо мучительно добавлять.


                      1. teleport_future
                        07.01.2022 13:26
                        -1

                        ну блин "ежу" понятно, что речь не об этом:

                        (я уж молчу про то, что в релизе ваш ассерт не вызовется никогда, и не важно, что у вас там во флаге)


                      1. lair
                        07.01.2022 13:40

                        Ежу, может быть, и понятно. А мне — нет. О чем тогда речь? Приведите полный пример кода и объясните, как он работает. Потом сравните с тем, как работает код из статьи.


            1. lair
              06.01.2022 21:29

              Кажется я уж описал суть проблемы- это дикие временные потери на разбор синтаксического сахара.

              Покажите в статье конкретный пример синтаксического сахара, на разбор которого у вас уходит дико много времени.


              Правду слушать всегда не приятно, а критику уж тем более.

              Пока что вы не высказали ни слова критики в мой адрес.


              Ваши подчиненные не могут этого сделать

              Действительно, не могут: у меня их нет.


      1. teleport_future
        06.01.2022 03:38

        del


  1. nsinreal
    06.01.2022 01:12
    +1

    Большое спасибо за статью. В целом, с высокого полета все плюс-минус понятно, но есть ощущение, что материал в будущем можно будет изложить лучше — сейчас намешаны и высокоуровневые концепты, и низкоуровневые.


  1. ARad
    06.01.2022 09:00

    Это все хорошо, но большинство вызовов string.Format выполняется с загрузкой шаблона форматирования из ресурсов для локализации и соответственно такие вызовы не оптимизируются настолько глубоко или я не прав?

    Насколько я понимаю, такая оптимизация создается только в момент компиляции, когда шаблон форматирования известен заранее.

    При локализации шаблон форматирования известен только в момент выполнения, соответственно такая оптимизация не создается компилятором.

    Насколько я прав?


    1. lair
      06.01.2022 10:12
      +2

      Это все хорошо, но большинство вызовов string.Format выполняется с загрузкой шаблона форматирования из ресурсов для локализации

      Я за последние пять, что ли, лет, не видел ни одного вызова string.Format, который бы что-то грузил из ресурсов (не считая кода в BCL и сторонних библиотеках). При этом работаю я со строками регулярно.


      1. DistortNeo
        06.01.2022 13:01

        Ну я у себя в проекте format string гружу из ресурсов.


        1. lair
          06.01.2022 13:04
          +3

          Ну да, вы — грузите. А я — не гружу, и те проекты, где я последние k лет работают — там тоже не грузят. Локализация совсем не так распространена, как можно/хочется думать.


    1. DistortNeo
      06.01.2022 13:12

      Можно использовать следующий паттерн:

      sealed class MyDebug
      {
          [Pure]
          public static MyDebug? Assert(bool condition) => condition ? null : new MyDebug();
          
          [DoesNotReturn]
          public void Message(string message) => Environment.FailFast(message);
          
          [DoesNotReturn]
          public void Message(string format, params object[] args) => Environment.FailFast(string.Format(format, args));
          
          MyDebug() {}
      }
      
      static void AssertExample()
      {
          MyDebug.Assert(true);   // Warning: Return value of pure method is not used
          MyDebug.Assert(true)?.Message(Strings.SomeMessage);
          MyDebug.Assert(false)?.Message(Strings.ErrorMsgFormat, "condition is false");
      }

      Здесь используется фича в виде ?., которая разворачивается в код, в котором format string извлекается только при необходимости:

      if (assertReturnValue != null)
      {
          assertReturnValue.Message(...);
      }

      И атрибут [Pure], который генерит предупреждение при статическом анализе и не даёт забыть написать вызов метода Message.

      Единственный недостаток такого подхода: это нестандартный велосипед.