В CLR есть особенность, что при загрузки сборки через Assembly.Load или через Assembly.ReflectionOnlyLoad, загружаются все сборки по мере запроса. В отличии от констант и их типов, они заранее копируются в дочернюю сборку и больше не зависят от родительской сборки. Но в определённых случаях типы констант не копируются в дочернюю сборку и их изменение может сломать работу дочерней сборки, несмотря на то, что тип константы, в теории, не должен этого делать. Эта статья Вам поможет разобраться в каких случаях это может произойти.

При компиляции любых сборок в .NET все значения констант копируются в дочернюю сборку и при попытке, без компиляции, подменить родительскую сборку вручную, приведёт к тому что константы в дочерней сборке - не изменятся и CLR будет использовать значения, которые были перенесены в дочернюю сборку.

Однако, изменение типа константы в родительской сборки и подмена её без компиляции в дочернюю сборку - может привести к CLR ошибке при определённых условиях:

Unhandled Exception: System.Reflection.CustomAttributeFormatException: Binary format of the specified custom attribute was invalid.
   at System.Reflection.CustomAttribute.GetCustomAttributes(RuntimeModule decoratedModule, Int32 decoratedMetadataToken, Int32 pcaCount, RuntimeType attributeFilterType, Boolean mustBeInheritable, IList derivedAttributes, Boolean isDecoratedTargetSecurityTransparent)
   at System.Reflection.CustomAttribute.GetCustomAttributes(RuntimeMethodInfo method, RuntimeType caType, Boolean inherit)
   at EnumTestApp.Program.Main(String[] args)

Следует разобраться, что происходит в CLI и почему смена типа константы может привести к таким последствиям: Для эксперимента использовано решение из 2х проектов, с применением .NET Framework (но можно собрать приложение с аналогичным результатом и на последних версиях .NET). Родительская (Common) сборка состоит из одного файла с enum размерностью в int:

/*
@echo off && cls
set WinDirNet=%WinDir%\Microsoft.NET\Framework
IF EXIST "%WinDirNet%\v2.0.50727\csc.exe" set csc="%WinDirNet%\v2.0.50727\csc.exe"
IF EXIST "%WinDirNet%\v3.5\csc.exe" set csc="%WinDirNet%\v3.5\csc.exe"
IF EXIST "%WinDirNet%\v4.0.30319\csc.exe" set csc="%WinDirNet%\v4.0.30319\csc.exe"
%csc% /nologo /target:library  /out:"CommonLib.dll" %0
goto :eof
*/
using System;


namespace CommonLib
{
    public enum SharedEnum : int
    {
   	 Undefined = 0,
   	 First = 1,
   	 Second = 2,
   	 Third = 3,
    }
}

Дочернее консольное приложение будет содержать в себе атрибут и тестовый код:

/*
@echo off && cls
IF EXIST "%~0.exe" (
"%~0.exe"
exit
)
call CommonLib.bat
set WinDirNet=%WinDir%\Microsoft.NET\Framework
IF EXIST "%WinDirNet%\v2.0.50727\csc.exe" set csc="%WinDirNet%\v2.0.50727\csc.exe"
IF EXIST "%WinDirNet%\v3.5\csc.exe" set csc="%WinDirNet%\v3.5\csc.exe"
IF EXIST "%WinDirNet%\v4.0.30319\csc.exe" set csc="%WinDirNet%\v4.0.30319\csc.exe"
%csc% /nologo /reference:CommonLib.dll  /out:"%~0.exe" %0
"%~0.exe"
PAUSE
exit
*/
using System;
using System.Reflection;
using CommonLib;

namespace EnumTestApp
{
    internal class Program
    {
   	 static void Main(string[] args)
   	 {
   		 EnumAttribute attr = (EnumAttribute)typeof(Program)
   			 .GetMethod("MethodWithEnumAttribute", BindingFlags.Static | BindingFlags.NonPublic)
   			 .GetCustomAttributes(typeof(EnumAttribute), false)[0];

   		 Console.WriteLine(attr.Value.ToString());
   	 }

   	 [Enum(Value = SharedEnum.Third)]
   	 static void MethodWithEnumAttribute() { }
    }

    internal class EnumAttribute : Attribute
    {
   	 public SharedEnum Value { get; set; }
    }
}

Если скопировать код выше в 2 файла и разместить их в одной папке с названием CommonLib.bat и ConsoleApp.bat, и запустить файл ConsoleApp.bat, можно увидеть в консоли значение атрибута метода MethodWithEnumAttribute.

Следовательно, выполнив файл ConsoleApp.bat, в консоли мы увидим строку: Third. Теперь удалим значение Third из файла CommonLib.bat. Следует произвести запуск файла CommonLib.bat для обновления сборки и повторно запустить файл ConsoleApp.bat. Как и следовало ожидать, значение сохраняется в консольном приложении, поэтому вместо Third выведится цифра 3. Из чего следует, что константа осталась неизменной в сборке ConsoleApp, несмотря на то, что в реальности её больше нет в CommonLib.

Далее следует изменить тип enum с int на byte в CommonLib.bat:

public enum SharedEnum : byte //int

и пересобрать CommonLib.bat, в последствии запустив консольное приложение: ConsoleApp.bat. В результате произойдёт вышеописанная ошибка:

System.Reflection.CustomAttributeFormatException: Binary format of the specified custom attribute was invalid

Как устроено хранение атрибутов в CLI

Чтобы понять что случилось, потребуется углубиться в дебри стандарта ECMA-335 на котором и основан CLI. Метаданные хранятся в 44 таблицах в числе которых есть таблица CustomAttribute, которая хранит ссылки и типы значений пользовательских атрибутов. Кроме системых атрибутов, системные атрибуты встраиваются в разные места CLI, а для DllImportAttribute предусмотрена даже отдельная таблица в метаданных. (Для визуализации я использую приложение PE Image Info Plugin):

Детализация сигнатуры Value в таблице CustomAttribute
Детализация сигнатуры Value в таблице CustomAttribute

Разберём колонки таблицы для понимания дальнейшего процесса:

  • Колонка Parent содержит в себе ссылку на объект к которому применяется атрибут, как видно из примера выше, атрибуты могут применяться к сборке, методу или полю класса

  • Type - содержит ссылку на конструктор атрибута. Атрибут может быть объявлен внутри нашей сборки и ссылка будет на таблицу MethodDef или атрибут может быть объявлен в родительской сборке, тогда будет ссылка будет на таблицу MemberRef.

  • В колонке Value содержится сигнатура создания атрибута с константами, описанная в разделе II.23.3 стандарта ECMA-335. В данном случае - конструктор (FixedArgs) без параметров и с одним свойством Value (NamedArgs).

[Enum(Value = SharedEnum.Third)]
[Enum(Value = SharedEnum.Third)]

0x0001 — Первые 2 байта представляют из себя константу Prolog.
Затем следует массив аргументов для создания экземпляра объекта. Кол-во аргументов описано в таблицах MethodDef или MemberRef. (В нашем случае это MethodDef, ибо атрибут объявлен внутри нашей сборки, у нашего атрибута нет конструктора с аргументами и, поэтому, в массиве его нет и его размерность равняется нулю).
0x0001 — Следующие 2 байта информируют о количестве именованных свойств и их значений. В нашем случае это будет одно свойство Value со значением SharedEnum.Third.
0x54 — Значение идентифицирует тип свойства — Property (Тут может быть только 0x54 или 0x53 - Field).
0x55 — Это тип значения - Enum.
0x56 — Представляет из себя упакованное int (II.23.2) значение длины типа нашего свойства. (Упаковка работает по значению первого байта и может занимать от 1 до 4 байт)
0x43…0x6C — Строковое представление типа значения: “CommonLib.SharedEnum, CommonLib, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null”
0x05 — Представляет из себя упакованное int значение длинны названия свойства.
0x56-0x65 — Представляет из себя название свойства Value.
И последние 4 байта - представляют из себя само значение enum, которое было передано в качестве значения в свойство Value атрибута: SharedEnum.Third = 3.

В данной сигнатуре можно заметить, что размер всего свойства никак не определён.
Обычно для размера объекта используется одна из следующих техник:

  1. Размер элемента. После Count, каждый Prop или Field начинается с размера всего поля. Для примера, как это сделано со строками выше, где каждая строка начинается с Length.

  2. Символ остановки. Ключевой байт, который обозначает что элемент прочитан полностью и дальше следует следующий элемент. Для примера, строка в C++ заканчивается символом 0x00.

В отсутствии разделителя и заключается проблема (получается исключение описанное выше):
У нас нет возможности понять сколько занимает значение enum, а без этого мы не можем прочитать значение: Int64, Int32, Int16 или Byte? То есть, для чтения всей этой сигнатуры, CLR загружает сборку CommonLib.dll, находит в метаданных описание этого enum, получает его размерность и только после этого он имеет возможность корректно прочитать всю сигнатуру целиком.
Данная проблема касается именно значений типа enum, так как для всех остальных данных можно понять тип или прочитать их размерность без запроса родительской сборки.

Мои предположения почему сделано именно так

В качестве практической части,следует задать вопрос:
Почему же не указана размерность значения и почему сделано именно так?

Данная сигнатура мапится напрямую в память?

Это маловероятно, так как в сигнатуре хранится полный путь к типу enum'а, а не ссылка на таблицу TypeRef -> AssemblyRef (В этой таблице хранятся ссылки на все родительские сборки). И так-же хранится название свойства, а не ссылка вида TypeDefOrRef coded index (II.24.2.6). При этом, длина константных строк хранится в упакованном виде, т.е. может занимать от одного до 4х байт. Логика маппинга заключается в том, что массив байт должен быть фиксированной длины, чтобы его можно было быстро положить в память, накрыв его “трафаретом” → структурой.

Возможно размерность исключена для экономии места?

Положительный ответ на этот вопрос является маловероятным, т.к. во первых, как описано выше, — строки дублируются вместо ссылок в другие таблицы, во вторых значение enum (0x55) можно было заменить на значение базового типа. В качестве примера можно рассмотреть CorElementType.ELEMENT_TYPE_I4 потому что, мы видели на примере выше, удаление значение Enum — не ломает приложение и вместо определённого значения Third было получено просто 3. В этом случае существует проблема — нет возможности через рефлексию понять, что это значение enum из внешней сборки, и поэтому теряется оригинальное приведение типов. А если заменить тип свойства Value в атрибуте с SharedEnum на Object, то сигнатура будет включать в себя не только тип переменной Enum (0x55), но ещё и Boxed (0x51).

internal class EnumAttribute : Attribute
{
    public Object Value { get; set; }
}
[Enum(Value = SharedEnum.Third)]
[Enum(Value = SharedEnum.Third)]

Вот так-бы выглядела сигнатура нашего атрибута, если в свойство Value передать значение 3 (Int32 - CorElementType.ELEMENT_TYPE_I4 - 0x08):

[Enum(Value = 3)]
[Enum(Value = 3)]

Итого

Моё мнение, архитекторы бинарного формата пожалев один байт для хранения типа enum — усложнили работу не только загрузчику CLR, но и добавив неопределённости при работе с константами.

А как Вы считаете, какой был вложен смысл в том, чтобы не добавлять размерность значения enum в CLI?

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


  1. a-tk
    20.12.2023 07:44

    А эта проблема в проде возникла или при ковырянии в исследовательских целях?
    Если первое, то что-то явно идёт не так, а если второе, то есть очень много подобных мест, но зачем по ним топтаться?


    1. ZetaTetra Автор
      20.12.2023 07:44

      Я много лет как ковыряюсь с разными форматами файлов PE, Elf, Dex и т.п. и в каждом есть какие-то интересные моменты, но вот чтобы вот прям нельзя было прочитать файл не взяв reference, с таким я столкнулся в первый раз.

      По поводу подмены сборок, то для Microsoft BizTalk это нормальная практика, они туда ставятся не шибко удобно...


  1. Kano
    20.12.2023 07:44

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


    1. ZetaTetra Автор
      20.12.2023 07:44

      Зато ошибка будет более предсказуемая и с понятной трассировкой стека: InvalidCastException или OverflowException.

      А тут получается что нет возможности даже прочитать всю сигнатуру целиком не зная длинны значения.


  1. nronnie
    20.12.2023 07:44

    Однако, изменение типа константы в родительской сборки и подмена её без компиляции в дочернюю сборку - может привести к CLR ошибке при определённых условиях

    Константы никогда не должны быть видимы за пределами объявляющей сборки - это еще всем джунам с самых младых лет при каждом случае на ревью вбивают. Та же тема касается параметров со значением по умолчанию, впрочем, с ними, все-таки есть исключение - если оно объявлено как-то наподобие:

    public void Foo(Bar bar = default)
    {
    ...
    }
    

    то это считается вполне приемлемым (например, в самом .NET API cancelationToken повсюду так и объявлен).


    1. ZetaTetra Автор
      20.12.2023 07:44

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

      Даже в BCL Int32.MaxValue/ Int32.MinValue объявлены константами, что зачастую очень удобно, а вот String.Empty уже статик. Что позволяет его использовать в рантайме, но не позволяет его использовать как значение аргумента в атрибуте.


      1. nronnie
        20.12.2023 07:44

        Проблема в том, что при компиляции сборки A, которая использует константу из сборки B происходит просто подстановка значения этой константы. Т.е.

        // В сборке B
        public static class Constants
        {
            public constant int Foo = 42;
        }
        
        // В сборке A
        Console.WriteLine(Constants.Foo);
        // В итоге скомпилируется в
        // Console.WriteLine(42);
        

        К чему это приведет, если в следующей версии сборки B значение константы Foo изменится, думаю понятно. С параметрами по умолчанию ситуация точно такая же. Про int.MaxValue/MinValue и подобное - это все-таки исключение (так же как и с default) - потому что в этом случае очевидно, что их значения меняться никогда не будут.

        А вот про как раз enum который вы упомянули это не константа. Можете посмотреть рефлексией - значения enum это поля, а вовсе не константы.

        Если нужно что-то "константноподобное" видимое за пределами сборки, то его надо просто объявлять как:

        public static readonly int Foo = 42;
        


        1. ZetaTetra Автор
          20.12.2023 07:44

          Для примера добавил в CommonLib.bat 2 поля рядом со значениями Enum (Я как раз этот момент тоже в статье демонстрировал, что значение enum является константным и копируется в доченюю сборку при компиляции):

          • public const Int32 ConstantValue=123;

          • public static readonly Int32 CtorInitValue=321;

          /*
          @echo off && cls
          set WinDirNet=%WinDir%\Microsoft.NET\Framework
          IF EXIST "%WinDirNet%\v2.0.50727\csc.exe" set csc="%WinDirNet%\v2.0.50727\csc.exe"
          IF EXIST "%WinDirNet%\v3.5\csc.exe" set csc="%WinDirNet%\v3.5\csc.exe"
          IF EXIST "%WinDirNet%\v4.0.30319\csc.exe" set csc="%WinDirNet%\v4.0.30319\csc.exe"
          %csc% /nologo /target:library  /out:"CommonLib.dll" %0
          goto :eof
          */
          using System;
          
          
          namespace CommonLib
          {
          public class Test{
              public const Int32 ConstantValue=123;
              public static readonly Int32 CtorInitValue=321;
          }
              public enum SharedEnum : int
              {
             	 Undefined = 0,
             	 First = 1,
             	 Second = 2,
             	 Third = 3,
              }
          }

          Убрал .ctor чтобы не машал:

          .namespace CommonLib
          {
          	.class public auto ansi sealed CommonLib.SharedEnum
          		extends [mscorlib]System.Enum
          	{
          		// Fields
          		.field public specialname rtspecialname int32 value__
          		.field public static literal valuetype CommonLib.SharedEnum Undefined = int32(0)
          		.field public static literal valuetype CommonLib.SharedEnum First = int32(1)
          		.field public static literal valuetype CommonLib.SharedEnum Second = int32(2)
          		.field public static literal valuetype CommonLib.SharedEnum Third = int32(3)
          
          	} // end of class CommonLib.SharedEnum
          
          	.class public auto ansi beforefieldinit CommonLib.Test
          		extends [mscorlib]System.Object
          	{
          		// Fields
          		.field public static literal int32 ConstantValue = int32(123)
          		.field public static initonly int32 CtorInitValue
          
          		// Methods
          		.method private hidebysig specialname rtspecialname static 
          			void .cctor () cil managed 
          		{
          			// Method begins at RVA 0x2050
          			// Header size: 1
          			// Code size: 11 (0xb)
          			.maxstack 8
          
          			IL_0000: ldc.i4 321
          			IL_0005: stsfld int32 CommonLib.Test::CtorInitValue
          			IL_000a: ret
          		} // end of method Test::.cctor
          
          	} // end of class CommonLib.Test
          
          }

          Никакой разницы


          1. nronnie
            20.12.2023 07:44

            Тут надо смотреть не во что оно компилируется, а во что компилируется код, который это использует:

            Console.WriteLine(Foo.ConstFoo);
            Console.WriteLine(Foo.FieldFoo);
            
            class Foo
            {
                public const int ConstFoo = 32;
                public static readonly int FieldFoo = 69;
            }
            
              .method private hidebysig static void
                '<Main>$'(
                  string[] args
                ) cil managed
              {
                .entrypoint
                .maxstack 8
            
                // [1 1 - 1 33]
                // Вот она, константа "разименовалась" (комментарий мой)
                IL_0000: ldc.i4.s     32 // 0x20
                IL_0002: call         void [System.Console]System.Console::WriteLine(int32)
                IL_0007: nop
            
                // [2 1 - 2 33]
                // А это уже поле
                IL_0008: ldsfld       int32 Foo::FieldFoo
                IL_000d: call         void [System.Console]System.Console::WriteLine(int32)
                IL_0012: nop
                IL_0013: ret
              }
            

            Видите разницу между строками #11 и #17?


            1. ZetaTetra Автор
              20.12.2023 07:44

              Я больше отвечал на этот комментарий:

              А вот про как раз enum который вы упомянули это не константа. Можете посмотреть рефлексией - значения enum это поля, а вовсе не константы.


          1. nronnie
            20.12.2023 07:44

            Хм... Я проверил еще на enum, и, к моему удивлению, оказалось, что он и в правду ведет себя как константа (т.е. в месте где используется подставляет литеральное значение). Странно, что про это в документации нигде нет (по крайней мере, я никогда не встречал). Вот интересно, если исключить случаи явного приведения enum к его underlying type и наоборот, то какие могут быть еще косяки при изменении значения enum в его определении (наверняка, например будут проблемы с сериализацией-десериализацией).


            1. ZetaTetra Автор
              20.12.2023 07:44

              Enum ведёт себя без дополнительных проверок как user friendly описание константы.

              И без дополнительных проверок undefined значение подходящего базового типа тоже будет считаться валидным значением даже при (де)сериализации:

              https://ideone.com/tqpHcN


              1. nronnie
                20.12.2023 07:44

                Всё это теперь будет еще один пункт в мою копилку личной нелюбви к enum-ам :))


                1. ZetaTetra Автор
                  20.12.2023 07:44

                  Без Enum'ов будет очень печально читать побитовый код :)

                  	[Flags]
                  	public enum TestValues
                  	{
                  		One = 1 << 0,
                  		Two = 1 << 1,
                  		Three = 1 << 2,
                  		Four = 1 << 3,
                  		Five = 1 << 4,
                  	}
                  
                  		TestValues val1 = TestValues.One | TestValues.Three | TestValues.Five;
                  		TestValues check1 = TestValues.One | TestValues.Five;
                  		if((val1 & check1) == check1)
                  			Console.WriteLine("Success");
                  
                  		Int32 val2 = 21;
                  		if((val2 & 17) == 17)
                  			Console.WriteLine("Success");

                  Можно, конечно, открыть калькулятор, или выучить один раз, но, боюсь, при сложных вычислениях это будет как с регулярными выражениями :)


                  1. nronnie
                    20.12.2023 07:44

                    Для битовых флагов enum-ы действительно хороши. Беды начинаются когда их используют для представления состояния объекта или для представления различных вариантов его поведения (просто потому что это первое что приходит в голову среднему разработчику). В ООП для таких вещей есть куда лучшие во всех отношениях шаблоны, но, похоже, что весь ООП с его шаблонами всегда успешно забывается сразу же после удачного прохождения собеседования. Смотришь потом в код, а там Fortran-66 с синтаксисом C#.


                    1. Gromilo
                      20.12.2023 07:44

                      А какие шаблоны лучше во всех отношениях чем enum?



                      1. Gromilo
                        20.12.2023 07:44

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


                      1. nronnie
                        20.12.2023 07:44

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


                    1. qw1
                      20.12.2023 07:44

                      Смотришь потом в код, а там Fortran-66 с синтаксисом C#

                      Синьористые синьоры думают, как писать кеш-френдли и избегать аллокаций, что приводит к стилю фортран-66. Если же писать по канонам ООП, вводя состояние как объект, это будет бить по мемори трафику.


  1. 3263927
    20.12.2023 07:44

    очень интересное исследование! возможно такое сделано для того чтобы можно было подписывать электронной подписью каждую сборку отдельно и они гарантированно давали бы ошибки при подстановке? у меня не настолько глубокие знания .net


    1. ZetaTetra Автор
      20.12.2023 07:44

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


  1. mvv-rus
    20.12.2023 07:44

    Обсуждаемая проблема с констатой, копируемой из динамической библиотеки не той версии - это частный случай общей проблемы: использования динамической библиотеки не той версии, с которой была собрана и проверена зависимая динамическая библиотека или программа в целом. В прежние времена, где-то четверть века назад, она была хорошо известна, и конкретно в Windows ее называли "DLL hell".

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

    К сожалению, в современном .NET (который яввляется развитем другой ветки - мультиплаформерно .NET Core) все эти выстраданные разработчиками под Windows меры, как минимум, не используются (а, может, и вообще выпилены: не разбирался).


    1. qw1
      20.12.2023 07:44

      DLL-hell возник из-за экономии ресурсов. 100 мегабайт было огромным объёмом, почти как вся Windows 95, и поэтому DLL-ки старались максимально переиспользовать. Сейчас принято к каждой версии приложения добавлять 500 мегабайт бинарных зависимостей, а то и вовсе .NET Framework целиком включать в приложение. Проблема DLL-ада уже не актуальна.