Saltar al contenido principal

Define relaciones con clases

Aprende sobre programación orientada a objetos en Dart, incluyendo clases abstractas, herencia, overrides y enums. Construye un framework para aplicaciones de CLI bien estructuradas.

En este capítulo, explorarás el poder de la programación orientada a objetos (OOP) en Dart. Aprenderás a crear clases y definir relaciones entre ellas, incluyendo la herencia y las clases abstractas. También construirás las bases para crear aplicaciones de CLI bien estructuradas.

Qué lograrás

Diseñar y entender clases abstractas
Extender clases padres y sobrescribir métodos (overrides)
Usar enums para representar conjuntos fijos de valores
Continuar construyendo tu framework de CLI

Prerrequisitos

#

Antes de comenzar este capítulo, asegúrate de:

  • Haber completado el Capítulo 4 y tener un entorno de desarrollo de Dart en funcionamiento con el proyecto dartpedia.
  • Estar familiarizado con conceptos básicos de programación como variables, funciones y flujo de control.
  • Entender los conceptos de paquetes y librerías en Dart.

Tareas

#

Una interfaz de línea de comandos (CLI) se define por los comandos, opciones y argumentos que un usuario puede escribir en su terminal.

Al final de esta lección, habrás construido un framework que puede entender un comando como este:

bash
$ dartpedia help --verbose --command=search

Aquí tienes un desglose de cada parte:

  • dartpedia: Este es el ejecutable, el nombre de tu aplicación.
  • help: Este es un comando, una acción que deseas que realice la aplicación.
  • --verbose: Esta es una bandera (flag) (un tipo de opción que no toma un valor), que modifica el comportamiento del comando.
  • --command=search: Esta es una opción que toma un valor. Aquí, la opción se llama command y su valor es search.

Las clases y la lógica que construyas en las siguientes tareas crean la base para parsear y ejecutar comandos como este.

Tarea 1: Definir la jerarquía de argumentos

#

Primero, definirás una clase Argument, una clase Option y una clase Command, estableciendo una relación de herencia.

  1. Crea el archivo command_runner/lib/src/arguments.dart. Este archivo contendrá las definiciones para tus clases Argument, Option, Command y ArgResults classes.

  2. Define un enum llamado OptionType.

    command_runner/lib/src/arguments.dart
    dart
    enum OptionType { flag, option }
    

    Este enum representa el tipo de opción, que puede ser una bandera (flag) (una opción booleana) o una opción normal (una opción que toma un valor). Los enums son útiles para representar un conjunto fijo de valores posibles.

  3. Define una clase abstracta llamada Argument.

    Comienza definiendo la estructura básica de tu clase Argument. La declararás como abstract, lo que significa que sirve como una clase base que otras clases pueden extender, pero no se puede instanciarInstanciarCrear una instancia concreta (objeto) de una clase. Más información por sí misma.

    Debajo del enum que acabas de agregar, pega el siguiente código:

    command_runner/lib/src/arguments.dart
    dart
    // Paste this new class below the enum you added
    abstract class Argument {
      String get name;
      String? get help;
    
      // In the case of flags, the default value is a bool.
      // In other options and commands, the default value is a String.
      // NB: flags are just Option objects that don't take arguments
      Object? get defaultValue;
      String? get valueHelp;
    
      String get usage;
    }
    
    • name es una cadena de texto (String) que identifica de manera única el argumento.
    • help es una cadena de texto (String) opcional que proporciona una descripción.
    • defaultValue es de tipo Object? porque puede ser un booleano (bool) (para banderas) o un String.
    • valueHelp es una cadena de texto (String) opcional para dar una pista sobre el valor esperado.
    • El método getterGetterUn método especial que proporciona acceso de lectura a las propiedades de un objeto. Más información de usage proporcionará una cadena que muestra cómo usar el argumento.

    Con la clase Argument completamente definida, tienes una interfaz común para todos los tipos de argumentos de línea de comandos. A continuación, construirás sobre esto definiendo Option, un tipo específico de argumento que extiende Argument.

  4. Define una clase llamada Option que extienda (extends) de Argument.

    La clase Option representará opciones de línea de comandos como --verbose o --output=file.txt. Heredará de tu clase Argument.

    Agrega la siguiente clase Option en la parte inferior de tu archivo:

    command_runner/lib/src/arguments.dart
    dart
    class Option extends Argument {
      Option(
        this.name, {
        required this.type,
        this.help,
        this.abbr,
        this.defaultValue,
        this.valueHelp,
      });
    
      @override
      final String name;
    
      final OptionType type;
    
      @override
      final String? help;
    
      final String? abbr;
    
      @override
      final Object? defaultValue;
    
      @override
      final String? valueHelp;
    
      @override
      String get usage {
        if (abbr != null) {
          return '-$abbr,--$name: $help';
        }
    
        return '--$name: $help';
      }
    }
    

    La palabra clave extends establece la relación de herencia. La clase utiliza la anotación @override en sus propiedades y getters para indicar que está reemplazando los miembros de marcador de posición definidos en Argument.

    También añade type (usando el enum OptionType) y un abbr opcional para una forma abreviada de la opción. El getter usage está implementado para proporcionar instrucciones claras al usuario.

    Con Option completo, tienes un tipo especializado de argumento. A continuación, definirás la clase Command, otro tipo de argumento que representará las acciones principales que un usuario puede realizar en tu aplicación de CLI.

  5. Define una clase abstracta llamada Command que también extienda (extends) de Argument.

    La clase Command representará una acción ejecutable. Dado que proporciona una plantilla para que la sigan otros comandos, la declararás como abstract.

    command_runner/lib/src/arguments.dart
    dart
    // Add this class below the Option class
    abstract class Command extends Argument {
      // Properties and methods will go here
    }
    

    La palabra clave abstract significa que Command no se puede instanciar directamente. Sirve como una clase base para otras clases.

    Ahora, agrega las propiedades principales. Un comando necesita un name y una description. También necesita una referencia de regreso al CommandRunner que lo ejecuta.

    command_runner/lib/src/arguments.dart
    dart
    abstract class Command extends Argument {
      @override
      String get name;
    
      String get description;
    
      bool get requiresArgument => false;
    
      late CommandRunner runner;
    
      @override
      String? help;
    
      @override
      String? defaultValue;
    
      @override
      String? valueHelp;
    }
    

    La propiedad runner es de tipo CommandRunner, que definirás más adelante en command_runner_base.dart.

    Observa la palabra clave late. Le dice a Dart que prometes inicializar esta variable antes de que se acceda a ella, lo que te permite declarar una variable no nulleable sin asignarla inmediatamente. Esto es útil cuando la inicialización de una variable depende de otros objetos (como cuando se agrega un comando a un runner).

    Para que Dart reconozca esta clase, debes importar el archivo que la define. Agrega la siguiente importación en la parte superior de command_runner/lib/src/arguments.dart:

    dart
    import '../command_runner.dart';
    

    A continuación, darás a los comandos su propio conjunto de opciones. Para evitar que otras partes de tu código modifiquen inesperadamente estas opciones, las guardarás en una lista privadaprivada de la libreríaAccesibilidad restringida a la [librería](#library) donde está definida. Saber más (_options). En Dart, anteponer un guion bajo (_) al nombre de una variable o campo la hace privada de la librería.

    En lugar de permitir el acceso directo, expones las opciones a través de una vista de solo lectura no modificable (UnmodifiableSetView). Este enfoque es una parte fundamental del encapsulamiento: la práctica de restringir el acceso directo al estado interno de una clase para evitar interferencias no deseadas.

    La clase UnmodifiableSetView es parte de la librería de colecciones principal de Dart. Para usarla, debes importar esa librería.

    Actualiza las importaciones en la parte superior de tu archivo para incluir dart:collection:

    dart
    import 'dart:collection'; // New import
    import '../command_runner.dart';
    

    Ahora, añade la lista options y su getter a tu clase Command:

    dart
    abstract class Command extends Argument {
      // ... existing properties ...
    
      @override
      String? valueHelp;
    
    
      // Add the following lines to the bottom of your Command class:
    
      final List<Option> _options = [];
    
      UnmodifiableSetView<Option> get options =>
          UnmodifiableSetView(_options.toSet());
    }
    

    Como _options es privado, el código externo no puede añadir opciones directamente. Para permitir que los comandos definan sus opciones, proporcionarás dos métodos de ayuda internos: addFlag y addOption. Estos métodos instanciarán los objetos Option correspondientes y los añadirán a la lista privada.

    dart
    abstract class Command extends Argument {
      // ... existing properties and getters ...
    
      UnmodifiableSetView<Option> get options =>
          UnmodifiableSetView(_options.toSet());
    
    
      // Add the following lines to the bottom of your Command class:
    
      // A flag is an [Option] that's treated as a boolean.
      void addFlag(String name, {String? help, String? abbr, String? valueHelp}) {
        _options.add(
          Option(
            name,
            help: help,
            abbr: abbr,
            defaultValue: false,
            valueHelp: valueHelp,
            type: OptionType.flag,
          ),
        );
      }
    
      // An option is an [Option] that takes a value.
      void addOption(
        String name, {
        String? help,
        String? abbr,
        String? defaultValue,
        String? valueHelp,
      }) {
        _options.add(
          Option(
            name,
            help: help,
            abbr: abbr,
            defaultValue: defaultValue,
            valueHelp: valueHelp,
            type: OptionType.option,
          ),
        );
      }
    }
    

    Finalmente, cada comando debe tener lógica para ejecutarse al ser llamado. Definirás un método abstracto run que los comandos concretos deben implementar.

    Dado que un comando puede ser síncrono o asíncrono, su método run devuelve el tipo FutureOr de dart:async, lo que le permite devolver tanto un valor crudo como un Future. Esta es tu última importación requerida.

    Actualiza las importaciones en la parte superior de tu archivo para incluir dart:async:

    dart
    import 'dart:async'; // New import
    import 'dart:collection';
    import '../command_runner.dart';
    

    Ahora puedes añadir el método abstracto run y proporcionar la implementación de usage para completar la clase Command.

    dart
    abstract class Command extends Argument {
      // ... existing properties, getters, and methods ...
    
      void addOption(
        String name, {
        String? help,
        String? abbr,
        String? defaultValue,
        String? valueHelp,
      }) {
        _options.add(
          Option(
            name,
            help: help,
            abbr: abbr,
            defaultValue: defaultValue,
            valueHelp: valueHelp,
            type: OptionType.option,
          ),
        );
      }
    
    
      // Add the following lines to the bottom of your Command class:
      FutureOr<Object?> run(ArgResults args);
    
      @override
      String get usage {
        return '$name:  $description';
      }
    }
    
    • run(ArgResults args): En este método abstracto es donde reside la lógica del comando. Las subclases concretas deben implementarlo.
    • usage: Este getter proporciona una cadena de uso simple, combinando el name y la description del comando.

    La clase Command ahora proporciona una robusta base para todos los comandos de tu aplicación CLI. Con la jerarquía de clases establecida, estás listo para definir ArgResults para contener la entrada parseada.

  6. Define una clase llamada ArgResults.

    command_runner/lib/src/arguments.dart
    dart
    // Add this class to the end of the file
    class ArgResults {
      Command? command;
      String? commandArg;
      Map<Option, Object?> options = {};
    
      // Returns true if the flag exists.
      bool flag(String name) {
        // Only check flags, because we're sure that flags are booleans.
        for (var option in options.keys.where(
          (option) => option.type == OptionType.flag,
        )) {
          if (option.name == name) {
            return options[option] as bool;
          }
        }
        return false;
      }
    
      bool hasOption(String name) {
        return options.keys.any((option) => option.name == name);
      }
    
      ({Option option, Object? input}) getOption(String name) {
        var mapEntry = options.entries.firstWhere(
          (entry) => entry.key.name == name || entry.key.abbr == name,
        );
    
        return (option: mapEntry.key, input: mapEntry.value);
      }
    }
    

    Esta clase representa los resultados del parseo de los argumentos de línea de comandos. Contiene el comando detectado, cualquier argumento para ese comando y un mapa de las opciones especificadas.

    • El método flag(): Itera sobre las claves del mapa options, filtrando aquellas donde type es OptionType.flag. Utiliza un casteo de tipo explícito (as bool) porque sabemos que las banderas son valores booleanos.
    • Tipos de registro (record types): El método getOption devuelve un tipo de registro (option: ..., input: ...), lo que te permite agrupar múltiples valores sin necesidad de crear una clase completa.

    Ahora has definido la estructura básica para manejar comandos, argumentos y opciones en tu aplicación de línea de comandos.

Tarea 2: Actualizar la clase CommandRunner

#

A continuación, actualiza la clase CommandRunner para usar la nueva jerarquía de Argument.

  1. Abre el archivo command_runner/lib/src/command_runner_base.dart.

  2. Reemplaza la clase CommandRunner existente con la siguiente:

    command_runner/lib/src/command_runner_base.dart
    dart
    import 'dart:collection';
    import 'dart:io';
    import 'arguments.dart';
    
    class CommandRunner {
      final Map<String, Command> _commands = <String, Command>{};
    
      UnmodifiableSetView<Command> get commands =>
          UnmodifiableSetView<Command>(<Command>{..._commands.values});
    
      Future<void> run(List<String> input) async {
        final ArgResults results = parse(input);
        if (results.command != null) {
          Object? output = await results.command!.run(results);
          print(output.toString());
        }
      }
    
      void addCommand(Command command) {
        // TODO: handle error (Commands can't have names that conflict)
        _commands[command.name] = command;
        command.runner = this;
      }
    
      ArgResults parse(List<String> input) {
        var results = ArgResults();
        results.command = _commands[input.first];
        return results;
      }
    
      // Returns usage for the executable only.
      // Should be overridden if you aren't using [HelpCommand]
      // or another means of printing usage.
    
      String get usage {
        final exeFile = Platform.script.path.split('/').last;
        return 'Usage: dart bin/$exeFile <command> [commandArg?] [...options?]';
      }
    }
    

    Esta clase actualizada incorpora tu nueva estructura orientada a objetos. Utiliza el operador de propagación (spread operator) (...) en el getter commands para desempaquetar los valores del mapa _commands en un nuevo conjunto (set). Esto asegura que el valor devuelto sea una copia, evitando que el código externo modifique tus datos.

    En el método run(), results.command!.run(...) usa el operador de aserción de no nulo (!) para decirle al analyzer de Dart que estás seguro de que results.command no es nulo. Es seguro aquí porque acabas de comprobar que no era nulo en la sentencia if anterior.

    Aquí están los detalles clave de la implementación:

    • Mapa _commands: Un mapa privado que vincula nombres de comandos con sus instancias de objeto concretas de Command. Sus valores se exponen a través de un UnmodifiableSetView.
    • addCommand(): Registra un comando y asigna este runner a la propiedad runner del comando. Esto cumple la promesa de inicialización tardía (late) hecha anteriormente en la clase Command.
    • parse() y run(): Evalúa la entrada del usuario, identifica el Command correspondiente del mapa y usa await para llamar al método run() del comando.
  3. Abre command_runner/lib/command_runner.dart y añade las siguientes exportaciones:

    command_runner/lib/command_runner.dart
    dart
    /// Support for doing something awesome.
    ///
    /// More dartdocs go here.
    library;
    
    export 'src/arguments.dart';
    export 'src/command_runner_base.dart';
    export 'src/help_command.dart';
    
    // TODO: Export any libraries intended for clients of this package.
    

    Esto hace que los archivos arguments.dart, command_runner_base.dart y help_command.dart estén disponibles para otros paquetes que dependan de command_runner.

Tarea 3: Crear un HelpCommand

#

Crea un HelpCommand que extienda la clase Command e imprima información de uso.

  1. Crea el archivo command_runner/lib/src/help_command.dart.

  2. Agrega el siguiente código a command_runner/lib/src/help_command.dart:

    command_runner/lib/src/help_command.dart
    dart
    import 'dart:async';
    
    import 'arguments.dart';
    
    // Prints program and argument usage.
    //
    // When given a command as an argument, it prints the usage of
    // that command only, including its options and other details.
    // When the flag 'verbose' is set, it prints options and details for all commands.
    //
    // This command isn't automatically added to CommandRunner instances.
    // Packages users should add it themselves with [CommandRunner.addCommand],
    // or create their own command that prints usage.
    
    class HelpCommand extends Command {
      HelpCommand() {
        addFlag(
          'verbose',
          abbr: 'v',
          help: 'When true, this command will print each command and its options.',
        );
        addOption(
          'command',
          abbr: 'c',
          help:
              "When a command is passed as an argument, prints only that command's verbose usage.",
        );
      }
      @override
      String get name => 'help';
    
      @override
      String get description => 'Prints usage information to the command line.';
    
      @override
      String? get help => 'Prints this usage information';
    
      @override
      FutureOr<Object?> run(ArgResults args) async {
        var usage = runner.usage;
        for (var command in runner.commands) {
          usage += '\n ${command.usage}';
        }
    
        return usage;
      }
    }
    

    La clase HelpCommand demuestra los beneficios de la herencia. Utiliza los métodos de su padre para configurar sus opciones, sobrescribe el método abstracto run y accede al estado de runner para generar el mensaje de uso.

Tarea 4: Actualizar cli.dart para usar el nuevo CommandRunner

#

Modifica cli/bin/cli.dart para usar el nuevo CommandRunner y HelpCommand.

  1. Abre el archivo cli/bin/cli.dart.

  2. Reemplaza el código existente con el siguiente:

    cli/bin/cli.dart
    dart
    import 'package:command_runner/command_runner.dart';
    
    const version = '0.0.1';
    
    void main(List<String> arguments) {
      var commandRunner = CommandRunner()..addCommand(HelpCommand());
      commandRunner.run(arguments);
    }
    

    Este código crea una instancia de CommandRunner, le añade el HelpCommand usando una cascada de métodos (..addCommand) lo que te permite llamar a un método en un objeto directamente después de crearlo, y luego ejecuta el ejecutor de comandos (command runner) con los argumentos de línea de comandos.

Tarea 5: Ejecutar la aplicación

#

Prueba los nuevos CommandRunner y HelpCommand.

  1. Abre tu terminal y navega al directorio cli.

  2. Ejecuta el comando dart run bin/cli.dart help.

    Deberías ver la información de uso impresa en la consola:

    bash
    Usage: dart bin/cli.dart <command> [commandArg?] [...options?]
     help:  Prints usage information to the command line.
    

    Esto confirma que el CommandRunner y el HelpCommand están funcionando correctamente.

Revisión

#

Qué lograste

Aquí tienes un resumen de lo que construiste y aprendiste en esta lección.
Diseñaste y comprendiste clases abstractas

Creaste una clase abstracta Argument como una clase base que no se puede instanciar directamente. Las clases abstractas definen un contrato que las subclases deben cumplir, asegurando la consistencia en toda tu jerarquía de clases.

Extendiste clases padres y sobrescribiste métodos (overrides)

Usaste extends para crear las subclases Option y Command de la clase abstracta Argument. Dentro de esas subclases, usaste @override para proporcionar implementaciones concretas de miembros abstractos.

Usaste enums para representar conjuntos fijos de valores

Definiste un enum OptionType para representar un conjunto fijo de valores. En Dart, los enums son seguros en cuanto al tipo (type-safe) y ayudan a evitar que se usen valores inválidos donde solo son válidas opciones específicas.

Continuaste construyendo tu framework de CLI

Aplicaste los principios de la programación orientada a objetos para implementar un framework robusto y extensible para parsear argumentos de línea de comandos.

Cuestionario

#

Pon a prueba tu comprensión

1 / 3
In the Option class, what is the purpose of the @override annotation?
  1. Crear un nuevo método que no existe en la clase padre.

    No exactamente.

    @override no crea nuevos métodos. Los nuevos métodos se crean simplemente definiéndolos en la clase.

  2. Indicar que un método es opcional.

    No exactamente.

    @override no afecta si los métodos son opcionales. Los parámetros opcionales utilizan una sintaxis completamente diferente.

  3. Proporcionar una implementación específica para un método o propiedad definido en una clase padre.

    ¡Correcto!

    @override indica que estás proporcionando una implementación concreta para un miembro abstracto o reemplazando una implementación heredada.

  4. Hacer que una propiedad sea privada.

    No exactamente.

    La privacidad se indica mediante un guion bajo inicial (_), no mediante anotaciones. @override sirve para un propósito diferente relacionado con la herencia.

What is the difference between an abstract class and a regular class in Dart?
  1. Una clase abstract no puede tener ningún método.

    No exactamente.

    Las clases abstractas pueden tener tanto métodos abstractos (sin implementación) como métodos concretos (con implementación).

  2. Una clase abstract solo puede tener métodos privados.

    No exactamente.

    Las clases abstractas pueden tener métodos con cualquier nivel de visibilidad. La palabra clave abstract no restringe la visibilidad.

  3. No hay diferencia entre una clase abstract y una clase normal.

    No exactamente.

    Hay una diferencia significativa. Intenta escribir var x = MyAbstractClass(); y mira lo que sucede.

  4. Una clase abstract no se puede instanciar directamente.

    ¡Correcto!

    Las clases abstractas sirven como plantillas que extienden otras clases. No puedes crear instancias de una clase abstracta directamente.

When should you use an enum instead of a class or a set of constants?
  1. Cuando necesitas un tipo que solo puede ser uno de un conjunto fijo y conocido de valores.

    ¡Correcto!

    Los enums son perfectos cuando tienes un conjunto cerrado de opciones, como flag contra option, días de la semana o códigos de estado. El compilador asegura que manejes todos los casos.

  2. Cuando deseas crear múltiples instancias con diferentes propiedades.

    No exactamente.

    Para eso están las clases. Cada valor de enum es un singleton, solo hay una instancia para cada valor.

  3. Cuando necesitas almacenar la entrada del usuario que podría ser cualquier cadena de texto.

    No exactamente.

    La entrada del usuario es impredecible y podría ser cualquier cosa. Los enums funcionan mejor cuando puedes listar cada valor posible por adelantado.

  4. Cuando necesitas realizar operaciones aritméticas sobre los valores.

    No exactamente.

    Usa int o double para la aritmética. Los enums representan categorías distintas, no valores numéricos (aunque sí tienen un índice).

Siguiente lección

#

En la siguiente lección, aprenderás a manejar errores y excepciones en tu código Dart. Crearás una clase de excepción personalizada y añadirás manejo de errores a tu CommandRunner para hacer tu aplicación más robusta.