Вызываем конструктор базового типа в произвольном месте +14




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

Утверждение оказалось ложью, враньем и провокацией
image

Но это уже не имело значения, потому что вызов был принят.

image

Дисклеймер
Приведенные ниже приемы не рекомендуется использовать в реальной жизни. Точнее даже рекомендуется не использовать. Это скорее тема для легкого светского разговора с коллегой. Или собеседующим.

Подготовка


Создаем цепочку наследования. Для простоты будем использовать конструкторы без параметров. В конструкторе будем выводить информацию о типе и идентификатор объекта, на котором он вызывается.

public class A
{
    public A()
    {
        Console.WriteLine($"Type '{nameof(A)}' .ctor called on object #{GetHashCode()}");
    }
}

public class B : A
{
    public B()
    {
        Console.WriteLine($"Type '{nameof(B)}' .ctor called on object #{GetHashCode()}");
    }
}

public class C : B
{
    public C()
    {
        Console.WriteLine($"Type '{nameof(C)}' .ctor called on object #{GetHashCode()}");
    }
}

Запускаем программу:

class Program
{
    static void Main()
    {
        new C();
    }
}

И получаем вывод:

Type 'A' .ctor called on object #58225482
Type 'B' .ctor called on object #58225482
Type 'C' .ctor called on object #58225482

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

public A() : this() { } // CS0516  Constructor 'A.A()' cannot call itself

и таким фокусом компилятор тоже не провести:

public A() : this(new object()) { }
public A(object _) : this(0) { }
public A(int _) : this() { } // CS0768  Constructor 'A.A(int)' cannot call itself through another constructor

Удаление дублирующегося кода


Добавляем вспомогательный класс:

internal static class Extensions
{
    public static void Trace(this object obj) =>
        Console.WriteLine($"Type '{obj.GetType().Name}' .ctor called on object #{obj.GetHashCode()}");
}

И заменяем во всех конструкторах

Console.WriteLine($"Type '{nameof(...)}' .ctor called on object #{GetHashCode()}");

на

this.Trace();

Однако теперь программа выводит:

Type 'C' .ctor called on object #58225482
Type 'C' .ctor called on object #58225482
Type 'C' .ctor called on object #58225482

В нашем случае можно использовать следующую хитрость. Кто знает о типах времени компиляции? Компилятор. А еще он выбирает перегрузки методов на основе этих типов. И для обобщенных типов и методов генерирует сконструированные сущности тоже он. Поэтому возвращаем правильный вывод типов, переписав метод Trace следующим образом:

public static void Trace<T>(this T obj) =>
    Console.WriteLine($"Type '{typeof(T).Name}' .ctor called on object #{obj.GetHashCode()}");

Получение доступа к конструктору базового типа


Здесь на помощь приходит рефлексия. Добавляем в Extensions метод:

public static Action GetBaseConstructor<T>(this T obj) =>
    () => typeof(T)
          .BaseType
          .GetConstructor(Type.EmptyTypes)
          .Invoke(obj, Array.Empty<object>());

В типы B и C добавляем свойство:

private Action @base => this.GetBaseConstructor();

Вызов конструктора базового типа в произвольном месте


Меняем содержимое конструкторов B и C на:

this.Trace();
@base();

Теперь вывод выглядит так:

Type 'A' .ctor called on object #58225482
Type 'B' .ctor called on object #58225482
Type 'A' .ctor called on object #58225482
Type 'C' .ctor called on object #58225482
Type 'A' .ctor called on object #58225482
Type 'B' .ctor called on object #58225482
Type 'A' .ctor called on object #58225482

Изменение порядка вызова конструкторов базового типа


Внутри типа A создаем вспомогательный тип:

protected class CtorHelper
{
    private CtorHelper() { }
}

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

Добавляем в A, B и C соответствующие конструкторы:

protected A(CtorHelper _) { }
protected B(CtorHelper _) { }
protected C(CtorHelper _) { }

Для типов B и C ко всем конструкторам добавляем вызов:

: base(null)

В результате классы должны выглядеть так
internal static class Extensions
{
    public static Action GetBaseConstructor<T>(this T obj) =>
        () => typeof(T)
        .BaseType
        .GetConstructor(Type.EmptyTypes)
        .Invoke(obj, Array.Empty<object>());

    public static void Trace<T>(this T obj) =>
        Console.WriteLine($"Type '{typeof(T).Name}' .ctor called on object #{obj.GetHashCode()}");
}

public class A
{
    protected A(CtorHelper _) { }

    public A()
    {
        this.Trace();
    }

    protected class CtorHelper
    {
        private CtorHelper() { }
    }
}

public class B : A
{
    private Action @base => this.GetBaseConstructor();

    protected B(CtorHelper _) : base(null) { }

    public B() : base(null)
    {
        this.Trace();
        @base();
    }
}

public class C : B
{
    private Action @base => this.GetBaseConstructor();

    protected C(CtorHelper _) : base(null) { }

    public C() : base(null)
    {
        this.Trace();
        @base();
    }
}

И вывод становится:

Type 'C' .ctor called on object #58225482
Type 'B' .ctor called on object #58225482
Type 'A' .ctor called on object #58225482

Наивный простачок думает, что обманул компилятор
image

Осмысление результата


Добавив в Extensions метод:

public static void TraceSurrogate<T>(this T obj) =>
    Console.WriteLine($"Type '{typeof(T).Name}' surrogate .ctor called on object #{obj.GetHashCode()}");

и вызвав его во всех конструкторах, принимающих CtorHelper, мы получим вывод:

Type 'A' surrogate .ctor called on object #58225482
Type 'B' surrogate .ctor called on object #58225482
Type 'C' .ctor called on object #58225482
Type 'A' surrogate .ctor called on object #58225482
Type 'B' .ctor called on object #58225482
Type 'A' .ctor called on object #58225482

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




Комментарии (11):

  1. kekekeks
    /#21932102 / +1

    Вообще такие вещи обычно делают через самописный Fody-weaver на пару десятков строк, меняющий место вызова базового конструктора в сгенерированном MSIL.

    • nightwolf_du
      /#21932126 / +1

      А можно пример реальной ситуации, когда это нужно?

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

      • Saladin
        /#21932212

        Сдаётся мне, что такое может понадобится в случае наследования от third-party класса. Например, если нужно внедрить свой hook до инициализации его состояния

      • kekekeks
        /#21932312

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

  2. AgentFire
    /#21932292

    Я бы тоже хотел увидеть пример ситуации, когда это реально нужно.

    • kraidiky
      /#21934082 / +3

      А вот я бы никогда не хотел сталкиваться с ситуацией когда это кому-то понадобилось. В том проекте, который мы сейчас делаем америкосы в C++ ной библиотеке переопределили new и delete, и один наш разработчик уже третий день с матами это из их кода выковыривает, потому что условия поменялись и грязный хак больше не работает. Тут точно та же ситуация.

  3. sparhawk
    /#21932624

    в Java конструктор базового типа можно вызвать в любом месте конструктора производного типа


    Это вряд ли. Конструктор базового класса всегда вызывается до выполнения конструктора производного. Даже сам оператор вызова конструктора базового типа «фейковый»: он лишь определяет с какими аргументами он будет вызываться, он отделен от остального кода конструктора просто пропастью:

    class B extends A {
        int a = 123;
        int b;
        B(int c) {
            // тут под класс B только выделена память, а сам он еще не инициализирован.
            // Его нестатические поля нельзя использовать внутри A(...) !!!
            super(c); // Это не вызов A(), а всего лишь указание, с какими параметрами вызвать A(). A() будет вызван в любом случае ДО выполнения B()
            // тут пропасть, в которой инициализируется класс B,
            // так что дальше его нестатические поля уже можно использовать
            b = a + c;
        }
    }
    


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

    • xapienz
      /#21935428

      Конструктор базового класса всегда вызывается до выполнения конструктора производного.

      Не совсем, всё-таки конструктор производного вызывает конструктор базового.
      Для проверки возьмём код:


      class Base {
        public Base() {
          System.out.println("Base()");
        }
      }
      
      class Child extends Base {
        private Child(Object o) {
          super();
        }
      
        public Child() {
          this(doSomething());
          System.out.println("Child()");
        }
      
        private static Object doSomething() {
          System.out.println("do something");
          return null;
        }
      }
      
      public class Main {
        public static void main(String[] args) {
          new Child();
        }
      }

      Запустим:


      $ java Main
      do something
      Base()
      Child()

      Видим, что мы смогли выполнить статический метод до вызова конструктора базового класса.
      Посмотрим скомпилированный класс:


      $ javap -p -c Child
      Compiled from "Main.java"
      class Child extends Base {
        private Child(java.lang.Object);
          Code:
             0: aload_0
             1: invokespecial #1    // Method Base."<init>":()V
             4: return
      
        public Child();
          Code:
             0: aload_0
             1: invokestatic  #2    // Method doSomething:()Ljava/lang/Object;
             4: invokespecial #3    // Method "<init>":(Ljava/lang/Object;)V
             7: getstatic     #4    // Field java/lang/System.out:Ljava/io/PrintStream;
          <...>
      }

      Видим, что статический метод вызывается раньше конструктора (invokestatic перед invokespecial), но при этом он находится внутри конструктора дочернего класса.

  4. Matisumi
    /#21932856 / +1

    Ах, эти бесполезные на практике, но такие «показательные» вопросы на собеседованиях!

  5. Dima_Sharihin
    /#21933440 / +1

    То ли дело C++, берешь вызываешь себе placement new где хочешь

  6. ForNeVeR
    /#21937288

    Конструктор родительского класса можно вызывать в любом месте конструктора дочернего класса в JS и CoffeeScript.


    Однажды я на это очень хорошо напоролся, когда переводил кодовую базу с CoffeeScript на одну из относительно ранних версий TypeScript, в которой так делать было нельзя (по крайней мере, тогда; как сейчас — уже не знаю). Пришлось довольно нефигово приседать, чтобы пофиксить все связанные с такой миграцией проблемы (да, само собой как-то нечаянно получилось, что код оказался завязан на это поведение; самому неприятно).


    Во всяком случае, точно могу сказать, что проблема не надуманная, а в рабочем (допустим, не вполне аккуратно написанном) коде сама собою образовалась.