Una discusión técnica sobre los comandos de C ++

Importante

Este artículo sirve como una discusión técnica sobre algunas de las decisiones de diseño que se tomaron al diseñar el nuevo marco basado en comandos en C++. No es necesario que comprenda la información de este artículo para utilizar el marco basado en comandos en su código de robot.

Nota

Este artículo asume que usted comprende los conceptos avanzados de C++, incluidas plantillas, punteros inteligentes, herencia, referencias de rvalue, semántica de copia, semántica de movimiento y CRTP.

Este artículo lo ayudará a comprender el razonamiento detrás de algunas de las decisiones tomadas en el nuevo marco basado en comandos (como el uso de std::unique_ptr, CRTP en la forma de CommandHelper<Base, Derived>, la falta de decoradores más avanzados que están disponibles en Java, etc.)

Modelo de propiedad

El antiguo marco basado en comandos empleaba el uso de punteros sin procesar, lo que significa que los usuarios tenían que usar nuevo (lo que resultaba en asignaciones de montón manuales) en su código de robot. Dado que no había una indicación clara sobre quién era el propietario de los comandos (el programador, los grupos de comandos o el usuario mismo), no era evidente quién se suponía que debía encargarse de liberar la memoria.

Varios ejemplos en el antiguo marco basado en comandos involucraban código como este:

#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());
}

En el grupo de comandos anterior, los comandos de los componentes del grupo de comandos se asignaban en montón y se pasaban a AddSequential todos en la misma línea. Esto significaba que ese usuario no tenía ninguna referencia a ese objeto en la memoria y, por lo tanto, no tenía forma de liberar la memoria asignada una vez que finalizaba el grupo de comando. El grupo de comandos en sí nunca liberó la memoria y tampoco el programador de comandos. Esto condujo a pérdidas de memoria en los programas de robot (es decir, la memoria se asignó en el montón pero nunca se liberó).

Este problema evidente fue una de las razones de la reescritura del marco. Se introdujo un modelo de propiedad integral con esta reescritura, junto con el uso de punteros inteligentes que liberarán memoria automáticamente cuando se salgan del alcance.

Los comandos predeterminados son propiedad del programador de comandos, mientras que los comandos de componentes de los grupos de comandos pertenecen al grupo de comandos. Otros comandos son propiedad de lo que el usuario decida que debe pertenecer (por ejemplo, una instancia de subsistema o una instancia de RobotContainer). Esto significa que la propiedad de la memoria asignada por cualquier comando o grupo de comando está claramente definida.

std::unique_ptr vs. std::shared_ptr

El uso de std::unique_ptr nos permite determinar claramente quién es el propietario del objeto. Debido a que un std::unique_ptr no se puede copiar, nunca habrá más de una instancia de un `` td::unique_ptr`` que apunte al mismo bloque de memoria en el montón. Por ejemplo, un constructor para SequentialCommandGroup toma un std::vector<std::unique_ptr<Command>> &&. Esto significa que requiere una referencia rvalue a un vector de std::unique_ptr <Command>. Repasemos un código de ejemplo paso a paso para comprender esto mejor:

// 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.

Con std::shared_ptr, no hay un modelo de propiedad claro porque puede haber varias instancias de un std::shared_ptr que apuntan al mismo bloque de memoria. Si los comandos estuvieran en instancias std::shared_ptr, un grupo de comandos o el programador de comandos no puede tomar posesión y liberar la memoria una vez que el comando ha terminado de ejecutarse porque el usuario, sin saberlo, todavía podría tener un std::shared_ptr instancia que apunta a ese bloque de memoria en algún lugar del alcance.

Uso de CRTP

Es posible que haya notado que para crear un nuevo comando, debe extender std::shared_ptr , proporcionando la clase base (generalmente frc2::Command ) y la clase que acaba de crear. Echemos un vistazo al razonamiento detrás de esto:

Decoradores de comando

El nuevo marco basado en comandos incluye una función conocida como «decoradores de comandos», que permite al usuario hacer algo como esto:

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

Cuando se programa la tarea , primero ejecutará MyCommand() y una vez que ese comando haya terminado de ejecutarse, imprimirá el mensaje en la consola. La forma en que esto se logra internamente es mediante el uso de un grupo de comando secuencial.

Recuerde de la sección anterior que para construir un grupo de comando secuencial, necesitamos un vector de punteros únicos para cada comando. Crear el puntero único para la función de impresión es bastante trivial:

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

Aquí, temp almacena el vector de comandos que necesitamos pasar al constructor SequentialCommandGroup . Pero antes de agregar ese InstantCommand,necesitamos agregar MyCommand() al``SequentialCommandGroup`` . ¿Como hacemos eso?

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

Podría pensar que sería así de sencillo, pero ese no es el caso. Debido a que este código de decorador está en el Command interface, *this referiendo a Command en la subclase desde la que está llamando al decorador y tiene el tipo de Command. Efectivamente, intentará mover un Command en lugar de MyCommand. Podríamos lanzar el esto puntero a un MyCommand* y luego desreferenciarlo, pero no tenemos información sobre la subclase a la que convertir en tiempo de compilación.

Soluciones al problema

Nuestra solución inicial a esto fue crear un método virtual en Command llamado TransferOwnership() que cada subclase de Command tenía que anular. Tal anulación se habría visto así:

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

Debido a que el código estaría en la subclase derivada, *this realmente apuntaría a la instancia de subclase deseada y el usuario tiene la información de tipo de la clase derivada para hacer el puntero único.

Después de unos días de deliberación, se propuso un método CRTP. Aquí, una clase derivada intermedia de Command llamada CommandHelper  esxistiría. CommandHelper tendría dos argumentos de plantilla, la clase base original y la subclase derivada deseada. Echemos un vistazo a una implementación básica de CommandHelper para entender esto:

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

Así, haciendo que tus comandos personalizados extiendan CommandHelper en lugar de Command implementará automáticamente esta plantilla para ti y este es el razonamiento detrás de pedir a los equipos que usen lo que puede parecer una forma bastante oscura de hacer las cosas.

Volviendo a nuestro AndThen() ejemplo, ahora podemos hacer lo siguiente:

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

Falta de decoradores avanzados

La mayoría de los decoradores de C++ toman std::function<void()> en lugar de comandos reales ellos mismos. La idea de tomar comandos reales en decoradores como AndThen(), BeforeStarting(), etc. fue considerada pero luego abandonada debido a una variedad de razones.

Decoradores de plantillas

Debido a que necesitamos saber los tipos de los comandos que estamos añadiendo a un grupo de comandos en tiempo de compilación, necesitaremos usar plantillas (variadic para múltiples comandos). Sin embargo, esto podría no parecer un gran problema. Los constructores de grupos de comandos lo hacen de todas formas:

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

Nota

Este es un constructor secundario para el SequentialCommandGroup, además del constructor de vectores que describimos anteriormente.

Sin embargo, cuando realizamos una función de plantilla, su definición debe ser declarada en línea. Esto significa que tendremos que instanciar el SequentialCommandGroup en el encabezado «Command.h», lo que plantea un problema. SequentialCommandGroup incluye «Command.h». Si incluimos SequentialCommandGroup dentro de «Command.h», tenemos una dependencia circular. ¿Cómo lo hacemos ahora entonces?

Usamos una declaración hacia adelante en la parte superior de Command.h:

class SequentialCommandGroup;

class Command { ... };

Y luego incluimos SequentialCommandGroup.h en «Command.cpp». Sin embargo, si estas funciones de decorador se templaron, no podemos escribir definiciones en los archivos .cpp, resultando en una dependencia circular.

Sintaxis de Java vs Sintaxis de C++

Estos decoradores suelen ahorrar más verbosidad en Java (porque Java requiere llamadas nuevas sin procesar) que en C++, así que en general, no hay mucha diferencia sintáctica en C++ si se crea el grupo de comandos manualmente en código de usuario.