Comments 35
А эта проблема в проде возникла или при ковырянии в исследовательских целях?
Если первое, то что-то явно идёт не так, а если второе, то есть очень много подобных мест, но зачем по ним топтаться?
Я много лет как ковыряюсь с разными форматами файлов PE, Elf, Dex и т.п. и в каждом есть какие-то интересные моменты, но вот чтобы вот прям нельзя было прочитать файл не взяв reference, с таким я столкнулся в первый раз.
По поводу подмены сборок, то для Microsoft BizTalk это нормальная практика, они туда ставятся не шибко удобно...
Добавив длину мы всё так же будем выдавать ошибку бинарного формата, т.к. приведение типов это не та задача которую должен решать сериализатор
Однако, изменение типа константы в родительской сборки и подмена её без компиляции в дочернюю сборку - может привести к CLR ошибке при определённых условиях
Константы никогда не должны быть видимы за пределами объявляющей сборки - это еще всем джунам с самых младых лет при каждом случае на ревью вбивают. Та же тема касается параметров со значением по умолчанию, впрочем, с ними, все-таки есть исключение - если оно объявлено как-то наподобие:
public void Foo(Bar bar = default)
{
...
}
то это считается вполне приемлемым (например, в самом .NET API cancelationToken
повсюду так и объявлен).
Enum - это тоже константа и зачастую она как раз используется вовне. По поводу что нельзя использовать константы, то зависит от проекта и можно-ли собирать константы, скажем, в ресурсах сборки.
Даже в BCL Int32.MaxValue/ Int32.MinValue объявлены константами, что зачастую очень удобно, а вот String.Empty уже статик. Что позволяет его использовать в рантайме, но не позволяет его использовать как значение аргумента в атрибуте.
Проблема в том, что при компиляции сборки 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;
Для примера добавил в 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
}
Никакой разницы
Тут надо смотреть не во что оно компилируется, а во что компилируется код, который это использует:
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?
Хм... Я проверил еще на enum
, и, к моему удивлению, оказалось, что он и в правду ведет себя как константа (т.е. в месте где используется подставляет литеральное значение). Странно, что про это в документации нигде нет (по крайней мере, я никогда не встречал). Вот интересно, если исключить случаи явного приведения enum
к его underlying type и наоборот, то какие могут быть еще косяки при изменении значения enum
в его определении (наверняка, например будут проблемы с сериализацией-десериализацией).
Enum ведёт себя без дополнительных проверок как user friendly описание константы.
И без дополнительных проверок undefined значение подходящего базового типа тоже будет считаться валидным значением даже при (де)сериализации:
Всё это теперь будет еще один пункт в мою копилку личной нелюбви к enum-ам :))
Без 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");
Можно, конечно, открыть калькулятор, или выучить один раз, но, боюсь, при сложных вычислениях это будет как с регулярными выражениями :)
Для битовых флагов enum-ы действительно хороши. Беды начинаются когда их используют для представления состояния объекта или для представления различных вариантов его поведения (просто потому что это первое что приходит в голову среднему разработчику). В ООП для таких вещей есть куда лучшие во всех отношениях шаблоны, но, похоже, что весь ООП с его шаблонами всегда успешно забывается сразу же после удачного прохождения собеседования. Смотришь потом в код, а там Fortran-66 с синтаксисом C#.
А какие шаблоны лучше во всех отношениях чем enum?
Спасибо за ответ. Но я бы не сказал, что эти шаблоны во всех отношениях лучше чем энум, по ситуации нужно действовать. Основной недостаток - это тяжеловесность. Где-то профиты от шаблоны перекрывают её, где-то овчинка не стоит выделки.
Смотришь потом в код, а там Fortran-66 с синтаксисом C#
Синьористые синьоры думают, как писать кеш-френдли и избегать аллокаций, что приводит к стилю фортран-66. Если же писать по канонам ООП, вводя состояние как объект, это будет бить по мемори трафику.
Вы уже доказали измерениями, что это проблема?
Когда пишешь что-то нагруженное, нужно максимально оптимизировать. Потому что, казалось бы, в каком-то месте "красивый" код вместо оптимального даст просадку в 1%, и это не может быть поводом писать некрасиво. Но 100 таких мест, плавно размазанных по проекту, и вот уже просадка 100%. И узкого места не найдётся, чтобы одним движением "сделать хорошо". Только переписывать всё в нуля.
очень интересное исследование! возможно такое сделано для того чтобы можно было подписывать электронной подписью каждую сборку отдельно и они гарантированно давали бы ошибки при подстановке? у меня не настолько глубокие знания .net
Обсуждаемая проблема с констатой, копируемой из динамической библиотеки не той версии - это частный случай общей проблемы: использования динамической библиотеки не той версии, с которой была собрана и проверена зависимая динамическая библиотека или программа в целом. В прежние времена, где-то четверть века назад, она была хорошо известна, и конкретно в Windows ее называли "DLL hell".
В .NET Framework были приняты серьезные меры, чтобы этой проблемы избежать: строгие имена сборок, включавшие номер версии и цифровую подпись кода, ссылки на полное имя сборки в таблице импорта и правила в манифесте приложения и в других местах, указывающие какие версии сборок какими другими можно заменять.
К сожалению, в современном .NET (который яввляется развитем другой ветки - мультиплаформерно .NET Core) все эти выстраданные разработчиками под Windows меры, как минимум, не используются (а, может, и вообще выпилены: не разбирался).
DLL-hell возник из-за экономии ресурсов. 100 мегабайт было огромным объёмом, почти как вся Windows 95, и поэтому DLL-ки старались максимально переиспользовать. Сейчас принято к каждой версии приложения добавлять 500 мегабайт бинарных зависимостей, а то и вовсе .NET Framework целиком включать в приложение. Проблема DLL-ада уже не актуальна.
По поводу звенринца сборок MS тоже уже позаботилась, так что начиная с .NET 5 есть PackageCompilationAssemblyResolver, который ищет шаренные сборки в локальном нугете. (Останется только их туда положить и все приложения которые используют кастомный AssemblyLoadContext могут их тягать от туда)
Не всё так плохо.
Проблема DLL Hell заключалась в одном ключе для поиска экспортируемой функции (Название файла - считаем ничтожным).
Т.е. GetProcAddress требовал только название функции, причём без списка входящих аргументов. В результате, мы вызываем функцию передавая указатель на что-то, что в итоге не знает не только отправивший, но и получивший. (Получивший тоже получал адрес на аргумент(ы), но не значение конкретного типа).
В новых версиях .NET'а произошёл только отказ от безопасности сборок исходя из PublicKeyToken. При этом, он остался в качестве идентификатора сборки и его всё ещё можно использовать. В итоге для идентификации сборки у нас остаётся:
AssemblyName - Полное название родительской сборки (с версией и ключом) вшитое в метаданные
MemberRef - Название метода с типами аргументов, ожидаемые в родительской сборке
Т.е. GetProcAddress требовал только название функции, причём без списка входящих аргументов. В результате, мы вызываем функцию передавая указатель на что-то, что в итоге не знает не только отправивший, но и получивший. (Получивший тоже получал адрес на аргумент(ы), но не значение конкретного типа).
А потом пришел C++ со своим name mangling, и тогда настал НАСТОЯЩИЙ ад.
Всё равно, это не спасает от случаев, когда сигнатура метода не поменялась, а реализация поменялась. Надёжнее таскать всё с собой, чем полагаться на что-то внешнее.
Нынче уже даже VS автоматом с каждой новой версии build number автоматом может инкрементить.
Тем более CI/CD это умеют, даже бесплатные:
https://github.com/DKorablin/PEReader/blob/master/.github/workflows/release.yml#L43
Это вот прям уже что-то совсем специфичное когда надо подменять сборку без идентификации. Потом ведь трассировку не собрать без конкретной идентификации...
.NET CLI — Зачем загружать все родительские сборки при загрузке сборки