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

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

Мой вопрос: а что, если виртуальный метод - это тот, кто инициализирует состояние объекта? Это хорошая практика или это должен быть двухэтапный процесс, сначала для создания объекта, а затем для загрузки состояния?

Первый вариант: (использование конструктора для инициализации состояния)

public class BaseObject {
    public BaseObject(XElement definition) {
        this.LoadState(definition);
    }

    protected abstract LoadState(XElement definition);
}

Второй вариант: (с использованием двухэтапного процесса)

public class BaseObject {
    public void LoadState(XElement definition) {
        this.LoadStateCore(definition);
    }

    protected abstract LoadStateCore(XElement definition);
}

В первом методе потребитель кода может создать и инициализировать объект с помощью одного оператора:

// The base class will call the virtual method to load the state.
ChildObject o = new ChildObject(definition)

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

ChildObject o = new ChildObject();
o.LoadState(definition);

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

Rune Grimstad 16.01.2009 10:29

Дочерний класс еще не инициализирован. Конструкторы базового класса выполняются перед конструкторами дочерних классов. Если конструкторы дочернего класса нетривиальны, состояние объекта будет некогерентным.

Gorpik 16.01.2009 11:19

Нет ничего плохого в вызове ВИРТУАЛЬНОЙ функции в конструкторе. Скорее, то, что вы имеете в виду, должно быть АБСТРАКТНОЙ функцией, верно? Итак, ваш вопрос кажется ошибочным.

Özgür 16.01.2009 11:44

@Comptrol Я не вижу разницы между виртуальными и абстрактными методами. В обоих случаях он позволяет базовому конструктору использовать дочерний объект в недопустимом состоянии.

Yona 16.01.2009 16:12

Я написал про эта проблема здесь вместе со скриншотами и кодом.

m.edmondson 04.09.2012 21:33
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
32
5
17 218
9
Перейти к ответу Данный вопрос помечен как решенный

Ответы 9

Ответ принят как подходящий

(Этот ответ относится к C# и Java. Я считаю, что C++ работает по-разному в этом вопросе.)

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

Я бы по возможности постарался этого избежать, но не загибая конструкцию чрезвычайно. (Например, опция «инициализировать позже» запрещает неизменяемость.) Если вы делать используете виртуальный метод в конструкторе, строго задокументируйте его очень. Пока все участники осведомлены о том, что делают, это не должно вызывать у тоже много проблем. Я бы попытался ограничить видимость, как вы это сделали в своем первом примере.

Обновлено: Здесь важно то, что существует разница между C# и Java в порядке инициализации. Если у вас есть такой класс, как:

public class Child : Parent
{
    private int foo = 10;

    protected override void ShowFoo()
    {
        Console.WriteLine(foo);
    }
}

где конструктор Parent вызывает ShowFoo, в C# он будет отображать 10. Эквивалентная программа в Java отобразит 0.

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

Scott Dorman 15.01.2009 23:16

@Scott: Согласен. Это может быть довольно неуловимой причиной ошибок.

Jon Skeet 15.01.2009 23:24

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

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

"Функции-члены могут быть вызваны из конструктора (или деструктора) абстрактного класса; эффект от виртуального вызова (class.virtual) чистой виртуальной функции прямо или косвенно для объекта, создаваемого (или уничтожаемого) из такого конструктора ( или деструктор) не определено. "

чистый или не чистый - это просто контракт компиляции, на практике он ничего не меняет в поведении. Проблема только в том, что указатель v-table указывает на таблицу конструируемого класса. Итак, сначала база. Это означает, что вы получаете вызываемые функции текущего уровня, а не производные переопределения. Что все равно является проблемой в чистом и нечистом случаях. Другое дело, если у вашей чистой виртуальной функции нет определения, то… ну, у нее есть определение, стандартное, которое выводит «вызов чистой виртуальной функции» и дает сбой. c.f. artima.com/cppsource/pure_virtual.html

v.oddou 25.09.2013 13:15

В C++ виртуальные методы маршрутизируются через vtable для создаваемого класса. Таким образом, в вашем примере это сгенерирует исключение чистого виртуального метода, поскольку пока создается BaseObject, просто нет метода LoadStateCore для вызова.

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

По этой причине вы просто не можете сделать это в C++ ...

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

Rob Kennedy 16.01.2009 01:34

@Rob: Нет. Он проходит через v-table и терпит неудачу. Он НЕ будет вызывать ожидаемый метод.

Martin York 16.01.2009 11:34

Это не определено, и поэтому вы не можете спорить ни с чем. Вызов чистых виртуалов может вызвать назальных демонов, несмотря на все заботы C++.

MSalters 16.01.2009 14:21

Да, но вызов виртуального метода (а не чистого виртуального метода) совершенно четко определен, и в этом и был вопрос.

David Thornley 16.01.2009 18:09

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

Rob Kennedy 17.01.2009 21:00

Для C++ этот случай рассматривается в параграфе 3 раздела 12.7 Стандарта.

Подводя итог, это законно. Он разрешит правильную функцию для типа запускаемого конструктора. Следовательно, адаптируя свой пример к синтаксису C++, вы должны будете вызвать BaseObject::LoadState(). Вы не можете добраться до ChildObject::LoadState(), и попытка сделать это, указав класс, а также функцию, приводит к неопределенному поведению.

Конструкторы абстрактных классов рассматриваются в разделе 10.4, параграф 6. Вкратце, они могут вызывать функции-члены, но вызов чистой виртуальной функции в конструкторе является неопределенным поведением. Не делай этого.

Обычно эти проблемы можно обойти, используя более жадный базовый конструктор. В вашем примере вы передаете XElement в LoadState. Если вы разрешаете напрямую устанавливать состояние в базовом конструкторе, ваш дочерний класс может анализировать XElement до вызова вашего конструктора.

public abstract class BaseObject {
   public BaseObject(int state1, string state2, /* blah, blah */) {
      this.State1 = state1;
      this.State2 = state2;
      /* blah, blah */
   }
}

public class ChildObject : BaseObject {
   public ChildObject(XElement definition) : 
      base(int.Parse(definition["state1"]), definition["state2"], /* blah, blah */) {
   }
}

Если дочернему классу нужно немного поработать, его можно передать статическому методу.

Для C++ базовый конструктор вызывается перед производным конструктором, что означает, что виртуальная таблица (которая содержит адреса переопределенных виртуальных функций производного класса) еще не существует. По этой причине это считается ОЧЕНЬ опасным делом (особенно, если функции являются чисто виртуальными в базовом классе ... это вызовет чисто виртуальное исключение).

Есть два способа обойти это:

  1. Сделайте двухэтапный процесс построения + инициализации
  2. Переместите виртуальные функции во внутренний класс, которым вы можете более внимательно управлять (можно использовать вышеупомянутый подход, см. Пример для подробностей)

Пример (1):

class base
{
public:
    base()
    {
      // only initialize base's members
    }

    virtual ~base()
    {
      // only release base's members
    }

    virtual bool initialize(/* whatever goes here */) = 0;
};

class derived : public base
{
public:
    derived ()
    {
      // only initialize derived 's members
    }

    virtual ~derived ()
    {
      // only release derived 's members
    }

    virtual bool initialize(/* whatever goes here */)
    {
      // do your further initialization here
      // return success/failure
    }
};

Пример (2):

class accessible
{
private:
    class accessible_impl
    {
    protected:
        accessible_impl()
        {
          // only initialize accessible_impl's members
        }

    public:
        static accessible_impl* create_impl(/* params for this factory func */);

        virtual ~accessible_impl()
        {
          // only release accessible_impl's members
        }

        virtual bool initialize(/* whatever goes here */) = 0;
    };

    accessible_impl* m_impl;

public:
    accessible()
    {
        m_impl = accessible_impl::create_impl(/* params to determine the exact type needed */);

        if (m_impl)
        {
            m_impl->initialize(/* ... */);  // add any initialization checking you need
        }
    }

    virtual ~accessible()
    {
        if (m_impl)
        {
            delete m_impl;
        }
    }

    /* Other functionality of accessible, which may or may not use the impl class */
};

Подход (2) использует шаблон Factory для обеспечения соответствующей реализации класса accessible (который будет предоставлять тот же интерфейс, что и ваш класс base). Одним из основных преимуществ здесь является то, что вы получаете инициализацию во время создания accessible, которая может безопасно использовать виртуальные члены accessible_impl.

Ваше предложение правильное, но не объяснение. v-таблица ДЕЙСТВИТЕЛЬНО существует, и она указывает на функции базы. указатель v-таблицы обновляется по мере развертывания иерархии конструктора.

v.oddou 25.09.2013 13:52

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

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

т.е.

public class BaseClass
{
    public BaseClass(XElement defintion)
    {
        // base class loads state here
    }
}

public class DerivedClass : BaseClass
{
    public DerivedClass (XElement defintion)
        : base(definition)
    {
        // derived class loads state here
    }
}

Тогда ваш код действительно прост, и у вас нет проблем с вызовом виртуального метода.

В C++ совершенно безопасно вызывать виртуальные функции из базового класса - пока они нечистый - с некоторыми ограничениями. Однако делать этого не стоит. Лучше инициализировать объекты, используя невиртуальные функции, которые явно помечены как такие функции инициализации с помощью комментариев и соответствующего имени (например, initialize). Если он даже объявлен как чисто виртуальный в вызывающем его классе, поведение не определено.

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

В C# и Java это не проблема, потому что не существует такой вещи, как инициализация по умолчанию, которая выполняется непосредственно перед входом в тело конструктора. В C# единственное, что делается за пределами тела, - это вызов конструкторов базового класса или родственных ему конструкторов. Однако в C++ инициализация, выполненная для членов производных классов переопределителем этой функции, будет отменена при построении этих членов при обработке списка инициализаторов конструктора непосредственно перед входом в тело конструктора производного класса.

Редактировать: Из-за комментария, я думаю, необходимо немного пояснения. Вот (надуманный) пример, давайте предположим, что будет разрешено вызывать виртуальные объекты, и этот вызов приведет к активации последнего переопределителя:

struct base {
    base() { init(); }
    virtual void init() = 0;
};

struct derived : base {
    derived() {
        // we would expect str to be "called it", but actually the
        // default constructor of it initialized it to an empty string
    }
    virtual void init() {
        // note, str not yet constructed, but we can't know this, because
        // we could have called from derived's constructors body too
        str = "called it";
    }
private:
    string str;
};

Эта проблема действительно может быть решена путем изменения стандарта C++ и его разрешения - корректировки определения конструкторов, времени жизни объекта и прочего. Потребуются правила, определяющие, что str = ...; означает для еще не созданного объекта. И обратите внимание, как эффект от этого зависит от того, кто позвонил в init. То, что мы получаем, не оправдывает тех проблем, которые нам предстоит решать. Таким образом, C++ просто запрещает динамическую отправку во время создания объекта.

Ваш последний абзац неверен. Любые инициализаторы (инициализация, определенная вне тела конструктора) запускаются в порядке от наиболее производных к наименее производным, а затем тела конструктора запускаются в порядке от наименее производных к наиболее производным.

Greg Beech 16.01.2009 01:57

к счастью, я объяснил порядок вещей в другом ответе: stackoverflow.com/questions/375141/… :)

Johannes Schaub - litb 16.01.2009 02:06

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

Johannes Schaub - litb 16.01.2009 02:14

Для C++ прочтите соответствующую статью Скотта Мейера:

Никогда не вызывайте виртуальные функции во время строительства или разрушения

ps: обратите внимание на это исключение в статье:

The problem would almost certainly become apparent before runtime, because the logTransaction function is pure virtual in Transaction. Unless it had been defined (unlikely, butpossible) the program wouldn't link: the linker would be unable to find the necessary implementation of Transaction::logTransaction.

Другие вопросы по теме