Строки в 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)
beskaravaev
00.00.0000 00:00+3Благодаря тому, что строка изменяется до того, как она была проинтернирована, этот способ не вызывает побочных эффектов, свойственных предыдущим методам.
Из этого складывается впячатление, что интернируются все строки, но это не так. Интернируются строки созданные на этапе компиляции, а именно константы. Строка созданная через string.Create string.Join и многими другими способами интернирована не будет, то есть все, что создаются во время работы приложения.
p.s. но если очень нужно есть специальный метод String.Internaamonster
00.00.0000 00:00Кстати, если помните стандарт: гарантируется, что строка интернирована не будет, или компилятор/среда имеет право воткнуть String.Intern, где ему нравится? (семантика строк при этом не меняется, так что если такая процедура ведёт к оптимизации – почему бы нет?)
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
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; }
Есть какой-то способ избежать аллокации тут, переиспользовав массив из оригинальной строки?Brom95 Автор
00.00.0000 00:00Вам нет нужды вызывать
.ToString()
, вы же изменяете объект по ссылке без изменения ссылки, если я правильно понял вопрос и если мы все еще говорим о способах, которые крайне не рекомендуется использовать в реальных проектах.О какой аллокации идет речь? Пока я не сморозил глупость, хочу напомнить, что span это ссылочная структура, которая не может быть аллоцирована на куче.
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 из кода выше. Сам по себе слайс ничего не сделает, просто выставит новые границы. Вот и было интересно, можно ли как-то в оригинальной строке, которую мы поменяли одним из небезопастных способов, потом еще и откусить суффикс, тем самым удалив несколько символов в конце.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
Hixon10
00.00.0000 00:00Ага, я вот тоже не смог придумать, как поменять размер строки, при условии что нам еще и DWORD m_StringLength надо поменять.
Brom95 Автор
00.00.0000 00:00Если же речь о том, чтобы писать в Span, который мы передаем в качестве аргумента, то это плохая идея. Мы не знаем, где в действительности находится память, абстракцию к которой дает span. Она может оказаться как на стеке, так и в неуправляемой среде или внезапно быть частью другой строки.
yuryleb
00.00.0000 00:00+1MutateSecondCharToV() должна же принимать аргумент wchar_t*, а не char* при объявлении CharSet.Unicode?
Brom95 Автор
00.00.0000 00:00я, честно признаться, очень плох что в C, что в C++, поэтому вполне может быть что ваш вариант правильнее, но мой код тоже рабочий...по крайней мере вчера работал
yuryleb
00.00.0000 00:00+1Тут не правильности дело, я пытаюсь себе представить условия, при каких ваш код вообще может работать, и пока не получается. Ну ладно, в dotNet Unicode little endian, при обращении к первому байту двухбайтового символа в случае латинской буквы ничего заметного не произойдет (во втором байте все равно только 0), но у вас же обращение ко второму символу
Brom95 Автор
00.00.0000 00:00Ваша правда, забыл dll перебилдить после правок, и полный уверенности, что все в порядке внес код в статью, сейчас исправлю
aamonster
_Ни один_ из методов не должен использоваться в продакшне. На последний тоже закладываться нельзя – нет никаких гарантий, что в результате оптимизаций в какой-то версии компилятора интернирование строки не случится раньше.
UPD: пардон, был невнимателен. В последнем примере вы не модифицируете String вообще.
И, главное, зачем эти извращения, если для изменяемых строк специально сделан StringBuilder?
Brom95 Автор
Развлечения ради, и, возможно, более глубокое понимание CLR. Вообще изначально задачу принесли с какого-то собеседования.
aamonster
Да, понимание – дело хорошее. Особенно хорошо, если понимание доходит до уровня "зачем строки сделаны иммутабельными".
Brom95 Автор
В любой ситуации надо говорить либо "зависит", либо "перфоманс".
А если серьезно, то на сколько я помню,одним из самых главных преимуществ иммутабельных строк является то, что мы можем посчитать их длину при создании и больше не пересчитывать ее. Ну, и интернирование :D
aamonster
Первый абзац – блеск). Абсолютно точный ответ на кучу вопросов (в т.ч. и на этот), не дающий никакой информации :-).
AgentFire
Работает до первого резонного "зависит от чего?".
40kTons
Уже 10 лет как статье, где более подробно описано всё это