Проблемы unsafe кода C#
В этой статье я покажу какие проблемы может вызвать unsafe код и пару примеров, как можно изменить значение константы, readonly поля и свойства без set метода.
Я не знаю насколько будет вам полезна эта статья, но листинги кода в ней просто взрывают мне мозг, приятного чтения.
Листинг 1
Как думаете что выведет этот код и чем вызвано такое поведение?
string str = "Strings are immutable!";
for(int i = 0; i < str.Length / 2; i++)
{
fixed(char* strPtr = str)
{
char tmp = strPtr[i];
strPtr[i] = str[str.Length - i - 1];
strPtr[str.Length - i - 1] = tmp;
}
}
Console.WriteLine("Strings are immutable!"); // Ответ вас удивит
Казалось бы, что за глупый вопрос, но когда я запустил этот код я очень удивился.
Что выводит код:
! elbatummi era sgnirtS
Да, да, проверьте сами.
Чтобы понять почему так происходит нужно знать 3 вещи:
Интернирование строк
Базовая работа с памятью
Строка — это указатель на первый символ
Ответ на самом деле прост! Когда мы создаем строку на этапе компиляции, она интернируется. Строка это адрес на первый символ, не сам символ, а именно адрес! Этот код меняет значение символов по ячейкам памяти, но адреса остаются прежними. Поэтому когда мы пытаемся вывести «Strings are immutable!», обращаясь к таблице интернированных строк, мы получаем адрес на первый символ строки, но значения этих символов уже совсем другие.
ILDasm.exe | Метаданные показывают интернированную строку
Проблема тут очевидна, если такой разворот строки будет в импортируемой библиотеке, то в будущем, пытаясь обратиться к строке «Strings are immutable!» мы получим другой результат, сами того не подозревая. Я не знаю, насколько это правдоподобный сценарий, но я уверен, что отловить такую ошибку будет, мягко говоря, сложно.
А ведь для этого у нас даже не должен быть включен небезопасный код! Серьезно, если выключить небезопасный код, но он будет в сторонней библиотеке, то программа спокойно запустится. Следующий листинг тому пример.
Листинг 2
Создадим новый проект как библиотеку классов и подключим к основному проекту. Я назвал новый проект LibReverse и единственный класс в нем LibReverseString. Я включил в этом проекте небезопасный код и создал публичный метод Reverse, который является копией разворота строки из листинга 1.
public class LibReverseString
{
unsafe public static void Reverse(string str)
{
fixed (char* strPtr = str)
{
for (int i = 0; i < str.Length / 2; i++)
{
char tmp = strPtr[i];
strPtr[i] = strPtr[str.Length - i - 1];
strPtr[str.Length - i - 1] = tmp;
}
}
}
}
В файле Program.cs (где наш метод Main) я импортировал нашу библиотеку
using LibReverse;
И сделал вызов метода Reverse
static void Main(string[] args)
{
string str = "Strings are immutable!";
LibReverseString.Reverse(str);
Console.WriteLine("Strings are immutable!");
}
// Output:
// !elbatummi era sgnirtS
Заметьте, что в основном проекте с методом Main небезопасный код даже не включен.
Листинг 3. Поведение развернутой строки в других методах
Везде в Листинге 3 вызов LibReverseString.Reverse(str)
можно спокойно заменить на следующий код и результат не изменится:
fixed (char* strPtr = str)
{
for (int i = 0; i < str.Length / 2; i++)
{
char tmp = strPtr[i];
strPtr[i] = strPtr[str.Length - i - 1];
strPtr[str.Length - i - 1] = tmp;
}
}
Я оставил вызов Reverse из сторонней библиотеки для более хорошей читабельности кода.
Досмотрите листинг до конца, чтобы понять почему тут странное поведение, свое предположения я выскажу в конце.
Я добавил простой метод Foo, который просто выводит строку на экран.
private static void Foo()
{
string str = "Strings are immutable!";
Console.WriteLine(str);
}
Листинг 3.1: Теперь метод Main имеет у нас следующие вид
unsafe static void Main(string[] args)
{
string str = "Strings are immutable!";
Foo();
LibReverseString.Reverse(str);
Foo();
}
// Output:
// Strings are immutable!
// !elbatummi era sgnirtS
Тут видно абсолютно нормальное поведение кода, но если чуть-чуть изменить код, убрав первый вызов Foo.
Листинг 3.2: нет первого вызова Foo
unsafe static void Main(string[] args)
{
string str = "Strings are immutable!";
LibReverseString.Reverse(str);
Foo();
}
// Output:
// Strings are immutable!
Мы просто убрали первый вызов Foo еще до вызова Reverse, но Reverse не сработал. Или сработал? У меня есть теория насчет правильности которой я не уверен, прошу ответить в комментариях, если вы знаете точно.
В листинге 3.1, вызывая Foo первый раз, мы производим JIT-компиляцию, которая связывает локальную для Foo переменную str с интернированной строкой «Strings are immutable!», и только после мы ее разворачиваем.
В листинге 3.2 вызывается Foo уже после того, как мы развернули строку. Локальная для Foo переменная str не ссылается на развернутую интернированную строку. В доказательство этой теории могу привести следующий листинг 3.3.
Листинг 3.3: предварительную JIT компиляция всей сборки TestApp.
unsafe static void Main(string[] args)
{
// Pre-JIT-compilation
foreach (var type in Assembly.Load("TestApp").GetTypes())
{
foreach (var method in type.GetMethods(BindingFlags.DeclaredOnly |
BindingFlags.NonPublic |
BindingFlags.Public | BindingFlags.Instance |
BindingFlags.Static))
{
System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod(method.MethodHandle);
}
}
string str = "Strings are immutable!";
LibReverseString.Reverse(str);
Foo();
}
// Output:
// !elbatummi era sgnirtS
Этот код я взял у Vitaliy Liptchinsky из его статьи про принудительную JIT-компиляцию (https://www.codeproject.com/Articles/31316/Pre-compile-pre-JIT-your-assembly-on-the-fly-or-tr)
Листинг 4
Казалось бы, константы это незаменяемые данные, но этот код говорит об обратном.
class Program
{
public const string PI = "3.14";
unsafe static void Main(string[] args)
{
fixed (char* PIPtr = PI)
{
PIPtr[0] = '0';
}
Console.WriteLine(PI); // 0.14
}
}
На самом деле нет, напомню вам еще раз, строка — это АДРЕС на первый символ. И адрес в этом случае действительно константный. Хотя на самом деле, я думаю, предполагается, что вы не будете изменять значение констант тоже.
Visual Studio все равно показывает значение как »3.14».
Листинг 4.1: Если добавить метод Foo и изменить Main
unsafe static void Main(string[] args)
{
fixed (char* PIPtr = PI)
{
PIPtr[0] = '0';
}
Console.WriteLine(PI); // 0.14
Foo();
}
private static void Foo()
{
Console.WriteLine(PI); // 3.14
}
// Output:
// 0.14
// 3.14
Тут такая же история, как в листинге 3. Растягивать пост не буду, просто скажу, что если провести предварительную JIT-компиляцию, то Foo будет выводить именно »0.14».
Листинг 5
Тут мы видим «иммутабельный» класс. Мы же не можем изменить Name, которое имеет ключевое слово readonly? Мы не можем изменить ссылку, но значение по ссылке изменить проще простого.
public class ImmutablePerson
{
public string Name { get; }
public ImmutablePerson(string name)
{
Name = name;
}
}
ImmutablePerson person = new ImmutablePerson("William");
Console.WriteLine(person.Name); // William
fixed (char* strPtr = person.Name)
{
strPtr[0] = 'M';
strPtr[1] = 'i';
strPtr[2] = 'c';
strPtr[3] = 'h';
strPtr[4] = 'a';
strPtr[5] = 'e';
strPtr[6] = 'l';
}
Console.WriteLine(person.Name); // Michael
Этот код будет работать, даже если мы сделаем свойство Name с init-аксессором или вовсе readonly полем:
public string Name { get; init; }
public readonly string Name;
Послесловие
Спасибо всем, кто прочел эту статью. Если вы не узнали ничего нового или полезного, надеюсь я хоть чуть-чуть смог вас удивить.
В статье Ксении Мосеенковны (@kmoseenk) хорошо рассказано про иммутабельность строк, часть информации взято из нее — https://habr.com/ru/company/otus/blog/676680/
Большинство знаний об устройстве .NET я узнал в книге 'CLR via C#' от автора Джеффри Рихтера.