В этой статье я покажу какие проблемы может вызвать 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 | Метаданные показывают интернированную строку
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#' от автора Джеффри Рихтера.

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


  1. shai_hulud
    22.12.2022 17:17
    +6

    Это вполне очевидное поведение для интернированых строк, куда входят все строковые литералы. Вполне можно "украсть" и заменить чужой литерал через string.Intern()

    Это не является какой-то проблемой unsafe кода, это проблема тех кто мутирует общие ресурсы объявленные иммутабельными. Аналогично можно скастить IReadOnlyCollection<T> к T[] и изменить массив, а потом обвинять механизм каста в том что он помог нарушить контракт.

    Со строкой вполне можно создать новую через `new string('\0', 100)` и спокойно ее менять.


    1. dopusteam
      22.12.2022 17:36
      +1

      скастить IReadOnlyCollection<T> к T[] и изменить массив

      Если там под капотом реальный ReadOnlyCollection, то он упадёт при попытке изменить его в рантайме


      1. mayorovp
        22.12.2022 18:37

        Однако, в том же Linq Expressions на всякий случай под капотом используется класс TrueReadOnlyCollection


  1. nkozhevnikov
    22.12.2022 18:01
    +12

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

    Если вы читали Рихтера, то должны помнить вот эту часть:

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

    Собственно, unsafe обозначает, что своими кривыми руками можно сломать все, что вздумается. ССЗБ.

    Про ваш пример в листинге 3.2: из-за JIT-компиляции подгрузилась новая строка, потому что в кэше не нашлась эта же строка. Механизм интернирования на стороне CoreCLR вы можете посмотреть здесь: https://github.com/dotnet/runtime/blob/319391eb90065ec2aca29c6de0046e47a1286016/src/coreclr/vm/stringliteralmap.cpp#L403


  1. Fedorkov
    23.12.2022 16:37
    +1

    Мы не можем изменить ссылку, но значение по ссылке изменить проще простого.

    К слову, того же можно добиться рефлексией:

    typeof(ImmutablePerson)
        .GetField("<Name>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)
        .SetValue(person, "Michael");