在运行时更改现有对象的VTBL,动态子类化

6
请确认需要翻译的语言是英语还是中文,谢谢!
class Thing {
  int f1;
  int f2;

  Thing(NO_INIT) {}
  Thing(int n1 = 0, int n2 = 0): f1(n1),f2(n2) {}
  virtual ~Thing() {}

  virtual void doAction1() {}
  virtual const char* type_name() { return "Thing"; }
}

而仅通过实现上述方法有所不同的派生类:

class Summator {
  Summator(NO_INIT):Thing(NO_INIT) {}

  virtual void doAction1() override { f1 += f2; }
  virtual const char* type_name() override { return "Summator"; }
}

class Substractor {
  Substractor(NO_INIT):Thing(NO_INIT) {}    
  virtual void doAction1() override { f1 -= f2; }
  virtual const char* type_name() override { return "Substractor"; }
}

我需要一个能够在运行时改变现有对象类(在这种情况下是VTBL)的能力。如果我没记错的话,这被称为动态子类化。

所以,我想出了以下函数:

// marker used in inplace CTORs
struct NO_INIT {}; 

template <typename TO_T>
    inline TO_T* turn_thing_to(Thing* p) 
    { 
      return ::new(p) TO_T(NO_INIT()); 
    }

这段代码使用了inplace new来构造一个对象来代替另一个对象,从而实现了相应的功能。实际上,这只是改变了对象中的vtbl指针。因此,这段代码可以正常工作:

Thing* thing = new Thing();
cout << thing->type_name() << endl; // "Thing"
turn_thing_to<Summator>(thing);
cout << thing->type_name() << endl; // "Summator"
turn_thing_to<Substractor>(thing);
cout << thing->type_name() << endl; // "Substractor"

我对这种方法唯一的主要问题是,a) 每个派生类都必须有特殊构造函数,例如 Thing(NO_INIT) {},它们什么也不做。b) 如果我想向Thing添加像std::string这样的成员,它们将无法工作 - 只允许作为Thing成员的具有NO_INIT构造函数的类型。
问题:是否有更好的解决动态子类化的方法来解决'a'和'b'问题?我觉得std::move语义可能会在某种程度上解决'b'问题,但不确定。
这里是代码的ideone

1
你需要这个做什么?如果你描述了实际问题,比起调用未指定的行为并冒着鼻子恶魔的愤怒风险,可能会有更好的解决方案被建议。 - Anton Tykhyy
@AntonTykhyy:考虑HTML DOM元素(class element)的树形结构。在解析后以及运行时,每个DOM元素都可以通过CSS/样式、脚本等方式定义不同的布局模型。因此,每个元素都可以是block_element、table_element等类别,具有不同的布局和可见性规则。拥有这样的动态子类化对于我在Sciter引擎中使用的架构至关重要:http://terrainformatica.com/sciter/ - c-smile
然后改变架构。如果你想要在运行时改变元素的行为,就要将行为与数据分开,并将指针分配给行为对象。这种未定义行为所节省的唯一事情是一个指针访问,而且很可能会命中缓存。 - Anton Tykhyy
@antonTykhyy 另一个缺点是多了一层间接性,另一个缺点是策略或pimpl模式的使用会增加维护/开发成本。根类大约有50个方法需要桥接到当前策略方法。最后看起来不太好看。而且如果C++已经有内置基础设施,为什么要引入新实体也不清楚。如果稍微换个角度思考,虚拟性就是一种策略实现。 - c-smile
你说得好像这是一件坏事,@AntonTykhyy... 我也和c-smile在同样的问题上找到了类似的解决方案。我正在制作实时音频合成软件,并拥有一组数字滤波器。我希望用户可以随时更改滤波器。实时不允许堆。当我开始使用新的滤波器时,放置运算符指示我停止使用旧的滤波器的确切时间。结构体相同,虚拟方法相同。要么这样做,要么创建一个带有开关的类!而且为什么它是未定义的? - Swiss Frank
显示剩余2条评论
8个回答

2

(已在RSDN上回答 http://rsdn.ru/forum/cpp/5437990.1)

有一个巧妙的方法:

struct Base
{
    int x, y, z;
    Base(int i) : x(i), y(i+i), z(i*i) {}
    virtual void whoami() { printf("%p base %d %d %d\n", this, x, y, z); }
};

struct Derived : Base
{
    Derived(Base&& b) : Base(b) {}
    virtual void whoami() { printf("%p derived %d %d %d\n", this, x, y, z); }
};

int main()
{
    Base b(3);
    Base* p = &b;

    b.whoami();
    p->whoami();

    assert(sizeof(Base)==sizeof(Derived));
    Base t(std::move(b));
    Derived* d = new(&b)Derived(std::move(t));

    printf("-----\n");
    b.whoami(); // the compiler still believes it is Base, and calls Base::whoami
    p->whoami(); // here it calls virtual function, that is, Derived::whoami
    d->whoami();
};

当然,这是UB。

1
这是稍微改变了一下的代码:http://ideone.com/5O6SFV 你在哪里看到UB了? - c-smile
@c-smile:使用b访问一个新对象,该对象占用相同的位置,但类型不匹配。 - Ben Voigt
我不确定我理解你的陈述。你是说在C++中使用inplace new是UB吗?还是关于在C++中重复使用相同的内存范围以用于不同的目的? - c-smile

1
对于你的代码,我不能百分之百确定它是否符合标准。我认为在C++中使用未初始化任何成员变量的放置new,以保留先前的类状态,是未定义的行为。想象一下有一个调试放置new,将把所有未初始化的成员变量初始化为0xCC。
union在这种情况下是更好的解决方案。然而,似乎你正在实现策略模式。如果是这样,请使用策略模式,这将使代码更易于理解和维护。 注意:在使用union时应该删除virtual 如Mehrdad所述,添加它是不合法的,因为引入虚函数不符合标准布局。 示例
#include <iostream>
#include <string>

using namespace std;

class Thing {
    int a;
public:
    Thing(int v = 0): a (v) {}
    const char * type_name(){ return "Thing"; }
    int value() { return a; }
};

class OtherThing : public Thing {
public:
    OtherThing(int v): Thing(v) {}

    const char * type_name() { return "Other Thing"; }
};

union Something {
    Something(int v) : t(v) {}
    Thing t;
    OtherThing ot;
};

int main() {
    Something sth{42};
    std::cout << sth.t.type_name() << "\n";
    std::cout << sth.t.value() << "\n";

    std::cout << sth.ot.type_name() << "\n";
    std::cout << sth.ot.value() << "\n";
    return 0;
}

如标准所述:

在联合体中,最多只能有一个非静态数据成员处于活动状态,也就是说,在联合体中,最多只能存储一个非静态数据成员的值。[注意:为了简化联合体的使用,做出了一个特殊保证:如果标准布局联合体包含多个共享公共初始序列(9.2)的标准布局结构,并且如果该标准布局联合体类型的对象包含其中一个标准布局结构,则允许检查任何标准布局结构成员的公共初始序列;参见9.2。—注]


“在C++中,使用未初始化任何成员变量的放置new(placement new)来保留先前类状态是未定义行为。” “你从哪里得到的?在C++中,new运算符不会初始化任何成员变量。按照其定义,这是构造函数的职责。” - c-smile
战略模式的实现中,对象可以动态地改变策略。 - c-smile
1
@c-smile 构造函数负责初始化成员变量,但不能保证在分配后内存不会被更改。对于非放置 new,通常调试 new 会将内存填充为某些固定值以进行故障排除;但是对于放置 new,我不确定标准是否规定了内存必须保持不变(标准中未找到)。但我高度怀疑这不是一个明确定义的行为,就像您的代码一样。 - Xin
@c-smile 另外,使用C++ union来实现策略模式,无论是在我的代码中还是在您的代码中,都很难理解、容易出错且难以维护。如果以后要向不同的子类添加2个不同的字段,那么必须仔细维护内存布局,以免破坏代码。因此使用指针(unique_ptr/shared_ptr)似乎是更好的选择。 - Xin
你的解决方案虽然(可能)解决了其他一些问题,但同时也破坏了最初的功能。你的解决方案需要像sth.ot.value()和sth.oth.value()这样的显式调用。它可以通过创建额外的包装器来解决,但这将增加维护成本并添加一个间接层或if/switch等。 - c-smile

1

问题:是否有更好的解决“a”和“b”问题的动态子类化方案?

如果您拥有固定的子类集,则可以考虑使用代数数据类型,例如boost::variant。将共享数据单独存储,并将所有变化的部分放入变体中。

此方法的特点:

  • 自然地与一组固定的“子类”一起工作。(尽管某些类型抹消的类可以放入变体中,集合将变得开放)
  • 调度通过对小整数标签的开关完成。标签的大小可以最小化到一个char。如果您的“子类”为空 - 那么会有一些额外开销(取决于对齐方式),因为boost::variant不执行empty-base-optimization
  • “子类”可以具有任意内部数据。来自不同“子类”的此类数据将放置在一个aligned_storage中。
  • 您可以使用每批仅一个调度对“子类”执行一系列操作,而在通常情况下,使用虚拟或间接调用调度将是每次调用。另外,在“子类”内部调用方法将没有间接性,而使用虚拟调用,则应使用final关键字尝试实现这一点。
  • self基本共享数据应显式传递。

好的,这是概念验证:

struct ThingData
{
    int f1;
    int f2;
};

struct Summator
{
    void doAction1(ThingData &self)  { self.f1 += self.f2; }
    const char* type_name() { return "Summator"; }
};

struct Substractor
{
    void doAction1(ThingData &self)  { self.f1 -= self.f2; }
    const char* type_name() { return "Substractor"; }
};

using Thing = SubVariant<ThingData, Summator, Substractor>;

int main()
{
    auto test = [](auto &self, auto &sub)
    {
        sub.doAction1(self);
        cout << sub.type_name() << " " << self.f1 << " " << self.f2 << endl;
    };

    Thing x = {{5, 7}, Summator{}};
    apply(test, x);
    x.sub = Substractor{};
    apply(test, x);

    cout << "size: " << sizeof(x.sub) << endl;
}

输出结果为:
Summator 12 7
Substractor 5 7
size: 2

在 Coliru 上进行实时演示

完整代码(它使用了一些 C++14 特性,但可以被机械地转换为 C++11):

#define BOOST_VARIANT_MINIMIZE_SIZE

#include <boost/variant.hpp>
#include <type_traits>
#include <functional>
#include <iostream>
#include <utility>

using namespace std;

/****************************************************************/
// Boost.Variant requires result_type:
template<typename T, typename F>
struct ResultType
{
     mutable F f;
     using result_type = T;

     template<typename ...Args> T operator()(Args&& ...args) const
     {
         return f(forward<Args>(args)...);
     }
};

template<typename T, typename F>
auto make_result_type(F &&f)
{
    return ResultType<T, typename decay<F>::type>{forward<F>(f)};
}
/****************************************************************/
// Proof-of-Concept
template<typename Base, typename ...Ts>
struct SubVariant
{
    Base shared_data;
    boost::variant<Ts...> sub;

    template<typename Visitor>
    friend auto apply(Visitor visitor, SubVariant &operand)
    {
        using result_type = typename common_type
        <
            decltype( visitor(shared_data, declval<Ts&>()) )...
        >::type;

        return boost::apply_visitor(make_result_type<result_type>([&](auto &x)
        {
            return visitor(operand.shared_data, x);
        }), operand.sub);
    }
};
/****************************************************************/
// Demo:

struct ThingData
{
    int f1;
    int f2;
};

struct Summator
{
    void doAction1(ThingData &self)  { self.f1 += self.f2; }
    const char* type_name() { return "Summator"; }
};

struct Substractor
{
    void doAction1(ThingData &self)  { self.f1 -= self.f2; }
    const char* type_name() { return "Substractor"; }
};

using Thing = SubVariant<ThingData, Summator, Substractor>;

int main()
{
    auto test = [](auto &self, auto &sub)
    {
        sub.doAction1(self);
        cout << sub.type_name() << " " << self.f1 << " " << self.f2 << endl;
    };

    Thing x = {{5, 7}, Summator{}};
    apply(test, x);
    x.sub = Substractor{};
    apply(test, x);

    cout << "size: " << sizeof(x.sub) << endl;
}

0

我有同样的问题,虽然我没有使用它,但我想到的一个解决方案是拥有一个单一的类,并根据类中的“项目类型”编号使方法切换。更改类型就像更改类型编号一样容易。

class OneClass {

  int iType;

  const char* Wears() {
      switch ( iType ) {
      case ClarkKent:
          return "glasses";
      case Superman:
          return "cape";
      }
  }
}

:
:

OneClass person;
person.iType = ClarkKent;
printf( "now wearing %s\n", person.Wears() );
person.iType = Superman;
printf( "now wearing %s\n", person.Wears() );

0

关于静态断言的问题,将turn_to声明为Thing类本身的方法即可:template inline TO_T* Thing::turn_to() { return ::new(this) TO_T(NO_INIT()); }这样就可以使用thing->turn_to<Summator>()调用它。我不确定我是否理解了有关参数传递的第二部分。它们如何帮助?正如你所看到的,当调用::new(this) Summator(NO_INIT())时,我没有调用任何析构函数。这就是整个要点 - 新对象的字段已经准备好使用。 - c-smile
使用static_assert,您可以完全摆脱NO_INIT结构。关于第二个问题,您说“如果我想要向Thing添加像std :: string这样的成员,它们将无法工作”;因此,您可以轻松地将变量传递给构造函数。例如,如果Summator有一个字符串,您将能够说thing->turn_to<Summator>("testString");,并且它将正确传递。请查看我在答案中提供的代码(http://ideone.com/W3PgMS)。 - Gasim
你好,以下是翻译内容:你似乎错过了这种方法的思路。想象一下,C++对象在内存中的布局如下:struct { void* vtbl; int f1; int f2 }。所以我们需要做的就是将vtbl值更改为其他值。而且所有这些都必须通过官方的C++手段完成,代码必须是可移植的等等。 - c-smile
vtbl = new SomeClass?同时使用 void* 是个坏主意。如果我有 class Base {...}; class Derived : public Base {},我会使用 Base * base = new Derived; - Gasim
你说你不想改变成员变量,但是你却试图去改变它们。唯一的解决方法就是定义自己的移动构造函数。 - Gasim
显示剩余2条评论

0

在C++中,您根本无法合法地“更改”对象的类。

但是,如果您提到为什么需要这样做,我们可能会建议替代方案。我可以想到以下几种:

  1. 手动执行v-tables。换句话说,给定类的每个对象都应该有一个指向函数指针表的指针,该表描述了类的行为。要修改此类对象的行为,您需要修改函数指针。非常痛苦,但这就是v-tables的全部意义:将其抽象化。

  2. 使用带标记的联合(variant等)将潜在不同类型的对象嵌套在同一种类型的对象中。但我不确定这是否适合您。

  3. 执行某些特定于实现的操作。您可能可以在线找到所使用实现的v-table格式,但这是未定义行为的领域,因此您正在冒险。而且它很可能在另一个编译器上无法工作。


2
你能具体一点吗?那里到底禁止什么? - c-smile
1
@c-smile:带有虚函数的类不是“标准布局”。这意味着您无法可移植地预测它们在内存中的任何布局。C++标准没有任何关于“虚函数表”的概念,那只是实现细节。因此,在一个对象之上构造另一个对象,并期望虚函数表指针匹配是错误和未定义行为。 - user541686
1
实际上这与VTBL无关。是的,C++中的虚拟性可以以其他方式实现。在我的情况下,没有明确的VTBL位置问题。 C ++可以保证类Thing的两个对象在内存中具有相同的offsetof,并且它们的字段在内存中具有相同的存储位置。只要您不调用析构函数并且不清除对象占用的内存,它始终处于构造状态。因此,没有“标准布局”,但有从它们派生的类和其他类的“稳定布局”。 - c-smile
1
@c-smile:确实,类Thing的对象和DerivedThingThing子对象的布局相同,否则无法将DerivedThing*强制转换为Thing*。但是,您错误地认为DerivedThing的布局始于Thing的布局。在流行的实现中通常是这样的,但标准并不强制要求这种行为(这就是Mehrdad所说的),在某些重要情况下,例如多重或虚拟继承,布局不匹配,static_cast<Thing*> (&derivedThing)不是nop。IMSMR“D&E”对此进行了讨论。 - Anton Tykhyy
@AntonTykhyy:我甚至不确定我是否同意第一句话。考虑一种将其字段的偏移量存储在对象内部的实现。这有点奇怪,但这是非法的吗?如果它是合法的,那么同一最终类型的两个对象可能会以相同的顺序排列偏移量,但偏移量所指的字段实际上可能以不同的顺序排列。事实上,我认为可能有一种方法使偏移量按不同的顺序排列。我不确定这是否被C++禁止用于非标准布局类,是吗? - user541686
显示剩余7条评论

0
你应该通过将数据与你的Thing类分离来实现数据重用。可以像这样做:

template <class TData, class TBehaviourBase>
class StateStorageable {
    struct StateStorage {
        typedef typename std::aligned_storage<sizeof(TData), alignof(TData)>::type DataStorage;
        DataStorage data_storage;
typedef typename std::aligned_storage<sizeof(TBehaviourBase), alignof(TBehaviourBase)>::type BehaviourStorage; BehaviourStorage behaviour_storage;
static constexpr TData *data(TBehaviourBase * behaviour) { return reinterpret_cast<TData *>( reinterpret_cast<char *>(behaviour) - (offsetof(StateStorage, behaviour_storage) - offsetof(StateStorage, data_storage))); } }; public: template <class ...Args> static TBehaviourBase * create(Args&&... args) { auto storage = ::new StateStorage;
::new(&storage->data_storage) TData(std::forward<Args>(args)...);
return ::new(&storage->behaviour_storage) TBehaviourBase; }
static void destroy(TBehaviourBase * behaviour) { auto storage = reinterpret_cast<StateStorage *>( reinterpret_cast<char *>(behaviour) - offsetof(StateStorage, behaviour_storage)); ::delete storage; } protected: StateStorageable() = default;
inline TData *data() { return StateStorage::data(static_cast<TBehaviourBase *>(this)); } };
struct Data { int a; };
class Thing : public StateStorageable<Data, Thing> { public: virtual const char * type_name(){ return "Thing"; } virtual int value() { return data()->a; } };

数据在将Thing更改为其他类型时保证不会被破坏,偏移量应在编译时计算,因此性能不应受影响。

通过一组适当的static_assert,您应该能够确保所有偏移量都是正确的,并且有足够的存储空间来容纳您的类型。现在,您只需要更改创建和销毁Thing的方式。


int main() {
    Thing * thing = Thing::create(Data{42});
    std::cout << thing->type_name() << "\n";
    std::cout << thing->value() << "\n";
turn_thing_to<OtherThing>(thing); std::cout << thing->type_name() << "\n"; std::cout << thing->value() << "\n";
Thing::destroy(thing); return 0; }

由于未重新分配thing而仍存在UB问题,可以通过使用turn_thing_to的结果来解决。

主函数: ```cpp int main() { ... thing = turn_thing_to(thing); ... } ```

0

这里还有一种解决方案

虽然它略微不太优化(使用中间存储和 CPU 周期来调用移动构造函数),但它不会改变原始任务的语义。

#include <iostream>
#include <string>
#include <memory>

using namespace std;

struct A
{
    int x;
    std::string y;
    A(int x, std::string y) : x(x), y(y) {}
    A(A&& a) : x(std::move(a.x)), y(std::move(a.y)) {}

    virtual const char* who() const { return "A"; }
    void show() const { std::cout << (void const*)this << " " << who() << " " << x << " [" << y << "]" << std::endl; }
};

struct B : A
{
    virtual const char* who() const { return "B"; }
    B(A&& a) : A(std::move(a)) {}
};

template<class TO_T> 
  inline TO_T* turn_A_to(A* a) {
    A temp(std::move(*a));
    a->~A();
    return new(a) B(std::move(temp));
  }


int main()
{
    A* pa = new A(123, "text");
    pa->show(); // 0xbfbefa58 A 123 [text]
    turn_A_to<B>(pa);
    pa->show(); // 0xbfbefa58 B 123 [text]

}

还有它的ideone

这个解决方案源于Nickolay Merkin表达的想法。 但他怀疑turn_A_to<>()中存在未定义行为。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接