понедельник, 2 марта 2015 г.

Закрытый конструктор базового класса

Вопрос: может ли конструктор абстрактного базового класса Base быть закрытым? Возможно ли в этом случае создать класс-наследник Derived и его экземпляр?

abstract class Base
{
   
// WTF! Что с этим делать!
    private
Base()
    {
       
Console.WriteLine("B.ctor");
    }
}

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

.

.

.

.

.

Давайте перебирать варианты решения.

Рефлекшн? Не походит, нам же не просто нужно вызвать закрытый конструктор, нам нужно создать класс наследник, который бы вызывал закрытый конструктор. Можно попробовать его сгенерировать в рантайме с помощью Reflection.Emit, но не факт, что это поможет (на самом деле, не поможет). Кто кроме текущего класса может иметь доступ к закрытым членам? Только он сам, рефлекшн, и ... вложенные классы.

Вменяемый вариант решения заключается в использовании вложенных (inner) классов.

abstract class Base
{
   
private
Base()
    {
       
Console.WriteLine("B.ctor"
);
    }

   
class Derived : Base
    { }

   
class AnotherDerived : Base
    { }

   
// Фабричный метод скрывает наследников!
    public static Base Create(string
args)
    {
       
return (args.Length % 2 == 0) ? (Base)new Derived() : new AnotherDerived();
    }
}

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

Но это был вменяемый способ. Теперь переходим к невменяемому способу от @vreshetnikov.

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

В настоящий момент, компилятор языка C# не особо настаивает на вызове конструктора базового класса. Ну, он прилагает все усилия, чтобы вы таки это сделали, но остается небольшая лазейка. Вот она:

class Derived : Base
{
   
private Derived(int
n)
        :
this(n.
ToString())
    { }

   
private Derived(string
s)
        :
this(42)
    { }
}

Класс Derived объявлен вне базового класса Base, поэтому он не имеет доступ к его закрытому конструктору. Но данный код успешно компилируется, поскольку с точки зрения компилятора – все хорошо (точнее, с точки зрения текущей версии компилятора).

Ок. Первая задача – создать наследника класса Base, выполнена. Теперь нужно создать его экземпляр.

Тут нужен еще один трюк. Мы не можем обратиться к конструктору базового класса, но нужно как-то сохранить себя (this) во внешнем контексте. Есть один такой способ! Вот тут и пригодится воскрешение.

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

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

class Derived : Base
{
   
private Derived(int
n)
        :
this(n.
ToString())
    { }

   
private Derived(string
s)
        :
this
(Throw())
    { }

   
~
Derived()
    {
        _alive
.Add(this
);
    }

   
public static Task<Derived>
Create()
    {
       
Action a = () =>
        {
           
try { new Derived(42
); }
           
catch
(CookieException) { }
        };

       
// Избавляемся от локальной переменной!
        a();

       
GC.
Collect();
       
GC.
WaitForPendingFinalizers();
       
GC.
Collect();

       
return Task.FromResult(_alive.
Take());
    }

   
private static int
Throw()
    {
       
throw new
CookieException();
    }

   
private static readonly BlockingCollection<Derived> _alive =
        new BlockingCollection<Derived>();
}

Кода довольно много и он кажется страшным, но все не так плохоJ. Основная магия происходит в статическом фабричном методе, который с помощью анонимного метода пытается создать экземпляр класса Derived(). В процессе создания, вызывается конструктор Derived(int), который вызывает Derived(string), который вызывает Throw().

Наличие анонимного метода обусловлено тем, что при наличии отладчика время жизни локальных объектов продлевается до конца метода. А значит, даже при вызове конструктора в форме new Derived(42) и не сохраняя результат в локальных переменных, объект все равно будет жить до конца текущего метода. Выделение анонимного метода решает эту проблему. (Подробнее об этом можно прочитать в статейке О сборке мусора и достижимости объектов).

Теперь переходим к финализатору. Он использует BlockingCollection в качестве хранилища root-объектов: финализатор помещает объект в очередь, а метод Create ожидает его появления путем вызова метода Take.

Все, теперь код вида Derived derived = await Derived.Create() позволяет создавать невменяемый экземпляр класса наследника, базовый класс которого имеет закрытый конструктор!

З.Ы. Второй способ – это один большой хак, который перестанет работать в будущих версиях языка C#. Мы получаем экземпляр, который толком не сконструирован, поскольку не был вызван ни конструктор базового класса, ни инициализаторы класса наследника. По большому счету, он ничем не отличается от вызова FormatterServices.GetUnitializedObject, который применяется при десериализации объектов.

15 комментариев:

  1. Если есть возможность сделать вложенный класс, почему бы просто не исправить модификатор доступа конструктора базового?

    ОтветитьУдалить
    Ответы
    1. Первый случай - вполне валидный сценарий, который позволяет очень жестко контролировать наследников.

      Ну а второй случай - это же полнейший brain teaser.

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

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

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

      Ну а второй случай - это просто тотальный хак.

      Удалить
    4. Я Мураду вообще-то отвечал.

      Удалить
    5. hazzik, ты предложил то же, что и я

      Удалить
  2. Могу предложить совсем хардкорный путь: в теории можно руками в памяти создать нужный класс. Скажем, можно взять созданный Dervied класс с двумя конструкторами из начала второго способа, а потом самостоятельно добраться до таблицы методов и исправить проблему. Правда, для этого нам нужен FullyTrusted-домен.

    ОтветитьУдалить
  3. Есть ещё один вариант: если мы знаем как выглядит внутри базовый класс, то мы можем создать его «публичного двойника», которого бы мы могли создать, а потом просто подменили бы ссылку на тип. Вот рабочий код: https://gist.github.com/AndreyAkinshin/95ebe7ce5b964fc6930c

    ОтветитьУдалить
    Ответы
    1. Впрочем, нам даже не нужно знать точное устройство класса в памяти. Достаточно знать максимальный размер класса Derived. Обновил гист.

      Удалить
    2. Этот комментарий был удален автором.

      Удалить
    3. Этот комментарий был удален автором.

      Удалить
    4. Хороший вариант:))
      "unions" в .NET-е - это ящик Пандоры какой-то:)

      Удалить
  4. В спеке шарпа вложенные классы называются не inner, а nested https://msdn.microsoft.com/en-us/library/ms173120.aspx

    ОтветитьУдалить
  5. А зачем во втором примере с трюком таски прикручены? Они как-то сказываются на рабочести трюка с бросанием исключения?

    ОтветитьУдалить
    Ответы
    1. Таски прикручены, чтобы показать немоментальность процесса создания. GC триггерится руками, но все равно две сборки мусора пережить - это процесс не мгновенный.

      Удалить