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

Прежде, чем приступить к молниеносному нарушению инкапсуляции, стоит в двух словах напомнить, что такое StructLayout. Строго говоря, это даже StructLayoutAttribute, то бишь атрибут, который позволяет создавать структуры и классы, подобные union в С++. Если говорить еще более подробно, то данный атрибут позволяет взять управление размещением членов класса в памяти на себя. Соответсвенно, ставится он над классом. Обычно, если класс имеет 2 поля, мы ожидаем, что они будут располагаться последовательно, то бишь будут независимы друг от друга (не перекрывать). Однако, StructLayout дает возможность указать, что расположение полей будет задавать не среда, а пользователь. Для явного указания смещения полей следует использовать параметр LayoutKind.Explicit. Для указания, по какому смещению относительно начала класса/структуры (в дальнейшем класса) мы хотим разместить поле, над ним следует поставить атрибут FieldOffset, который принимает в качестве параметра количесво байт — отступ от начала класса. Отрицательное значение передать не получится, так что о том, чтобы испортить указатели на таблицу методов или индекс блока синхронизации, даже и не думайте, все будет немного сложнее.

Приступим к написанию кода. Для начала предлагаю начать с простого примера. Создадим класс следующего вида:
    public class CustomClass
    {
        public override string ToString()
        {
            return "CUSTOM";
        }

        public virtual object Field { get; } = new object();
    }

Далее используем вышеописанный механизм явного задания смещений полей.
[StructLayout(LayoutKind.Explicit)]
    public class CustomStructWithLayout
    {
        [FieldOffset(0)]
        public string Str;

        [FieldOffset(0)]
        public CustomClass SomeInstance;
    }

Пока отложу объяснения и воспользуюсь написанным классом следующим образом:
    class Program
    {
        static void Main(string[] args)
        {
            CustomStructWithLayout instance = new CustomStructWithLayout();
            instance.SomeInstance = new CustomClass();
            instance.Str = "4564";
            Console.WriteLine(instance.SomeInstance.GetType()); //System.String
            Console.WriteLine(instance.SomeInstance.ToString()); //4564
            Console.Read();
        }
    }

Итого. Вызов метода GetType() выдает string, метод ToString() шалит и дает нам строку «4564».
Разрядка для мозгов: Что будет выведено при вызове виртуального свойства CustomClass?

Как вы уже догадались, мы проинициализировали CustomStructWithLayout, обе ссылки равны null, далее инициализируем поле нашего типа, а после присваиваем строку полю Str. В итоге от CustomClass остается чуть больше, чем ничего. Поверх его была записана строка со всей ее внутренней структурой, включая таблицу методов и индекс блока синхронизации. Но компилятор видит поле все еще типа нашего класса.
Для доказательсва приведу небольшую вырезку из WinDbg:

Здесь можно увидеть несколько необычных вещей. Первая — в объекте адреса на таблицы методов у полей класса разные, что и ожидаемо, но адрес значения поля один. Вторая — можно увидеть, что оба поля расположены по смещению 4. Думаю, большинсво поймет, но на всякий случай поясню, непосредсвенно по адресу объекта располагается ссылка на таблицу методов. Поля начинаются со смещением 4 байта (длz 32 бит), а индекс блока синхронизации расположен со смещением -4.

Теперь, когда разобрались, что происходит, можно попробовать использовать смещения для вызова того, что вызывать не следовало бы.
Для этого я повторил структуру класса string в одном из своих классов. Правда повторил я лишь начало, поскольку класс string весьма объемный.
    public class CustomClassLikeString
    {
        public const int FakeAlignConst = 3;
        public const int FakeCharPtrAlignConst = 3;
        public static readonly object FakeStringEmpty;
        public char FakeFirstChar;
        public int FakeLength = 3;
        public const int FakeTrimBoth = 3;
        public const int FakeTrimHead = 3;
        public const int FakeTrimTail = 3;

        public CustomClassLikeString(){}
        public CustomClassLikeString(int a){}
        public CustomClassLikeString(byte a){}
        public CustomClassLikeString(short a){}
        public CustomClassLikeString(string a){}
        public CustomClassLikeString(uint a){}
        public CustomClassLikeString(ushort a){}
        public CustomClassLikeString(long a){ }

        public void Stub1(){}
        public virtual int CompareTo(object value)
        {
            return 800;
        }
        public virtual int CompareTo(string value)
        {
            return 801;
        }
    }

Ну и немного меняется структура с Layout
    [StructLayout(LayoutKind.Explicit)]
    public class CustomStructWithLayout
    {
        [FieldOffset(0)]
        public string Str;

        [FieldOffset(0)]
        public CustomClassLikeString SomeInstance;
    }

Далее, при вызове FakeLength или метода CompareTo() благодаря идентичным смещением этих членов класса относительно адреса самого объекта будет вызван соответсвующий метод строки (в данном случае). Добираться до первого приватного метода в строке, который я могу использовать, было довольно долго, поэтому я остановился на публичном. Но поле приватное, все честно. Кстати, методы сделаны виртуальными для защиты от всяких оптимизаций, мешающий работе (например, встраивания), а также для того, чтоб метод вызывался по смещению в таблице методов.

Итак, производительность. Исное дело, что прямой конкурент в вызове того, что вызвать не надо и в нарушении инкапсуляции — рефлексия. Я думаю, что и так понятно, что мы быстрее этой вещи, все ж мы не анализируем метаданные. Точные значения:
Method Job Mean Error StdDev Median
StructLayoutField Clr 0.0597 ns 0.0344 ns 0.0396 ns 0.0498 ns
ReflectionField Clr 197.1257 ns 1.9148 ns 1.7911 ns 197.4787 ns
StructLayoutMethod Clr 3.5195 ns 0.0382 ns 0.0319 ns 3.5285 ns
ReflectionMethod Clr 743.9793 ns 13.7378 ns 12.8504 ns 743.8471 ns
Здесь длинный кусок кода с тем, как я измерял производительность (Если кому-то оно надо):
Код
    [ClrJob]
    [RPlotExporter, RankColumn]
    [InProcessAttribute]
    public class Benchmarking
    {
        private CustomStructWithLayout instance;
        private string str;
        [GlobalSetup]
        public void Setup()
        {
            instance = new CustomStructWithLayout();
            instance.SomeInstance = new CustomClassLikeString();
            instance.Str = "4564";
            str = "4564";
        }

        [Benchmark]
        public int StructLayoutField()
        {
            return instance.SomeInstance.FakeLength;
        }

        [Benchmark]
        public int ReflectionField()
        {
            return (int)typeof(string).GetField("m_stringLength", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(str);
        }

        [Benchmark]
        public int StructLayoutMethod()
        {
            return instance.SomeInstance.CompareTo("4564");
        }

        [Benchmark]
        public int ReflectionMethod()
        {
            return (int)typeof(string).GetMethod("CompareTo", new[] { typeof(string) }).Invoke(str, new[] { "4564" });
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var summary = BenchmarkRunner.Run<Benchmarking>();
        }
    }

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


  1. KYKYH
    18.09.2018 16:01

    Для нарушения инкапсуляции инструмент годный. А вот насчёт рефлексии, мне всегда казалось, что она существует для того, чтобы пользоваться обьектами произвольных типов, которые разработчику вообще могут быть неизвестны. Ну как например я сериализую вызов функции и параметры, сохраняю куда угодно, а потом через рефлексию этот вызов произвожу, и для этого интерфейса мне не важно какого типа обьект, какая функция вызывается, что возвращает, я могу просто сделать сериализацию и вызов произвольной процедуры в вакууме.


    1. ZloyChert Автор
      18.09.2018 16:11

      Согласен, целью рефлексии, разумеется, не является нарушение инкапсуляции. Однако это не отменяет того факта, что с ее помощью (при желании), нарушение инкапсуляции легче некуда.


  1. Oxoron
    18.09.2018 19:02

    Серьезный результат. Товарищ Warren указывает Get доступ к свойству в 0.21ns, когда у вас 0.05ns на доступ к полю.

    Насколько реально отстрелить себе ноги с таким подходом? Точнее, вы пробовали применять его в продакшне?


    1. ZloyChert Автор
      18.09.2018 19:38

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


      1. vdasus
        19.09.2018 14:18
        +1

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

        Если я увижу что-то подобное в продакшн коде своих разработчиков — голову отверну не глядя.


  1. Deosis
    19.09.2018 07:26

    Бенчмарк стоит дополнить возможными способами оптимизции:


    • Сохранить MethodInfo, FieldInfo вместо вычисления каждый раз
    • Преобразовать в делегат
    • Использовать ExpressionTree, ILGenerator

    Последние варианты проигрывают прямому вызову совсем немного.


  1. Veikedo
    19.09.2018 11:56

    Разрядка для мозгов: Что будет выведено при вызове виртуального свойства CustomClass?

    А можно для не просвящённых почему так?
    Я понимаю, какой был бы результат, если бы свойство было не виртуальным, но почему возвращается null для виртуального я не догоняю.


    Мне бы хотелось получить что-нибудь вроде MethodNotFoundException.


    1. ZloyChert Автор
      19.09.2018 16:35

      На самом деле вопрос действительно сложный.
      Весь этот пример основан лишь на смещениях. Например, в смещении метода CompareTo я уверен, он совпадает с моим CompareTo. Однако свойство, когда идет первым, проецируется на другой метод. В моем случае это Equals. Попробуйте сделать вместо данного свойства аналог метода Equals. А далее попробуйте передать такую-же строку («4564» в моем случае), или другую. Результаты будут ожидаемыми для метода Equals (true и false соотвественно).
      Причина же по которой возвращаемое значение становится null (строго говоря, дефолтным, для int будет 0) мною не разгадана. Если вам интересно, попробуйте подебажить в dnSpy, там можно своими глазами увидеть, в какой метод переходит выполнение. Порядок методов в таблице методов не тривиален. Есть правила, по которым располагаются методы, но я предпочитаю видеть точно.


    1. ZloyChert Автор
      19.09.2018 17:43

      Могу высказать текущую гипотезу. Во-первых: при проецировании на Equals, параметр внуть метода строки передается null, скорее всего это связано с тем, что данный параметр передается через регистры, и значение в регистре при вызове метода соответсвет null. Второе — при этом метод возвращает false, то бишь нули (память). Которые интерпритируются как null в случае ссылочных типов, как 0 в случае int и тд.
      Это звучит довольно дико для шарпа. И я не утвержаю, а лишь предполагаю. Возможно, это послужит толчком для дальнейших исследований (моих или ваших).


      1. Veikedo
        19.09.2018 17:57

        Хм, если честно у меня более прозаическая догадка.
        Игрался с таким кодом


        Заголовок спойлера
        public class CustomClass
        {
            public override string ToString()
            {
                return "CUSTOM";
            }
        
            public virtual object SomeVirtualMethod()
            {
                return "SomeVirtualMethod";
            }
        
            public object SomeMethod()
            {
                return "SomeMethod";
            }
        }
        
        [StructLayout(LayoutKind.Explicit)]
        public class CustomStructWithLayout
        {
            [FieldOffset(0)]
            public string Str;
        
            [FieldOffset(0)]
            public CustomClass SomeInstance;
        }
        
        class Program
        {
            static void Main(string[] args)
            {
                CustomStructWithLayout instance = new CustomStructWithLayout();
                instance.SomeInstance = new CustomClass();
                instance.Str = "4564";
                Console.WriteLine(instance.SomeInstance.GetType()); //System.String
                Console.WriteLine(instance.SomeInstance.ToString()); //4564     
        
                Console.WriteLine(instance.SomeInstance.SomeMethod()); // SomeMethod
                Console.WriteLine(instance.SomeInstance.SomeVirtualMethod()); // null       
            }
        }


        1. ZloyChert Автор
          19.09.2018 18:17

          Виртуальные методы вызываются по смещению в таблице методов. На этом и основана вся эта статья.
          И благодаря этому при вызове метода CompareTo на CustomClass, вызывается метод строки. Это же и объясняет поведение при замене виртуального свойства на клон Equals. Заглушка действительно генерируется(для всех методов), но ради последующей компиляции JITом(имеет единсвеннную инструкцию на тригеринг JITа), которая потом затирается и изменяется на jmp в нужное место памяти, где расположен скомпилированный метод.


  1. HavenDV
    20.09.2018 16:45

    Еще, как вариант (Возможно, всем известно, но мало ли):

    Допустим есть метод

    Код string.CreateTrimmedString()
    [SecurityCritical]
    private string CreateTrimmedString(int start, int end)
    {
          int length = end - start + 1;
          if (length == this.Length)
            return this;
          if (length == 0)
            return string.Empty;
          return this.InternalSubString(start, length);
    }
    


    1. Veikedo
      20.09.2018 17:03

      Фух, я уж думал, я опозорился начав писать коменты, не прочитав статью.


      1. HavenDV
        20.09.2018 18:31

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