1

在 C++ 中,我们可以通过放入本地对象的析构函数来确保foo在退出作用域时被调用。foo()当我领导“范围守卫”时,这就是我的想法。有很多通用的实现。

我想知道——只是为了好玩——与仅foo()在每个退出点写入相比,是否有可能以零开销实现范围保护的行为。

零开销,我认为:

{
  try {
    do_something();
  } catch (...) {
    foo();
    throw;
  }
  foo();
}

至少 1 个字节的开销,为范围保护提供地址:

{
  scope_guard<foo> sg;
  do_something();
}

编译器会优化给出sg地址吗?

一个稍微复杂一点的案例:

{
  Bar bar;
  try {
    do_something();
  } catch (...) {
    foo(bar);
    throw;
  }
  foo(bar);
}

相对

{
  Bar bar;
  scope_guard<[&]{foo(bar);}> sg;
  do_something();
}

的生命周期bar完全包含 的生命周期sg及其持有的 lambda(以相反的顺序调用析构函数),但持有的 lambdasg仍然必须持有对bar. 例如,我的意思是在我的 64 位系统上int x; auto l = [&]{return x;};提供。sizeof(l) == 8

是否有一些模板元编程魔法可以在scope_guard没有任何开销的情况下实现糖?

4

2 回答 2

1

如果开销是指作用域保护变量占用了多少空间,那么如果功能对象是编译时值,则开销为零是可能的。我编写了小片段来说明这一点:

在线尝试!

#include <iostream>

template <auto F>
class ScopeGuard {
public:
    ~ScopeGuard() { F(); }
};

void Cleanup() {
    std::cout << "Cleanup func..." << std::endl;
}

int main() {
    {
        char a = 0;
        ScopeGuard<&Cleanup> sg;
        char b = 0;
        std::cout << "Stack difference "
            << int(&a - &b - sizeof(char)) << std::endl;
    }
    {
        auto constexpr f = []{
            std::cout << "Cleanup lambda..." << std::endl; };
        
        char a = 0;
        ScopeGuard<f> sg;
        char b = 0;
        std::cout << "Stack difference "
            << int(&a - &b - sizeof(char)) << std::endl;
    }
}

输出:


Stack difference 0
Cleanup func...
Stack difference 0
Cleanup lambda...

上面的代码甚至不会在堆栈上创建一个字节,因为任何没有字段的类变量都占用堆栈 0 字节,这是任何编译器都可以完成的明显优化之一。当然,除非您使用指向此类对象的指针,否则编译器必须创建 1 字节内存对象。但是在您的情况下,您不会向范围警卫发送地址。

通过查看代码上方的链接,您可以看到没有一个字节被占用Try it online!,它显示了 CLang 的汇编器输出。

要完全没有字段范围的保护类应该只使用编译时函数对象,比如没有捕获的 lambda 的全局函数指针。我上面的代码中使用了这两种对象。

在上面的代码中,您甚至可以看到我在作用域保护变量之前和之后输出了 char 变量的堆栈差异,以表明作用域保护实际上占用了 0 个字节。


让我们更进一步,让函数对象有非编译时值的可能性。

为此,我们再次创建没有字段的类,但现在将所有功能对象存储在一个具有线程本地存储的共享向量中。

同样,由于我们在类中没有字段并且不使用任何指向作用域保护对象的指针,因此编译器不会为堆栈上的作用域保护对象创建一个字节。

但相反,单个共享向量在堆中分配。这样,如果您的堆栈内存不足,您可以将堆栈存储换成堆存储。

同样拥有共享向量将允许我们使用尽可能少的内存,因为向量只使用与使用范围保护的嵌套块一样多的内存。如果所有作用域保护按顺序位于不同的块中,则向量内部将只有 1 个元素,因此对于使用的所有作用域保护只使用几个字节的内存。

为什么共享向量的堆内存在内存方面比范围保护的堆栈存储内存更经济。因为在堆栈内存的情况下,如果您有几个连续的警卫块:

void test() {
    {
        ScopeGuard sg(f0);
    }
    {
        ScopeGuard sg(f1);
    }
    {
        ScopeGuard sg(f2);
    }
}

那么所有 3 个守卫在堆栈上占用三倍的内存,因为对于像test()上面这样的每个函数,编译器为函数变量中使用的所有函数分配堆栈内存,所以对于 3 个守卫,它分配三倍的数量。

如果上面的共享向量test()函数将仅使用 1 个向量的元素,因此向量的大小最多为 1,因此将仅使用单个内存量来存储功能对象。

因此,如果您在一个函数中有许多非嵌套范围的守卫,那么共享向量将更加经济。

现在,我在下面展示了零字段和零堆栈内存开销的共享向量方法的代码片段。提醒一下,这种方法允许使用非编译时功能对象,这与我的答案之一中的解决方案不同。

在线尝试!

#include <iostream>
#include <vector>
#include <functional>

class ScopeGuard2 {
public:
    static auto & Funcs() {
        thread_local std::vector<std::function<void()>> funcs_;
        return funcs_;
    }
    ScopeGuard2(std::function<void()> f) {
        Funcs().emplace_back(std::move(f));
    }
    ~ScopeGuard2() {
        Funcs().at(Funcs().size() - 1)();
        Funcs().pop_back();
    }
};

void Cleanup() {
    std::cout << "Cleanup func..." << std::endl;
}

int main() {
    {
        ScopeGuard2 sg(&Cleanup);
    }
    {
        auto volatile x = 123;
        auto const f = [&]{
            std::cout << "Cleanup lambda... x = "
                << x << std::endl;
        };

        ScopeGuard2 sg(f);
    }
}

输出:

Cleanup func...
Cleanup lambda... x = 123
于 2022-01-20T17:50:31.767 回答
1

在这里,您所说的“零开销”并不完全清楚。

编译器会优化给 sg 一个地址吗?

在优化模式下运行时,最有可能的现代主流编译器会这样做。不幸的是,这是尽可能确定的。它取决于环境,必须经过测试才能可靠。

如果问题是if there is a guaranteed way to avoid <anything> in the resulting assembly,答案是否定的。正如@Peter 在评论中所说,编译器可以做任何事情来产生等效的结果。它可能根本不会调用foo()即使你将它逐字写在那里- 当它可以证明观察到的程序行为中的任何内容都不会改变时。

于 2021-11-18T23:45:46.523 回答