代码之家  ›  专栏  ›  技术社区  ›  John Smith

为什么全局对象析构函数在初始化引用后被调用两次?

c++
  •  1
  • John Smith  · 技术社区  · 11 月前

    如果我有条件地初始化一个const引用来引用全局对象或函数返回的临时对象,那么在这两种情况下,当析构函数超出作用域时,编译器都会调用析构函数,因此全局对象会被销毁两次。

    #include <stdio.h>
    #include <string>
    
    using namespace std;
    
    struct S {
        string name;
        S(const string & n) : name(n) {
            printf("%s(%s)\n", __func__, name.c_str());
        }
        ~S() {
            printf("%s(%s)\n", __func__, name.c_str());
        }
        void g() const {
            printf("%s(%s)\n", __func__, name.c_str());
        }
    };
    
    S f() {
        return S("f");
    }
    
    S s("global");
    
    int main(int argc, char ** argv) {
        (void)argc, (void)argv;
        printf("%s: begin\n", __func__);
        for (int i=0; i<2; i++) {
            const auto & z = i ? s : f();
            z.g();
        }
        printf("%s: end\n", __func__);
    }
    

    打印此内容:

    S(global)
    main: begin
    S(f)
    g(f)
    ~S(f)
    g(global)
    ~S(global)
    main: end
    ~S(global)
    

    在GCC 8.5、14.2等版本上进行了测试 https://www.programiz.com/online-compiler/6YuASdDFxt7dE 使用。

    编辑: 请注意,如果我将条件表达式替换为just const auto & z = s; 那么编译器在每次循环迭代后不会尝试调用其析构函数。我想条件表达式中有什么东西会在没有警告的情况下中断生命周期的延长?

    编辑2: 谜团解决了,我忘记插入的复制操作符创建了一个额外的副本,因此析构函数正常工作。虽然代码没有按预期工作,但它没有将引用绑定到全局变量,而是将其绑定到它的临时副本,以匹配另一个分支。感谢所有提供帮助的人。

    3 回复  |  直到 11 月前
        1
  •  3
  •   Marek R    11 月前

    这是你的代码, modernized to use C++23 特征。
    这揭示了真正的问题所在。

    首先,你应该熟悉 rule of 3/5/0 ( /7*将在C++26中引入,目前不相关 -如果这是真的,我在网上看到的这个大胆的说法找不到官方的证实)。

    默认情况下,当没有手动定义任何内容时,编译器会自动为构造函数和赋值运算符生成默认实现。以下是一个简洁的表格,简要介绍了其工作原理:

    enter image description here

    在原始代码中,使用了隐式生成的复制构造函数。具体来说,你的代码无意中生成了一个额外的实例 S 通过复制构造函数。

    这发生在以下行中:

    const auto& z = i ? s : f();
    

    这是一个极端情况。请注意 f() 返回一个值,而不是引用。对于这种情况,有一个定义明确的例外,称为“临时对象的生命周期延长”。本质上,当一个临时对象被分配给引用时,它的生命周期会被延长以匹配引用的生命周期。

    在这种情况下,由于您使用的是三元运算符和 z 可以指两种不同的场景:

    1. 简单参考全球 s .
    2. 延长由返回的临时对象的生命周期的引用 f .

    编译器必须协调这两种可能性,以使 z 在每次迭代中。正确的做法是在这两种情况下都使用第二种情况,这意味着要复制 s 创建。

    As alagner 指出用更简单的运算符代替三元运算符 if hides this extra copy 由于存在两个独立的实例 z 它们可以具有不同的行为。

        2
  •  2
  •   alagner    11 月前

    这一切都归结为三元运算符产生的类型。检查类型特征。

    这个 static_assert(std::is_same_v<decltype(i ? s : f()), S>, "!"); 编译,同时 那个 static_assert(std::is_same_v<decltype(i ? s : f()), S&>, "!"); 未能做到。

    这可能违反直觉,但请注意 S f(); 按值返回。因此,三元表达式的结果类型将是副本,而不是引用。事实上,三元运算符能够返回引用,但这不是必需的。 您可以使用以下方法解决此问题 if 而不是三元运算符,例如:

    if (i) {
        const auto &z = s;
        z.g();
    } else {
        const auto &z = f();
        z.g();
    }
    

    由于重复,这可能是次优的(就DRY等规则而言) z.g() 在这两个代码路径中,但这只是为了示例。请随意调整以满足您的要求。

    使用if-else语句(以及插入指令的复制构造函数)进行演示: https://godbolt.org/z/MGbG61x59

    另一种选择可能是 f() 返回一个引用。它是否适合你在现实生活中的需求是一个悬而未决的问题。为了示例,我在内部使用了一个静态对象 f .

    S& f() {
        static S x("f");
        return x;
    }
    

    演示与 S& f() : https://godbolt.org/z/YWfTn55Gc

        3
  •  -4
  •   Kozydot    11 月前

    这是由于C++如何管理对象和引用的生存期,特别是在循环中自动存储持续时间的情况下。

    核心问题不是编译器混淆或不知道它是全局的。问题在于 const auto& z 循环中的变量。在循环的每次迭代中, z 是在该迭代范围内声明的新引用。

    为什么是双重毁灭?

    • 参考文献 z 在循环的作用域内声明。
    • 在每次循环迭代结束时,在该范围内声明具有自动存储持续时间的变量将被销毁。
    • 尽管 z 在第二次迭代中引用全局对象,编译器会处理 z 将自身作为本地引用,并在以下情况下调用所引用对象的析构函数 z 超出范围。

    如果你打算让引用潜在地指向一个全局对象,并希望避免额外的破坏,你需要以不同的方式管理生命周期。

    方法很少 :

    在循环外声明引用 :

    int main(int argc, char ** argv) {
        (void)argc, (void)argv;
        printf("%s: begin\n", __func__);
        const S& z = s; // Initialize with the global initially
        for (int i=0; i<2; i++) {
            if (i == 0) {
                const S temp = f(); // Create a temporary
                z = temp;           // Try to reassign - ERROR: references cannot be reseated
            } else {
                z.g(); // Access the global
            }
        }
        printf("%s: end\n", __func__);
    }
    

    使用指针:

    int main(int argc, char ** argv) {
        (void)argc, (void)argv;
        printf("%s: begin\n", __func__);
        const S* pz = &s;
        S temp;
        for (int i=0; i<2; i++) {
            if (i == 0) {
                temp = f();
                pz = &temp;
            }
            pz->g();
        }
        printf("%s: end\n", __func__);
    }
    

    循环外的条件逻辑:

    int main(int argc, char ** argv) {
        (void)argc, (void)argv;
        printf("%s: begin\n", __func__);
        {
            const auto & z = f();
            z.g();
        }
        {
            const auto & z = s;
            z.g();
        }
        printf("%s: end\n", __func__);
    }