运算符重载:修改临时对象或创建新对象

Operator overloading: Modify temporary object or create new one

提问人:phlipsy 提问时间:8/25/2023 更新时间:8/25/2023 访问量:157

问:

我在我们的项目中看到了以下代码,并问自己技术和心理含义是什么:

class A {
public:
    A(const A&);
    A(A &&);
    ~A();

    A &operator += (const A &);
    A operator + (const A &);
private:
    class B;
    B *b;
};

A factory();
void sink(const A &);

void foo(const A &x) {
    sink(factory() += x); // <--
}

在突出显示的行中,我期望.我在审查过程中指出了这一点,并得到的答复是,由于没有创建第二个临时对象,因此当前的实现效率更高。作为一个有自尊心的书,我立即在这里检查了这个声明,并得到了以下程序集输出。sink(factory() + x)x86_64

foo(A const&):
   push    rbx
    mov     rbx, rdi
    sub     rsp, 16
    lea     rdi, [rsp+8]
    call    factory()
    mov     rsi, rbx
    lea     rdi, [rsp+8]
    call    A::operator+=(A const&)
    mov     rdi, rax
    call    sink(A const&)
    lea     rdi, [rsp+8]
    call    A::~A()
    add     rsp, 16
    pop     rbx
    ret

bar(A const&):
    push    rbx
    mov     rbx, rdi
    sub     rsp, 16
    mov     rdi, rsp
    call    factory()
    mov     rdx, rbx
    mov     rsi, rsp
    lea     rdi, [rsp+8]
    call    A::operator+(A const&)
    lea     rdi, [rsp+8]
    call    sink(A const&)
    lea     rdi, [rsp+8]
    call    A::~A() [complete object destructor]
    mov     rdi, rsp
    call    A::~A() [complete object destructor]
    add     rsp, 16
    pop     rbx
    ret

你显然保存了一个临时对象!但从直觉上讲,我仍然更喜欢加号运算符,并希望编译器能够通过就地构造这些对象来优化临时对象,这样就不必破坏第二个临时对象。

你对这种“优化”有什么看法?还有其他赞成和反对的论点吗?有没有可能的方法可以保存加号运算符方法?我一直认为移动语义会处理这样的事情,它会阻止所有这些临时对象,将我们从过去的代理对象中拯救出来,让我们编写更直接和实用的代码等等......

C++ 运算符重载 move-semantics 返回值优化

评论

1赞 463035818_is_not_an_ai 8/25/2023
通常,修改左侧对象,同时返回一个新对象。您遗漏了重要的细节,但这就是代码的作用。+=+
0赞 463035818_is_not_an_ai 8/25/2023
要修改就写,不想修改就写。这与移动语义无关xx += axy = x +a
1赞 Louis Go 8/25/2023
你有问题的代码与你的 godbolt 链接不同。

答:

1赞 Alan Birtles 8/25/2023 #1

编译器不知道如何实现,因此它无法知道任何构造函数、析构函数或运算符是否有副作用,因此它无法证明优化它们满足了“仿佛”规则,因此它必须完全按照编写的代码保留代码。A

评论

2赞 Mooing Duck 8/25/2023
编译器足够聪明,可以在所有定义都提供时对其进行优化 godbolt.org/z/sqvq73r39
0赞 fabian 8/25/2023 #2

您可以在命名空间范围内引入以下 4 个重载(或成员运算符的 add 和修饰符),并利用表达式导致重载 take 优先于重载 take 这一事实。至少在其中一个操作数是右值引用的情况下,这允许您返回此引用。请注意,如果尝试通过将操作结果绑定到引用来延长操作结果的生存期,这可能会导致令人讨厌的意外。&&&factory()A&&A const&+

请注意,为简单起见,运算符在以下代码中不包含任何有意义的实现,而只是演示了重用传递给操作数的对象(如果它是右值)的可能性。

struct A
{
    int m_dummy{ 1 };

    friend constexpr A operator+(A const& s1, A const& s2)
    {
        return {};
    }

    friend constexpr A&& operator+(A&& s1, A const& s2)
    {
        return std::move(s1);
    }

    friend constexpr A&& operator+(A&& s1, A&& s2)
    {
        return std::move(s1);
    }

    friend constexpr A&& operator+(A const& s1, A&& s2)
    {
        return std::move(s2);
    }
};

A const* address(A const& a)
{
    return &a;
}

int main() {
    std::cout << std::boolalpha;

    A s1;
    A s2;

    // none of the operands is an rvalue -> create a new temporary
    std::cout << (address(s1 + s2) == &s1) << '\n'; // false
    std::cout << (address(s1 + s2) == &s2) << '\n'; // false

    // same scenario as sink(factory() + x); here...
    // the first operand is an rvalue
    std::cout << (address(std::move(s1) + s2) == &s1) << '\n'; // true

    // second operand is an rvalue
    std::cout << (address(s1 + std::move(s2)) == &s2) << '\n'; // true

    // both operands are rvalues
    std::cout << (address(std::move(s1) + std::move(s2)) == &s1) << '\n'; // true
}

不过,通常您不会期望以下代码会导致未定义的行为,这就是为什么我不会像这样重载运算符的原因。(您可以通过实现 for 的移动语义并按值返回来避免此问题;但是,这将导致 的 移动构造 的新实例。AA

int main() {
    A const& value = A{} + A{};
    std::cout << value.m_dummy << '\n'; // reading from a dangling reference here -> UB
}

评论

0赞 phlipsy 9/1/2023
有趣的是,我将研究这些重载。你对 vs. 问题?为了避免,是否值得提供您的超载?+=++=
0赞 fabian 9/2/2023
@phlipsy 取决于通过避免从头开始构建新对象可以节省多少。如果构造成本低廉,并且您没有探查器显示涉及此运算符的代码部分是瓶颈,请选择您认为更具可读性的代码。不过,我绝对不会在同一个语句中这样做,因为恕我直言,这会降低可读性;我会选择相同的效果(假设返回)。+={ auto temp = factory(); temp += x; sink(temp);+=*this