Строки в dotnet являются предназначенной только для чтения последовательностью Char-ов. Об этом явно написано в документации Microsoft, посвященной строкам. Там же в секции "Неизменность строк" сказано следующее: "Может показаться, что все методы String и операторы C# изменяют строку, но в действительности они возвращают результаты в новый строковый объект". Согласно документации, изменить строки нельзя, но жизнь не всегда согласуется с документацией, поэтому предлагаю взглянуть на способы, позволяющие изменять строки в dotnet (к тому же это иногда спрашивают на собеседованиях!).


Fixed

Нельзя изменять строки, но можно попробовать изменить то, из чего строка состоит. Как было упомянуто ранее, строка — это последовательность Char-ов, а про неизменяемость коллекций или Char-ов в документации информации нет. Однако, там же нет и информации о том, как получить коллекцию, на которой построена строка. Здесь будет полезно знать, что с точки зрения логики работы CLR строки — это особый тип данных. При получении указателя на объект строки возвращается указатель не на сам объект System.String, в котором есть поле, в котором находится коллекция Char-ов, а сразу указатель на коллекцию Char-ов.
Это значит, что воспользовавшись инструкцией fixed внутри блока unsafe, можно получить указатель на первый символ строки. Путем инкрементирования или декрементирования этого указателя можно получить адрес любого символа в строке. В приведенном ниже примере изменяется второй символ в строке:

var test = "Test"
unsafe
{
 fixed(char* c = test)
 {
   var c1=c+1;
   *c1 = 'v';
 }
}
Console.WriteLine(test); // Tvst

Span

Начиная с .net core 2.1 стало возможным изменить строку более изящным способом. В dotnet появилась Span-ы — абстракция для типобезопасной работы с последовательным фрагментом управляемой или неуправляемой памяти. Также у строк появился метод расширение .AsSpan(), который позволяет получить коллекцию Char-ов в виде неизменяемого ReadOnlySpan<Char>. Теперь необходимо конвертировать ReadOnlySpan<Char> в Span<Char>. Для этих целей подойдет класс MemoryMarshal, в котором есть методы:

  • CreateSpan, позволяющий создать новый Span для коллекции заданной длины, по ссылке на первый элемент коллекции;

  • GetReference, позволяющий получить ссылку на первый элемент Span-а.

var test = "Test"; 
var span = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(test.AsSpan()), test.Length); 
span[1] = 'v'; 
Console.WriteLine(test); // Tvst

P/Invoke

В мире dotnet существует технология P/Invoke, которая помимо прочего позволяет осуществлять вызов функций в неуправляемых библиотеках из управляемого кода. Это позволяет собрать собственную динамическую библиотеку, реализованную, к примеру, на C++, и работать со строкой аналогичным с описанным в разделе Fixed образом.
Изменение строки реализуем в методе MutateSecondCharToV, который, как следует из названия, будет заменять второй символ в строке на v. Для создания динамической библиотеки понадобится два файла:

  • StringMutLib.h

    #pragma once
    
    #ifdef STRINGMUTATION_EXPORTS
    #define STRINGMUTHLIB_API __declspec(dllexport)
    #else
    #define STRINGMUTHLIB_API __declspec(dllimport)
    #endif
    
    extern "C" STRINGMUTHLIB_API void MutateSecondCharToV(wchar_t* lha);
  • StringMutLib.cpp

    STRINGMUTHLIB_API void MutateSecondCharToV(wchar_t* lha)
    {
        wchar_t* second = lha + 1;
        *second = 'v';
    }

После того, как динамическая библиотека будет скомпилирована и положена в видимое для проекта место, останется только воспользоваться описанной в библиотеке функцией MutateSecondCharToV:

using System.Runtime.InteropServices;
using System.Text;

var a = "Test";
Console.WriteLine(a); // Test
TestMutator.MutateSecondCharToV(a);
Console.WriteLine(a); // Tvst
public static class TestMutator
{
    [DllImport("StringMutation.dll", CharSet = CharSet.Unicode)]
    public static extern void MutateSecondCharToV(string foo);
}

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

Побочные эффекты

После применения любого из описанных выше способов для изменения строк в программе проявится "побочный эффект": Каждый вызов Console.WriteLine("Test") будет выводить в консоль не Test, а Tvst. Подобный "эффект" связан с механизмом интернирования литеральных строк.
Среда CLR поддерживает хранилище строк в виде таблицы, называемой пулом интернирования. Эта таблица содержит ссылку на каждую уникальную строку литерала, объявленную или созданную в программе.Это позволяет экземпляру литеральной строки с определенным значением встречаться в системе только один раз. Манипуляции по изменению строк из предыдущих разделов создали ситуацию, когда CLR не знает, что указатель, который раньше указывал на строку Test, теперь указывает на Tvst, как если бы в супермаркете кто-то поставил товар под несоответствующий ему ценник. По этой причине не стоит использовать эти методы без особой необходимости.

String.Create

Если необходимо изменять строки во время их создания с целью повышения производительности, то для этих целей подойдет метод String.Create. Этот метод позволяет преобразовать коллекцию Char-ов в строку, используя переданный делегат:

var buffer = new char[] { 'T', 'e', 's', 't' };
string result = string.Create(buffer.Length, buffer, (chars, buf) => {
    for (int i = 0; i < chars.Length; i++)
    {
        if(i == 1)
        {
            chars[i] = 'v';
            continue;
        }
        chars[i] = buf[i];
    }
});
Console.WriteLine(result); // Tvst

В рамках делегата предоставляется доступ к Span<Char>, который оборачивает коллекцию Char-ов будущей строки. Благодаря тому, что строка не будет проинтернирована, этот способ не вызывает побочных эффектов, свойственных предыдущим методам.

Заключение

Вопреки тому, что в документации dotnet в отношении строк сказано, что они являются неизменяемыми, существуют разные подходы к изменению строк. Часть из рассмотренных ранее способов являются скорее "хаками" и вряд ли могут быть использованы в production коде, хотя и могут служить наглядной демонстрацией особенностей работы CLR. Последний из рассмотренных методов, напротив, является вполне законным и уместным способом изменять строки и более того может служить для оптимизации работы приложения.

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


  1. aamonster
    00.00.0000 00:00
    +9

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

    UPD: пардон, был невнимателен. В последнем примере вы не модифицируете String вообще.

    И, главное, зачем эти извращения, если для изменяемых строк специально сделан StringBuilder?


    1. Brom95 Автор
      00.00.0000 00:00
      +4

      Развлечения ради, и, возможно, более глубокое понимание CLR. Вообще изначально задачу принесли с какого-то собеседования.


      1. aamonster
        00.00.0000 00:00
        +4

        Да, понимание – дело хорошее. Особенно хорошо, если понимание доходит до уровня "зачем строки сделаны иммутабельными".


        1. Brom95 Автор
          00.00.0000 00:00
          +3

          В любой ситуации надо говорить либо "зависит", либо "перфоманс".

          А если серьезно, то на сколько я помню,одним из самых главных преимуществ иммутабельных строк является то, что мы можем посчитать их длину при создании и больше не пересчитывать ее. Ну, и интернирование :D


          1. aamonster
            00.00.0000 00:00
            +1

            Первый абзац – блеск). Абсолютно точный ответ на кучу вопросов (в т.ч. и на этот), не дающий никакой информации :-).


            1. AgentFire
              00.00.0000 00:00

              Работает до первого резонного "зависит от чего?".


      1. 40kTons
        00.00.0000 00:00
        +4

        Уже 10 лет как статье, где более подробно описано всё это


  1. beskaravaev
    00.00.0000 00:00
    +3

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

    Из этого складывается впячатление, что интернируются все строки, но это не так. Интернируются строки созданные на этапе компиляции, а именно константы. Строка созданная через string.Create string.Join и многими другими способами интернирована не будет, то есть все, что создаются во время работы приложения.
    p.s. но если очень нужно есть специальный метод String.Intern


    1. aamonster
      00.00.0000 00:00

      Кстати, если помните стандарт: гарантируется, что строка интернирована не будет, или компилятор/среда имеет право воткнуть String.Intern, где ему нравится? (семантика строк при этом не меняется, так что если такая процедура ведёт к оптимизации – почему бы нет?)


    1. beskaravaev
      00.00.0000 00:00
      +1

      А в целом за статью спасибо, было интересно:)


    1. Brom95 Автор
      00.00.0000 00:00

      Да, вы совершенно правы! Мне трудно вспомнить почему я так написал, хотя очевидно что интернирования там не будет, но сейчас поправлю.

      var buffer = new char[] { 'T', 'e', 's', 't' };
      Span<char> external = Array.Empty<char>();
      string result = string.Create(buffer.Length, buffer, (chars, buf) => {
          for (int i = 0; i < chars.Length; i++)
          {
              chars[i] = buf[i];
          }
      });
      
      
      
      Console.WriteLine(result);
      Console.WriteLine(Object.ReferenceEquals(result, "Test")); // False


  1. Hixon10
    00.00.0000 00:00

    Вопрос про второй способ (Span), ну, или вообще любой.

    Допустим я получил из строки Span, поменял что-то, или вообще хочу взять слайс, потому что мне не нужны последние 2 символа. Если я вызову .ToString(), для того чтобы обратно получить строку, я получу в лоб аллокацию. Выглядит так, что весь смысл string -> span -> string потерялся.

    [MethodImpl(MethodImplOptions.InternalCall)]
    [DynamicDependency("Ctor(System.ReadOnlySpan{System.Char})")]
    public extern String(ReadOnlySpan<char> value);
    
    private static unsafe string Ctor(ReadOnlySpan<char> value)
    {
        if (value.Length == 0)
            return Empty;
    
        string result = FastAllocateString(value.Length);
        Buffer.Memmove(ref result._firstChar, ref MemoryMarshal.GetReference(value), (uint)value.Length);
        return result;
    }


    Есть какой-то способ избежать аллокации тут, переиспользовав массив из оригинальной строки?


    1. Brom95 Автор
      00.00.0000 00:00

      1. Вам нет нужды вызывать .ToString(), вы же изменяете объект по ссылке без изменения ссылки, если я правильно понял вопрос и если мы все еще говорим о способах, которые крайне не рекомендуется использовать в реальных проектах.

      2. О какой аллокации идет речь? Пока я не сморозил глупость, хочу напомнить, что span это ссылочная структура, которая не может быть аллоцирована на куче.


      1. Hixon10
        00.00.0000 00:00

        Извиняюсь, не ясно спросил.

        Я думал про какой-то такой пример:

        string test = "Test"; 
        Span<char> span = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(test.AsSpan()), test.Length); 
        span[1] = span[3]; 
        Console.WriteLine(test); // Ttst
                
        Span<char> newSpan = span.Slice(0, 2);
        Console.WriteLine(newSpan.ToString()); // Tt


        То есть, мы получили span, который взят из оригинальной строки. Потом допустим хотим удалить последние 2 символа строки, условно говоря мой newSpan из кода выше. Сам по себе слайс ничего не сделает, просто выставит новые границы. Вот и было интересно, можно ли как-то в оригинальной строке, которую мы поменяли одним из небезопастных способов, потом еще и откусить суффикс, тем самым удалив несколько символов в конце.


        1. Brom95 Автор
          00.00.0000 00:00
          +1

          Думаю так не выйдет, наверняка не знаю, но как рассуждал:
          Скорее всего строки иммутабельны не просто так, значит длина строки, скорее всего, высчитывается при ее создании и больше не актуализируются.
          Важно понимать, что Span дает нам доступ к коллекции внутри String, в то время как CLR оперирует классом System.String

          Крохотный эксперимент:

          using System.Runtime.InteropServices;
          
          var a = "Hello, World!";
          Console.WriteLine(a); // Hello, World!
          Console.WriteLine(a.Length); // 13
          var stringSpan = MemoryMarshal.CreateSpan(ref MemoryMarshal.GetReference(a.AsSpan()), a.Length);
          stringSpan[^1] = '\0';
          stringSpan[^2] = '\0';
          stringSpan[^3] = '\0';
          Console.WriteLine(a); // Hello, Wor
          Console.WriteLine(a.Length); // 13
          


          1. Hixon10
            00.00.0000 00:00

            Ага, я вот тоже не смог придумать, как поменять размер строки, при условии что нам еще и DWORD m_StringLength надо поменять.


    1. Brom95 Автор
      00.00.0000 00:00

      Если же речь о том, чтобы писать в Span, который мы передаем в качестве аргумента, то это плохая идея. Мы не знаем, где в действительности находится память, абстракцию к которой дает span. Она может оказаться как на стеке, так и в неуправляемой среде или внезапно быть частью другой строки.


  1. yuryleb
    00.00.0000 00:00
    +1

    MutateSecondCharToV() должна же принимать аргумент wchar_t*, а не char* при объявлении CharSet.Unicode?


    1. Brom95 Автор
      00.00.0000 00:00

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


      1. yuryleb
        00.00.0000 00:00
        +1

        Тут не правильности дело, я пытаюсь себе представить условия, при каких ваш код вообще может работать, и пока не получается. Ну ладно, в dotNet Unicode little endian, при обращении к первому байту двухбайтового символа в случае латинской буквы ничего заметного не произойдет (во втором байте все равно только 0), но у вас же обращение ко второму символу


        1. Brom95 Автор
          00.00.0000 00:00

          Ваша правда, забыл dll перебилдить после правок, и полный уверенности, что все в порядке внес код в статью, сейчас исправлю