有关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.
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 thefrc2::cmd
namespace in theCommands.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 multipleCommandPtr
instances.All decorators, including those defined on
Command
, returnCommandPtr
. This has allowed defining almost all decorators onCommand
, so a decorator chain can start from aCommand
.A
ToPtr()
method has been added to the CRTP, akin toTransferOwnership
. 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.