【问题标题】:Is explicitly calling destructors from constructors bad practice in C++?在 C++ 中从构造函数显式调用析构函数是不好的做法吗?
【发布时间】:2021-02-18 19:56:42
【问题描述】:

我通常不会显式调用析构函数。但我正在设计 TCP 服务器类,它看起来像这样:

class Server {
public:
    Server() {
        try {
            WSADATA wsaData;
            if (WSAStartup(MAKEWORD(2, 2), &wsaData))
                throw std::runtime_error("WSAStartup function failed.");
            ...

            if ((m_scListener = socket(pAddr->ai_family, pAddr->ai_socktype, pAddr->ai_protocol)) == INVALID_SOCKET)
                throw std::runtime_error("'socket' function failed.");
            ...
        }
        catch (std::exception& ex) {
            this->~Server();
            throw;
        }
    }

    ~Server() {
        if (m_scListener != INVALID_SOCKET) {
            closesocket(m_scListener);
            m_scListener = INVALID_SOCKET;
        }
        WSACleanup();
    }
private:
    SOCKET m_scListener = INVALID_SOCKET;
}

上面的代码是否被认为是不好的做法或设计?推荐的设计方法是什么?我是这样写的,因为构造函数不能返回 NULL。我应该将构造函数设为私有,并编写创建 Server 类实例的静态方法吗?

===== U P D A T E =====

好的,总结一下答案,我得出了这个结论:

  • 显式调用析构函数通常不是一个好主意,即使它按预期工作,这是不寻常的,其他将处理您的代码的 C++ 程序员可能会对这种方法感到困惑。所以最好避免显式调用析构函数。

  • 将我原来的 RAII 类分解为微型 RAII 类看起来是一个不错的解决方案。但我担心我的真实代码中有太多 API 调用需要清理(closesocket、CloseHandle、DeleteCriticalSection 等)。其中一些只被调用一次并且从未被重用,并且将它们全部移动到单独的 RAII 类对我来说似乎太狂热了。这也会增加我的代码。

  • 在我看来,最有帮助的答案来自 MM

更好的解决方案是将初始化代码保留在 构造函数,并在抛出前调用清理函数。

按照 M.M 的建议,我以这种方式重写了我的代码:

class Server {
public:
    Server() {
        WSADATA wsaData;
        if (WSAStartup(MAKEWORD(2, 2), &wsaData))
            ThrowError("WSAStartup function failed.", true);
        ...

        if ((m_scListener = socket(pAddr->ai_family, pAddr->ai_socktype, pAddr->ai_protocol)) == INVALID_SOCKET)
            ThrowError("'socket' function failed.", true);
        ...
    }

    ~Server() { CleanUp(); }

private:
    SOCKET m_scListener = INVALID_SOCKET;

    void ThrowError(const char* error, bool cleanUp) {
        if (cleanUp)
            CleanUp();
        throw std::runtime_error(error);
    }

    void CleanUp() {
        if (m_scListener != INVALID_SOCKET) {
            closesocket(m_scListener);
            m_scListener = INVALID_SOCKET;
        }
        WSACleanup();
    }
};

我相信这个设计遵循 RAII 模式,但只有一个类而不是 3-4 个微 RAII 类。

【问题讨论】:

  • 调用析构函数有特殊意义,它结束了objext的生命周期。当构造函数抛出时,生命周期从未开始。你不能从构造函数中调用析构函数。
  • 其实我测试了这个把MessageBox放到析构函数里面,当构造函数抛出异常的时候就出现了。
  • 你在这里玩的是未定义的行为。未定义行为的阴险之处在于,它看起来像是在工作,即使实际上并没有。
  • 显式调用析构函数根本是不好的做法。在这种情况下,这是完全没有必要的。
  • "其中一些只被调用一次并且从不重复使用,并且将它们全部移动到单独的 RAII 类中对我来说似乎太狂热了。这也会增加我的代码。"这是完全不正确的,如果处理得当,它会让你的代码更短更干净。

标签: c++ class exception constructor destructor


【解决方案1】:

在 C++ 中从构造函数显式调用析构函数是不好的做法吗?

是的。如果你调用一个尚未构造的对象的析构函数,程序的行为是不确定的。

有未定义的行为是一件坏事。应尽可能避免。


推荐的设计方法是什么?

遵循单一职责原则 (SRP),资源获取即初始化 (RAII) 模式。

尤其是你的Server 有太多的责任。您应该创建一个单独的类来管理套接字。在该类的构造函数中调用scoket,在析构函数中调用那个类,调用closesocket。保持包含的套接字始终有效(可关闭)或INVALID_SOCKET 的类不变量,并且如果有效并且永远不会泄漏(即,在没有先关闭的情况下永远不会覆盖该值),则始终是唯一的。这是 RAII 模式。

为 wsa 数据创建一个类似的包装器。

Server 中,存储这些包装器类型的成员。 Server 不需要自定义析构函数或其他特殊成员函数,因为它们由管理自己的成员处理。

【讨论】:

  • 如果我在 Win32 API 中遵循 SRP,那么每个 API 函数都会有它自己的类。我不认为我的案件违反了 SRP。
  • @TiberSeptim If I will follow SRP that way in Win32 API then every single API function will have it's own class. 我怀疑单个 Win32 API 函数是否需要清理。而且我怀疑您的程序需要调用每个 Win32 API 调用。如果您只调用一次特定的创建/销毁函数对,则可以按照另一个答案中的建议使用通用 RAII 包装器,即std::unique_ptr。但是,当功能良好、可重用时,可重用的命名类特别有用。 I don't think that my case violates the SRP. 我想是的。
【解决方案2】:

只能由完全构造的对象调用析构函数。

您可以创建一个 Init() 和 CleanUp() 函数,而不是将设置代码放在构造函数中。这也将使您的 Server 对象更快地构建。

class Server {
public:
    Server() = default;

    bool Init() {
      try {
            WSADATA wsaData;
            if (WSAStartup(MAKEWORD(2, 2), &wsaData))
                throw std::runtime_error("WSAStartup function failed.");
            ...

            if ((m_scListener = socket(pAddr->ai_family, pAddr->ai_socktype, pAddr->ai_protocol)) == INVALID_SOCKET)
                throw std::runtime_error("'socket' function failed.");
            ...
            return true;
        }
        catch (std::exception& ex) {
            return false;
        }
    }

    void CleanUp() {
        if (m_scListener != INVALID_SOCKET) {
            closesocket(m_scListener);
            m_scListener = INVALID_SOCKET;
        }
        WSACleanup();
    }

    ~Server() {
      CleanUp();
    }

private:
    SOCKET m_scListener = INVALID_SOCKET;
};

调用方代码:

Server server;
if (!server.init()) {
   server.CleanUp();
}

【讨论】:

  • 这就是我要做的——即使消费者稍后忘记手动调用它,也会进行清理。
  • 请注意,无论WSAStartup 是否被成功调用,这最终都会调用WSACleanup。它被调用了两次。
  • 这被称为“两阶段初始化”并且通常不被接受,因为它引入了初始化失败的“僵尸”对象的可能性。更好的解决方案是将初始化代码保留在构造函数中,并在抛出之前调用清理函数。
  • 我完全同意你的看法。我也更喜欢避免这种方法。我只是想为什么不显式调用析构函数,它与类的常规成员函数相同,但在释放之前自动调用。我只是无法完全理解为什么在我的情况下显式调用析构函数是不好的做法。至于我,析构函数与普通成员函数没有什么不同。
【解决方案3】:

推荐的设计方法是什么?

我会说:更多的 RAII。比如:

class WSARaii
{
public:
    WSARaii()
    {
        if (WSAStartup(MAKEWORD(2, 2), &wsaData))
            throw std::runtime_error("WSAStartup function failed.");
    }
    ~WSARaii()
    {
        WSACleanup();
    }
    WSARaii(const WSARaii&) = delete;
    WSARaii& operator =(const WSARaii&) = delete;

private:
    WSADATA wsaData;
};

class Socket
{
public:
    Socket(..) : m_scListener(socket(pAddr->ai_family, pAddr->ai_socktype, pAddr->ai_protocol) {
        if (m_scListener == INVALID_SOCKET)
            throw std::runtime_error("'socket' function failed.");
    }
    ~Server() {
        if (m_scListener != INVALID_SOCKET) {
            closesocket(m_scListener);
        }
    }
private:
    SOCKET m_scListener
};

最后

class Server {
public:
    Server() : wsa(), socket(..) {}

private:
    WSARaii wsa;
    Socket socket;
};

【讨论】:

  • 感谢您提供有用的 RAII 示例!它看起来很干净,我喜欢它。但我担心我的代码中有太多 API 需要将它们包装在 RAII 中。有些函数只调用一次(但需要像 CloseHandle() 等清理)。将它们全部包裹在 RAII 中有意义吗?
  • @TiberSeptim 但恐怕我的代码中有太多 API 需要将它们包装在 RAII 中 -- 如果有可能在当使用这些对象调用“初始化”和“结束”函数时,除了代码中的try/catch 块之外,您还有什么选择?您最好的选择是识别这些类并将它们包装在某种 RAII 包装器中(或使用带有自定义删除器的 std::unique_ptr)。
  • 感谢您的建议。您能否提供一个将 std::unique_ptr 与自定义删除器一起使用的示例。我不熟悉这个 Code Pattern。
  • 您也可以通过 API 实现一些通用的 RAII 类(如Finally)以避免一个类。
【解决方案4】:

我不知道在技术层面上会发生什么,但看起来不太好。我建议不要这样做。在您的班级中使用单独的 Init() 方法初始化高级系统(如网络等)要容易得多且不易出错的 IMO。这样您就可以安全地创建一个实例,调用其Init() 方法,检查结果,并在失败时调用delete(或调用Destroy(),或两者兼而有之)。

我只会在构造函数内部分配默认值,并让外部代码使用delete 调用您的析构函数。

【讨论】:

  • 当然,Init/Destroy 可以解决这个问题,但是存在 Server 类的使用者可能忘记调用 Destroy 的风险,但会自动调用析构函数。这就是为什么我决定把它放在构造函数/析构函数中。
  • 在这种情况下,您可以通过一些设置为 true 的简单 bInitialized 布尔值检查 WSA 是否已在析构函数中初始化,如果有,则调用 Destroy() 将其关闭。还要让 Destroy() 在执行清理之前做同样的检查,所以无论他们在删除之前是否 Destroy(),它总是会被调用一次。
【解决方案5】:

上面的代码是否被认为是不好的做法或设计?

是的,显式调用构造函数或析构函数几乎总是错误的,除了极少数情况,这不是那个。

推荐的设计方法是什么?

推荐的方法是使用 RAII。在这种情况下,您可以将std::unique_ptr 与调用closesocket() 等的自定义删除器一起使用。或者您可以创建自己的包装器。然后您就可以安全地抛出异常并确保初始化的资源得到正确清理。

【讨论】:

  • 你能提供一些伪代码作为例子吗?我在现代 C++ 方面不是很好。主要在 C# 上编程,有时在 C/C++ 中做一些低级的事情。另外,我的代码不遵循 RAII 吗?在构造函数中初始化WinSocket的东西并在析构函数中释放它们?
  • @TiberSeptim 不,您的代码没有遵循,每个资源都需要一个对象。 Jarod42 已经展示了如何使用 RAII。以下是如何使用 std::unique_ptrFILE gist.github.com/meshell/876f1e01c7e72994b126 的示例
【解决方案6】:

看看你的设计,你在构造函数的socket()调用中有这段代码:

pAddr->ai_family, pAddr->ai_socktype, pAddr->ai_protocol.

如果Server 类的用户想要使用不同的套接字类型、协议等。socket() 打开之前?它们没有追索权,因为它们被锁定在您在 pAddr 中使用的值中(您从未提及从何处获取这些值,但它们肯定是在 Server 构造函数之前或内部设置的)。

如果您将这些套接字参数设置为类的单个成员,则会打开类设计,因此无需调用对析构函数的错误调用,因为构造函数不会参与调用 socket()甚至WSAStartup

class Server 
{
    public:
        void set_family(int family) { m_family = family; }
        //.. other setters

        void start()
        {
            WSADATA wsaData;
            if (WSAStartup(MAKEWORD(2, 2), &wsaData))
                throw std::runtime_error("WSAStartup function failed.");

            if ((m_scListener = socket(m_family, m_type, m_protocol)) == INVALID_SOCKET)
                throw std::runtime_error("'socket' function failed.");
        }

        void stop()  
        {
            if (m_scListener != INVALID_SOCKET) 
            {
                closesocket(m_scListener);
                m_scListener = INVALID_SOCKET;
            }
            WSACleanup();
        }

        ~Server() noexcept
        {
           try 
           { 
              stop();
           }
           catch(...) { } // add any additional catches above this, 
                          // but make sure no exceptions escape the destructor
       }  

    private:
        SOCKET m_scListener = INVALID_SOCKET;
        int m_family = AF_INET;
        int m_type = SOCK_STREAM;
        int m_protocol = IPPROTO_TCP;
 };

这(对我来说)是一个更干净、更灵活的接口,不需要显式调用析构函数。 WinSock 的实际初始化和到套接字的连接只在start() 调用中完成。

此外,socket 的参数是初始化为基本值的成员变量,但可以在调用Server::start() 之前使用set... 函数进行更改。

另一个添加是Server 的析构函数中的try/catch。请注意,这样做是为了确保可以抛出的任何内容都不会逃脱析构函数调用,否则将调用 std::terminate

【讨论】:

  • 这些值作为参数传递给构造函数。我在这里发布的代码是我真实代码的简化版本,我只是想让我的帖子更紧凑,并从代码示例中删除一些不相关的东西,并将部分代码替换为“...”。
猜你喜欢
  • 1970-01-01
  • 2014-10-23
  • 2010-09-23
  • 2014-08-21
  • 2014-04-15
  • 2013-03-31
  • 2014-08-15
  • 2015-07-10
相关资源
最近更新 更多