Прежде, чем приступить к молниеносному нарушению инкапсуляции, стоит в двух словах напомнить, что такое 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)
Oxoron
18.09.2018 19:02Серьезный результат. Товарищ Warren указывает Get доступ к свойству в 0.21ns, когда у вас 0.05ns на доступ к полю.
Насколько реально отстрелить себе ноги с таким подходом? Точнее, вы пробовали применять его в продакшне?ZloyChert Автор
18.09.2018 19:38Не пробовал и, если честно, не хотелось бы.
Я думал о возможностях использования, но возможность отстрела ноги слишком велика. Представляет интерес скорее с научной точки зрения.vdasus
19.09.2018 14:18+1Совершенно согласен. Если нужны такие танцы с бубном, то очевидно, что выбран не тот язык (платформа) для реализации задачи. Т.е. все это интересно, но исключительно с точки зрения лучшего понимания внутренностей, а не для практического применения. Ну или костыль для легаси. Там и не такое встречается.
Если я увижу что-то подобное в продакшн коде своих разработчиков — голову отверну не глядя.
Deosis
19.09.2018 07:26Бенчмарк стоит дополнить возможными способами оптимизции:
- Сохранить MethodInfo, FieldInfo вместо вычисления каждый раз
- Преобразовать в делегат
- Использовать ExpressionTree, ILGenerator
Последние варианты проигрывают прямому вызову совсем немного.
Veikedo
19.09.2018 11:56Разрядка для мозгов: Что будет выведено при вызове виртуального свойства CustomClass?
А можно для не просвящённых почему так?
Я понимаю, какой был бы результат, если бы свойство было не виртуальным, но почему возвращаетсяnull
для виртуального я не догоняю.
Мне бы хотелось получить что-нибудь вроде
MethodNotFoundException
.ZloyChert Автор
19.09.2018 16:35На самом деле вопрос действительно сложный.
Весь этот пример основан лишь на смещениях. Например, в смещении метода CompareTo я уверен, он совпадает с моим CompareTo. Однако свойство, когда идет первым, проецируется на другой метод. В моем случае это Equals. Попробуйте сделать вместо данного свойства аналог метода Equals. А далее попробуйте передать такую-же строку («4564» в моем случае), или другую. Результаты будут ожидаемыми для метода Equals (true и false соотвественно).
Причина же по которой возвращаемое значение становится null (строго говоря, дефолтным, для int будет 0) мною не разгадана. Если вам интересно, попробуйте подебажить в dnSpy, там можно своими глазами увидеть, в какой метод переходит выполнение. Порядок методов в таблице методов не тривиален. Есть правила, по которым располагаются методы, но я предпочитаю видеть точно.
ZloyChert Автор
19.09.2018 17:43Могу высказать текущую гипотезу. Во-первых: при проецировании на Equals, параметр внуть метода строки передается null, скорее всего это связано с тем, что данный параметр передается через регистры, и значение в регистре при вызове метода соответсвет null. Второе — при этом метод возвращает false, то бишь нули (память). Которые интерпритируются как null в случае ссылочных типов, как 0 в случае int и тд.
Это звучит довольно дико для шарпа. И я не утвержаю, а лишь предполагаю. Возможно, это послужит толчком для дальнейших исследований (моих или ваших).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 } }
ZloyChert Автор
19.09.2018 18:17Виртуальные методы вызываются по смещению в таблице методов. На этом и основана вся эта статья.
И благодаря этому при вызове метода CompareTo на CustomClass, вызывается метод строки. Это же и объясняет поведение при замене виртуального свойства на клон Equals. Заглушка действительно генерируется(для всех методов), но ради последующей компиляции JITом(имеет единсвеннную инструкцию на тригеринг JITа), которая потом затирается и изменяется на jmp в нужное место памяти, где расположен скомпилированный метод.
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); }
KYKYH
Для нарушения инкапсуляции инструмент годный. А вот насчёт рефлексии, мне всегда казалось, что она существует для того, чтобы пользоваться обьектами произвольных типов, которые разработчику вообще могут быть неизвестны. Ну как например я сериализую вызов функции и параметры, сохраняю куда угодно, а потом через рефлексию этот вызов произвожу, и для этого интерфейса мне не важно какого типа обьект, какая функция вызывается, что возвращает, я могу просто сделать сериализацию и вызов произвольной процедуры в вакууме.
ZloyChert Автор
Согласен, целью рефлексии, разумеется, не является нарушение инкапсуляции. Однако это не отменяет того факта, что с ее помощью (при желании), нарушение инкапсуляции легче некуда.