Lo principal está en los detalles. ¿Qué hace realmente OOP?





He estado investigando en OOP día y noche durante más de dos años. Lea una gran cantidad de libros, pasó meses refactorizando el código de procedimiento a orientado a objetos y viceversa. Un amigo dice que me he ganado la POO del cerebro. Pero, ¿tengo confianza en que puedo resolver problemas complejos y escribir código claro?



Envidio a las personas que pueden empujar con confianza su opinión delirante. Especialmente cuando se trata de desarrollo, arquitectura. En general, a lo que aspiro con pasión, pero de lo que tengo un sinfín de dudas. Como no soy un genio y no soy un FP, no tengo una historia de éxito. Pero déjame poner 5 kopeks.



¿Encapsulación, polimorfismo, pensamiento de objetos ...?



¿Te gusta cuando estás cargado de términos? He leído lo suficiente, pero las palabras anteriores todavía no me dicen nada en particular. Estoy acostumbrado a explicar las cosas en un idioma que entiendo. Un nivel de abstracción, por así decirlo. Y durante mucho tiempo quise saber la respuesta a una pregunta simple: "¿Qué da OOP?" Preferiblemente con ejemplos de código. Y hoy intentaré responder yo mismo. Pero primero, un poco de abstracción.



Complejidad de la tarea



El desarrollador se dedica de una forma u otra a resolver problemas. Cada tarea tiene muchos detalles. Partiendo de los detalles de la API de interacción con una computadora, terminando con los detalles de la lógica empresarial.



El otro día coleccioné un mosaico con mi hija. Solíamos coleccionar rompecabezas de gran tamaño, literalmente de 9 partes. Y ahora puede manipular pequeños mosaicos para niños a partir de los 3 años. ¡Es interesante! Cómo el cerebro encuentra su lugar entre los rompecabezas dispersos. ¿Y qué determina la complejidad?



A juzgar por los mosaicos para niños, la complejidad está determinada principalmente por la cantidad de detalles. No estoy seguro de que la analogía del rompecabezas cubra todo el proceso de desarrollo. Pero, ¿qué más se puede comparar el nacimiento de un algoritmo en el momento de escribir un cuerpo de función? Y me parece que reducir la cantidad de detalles es una de las simplificaciones más significativas.



Para mostrar más claramente la característica principal de OOP, hablemos de tareas, cuya cantidad de detalles no nos permite armar un rompecabezas en un tiempo razonable. En tales casos, necesitamos descomposición.



Descomposición



Como saben en la escuela, un problema complejo se puede dividir en problemas más simples para resolverlos por separado. La esencia del enfoque es limitar el número de piezas.



Da la casualidad de que mientras aprendemos a programar, nos acostumbramos a trabajar con un enfoque procedimental. Cuando hay un dato en la entrada, que transformamos, lo colocamos en subfunciones y lo asignamos al resultado. Y, en última instancia, nos descomponemos durante la refactorización cuando la solución ya está allí.



¿Cuál es el problema con la descomposición procedimental? Por costumbre, necesitamos datos iniciales, y preferiblemente con una estructura finalmente formada. Además, cuanto mayor sea la tarea, más compleja será la estructura de estos datos iniciales, más detalles debe tener en cuenta. Pero, ¿cómo estar seguro de que los datos iniciales serán suficientes para resolver las subtareas y, al mismo tiempo, eliminar la suma de todos los detalles en el nivel superior?



Veamos un ejemplo. No hace mucho escribí un script que hace ensamblajes de proyectos y los arroja a las carpetas necesarias.



interface BuildConfig {
  id: string;
  deployPath: string;
  options: BuildOptions;
  // ...
}

interface TestService {
  runTests(buildConfigs: BuildConfig[]): Promise<void>;
}

interface DeployService {
  publish(buildConfigs: BuildConfig[]): Promise<void>;
}

class Builder {
  constructor(
    private testService: TestService,
    private deployService: DeployService
  ) // ...
  {}

  async build(buildConfigs: BuildConfig[]): Promise<void> {
    await this.testService.runTests(buildConfigs);
    await this.build(buildConfigs);
    await this.deployService.publish(buildConfigs);
    // ...
  }

  // ...
}



Puede parecer que he aplicado OOP en esta solución. Puede reemplazar las implementaciones de servicios, incluso puede probar algo. Pero, de hecho, este es un excelente ejemplo de enfoque procedimental.



Eche un vistazo a la interfaz BuildConfig. Esta es una estructura que creé al principio de escribir el código. Me di cuenta de antemano de que no podía prever todos los parámetros por adelantado y simplemente agregué campos a esta estructura según fuera necesario. A la mitad del trabajo, la configuración estaba repleta de un montón de campos que se usaban en diferentes partes del sistema. Me molestó la presencia de un "objeto" que debe terminarse con cada cambio. Es difícil navegar en él y es fácil romper algo confundiendo los nombres de los campos. Y, sin embargo, todas las partes del sistema de compilación dependen de BuildConfig. Dado que esta tarea no es tan voluminosa y crítica, no hubo desastre. Pero está claro que si el sistema fuera más complicado, habría arruinado el proyecto.



Un objeto



El principal problema del enfoque procedimental son los datos, su estructura y cantidad. La compleja estructura de datos introduce detalles que dificultan la comprensión de la tarea. Ahora, cuidado con tus manos, aquí no hay engaño.



Recordemos, ¿por qué necesitamos datos? Para realizar operaciones en ellos y obtener el resultado. A menudo, sabemos qué subtareas deben resolverse, pero no entendemos qué tipo de datos se requieren para ello.



¡Atención! Podemos manipular operaciones sabiendo que poseen los datos de antemano para ejecutarlos.



El objeto le permite reemplazar un conjunto de datos con un conjunto de operaciones. Y si reduce el número de piezas, ¡simplifica parte de la tarea!



// ,     / 
interface BuildConfig {
  id: string;
  deployPath: string;
  options: BuildOptions;
  // ...
}

// vs

//  ,          
interface Project {
  test(): Promise<void>;
  build(): Promise<void>;
  publish(): Promise<void>;
}



La transformación es muy simple: f (x) -> de (), donde o es menor que x . El secundario se escondió dentro del objeto. Parecería, ¿cuál es el efecto de transferir el código con la configuración de un lugar a otro? Pero esta transformación tiene implicaciones de gran alcance. Podemos hacer el mismo truco para el resto del programa.



// project.ts
// ,   Project      .
class Project {
  constructor(
    private buildTester: BuildTester,
    private builder: Builder,
    private buildPublisher: BuildPublisher
  ) {}

  async test(): Promise<void> {
    await this.buildTester.runTests();
  }

  async build(): Promise<void> {
    await this.builder.build();
  }

  async publish(): Promise<void> {
    await this.buildPublisher.publish();
  }
}

// builder.ts

export interface BuildOptions {
  baseHref: string;
  outputPath: string;
  configuration?: string;
}

export class Builder {
  constructor(private options: BuildOptions) {}

  async build(): Promise<void> {
    //  ...
  }
}



Ahora el constructor recibe solo los datos que necesita, al igual que otras partes del sistema. Al mismo tiempo, las clases que reciben el Builder a través del constructor no dependen de los parámetros que se necesitan para inicializarlo. Cuando los detalles están en su lugar, es más fácil comprender el programa. Pero también hay un punto débil.



export interface ProjectParams {
  id: string;
  deployPath: Path | string;
  configuration?: string;
  buildRelevance?: BuildRelevance;
}

const distDir = new Directory(Path.fromRoot("dist"));

const buildRecordsDir = new Directory(Path.fromRoot("tmp/builds-manifest"));

export function createProject(params: ProjectParams): Project {
  return new ProjectFactory(params).create();
}

class ProjectFactory {
  private buildDir: Directory = distDir.getSubDir(this.params.id);
  private deployDir: Directory = new Directory(
    Path.from(this.params.deployPath)
  );

  constructor(private params: ProjectParams) {}

  create(): Project {
    const builder = this.createBuilder();
    const buildPublisher = this.createPublisher();
    return new Project(this.params.id, builder, buildPublisher);
  }

  private createBuilder(): NgBuilder {
    return new NgBuilder({
      baseHref: "/clientapp/",
      outputPath: this.buildDir.path.toAbsolute(),
      configuration: this.params.configuration,
    });
  }

  private createPublisher(): BuildPublisher {
    const buildHistory = this.getBuildsHistory();
    return new BuildPublisher(this.buildDir, this.deployDir, buildHistory);
  }

  private getBuildsHistory(): BuildsHistory {
    const buildRecordsFile = this.getBuildRecordsFile();
    const buildRelevance = this.params.buildRelevance ?? BuildRelevance.Default;
    return new BuildsHistory(buildRecordsFile, buildRelevance);
  }

  private getBuildRecordsFile(): BuildRecordsFile {
    const buildRecordsPath = buildRecordsDir.path.join(
      `${this.params.id}.json`
    );
    return new BuildRecordsFile(buildRecordsPath);
  }
}



Todos los detalles asociados con la estructura compleja de la configuración original entraron en el proceso de creación del objeto Proyecto y sus dependencias. Tienes que pagar por todo. Pero a veces esta es una oferta lucrativa: deshacerse de las piezas menores en todo el módulo y concentrarlas dentro de una fábrica.



Por lo tanto, la POO permite ocultar detalles, cambiándolos en el momento de la creación del objeto. Desde el punto de vista del diseño, esta es una superpotencia: la capacidad de deshacerse de detalles innecesarios. Esto tiene sentido si la suma de los detalles en la interfaz del objeto es menor que en la estructura que encapsula. Y si puede separar la creación del objeto y su uso en la mayor parte del sistema.



SÓLIDO, abstracción, encapsulado ...



Hay toneladas de libros sobre programación orientada a objetos. Llevan a cabo estudios en profundidad que reflejan la experiencia de escribir programas orientados a objetos. Pero fue la constatación de que la programación orientada a objetos simplifica el código principalmente al limitar los detalles lo que cambió mi visión del desarrollo. Y seré polar ... pero a menos que te deshagas de los detalles con los objetos, no estás usando OOP.



Puede intentar cumplir con SOLID, pero no tiene mucho sentido si no ha ocultado detalles menores. Es posible hacer que las interfaces parezcan objetos en el mundo real, pero eso no tiene mucho sentido si no ha ocultado los detalles menores. Puedes mejorar la semántica usando sustantivos en tu código, pero ... entiendes la idea.



Encuentro que SOLID, patrones y otras pautas de escritura de objetos son excelentes pautas de refactorización. Después de completar el rompecabezas, puede ver la imagen completa y puede seleccionar las partes más simples. En general, estas son herramientas y métricas importantes que requieren atención, pero a menudo los desarrolladores pasan a aprenderlas y usarlas antes de convertir el programa en forma de objeto.



Cuando sepas la verdad



OOP es una herramienta para resolver problemas complejos. Las tareas difíciles se ganan dividiendo en simples limitando los detalles. Una forma de reducir el número de partes es reemplazar los datos con un conjunto de operaciones.



Ahora que sabe la verdad, intente deshacerse de lo innecesario en su proyecto. Haga coincidir los objetos resultantes con SOLID. Luego intente llevarlos a objetos del mundo real. No de la otra manera. Lo principal está en los detalles.



Recientemente escribí una extensión VSCode para la refactorización de la clase Extract . Creo que este es un buen ejemplo de código orientado a objetos. Lo mejor que tengo. Me encantaría recibir comentarios sobre la implementación o sugerencias para mejorar el código o la funcionalidad. Quiero emitir un PR en Abracadabra en un futuro próximo



All Articles