Программистам вроде меня, которые пришли в C# с большим опытом работы в Delphi, часто не хватает того, что в Delphi принято называть ссылками на класс (class reference), а в теоретических работах – метаклассами. Я несколько раз натыкался в разных форумах на обсуждение, проходящее по одной схеме. Начинается оно с вопроса кого-то из бывших дельфистов на тему того, как сделать метакласс в C#. Шарписты просто не понимают вопроса, пытаются уточнить, что это за зверь такой – метакласс, дельфисты как могут объясняют, но объяснения краткие и неполные, и в итоге шарписты остаются в полном недоумении, зачем всё это нужно. Ведь то же самое можно сделать с помощью рефлексии и фабрик класса.

В этой статье я попытаюсь всё-таки рассказать, что такое метаклассы, тем, кто с ними никогда не сталкивался. Дальше уже пусть каждый сам решает, хорошо ли было бы иметь такую вещь в языке, или рефлексии достаточно. Всё, что я тут пишу, это именно фантазии на тему того, как оно могло бы всё быть, если бы в C# действительно были метаклассы. Все примеры в статье написаны на этом гипотетическом варианте C#, ни один существующий на данный момент компилятор не сможет их откомпилировать.

Что такое метакласс


Итак, что же такое метакласс? Это специальный тип, который служит для описания других типов. В C# есть что-то очень похожее – тип Type. Но только похожее. Значением типа Type можно описать любой тип, метакласс же может описывать только наследников класса, указанного при объявлении метакласса.

Для этого наш гипотетический вариант C# обзаводится типом Type<T>, являющимся наследником Type. Но Type<T> пригоден только для описания типа T или его наследников.
Поясню это на таком примере:

class A { }

class A2 : A { }

class B { }

static class Program
{
    static void Main()
    {
        Type<A> ta;
        ta = typeof(A);  // Это откомпилируется
        ta = typeof(A2); // Это тоже откомпилируется
        ta = typeof(B);  // Ошибка компиляции – Type<B> несовместим с Type<A>
        ta = (Type<A>)typeof(B); // Исключение во время работы программы из-за невозможности приведения

        Type tx = typeof(A);
        ta = tx; // Ошибка компиляции – нет неявного приведения Type к Type<A>
        ta = (Type<A>)tx; // Здесь всё нормально
        Type<B> tb = (Type<B>)tx; // Исключение
    }
}

Приведённый выше пример – это первый шаг к появлению метаклассов. Тип Type<T> позволяет ограничивать то, какие типы могут описываться соответствующим значениям. Эта возможность и сама по себе может оказаться полезной, но на этом возможности метаклассов не исчерпываются.

Метаклассы и статические члены классов


Если некоторый класс X имеет статические члены, то метакласс Type<X> получает аналогичные ему члены, уже не статические, через которые можно обращаться к статическим членам X. Поясним эту запутанную фразу примером.

class X
{
    public static void DoSomething() { }
}

static class Program
{
    static void Main()
    {
        Type<X> tx = typeof(X);
        tx.DoSomething(); // Тот же результат, что и при вызове X.DoSomething();
    }
}

Тут, вообще говоря, встаёт вопрос – а что если в классе X будет объявлен статический метод, имя и набор параметров которого совпадает с именем и набором параметров одного из методов класса Type, наследником которого является Type<X>? Есть несколько достаточно простых вариантов решения этой проблемы, но я не буду на них останавливаться – для простоты считаем, что в нашем фантазийном языке конфликтов имён волшебным образом не бывает.

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

Классовые методы


Классовые методы – это ещё одна конструкция, которая есть в Delphi, но отсутствует в C#. Эти методы при объявлении помечаются словом class и являются чем-то средним между статическими методами и методами экземпляра. Как и статические методы, они не привязаны к конкретному экземпляру и могут быть вызваны через имя класса без создания экземпляра. Но, в отличие от статических методов, они имеют неявный параметр this. Только this в данном случае является не экземпляром класса, а метаклассом, т.е. если классовый метод описан в классе X, то его параметр this будет иметь тип Type<X>. И пользоваться им можно будет примерно так:

class X
{
    public class void Report()
    {
        Console.WriteLine($”Метод вызван из класса {this.Name}”);
    }
}

class Y : X
{
}

static class Program
{
    static void Main()
    {
        X.Report() // Вывод: «Метод вызван из класса X»
        Y.Report() // Вывод: «Метод вызван из класса Y»
    }
}

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

class X
{
    protected static virtual DoReport()
    {
        Console.WriteLine(“Привет!”);
    }

    public static Report()
    {
        DoReport();
    }
}

class Y : X
{
    protected static override DoReport()
    {
        Consloe.WriteLine(“Пока!”);
    }
}

static class Program
{
    static void Main()
    {
        X.Report() // Вывод: «Привет!»
        Y.Report() // Вывод: ???
    }
}

По логике вещей, при вызове Y.Report должно быть выведено «Пока!». Но метод X.Report не имеет никакой информации о том, из какого класса он был вызван, поэтому выбрать между X.DoReport и Y.DoReport динамически он не может. Как следствие, X.Report всегда будет вызывать X.DoReport, даже если Report был вызван через Y. Смысла делать метод DoReport виртуальным нет никакого. Поэтому C# и не разрешает делать статические методы виртуальными – сделать-то их виртуальными было бы можно, но извлечь пользу из их виртуальности не получится.

Другое дело – классовые методы. Если бы Report в предыдущем примере был не статическим, а классовым, он бы «знал», когда его вызывают через X, а когда через Y. Соответственно, компилятор мог бы сгенерировать код, который выбрал бы нужный DoReport, и вызов Y.Report привёл бы к выводу «Пока!».

Эта возможность сама по себе полезна, но становится ещё более полезной, если к ней добавить возможность вызова классовых переменных через метаклассы. Как-то вот так:

class X
{
    public static virtual Report()
    {
        Console.WriteLine(“Привет!”);
    }
}

class Y : X
{
    public static override Report()
    {
        Consloe.WriteLine(“Пока!”);
    }
}

static class Program
{
    static void Main()
    {
        Type<X> tx = typeof(X);
        tx.Report() // Вывод: «Привет!»
        tx = typeof(Y);
        tx.Report() // Вывод: «Пока!»
    }
}

Чтобы достичь подобного полиморфизма без метаклассов и виртуальных классовых методов, для класса X и каждого из его наследников пришлось бы писать вспомогательный класс с обычным виртуальным методом. Это требует существенно больших усилий, да и контроль со стороны компилятора будет не столь полным, что увеличивает вероятность где-нибудь ошибиться. Между тем ситуации, когда нужен полиморфизм на уровне типа, а не на уровне экземпляра, встречаются регулярно, и если язык поддерживает такой полиморфизм, это очень полезное свойство.

Виртуальные конструкторы


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

class A
{
    public virtual A(int x, int y)
    {
        ...
    }
}

class B : A
{
    public override B(int x, int y)
        : base(x, y)
    {
    }
}

class C : A
{
    public C(int z)
    {
        ...
    }
}

В этом коде класс C не должен откомпилироваться, потому что у него нет конструктора с параметрами int x, int y, а вот класс B откомпилируется без ошибок.

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

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

class A
{
    public virtual A(int x, int y)
    {
        ...
    }
}

class B : A
{
    public override B(int x, int y)
        : base(x, y)
    {
    }
}

static class Program
{
    static void Main()
    {
        Type<A> ta = typeof(A);
        A a1 = ta.CreateInstance(10, 12); // Будет создан экземпляр A
        ta = typeof(B);
        A a2 = ta.CreateInstance(2, 7); // Будет создан экземпляр B
    }
}

Другими словами, мы получаем возможность создавать объекты, тип которых определяется на этапе выполнения. Сейчас это тоже можно делать с помощью Activator.CreateInstance. Но этот метод работает через рефлексию, поэтому правильность набора параметров проверяется только на этапе выполнения. А вот если у нас будут метаклассы, то код с неправильными параметрами просто не откомпилируется. Кроме того, при использовании рефлексии скорость работы оставляет желать лучшего, а метаклассы позволяют свести издержки к минимуму.

Заключение


Меня всегда удивляло, почему Хейлсберг, который является главным разработчиком и Delphi, и C#, не стал делать метаклассы в C#, хотя они так хорошо зарекомендовали себя в Delphi. Может быть, тут дело в том, что в Delphi (в тех версиях, которые делал ещё Хейлсберг) практически полностью отсутствует рефлексия, и альтернативы метаклассам просто нет, чего нельзя сказать о C#. Действительно, все примеры из этой статьи не так сложно переделать, используя только те средства, которые есть в языке уже сейчас. Но всё это будет работать заметно медленнее, чем могло бы с метаклассами, и правильность вызовов будет проверяться во время выполнения, а не компиляции. Так что моё личное мнение — C# сильно выиграл бы, если бы в нём появились метаклассы.

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


  1. Ascar
    19.08.2019 09:48

    Возможно пригодилось бы. Только непонятно если это typeof(A2) типа Type<A2>, то привести к Type<A> не получится из за не вариативности классов.


  1. Griboks
    19.08.2019 10:35

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


    Но идея интересная. Предлагаю вам еще добавить динамическое множественное наследование и написать транспайлер ака TypeSharp.


    1. SoftwareEngineerInTest
      20.08.2019 07:12

      я всегда думала, что c# сам по себе — прикольная штука)
      «динамическое множественное наследование» добавить, что это привнесёт, на ваш взгляд?


      1. Griboks
        20.08.2019 09:23

        Меня всегда удивляло, почему Хейлсберг, который является главным разработчиком и Delphi, и C#, не стал делать динамическое множественное наследование в C#, хотя оно так хорошо зарекомендовало себя в других языках. Все примеры его использования не так сложно переделать, используя только те средства, которые есть в языке уже сейчас. Но всё это будет работать заметно медленнее, чем могло бы с динамическим множественным наследованием, и правильность вызовов будет проверяться во время выполнения, а не компиляции. Так что моё личное мнение — C# сильно выиграл бы, если бы в нём появилось динамическое множественное наследование.


  1. AgentFire
    19.08.2019 11:37

    В этом коде класс C не должен откомпилироваться, потому что у него нет конструктора с параметрами int x, int y, а вот класс B откомпилируется без ошибок.

    В C# класс С тоже не откомпилируется, он тоже обязан вызвать базовый конструктор через : base(..).


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

    Я не вижу ни одного плюса такой непонятной реализации.


    А вот если у нас будут метаклассы, то код с неправильными параметрами просто не откомпилируется.

    Все, что от C# требуется, это добавить Method<T>() where T : class. new (int, string), но мелкомягкие уже что-то вроде "сложно и не нужно" писали на этот счет.


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


    1. darkdaskin
      19.08.2019 14:05

      new T() компилируется в Activator.CreateInstance(typeof(T)), так что особых преимуществ ограничение new с параметрами не даст, можно вызвать Activator.CreateInstance руками. Проверку наличия конструктора на этапе компиляции несложно реализовать с помощью Roslyn analyzer.


    1. Vglk Автор
      20.08.2019 07:49

      Я не вижу ни одного плюса такой непонятной реализации.

      Ну, собственно, плюс не относится непосредственно к теме метаклассов. Меня очень задалбывает одна штука в C#. Представьте, что у вас есть класс, в котором определены десять конструкторов. Вам надо написать наследник этого класса. В наследнике только перекрывается один виртуальный метод. Новые поля и свойства не добавляются, поэтому код инициализации писать не нужно. Но все десять конструкторов вам придётся вручную перекрыть, и код каждого конструктора будет состоять только из вызова аналогичного унаследованного конструктора. Мне очень не нравится такая рутина, тем более что она резко контрастирует с тем, к чему я привык в Delphi, где производный класс автоматически наследовал все конструкторы предка. Например, чтобы объявить свой класс исключения, в Delphi достаточно написать:
      type TMyException = class(TException);

      И всё, все конструкторы TException будут доступны TMyException. Сравните это с тем, как аналогичное объявление будет выглядеть в C#. Поэтому хотелось бы, чтобы хотя бы для классов без новых полей и свойств конструкторы наследовались автоматически, чтобы наоборот, надо было явно указывать, если какой-то унаследованный конструктор не нужен.

      Все, что от C# требуется, это добавить Method<T>() where T: class. new (int, string)

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

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

      А вот с этим не согласен. Мне приходится писать не только готовые приложения, но и библиотеки, которыми пользуются потом другие люди. И нередко возникает необходимость сделать что-то как в WinForms или WPF, где пользователь может написать свой собственный UserControl, а библиотека будет работать с этим классом так же легко, как со своими внутренними классами. Вот здесь и нужна или рефлексия, или метаклассы, или ещё какой-то способ динамически получать метаинформацию о классе и уметь его создавать.


      1. Mabu
        21.08.2019 11:29

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

        Обычно делают так. В библиотеке есть абстрактный базовый класс или интерфейс, который должен реализовать клиент. Клиент сам создаёт наследников этого класса и передаёт в функции библиотеке. Почему такое решение вас не устраивает?


  1. Kanut
    19.08.2019 12:40
    +2

    Классовые методы – это ещё одна конструкция, которая есть в Delphi, но отсутствует в C#. Эти методы при объявлении помечаются словом class и являются чем-то средним между статическими методами и методами экземпляра.

    Я может что-то неправильно понял, но что мешает использовать extension methods?


    1. Vglk Автор
      20.08.2019 07:25

      Методы расширения не могут обеспечить настоящий полиморфизм. Чтобы код мог вызвать такой метод, он должен быть доступен на момент компиляции этого кода. А классовые методы могут быть виртуальными в полном смысле слова, т.е. код умеет работать только с некоторым общим предком, но этого достаточно, чтобы вызывать переопределённые методы, которые будут написаны уже после компиляции этого кода.


      1. Kanut
        20.08.2019 08:19

        Ок, разница понятна. Но если честно у меня всё ещё есть сомнения в необходимости такого в С#. Ну с той точки зрения что не могу вспомнить ни один "use case", где такое могло пригодится и который не решался уже доступными в С# фичами.
        Зато вижу пару "проблем", которые кассовые методы легко могут создать.


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


        1. Vglk Автор
          21.08.2019 08:13

          Зато вижу пару «проблем», которые кассовые методы легко могут создать.

          Это какие же?


          1. Kanut
            21.08.2019 08:40

            Ну например то же самое изменение поведения класса после компиляции :) Это обоюдоострый клинок и с таким всегда нужно быть аккуратным. И не уверен что не будет проблем с юнит-тестингом.

            И да, я согласен что если какая-то фича кажется тебе не особенно безопасной, то можно её просто не использовать. И если бы в C# уже были классовые методы и/или были проблемы/ситуации, которые без них ну вообще не решить, то никаких «претензий» к ним у меня бы не было.

            Но пока же я вижу просто желание добавить что-то в C# чтобы определённой(и на мой взгляд относительно небольшой) группе людей просто было удобнее/привычнее работать с новым языком.


  1. iluxa1810
    19.08.2019 12:50
    +2

    Что-то не представляю когда бы мне эта возможность могла пригодится…


    1. qw1
      19.08.2019 14:26

      В Delphi нет дженериков, и поэтому можно было передавать тип как параметр.

      type
        TAnimal = class
        public
          constructor Create();
        end;
        TCat = class(TAnimal)
        end;
        TDog = class(TAnimal)
        end;
        TAnimalClass = class of TAnimal;
      
      function CreatePet(t: TAnimalClass): TAnimal;
      begin
        result := t.Create();
      end;
      
      cat := CreatePet(TCat);
      dog := CreatePet(TDog);


      В C#, я думаю, достаточно средств, чтобы записать это другими способами
      public interface IAnimalTraits {
          IAnimal Create();
      };
      
      public class DogTraits : IAnimalTraits {
          public IAnimal Create() { return new Dog(); }
      };
      
      
      IAnimal CreatePet(IAnimalTraits t) {
          return t.Create();
      }
      
      var cat = CreatePet(new CatTraits());
      var dog = CreatePet(new DogTraits());


      1. ashumkin
        19.08.2019 19:31

        В Delphi нет дженериков,

        Э? Вы про какую версию Delphi? Они даже в FPC есть


        1. qw1
          19.08.2019 19:37
          -1

          Классическую ))) 3,5,7…


          1. Groramar
            19.08.2019 20:11

            Хмм, забудьте уже про мамонтов :) Дженерики есть и уже очень давно.


    1. darkdaskin
      19.08.2019 14:27

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


      Вместо:


      interface IPlugin
      {
          void DoWork();
      }
      
      class PluginAttribute : Attribute
      {
          public string Name { get; }
          public PluginAttribute(string name)
          {
              Name = name;
          }
      }
      
      [Plugin("My plugin")]
      class MyPlugin : IPlugin
      {
          void DoWork() {};
      }
      
      string GetPluginName(Type type)
      {
          return type.GetCustomAttribute<PluginAttribute>().Name;
      }

      можно будет написать:


      interface IPlugin
      {
          static string Name { get; }
          void DoWork();
      }
      
      class MyPlugin : IPlugin
      {
          static string Name => "My plugin";
          void DoWork() {};
      }
      
      string GetPluginName(Type<IPlugin> type)
      {
          return type.Name;
      }

      Впрочем, есть ещё вариант с вызовом статических методов через рефлексию и реализацией проверки их наличия с Roslyn analyzer.
      Также встречал вариант с указанием класса-дескриптора в атрибуте, экземпляр которого создаётся для получения мета-информации. В этом варианте тоже нужен Roslyn analyzer для проверки, что дескриптор реализует интерфейс.
      Ещё можно сделать виртуальные методы в PluginAttribute, этот вариант позволит вернуть мета-данные любого типа с проверкой на этапе компиляции, но несколько неочевидно в применении.


      1. Kanut
        19.08.2019 14:38

        Хм, а что мешает использовать генерики и реализовать «второй вариант» через них?

        upd. Прошу прощения, неправильно прочитал пример. Тут действительно наверное придётся через рефлекию в том или ином виде.


      1. IL_Agent
        19.08.2019 14:42

        можно будет получать её непосредственно из статических свойств.

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


        1. Vglk Автор
          20.08.2019 08:14

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

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


          1. IL_Agent
            20.08.2019 11:20

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

            Не понял вашу мысль. Все плагины должны реализовывать какой-нибудь контракт IPlugin. А как реализовывать — дело плагина. Вот и всё.


            1. Vglk Автор
              21.08.2019 08:17

              Если бы всё было так просто! В вашем варианте прежде чем начать хоть как-то работать с плагином, надо его создать, получить какую-то информацию о плагине, не создавая его, не получится. Это не всегда удобно — процесс создания плагина может быть дорогим или проводить к изменениям в пользовательском интерфейсе. А ещё это лишние операции выделения и освобождения динамической памяти, чего я очень не люблю, так как они, особенно при многократном повторении, становятся достаточно затратными.


              1. IL_Agent
                21.08.2019 10:43

                И не надо усложнять. Вам уже ответили про фабрику, как вариант. Она может быть скрыта в реализации плагина. Например
                IPlugin
                {
                void Init();
                }
                В методе инит вся тяжелая логика, она может быть в подкапотной фабрике, может не быть — детали реализации. Что касается цены, метакласс тоже не бесплатный, и неизвестно, что в итоге будет дешевле.


                1. Vglk Автор
                  23.08.2019 08:29

                  Про фабрику как вариант я и сам написал в статье. И написал, почему этот вариант меня не устраивает. Главная претензия — проверки того, что плагин вместе с фабрикой выполняет все требования контракта приходится переносить с этапа компиляции на этап выполнения. Классовые методы и метаклассы могли бы формализовать контракт настолько, что его мог бы проверить компилятор.

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

                  Есть некоторый универсальный класс

                  public class Container<T> where T : BaseT
                  где BaseT — некоторый абстрактный тип. Типы Container и BaseT описаны в библиотеке, наследников BaseT и производные от универсального Container будет создавать пользователь библиотеки, на этапе компиляции библиотеки они неизвестны. Есть задачи, требующие метаописания класса Container<T>, причём это метаописание зависит от того, как реализован класс T, т.е. для него тоже нужно метаописание (например, это нужно, чтобы правильно распарсить строку, в которой хранится значение Container<T>; реализовать парсинг статическим методом Container<T> не очень удобно, потому что такой метод потом без рефлексии не вызовешь, так как вызывающий код в общем случае не знает, с какой именно производной от Container<T> ему придётся работать, а рефлексия нежелательна из-за медленной скорости).

                  Имея классовые методы, я бы сделал очень просто. В классе BaseT объявил бы абстрактный классовый метод для получения метаинформации. Соответственно, в любом потомке BaseT обязательно надо было бы перекрывать его. Далее, я сделал бы абстрактного неуниверсального предка ContainerBase для Container<T>, в котором тоже объявил бы абстрактный классовый метод для метаинформации, а в Container<T> реализовал бы этот метод с учётом метаинформации о типе T. И когда возникала бы необходимость получить информацию о конкретной производной от Container<T>, я бы получил её, вызвав этот классовый метод через метакласс для ContainerBase, и за счёт полиморфизма получил бы информацию о нужном классе. Бинго!

                  Как пришлось реализовывать это имеющими средствами. Во-первых, для наследников BaseT придуманы атрибуты, с помощью которых разработчик, создающий этих наследников, описывает метаинформацию для своего класса. Container<T> реализован так (ContainerInfo — это некоторый тип, содержащий информацию о нём):
                  
                  public abstract ContainerBase
                  {
                      protected static Dictionary<Type, ContainerInfo> meta
                          = new Dictionary<Type, ContainerInfo>();
                  
                      public static IReadOnlyDictionary<Type, ContainerInfo> Meta
                          { get => meta; }
                  }
                  
                  public class Container<T> : ContainerBase
                  {
                      static Container()
                      {
                           ContainerInfo info = new ContainerInfo();
                           // Здесь анализируются атрибуты класса T
                           // и заполняются свойства info
                           meta[typeof(Container<T>)] = info;
                      }
                  }
                  

                  Чтобы получить метаинформацию о некотором контейнере, тип которого содержит переменная Type t, нужно выполнить конструкцию
                  ContainerBase.Meta[t]

                  Это вполне работоспособно, хотя выглядит несколько коряво. Но главное, что мне не нравится, это то, что компилятор даже не чихнёт, если забыть назначить наследнику BaseT нужные атрибуты, это вскроется только во время выполнения программы.

                  Вот такая задача из реальной жизни. Есть у вас вариант решения такой задачи?


                  1. Deosis
                    23.08.2019 09:55

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


                  1. IL_Agent
                    23.08.2019 11:36

                    Возможно, не до конца вник в вашу задачу, но почему вы зациклились именно на мета? Вот решение без статиков и атрибутов. Добавляем в BaseT абстрактный метод(свойство), который предоставляет нужную для работы с ним информацию, и дёргаем его в контейнере, когда нам эта информация понадобилась.
                    Статики вообще надо использовать по минимуму.


                    1. a-tk
                      23.08.2019 11:50

                      Поддержу предыдущего оратора.
                      Гражданин Мартин, который Роберт Мартин, описывая смысл архитектуры, исходит из того, что в хорошей архитектуре основная идея — отложить решения о деталях. Статики — они про раннее связывание в любом случае.


                  1. qw1
                    23.08.2019 15:21

                    Есть у вас вариант решения такой задачи?
                    Есть довольно прямолинейный способ: без статиков, без контейнера, с полной проверкой в compile-time

                    Spoiler header
                        public abstract class BaseT
                        {
                        }
                    
                        public abstract class BaseMeta<T>
                            where T : BaseT
                        {
                            public abstract string GetInfo();
                        }
                    
                        public class SimpleT : BaseT
                        {
                        }
                    
                        public class SimpleMeta : BaseMeta<SimpleT>
                        {
                            public override string GetInfo()
                            {
                                return "It's simple";
                            }
                        }
                    
                        public class Container<T, TMeta>
                            where T : BaseT
                            where TMeta : BaseMeta<T>, new()
                        {
                            public static string GetMeta()
                            {
                                return new TMeta().GetInfo();
                            }
                        }
                    


                    1. qw1
                      23.08.2019 15:24

                      Метакласс можно и закешировать, это ещё уменьшит оверхед, и наверное, сделает его сопоставимым с дельфийским метаклассом

                      Spoiler header
                          public class Container<T, TMeta>
                              where T : BaseT
                              where TMeta : BaseMeta<T>, new()
                          {
                              static readonly TMeta meta = new TMeta();
                      
                              public static string GetMeta()
                              {
                                  return meta.GetInfo();
                              }
                          }
                      


                    1. a-tk
                      23.08.2019 15:28
                      +1

                      А ещё можно сделать BaseT интерфейсом, SimpleT структурой, а на контейнер наложить соответствующие ограничения, и тогда memory traffic ещё снизится.


          1. qw1
            20.08.2019 16:48

            Зачем сопоставлять плагины и фабрики?

            Программа работает только с фабрикой, и напрямую класс плагина не знает, и он ей не нужен. Вся метаинформация о классе плагине, которая должна быть доступна до создания экземпляра плагина, должна быть доступна через фабрику.


            1. Vglk Автор
              21.08.2019 08:39

              Фабрики класса мне не нравятся по следующим причинам:

              1. Это дополнительная нагрузка на того, кто будет писать плагины: надо написать не только плагин, но и фабрику к нему.
              2. Встаёт вопрос о том, как создавать сами фабрики. Они создаются либо через рефлексию со всеми вытекающими отсюда последствиями, либо ответственность за их создание переносится на разработчиков плагинов, что ещё больше усложняет работу с библиотекой.
              3. Экземпляры фабрик — это затраты на их хранение в памяти, перемещение при компрессии и т.п. Если этого можно избежать, почему бы этого не сделать (правда, этот пункт спорный — он связан с тем, что я ещё помню, как программировать на ZX Spectrum с 48 кБ памяти, поэтому стараюсь её если не экономить, то хотя бы не использовать совсем уж бездумно).
              4. Ну и, наконец, главное — у нас появляются две сущности — класс и его фабрика, которые тесно связаны между собой. В общем случае фабрика должна уметь сообщить, объекты какого класса она создаёт, а класс — какая фабрика ему нужна. И то, что они о себе сообщают, должно соответствовать тому, как они реализованы. Это заставляет разработчика плагина каждый раз выполнять рутинную работу, в которой легко сделать ошибку, не отлавливаемую компилятором. Я же предпочитаю избегать рутинной работы, пусть такие вещи делает сам компьютер. Или хотя бы пусть он проверяет, не сделал ли я какую-нибудь глупую ошибку. Метаклассы — это шаг как раз в таком направлении.

              И вопрос к вам. UserControl в WinForms и WPF — это, по сути, тот же плагин. Но разработчики этих библиотек предпочли обойтись без фабрик. Как вы думаете, почему? И было ли бы лично вам удобнее, если бы это реализовали через фабрики?


              1. Deosis
                21.08.2019 09:56

                1. Затраты одинаковы. Писать статические методы в классе плагина, либо методы в классе фабрики.
                2. Хоть один класс придется искать.
                3. Фабрика не обязана содержать огромное состояние. Экземпляр будет занимать всего 12 байт (24 на х64)
                4. В общем случае у фабрики будет всего один обязательный метод: IPlugin Create(); А плагин о фабрике не будет знать ничего, это не его обязанность.

                UserControl — просто базовый класс, используемые наследники известны на этапе компиляции.


                1. Vglk Автор
                  23.08.2019 08:39

                  Прошу прощения, с WinForms я лоханулся — вылетело из головы, что эта библиотека устроена не так, как дельфийская VCL, и при программировании на WinForms дизайнер сразу пишет код, где создаются нужные объекты. В VCL дизайнер пишет информацию о размещённых контролах в ресурсы, а при запуске программа их разбирает, там действительно без метаинформации никак.

                  Если вас интересует дальнейшая дискуссия, то прошу продолжить её в ветке ответа habr.com/ru/post/464141/#comment_20540683 — там я изложил новые аргументы.


              1. qw1
                21.08.2019 15:58
                +1

                И вопрос к вам. UserControl в WinForms и WPF — это, по сути, тот же плагин.
                Это плагин для IDE, но не плагин для программы, куда включен UserControl.
                Но разработчики этих библиотек предпочли обойтись без фабрик
                И без метаклассов.
                Как вы думаете, почему?
                Потому что нет требований к производительности. В IDE можно пользоваться рефлексией.
                И было ли бы лично вам удобнее, если бы это реализовали через фабрики?
                Тут вопрос требований. Если нужно создавать миллион объектов в секунду, без фабрики не обойтись. Если плагин загружается однократно при старте, тут открытый вопрос — ускорить загрузку, используя фабрики, или добавить удобства разработчикам плагинов.


                1. Vglk Автор
                  23.08.2019 08:39

                  Прошу прощения, с WinForms я лоханулся — вылетело из головы, что эта библиотека устроена не так, как дельфийская VCL, и при программировании на WinForms дизайнер сразу пишет код, где создаются нужные объекты. В VCL дизайнер пишет информацию о размещённых контролах в ресурсы, а при запуске программа их разбирает, там действительно без метаинформации никак.

                  Если вас интересует дальнейшая дискуссия, то прошу продолжить её в ветке ответа habr.com/ru/post/464141/#comment_20540683 — там я изложил новые аргументы.


  1. IL_Agent
    19.08.2019 14:36

    не там ответил


  1. IL_Agent
    19.08.2019 14:41

    Для меня осталось нераскрытым, таки зачем это может пригодиться в C#?
    Перегрузка статических методов? Если есть разные реализации какой-либо абстракции, то про статику нужно забыть.
    Виртуальный конструктор? Паттерн фабрика.
    Кажется, правильно не стали затаскивать.

    Может быть, тут дело в том, что в Delphi (в тех версиях, которые делал ещё Хейлсберг) практически полностью отсутствует рефлексия, и альтернативы метаклассам просто нет, чего нельзя сказать о C#.

    Скорее всего.
    А в целом интересно, спасибо)


  1. a-tk
    19.08.2019 21:56

    Класс как объект высшего порядка… Есть наверное во всех динамических языках, где есть классы. Потому как можно творить любую дичь на этапе выполнения. Забавно, что в Delphi она была во время компиляции. Хотя в Pascal были и другие плюшки в системе типов, по которым я иногда скучаю, например enumerated types, subranges.


  1. khim
    20.08.2019 00:05

    Не знаю, всё это выглядит больше как пародия на метаклассы. Почему у вас метакласс — не класс?

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

    Если уж в C# и затаскивать метаклассы, то было бы разумно сделать их так, как в языках, где они таки являются классами. И используются, в частности, при создании, собственно, классов. Это же, собственно, стандарт — в большинстве языков, где есть метаклассы классы — это обычные объекты, а метаклассы, соотвественно, обычные классы. Delphi и Java/.NET — два ущербных варианта (но ущербных по разному).

    Из распространённых — можете посмотреть, хотя бы, на Python

    P.S. Вообще метаклассы в Delphi (как и многое другое в современном Delphi) оставляют «неприятный привкус во рту». Зачем вообще понятие «конструктор»? Почему это не просто функция класса (возможно виртуальная)? Да, я знаю ответ (потому что так исторически сложилось)… и тем не менее — это некрасиво и запутанно.


    1. Vglk Автор
      20.08.2019 07:51

      Почему у вас метакласс — не класс?

      Как же не класс? Он у меня наследник типа Type, а Type — это класс. Или вы что-то другое имели ввиду?


      1. khim
        20.08.2019 11:47

        Я имею в виду, что его методы не описываются как методы класса, что у него не может быть своего состояния и так далее. То есть это — не обычный класс, который управляет другим классом, а некоторая странная конструкция, которую компилятор собирает «в кучку» из разрозненных кусков.

        Ну вот смотрите как это в Python сделано:

        class Meta(type):
            def __new__(cls, name, bases, dct):
                x = super().__new__(cls, name, bases, dct)
                x.attr = 100
                x.foo = lambda a : a * a
                return x
        
        class Foo(metaclass=Meta):
            pass
        
        print(Foo.attr)
        # 100
        print(Foo.foo(2))
        # 4
        То есть метакласс — это просто класс, ну вот совершенно обычный класс — с одном исключением: он используется тогда, когда другой класс создаётся. И может там понаделать конструкторов, деструкторов и массу чего ещё — в принципе все возможности языка могут быть использованы.

        В C++ метаклассов (пока?) нету — но пропозал следует той же идее.

        А в Delphi (и у вас) метакласс — это что-то такое странное, создаваемое компилятором только для того, чтобы сделать наследование конструкторов и виртуальные конструкторы (без явных фабрик), фактически.


        1. Vglk Автор
          21.08.2019 08:43

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

          А так даже интересно получается: если метаклассы — это частный случай классов, можно ведь создавать метаклассы для метаклассов, правильно? Не знаю, зачем это может понадобиться, но иметь средства, достаточно гибкие для того, чтобы реализовать такое, мне бы понравилось.


          1. khim
            21.08.2019 11:31

            А так даже интересно получается: если метаклассы — это частный случай классов, можно ведь создавать метаклассы для метаклассов, правильно?
            Да, конечно. Цепочка не уходит в бесконечность, впрочем, так как у стандартного класса Metaclass его metaclass — это тоже Metaclass.

            Не знаю, зачем это может понадобиться, но иметь средства, достаточно гибкие для того, чтобы реализовать такое, мне бы понравилось.
            На LISP посмотрите. Собственно там метаклассы и появились много лет назад. И да — это имеет смысл. Например для AST вам потребуются десятки типов — и при этом у них могут быть разные метаклассы. Если из окажется много, то можно создать и метакласс для них… на практике я нечто подобное видел только в FORTH: классов и метаклассов там нет, зато есть «компилирующие слова»… и иногда там бывают трёх-четырёхступенчатые иерархии…

            P.S. Но вообще у этих технологий есть проблема: примерно у 5-10% при взгляде на них «загораются глаза», они говорят «ух как клёво» и начинают пользовать. Но 90%-95% ничего не готовы изучать и так далее. А так как решает-таки большинство, то… имеем то, что имеем.


  1. IP-human
    20.08.2019 07:12

    Непонятно зачем всё это нужно. Тема для чего это нужно не раскрыта совсем.

    Вообще говоря в язык можно понапихать огромное количество фич, с «Дом Советов». Язык распухнет до невозможности, его будет трудно читать и использовать, так что каждая фича должна быть обоснована и выверена. Кроме метаклассов ведь можно еще огромное количество фич включить в С#, они и включаются потому что они более очевидно полезные чем метаклассы, так что C# уже давно не тот компактный язык.


    1. Vglk Автор
      20.08.2019 08:05

      Непонятно зачем всё это нужно. Тема для чего это нужно не раскрыта совсем.

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


  1. varus
    20.08.2019 07:12

    Чтобы достичь подобного полиморфизма без метаклассов и виртуальных классовых методов, для класса X и каждого из его наследников пришлось бы писать вспомогательный класс с обычным виртуальным методом


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

    Правильно ли я понял, что решаемая проблема крайне похожа на double-dispatching, реализуемый, в частности, паттерном Visitor?


    1. Vglk Автор
      20.08.2019 08:03

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

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

      Правильно ли я понял, что решаемая проблема крайне похожа на double-dispatching, реализуемый, в частности, паттерном Visitor?

      Только частично. Паттерн Visitor, насколько я его знаю, ориентирован на работу, во-первых, с уже созданными объектами, а во-вторых, с объектами из фиксированного перечня классов. Вопросы создания объектов и динамического расширения перечня классов он не покрывает.

      Как я уже писал в другом комментарии, все эти штуки предназначены, в первую очередь, для написания библиотек типа WinForms, при использовании которого можно создать свой UserControl, а библиотека будет с ним работать как с родным. В частности, на метаклассах построена библиотека VCL — дельфийский аналог WinForms (точнее, метаклассы придуманы в Delphi для того, чтобы можно было написать VCL).


      1. a-tk
        20.08.2019 08:15

        VCL — дельфийский аналог WinForms

        Если точнее, то дельфийский праобраз, учитывая кто дизайнил C# и WinForms.