一点C ++模板魔术和CRTP来控制编译时程序员动作的正确性

最近,在开发新版本的SObjectizer时 ,我面临着在编译时控制开发人员动作的任务。 底线是以前程序员可以调用以下形式:


receive(from(ch).empty_timeout(150ms), ...); receive(from(ch).handle_n(2).no_wait_on_empty(), ...); receive(from(ch).empty_timeout(2s).extract_n(20).stop_on(...), ...); receive(from(ch).no_wait_on_empty().stop_on(...), ...); 

receive()操作需要一组参数,为此使用了一系列方法,例如以上from(ch).empty_timeout(150ms)from(ch).handle_n(2).no_wait_on_empty() 。 同时,调用handle_n()/ extract_n()方法可以限制要提取/处理的消息数,这是可选的。 因此,上面显示的所有链条都是正确的。


但是在新版本中,需要强制用户明确指示要提取和/或处理的消息数。 即 from(ch).empty_timeout(150ms)形式的链现在变得不正确。 应该用from(ch).handle_all().empty_timeout(150ms)


而且我想这样做,以便在程序员忘记调用handle_all(),handle_n()或extract_n()的情况下,使编译器能够击败程序员。


C ++可以帮助您吗?


是的 如果有人对确切的方法感兴趣,那么欢迎您。


除了接收()函数外


上面显示了receive()函数,使用调用链(也称为构建器模式 )设置了参数。 但是还有一个select()函数,它接收几乎相同的参数集:


 select(from_all().empty_timeout(150ms), case_(...), case_(...), ...); select(from_all().handle_n(2).no_wait_on_empty(), case_(...), case_(...), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...); select(from_all().no_wait_on_empty().stop_on(...), case_(...), case_(...), ...); 

因此,我想获得一个适用于select()和receive()的解决方案。 此外,用于select()和receive()本身的参数已在代码中表示出来,从而避免了复制粘贴。 但这将在下面讨论。


可能的解决方案


因此,任务是让用户无故障地调用handle_all(),handle_n()或extract_n()。


原则上,这可以在不依靠任何复杂决定的情况下实现。 例如,您可以为select()输入一个附加参数并接收():


 receive(handle_all(), from(ch).empty_timeout(150ms), ...); select(handle_n(20), from_all().no_wait_on_empty(), ...); 

或者有可能迫使用户以不同的方式进行receive()/ select()调用:


 receive(handle_all(from(ch).empty_timeout(150ms)), ...); select(handle_n(20, from_all().no_wait_on_empty()), ...); 

但是这里的问题是,当切换到新版本的SObjectizer时,用户将不得不重做他的代码。 即使原则上不需要重新编写代码。 说,在这种情况下:


 receive(from(ch).handle_n(2).no_wait_on_empty(), ...); select(from_all().empty_timeout(2s).extract_n(20).stop_on(...), case_(...), case_(...), ...); 

我认为,这是一个非常严重的问题。 这使您寻找另一种方式。 并且将在下面描述该方法。


那么CRTP进入哪里呢?


文章标题提到CRTP。 他还是一个反复出现的模板模式(那些想熟悉这种有趣但略带大脑容忍度的技术的人,可以从Fluent C ++博客上的系列文章开始)。


之所以提到CRTP,是因为通过CRTP,我们实现了使用receive()和select()函数参数的工作。 由于接收()和选择()的参数所占的份额是相同的,因此代码使用了以下内容:


 template<typename Derived> class bulk_processing_params_t { ...; //     . Derived & self_reference() { return static_cast<Derived &>(*this); } ... public: auto & handle_n(int v) { to_handle_ = v; return self_reference(); } ... auto & extract_n(int v) { to_extract_ = v; return self_reference(); } ... }; class receive_processing_params_t final : public bulk_processing_params_t<receive_processing_params_t> { ...; //   receive . }; class select_processing_params_t final : public bulk_processing_params_t<select_processing_params_t> { ...; }; 

为什么在这里完全使用CRTP?


我们在这里必须使用CRTP,以便在基类中定义的setter方法可以返回对基本类型的引用,而不是对派生类型的引用。


也就是说,如果不是使用CRTP,而是普通继承,那么我们只能这样写:


 class bulk_processing_params_t { public: //      bulk_processing_params_t, //     . bulk_processing_params_t & handle_n(int v) {...} bulk_processing_params_t & extract_n(int v) {...} ... }; class receive_processing_params_t final : public bulk_processing_params_t { public: //      //   bulk_processing_params_t,    // receive_processing_params_t. ... //       //  receive_processing_params_t. receive_processing_params_t & receive_payload(int v) {...} }; class select_processing_params_t final : public bulk_processing_params_t { public: //      //   bulk_processing_params_t,    // select_processing_params_t. ... }; 

但是这种原始机制将不允许我们使用相同的构建器模式,因为:


 receive_processing_params_t{}.handle_n(20).receive_payload(0) 

没有编译。 handle_n()方法将返回对bulk_processing_params_t的引用,并且尚未定义receive_payload()方法。


但是使用CRTP时,构建器模式没有问题。


最终决定


最终的解决方案是使最终类型(例如receive_processing_params_t和select_processing_params_t)本身成为模板类型。 这样就可以使用以下形式的标量对它们进行参数化:


 enum class msg_count_status_t { undefined, defined }; 

这样最终的类型可以从T <msg_count_status_t :: undefined>转换为T <msg_count_status_t :: defined>。


例如,这将允许在receive()函数中接收receive_processing_params_t并在comp-time中检查Status值。 类似于:

 template< msg_count_status_t Msg_Count_Status, typename... Handlers > inline mchain_receive_result_t receive( const mchain_receive_params_t<Msg_Count_Status> & params, Handlers &&... handlers ) { static_assert( Msg_Count_Status == msg_count_status_t::defined, "message count to be processed/extracted should be defined " "by using handle_all()/handle_n()/extract_n() methods" ); 

通常,一切都像往常一样简单:采取并执行;)


决策说明


让我们看一个最小的示例,它看起来与SObjectizer的细节无关。


因此,我们已经有一个类型可以确定是否设置了邮件数量限制:


 enum class msg_count_status_t { undefined, defined }; 

接下来,我们需要一个结构,其中将存储所有通用参数:


 struct basic_data_t { int to_extract_{}; int to_handle_{}; int common_payload_{}; }; 

通常,basic_data_t的内容是什么都没有关系。 例如,上面显示的最小字段集是合适的。


对于basic_data_t,重要的是对于特定操作(无论是接收(),选择()还是其他操作),将创建其自己的具体类型来继承basic_data_t。 例如,对于我们抽象示例中的receive(),它将是以下结构:


 struct receive_specific_data_t final : public basic_data_t { int receive_payload_{}; receive_specific_data_t() = default; receive_specific_data_t(int v) : receive_payload_{v} {} }; 

我们假设basic_data_t结构及其后代不会引起困难。 因此,我们继续进行解决方案中更复杂的部分。


现在我们需要一个围绕basic_data_t的包装器,该包装器将提供getter方法。 这将是以下形式的模板类:


 template<typename Basic_Data> class basic_data_holder_t { private : Basic_Data data_; protected : void set_to_extract(int v) { data_.to_extract_ = v; } void set_to_handle(int v) { data_.to_handle_ = v; } void set_common_payload(int v) { data_.common_payload_ = v; } const auto & data() const { return data_; } public : basic_data_holder_t() = default; basic_data_holder_t(Basic_Data data) : data_{std::move(data)} {} int to_extract() const { return data_.to_extract_; } int to_handle() const { return data_.to_handle_; } int common_payload() const { return data_.common_payload_; } }; 

此类是样板程序,因此它可以包含basic_data_t的任何继承者,尽管它仅对basic_data_t中的那些字段实现getter方法。


在继续解决方案的更复杂部分之前,您应该注意basic_data_holder_t中的data()方法。 这是一种重要的方法,我们稍后会遇到。


现在我们进入关键模板类,对于不太热衷于现代C ++的人来说,它看起来很吓人:


 template<typename Data, typename Derived> class basic_params_t : public basic_data_holder_t<Data> { using base_type = basic_data_holder_t<Data>; public : using actual_type = Derived; using data_type = Data; protected : actual_type & self_reference() { return static_cast<actual_type &>(*this); } decltype(auto) clone_as_defined() { return self_reference().template clone_if_necessary< msg_count_status_t::defined >(); } public : basic_params_t() = default; basic_params_t(data_type data) : base_type{std::move(data)} {} decltype(auto) handle_all() { this->set_to_handle(0); return clone_as_defined(); } decltype(auto) handle_n(int v) { this->set_to_handle(v); return clone_as_defined(); } decltype(auto) extract_n(int v) { this->set_to_extract(v); return clone_as_defined(); } actual_type & common_payload(int v) { this->set_common_payload(v); return self_reference(); } using base_type::common_payload; }; 

此basic_params_t是主要的CRTP模板。 只是现在它由两个参数来参数化。


第一个参数是必须包含在其中的数据类型。 例如,receive_specific_data_t或select_specific_data_t。


第二个参数是CRTP熟悉的后继类型。 在self_reference()方法中使用它来获取对派生类型的引用。


实现basic_params_t模板的关键是其clone_as_defined()方法。 此方法期望继承人实现clone_if_necessary()方法。 而此clone_if_necessary()只是用于将对象T <msg_count_status_t :: undefined>转换为对象T <msg_count_status_t :: defined>。 然后在setter方法handle_all(),handle_n()和extract_n()中启动这种转换。


此外,您应注意clone_as_defined(),handle_all(),handle_n()和extract_n()将其返回值的类型确定为decltype(自动)的事实。 这是另一个技巧,我们将很快讨论。


现在,我们已经可以查看所有最终构想的最终类型之一:


 template< msg_count_status_t Msg_Count_Status > class receive_specific_params_t final : public basic_params_t< receive_specific_data_t, receive_specific_params_t<Msg_Count_Status> > { using base_type = basic_params_t< receive_specific_data_t, receive_specific_params_t<Msg_Count_Status> >; public : template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status != Msg_Count_Status, receive_specific_params_t<New_Msg_Count_Status> > clone_if_necessary() const { return { this->data() }; } template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status == Msg_Count_Status, receive_specific_params_t& > clone_if_necessary() { return *this; } receive_specific_params_t(int receive_payload) : base_type{ typename base_type::data_type{receive_payload} } {} receive_specific_params_t(typename base_type::data_type data) : base_type{ std::move(data) } {} int receive_payload() const { return this->data().receive_payload_; } }; 

您首先要注意的是构造函数,它采用base_type :: data_type。 使用此构造函数,在从T <msg_count_status_t :: undefined>到T <msg_count_status_t :: defined>的转换过程中,将传输当前参数值。


总的来说,这个rec​​eive_specific_params_t是这样的:


 template<typename V, int K> class holder_t { V v_; public: holder_t() = default; holder_t(V v) : v_{std::move(v)} {} const V & value() const { return v_; } }; holder_t<std::string, 0> v1{"Hello!"}; holder_t<std::string, 1> v2; v2 = v1; //   ,   v1  v2   . v2 = holder_t<std::string, 1>{v1.value()}; //    . 

只是提到的构造函数receive_specific_params_t允许使用来自receive_specific_params_t <msg_count_status_t :: undefined>的值来初始化receive_specific_params_t <msg_count_status_t :: defined>。


receive_specific_params_t中的第二个重要事项是两个clone_if_necessary()方法。


为什么有两个? 那么,这些SFINAE-vskaya魔术的定义到底意味着什么?


为了避免不必要的转换,已经制作了两个clone_if_necessary()方法。 假设一个名为handle_n()方法的程序员并且已经收到了receive_specific_params_t <msg_count_status_t :: defined>。 然后它调用了extract_n()。 这是允许的,handle_n()和extract_n()设置的限制略有不同。 对extract_n()的调用还应该给我们receive_specific_params_t <msg_count_status_t :: Defined>。 但是我们已经有一个。 那么,为什么不重用现有的呢?


这就是为什么这里有两个clone_if_necessary()方法的原因。 第一个将在确实需要转换时起作用:


  template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status != Msg_Count_Status, receive_specific_params_t<New_Msg_Count_Status> > clone_if_necessary() const { return { this->data() }; } 

例如,当状态从未定义变为已定义时,编译器将选择它。 并且此方法将返回一个新对象。 是的,在此方法的实现中,我们要注意data()调用,该调用已在basic_data_holder_t中定义。


第二种方法:


  template<msg_count_status_t New_Msg_Count_Status> std::enable_if_t< New_Msg_Count_Status == Msg_Count_Status, receive_specific_params_t& > clone_if_necessary() { return *this; } 

不需要更改状态时将调用。 并且此方法返回对现有对象的引用。


现在应该弄清楚,为什么在basic_params_t中,对于许多方法,返回类型都被定义为decltype(auto)。 毕竟,这些方法取决于在派生类型中将调用哪个特定版本的clone_if_necessary(),并且可以在此处返回对象或链接……您将无法预先预测。 在这里,decltype(auto)可以解决。


小免责声明


所描述的简约示例旨在针对所选解决方案进行最简单,最易懂的演示。 因此,它没有很明显的东西要包含在代码中。


例如,basic_data_holder_t :: data()方法返回对数据的常量引用。 这导致在将T <msg_count_status_t :: undefined>转换为T <msg_count_status_t :: defined>期间复制参数值。 如果复制参数是一项昂贵的操作,那么您应该对移动语义感到困惑,并且data()方法可能具有以下形式:


 auto data() { return std::move(data_); } 

同样现在,在每种最终类型中(例如receive_specific_params_t和select_specific_params_t),您都必须包括clone_if_necessary方法的实现。 即 在这个地方,我们仍然使用复制粘贴。 为了避免重复相同类型的代码,也许还应该提出一些建议。


是的,没错,noexcept不会放在代码中,以减少“语法开销”。


就这样


此处讨论的简约示例的源代码可以在此处找到。 例如,您可以在此处使用在线编译器(您可以在第163行注释掉对handle_all()的调用,看看会发生什么)。


我不想说我实施的方法是唯一正确的方法。 但是,首先,除非复制粘贴,否则我看到了一个替代方法。 其次,做到这一点一点也不困难,而且幸运的是,它并不需要很多时间。 但是,由于旧的测试和示例适应了最新版本的SObjectizer的新功能,因此编译器的一举一动立即起到了很大的帮助作用。


因此,对于我来说,C ++再次证明它很复杂。 但不仅如此,而是为了给开发人员更多的机会。 好吧,如果能以比我更简单的方式在现代C ++中获得所有这些功能,我不会感到惊讶。


PS。 如果其中一位读者遵循SObjectizer,那么我可以说与5.5分支的兼容性受到严重侵犯的新版本5.6已经呼吸了很多。 您可以在BitBucket上找到它。 该版本还有很长的路要走,但是SObjectizer-5.6已经是它的原意了。 您可以尝试并分享自己的印象。

Source: https://habr.com/ru/post/zh-CN447816/


All Articles