有关C ++指令的技术讨论

重要

本文是对在C ++中设计基于指令的新框架时所做的一些设计决策的技术讨论。您无需了解本文中的信息即可在机器人代码中使用基于指令的框架。

注解

本文假定您对高级C ++概念有一定的了解,包括模板,智能指针,继承,右值引用,复制语义,移动语义和CRTP。

本文将帮助您了解在基于指令的新框架中做出某些决策的原因(例如“std :: unique_ptr”的使用,CRTP以“CommandHelper <Base, Derived>”的形式存在,缺乏Java可用的更高级的装饰器等)

所有权模型

基于指令的旧框架使用原始指针,这意味着用户必须在其机器人代码中使用“new’”(导致手动堆分配)。由于没有明确指示谁拥有命令(调度程序,指令组或用户自己),因此不清楚谁应该负责释放内存。

旧的基于命指令的框架中的几个示例涉及如下代码:

#include "PlaceSoda.h"
#include "Elevator.h"
#include "Wrist.h"

PlaceSoda::PlaceSoda() {
  AddSequential(new SetElevatorSetpoint(Elevator::TABLE_HEIGHT));
  AddSequential(new SetWristSetpoint(Wrist::PICKUP));
  AddSequential(new OpenClaw());
}

在上面的指令组中,指令组的组件指令正在堆中分配,并传递到同一行中的“AddSequential”中。这意味着该用户没有对内存中该对象的引用,因此,一旦指令组结束,该用户就无法释放分配的内存。指令组本身从不释放内存,指令调度程序也没有。这导致机器人程序中的内存泄漏(即,内存在堆上分配但从未释放)。

这个明显的问题是重写框架的原因之一。这次重写引入了一个全面的所有权模型,以及智能指针的使用,这些智能指针将在超出范围时自动释放内存。

默认指令归指令调度程序所有,而指令组的组件指令归指令组所有。其他指令归用户决定应归其所有的对象(例如子系统实例或“RobotContainer”实例)。这意味着可以清楚地定义任何指令或指令组分配的内存的所有权。

“std::unique_ptr”vs.“std::shared_ptr”

使用“std::unique_ptr”以让我们清楚地确定谁拥有对象。由于无法复制“std::unique_ptr”,因此不会有超过一个“std::unique_ptr”实例指向堆上的同一内存块。例如,“SequentialCommandGroup”的构造函数采用“std :: vector <std::unique_ptr<Command>> &&”。这意味着它需要对向量“std::unique_ptr <Command>”的右值引用。让我们逐步看一些示例代码,以更好地理解这一点:

// Let's create a vector to store our commands that we want to run sequentially.
std::vector<std::unique_ptr<Command>> commands;

// Add an instant command that prints to the console.
commands.emplace_back(std::make_unique<InstantCommand>([]{ std::cout << "Hello"; }, requirements));

// Add some other command: this can be something that a user has created.
commands.emplace_back(std::make_unique<MyCommand>(args, needed, for, this, command));

// Now the vector "owns" all of these commands. In its current state, when the vector is destroyed (i.e.
// it goes out of scope), it will destroy all of the commands we just added.

// Let's create a SequentialCommandGroup that will run these two commands sequentially.
auto group = SequentialCommandGroup(std::move(commands));

// Note that we MOVED the vector of commands into the sequential command group, meaning that the
// command group now has ownership of our commands. When we call std::move on the vector, all of its
// contents (i.e. the unique_ptr instances) are moved into the command group.

// Even if the vector were to be destroyed while the command group was running, everything would be OK
// since the vector does not own our commands anymore.

使用“std::shared_ptr”时,没有明确的所有权模型,因为“std::shared_ptr”可能有多个实例指向同一块内存。如果指令在“std::shared_ptr”实例中,则指令组或指令调度程序将无法获得所有权并在指令完成执行后释放内存,因为用户可能仍然不知不觉中仍然拥有“std::shared_ptr”。指向范围内某处内存块的实例。

CRTP的使用

您可能已经注意到,为了创建新指令,必须扩展“CommandHelper”,提供基类(通常是“frc2::Command”)和刚创建的类。让我们看一下其背后的原因:

Command Decorators

新的基于指令的框架包括称为“command decorators”的功能,该功能允许用户执行以下操作:

auto task = MyCommand().AndThen([] { std::cout << "This printed after my command ended."; },
  requirements);

计划好“task”之后,它将首先执行“MyCommand()”,一旦该命令执行完毕,它将把消息打印到控制台。在内部实现此目标的方法是使用顺序指令组。

回顾上一节,为了构建顺序指令组,我们需要一个指向每个指令的唯一指针的向量。为打印功能创建唯一的指针非常简单:

temp.emplace_back(
   std::make_unique<InstantCommand>(std::move(toRun), requirements));

这里的“temp”存储我们需要传递给“SequentialCommandGroup”构造函数的指令向量。但是在添加“InstantCommand”之前,我们需要将“MyCommand()”添加到“SequentialCommandGroup”中。我们该怎么做?

temp.emplace_back(std::make_unique<MyCommand>(std::move(*this));

您可能会认为这很简单,但事实并非如此。因为此修饰符代码在“Command”界面中,所以“this”指代您从其调用修饰符的子类中的“Command”,并且具有“Command”的类型。实际上,您将尝试移动“Command”而不是“MyCommand”。我们可以将“this”指针转换为“MyCommand”,然后取消引用,但我们没有有关在编译时要转换为子类的信息。

解决问题的方法

我们对此的最初解决方案是在“Command”中创建一个虚拟方法,称为“TransferOwnership()”,“Command”的每个子类都必须重写。这样的覆盖看起来像这样:

std::unique_ptr<Command> TransferOwnership() && override {
  return std::make_unique<MyCommand>(std::move(*this));
}

因为代码将在派生的子类中,所以“*this”实际上将指向所需的子类实例,并且用户具有派生类的类型信息以构成唯一的指针。

经过几天的审议,提出了一种CRTP方法。在这里,将存在一个称为“CommandCommander”的中间派生类“Command”。 “CommandHelper”有两个模板参数,原始基类和所需的派生子类。让我们看一下“CommandHelper”的基本实现以了解这一点:

// In the real implementation, we use SFINAE to check that Base is actually a
// Command or a subclass of Command.
template<typename Base, typename Derived>
class CommandHelper : public Base {
  // Here, we are just inheriting all of the superclass (base class) constructors.
  using Base::Base;

  // Here, we will override the TransferOwnership() method mentioned above.
  std::unique_ptr<Command> TransferOwnership() && override {
    // Previously, we mentioned that we had no information about the derived class
    // to cast to at compile-time, but because of CRTP we do! It's one of our template
    // arguments!
    return std::make_unique<Derived>(std::move(*static_cast<Derived*>(this)));
  }
};

因此,使您的自定义命令扩展为“CommandHelper”而不是“Command”将自动为您实现此样板,这就是要求团队使用似乎是一种相当晦涩的处理方式的原因。

回到我们的“AndThen()”示例,我们现在可以执行以下操作:

// Because of how inheritance works, we will call the TransferOwnership()
// of the subclass. We are moving *this because TransferOwnership() can only
// be called on rvalue references.
temp.emplace_back(std::move(*this).TransferOwnership());

缺乏高级装饰

大多数C ++装饰器都采用“std::function <void()>”代替实际的命令本身。考虑了在装饰器中接收实际命令的想法,例如“AndThen()”,“BeforeStarting()”等,但由于多种原因而被放弃。

模板装饰器

因为我们需要在编译时知道要添加到指令组的指令的类型,所以我们将需要使用模板(对于多个指令来说是可变的)。但是,这似乎没什么大不了的。无论如何,指令组的构造函数都会这样做:

template <class... Types,
         typename = std::enable_if_t<std::conjunction_v<
             std::is_base_of<Command, std::remove_reference_t<Types>>...>>>
explicit SequentialCommandGroup(Types&&... commands) {
  AddCommands(std::forward<Types>(commands)...);
}

template <class... Types,
         typename = std::enable_if_t<std::conjunction_v<
             std::is_base_of<Command, std::remove_reference_t<Types>>...>>>
void AddCommands(Types&&... commands) {
  std::vector<std::unique_ptr<Command>> foo;
  ((void)foo.emplace_back(std::make_unique<std::remove_reference_t<Types>>(
       std::forward<Types>(commands))),
   ...);
  AddCommands(std::move(foo));
}

注解

除了我们上面描述的向量构造器之外,这是“SequentialCommandGroup”的辅助构造器。

但是,当我们制作模板函数时,必须将其定义内联声明。这意味着我们将需要在“Command.h”标头中实例化“SequentialCommandGroup”,这会带来问题。 “SequentialCommandGroup.h”包括“Command.h”。如果我们在“Command.h”内包含“SequentialCommandGroup.h”,则具有循环依赖关系。那我们现在怎么办呢?

我们在“Command.h”的顶部使用前向声明:

class SequentialCommandGroup;

class Command { ... };

然后我们在“Command.cpp”中包含“SequentialCommandGroup.h”。但是,如果这些装饰器函数是模板化的,则无法在“.cpp”文件中编写定义,从而导致循环依赖。

Java与C ++语法

这些装饰器通常在Java中保存比在C ++中更多的详细信息(因为Java需要原始的“new”调用),因此通常,如果您在用户代码中手动创建指令组,则在C ++中并不会产生太大的区别。