Не уважаю инкапсуляцию, или использование таблицы методов другого типа для быстрого вызова приватных методов

Всем привет. Хотелось бы поделиться примером использования 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>();
        }
    }

Поделиться публикацией

Похожие публикации

Комментарии 15
    0
    Для нарушения инкапсуляции инструмент годный. А вот насчёт рефлексии, мне всегда казалось, что она существует для того, чтобы пользоваться обьектами произвольных типов, которые разработчику вообще могут быть неизвестны. Ну как например я сериализую вызов функции и параметры, сохраняю куда угодно, а потом через рефлексию этот вызов произвожу, и для этого интерфейса мне не важно какого типа обьект, какая функция вызывается, что возвращает, я могу просто сделать сериализацию и вызов произвольной процедуры в вакууме.
      0
      Согласен, целью рефлексии, разумеется, не является нарушение инкапсуляции. Однако это не отменяет того факта, что с ее помощью (при желании), нарушение инкапсуляции легче некуда.
      0
      Серьезный результат. Товарищ Warren указывает Get доступ к свойству в 0.21ns, когда у вас 0.05ns на доступ к полю.

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

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

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

          Если я увижу что-то подобное в продакшн коде своих разработчиков — голову отверну не глядя.
          0
          Если выстрел в голову в упор из дробовика не задевает мозг — это либо большое везение, либо мастерство. Первое вероятнее.
          0

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


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

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

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

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


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

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

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


                  Заголовок спойлера
                  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       
                      }
                  }

                  И как мне кажется, для виртуального метода CLR генерит метод-заглушку, возвращающий дефолтное значение.


                  Поскольку, насколько я понимаю, для вызова виртуального метода CLR необходимо действительно найти этот метод в иерархии типа, и так как метода SomeVirtualMethod нет в иерархии типа string, то и возвращается эта заглушка.

                    0

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

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

                Допустим есть метод
                Код 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);
                }
                



                Чтобы вызвать этот метод, можно написать:
                var method = typeof(string).GetMethod("CreateTrimmedString", BindingFlags.Instance | BindingFlags.NonPublic);
                var text = "123";
                method.Invoke(text, new object[] { 1, 1 })
                // "2"
                


                Можно протестировать в С# Interactive
                  0
                  Фух, я уж думал, я опозорился начав писать коменты, не прочитав статью.
                    0
                    Да, глупо получилось, посмотрел код под спойлером только после отправки комментария. А отменить было уже никак.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое