Une discussion technique sur les commandes C++
Note
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.
Note
The model was further changed in 2023, as described below.
Modèle de propriété
L’ancien cadre basé sur les commandes utilisait des pointeurs bruts, ce qui signifie que les usagers devaient employer la déclaration new
(résultant en des allocations manuelles de mémoire de type « heap ») dans leur code robot. Comme il n’y avait aucune indication claire sur quel partie du programme qui était responsable des commandes (le planificateur, les groupes de commandes ou l’utilisateur lui-même), il n’était pas évident de savoir qui devait s’occuper de libérer cette mémoire allouée.
Plusieurs exemples dans l’ancien cadre basé sur des commandes impliquaient du code comme celui-ci:
#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).
Ce problème flagrant a été l’une des raisons de la réécriture du cadre. Un nouveau modèle de propriété complet a été généré avec cette réécriture, qui comprend l’utilisation de pointeurs intelligents pour libérer automatiquement la mémoire lorsqu’ils ne seront plus actifs.
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.
Utilisation de 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:
Décorateurs de commandes
Le nouveau cadre basé sur les commandes comprend une fonctionnalité connue sous le nom de « décorateurs de commandes », qui permet à l’utilisateur de faire quelque chose comme ceci:
auto task = MyCommand().AndThen([] { std::cout << "This printed after my command ended."; },
requirements);
Lorsque task
est planifiée, elle exécutera d’abord MyCommand()
et une fois que cette commande aura fini de s’exécuter, elle imprimera le message sur la console. La façon dont cela est réalisé en interne consiste à utiliser un groupe de commandes séquentielles.
Rappelez-vous de la section précédente que pour construire un groupe de commandes séquentielles, nous avons besoin d’un vecteur de pointeurs uniques vers chaque commande. La création du pointeur unique pour la fonction d’impression est assez simple:
temp.emplace_back(
std::make_unique<InstantCommand>(std::move(toRun), requirements));
Ici, temp
stocke le vecteur de commandes que nous devons passer dans le constructeur SequentialCommandGroup
. Mais avant d’ajouter InstantCommand
, nous devons ajouter `` MyCommand () “” au SequentialCommandGroup
. Comment fait-on cela?
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.
Solutions au problème
Notre solution initiale à cela était de créer une méthode virtuelle dans Command
appelée TransferOwnership()
que chaque sous-classe de Command
devait remplacer. Un tel remplacement aurait ressemblé à ceci:
std::unique_ptr<Command> TransferOwnership() && override {
return std::make_unique<MyCommand>(std::move(*this));
}
Parce que le code serait dans la sous-classe dérivée, *this
pointerait alors vers l’instance souhaitée de la sous-classe, et par conséquant, l’utilisateur dispose alors des informations de type de la classe dérivée pour créer le pointeur unique.
Après quelques jours de délibération, une méthode CRTP a été proposée. Ici, une classe dérivée intermédiaire de Command
appelée CommandHelper
serait créé. CommandHelper
aurait deux arguments de modèle, la classe de base d’origine et la sous-classe dérivée souhaitée. Jetons un coup d’œil à une implémentation de base de CommandHelper
pour comprendre ceci:
// 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)));
}
};
Ainsi, en faisant étendre vos commandes personnalisées via CommandHelper
au lieu de Command
fera implémenter automatiquement ce processus pour vous et c’est le raisonnement derrière le fait de demander aux équipes d’utiliser ce qui peut sembler être une façon assez obscure de faire les choses.
Pour revenir à notre exemple AndThen()
, nous pouvons maintenant faire ce qui suit:
// 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());
Manque de décorateurs avancés
La plupart des décorateurs C ++ utilisent std::function<void()>
au lieu des commandes proprement dites. L’idée de prendre des commandes réelles dans les décorateurs telles que AndThen()
, BeforeStarting()
, etc. a été envisagée mais abandonnée pour diverses raisons.
Décorateurs de modèles
Parce que nous avons besoin de connaître les types de commandes que nous ajoutons à un groupe de commandes au moment de la compilation, nous devrons utiliser des modèles (variadic pour plusieurs commandes). Cependant, cela peut ne pas sembler un gros problème. Les constructeurs des groupes de commandes le font par défaut:
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));
}
Note
Il s’agit d’un constructeur secondaire pour SequentialCommandGroup
en plus du constructeur vectoriel que nous avons décrit ci-dessus.
Cependant, lorsque nous créons une fonction basée sur un modèle, sa définition doit être déclarée en ligne. Cela signifie que nous devrons instancier le SequentialCommandGroup
dans l’en-tête Command.h
, ce qui pose un problème. SequentialCommandGroup.h
inclut Command.h
. Si nous incluons SequentialCommandGroup.h
à l’intérieur de Command.h
, nous avons une dépendance circulaire. Comment procéder alors?
Nous utilisons une déclaration en haut de Command.h
:
class SequentialCommandGroup;
class Command { ... };
Et puis nous incluons SequentialCommandGroup.h
dans Command.cpp
. Cependant, si ces fonctions décoratrices ont été modélisées, nous ne pouvons pas écrire de définitions dans les fichiers .cpp
, ce qui entraîne une dépendance circulaire.
Syntaxe Java vs C ++
Ces décorateurs réduisent la verbosité en Java (car Java nécessite des appels bruts de type new
) par rapport à C ++, donc en général, cela ne fait pas beaucoup de différence au niveau de la syntaxe en C ++ si vous créez le groupe de commandes manuellement dans le code utilisateur.
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.