RPC-在C ++ 14/17中尝试新的机会

几年前,C ++开发人员收到了期待已久的C ++ 11标准,该标准带来了许多新事物。 我有兴趣快速将其用于日常任务。 转到C ++ 14和17,这不是。 似乎没有一些有趣的功能。 在春季,我决定研究语言的创新并尝试一些尝试。 要尝试创新,您必须自己完成一项任务。 我没想多久。 决定使用自定义数据结构作为参数编写RPC,而无需使用宏和代码生成-所有这些都使用C ++。 这要归功于该语言的新功能。

想法,实施,Reddit的反馈,改进-一切都在春季,初夏出现。 最终,他们设法完成了有关Habr的文章。

您是否考虑过自己的RPC? 也许帖子的内容将帮助您确定目标,方法,手段并决定是否支持已完成的目标或自己实现某些目标...

引言


RPC(远程过程调用)不是一个新主题。 有许多不同编程语言的实现。 实现使用各种数据格式和传输模式。 所有这些都可以体现在以下几点:

  • 序列化/反序列化
  • 交通运输
  • 远程方法执行
  • 返回结果

实施取决于所需的目标。 例如,您可以为自己设定目标,以确保高速调用远程方法并牺牲可用性,反之亦然,从而为编写代码提供最大的舒适度,而可能会降低性能。 目标和工具是不同的……我想要舒适和可接受的性能。

实作


以下是在C ++ 14/17中实现RPC的一些步骤,重点放在导致该材料出现的一些语言创新上。

该材料供那些出于某种原因对RPC感兴趣,并且可能到目前为止需要更多信息的用户使用。 在评论中,看到描述其他面临类似任务的开发人员的经历会很有趣。

序列化


在开始编写代码之前,我将形成一个任务:

  • 所有方法参数和返回的结果都通过元组传递。
  • 被调用的方法本身没有义务接受和返回元组。
  • 打包元组的结果应该是格式不固定的缓冲区

以下是简化的字符串序列化程序代码。

string_serializer
namespace rpc::type { using buffer = std::vector<char>; } // namespace rpc::type namespace rpc::packer { class string_serializer final { public: template <typename ... T> type::buffer save(std::tuple<T ... > const &tuple) const { auto str = to_string(tuple, std::make_index_sequence<sizeof ... (T)>{}); return {begin(str), end(str)}; } template <typename ... T> void load(type::buffer const &buffer, std::tuple<T ... > &tuple) const { std::string str{begin(buffer), end(buffer)}; from_string(std::move(str), tuple, std::make_index_sequence<sizeof ... (T)>{}); } private: template <typename T, std::size_t ... I> std::string to_string(T const &tuple, std::index_sequence<I ... >) const { std::stringstream stream; auto put_item = [&stream] (auto const &i) { if constexpr (std::is_same_v<std::decay_t<decltype(i)>, std::string>) stream << std::quoted(i) << ' '; else stream << i << ' '; }; (put_item(std::get<I>(tuple)), ... ); return std::move(stream.str()); } template <typename T, std::size_t ... I> void from_string(std::string str, T &tuple, std::index_sequence<I ... >) const { std::istringstream stream{std::move(str)}; auto get_item = [&stream] (auto &i) { if constexpr (std::is_same_v<std::decay_t<decltype(i)>, std::string>) stream >> std::quoted(i); else stream >> i; }; (get_item(std::get<I>(tuple)), ... ); } }; } // namespace rpc::packer 

主要功能代码演示了串行器的操作。

主要功能
 int main() { try { std::tuple args{10, std::string{"Test string !!!"}, 3.14}; rpc::packer::string_serializer serializer; auto pack = serializer.save(args); std::cout << "Pack data: " << std::string{begin(pack), end(pack)} << std::endl; decltype(args) params; serializer.load(pack, params); // For test { auto pack = serializer.save(params); std::cout << "Deserialized pack: " << std::string{begin(pack), end(pack)} << std::endl; } } catch (std::exception const &e) { std::cerr << "Error: " << e.what() << std::endl; return EXIT_FAILURE; } return EXIT_SUCCESS; } 

认可的口音

首先,您需要确定用于执行整个数据交换的缓冲区:

 namespace rpc::type { using buffer = std::vector<char>; } // namespace rpc::type 

序列化程序具有将元组保存到缓冲区(保存)并从缓冲区中加载(加载)方法。

save方法采用一个元组并返回一个缓冲区。

 template <typename ... T> type::buffer save(std::tuple<T ... > const &tuple) const { auto str = to_string(tuple, std::make_index_sequence<sizeof ... (T)>{}); return {begin(str), end(str)}; } 

元组是具有可变数量的参数的模板。 这种模式出现在C ++ 11中,并且运行良好。 在这里,您需要以某种方式遍历此类模板的所有元素。 可能有几种选择。 我将使用C ++ 14的功能之一-整数序列(索引)。 make_index_sequence类型已出现在标准库中,该库允许获取以下序列:

 template< class T, T... Ints > class integer_sequence; template<class T, T N> using make_integer_sequence = std::integer_sequence<T, /* a sequence 0, 1, 2, ..., N-1 */ >; template<std::size_t N> using make_index_sequence = make_integer_sequence<std::size_t, N>; 

可以在C ++ 11中实现类似的功能,然后将其从一个项目移植到另一个项目。

这样的索引序列使“遍历”元组成为可能:

 template <typename T, std::size_t ... I> std::string to_string(T const &tuple, std::index_sequence<I ... >) const { std::stringstream stream; auto put_item = [&stream] (auto const &i) { if constexpr (std::is_same_v<std::decay_t<decltype(i)>, std::string>) stream << std::quoted(i) << ' '; else stream << i << ' '; }; (put_item(std::get<I>(tuple)), ... ); return std::move(stream.str()); } 

to_string方法使用最新的C ++标准的多个功能。

认可的口音

在C ++ 14中,可以将auto用作lambda函数的参数。 例如,当使用标准库的算法时,这通常是不够的。

卷积出现在C ++ 17中,它允许您编写如下代码:

 (put_item(std::get<I>(tuple)), ... ); 

在给定的片段中,为传输的元组的每个元素调用put_item lambda函数。 这样可以保证序列独立于平台和编译器。 类似的东西可以用C ++ 11编写。

 template <typename … T> void unused(T && … ) {} // ... unused(put_item(std::get<I>(tuple)) ... ); 

但是元素的存储顺序取决于编译器。

许多别名出现在C ++ 17标准库中,例如,delay_t,它减少了以下形式的记录:

 typename decay<T>::type 

写较短的结构的愿望是存在的。 模板设计在一行中找到了两个类型名和模板,并用冒号和尖括号隔开,看起来令人毛骨悚然。 你怎么能吓到你的一些同事。 将来,他们承诺减少您需要编写模板(类型名)的位置。

对简洁的渴望为语言“ if constexpr”提供了另一种有趣的构造,从而避免了编写模板的许多私有专业知识。

有一个有趣的观点。 许多人被教导开关和类似的构造在代码可伸缩性方面不是很好。 最好使用运行时/编译时多态性和带有参数的重载,以支持“正确的选择”。 然后是“如果是constexpr”……紧凑的可能性并不会让每个人都对它无动于衷。 语言的可能性并不意味着需要使用它。

有必要为字符串类型编写一个单独的序列化。 为了方便使用字符串,例如,保存到流中并从中读取时,会出现std ::引号函数。 它使您可以筛选字符串,并可以保存到流中并从中加载日期,而无需考虑分隔符。

您现在可以停止序列化的描述。 反序列化(负载)的实现方式与此类似。

交通运输


运输很简单。 这是一个接收并返回缓冲区的函数。

 namespace rpc::type { // ... using executor = std::function<buffer (buffer)>; } // namespace rpc::type 

使用std :: bind,lambda函数等形成这样的对象“执行程序”,您可以使用任何传输实现。 将不考虑此职位内的运输实施细节。 您可以查看完成的RPC实现,最后将提供指向该链接的链接。

顾客


以下是测试客户端代码。 客户端会生成请求,并考虑到所选的传输,将其发送到服务器。 在下面的测试代码中,所有客户端请求都显示在控制台上。 在下一步的实施中,客户端将已经与服务器直接通信。

顾客
 namespace rpc { template <typename TPacker> class client final { private: class result; public: client(type::executor executor) : executor_{executor} { } template <typename ... TArgs> result call(std::string const &func_name, TArgs && ... args) { auto request = std::make_tuple(func_name, std::forward<TArgs>(args) ... ); auto pack = packer_.save(request); auto responce = executor_(std::move(pack)); return {responce}; } private: using packer_type = TPacker; packer_type packer_; type::executor executor_; class result final { public: result(type::buffer buffer) : buffer_{std::move(buffer)} { } template <typename T> auto as() const { std::tuple<std::decay_t<T>> tuple; packer_.load(buffer_, tuple); return std::move(std::get<0>(tuple)); } private: packer_type packer_; type::buffer buffer_; }; }; } // namespace rpc 

客户端被实现为模板类。 template参数是一个序列化器。 如有必要,可以不在模板1中重做该类,并将实现序列化器的对象传递给构造函数。

在当前的实现中,类构造函数接受一个执行对象。 承包商将传输的实现隐藏在自己的内部,并且使代码在这一点上有可能不考虑在进程之间交换数据的方法。 在测试用例中,传输实现将请求显示到控制台。

 auto executor = [] (rpc::type::buffer buffer) { // Print request data std::cout << "Request pack: " << std::string{begin(buffer), end(buffer)} << std::endl; return buffer; }; 

自定义代码尚未尝试利用客户的工作成果,因为没有地方可以从中获取。

客户端调用方法:

  • 使用序列化程序打包被调用方法的名称及其参数
  • 使用执行对象将请求发送到服务器并接收响应
  • 将接收到的响应传递给检索接收到的结果的类

基本客户端实现已准备就绪。 剩下的东西。 稍后再详细介绍。

伺服器


在开始考虑服务器端的实现细节之前,我建议快速,斜着地看一下完整的客户端-服务器交互示例。

为简单起见,演示全部在一个过程中进行。 传输实现是一个lambda函数,该函数在客户端和服务器之间传递缓冲区。

客户端-服务器交互。 测试用例
 #include <cstdint> #include <cstdlib> #include <functional> #include <iomanip> #include <iostream> #include <map> #include <sstream> #include <string> #include <tuple> #include <vector> #include <utility> namespace rpc::type { using buffer = std::vector<char>; using executor = std::function<buffer (buffer)>; } // namespace rpc::type namespace rpc::detail { template <typename> struct function_meta; template <typename TRes, typename ... TArgs> struct function_meta<std::function<TRes (TArgs ... )>> { using result_type = std::decay_t<TRes>; using args_type = std::tuple<std::decay_t<TArgs> ... >; using request_type = std::tuple<std::string, std::decay_t<TArgs> ... >; }; } // namespace rpc::detail namespace rpc::packer { class string_serializer final { public: template <typename ... T> type::buffer save(std::tuple<T ... > const const &tuple) const { auto str = to_string(tuple, std::make_index_sequence<sizeof ... (T)>{}); return {begin(str), end(str)}; } template <typename ... T> void load(type::buffer const &buffer, std::tuple<T ... > &tuple) const { std::string str{begin(buffer), end(buffer)}; from_string(std::move(str), tuple, std::make_index_sequence<sizeof ... (T)>{}); } private: template <typename T, std::size_t ... I> std::string to_string(T const &tuple, std::index_sequence<I ... >) const { std::stringstream stream; auto put_item = [&stream] (auto const &i) { if constexpr (std::is_same_v<std::decay_t<decltype(i)>, std::string>) stream << std::quoted(i) << ' '; else stream << i << ' '; }; (put_item(std::get<I>(tuple)), ... ); return std::move(stream.str()); } template <typename T, std::size_t ... I> void from_string(std::string str, T &tuple, std::index_sequence<I ... >) const { std::istringstream stream{std::move(str)}; auto get_item = [&stream] (auto &i) { if constexpr (std::is_same_v<std::decay_t<decltype(i)>, std::string>) stream >> std::quoted(i); else stream >> i; }; (get_item(std::get<I>(tuple)), ... ); } }; } // namespace rpc::packer namespace rpc { template <typename TPacker> class client final { private: class result; public: client(type::executor executor) : executor_{executor} { } template <typename ... TArgs> result call(std::string const &func_name, TArgs && ... args) { auto request = std::make_tuple(func_name, std::forward<TArgs>(args) ... ); auto pack = packer_.save(request); auto responce = executor_(std::move(pack)); return {responce}; } private: using packer_type = TPacker; packer_type packer_; type::executor executor_; class result final { public: result(type::buffer buffer) : buffer_{std::move(buffer)} { } template <typename T> auto as() const { std::tuple<std::decay_t<T>> tuple; packer_.load(buffer_, tuple); return std::move(std::get<0>(tuple)); } private: packer_type packer_; type::buffer buffer_; }; }; template <typename TPacker> class server final { public: template <typename ... THandler> server(std::pair<char const *, THandler> const & ... handlers) { auto make_executor = [&packer = packer_] (auto const &handler) { auto executor = [&packer, function = std::function{handler}] (type::buffer buffer) { using meta = detail::function_meta<std::decay_t<decltype(function)>>; typename meta::request_type request; packer.load(buffer, request); auto response = std::apply([&function] (std::string const &, auto && ... args) { return function(std::forward<decltype(args)>(args) ... ); }, std::move(request) ); return packer.save(std::make_tuple(std::move(response))); }; return executor; }; (handlers_.emplace(handlers.first, make_executor(handlers.second)), ... ); } type::buffer execute(type::buffer buffer) { std::tuple<std::string> pack; packer_.load(buffer, pack); auto func_name = std::move(std::get<0>(pack)); auto const iter = handlers_.find(func_name); if (iter == end(handlers_)) throw std::runtime_error{"Function \"" + func_name + "\" not found."}; return iter->second(std::move(buffer)); } private: using packer_type = TPacker; packer_type packer_; using handlers_type = std::map<std::string, type::executor>; handlers_type handlers_; }; } // namespace rpc int main() { try { using packer_type = rpc::packer::string_serializer; rpc::server<packer_type> server{ std::pair{"hello", [] (std::string const &s) { std::cout << "Func: \"hello\". Inpur string: " << s << std::endl; return "Hello " + s + "!"; }}, std::pair{"to_int", [] (std::string const &s) { std::cout << "Func: \"to_int\". Inpur string: " << s << std::endl; return std::stoi(s); }} }; auto executor = [&server] (rpc::type::buffer buffer) { return server.execute(std::move(buffer)); }; rpc::client<packer_type> client{std::move(executor)}; std::cout << client.call("hello", std::string{"world"}).as<std::string>() << std::endl; std::cout << "Convert to int: " << client.call("to_int", std::string{"100500"}).as<int>() << std::endl; } catch (std::exception const &e) { std::cerr << "Error: " << e.what() << std::endl; return EXIT_FAILURE; } return EXIT_SUCCESS; } 

在服务器类的上述实现中,最有趣的是它的构造函数和execute方法。

服务器类构造函数

 template <typename ... THandler> server(std::pair<char const *, THandler> const & ... handlers) { auto make_executor = [&packer = packer_] (auto const &handler) { auto executor = [&packer, function = std::function{handler}] (type::buffer buffer) { using meta = detail::function_meta<std::decay_t<decltype(function)>>; typename meta::request_type request; packer.load(buffer, request); auto response = std::apply([&function] (std::string const &, auto && ... args) { return function(std::forward<decltype(args)>(args) ... ); }, std::move(request) ); return packer.save(std::make_tuple(std::move(response))); }; return executor; }; (handlers_.emplace(handlers.first, make_executor(handlers.second)), ... ); } 

该类的构造函数是样板。 在输入处,包含一对对的列表。 每对都是一个方法名称和一个处理程序。 而且由于构造函数是带有可变数量参数的模板,因此在创建服务器对象时,将立即注册服务器上所有可用的处理程序。 这样就可以避免在服务器处理程序上调用其他注册方法。 进而使人们不必考虑服务器类对象是否将在多线程环境中使用以及是否需要同步。

服务器类的构造函数的一个片段

 template <typename ... THandler> server(std::pair<char const *, THandler> const & ... handlers) { // … (handlers_.emplace(handlers.first, make_executor(handlers.second)), ... ); } 

将大量传递的异构处理程序放入相同类型的函数映射中。 为此,还使用了卷积,这使得轻松地将std ::映射整个传递的处理程序集放在一行中而没有循环和算法

 (handlers_.emplace(handlers.first, make_executor(handlers.second)), ... ); 

允许将auto用作参数的Lambda函数使在处理程序上实现相同类型的包装器变得容易。 相同类型的包装在服务器上可用方法的映射中注册(std :: map)。 处理请求时,将在此类卡上执行搜索,并且相同的处理程序将调用找到的处理程序,而不管接收到的参数和返回的结果如何。 标准库中出现的std :: apply函数使用作为元组传递的参数调用传递给它的函数。 std :: apply函数也可以在C ++ 11中实现。 现在,它是“开箱即用”的,无需在项目之间转移。

执行方法

 type::buffer execute(type::buffer buffer) { std::tuple<std::string> pack; packer_.load(buffer, pack); auto func_name = std::move(std::get<0>(pack)); auto const iter = handlers_.find(func_name); if (iter == end(handlers_)) throw std::runtime_error{"Function \"" + func_name + "\" not found."}; return iter->second(std::move(buffer)); } 

检索被调用函数的名称,在已注册处理程序的映射中搜索方法,调用处理程序并返回结果。 在服务器类的构造函数中准备的包装器中,所有这些都很有趣。 可能有人注意到了该异常,也许又出现了一个问题:“异常是否以某种方式处理?” 是的,在完整的实施中(将在最后提供参考),提供了异常封送处理。 为了简化内容,客户端和服务器之间没有传递异常。

再看一下功能

主要的
 int main() { try { using packer_type = rpc::packer::string_serializer; rpc::server<packer_type> server{ std::pair{"hello", [] (std::string const &s) { std::cout << "Func: \"hello\". Inpur string: " << s << std::endl; return "Hello " + s + "!"; }}, std::pair{"to_int", [] (std::string const &s) { std::cout << "Func: \"to_int\". Inpur string: " << s << std::endl; return std::stoi(s); }} }; auto executor = [&server] (rpc::type::buffer buffer) { return server.execute(std::move(buffer)); }; rpc::client<packer_type> client{std::move(executor)}; std::cout << client.call("hello", std::string{"world"}).as<std::string>() << std::endl; std::cout << "Convert to int: " << client.call("to_int", std::string{"100500"}).as<int>() << std::endl; } catch (std::exception const &e) { std::cerr << "Error: " << e.what() << std::endl; return EXIT_FAILURE; } return EXIT_SUCCESS; } 

它实现了成熟的客户端-服务器交互。 为了不使内容复杂化,客户端和服务器在一个过程中工作。 替换执行程序的执行程序,可以使用必要的传输方式。

在C ++ 17标准中,有时可能在实例化时不指定模板参数。 在上面的主要功能中,此功能在注册服务器处理程序(不带模板参数的std :: pair)时使用,并使代码更简单。

基本的RPC实施已准备就绪。 仍然有希望增加将自定义数据结构作为参数传递并返回结果的能力。

自定义数据结构


为了跨过程边界传输数据,需要将它们序列化为某种东西。 例如,您可以将所有内容输出到标准流。 开箱即用将提供很多支持。 对于自定义数据结构,您将必须自己实现输出运算符。 每个结构都需要自己的输出运算符。 有时您不想这样做。 要对结构的所有字段进行排序并将每个字段输出到流,您需要一些通用方法。 反思可以很好地帮助这一点。 在C ++中还没有。 您可以求助于代码生成以及宏和模板的混合使用。 但想法是使库接口使用纯C ++。

C ++尚无完整的体现。 因此,下面的解决方案可以有一些限制。

该解决方案基于对新C ++ 17“结构化绑定”功能的使用。 通常,在对话框中您会发现很多行话,因此我拒绝使用俄语提供此功能名称的任何选项。

下面是一个解决方案,允许您将已传输数据结构的字段传输到元组。

 template <typename T> auto to_tuple(T &&value) { using type = std::decay_t<T>; if constexpr (is_braces_constructible_v<type, dummy_type, dummy_type, dummy_type>) { auto &&[f1, f2, f3] = value; return std::make_tuple(f1, f2, f3); } else if constexpr (is_braces_constructible_v<type, dummy_type, dummy_type>) { auto &&[f1, f2] = value; return std::make_tuple(f1, f2); } else if constexpr (is_braces_constructible_v<type, dummy_type>) { auto &&[f1] = value; return std::make_tuple(f1); } else { return std::make_tuple(); } } 

在Internet上,您可以找到许多类似的解决方案。

上面说了很多在这里使用的内容,除了结构化绑定。 to_tuple函数接受自定义类型,确定字段数,并借助结构化绑定将结构字段“转移”到元组。 “ if constexpr”使您可以选择所需的实现分支。 由于C ++中没有反射,因此无法构建一个考虑类型所有方面的完整解决方案。 对使用的类型有限制。 其中之一-类型应该没有自定义构造函数。

To_tuple使用is_braces_constructible_v。 使用此类型,您可以确定使用花括号初始化传输的结构并确定字段数的能力。

is_braces_constructible_v
 struct dummy_type final { template <typename T> constexpr operator T () noexcept { return *static_cast<T const *>(nullptr); } }; template <typename T, typename ... TArgs> constexpr decltype(void(T{std::declval<TArgs>() ... }), std::declval<std::true_type>()) is_braces_constructible(std::size_t) noexcept; template <typename, typename ... > constexpr std::false_type is_braces_constructible(...) noexcept; template <typename T, typename ... TArgs> constexpr bool is_braces_constructible_v = std::decay_t<decltype(is_braces_constructible<T, TArgs ... >(0))>::value; 

上面的to_tuple函数可以将包含不超过三个字段的用户数据结构转换为元组。 为了增加结构的“移位”字段的可能数量,您可以复制“ if constexpr”分支,但要注意一点,也可以不使用最简单的boost.preprocessor库。 如果选择第二个选项,则代码将变得难以阅读,并且可以使用具有大量字段的结构。

用boost.preprocessor实现to_tuple
 template <typename T> auto to_tuple(T &&value) { using type = std::decay_t<T>; #define NANORPC_TO_TUPLE_LIMIT_FIELDS 64 // you can try to use BOOST_PP_LIMIT_REPEAT #define NANORPC_TO_TUPLE_DUMMY_TYPE_N(_, n, data) \ BOOST_PP_COMMA_IF(n) data #define NANORPC_TO_TUPLE_PARAM_N(_, n, data) \ BOOST_PP_COMMA_IF(n) data ## n #define NANORPC_TO_TUPLE_ITEM_N(_, n, __) \ if constexpr (is_braces_constructible_v<type, \ BOOST_PP_REPEAT_FROM_TO(0, BOOST_PP_SUB(NANORPC_TO_TUPLE_LIMIT_FIELDS, n), NANORPC_TO_TUPLE_DUMMY_TYPE_N, dummy_type) \ >) { auto &&[ \ BOOST_PP_REPEAT_FROM_TO(0, BOOST_PP_SUB(NANORPC_TO_TUPLE_LIMIT_FIELDS, n), NANORPC_TO_TUPLE_PARAM_N, f) \ ] = value; return std::make_tuple( \ BOOST_PP_REPEAT_FROM_TO(0, BOOST_PP_SUB(NANORPC_TO_TUPLE_LIMIT_FIELDS, n), NANORPC_TO_TUPLE_PARAM_N, f) \ ); } else #define NANORPC_TO_TUPLE_ITEMS(n) \ BOOST_PP_REPEAT_FROM_TO(0, n, NANORPC_TO_TUPLE_ITEM_N, nil) NANORPC_TO_TUPLE_ITEMS(NANORPC_TO_TUPLE_LIMIT_FIELDS) { return std::make_tuple(); } #undef NANORPC_TO_TUPLE_ITEMS #undef NANORPC_TO_TUPLE_ITEM_N #undef NANORPC_TO_TUPLE_PARAM_N #undef NANORPC_TO_TUPLE_DUMMY_TYPE_N #undef NANORPC_TO_TUPLE_LIMIT_FIELDS } 

如果您曾经尝试为C ++ 03做诸如boost.bind之类的事情,其中​​您不得不使用不同数量的参数进行许多实现,那么使用boost.preprocessor实现to_tuple似乎并不奇怪或复杂。

而且,如果在序列化程序中添加了元组支持,则to_tuple函数将启用用户数据结构的序列化。 并且有可能将它们作为参数出卖,并在RPC中返回结果。

除了用户定义的数据结构外,C ++还有其他内置类型,这些类型无法实现向标准流的输出。 减少流中重载输出运算符数量的需求导致了一种通用代码,该代码允许一种方法来处理大多数C ++容器,例如std :: list,std :: vector,std :: map。 在不忘记SFINAE和std :: enable_if_t的情况下,您可以继续扩展序列化器。 在这种情况下,有必要以某种方式间接确定类型的属性,这与在is_braces_constructible_v的实现中类似。

结论


, , stl- . , RPC — C++ 14 / 17. HTTP / HTTPS .

NanoRPC GitHub .

感谢您的关注!

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


All Articles