寻找减少虚方法重载的设计模式

Looking for design pattern to reduce virtual method overloads

本文关键字:重载 设计模式 方法 寻找      更新时间:2023-10-16

我有大量(~100)个从公共基类(Device)派生的类。每个设备都可以接受大量类似命令的某个子集。不同的命令可以有不同数量和类型的参数,因此每个命令都被封装为自己的类型(如果需要,可以更改)。

有什么模式允许我将命令传递给设备,只给一个指向Device基类的指针/引用,这样设备就可以访问命令的类型和参数?

我想到的选项:

  1. 最直接的方法是在基类Device中添加一个单独的接受每种命令类型的虚拟方法。然而,这最终会导致基类中的大量虚方法只在极少数派生类中被重写。
  2. 我考虑了访问者模式,但由于命令类的数量大约等于设备类的数量,因此这并没有真正获得任何好处。
  3. 使用RTTI(或每个命令唯一的enum/标识符)来确定命令类型,然后使用switch/if分支到适当的代码。这感觉很脏,因为它绕过了普通的c++多态性。此外,dynamic_cast在这里非常不受欢迎,所以这个选项几乎是不存在的。

Device基类中没有大量的虚拟方法的情况下,有什么模式的建议吗?

您的系统由设备网络和分配给每个设备的命令列表组成。

你有代码来反序列化命令并将它们分发给设备。我想这是错误的顺序。

相反,命令应该发送序列化(或者,以"通用形式"或"未解析形式"——字符串向量作为参数,int作为命令id)到设备。设备在"自身"中使用通用(可能是模板)代码来反序列化命令并在自身上调用它。

在调用点,设备知道它自己的类型。模板反序列化代码可以被告知设备可以理解什么类型的命令,并且给定一个无效的命令可以静态地出错并失败。给定一个有效的命令,它可以以类型安全的方式在设备上调用它。

如果您愿意,可以在将命令传递给设备时对其进行部分反序列化。

如果添加新命令或设备类型,则不需要重新编译任何现有设备。旧的设备解析器应该足够健壮,可以检测并丢弃无效的命令。新的设备解析器将使用新的命令类型。

"执行序列化命令"接口必须有一个返回值,该值指示该命令是否无效,因此您可以在Device接口之外处理它。这可能涉及错误代码、std::experimental::expected类型模式或异常。


这是一个实现的草图。

从序列化数据中高效地编写"执行命令"代码(就无DRY和运行时效率而言)有点棘手。

假设我们有一个名为"command_type"的命令枚举。已知命令类型的数量是command_type::number_known——所有命令类型的值都必须严格少于这个数。

接下来,添加一个函数,如下所示:

template<command_type t>
using command_t = std::integral_constant<command_type, t>;
template<command_type t, class T>
error_code execute_command( command_t<t>, T const&, std::vector<Arg>const&){
return error_code::command_device_mismatch;
}

这是默认行为。缺省情况下,命令类型和设备类型不匹配。参数被忽略,并返回错误。

我们还编写了一个helper类型,以供稍后使用。这是一个template<size_t>class,它以ADL(参数依赖查找)友好的方式调用execute_command。这个类模板应该和execute_command在同一个命名空间。

template<size_t N>
struct execute_command_t {
template<class T>
error_code operator()( T const& t, std::vector<Arg>const& a){
return execute_command(command_t<static_cast<command_type>(N)>{}, t, a);
}
};

它们都应该是普遍可见的。

然后我们继续创建execute_command重载,这些重载仅对各种Device子类型的实现私有可见。

假设我们有一个类型Bob,我们希望Bob能够理解命令command_type::jump

我们在Bob的文件中定义了一个函数,如下所示:

error_code execute_command( command_t<command_type::jump>, Bob& bob, std::vector<Arg> const& args );

应该与Bob类型在同一个命名空间中。

然后我们编写一个魔法开关。magic switch接受一个运行时值(本例中为enum值),并映射到一个函数表,该函数表使用该运行时值实例化一个编译时模板数组。这是一个实现草图(它没有编译,我只是写在我的头上,所以它可以包含错误):

namespace {
template<template<size_t>class Target, size_t I, class...Args>
std::result_of_t<Target<0>(Args...)> helper( Args&&... args ) {
using T=Target<I>;
return T{}(std::forward<Args>(args)...);
}
}
template<size_t N, template<size_t>class Target>
struct magic_switch {
private:
template<class...Args>
using R=std::result_of_t<Target<0>(Args...)>;
template<size_t...Is, class...Args>
R<Args...> execute(std::index_sequence<Is...>, size_t I, R<Args...> err, Args&&...args)const {
using Res = R<Args...>;
if (I >=N) return err;
using f = Res(Args&&...);
using pf = f*;
static const pf table[] = {
//      [](Args&&...args)->Res{
//          return Target<Is>{}(std::forward<Args>(args)...);
//      }...,
&helper<Target, Is, Args...>...,
0
};
return table[I](std::forward<Args>(args)...);
}
public:
template<class...Args>
R<Args...> operator()(size_t I, R<Args...> err, Args&&...args)const {
return execute( std::make_index_sequence<N>{}, I, std::forward<R<Args...>>(err), std::forward<Args>(args)... );
}
};

magic_switch本身是一个模板。它接受一个可以处理N的最大值和一个它将创建和调用的template<size_t>class Target

(带有lambda的注释掉的代码是合法的c++ 11,但gcc5.2和clang 3.7都不能编译它,所以使用helper版本。)

它的operator()接受一个索引(size_t I),一个越界的错误(err)和一组参数来完善向前。

operator()创建index_sequence<0, 1, ..., N-1>并传递给私有execute方法。execute使用index_sequence的整数来创建一个函数指针数组,每个函数指针实例化Target<I>并传递给它Args&&...args

使用运行时参数I进行边界检查,然后对该列表进行数组查找,然后运行调用Target<I>{}(args...)的函数指针。

上面的代码是通用的,不是针对这个问题的。我们现在需要一些胶水来解决这个问题。

该函数接受上面的magic_switch,并将其与ADL调度的execute_command合并:

template<class T>
error_code execute_magic( command_type c, T&& t, std::vector<Arg> const& args) {
using magic = magic_switch< static_cast<size_t>(command_type::number_known), execute_command_t >;
return magic{}( size_t(c), error_code::command_device_mismatch, std::forward<T>(t), args );
}

我们的execute_command_t模板被传递给magic_switch

最后,我们有一个运行时的"跳转表",函数指针指向对每个command_type枚举值执行execute_command( command_t<command_type::???>, bob, args )的代码。我们取一个运行时的command_type,用它做一个数组查找,然后调用相应的execute_command

如果没有专门编写execute_command( command_t<command_type::X>, bob, args )过载,则调用默认的(在本示例开始时),并返回命令-设备不匹配错误。如果已经编写了一个,则通过参数依赖查找的魔力找到它,并且它比失败的泛型重载更专门化,因此改为调用它。

如果我们输入的command_type超出了范围,我们也会处理它。因此,当创建新的command_type时,不需要重新编译每个设备(这是可选的):它们仍然可以工作。

这一切都很有趣,但我们如何获得execute_magic与真正的设备子类型调用?

Device添加一个纯虚方法:

virtual error_code RunCommand(command_type, std::vector<Arg> const& args) = 0;

我们可以在Device的每个派生类型中自定义实现RunCommand,但这违反了DRY并可能导致错误。

相反,编写一个名为DeviceImpl:

的CRTP(奇怪地重复出现的模板模式)辅助器。
template<class Derived>
struct DeviceImpl {
virtual error_code RunCommand(command_type t, std::vector<Arg>const& args) final override
{
auto* self = static_cast<Derived*>(this);
return execute_magic( t, *self, args );
}
};
现在,当我们定义命令Bob时,我们这样做:
class Bob : public DeviceImpl<Bob>

而不是直接从Device继承。这为我们自动实现了Derived::RunCommand,避免了DRY问题。

接受Bob&重载execute_command的声明必须在DeviceImpl<Bob>实例化之前是可见的,否则上面的方法不起作用。

最后一位是实现execute_command。这里我们必须取std::vector<Arg> const&并在Bob上正确地调用它。在这个问题上有许多堆栈溢出问题。

在上面的代码中,使用了少量c++ 14的特性。它们可以很容易地用c++ 11编写。

使用的关键技术:

Magic Switch(我称之为从运行时跳转表到编译时模板实例的技术)

参数依赖查找(如何找到execute_command)

类型擦除或运行时概念(我们将命令的执行类型擦除到虚拟的RunCommand接口中)

标签调度(我们将command_t<?>作为标签类型传递给execute_command的正确过载)。

CRTP(好奇循环模板模式),我们在DeviceImpl<Derived>中使用它来实现RunCommand虚方法一次,在我们知道Derived类型的上下文中,因此我们可以正确地调度。

生活例子。

一个不错的选择可能是使用boost::variant之类的东西(您不必特别使用boost,但可以使用类似的东西)。它看起来像这样。

首先定义一个封装所有命令类型的类型:

struct command1 { ... };
struct command2 { ... };
...
typedef boost::variant<command1, command2, ...> command_t;

你的基本设备类只有一个虚函数,它看起来像这样:

class Device {
...
virtual return_t run_command(const command_t& cmd) = 0;
...
};
现在要处理这些命令,定义一个不执行任何操作的基本访问器:

struct base_command_visitor : public boost::static_visitor<bool>
{
bool operator()(const command1& cmd) const { return false; }
bool operator()(const command2& cmd) const { return false; }
...
};

那么你的派生类看起来像这样:

class Device1 {
...
virtual return_t run_command(const command_t& cmd) override {
struct command_visitor : public base_command_visitor {
using base_command_visitor::operator();
// only define commands you will use
bool operator()(const command1& cmd) const { ...; return true; }
bool operator()(const command5& cmd) const { ...; return true; }
};
if (!boost::apply_visitor(command_visitor(), cmd)) {
// did not implement the command;
}
}
...
};

优点:

  • 基类中只有一个虚函数
  • 派生类只需要为它们将要使用的命令编写代码,这要感谢using base_command_visitor::operator();

缺点:

  • 如果你添加一个新的命令类型,你仍然需要重新编译所有的代码和基类仍然改变(仍然是command_t的变化)。你几乎没有办法,除非你使用dynamic_cast或等价的(如参数的void *)。
  • 仍然有相当数量的样板代码。但这就是c++。

这是典型的双重分派问题。

我遇到过这种模式几次,并使用了以下策略来处理它。

假设基类Command有一个返回"id"的函数,该函数可以是整型、字符串类型或其他可以用作map中的键的类型。

struct Command
{
typedef  <SomeType> IDType;
virtual IDType getID() const = 0;
};

Device的接口可以简化为:

struct Command;
struct Device
{
virtual execute(Command const& command) = 0;
};

假设DeviceABCD是一个派生类型,而我们通过基类指针/引用操作的实际设备是DeviceABCD。在第一次调度中,执行命令的调用被调度到DeviceABCD::execute()

DeviceABCD::execute()的实现,它被分派给另一个做实际工作的函数。

您需要一个适当的框架来正确执行第二次分派。在框架中:

  1. 需要有"命令ID"->"命令执行者"的映射
  2. 需要有一种方法来注册一个"命令执行器"给定一个"命令ID"。

基于这些,您可以获得给定"命令ID"的"命令执行器"。如果存在"命令执行器",则可以简单地将命令执行分派给"命令执行器"。如果没有,则需要处理该错误,最可能的方法是引发异常。

这个框架可以被Device的所有子类型使用。因此,框架可以在Device本身中实现,也可以在与Device对等的helper类中实现。我更喜欢第二种方法,并建议创建两个类:CommandExecutorCommandDispatcher

CommandExecutor.h:

struct CommandExecutor
{
virtual execute(Command const& command) = 0;
};

CommandDispatcher.h:

class CommandDispatcher
{
public:
void registerCommandExecutor(Command::IDType commandID,
CommandExecutor* executor);
void executeCommand(Command const& command);
std::map<Command::IDType, CommandExecutor*>& getCommandExecutorMap();
public:
std::map<Command::IDType, CommandExecutor*> theMap;
};

CommandDispatcher.cpp:

void CommandDispatcher::registerCommandExecutor(Command::IDType commandID,
CommandExecutor* executor)
{
getCommandExecutorMap()[commandID] = executor;
}
void CommandDispatcher::executeCommand(Command const& command)
{
CommandExecutor* executor = getCommandExecutorMap()[commandID];
if ( executor != nullptr )
{
executor->execute(command);
}
else
{
throw <AnAppropriateExecption>;
}
}
std::map<Command::IDType, CommandExecutor*>& CommandDispatcher::getCommandExecutorMap()
{
return theMap;
}

如果DeviceABCD可以执行Command12Command34,它的实现看起来像这样:

DeviceABCD.cpp:

struct Command12Executor : public CommandExecutor
{
virtual void execute(Command const& command) { ... }
};
struct Command34Executor : public CommandExecutor
{
virtual void execute(Command const& command) { ... }
};
DeviceABCD::DeviceABCD() : commandDispatcher_(CommandExecutor)
{
static Command12Executor executor12;
static Command34Executor executor34;
// This assumes that you can get an ID for all instances of Command12
// without an instance of the class, i.e. it is static data of the class.
commandDispatcher_.registerExecutor(Command12Type, &executor12);
commandDispatcher_.registerExecutor(Command34Type, &executor34);
}

有了这个框架,DeviceABCD::execute()的实现非常简单。

void DeviceABCD::execute(Command const& command)
{
commandDispatcher_.executeCommand(command);
}

这被简化到甚至可以在基类中实现的程度。只有当需要在命令被分派到正确的CommandExecutor之前对命令进行处理或更新一些其他状态时,才需要在派生类中实现它。