如何强制非聚合类的成员就地构造?

huangapple go评论68阅读模式
英文:

How to enforce in-place member construction for non-aggregate classes?

问题

有时,我希望在类的某些成员中强制执行原地构造(特别是非聚合类),以便可能提高效率,或者为了一种确定性感(“我对它做了控制”)。

然而,我无法做到。问题在于下面尝试实现作用域守卫时有所说明。

一个简单的实现将是这样。我希望隐藏 _func_exec,并禁用复制/移动构造/赋值,因此聚合体不是一个选项。

template<class F>
class [[nodiscard]] at_scope_exit {
    F _func;
    bool _exec = true;
public:
    at_scope_exit(F&& f) noexcept(std::is_nothrow_move_constructible_v<F>):_func{ std::move(f) } {}
    void disable() noexcept { _exec = false; }
    at_scope_exit(const at_scope_exit&) = delete;
    at_scope_exit& operator=(const at_scope_exit&) = delete;
    ~at_scope_exit() {
        if (_exec) { _func(); }
    }
};

void useit() {
    int i = 1;
    at_scope_exit atexit{ [&] {i--; } };
}

在大多数情况下它运行良好。然而,它不能保证原地构造。

struct bad_closure {
    ~bad_closure() { std::cout << "~bad_closure()"; }
    void operator()() { std::cout << "operator()"; }
};

void problem() {
    at_scope_exit atexit{ bad_closure{} };
    //output: ~bad_closure()operator()~bad_closure()
}

撇开在实践中是否应该使用类似 bad_closure 的东西,构造然后移动的模式可能会让人感到不舒服。(在这种情况下,似乎编译器不允许进行拷贝省略)我如何消除这种可能性?

我应该如何修改 at_scope_exit 以便能够编写以下代码,而不用担心在构造时有额外的拷贝/移动?

void expected() {
    struct foo { ~foo() { std::cout << "~foo()"; } };
    int i = 1;
    at_scope_exit atexit{ [&i, f = foo{}] { i = 0; } };
    /*
    required output:
    ~foo()
    */
}
英文:

Sometimes I hope to enforce in-place construction of some members in a class(especially a non-aggregate one), for possible efficiency gain, or for a sense of certainty("I have control over what it does").

However, I cannot manage to do so. The problem is illustrated in the following attempt to implement a scope guard.

A trivial implementation will be like this. I hope to hide _func and _exec, and disable copy/move construction/assignment, so an aggregate is not an option.

template&lt;class F&gt;
class [[nodiscard]] at_scope_exit {
	F _func;
	bool _exec = true;
public:
	at_scope_exit(F&amp;&amp; f)noexcept(std::is_nothrow_move_constructible_v&lt;F&gt;):_func{ std::move(f) } {}
	void disable()noexcept { _exec = false; }
	at_scope_exit(const at_scope_exit&amp;) = delete;
	at_scope_exit&amp; operator=(const at_scope_exit&amp;) = delete;
	~at_scope_exit() {
		if (_exec) { _func(); }
	}
};

void useit() {
	int i = 1;
	at_scope_exit atexit{ [&amp;] {i--; } };
}

It works fine in most cases. However, it cannot guarantee in-place construction.

struct bad_closure {
	~bad_closure() { std::cout &lt;&lt; &quot;~bad_closure()&quot;; }
	void operator()() { std::cout &lt;&lt; &quot;operator()&quot;; }
};

void problem() {
	at_scope_exit atexit{ bad_closure{} };
	//output: ~bad_closure()operator()~bad_closure()
}

Leaving aside wether we should use something like bad_closure in practice, this possibility of construct-then-move pattern makes me uncomfortable.(In this certain case, it seems the compiler is not allowed to do copy elision) How can I eliminate this possibility?

How should I modify at_scope_exit to be able to write down the following code, without worrying about an extra copy/move on construction?

void expected() {
    struct foo { ~foo() { std::cout &lt;&lt; &quot;~foo()&quot;; } };
    int i = 1;
    at_scope_exit atexit{ [&amp;i,f = foo{}] { i = 0; } };
    /*
    required output:
    ~foo()
    */
}


</details>


# 答案1
**得分**: 0

以下是您要翻译的内容:

只有在聚合初始化时才能获得复制/移动省略,而不能通过构造函数调用获得。

虽然您无法在聚合类中将复制/移动特殊成员函数声明为`delete`,但您可以通过添加一个不可复制/不可移动的成员来强制隐式声明的成员函数被定义为`delete`或根本不被声明。

例如:

    struct non_movable {
        non_movable() = default;
        non_movable(const non_movable&) = delete;
        non_movable& operator=(const non_movable&) = delete;
    };

    template<class F>
    struct [[nodiscard]] at_scope_exit {
        F _func;

        class _exec_class : non_movable {
            bool _value = true;
            friend at_scope_exit;
        } _exec{};

        void disable() noexcept { _exec._value = false; }
        ~at_scope_exit() {
            if (_exec._value) { _func(); }
        }
    };

这样,用户也无法访问`_exec`值。

但是,用户仍然可以访问`_func`对象。我认为在仍然保持聚合初始化的情况下,没有办法避免这一点。复制/移动省略无论如何都意味着对象在调用者的上下文中构造,因此它对其有完全控制权。

---

另一种选择是不直接传递`_func`对象,而是只传递构造对象所需的信息,然后在`at_scope_exit`的构造函数中就地构造它。

但只有在传递该信息比传递对象本身更便宜,或者`F`不可复制/不可移动时才有意义。

在您的示例中,复制/移动lambda表达式是便宜的,如果不是因为编译器不允许删除析构函数中的输出,生成的代码中可能没有实际成本。无论复制/移动省略规则如何,只要可观察的行为没有改变,编译器始终可以以任何方式进行优化。如果不是因为析构函数中的输出,对`closure`进行复制不会有任何可观察的行为差异。

我可以想到三种实现方法:

1. `at_scope_exit`的构造函数接受可变数量的参数,并将它们完美地转发以就地构造`F`对象:

    template<typename... Args>
    at_scope_exit(Args&&... args) : _func(std::forward<Args>(args)...) { }

2. `at_scope_exit`的构造函数接受一个可调用对象,该对象生成该对象:

    template<std::invocable_r<F> U>
    at_scope_exit(U&& u) : _func(std::invoke(std::forward<U>(u))) { }

3. `at_scope_exit`的构造函数接受可以转换为`F`的对象:

    template<std::convertible_to<F> U>
    at_scope_exit(U&& u) : _func(static_cast<F>(std::forward<U>(u))) { }

这些方法各有利弊。

在情况1和情况3中,用户需要显式提供类型`F`,而在情况2中,可能仍然可以编写一个类型推导指南,以使CTAD仍然能够工作

可能在所有情况下,都有意义添加另一个参数作为标签,以允许仍然使用简单的复制/移动构造函数,并且明确定义,以便不将构造函数用作复制/移动构造函数。

(我假设C++20或更高版本。)

<details>
<summary>英文:</summary>

You can only get copy/move elision with aggregate initialization, not with a constructor call.

While you can&#39;t declare the copy/move special members functions as `delete`d in an aggregate class, you can enforce that the implicitly-declared member functions are defined as `delete`d or not declared at all by adding a member that is not copyable/movable.

For example:

    struct non_movable {
        non_movable() = default;
        non_movable(const non_movable&amp;) = delete;
        non_movable&amp; operator=(const non_movable&amp;) = delete;
    };

    template&lt;class F&gt;
    struct [[nodiscard]] at_scope_exit {
        F _func;

        class _exec_class : non_movable {
            bool _value = true;
            friend at_scope_exit;
        } _exec{};

        void disable() noexcept { _exec._value = false; }
        ~at_scope_exit() {
            if (_exec._value) { _func(); }
        }
    };

This way it is also impossible for a user to access the `_exec` value.

However, it is still possible for a user to access the `_func` object. I don&#39;t think there is any way to avoid this while still maintaining aggregate initialization. Copy/move elision does anyway imply that the object is constructed in the context of the caller, so it has full control over it.

---

The other alternative is to not pass the `_func` object directly, but instead to pass only information needed to construct the object and then in-place construct it in a constructor of `at_scope_exit`.

But that only makes sense if passing that information would be cheaper than passing the object itself or if `F` isn&#39;t copyable/movable.

In your examples copying/moving the lambdas is cheap and there is probably no actual cost in the generated code if it weren&#39;t for your output in the destructor which the compiler is not allowed to remove. Regardless of copy/move elision rules the compiler is always allowed to optimize in any way as long as the observable behavior doesn&#39;t change. There wouldn&#39;t be any observable behavior difference between doing the copy of `closure` and not doing it if it weren&#39;t for the output in the destructor.

There are three ways to achieve this that I can think of:

1. A constructor for `at_scope_exit` which takes a variadic number of arguments and perfectly-forwards them to in-place construct the `F` object:

        template&lt;typename... Args&gt;
        at_scope_exit(Args&amp;&amp;... args) : _func(std::forward&lt;Args&gt;(args)...) { }

2. A constructor for `at_scope_exit` which takes a callable that produces the object:

        template&lt;std::invocable_r&lt;F&gt; U&gt;
        at_scope_exit(U&amp;&amp; u) : _func(std::invoke(std::forward&lt;U&gt;(u))) { }

3. A constructor for `at_scope_exit` which takes an object that can be converted to `F`:
    
        template&lt;std::convertible_to&lt;F&gt; U&gt;
        at_scope_exit(U&amp;&amp; u) : _func(static_cast&lt;F&gt;(std::forward&lt;U&gt;(u))) { }

These all have various pros and cons.

In cases 1. and 3. the user would need to provide the type `F` explicitly, while it would be possible to write a deduction guide for 2. that will still allow for CTAD to work.

Probably, in all cases, it would make sense to add a another parameter as a tag to allow the simple copying/moving constructor to still be used and clearly distinguished and so that the constructors aren&#39;t used as copy/move-like constructors.

(I am assuming C++20 or later.)

</details>



# 答案2
**得分**: 0

For your first case, you are passing a prvalue to `_func`, which requires 点击复制[1] for the compiler.

And the second case. In your constructor of `at_scope_exit_class`, you bind it to an rvalue reference (`f`). And your destructor of `closure` has observable side-effect. This prevents the compiler from optimizing.

For your question, your `closure` should have a constructor and destructor without any observable side-effect. And the compiler will be able to optimize.

[1]: https://en.cppreference.com/w/cpp/language/copy_elision

<details>
<summary>英文:</summary>

Assuming your are using C++17 or later. 

For your first case, you are passing a prvalue to `_func`, which requires 点击复制[1] for compiler. 

And the second case. In your constructor of `at_scope_exit_class`, you bind it to a rvalue reference(`f`). And your deconstructor of `closure` has observable side-effect. This prevents compiler from optimizing.

For your question, your `closure` should have constructor and deconstructor without any observable side-effect. And the compiler will be able to optimize.

  [1]: https://en.cppreference.com/w/cpp/language/copy_elision

</details>



huangapple
  • 本文由 发表于 2023年5月7日 18:06:47
  • 转载请务必保留本文链接:https://go.coder-hub.com/76193247.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定