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

备注

This article assumes that you have a fair understanding of advanced C++ concepts, including templates, smart pointers, inheritance, rvalue references, copy semantics, move semantics, and CRTP. You do not need to understand the information within this article to use the command-based framework in your robot code.

This article will help you understand the reasoning behind some of the decisions made in the 2020 command-based framework (such as the use of std::unique_ptr, CRTP in the form of CommandHelper<Base, Derived>, etc.). You do not need to understand the information within this article to use the command-based framework in your robot code.

备注

The model was further changed in 2023, as described below.

所有权模型

基于指令的旧框架使用原始指针,这意味着用户必须在其机器人代码中使用“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());
}

In the command-group above, the component commands of the command group were being heap allocated and passed into AddSequential all in the same line. This meant that user had no reference to that object in memory and therefore had no means of freeing the allocated memory once the command group ended. The command group itself never freed the memory and neither did the command scheduler. This led to memory leaks in robot programs (i.e. memory was allocated on the heap but never freed).

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

Default commands are owned by the command scheduler whereas component commands of command compositions are owned by the command composition. Other commands are owned by whatever the user decides they should be owned by (e.g. a subsystem instance or a RobotContainer instance). This means that the ownership of the memory allocated by any commands or command compositions is clearly defined.

“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的使用

You may have noticed that in order to create a new command, you must extend CommandHelper, providing the base class (usually frc2::Command) and the class that you just created. Let’s take a look at the reasoning behind this:

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));

You might think it would be this straightforward, but that is not the case. Because this decorator code is in the Command class, *this refers to the Command in the subclass that you are calling the decorator from and has the type of Command. Effectively, you will be trying to move a Command instead of MyCommand. We could cast the this pointer to a MyCommand* and then dereference it but we have no information about the subclass to cast to at compile-time.

解决问题的方法

我们对此的最初解决方案是在“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 ++中并不会产生太大的区别。

2023 Updates

After a few years in the new command-based framework, the recommended way to create commands increasingly shifted towards inline commands, decorators, and factory methods. With this paradigm shift, it became evident that the C++ commands model introduced in 2020 and described above has some pain points when used according to the new recommendations.

A significant root cause of most pain points was commands being passed by value in a non-polymorphic way. This made object slicing mistakes rather easy, and changes in composition structure could propagate type changes throughout the codebase: for example, if a ParallelRaceGroup were changed to a ParallelDeadlineGroup, those type changes would propagate through the codebase. Passing around the object as a Command (as done in Java) would result in object slicing.

Additionally, various decorators weren’t supported in C++ due to reasons described above. As long as decorators were rarely used and were mainly to reduce verbosity (where Java was more verbose than C++), this was less of a problem. Once heavy usage of decorators was recommended, this became more of an issue.

CommandPtr

Let’s recall the mention of std::unique_ptr far above: a value type with only move semantics. This is the ownership model we want!

However, plainly using std::unique_ptr<Command> had some drawbacks. Primarily, implementing decorators would be impossible: unique_ptr is defined in the standard library so we can’t define methods on it, and any methods defined on Command wouldn’t have access to the owning unique_ptr.

The solution is CommandPtr: a move-only value class wrapping unique_ptr, that we can define methods on.

Commands should be passed around as CommandPtr, using std::move. All decorators, including those not supported in C++ before, are defined on CommandPtr with rvalue-this. The use of rvalues, move-only semantics, and clear ownership makes it very easy to avoid mistakes such as adding the same command instance to more than one command composition.

In addition to decorators, CommandPtr instances also define utility methods such as Schedule(), IsScheduled(). CommandPtr instances can be used in nearly almost every way command objects can be used in Java: they can be moved into trigger bindings, default commands, and so on. For the few things that require a Command* (such as non-owning trigger bindings), a raw pointer to the owned command can be retrieved using get().

There are multiple ways to get a CommandPtr instance:

  • CommandPtr-returning factories are present in the frc2::cmd namespace in the Commands.h header for almost all command types. For multi-command compositions, there is a vector-taking overload as well as a variadic-templated overload for multiple CommandPtr instances.

  • All decorators, including those defined on Command, return CommandPtr. This has allowed defining almost all decorators on Command, so a decorator chain can start from a Command.

  • A ToPtr() method has been added to the CRTP, akin to TransferOwnership. This is useful especially for user-defined command classes, as well as other command classes that don’t have factories.

For instance, consider the following from the HatchbotInlined example project:

33frc2::CommandPtr autos::ComplexAuto(DriveSubsystem* drive,
34                                    HatchSubsystem* hatch) {
35  return frc2::cmd::Sequence(
36      // Drive forward the specified distance
37      frc2::FunctionalCommand(
38          // Reset encoders on command start
39          [drive] { drive->ResetEncoders(); },
40          // Drive forward while the command is executing
41          [drive] { drive->ArcadeDrive(kAutoDriveSpeed, 0); },
42          // Stop driving at the end of the command
43          [drive](bool interrupted) { drive->ArcadeDrive(0, 0); },
44          // End the command when the robot's driven distance exceeds the
45          // desired value
46          [drive] {
47            return drive->GetAverageEncoderDistance() >=
48                   kAutoDriveDistanceInches;
49          },
50          // Requires the drive subsystem
51          {drive})
52          .ToPtr(),
53      // Release the hatch
54      hatch->ReleaseHatchCommand(),
55      // Drive backward the specified distance
56      // Drive forward the specified distance
57      frc2::FunctionalCommand(
58          // Reset encoders on command start
59          [drive] { drive->ResetEncoders(); },
60          // Drive backward while the command is executing
61          [drive] { drive->ArcadeDrive(-kAutoDriveSpeed, 0); },
62          // Stop driving at the end of the command
63          [drive](bool interrupted) { drive->ArcadeDrive(0, 0); },
64          // End the command when the robot's driven distance exceeds the
65          // desired value
66          [drive] {
67            return drive->GetAverageEncoderDistance() <=
68                   kAutoBackupDistanceInches;
69          },
70          // Requires the drive subsystem
71          {drive})
72          .ToPtr());
73}

To avoid breakage, command compositions still use unique_ptr<Command>, so CommandPtr instances can be destructured into a unique_ptr<Command> using the Unwrap() rvalue-this method. For vectors, the static CommandPtr::UnwrapVector(vector<CommandPtr>) function exists.