Самая первая моя статья на хабре «вышла комом». Спасибо всем кто оставил критические комментарии. Благодарю за науку! В том числе учтя вашу критику, написал вторую версию.
Убрал юмор - основная претензия по которой заминусовали.
На то была причина
Меня натуральным образом ограбили на 3 годовых зарплаты, была жуткая депрессия, в том числе поэтому как выход взялся на статью, чтобы отвлечься + получить положительные эмоции от общения с коллегами. А когда очень плохо, то начинаю сильно юморить — видимо такой мой защитный психологический механизм. И, да, как сейчас смотрю уже спокойным взглядом, этого юмора в статье было сильно излишне (+ еще были отсылки к старым афоризмам и мемам, которые, похоже, уже вышли из моды, но и они лишние). Извините, был не адекватен.
Главная же причина необходимости второй версии вовсе не в стилистических правках. А в том, что проект заметно продвинулся в технической части: новые серьезные фичи + небольшие коррекции старых.
Другая важная причина в том, что акценты были расставлены по другому, поэтому название было изменено на более адекватное, как более отражающее суть (старое скорее путало). И даже сделан ребрендинг названий проектов также на более отражающие суть.
И если бы не вторая причина, то здесь можно было бы просто рассказать о новых фичах. А так, второй виток/итерация (подобно тому как это принято в методологии разработки ПО), после этапа переосмысления первого, породили трансформацию, которую трудно выразить просто добавлением новых глав — проще представить новую версию со систематическим изложением материала.
Тот же текст (по большей части начальный), что «пережил» переход из первой версии, был существенно сокращен, а также отредактирован.
Добавлен раздел с ответами на задаваемые вопросы и много примеров (см. Приложение).
hand made!
Тут на хабре прочитал, что одним из верных маркеров ИИ статей (которые все разоблачают, ненавидят, минусуют) являются «ёлочные кавычки» и длинное тире. Беда! Как раз их и использую (на punto-switcher давно поставил замену: "! -> «» и -! -> — и др.). Ответственно заявляю: это «кожаная» статья! Выстраданная много-трудными человеко-часами.
Предисловие
Предполагается, что тема будет интересна тем кто любит четкие контракты в своих проектах, строгость и чистоту в инкапсуляции, новые подходы в ООП. А также тем, кто уважает функциональное программирование (ФП).
Будет параллель с friend-ми в C++, именно с нее и начну после предисловия. Однако, это лишь ассоциация-приквел к основной теме.
Как адепт ФП, всегда стараюсь выстроить так систему классов приложения, чтобы классы в идеальном случаем имели только readonly (get-only) публичные свойства.
Примечание
Для менее искушенного читателя лишь замечу — гляньте на тот же string C# класс. Как же проще понимать логику программы при работе со string экземплярами, когда знаешь, что их невозможно изменить.
А беззаботность в многопоточности? Недаром ФП ныне один из основных трендов в развитии языков.
А что делать? Кремневая микроэлектроника уже уперлась в физический предел и дальнейший прогресс идет по пути наращивания числа ядер, многопоточности. Благодаря же неизменяемости в ФП, хороший компилятор с функционального языка умеет сам автоматически распараллеливать, создавать на выходе многопоточное приложение из кода, где вручную этого даже не было предусмотрено.
Не раз в процессе разработке приложений на C# со сложной логикой, где по обыкновению следую ФП readonly принципу, сталкиваюсь с тем, что это, увы, не всегда удается. Конечно, ради принципа, можно усложнить архитектуру, потерять в производительности... Но хочется не впадать в крайности (типа «науки ради науки»), а найти компромисс. Нередко в качестве которого из C++ опыта «стучались» friend-ы.
Friend-ы, конечно, не входит в инструментарий академического ФП, но эта фича позволяет минимизировать «ущерб», когда пытаемся подражать стилю ФП в не ФП языках (точнее в нечистых ФП языках, ибо функциональные фичи сейчас есть везде). Пусть свойство все же изменяемое, но мы локализуем случаи, когда такие его изменения допустимы, не допуская до него вообще всех.
Помимо ФП тут скорее даже уместнее еще говорить в терминах инкапсуляции, открытости/закрытости... Но именно ФП + «friend подача» от плюсов стало побудительным мотивом, вдохновило на сей труд. Как результат которого предлагается elvis-модификатор доступа.
Зачем нужен этот модификатор?
Да, собственно, за тем же самым что и другие модификаторы.
Ведь так же можно спросить: зачем нужны private, protected… когда (гипотетически) пусть будет только и только public?
Ниже есть специальный раздел (см. Приложение), где более подробно обсуждается применения модификатора с массой примеров. Там также см., имхо, интересный «Общий вывод из примера».
Но сначала, «для разогрева», рассмотрим еще один способ, который исторически первым пришел в голову. Пусть он не совсем «то», более ограниченный и менее лаконичный, но он не требует подключения к проекту специального анализатора или расширения. Возможно, кому-то будет тоже интересен.
Через интерфейс
Как это принято, когда требуется продемонстрировать к-л концепцию, берется простейший пример-задача. Пусть можно предлагать альтернативные решения этой задачи, не важно, тут целью будет именно демонстрация на простейшем «hello world» примере.
И этот пример будет сквозным образом проходить через всю статью (но в «Приложении» см. раздел с большим числом других примеров).
Пусть есть условный «я» (class Me) и у меня есть друг (class MyFriend).
И пусть, с одной стороны, у меня есть 100 рублей (св-во Money).
А с другой, пусть следуя принципу, что у друзей все должно быть «налапопам», готов разделить с ним эти рубли:
class Me { public decimal Money { get; set; } = 100; } class MyFriend { public void AcceptMoney(in Me me) { decimal half = me.Money / 2; me.Money = half; Money += half; } public decimal Money { get; private set; } = -40; }
Однако, очевидно, в такой реализации мои рубли открыты для всех, а не только для друга.
В плюсах тут можно было бы объявить класс MyFriend как friend для класса Me, где Me.Money было бы свойством открытым только для чтения, а вот к закрытому полю под этим свойством класс MyFriend как раз и имел бы доступ.
Но вот как можно решить через интерфейс:
(чисто для сравнения, добавим еще свойство UnsharedMoney)
///////// Реализиция через интерфейс: class Me { public interface IFriend { // Setter static protected void setMoney(in Me self, in decimal value) => self.Money = value; } public decimal Money { get; private set; } = 100; public decimal UnsharedMoney { get; private set; } = 100_000; } class MyFriend : Me.IFriend { public void AcceptMoney(in Me me) { decimal half = me.Money / 2; Me.IFriend.setMoney(me, half); Money += half; //me.UnsharedMoney = me.Money = 0; // obviously 2 compile errors } public decimal Money { get; private set; } = -40; } ///////// Тестируем: static class Test { public static void Run() { var me = new Me(); var myPoorFriend = new MyFriend(); log(me, myPoorFriend); myPoorFriend.AcceptMoney(me); log(me, myPoorFriend); } static void log(in Me me, MyFriend friend) => Console.WriteLine($"me: {me.Money}; friend: {friend.Money}"); }
выполнив тест, ожидаемо получим:
me: 100; friend: -40 me: 50; friend: 10
Очевидно, здесь доступ будет разрешен только и только для друзей (тех, кто является наследником от Me.IFriend интерфейса).
Причем доступ к Me.Money для друга открыт, а к Me.UnsharedMoney закрыт.
А посмотрев на IFriend в IDE, например, в Visual Studio:

Сразу видим кто и где допущен.
Более того, ведь не обязательно тут должен быть именно сеттер (setMoney), это избыточно «щедро» и опасно.
Чтобы не вводить друга в искушение, можно так сделать:
class Me { public interface IFriend { static protected decimal TakeMyHalfMoney(Me self) { decimal half = self.Money / 2; self.Money -= half; return half; } } public decimal Money { get; private set; } = 100; public decimal UnsharedMoney { get; private set; } = 100_000; } class MyFriend : Me.IFriend { public void AcceptMoney(in Me me) => Money += Me.IFriend.TakeMyHalfMoney(me); public decimal Money { get; private set; } = -40; }
Здесь уже дали более ограниченный доступ, разрешив другу делать только и только то, что хотели, иначе в варианте с setMoney(..) он теоретически мог загнать меня в минус, например, вызвав setMoney(me, -1000).
Анализ, сравнение с C++, плюсы и минусы
• Большой плюс:
IFriend дает доступ не ко всем закрытым членам, а позволяет тонкую настройку, где можно обернуть это логикой, защитить от опасных операций (как для случая доступа к непосредственному сеттеру, когда можно загнать в минус). В плюсах же для friend-а открылся бы доступ и к неприкосновенному UnsharedMoney (к приватному полю под этим свойством).
• Минус:
В C++ весь контроль над друзьями находится на стороне класса (в отличии от наследования MyFriend : Me.IFriend), что по идее более правильно.
Хотя, с другой стороны, интерфейс может иногда быть и плюсиком, если класс находится в другой библиотеке, а мы хотим воспользоваться его дружелюбными фичами, которые он предоставляет.
И вам не докучают с просьбами добавить в друзья (или лезут в код, редактируя ваш C++ класс) — один раз написали C# класс, определив границы его дружелюбия, и отдыхаете.
Есть и определенный контроль. IDE в помощь — она всегда покажет разработчику Me.IFriend интерфейса кто подключился в друзья (см. последний скриншот).
• Большой минус:
В отличии от плюсов, не можем сделать другом только для конкретного метода другого класса.
В общем несколько разные подходы с определенным балансом своих плюсов и минусов.
Elvis модификатор доступа
Для того чтобы сделать доступ только для конкретного метода, а также контроль друзей на стороне класса, можно воспользоваться такими Roslyn технологиями как Analyzers или/и Incremental Source Generators.
Более того, далее предлагается отбросить концепцию friend-ов как начально-ассоциативную «ступень» и перейти к концепции модификатора.
Хотя сам термин «друг» как устоявшийся и ясный оставим в нашем арсенале (см. далее).
Ссылка на проект: github link
Описывать внутреннюю реализацию этого Roslyn проекта здесь нет смысла — обычный проект такого рода. Речь в статье совсем не о техниках Roslyn.
А о том какой инструментарий предоставляется и как его использовать. Каков предлагаемый функционал, а не второстепенное дело деталей его реализации (по ним см. код на гитхабе).
Вот на этом далее и сосредоточимся.
Элвис атрибут.
Это наш «главный герой»: [OnlyYou] атрибут (можно было назвать и OnlyFor, но просто в честь известной песни ▶ а-ля Элвиса, см. историческую справку), вот его полная реализация:
[AttributeUsage( AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method | AttributeTargets.Property, | AttributeTargets.Field | AttributeTargets.Constructor | AttributeTargets.Event , AllowMultiple = true)] class OnlyYouAttribute : Attribute { public OnlyYouAttribute(Type type, params string[] members) { } }
Как видим, атрибут можно применять для любого метода/индексера, свойства/поля, конструктора, события (в общем мембера), класса или интерфейса, статического или не статического.
Для C# версии 11 и выше (как напомнил @a-tk), можно использовать более лаконичный вариант — generic атрибут. В вашем проекте в зависимости от версии C# можете использовать либо вышеприведенный атрибут, либо его generic вариант:
[AttributeUsage(...)] class OnlyYouAttribute<T> : Attribute { public OnlyYouAttribute(params string[] members) { } }
Далее для лаконичности будем использовать generic вариант.
Теперь можем следующим образом избавится от того недостатка, когда не было возможности сделать друго�� только и только конкретный метод другого класса:
class Me { public protected interface IFriend { [OnlyYou<MyFriend>(nameof(MyFriend.AcceptMoney))] static decimal TakeMyHalfMoney(Me self) {...} } ... } class MyFriend : Me.IFriend { public void AcceptMoney(in Me me) => Money += Me.IFriend.TakeMyHalfMoney(me); // ok public void CantAcceptMoney(in Me me) => Money += Me.IFriend.TakeMyHalfMoney(me); // err ... }
Здесь комментарии err и ok показывают, где будет ошибка компиляции, а где она пройдет успешно.
Только этот пример избыто усложненный — теперь интерфейс по сути не нужен (см. следующий пример).
Analyzer проверяет вызовы (использование) мемберов, которые отмечены данными атрибутами. И только в тех местах вызовы этих мемберов будут разрешены, которые описаны в параметрах [OnlyYou] атрибутов. Для всех же остальных вызовов будет ошибка компиляции.
Для одного мембера, как видно из определения атрибута, можно задать несколько таких атрибутов.
Вот более чистый (без Me.IFriend интерфейса) пример использования этого атрибута:
class Me { public decimal Money { get; private set; } = 100; [OnlyYou<MyFriend>(nameof(MyFriend.AcceptMoney))] public decimal TakeMyHalfMoney() { decimal half = Money / 2; Money -= half; return half; } } class MyFriend { public void AcceptMoney(in Me me) => Money += me.TakeMyHalfMoney(); // ok public void CantAcceptMoney(in Me me) => Money += me.TakeMyHalfMoney(); // err public decimal Money { get; private set; } } class NotMyFriend { public void AcceptMoney(in Me me) => Money += me.TakeMyHalfMoney(); // err public decimal Money { get; private set; } }
В Visual Studio это так выглядит:

Аналогично для свойств:
class Me { [OnlyYou<MyFriend>(nameof(MyFriend.AccessMoney))] public decimal Money { get; set; } = 100; } class MyFriend { public void AccessMoney(in Me me) // all ok { var half = me.Money / 2; me.Money = half; me.Money += half; me.Money -= half; me.Money *= half; ++me.Money; --me.Money; me.Money++; me.Money--; } public void CantAccessMoney(in Me me) // all err { var half = me.Money / 2; // err me.Money = half; // err // ... err } }
И картинка из Visual Studio:

Коль какое-то публичное свойство отмечено элвис-атрибутом, то также как и с методами, пользоваться им могут только друзья.
Более того, именно для свойств определен еще[OnlyYouSet] атрибут (также в 2-х вариантах — для C#10 и C#11+, приводим последний):
[AttributeUsage( AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Property | AttributeTargets.Field , AllowMultiple = true)] public class OnlyYouSetAttribute<T> : Attribute { public OnlyYouSetAttribute(params string[] members) { } }
Как можно догадаться из названия он относится к set; части свойства и только друзьям дает доступ к ней. Изменять свойство (если у свойства предусмотрен set; и он публичный) могут только друзья. Про get; же ничего не сказано, поэтому следуя принципу «что не запрещено, то разрешено» читать публичное свойство разрешено ВСЕМ.
Итак, превращаем свойство в get-only для всех кроме избранных, только им разрешаем изменять его:
class Me { [OnlyYouSet<MyFriend>(nameof(MyFriend.SetMoney))] public decimal Money { get; set; } = 100; } class MyFriend { public void SetMoney(in Me me) // all ok { var half = me.Money / 2; // ok me.Money = half; // ok me.Money += half; // ok ... // all ok } public void CantSetMoney(in Me me) { var half = me.Money / 2; // ok me.Money = half; // err ... // all err } } class NotMyFriend { public void CantSetMoney(in Me me) { var half = me.Money / 2; // ok me.Money = half; // err ... // all err } }
Все то же самое справедливо и полей.
Можно аналогично определить и [OnlyYouGet] — ограничиваем только по get;, а set; разрешаем всем. Однако, на практике (по крайней мере моей) необходимость в only-set свойствах встречаются исключительно редко, поэтому такой атрибут пока не был введен (но сообщите, пожалуйста, если думаете, что он нужен).
Generic случаи
Говоря о [OnlyYou*<MyFriend>] и [OnlyYou*(typeof(MyFriend))], стоит заметить, что имеются случаи, когда даже в C# 11+ применим только вариант с typeof — это случаи, когда MyFriend является generic классом:
class Me<T> { T Value { get; set; } //[OnlyYou<MyFriend<>>] // compile err, invalid syntax [OnlyYou(typeof(MyFriend<>))] // ok public T GetValue() => Value; } class MyFriend<T> { void Test(Me<T> me) { var v = me.GetValue(); // ok } } class NotMyFriend<T> { void Test(Me<T> me) { var v = me.GetValue(); // err } }
Более серьезный тест для generic есть в тестах проекта (на гитхабе).
А в Приложении есть практический пример, см. Шаблон «Снимок/Хранитель» (Memento) (на гитхабе тоже есть, как и все приведенные здесь примеры).
Поддержка generic тоже одна из новых фич.
Elvis-модификаторы доступа, определение и другая терминология
Семейство [Only*] атрибутов (уже приведенных и следующих далее) на классе и/или мемберах, как, думаю, уже все давно догадались и предлагается называть elvis-модификаторами доступа (Elvis access modifiers).
Так же предлагается ввести еще такую терминологию:
Про термин «друг» (класс
MyFriendв примерах) вопросов нет, с единственным уточнением, что в качестве друга может пониматься не только класс целиком, но и лишь его мембер.А вот того с кем хотят все дружить, кто всех привлекает (класс
Meв примерах), назову «аттрактором» (придумайте лучше? социофил? другофил(фу!)?).
Кардинальное архи-положительное отличие от плюсов, что здесь уже не позволяем залезать в приватную часть класса. То что приватно и должно оставаться приватным. Тем более что всякого приватного у класса может быть очень много, и выставлять все это хозяйство на показ (как в плюсах) тот еще «эксгибиционизм». Да еще и все это (надо - не надо) C++ друзья могут курочить как угодно, внося хаос, так что может так случится, что с такими «друзьями» и врагов не понадобится.
Немного о roslyn проектах
Написал как Analyzer (ElvisModifiersAnalyzer) так и Incremental Source Generators (ElvisModifiersGenerator). Это предварительные демонстрационные проекты.
Где ElvisModifiersGenerator сейчас сделан только для методов. Собственно и начал с генератора (плюс в том, что для генератора не нужно отдельного проекта с атрибутами, он сам их внедряет в компиляцию целевого проекта). Однако генератор не всегда ожидаемо работал со студией — при компиляции проекта, к которому подключен генератор, все ок, всегда выдает нужные ошибки. А вот при просмотре файлов, IntelliSense студии не всегда их подчеркивает красным, помогает перезапуск студии, но это не очень удобно.
Поэтому попробовал сделать Analyzer. И вот с ним уже все ок. Так что решил остановится на варианте с Analyzer и далее уже только его развивал. В статье описывается именно его функционал.
Для простейшего профайлинга сгенерировал 1000 cs-файлов с простыми классами друзей, добавил в проект и не заметил каких-либо тормозов при редактировании кода в Visual Studio (для определенности: Visual Studio 2022, 17.14.16).
Было беспокойство, что при редактировании кода будет пере-анализироваться весь проект. Ибо как в этом плане ведут себя аналайзеры, как-то не встречал чтобы про это писали в доках по ним. Добавил в аналайзер короткий (в 100ms) консольный бип, фактически трещетку. И убедился, что аналайзер действует очень экономно — анализирует только тот файл на который смотрим. И даже похоже учитывает прокрутку, т.е. только тот кусок что видим. При открытии файла выдает очень короткую дробь, а при первой (только первой) прокрутке можно услышать одиночные щелчки. При редактировании — та же очень короткая дробь, несмотря на > 1000 зависимых файлов.
Случай перегруженных методов
У [OnlyYou<T>(method1, prop1, field1,..)] аргументами идет список имен мемберов. Конкретно для методов же, как известно, возможна перегрузка. Однако в этом атрибуте такие перегруженные методы не различаются — атрибут принимает только имена. Так что если method1 перегружен, то все его варианты будут френдами.
Если все же хотим различать перегруженные методы, то нужно реализовать более тонкий подход — например, передавать не просто имена методов, а сигнатуры, например: "method1(string, int)".
Однако, такой подход со строкой сигнатуры не очень нравятся. Ибо здесь нужно и не ошибиться в ее составлении, и в процессе разработки следить за тем, чтобы в этой строке всегда была именно актуальная сигнатура, и следить за регистром букв...
Поэтому пошел другим путем. Были определены еще 2 атрибута:
[AttributeUsage( // the same as for OnlyYouAttribute AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method | AttributeTargets.Property, | AttributeTargets.Field | AttributeTargets.Constructor | AttributeTargets.Event , AllowMultiple = true)] class OnlyAliasAttribute<T> : Attribute { public OnlyAliasAttribute(params string[] aliases) { } } [AttributeUsage( AttributeTargets.Method | AttributeTargets.Constructor , AllowMultiple = true)] class AliasAttribute : Attribute { public AliasAttribute(string alias) { } }
Атрибут [OnlyAlias] играет ту же роль что и [OnlyYou], только вторым аргументом теперь передаем не имена методов, а их алиасы. Алиас же для метода можно назначить с помощью атрибута [Alias].
Пример:
class Me { public const string AcceptMul = nameof(AcceptMul); public const string CtorWithArg = nameof(CtorWithArg); public decimal Money { get; private set; } = 100; [OnlyAlias<MyFriend>(AcceptMul, CtorWithArg)] public decimal TakeMyHalfMoney() { decimal half = Money / 2; Money -= half; return half; } } class MyFriend { public void AcceptMoney(in Me me) => Money += me.TakeMyHalfMoney(); // err [Alias(Me.AcceptMul)] public void AcceptMoney(in Me me, int mul) => Money += mul * me.TakeMyHalfMoney(); // ok public decimal Money { get; private set; } = -40; [Alias(Me.CtorWithArg)] public MyFriend(Me me) { Money = me.TakeMyHalfMoney(); // ok } public MyFriend() { var me = new Me(); Money = me.TakeMyHalfMoney(); // err } }
Так что для случаев перегрузки методов, можно использовать этот инструментарий.
Рекомендую именно так определять алиасы — константами в классе аттрактора. Такое единственное место определения убережет от возможной ошибки в значениях алиаса в обоих атрибутах (в принципе можно даже добавить специальное правило в аналайзер, которое будет заставлять именно так делать).
Алиасами можно помечать и конструкторы, собственно это сейчас (пока) единственный вариант задавать правила для конструкторов, даже если они не перегружены.
В пару к [OnlyYouSet] ��налогично определен [OnlyAliasSet] с точно таким же функционалом, только для случая алиасов.
Далее, чтобы не обговаривать везде что имеются ввиду как [OnlyYou/Set] так и [OnlyAlias/Set], будем говорить только об [OnlyYou/Set], понимая, что все то же самое будет справедливо и для [OnlyAlias/Set].
В качестве примеров рассматривали методы и свойства, но все то же самое справедливо и для полей, индексеров (this[index]), конструкторов (создавать объекты могут только те кому разрешено) и событий (подписываться могут только те кому разрешено).
В случае применения к классам/интерфейсам
Как видно из определения [OnlyYou] атрибута его можно применять и к классам/интерфейсам. С очевидным смыслом — разрешать доступ к мемберам аттрактора только избранным (далее еще немного «порастекаюсь по древу», а потом будет сразу общий пример кода).
[OnlyYou] на классе-аттракторе «бьет» [OnlyYou]на принадлежащих ему мемберах. Т.е. если у аттрактора запрещено все кроме некоторых привилегированных случаев, то на его мемберах уже никто не сможет пролезть, пытаясь определить на них атрибуты разрешающие доступ к уже запрещенным случаям на классе.
А вот добавить на мемберах дополнительные ограничения — без вопросов.
Как если у короля (класса-аттрактора) есть враги (недружественные типы), то если его подданные (его мемберы) вдруг попытаются с ними дружить, то это будет расценено как предательство, и СБ королевства (аналайзер) это будет пресекать.
NB. В принципе, можно даже добавить в аналайзер правило, согласно которому запрещено использовать [OnlyYou] на мемберах, если параметр типа применяемого атрибута не один из тех, что разрешены в атрибутах на классе аттракторе.
Пример:
[OnlyYou<MyFriend>] //[OnlyYou<MyFriend>(nameof(MyFriend.CanInvoke1))] // тоже вариант class Me { public decimal Money { get; set; } = 100; public void Method1() { } [OnlyYou<MyFriend>( // излишне nameof(MyFriend.CanInvoke1))] [OnlyYou<NotMyFriend>( // no effect nameof(NotMyFriend.Some1))] public void Method2() { } } class MyFriend { public void CanInvoke1(in Me me) => me.Method2(); // ok public void CanInvoke2(in Me me) => me.Method1(); // ok public void CanSet(in Me me) => me.Money = 200; // ok public void CantInvoke(in Me me) => me.Method2(); // err } class NotMyFriend { public void Some1(in Me me) => me.Method1(); // err public void Some2(in Me me) => me.Method2(); // err public void SomeSet(in Me me) => me.Money = 0; // err }
Хотя тут можно было реализовать и другой принцип, типа: «вассал моего вассала не мой вассал».
Комбинаторика нескольких атрибутов на одной «сущности»
Для примера рассмотрим такой случай:
Пусть для одного и того же MyFriend класса на один и тот же перегруженный AcceptMoney метод (для неперегруженного вопросов нет) из нашего примера выше, навесили сразу и [OnlyAlias] и [OnlyYou] атрибуты:
class Me { public const string AcceptMul = nameof(AcceptMul); public decimal Money { get; private set; } = 100; [OnlyYou<MyFriend> (AcceptMoney)] [OnlyAlias<MyFriend>(AcceptMul)] public decimal TakeMyHalfMoney() { decimal half = Money / 2; Money -= half; return half; } }
Тогда имеем дилемму:
[OnlyYou] разрешает оба варианта AcceptMoney,
[OnlyAlias] же, со своей стороны, разрешает только метод под AcceptMul алиасом.
Кто будет прав?
Сделал по принципу «или» — объединение множеств разрешенных методов первого и второго атрибутов, а не пересечение. Т.е. в нашем примере оба перегруженных метода будут разрешены.
Этот же «или» принцип работает вообще для всех всевозможных вариантах одновременного навешивания атрибутов. Иначе (если взять «и», т.е. пересечение) было бы странно, если, скажем, имелось бы 2 атрибута на разноименные методы (или методы в разных классах), ведь тогда пересечение этих условий доступа было бы пустым множеством.
В общем, почти такой же «или» принцип как это обычно делается для всяких опций (к примеру для регулярных выражений: RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline | ...).
Поведение в иерархии наследования
Это поведение не вытекает каким-то естественным образом. В этом разделе собственно и описывается как это поведение было реализовано в аналайзере.
Прежде всего сделано, чтобы модификаторы на мемберах класса никак не ограничивали эти мемберы во внутренних вызовах:
class Me { [OnlyYou<MyFriend1>(..)] public decimal SomeProp { get; set; } = 100; [OnlyYou<MyFriend2>(..)] public void SomeMethod() { } void AnyMethod() { SomeMethod(); // ok SomeProp = 0; // ok } }
Далее сделано, чтобы также не было ограничений и во всех производных классах:
class Me // из предыдыщего примера { ... } class Me2 : Me { void AnyMethod2() { SomeMethod(); // ok SomeProp = 0; // ok } void AnyMethod3(Me me) { me.SomeMethod(); // ok me.SomeProp = 0; // ok } }
И вот с производными классами вопрос уже обсуждаемый. Правильно ли так? При строгом подходе, коль в базовом прописаны определенные разрешения и Me2 в них явно не входит, то может вместо ok нужно выдавать ошибки?
У меня нет обширной практики, которая бы подсказала как тут лучше (поделитесь своим мнением, если у кого тут есть соображения). Можно, конечно, добавить опциональный флаг в атрибуты (типа bool isStrict), или завести еще одно семейство атрибутов… А пока решение не принято (нужно ли тут вообще суетиться?), изменить поведение на строгое все же можно: для этого в коде класса аналайзера (файл ElvisModifiersAnalyzer.cs), в его начале раскомментировать:
//#define STRICT_OU
Следующий тип случаев с иерархией, реализован так:
class Me { [OnlyYou<IFriend>(..)] public void SomeMethod() { } } interface IFriend { void UseMe(Me me); } class Friend1 : IFriend { void UseMe(Me me) => me.SomeMethod(); // ok } class Friend2 : IFriend { void UseMe(Me me) => me.SomeMethod(); // ok } class Friend3 { void UseMe(Me me) => me.SomeMethod(); // err }
Т.е. [OnlyYou*] правила у мембера с указанием интерфейса (IFriend) распространяет эти правила на все реализации этого интерфейса.
И, наконец, еще один тип случаев:
interface IMe { //[OnlyYou<IFriend>] [OnlyYou<Friend>] void SomeMethod(); } class Me1 : IMe { public void SomeMethod() { } } class Me2 : IMe { public void SomeMethod() { } } //interface IFriend { } class Friend // : IFriend { void UseMe(Me1 me) => me.SomeMethod(); // ok void UseMe(Me2 me) => me.SomeMethod(); // ok } class NotFriend { void UseMe(Me1 me) => me.SomeMethod(); // err void UseMe(Me2 me) => me.SomeMethod(); // err }
Правила определенные на интерфейсе аттрактора (IMe), автоматически пролонгируются и для всех реализаций этого интерфейса.
Также ничто не мешает комбинировать последний и предпоследний типы случаев (с одновременным IFriend иIMe) — соответственно скомбинируется поведение.
По идее, то, как сделано для интерфейсов нужно бы сделать и для наследования от (базовых) классов. Однако, пока «not implemented», тем более что при необходимости всегда можно для базового класса создать интерфейс (авто-операция в IDE) и задать правила там.
Некоторые комментарии по аналайзеру и vsix расширение
Вот и описан базовый функционал.
Только в аналайзере все же больше всяких нюансов. Множество примеров ситуаций использования можно найти в github репозитории — см. там TestAnalyzerLib11 либу (обычная библиотека).
Аналайзер достаточно плотно покрыт юнит тестами — проверяется 125 кейса (или 250 если считать, что проверяется как вариант для C# 10-, так и вариант для C# 11+). См. ElvisModifiersAnalyzer.Tests проект, который на самом деле все юнит тесты создает из исходных файлов TestAnalyzerLib11 либы. Эти кейсы в этой либе обозначены через специальные комментарии в исходном коде, благодаря чему проект юнит тестов парсит файлы исходного кода и понимает какие реакции аналайзера и в каком конкретно месте должны быть.
Таким образом, TestAnalyzerLib11 либа «два в одном» — можно открыть ее в студии и вживую посмотреть работу аналайзера, и в то же время она источник для юнит тестов.
Есть открытые вопросы: их примеры можно найти поиском по !!! и ???строкам в сольюшене (а то и добавьте что от себя).
• Это упомянутый момент с базовыми классами (сделать как для интерфейсов).
• Также для базовых классов имеющих конструктор на котором поставили правила, если он (явно) используется в конструкторах производных классов, то чтобы правила распространялись и на эти конструкторы (как пример, тогда можно было бы сделать чуть более лаконичной реализацию «Шаблон «Посредник» (Mediator)» — см. далее).
• Упомянутый момент с STRICT_OU.
• Также см. определенный, но пока не используемый аналайзером Exclude(You) атрибут. Про его предполагаемое назначение можно почитать в файле ExcludeAttribute.cs + ниже в примере с шаблоном «Строитель» (Builder).
• Еще одним пунктом может быть добавление OnlyYou* подобного семейства (OnlyNs* атрибуты для классов и мемберов), позволяющего селективно задавать правила доступа к функционалу для целых неймспейсов.
Feel free, если кто заинтересовался этой темой (особенно если кто хорошо шарит в анализаторах/генераторах), и желает подключится к развитию этого проекта (стать контрибьютером).
Весь код в репозитории. При компиляции аналайзера создаются ElvisModifiers.Analyzer nuget пакет и крошечный ElvisModifiersLib пакетик с атрибутами. Они опубликованы, так что welcome для подключения к вашим проектам. Достаточно подключить только ElvisModifiers.Analyzer пакет, ElvisModifiersLib подключится автоматически.
Атрибуты в ElvisModifiersLib не имеют неймспейса (т.е. к-л using для их использования не требуется), но при необходимости вы можете назначить алиас для них в .csproj файле вашего проекта.
Хотя и назвал это демонстрационным проектом, но его вполне можно использовать. В отличии от к-л сторонней либы, которая при ее использовании может подвести вас, если она «сырая», FriendAnalyzer ничего в принципе не может нехорошего привнести в ваш прекрасный код, а может лишь выдавать ошибки компиляции, чем попросит вас сделать ваш код еще прекрасней, понятней, яснее.
А возможно эти фичи будут вам столь угодны, что захотите установить расширение в студию, и больше не заморачиваться с установкой пакетов аналайзера — тогда, как воскликнул бы Якубович, «расширение в студию!»
В общем, как поняли, сделано и расширение.
Единственное, для расширения ElvisModifiersLib либа уже автоматом не подтянется в ваш проект, поэтому, хоть ElvisModifiers.Analyzer пакет уже не надо будет ставить, а вот ElvisModifiersLib пакетик с атрибутами необходимо будет установить.
Чтобы решить задачу автоматического подключения атрибутов, пробовал совместить аналайзер и генератор «в одном флаконе», наследуя ElvisModifiersAnalyzer класс не только от DiagnosticAnalyzer но и от IIncrementalGenerator интерфейса. В реализации интерфейса в зависимости от C# версии целевого проекта, инжектиться либо код с generic версией атрибутов, либо без.
Однако, такой подход показал себя крайне нестабильным — приходилось постоянно компилировать и перегружать студию, чтобы ошибки подсвечивались как надо. Возможно есть какие-то тонкости о ��оторых не в курсе, а возможно Microsoft не рассчитывала на такие сценарии. Не стал далее копать, но если кому интересно, в файле ElvisModifiersAnalyzer.cs, в его начале, можете раскомментировать //#define INJECT_ATTR и попробовать.
Но это пока скорее абстрактные исследования с неясными перспективами, ибо расширение с таким автоматическим инжектом атрибутов, скорее всего было бы полезно только для тех, кто сильно проникся новыми модификаторами и хочет их использовать везде-везде.
Если же без фанатизма, то чтобы каждый новый проект вашего сольюшена автоматически цеплял аналайзер, можете в root директорию сольюшена добавить/дополнить Directory.Build.props файл следующего содержания:
<Project> <ItemGroup> <PackageReference Include="ElvisModifiers.Analyzer" Version="*"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> </ItemGroup> </Project>
Заключение
Elvis-модификаторы, позволяют задавать новые правила-ограничения.
Ограничения важны. Они суть законы. Как в физике, где формула-закон жестко связывая разные величины тем самым накладывает ограничение, и пущенный камень уже летит не рамдомно, а с ограничением — по параболе.
Тут можно только помечтать (как минимум мне; простите эту слабость), чтобы для этого типа правил-ограничений в языке ввели какую-то более удобную синтаксическую конструкцию нежели вот так на атрибутах и аналайзере (если это вдруг окажется достаточно любезным народу, в этом плане см. далее в «Приложении»: «Общий вывод из примера»).
В некотором смысле, законы-ограничения и определяют тот или иной язык программирования. Одно из сильнейших ограничений — неизменяемость, а какая красота порождается в ФП. Или в ООП та же инкапсуляция (и прочее).
Хотите полной свободы — программируйте в машинных кодах. Неудобно? Кто бы спорил.
Законы из хаоса наводят порядок и красоту, везде: в физике (пристрастен к ней, см. P.S.), в других науках... в человеческом обществе и, конечно, в языках программирования.
Что было бы, если бы камень который вы уронили не упал на землю, а ударил по голове? Пол в квартире внезапно проваливался?... Каждый хочет контролировать свою жизнь, жить в среде, которую он может предсказывать. Законы именно это и позволяют. Так же и в программной среде. И, надеюсь, Elvis-модификаторы, сделают эту среду чуточку более предсказуемой, внесут, больше управляемости в этот важный аспект вашей жизни, дорогие коллеги.
Понравился стих (по памяти, кто написал гугл точно не смог определить):
Не терпит красота канона,
Но и без формы гибнет красота,
А форма требует закона.
Ссылки:
код на github
ElvisModifiers.Analyzer и ElvisModifiersLib nuget пакеты
vsix расширение
P.S. Хочу спросить: будут ли вам интересны статьи на другие темы?
Как минимум, могу предложить статьи по следующим 2-м темам:
1) По квантовой механике. Взглянуть на нее с нетипичной (но вполне академической, не фриковой) точки зрения. Что, надеюсь, поможет лучше понимать ее суть и необычности не специалистам.
Сам физик по базовому образованию. Правда, по специальности не работал — оканчивал в трудные нулевые, так что быстро ушел из нищей аспирантуры (куда был приглашен как красно-дипломник) в коммерческое программирование. А квантовая механика мой любимый раздел физики (ностальгирую).
2) Осенью в сентябре торкнуло отрефрешить свои знания по Haskell, ибо весьма «давненько не брал в руки шашки» и порядком все подзабыл. Чисто академический интерес. Выбрал самую тоненькую книжку «О Haskell по-человечески» (2-е издание), всего 226 страниц крупного текста (pdf версия для планшетов/мобильников). Однако, оказалось что после 24 главы текст обрывается (автор пропал прекратив работу над 2-м изданием), а материал следующих 7-ми глав (более объемные с под-главами) добирал уже в первом издании от 2014.
Но не в этом суть. А в том, что по мере чтения делал конспект в Obsidian (точнее перед чтением перенес книгу в obsidian, где и читал тут же редактируя ее). При всем уважении к автору и благодарности к нему, имхо, у него все же недостаточно чисто писательского мастерства, много лишних фраз, также как и нестрогих формулировок.
Составляя конспект, в меру своих способностей, перерабатывал текст и ликвидировал эти недостатки. Что существенно сократило объем текста.
Некоторые места имели логические лакуны, вызывающие вопросы для более пытливого взгляда — дополнил их.
Вопрос: интересно ли это высокой публике?
Если да, то как лучше этот материл представить на хабре? Если выкладывать по главам, то получится где-то 28 публикаций, хотя можно сразу несколько глав компоновать, тогда будет в разы меньше.
Или может как одну статью + просто приложить архив obsidian базы (можно и то другое)? Насколько люди пользуются obsidian?
Или может выложить на гитхабе? Единственное, в obsidian не стандартный markdown, а более расширенный (и пользуюсь этим), так что не проверял как на гитхабе такой extra-markdown будет выглядеть (в принципе, у obsidian есть еще плагины позволяющие экспортировать куда угодно: html, fb2..., но не пробовал).
Приложение.
A. Ответы на замечания/вопросы
B. Больше примеров
C. Историческая справка
A. Ответы на замечания/вопросы
Приведу ответы на некоторые замечания/вопросы от коллег (огромная благодарность):
Friend нарушает инкапсуляцию и поэтому нам не нужен.
В плюсах отчасти так, и как раз именно это критиковал в статье (как «эксбиционизм»). Однако, даже в плюсах friend вполне себе является стандартом и находит свою полезную нишу применения.
Если же спорить именно о friend, мое мнение: любой инструмент можно как правильно использовать, так и извращенно до абсурда. В общем, как и везде, нужно пользоваться головой.
Только в статье речь не о плюсовых friend-ах, они проходят лишь как начальная ассоциация. А предлагается инструмент лишенный их недостатков — новый модификатор доступа, дополняющий стандартную коллекцию модификаторов. Именно поэтому, чтобы не было этой путаницы, переименовал статью и сделал ребрендинг названий проектов.
И без этого жили, можно вполне обойтись тем что есть.
В принципе так. Так же как можно обходиться и без любых новых фич в языках, даже без ООП — того же C достаточно.
Совсем не предлагается втыкать куда только можно. Как и всякий инструмент, имеет свою специализацию, подходящие случаи применения.
По моему опыту, полезен для задач со сложной логикой. В своих проектах стараюсь следовать ФП «дао-пути», чтобы все классы имели только readonly публичные свойства. И чтобы каждый класс имел доступ только к тому что ему нужно, особенно если это методы с side эффектом. Когда же это не удается сделать тотально (усложняет архитектуру, непроизводительно,…), а это где-то 2-8 случаев на сотню классов, то тут и полезен elvis-модификатор.
Но не только ФП соображения.
Такой модификатор особенно полезен в крупных проектах с четкой архитектурой, где важно контролировать зависимости и предотвращать непреднамеренное использование внутренних API.
В больших, командных проектах иногда видел, что чуть ли не все свойства (надо - не надо) имеют публичные get-set. Вот смотришь на сотни/тысячи таких классов (особенно на чужие, да и на свои сколько-то времени спустя) и чешешь репу — да где же только в этом огромном коде с массой классов (и кратно большим числом мемберов) не сетятся все эти свойства и вызываются все эти методы? Intellisense же при большом числе классов, с их массой мемберов в каждом, тут слабо помогает, к тому же intellisense работает в моменте пока смотришь и не убережет от нарушения неявного (никак не прописанного) контракта в любой момент. С модификаторами же все прозрачно, не тысячи возможных нитей-связей за которые могут все дергать, а одна/две явно прописанные, которые уже не нарушишь.
Описанная ситуация напоминает пресловутый антипаттерн глобального состояния, и усугубляется тем, что это состояние размазано по массе классов и попробуй разберись, как оно меняется, если каждый может изменять каждого. И описываемые в статье инструменты могут помочь тут если и не достичь ФП идеала, то навести больше порядка.
Может приглянутся архитектору, тим-лиду... при проектировании системы классов, когда таких классов тысячи (а мемберов кратно больше). Убережет тех кто потом пишет код (особенно новичков) от неправомерного использования мемберов/классов. В большой системе мало кто все видит и понимает в целом, и вследствие этого есть такой риск. Сделает архитектуру строже и чище. Что, конечно, не отрицает (но дополняет) и другие варианты решения данной проблемы.
Лично мне нравятся такие тонкие инструменты. Как инструменты ювелира (vs инструменты слесаря), ими можно всякую тонкую, так сказать, «часовую механику» налаживать, точнее моделировать предметную область.
Так же см. раздел «Больше примеров».
Связность
Не увеличивается ли связность?
Наверное все же имели ввиду «связанность» (сам путаю эти названия).
Если мембер где-то используется, то эта связь так и так есть. Декларация разрешающая ее тут лишь «документирует» этот факт.
А вот, так сказать, потенциальная связанность, вследствие вводимого ограничения, кардинально, многократно уменьшается.
В то время как связность, которая считается благом, как раз увеличивается, в том смысле, что накладываемые ограничения делают связанные классы более изолированными, не только семантически, но уже и синтаксически модульными (см. далее общий вывод под примером «Шаблон «Посредник» (Mediator)»).
Более того, если хотите, в аналайзер нетрудно добавить выдачу варнинга, если в модификаторе прописано использование для к-л класса/мембера, но не используется.
Тогда помимо документирования это может, с одной стороны, служить как TODO для разработчиков в начале разработки, а с другой, на финише разработки, как сигнал, что данный мембер то ли лишний, то ли его можно убрать в приватные.
B. Больше примеров
В статье был единственный демонстрационный пример. Попробую набросать еще разных простых примеров (теоретических, реальные примеры из моей практики в силу их специфичности потребовали бы долгого ввода в тему, да и в силу новизны проекта мало еще сам где использовал).
И, еще раз, это не значит, что не может быть других решений. Здесь предлагаются решения именно с помощью elvis-модификаторов.
Строгие контракты между связанными классами
⠀⠀
Когда классы реализуют общий протокол или паттерн:
class Parser { // Только Tokenizer может использовать этот метод [OnlyYou<Tokenizer>(nameof(Tokenizer.Reparse))] public void ResetState() { ... } } class Tokenizer { // Использует Parser.ResetState() когда нужно public void Reparse() { parser.ResetState(); // Разрешено } }
class Logger { // Только класс LogManager может инициализировать логгер [OnlyYou<LogManager>(...)] public void Initialize(CoreConfig config) { ... } // Только классы LogWriter и LogReader могут использовать [OnlyYou<LogWriter>(...)] [OnlyYou<LogReader>(...)] public string FormatEntry(LogEntry entry) { ... } }
Разделение ответственности внутри модуля: когда логически связанные классы должны работать вместе, но не раскрывать детали наружу:
class Database { [OnlyYou<QueryOptimizer>(...)] public Statistics GetStats() { ... } } class QueryOptimizer { // Использует статистику для оптимизации запросов }
Безопасное разделение уровней абстракции
// Низкоуровневый компонент class GraphicsBuffer { [OnlyYou<GraphicsRenderer>(...)] public IntPtr GetNativeHandle() { ... } } // Высокоуровневый компонент class GraphicsRenderer { // Единственный, кто может работать с нативными ресурсами }
Тестирование без нарушения инкапсуляции
Вместо internal или рефлексии:
class Cache { // Только тестовый класс может очищать кэш [OnlyYou<CacheTests>(...)] public void ClearForTesting() { ... } }
Всякие случаи реализации структур данных с алгоритмами:
namespace DataStructures; class RedBlackTree { [OnlyYou<RedBlackTreeIterator>(...)] public Node RotateLeft(Node n) { ... } } class RedBlackTreeIterator { // Может использовать внутренние операции дерева }
Реализация шаблонов проектирования
Пройдемся по некоторым шаблонам проектирования.
Состояние (State).
Делаем чтобы методы перехода между состояниями были доступны только контексту. Обычно вынуждены делать методы перехода публичными (или internal), так что любой класс может вызвать методы на состоянии, что нежелательно.
Решение с модификатором:
// Банковский счет - контекст class BankAccount { private IAccountState _state; public decimal Balance { get; private set; } public BankAccount() { _state = new ActiveState(this); } // Только классы состояний могут менять состояние счета [OnlyYou<IAccountState>] public void SetState(IAccountState newState) { _state = newState; Console.WriteLine($"Состояние изменено на {newState.GetType().Name}"); } // Только классы состояний могут изменять баланс [OnlyYou<IAccountState>] public void UpdateBalance(decimal amount) { Balance += amount; } // Публичные методы - интерфейс для клиентов public void Deposit(decimal amount) => _state.Deposit(amount); public void Withdraw(decimal amount) => _state.Withdraw(amount); public void Freeze() => _state.Freeze(); public void Close() => _state.Close(); } // Интерфейс состояния interface IAccountState { void Deposit(decimal amount); void Withdraw(decimal amount); void Freeze(); void Close(); } // Активное состояние class ActiveState : IAccountState { private BankAccount _account; public ActiveState(BankAccount account) { _account = account; } public void Deposit(decimal amount) { // Может обновлять баланс, потому что разрешено модификатором // ... здесь любая необходимая бизнес-логика _account.UpdateBalance(amount); Console.WriteLine($"Внесено {amount}, баланс: {_account.Balance}"); } public void Withdraw(decimal amount) { ... } public void Freeze() { ... } public void Close() { ... } } // Замороженное состояние class FrozenState : IAccountState { ... } // Закрытое состояние class ClosedState : IAccountState { ... }
Преимущества такого подхода:
Контролируемый переход состояний:
class FraudDetectionService { public void CheckForFraud(BankAccount account) { // Ошибка компиляции! Нельзя напрямую менять состояние account.SetState(new FrozenState(account)); // err // Только через публичный интерфейс account.Freeze(); // Правильно - через бизнес-логику } }
Безопасное изменение внутренних данных:
class ExternalService { public void ProcessPayment(BankAccount account) { // Ошибка компиляции! Нельзя напрямую менять баланс account.UpdateBalance(1000); // err // Только через методы состояния account.Deposit(1000); // Правильно - через бизнес-логику } }
Явные отношения между классами
// Модификаторы явно документируют, что только эти классы // могут управлять внутренним состоянием BankAccount [OnlyYou<IAccountState>]
Сценарий использования:
static void Main() { var account = new BankAccount(); // Клиентский код - работает с публичным интерфейсом account.Deposit(1000); // Внесено 1000, баланс: 1000 account.Withdraw(500); // Снято 500, баланс: 500 // Система безопасности решает заморозить счет account.Freeze(); // Счет заморожен // Попытка снять деньги account.Withdraw(100); // Невозможно снять средства - счет заморожен // Внести все еще можно account.Deposit(200); // Внесено на замороженный счет 200, баланс: 700 // После разбирательства счет закрывают account.Close(); // Счет закрыт // Дальнейшие операции невозможны account.Withdraw(100); // Невозможно снять средства - счет закрыт // Но! Никто не может случайно "разморозить" счет, // минуя бизнес-логику account.SetState(new ActiveState(account)); // Ошибка компиляции! }
Шаблон «Фабричный метод» (Factory Method)
Только фабрика может создавать документы:
abstract class Document { public abstract void Open(); public abstract void Save(); } class TextDocument : Document { public const string Type = nameof(TextDocument); // Только фабрика может создавать документы [OnlyYou<DocumentFactory>] public TextDocument() { } public override void Open() => Console.WriteLine("Opening text document"); public override void Save() => Console.WriteLine("Saving text document"); } class SpreadsheetDocument : Document { public const string Type = nameof(SpreadsheetDocument); // Только фабрика может создавать документы [OnlyYou<DocumentFactory>] public SpreadsheetDocument() { } public override void Open() => Console.WriteLine("Opening spreadsheet"); public override void Save() => Console.WriteLine("Saving spreadsheet"); } // Фабрика - единственная, кто может создавать документы class DocumentFactory { public Document CreateDocument(string type) { return type switch { TextDocument.Type => new TextDocument(), // Разрешено SpreadsheetDocument.Type => new SpreadsheetDocument(), // Разрешено _ => throw new ArgumentException() }; } } // Клиентский код не может создать документ напрямую class Client { void Test() { var doc_try = new TextDocument(); // Ошибка компиляции! Client не DocumentFactory // Правильно: var factory = new DocumentFactory(); var doc = factory.CreateDocument(TextDocument.Type); } }
Шаблон «Посредник» (Mediator)
Только медиатор ответственен за создание коллег и только коллеги могут нотифицировать медиатора:
interface IMediator { // Только коллеги могут отправлять сообщения через медиатор [OnlyYou<Colleague>] void Notify(object sender, string eventName); } abstract class Colleague { readonly IMediator _mediator; //[OnlyYou<IMediator>] !!! not implemented protected Colleague(IMediator mediator) => _mediator = mediator; protected void Send(string eventName) => _mediator.Notify(this, eventName); } class Button : Colleague { // Только медиатор может создавать коллег [OnlyYou<IMediator>] public Button(IMediator mediator) : base(mediator) { } public void Click() { Console.WriteLine("Button clicked"); Send("buttonClicked"); // Разрешено как наследнику Colleague } } class TextBox : Colleague { [OnlyYou<IMediator>] public TextBox(IMediator mediator) : base(mediator) { } public string Text { get; private set; } = ""; public void SetText(string text) { Text = text; Send("textChanged"); } } // Конкретный медиатор class MyDialogMediator : IMediator { readonly Button _okButton; readonly TextBox _nameField; public MyDialogMediator() { _okButton = new Button(this); _nameField = new TextBox(this); } public void Notify(object sender, string eventName) { if (sender is TextBox && eventName == "textChanged") { // Включаем кнопку, если поле не пустое if (!string.IsNullOrEmpty(_nameField.Text)) { Console.WriteLine("Enabling OK button"); } } // ... } } // Никто другой не может созавать коллег и отправлять сообщения class Outsider { void TryHack() { var dialog = new MyDialogMediator(); var button = new Button(dialog); // Ошибка компиляции! Outsider не IMediator dialog.Notify(button, "hack event"); ; // Ошибка компиляции! } }
Общий вывод из примера:
Модификаторы позволяют делать так, что группа классов, не только семантически (в голове у разработчика), но теперь и синтаксически образует целостный концепт, без «торчащих ниток» за которые могут дергать кто угодно.
[OnlyYou*] от одних классов, подобно «ребрам», создают узкие направления к другим классам (направленный граф), даже могут «прикрепляться» в конкретных местах к этим классам.
Все видели модели молекул из шариков и ребер. Если сравнивать классы с атомами, то модификаторы позволяют синтаксически задавать некий мета-уровень над классами, более высокий уровень ООП абстракции, скрепляя классы в «молекулы/графы» еще одним способом. Под другими способами имеются ввиду агрегация и наследование.
Шаблон «Строитель» (Builder)
// Только строитель может устанавливать свойства [OnlyYouSet<PizzaBuilder>] class Pizza { readonly List<string> _Toppings = new(); public IEnumerable<string> Toppings => _Toppings; // Только строитель может создавать пиццу [OnlyYou<PizzaBuilder>] public Pizza() { } public string Dough { get; set; } = "Default"; public string Sauce { get; set; } = "Default"; // Только строитель может добавлять [OnlyYou<PizzaBuilder>] public void AddToppings(string topping) => _Toppings.Add(topping); public string Description => $"Dough: {Dough}; Sauce: {Sauce};..."; } //[OnlyYou<PizzaDirector>] class PizzaBuilder { Pizza _pizza = new Pizza(); // Только директор может устанавливать ингредиенты [OnlyYou<PizzaDirector>] public void SetDough(string dough) { _pizza.Dough = dough; } [OnlyYou<PizzaDirector>] public void SetSauce(string sauce) { _pizza.Sauce = sauce; } [OnlyYou<PizzaDirector>] public void AddTopping(string topping) { _pizza.AddToppings(topping); } //[Exclude] // Доступен всем - получить результат можно всегда public Pizza Build() => _pizza; } // Директор - единственный, кто может управлять строителем class PizzaDirector { private PizzaBuilder _builder; public PizzaDirector(PizzaBuilder builder) { // Директор получает строителя и теперь только он им управляет _builder = builder; } public void MakeMargherita() { // Разрешено - мы внутри PizzaDirector _builder.SetDough("thin"); _builder.SetSauce("tomato"); _builder.AddTopping("mozzarella"); _builder.AddTopping("basil"); } public void MakePepperoni() { // Разрешено _builder.SetDough("thick"); _builder.SetSauce("tomato"); _builder.AddTopping("pepperoni"); _builder.AddTopping("cheese"); } } // Клиент теперь защищён от случайных ошибок class Client { void OrderPizza() { var try_pizza = new Pizza(); // Ошибка компиляции! try_pizza.Dough = "Some toxic"; // Ошибка компиляции! var builder = new PizzaBuilder(); var director = new PizzaDirector(builder); director.MakePepperoni(); var pizza = builder.Build(); // ok // но: builder.SetDough("Some toxic"); // Ошибка компиляции! builder.AddTopping("мухамор"); // Ошибка компиляции! // Клиент может только получить готовый продукт Console.WriteLine($"Got pizza: {pizza.Description}"); // и получить (уже только для чтения) свойства var dough = pizza.Dough; // ok } }
Вот и здесь теперь синтаксически целостный концепт, а не набор классов.
Кстати, здесь пригодился бы Exclude(You) атрибут, особенно если бы у класса PizzaBuilder помимо Build() метода было бы не 3, а очень много других Set-подобных методов. Тогда можно было бы поставить [OnlyYou<PizzaDirector>] целиком на класс, а внутри класса использовать Exclude(You) атрибут только и только на Build(), отменяющий правило класса исключительно на этом методе. Именно в этом идея Exclude(You) атрибута (как говорилось, пока не реализовано, даже его название пока условное).
Шаблон «Снимок/Хранитель» (Memento)
Который представим в generic версии:
[OnlyYou(typeof(Originator<>))] class Memento<T> // Снимок/Хранитель { // Только Originator может создавать Memento public Memento(T state) => State = state; // Только Originator может читать состояние public T State { get; } } class Originator<T> // Создатель { public T State { get; set; } // Только Caretaker может создавать Снимок [OnlyYou(typeof(Caretaker<>))] public Memento<T> Save() => new Memento<T>(State); // Только Caretaker может восстанавливать Снимок [OnlyYou(typeof(Caretaker<>))] public void Restore(Memento<T> memento) => State = memento.State; } // Может хранить Memento, но не может читать или изменять его class Caretaker<T> // Опекун { private Stack<Memento<T>> _history = new (); public void SaveState(Originator<T> originator) => _history.Push(originator.Save()); public void Undo(Originator<T> originator) { if (_history.Count > 0) { var memento = _history.Pop(); originator.Restore(memento); var state = memento.State; // Ошибка компиляции! Caretaker не Originator } } } // Использование class Program { static void Main() { var origin = new Originator<string>(); var care = new Caretaker<string>(); origin.State = "A"; care.SaveState(origin); origin.State = "B"; care.Undo(origin); // Восстановит состояние "A" // Но: var memento = origin.Save(); // Ошибка компиляции! var state = memento.State; // Ошибка компиляции! memento = new Memento<string>("bad state"); // Ошибка компиляции! } }
Можно и дальше идти по шаблонам, перебирая и улучшая их (оказалось увлекательное занятие), но, пожалуй, остановлюсь на этом, статья «не резиновая». Если кому интересно и неленивый может попробовать перебрать остальные основные шаблоны.
Реализация механизмов платформы/фреймворка
Лишь наметим некоторые направления. Не прорабатывал, поэтому скорее как гипотезы.
Системы привязки данных (data binding)
Обычно это делается через интерфейс INotifyPropertyChanged:
// Типичная модель с уведомлениями class Person : INotifyPropertyChanged { // Проблема: это событие публично, каждый может подписаться // хотя нужно только системе привязки public event PropertyChangedEventHandler PropertyChanged; private string _Name; public string Name { get => _Name; set { if (_Name != value) { _name = value; OnPropertyChanged(nameof(Name)); } } } // Проблема: этот метод публичный, его может вызвать кто угодно, // хотя он нужен только системе привязки public void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } class EvilService { public void MessWithPerson(Person person) { // Можно вызвать уведомление без реального изменения person.OnPropertyChanged(nameof(Person.Name)); // Или вызвать для несуществующего свойства person.OnPropertyChanged("NonExistentProperty"); // Подписаться на событие person.PropertyChanged += (s, e) => { // Evil action } }
Защититься можно разрешив для отмеченных мемберов доступ только интерфейсу IDataBindingSystem(условное название) фреймворка системы привязки данных.
Сериализаторы,
где хотим регулировать доступ к свойствам/полям.
Более явно, чем рефлексия — проверяется на этапе компиляции.
На этом, пожалуй, хватит примеров. Надеюсь немного «растопил лед», расшевелил вашу фантазию, чтобы тоже могли прикидывать варианты применения, пусть поначалу, как и со всяким новым инструментом, это может быть непривычно.
C. Историческая справка
Все написав и опубликовав: код, пакеты, статью, ai-сгенерив элвис-обложку и элвис-иконки для пакетов; решил отвлечься и узнать больше об истории песни «Only You» от Элвиса Пресли, ну как всю жизнь считал. Однако похоже это одно из массовых заблуждений (а-ля эффект Манделы). Инет пестрит ссылками приписывающими эту песню Элвису:
https://only-you-presley.skysound7.com
Elvis Presley - Only You (lyrics)
...
В реальности, эта песня все же похоже за авторством группы The Platters (хотя некоторые в постах продолжают спорить упорно приписывая ее Элвису).
Что делать? Пусть будет как будет. Бывает, что виртуальный факт перевешивает реальный, история от этого только интереснее и богаче, главное знать ее полностью — и мнимую и реальную часть (как в комплексных числах).
В конце-концов, это песня эпохи и стиля Элвиса и «утиный тест» в применении к нему, очевидно, проходит.
Да и вообще, никто не отменял авторского произвола называть как угодно, даже без всяких привязок, а тут привязка ничуть не хуже (имхо, даже покрепче), чем у сово-натяжного названия «элвис-оператор».
А вот еще ИИ спешит на помощь:
Imagine Elvis Singing in The Platters’ Voice ONLY YOU | A WISH AI Video
или:
Elvis Presley - Only You in This Room | AI Inspired Vintage Love Ballad | 1950s Romantic Rock & Soul
для тех у кого сложности с ютубом (google drive):
Так что будем идти в ногу со временем, ведь ИИ артефакты это «стильно, модно, молодежно».
Как я выкрутился, а? :)
Бонус :) (google drive)
(извините, по завершении получилось как индийское кино — с песнями)
