一个类的构造函数永远阻塞是可以的吗?

41

假设我有一个对象,在无限循环中提供某种功能。

将无限循环放在构造函数中是否可行?

例如:

class Server {
    public:
    Server() {
        for(;;) {
            //...
        }
    }
};

如果构造函数从未完成,C++中是否存在固有的初始化问题?

(意思是运行服务器只需在某个线程中键入Server server;即可...)


14
我认为这样做没有什么“问题”,但是对于你的类的用户来说可能会很困惑。为什么不在server::run函数或类似的函数中放置循环呢? - Peter
11
因为大多数人(我假设)不希望构造函数被阻塞,更不用说永远不返回。 - Peter
15
我相信在构造函数运行之前,你的对象是无效的。因此,从其他地方访问你的对象可能会导致未定义的行为。 - Galik
27
你能不能只写一个函数? - Galik
35
从技术上讲这是没问题的,但在我看来它有代码异味。按照单一职责原则,我不希望 Server server; 创建并运行服务器。Server server; server.run(); 更易读且更易维护。 - rustyx
显示剩余6条评论
6个回答

76

这不是按照标准做法,而是一种不好的设计。

构造函数通常不会阻塞。它们的目的是将一块原始内存转换为有效的C++对象。析构函数则相反:它们将有效的C++对象转换回原始内存块。

如果你的构造函数永远(强调永远)阻塞,它所做的事情就不同于只是将一块内存转换成对象了。 如果阻塞时间很短(互斥锁就是一个很好的例子),以服务于对象的构造,则可以阻塞。 在你的情况下,看起来你的构造函数正在接受和为客户端提供服务。这不是将内存转换为对象。

我建议你将构造函数拆分为一个“真正的”构造函数,用于构建服务器对象,再添加另外一个start方法来为客户端提供服务(通过启动事件循环)。

注:在某些情况下,必须从构造函数中单独执行对象的功能/逻辑,例如如果你的类继承自std::enable_shared_from_this


4
我基本上同意你的看法,尽管现代构造函数似乎并不总是仅将内存转换为对象。例如,你提到的互斥锁示例或C ++线程构造函数会立即启动给定的功能。有人可能会认为创建服务器是构造函数的作用... - CaptainCodeman
2
几个事情:1)std::thread背后有一些不好的决策。这就是为什么std::jthread被标准化的原因。2)我实际上更倾向于线程对象具有启动方法3)的确,有时我们会滥用我们自己的原则。软件工程原则是经验法则-通常它们帮助我们。很少情况下,我们打破它们,因为它们阻止我们做一些更简单、更正确的事情。 - David Haim
2
在RAII构造函数中打开文件是一种合理的操作;这可能会阻塞I/O以进行路径名查找,潜在地需要数十毫秒,或在负载繁重的系统上需要数百毫秒。或者需要几秒钟来启动处于空闲状态的磁盘驱动器。(或在火星上挂载NFS服务器...) - Peter Cordes
2
@CaptainCodeman 是正确的:RAII 表示构造函数应该不仅仅是内存操作。 - Paul Draper
7
强调“永远”。阻止获取资源和永久阻止是非常不同的。 - Matthieu M.
显示剩余2条评论

18

是允许这样做的。但像其他无限循环一样,它必须具有可观察的副作用,否则你将得到未定义行为

调用网络功能被视为“可观察的副作用”,因此您是安全的。此规则仅禁止那些真正不执行任何操作或只是在数据之间移动而不与外部世界交互的循环。


谢谢。UB是什么? - CaptainCodeman
7
“未定义行为”(Undefined behavior)是指当违反语言规则时发生的各种不可预测的行为,这是标准所称的(可能会出现任何结果,包括代码按预期工作,直到某些无关的东西发生改变)。 - HolyBlackCat
1
@CaptainCodeman - C程序员应该了解的未定义行为知识 - Peter Cordes

16

这是合法的,但最好避免使用。

主要问题在于应该避免惊讶用户。拥有一个永远不会返回的构造函数是不合逻辑的,因此构造一个永远不能使用的东西是不寻常的。虽然这种模式可能有效,但不太可能符合预期行为。

第二个问题是它限制了如何使用你的Server类。C++的构造和析构过程是语言的基础,因此劫持它们可能会很棘手。例如,一个人可能想要一个Server作为类的成员,但现在那个超越的类的构造函数将阻塞......即使这并不直观。这也使得将这些对象放入容器中变得非常困难,因为这可能涉及到分配许多对象。

我能想到的与您所做的最接近的是std::thread。线程并不永远阻塞,但它确实有一个构造函数,执行的工作量相当大。但如果看一下std::thread,就会意识到,在多线程方面,被惊讶是正常的,因此人们对这样的选择没有太大问题。(我个人不知道启动线程时构造函数的原因,但在多线程中有这么多的边角情况,如果它解决了其中一些问题,我不会感到惊讶)


4

用户可能期望在主线程中设置您的Server对象。然后在工作线程中调用server.endless_loop()函数。

在实际服务器中,获取端口的过程需要提升特权,然后可以放弃。或者您有一个需要加载设置的对象。这些任务可以在长期循环发生之前在主线程中进行。

个人而言,我更喜欢您的对象拥有一个快速且非阻塞的“poll”函数。然后您可以有一个循环函数,在其中调用poll和sleep,形成无限循环。您甚至可以使用原子变量来设置从不同线程退出循环的方式。另一个功能是在Server对象内部启动内部线程。


2
正如其他人指出的那样,就C++语义而言,这没有什么“错误”,但它是设计不良。构造函数的目的是构造一个对象,因此如果该任务从未完成,则对用户来说将会是令人惊讶的。
其他人提出了关于将构建和运行步骤拆分为构造函数和方法的建议,如果您有其他想要在运行Server之外做的事情,或者如果您实际上想要构建它、做其他事情,然后再运行它,这是有道理的。
但是,如果您期望调用者总是只执行Server server; server.run(),那么您甚至不需要一个——它可以是一个独立的函数run_server()。如果您没有要封装和传递的状态,则不一定需要对象。一个独立的函数甚至可以被标记为[[noreturn]],以使用户和编译器清楚地知道该函数永远不会返回。
不知道您的使用情况,很难说哪种更合理。但简而言之:构造函数构造对象——如果您正在做其他事情,请不要使用它们。

2
在大多数情况下,你的代码没有问题。这是由于以下规则所导致的:
一个类在类说明符的结束}处被视为完全定义的对象类型([basic.types])(或完整类型)。在类成员说明中,在函数体、默认参数、noexcept-specifiers和默认成员初始化程序(包括嵌套类中的这些东西)中,该类在函数体内被视为完整的。否则,在其自己的类成员说明中,它被视为不完整。
然而,对于你的代码有一个限制,即不能使用不通过指向 this 指针获得的 glvalue 访问此对象,因为行为是未指定的。这是由以下规则控制的:
在对象构造期间,如果通过不是直接或间接从构造函数的this指针获得的glvalue访问对象或其任何子对象的值,则所获得对象或子对象的值是未指定的。
此外,你不能使用实用程序 shared_ptr 来管理这种类对象。一般来说,在构造函数中放入无限循环不是一个好主意,当你使用该对象时会应用许多限制。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接