【问题标题】:Compile-time Validated Type Erasure编译时验证类型擦除
【发布时间】:2020-04-01 04:15:33
【问题描述】:

这是我的目标:删除类型信息以简化对象访问。这是我的目标的一个简单示例:

class magic;

magic m = std::string("hello"); // ok: m now stores a string
m = 32;                         // error: m is supposed to be a string
m += " world";                  // ok: operator for this exists

您可能已经注意到,它的功能基本上类似于 auto 关键字。

要继续,最好不要根据类型改变其大小(例如,使用指针)。这样我就可以使用容器了。

std::vector<magic> vec; // homogeneous
vec.emplace_back(8);
vec.emplace_back(std::string("str"));
vec[0] = 4; // ok
vec[1] = 2; // no way, jose. compile error here because vec[1] is a string

这个想法是它必须是编译时的(而不是像 std::any 或 std::variant 那样的运行时),因为无论如何类型在编译时都是已知的;这只是我不需要的额外开销。

我知道这是可能的原因是因为 auto 已经完成了这项工作。我只需要某种类型的容器,其功能类似于 auto*,它实际上在编译时验证操作,以节省开销和非常繁琐的冗余编程。

这是我打算如何使用它(警告:错误的伪代码)

struct base
{
    auto* p;
};
struct child: base<int> // child implements base as an int
{
    // use p and implement whatever functions are necessary
};
std::vector<base> vec;
vec.emplace_back(child());
vec[0] = 20;

如果您愿意,如果您担心“键”访问权限会根据所推回的内容而改变,请假装它是地图而不是矢量。但我有一种预感,stl 容器无论如何都不会工作,所以请随意发布一个使用编译时类型擦除的容器的答案,因为我认为这可能比独立类型容易得多。

【问题讨论】:

  • 定义运行时?你已经在你的例子中使用了 std::vector,你的意思是你想要一个带有编译时变体的编译时向量吗?
  • 你不能保证在编译时类型不会改变。考虑创建一个包含字符串的vector&lt;magic&gt; vec,然后在开头插入argc ints。然后你尝试访问vec[1]。编译器无法在编译时知道argc 是什么,因此无法知道它是 int 还是 string。
  • 这里似乎对运行时和编译的含义存在误解。如果您询问您的要求,而不是具体提出的解决方案,这个问题会更清楚。特别是在“我知道这是可能的原因是因为 auto 已经完成了这项工作。” - auto 所做的只是推断一个类型,就好像你自己输入了一些东西一样。它与编译时或运行时值保证关系不大。
  • 您“知道这是可能的”吗? vector&lt;auto*&gt; 肯定不存在。
  • 模板并不神奇。他们不会让你解决 Daniel H 指出的问题。

标签: c++ c++17 c++20


【解决方案1】:

类型擦除是一个运行时概念。根据定义,它不能在编译时进行验证。如果可能存在任何这样的magic 类型,那么它就无法确定在编译时vec[0] = 4 可以,而vec[1] = 2 不行。

我知道这是可能的原因是因为 auto 已经完成了这项工作。

不,它没有。 auto 是一种语法结构,它使 C++ 根据表达式的(编译时确定的)类型推断变量的(编译时确定的)类型。 auto 存在于编译器中,而不是运行时。

您想要的是在运行时发生的事情。虽然任何特定 vec[X] 的类型是在编译时确定的,但它的 是运行时属性。您希望该值以某种方式使分配成为编译错误。这是不可能的。

这就是为什么tuple 使用get&lt;X&gt; 而不是get(X)。索引必须是一个编译时常量,它允许get&lt;X&gt; 的类型对于元组中的每个特定X 可能不同。

类型的属性,例如可以从整数赋值,是编译时 构造。也就是说,vec[X] = 4 要么是格式良好的代码,要么不是;根据Xvec 的内容,不可能使其有时格式正确,有时则不正确。您可以将其设为 UB,或抛出异常。但是你不能让它成为一个编译错误

【讨论】:

  • 好吧,那就假装它是custom_container.get&lt; 3 &gt;( ) 或者用钥匙代替。
  • @nowi:但是你已经有一个这样的容器了;它被称为“元组”。当然,您不能从中插入或删除任何内容,因为那将是一个运行时进程,并且它的编译时特征必须在...编译时确定。
  • 使用consteval,您可以根据函数参数使某些此类事物不正确——但您不能使它们的类型依赖他们。
  • @DavisHerring:“你可以根据函数参数使一些这样的东西格式错误”这需要有问题的值是常量表达式。 OP 并没有要求这样做。
  • @DavidLedger:我不知道这有什么关系。我没有说这不是真的。不传递值意味着get 不起作用。所以我不知道这对你引用的内容有什么影响。特别要注意短语“in a tuple”;这意味着你已经传递了一个元组。
【解决方案2】:

很遗憾,我无法使用与问题中相同的语法来回答您的问题。因为正如其他人所说auto 与您的假设不同。 auto 只是一个推导类型。

如果分配了int,则auto 的类型为int。但是,这仅适用于推断 auto 类型的情况。任何正在进行的分配只是分配给int,而不是分配给autoauto 的类型不是动态的,它的存储也不是动态的,这就是为什么 auto 不能用于在 std::vector 中存储各种不同的类型。

只是添加到另一个答案,希望有助于理解:


auto i = 10;

这里i 的类型是int 而不是auto


auto b = true;

这里i 的类型是bool 而不是auto


但是,我可以尽力解决我认为是您面临的问题。


这个答案的作用:

  1. 在编译时确保通过具有正确参数类型的函数完成对变量的访问(绕过检查类型的需要)。

  2. 提供对已擦除数据的无例外访问(我认为它是安全的...)。

  3. 允许修改数据。


这不能做什么:

  1. 由于重新解释大小写,在编译时运行。
  2. 允许直接通过 std::vector 中的成员进行赋值,尽管您可以从调用的访问函数中进行赋值。

工作原理:

类型化参数为 T& 的回调函数被类型擦除并存储为泛型函数。该函数的存储是 void (*)() 因为函数指针与普通的 void * 指针不同,它们通常具有不同的大小。

带有类型化参数的访问器函数被设置为由带有两个类型擦除指针参数的函数调用。参数在此函数中被转换为它们的真实类型,这些类型是已知的,因为它们存在于 base 对象的构造函数中。 runner 函数指针中存储了一个指向在构造函数中创建为 lambda 的函数的指针。

access函数运行时,runner函数带有参数dataaccessor函数。一旦运行器函数被执行,它会在内部执行带有参数 dataaccessor 函数,但这一次是在它被转换为正确的类型之后。

当需要访问时,调用上述函数的类型擦除版本,该函数在内部调用类型化函数。我可以在以后的版本中添加对 lambdas 的支持,但它已经相当复杂了,我想我现在就发布......

在基类中存在一个析构函数类。这是存储类型擦除析构函数的一般方式,与Herb Sutters method 几乎相同。这只是确保提供给基础的数据能够运行其析构函数。


基于堆的方法在概念上更简单,您可以在此处运行它: https://godbolt.org/z/cb-a6m

基于堆栈的方法可能更快,但有更多限制: https://godbolt.org/z/vxS4tJ


基于代码堆的(更简单的)代码:

#include <iostream>
#include <memory>
#include <utility>
#include <vector>


template <typename T>
struct mirror { using type = T; };
template <typename T>
using mirror_t = typename mirror<T>::type;

struct destructor
{
    const void* p = nullptr;
    void(*destroy)(const void*) = nullptr;
    //
    template <typename T>
    destructor(T& data) noexcept :
        p{ std::addressof(data) },
        destroy{ [](const void* v) { static_cast<T const*>(v)->~T(); } }
    {}
    destructor(destructor&& d) noexcept
    {
        p = d.p;
        destroy = d.destroy;
        d.p = nullptr;
        d.destroy = nullptr;
    }
    destructor& operator=(destructor&& d) noexcept
    {
        p = d.p;
        destroy = d.destroy;
        d.p = nullptr;
        d.destroy = nullptr;
        return *this;
    }
    //
    destructor() = default;
    ~destructor()
    {
        if (p and destroy) destroy(p);
    }
};

struct base
{
    using void_ptr_t = void*;          // Correct size for a data pointer.
    using void_func_ptr_t = void(*)(); // Correct size for a function pointer.
    using callback_t = void (*)(void_func_ptr_t, void_ptr_t);
    //
    void_ptr_t data;
    void_func_ptr_t function;
    callback_t runner;
    destructor destruct;
    //
    template <typename T>
    constexpr base(T * value, void (*callback)(mirror_t<T>&)) noexcept :
        data{ static_cast<void_ptr_t>(value) },
        function{ reinterpret_cast<void_func_ptr_t>(callback) },
        runner{
            [](void_func_ptr_t f, void_ptr_t p) noexcept
            {
                using param = T&;
                using f_ptr = void (*)(param);
                reinterpret_cast<f_ptr>(f)(*static_cast<T*>(p));
            }
        },
        destruct{ *value }
    {}
    //
    constexpr void access() const noexcept
    {
        if (function and data) runner(function, data);
    }
};

struct custom_type
{
    custom_type()
    {
        std::cout << __func__ << "\n";
    }
    custom_type(custom_type const&)
    {
        std::cout << __func__ << "\n";
    }
    custom_type(custom_type &&)
    {
        std::cout << __func__ << "\n";
    }
    ~custom_type()
    {
        std::cout << __func__ << "\n";
    }
};
//
void int_access(int & a)
{
    std::cout << "int_access a = " << a << "\n";
    a = 11;
}
void string_access(std::string & a)
{
    std::cout << "string_access a = " << a << "\n";
    a = "I'm no longer a large string";
}
void custom_access(custom_type& a)
{

}

int main()
{
    std::vector<base> items;
    items.emplace_back(new std::string{ "hello this is a long string which doesn't just sit in small string optimisations, this needs to be tested in a tight loop to confirm no memory leaks are occuring." }, &string_access);
    items.emplace_back(new custom_type{},   &custom_access);
    items.emplace_back(new int (10),        &int_access);
    //
    for (auto& item : items)
    {
        item.access();
    }
    for (auto& item : items)
    {
        item.access();
    }
    //
    return 0;
}

【讨论】:

  • 我不明白这将如何导致items[2].access(some_int) 编译,而items[0].access(some_int) 编译失败。
  • 这需要不同的访问模式,但实现了针对类型的访问的编译时验证。你的例子也不正确,它应该是items[0].access(),没有参数。错误将在此处出现:base(new int(10), &amp;typed_access); 关于基础对象的构造。访问函数必须与分配的变量位于同一位置,因为这是 T 类型存在的唯一位置(启用访问函数参数类型的检查)。
  • @DavidLedger: "错误会在这里发现" 但这是值的初始创建,而不是值的赋值 . OP 的问题非常明确,希望拥有该类型的活动对象,然后分配给它,分配在编译时基于该对象中当前值的属性失败。
  • 这个答案只是一个非常迂回的 std::function 数组,部分应用了一个值。实际上,这具有相对较大的运行时成本,并且似乎无法解决任何问题,正如 Nicol 一直指出的那样。 OP 接受这一事实表明他们确实没有一个精心设计的问题一开始。
  • 在进一步调查之后,它似乎并没有真正实现我的目标。它只是验证函数签名是否与分配时的类型匹配,然后删除类型以供以后访问。如果我足够努力地思考,这似乎在理论上可以扩展,但我也想不出任何让它发挥作用的东西。
猜你喜欢
  • 2021-08-03
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多