¿Cuándo se vuelve dañino el paradigma "Do One Thing"?

21

En aras del argumento, aquí hay una función de muestra que imprime el contenido de un archivo determinado línea por línea.

Versión 1:

void printFile(const string & filePath) {
  fstream file(filePath, ios::in);
  string line;
  while (std::getline(file, line)) {
    cout << line << endl;
  }
}

Sé que se recomienda que las funciones hagan una cosa en un nivel de abstracción. Para mí, aunque el código anterior hace más o menos una cosa y es bastante atómico.

Algunos libros (como el Código Limpio de Robert C. Martin) parecen sugerir dividir el código anterior en funciones separadas.

Versión 2:

void printFile(const string & filePath) {
  fstream file(filePath, ios::in);
  printLines(file);
}

void printLines(fstream & file) {
  string line;
  while (std::getline(file, line)) {
    printLine(line);
  }
}

void printLine(const string & line) {
  cout << line << endl;
}

Entiendo lo que quieren lograr (abrir archivo / leer líneas / imprimir línea), pero ¿no es un poco exagerado?

La versión original es simple y, en cierto sentido, ya hace una cosa: imprime un archivo.

La segunda versión conducirá a una gran cantidad de funciones realmente pequeñas que pueden ser mucho menos legibles que la primera versión.

¿No sería, en este caso, mejor tener el código en un lugar?

¿En qué punto el paradigma "Do One Thing" se vuelve dañino?

Petr
fuente
13
Este tipo de práctica de codificación siempre se basa en caso por caso. Nunca hay un solo enfoque.
iammilind
1
@Alex: la respuesta aceptada literalmente no tiene nada que ver con la pregunta. Eso me parece realmente extraño.
ChaosPandion
2
Noto que su versión refactorizada está al revés, lo que contribuye a una falta de legibilidad. La lectura hasta el archivo, se puede esperar para ver printFile, printLinesy, por último printLine.
Anthony Pegram
1
@Kev, una vez más solo puedo estar en desacuerdo, particularmente con esa categorización. ¡No es pedantería, es el punto! Es el OP que específicamente dice que la segunda versión podría no ser tan legible. Es el OP que cita específicamente Clean Code como inspiración para la segunda versión. Mi comentario es esencialmente que Clean Code no lo haría escribir código de esa manera. El orden es realmente importante para la legibilidad, lee el archivo como lee un artículo de periódico, obteniendo más y más detalles hasta que básicamente se vuelve desinteresado.
Anthony Pegram
1
Al igual que no esperarías leer un poema al revés, tampoco esperarías ver el nivel más bajo de detalle como lo primero dentro de una clase en particular. Según su punto de vista, este código tarda poco tiempo en clasificarse rápidamente, pero solo asumiría que este código no es el único código que va a escribir. En mi opinión, si va a citar Clean Code, lo menos que puede hacer es seguirlo. Si el código está fuera de servicio, ciertamente será menos legible que de lo contrario.
Anthony Pegram

Respuestas:

15

Por supuesto, esto solo plantea la pregunta "¿Qué es una cosa?" ¿Leer una línea es una cosa y escribir una línea es otra? ¿O copiar una línea de un flujo a otro se considera una cosa? O copiando un archivo?

No hay una respuesta dura y objetiva a eso. Tu decides. Tú puedes decidir. Tienes que decidir. El objetivo principal del paradigma "hacer una cosa" es probablemente producir código que sea lo más fácil de entender posible, para que pueda usarlo como una guía. Desafortunadamente, esto tampoco es medible objetivamente, por lo que debe confiar en su instinto y en el "WTF". contar en la revisión de código .

OMI, una función que consiste en una sola línea de código rara vez vale la pena. Tu printLine()no tiene ninguna ventaja sobre el uso de std::cout << line << '\n'1 directamente. Si veo printLine(), tengo que asumir que hace lo que dice su nombre o buscarlo y comprobarlo. Si veo std::cout << line << '\n', sé de inmediato lo que hace, porque esta es la forma canónica de generar el contenido de una cadena como una línea std::cout.

Sin embargo, otro objetivo importante del paradigma es permitir la reutilización del código, y esa es una medida mucho más objetiva. Por ejemplo, en su segunda versión printLines() podría escribirse fácilmente para que sea un algoritmo universalmente útil que copie líneas de una secuencia a otra:

void copyLines(std::istream& is, std::ostream& os)
{
  std::string line;
  while( std::getline(is, line) );
    os << line << '\n';
  }
}

Tal algoritmo podría reutilizarse también en otros contextos.

Luego puede poner todo lo específico de este caso de uso en una función que llame a este algoritmo genérico:

void printFile(const std::string& filePath) {
  std::ifstream file(filePath.c_str());
  printLines(file, std::cout);
}

1 Tenga en cuenta que usé en '\n'lugar de std::endl. '\n'debería ser la opción predeterminada para generar una nueva línea , std::endles el caso extraño .

sbi
fuente
2
+1 - Estoy de acuerdo en su mayoría, pero creo que hay más que "instinto". El problema es cuando las personas juzgan "una cosa" contando los detalles de implementación. Para mí, la función debería implementar (y su nombre describe) una única abstracción clara. Nunca debe nombrar una función "do_x_and_y". La implementación puede y debe hacer varias cosas (más simples), y cada una de esas cosas más simples puede descomponerse en varias cosas aún más simples, etc. Es solo descomposición funcional con una regla adicional: que las funciones (y sus nombres) deben describir cada una un concepto / tarea / lo que sea claro.
Steve314
@ Steve314: No he enumerado los detalles de implementación como posibilidades. Copiar líneas de una secuencia a otra claramente es una abstracción de una sola cosa . ¿O es eso? Y es fácil de evitar do_x_and_y()nombrando la función en su do_everything()lugar. Sí, ese es un ejemplo tonto, pero muestra que esta regla ni siquiera evita los ejemplos más extremos de mal diseño. En mi opinión, esta es una decisión instintiva tanto como la dictada por las convenciones. De lo contrario, si fuera objetivo, podría encontrar una métrica para ello, que no puede.
sbi
1
No tenía la intención de contradecir, solo sugerir una adición. Supongo que lo que olvidé decir es que, a partir de la pregunta, la descomposición a printLineetc. es válida, cada una de ellas es una sola abstracción, pero eso no significa que sea necesario. printFileYa es "una cosa". Aunque puede descomponer eso en tres abstracciones separadas de nivel inferior, no tiene que descomponerse en cada nivel posible de abstracción. Cada función debe hacer "una cosa", pero no todas las "una cosa" posibles deben ser una función. Mover demasiada complejidad al gráfico de llamadas puede ser un problema.
Steve314
7

Tener una función que haga solo "una cosa" es un medio para dos fines deseables, no un mandamiento de Dios:

  1. Si su función solo hace "una cosa", lo ayudará a evitar la duplicación de código y la hinchazón de la API porque podrá componer funciones para hacer cosas más complejas en lugar de tener una explosión combinatoria de funciones de alto nivel y menos componibles .

  2. Tener funciones solo hace "una cosa" puede hacer que el código sea más legible. Esto depende de si gana más claridad y facilidad de razonamiento al desacoplar las cosas de lo que pierde a la verbosidad, la indirección y la sobrecarga conceptual de las construcciones que le permiten desacoplar las cosas.

Por lo tanto, "una cosa" es inevitablemente subjetiva y depende de qué nivel de abstracción sea relevante para su programa. Si printLinesse considera una operación única y fundamental y la única forma de imprimir líneas que le interesan o prevé que le interesen, entonces, para sus propósitos, printLinessolo hace una cosa. A menos que encuentre la segunda versión más legible (no) la primera versión está bien.

Si comienza a necesitar más control sobre los niveles más bajos de abstracción y termina con una duplicación sutil y una explosión combinatoria (es decir, printLinespara nombres de archivos y completamente separados printLinespara fstreamobjetos, una printLinespara consola y una printLinespara archivos), entonces printLinesestá haciendo más de una cosa a nivel de abstracción que te importa.

dsimcha
fuente
Agregaría un tercero y es que las funciones más pequeñas se prueban más fácilmente. Como probablemente se requieren menos entradas si la función solo hace una cosa, hace que sea más fácil probarla de forma independiente.
PersonalNexus
@PersonalNexus: estoy un poco de acuerdo con el tema de las pruebas, pero en mi humilde opinión, es una tontería probar los detalles de implementación. Para mí, una prueba unitaria debería evaluar "una cosa" como se define en mi respuesta. Cualquier cosa más fina está haciendo que sus pruebas sean frágiles (porque cambiar los detalles de implementación requerirá que sus pruebas cambien) y su código molesto, detallado, indirecto, etc. (porque agregará indirección solo para admitir las pruebas).
dsimcha
6

A esta escala, no importa. La implementación de una sola función es perfectamente obvia y comprensible. Sin embargo, agregar un poco más de complejidad hace que sea muy atractivo dividir la iteración de la acción. Por ejemplo, suponga que necesita imprimir líneas desde un conjunto de archivos especificado por un patrón como "* .txt". Luego separaría la iteración de la acción:

printLines(FileSet files) {
   files.each({ 
       file -> file.eachLine({ 
           line -> printLine(line); 
       })
   })
}

Ahora la iteración del archivo se puede probar por separado.

Divido las funciones para simplificar las pruebas o para mejorar la legibilidad. Si la acción realizada en cada línea de datos fuera lo suficientemente compleja como para justificar un comentario, entonces ciertamente la dividiría en una función separada.

Kevin Cline
fuente
44
Creo que lo has clavado. Si necesitamos un comentario para explicar una línea, siempre es hora de extraer un método.
Roger CS Wernersson
5

Extraiga métodos cuando sienta la necesidad de un comentario para explicar las cosas.

Escriba métodos que solo hagan lo que su nombre dice de una manera obvia, o cuente una historia llamando a métodos ingeniosamente nombrados.

Roger CS Wernersson
fuente
3

Incluso en su caso simple, le faltan detalles de que el Principio de responsabilidad única lo ayudaría a administrar mejor. Por ejemplo, qué sucede cuando algo sale mal al abrir el archivo. Agregar en el manejo de excepciones para fortalecer los casos límite de acceso a archivos agregaría 7-10 líneas de código a su función.

Después de abrir el archivo, aún no estás seguro. Podría ser arrancado de usted (especialmente si se trata de un archivo en una red), podría quedarse sin memoria, nuevamente pueden ocurrir una serie de casos extremos contra los que desea fortalecer y aumentar su función monolítica.

La línea de impresión, la línea de impresión parece lo suficientemente inocuo. Pero a medida que se agreguen nuevas funciones a la impresora de archivos (análisis y formateo de texto, renderización en diferentes tipos de pantallas, etc.) crecerá y se lo agradecerá más tarde.

El objetivo de SRP es permitirle pensar en una sola tarea a la vez. Es como dividir un gran bloque de texto en múltiples párrafos para que el lector pueda comprender el punto que está tratando de transmitir. Lleva un poco más de tiempo escribir código que se adhiera a estos principios. Pero al hacerlo, hacemos que sea más fácil leer ese código. Piense en lo feliz que será su futuro yo cuando tenga que rastrear un error en el código y lo encuentre perfectamente particionado.

Michael Brown
fuente
2
¡Voté esta respuesta porque me gusta la lógica aunque no estoy de acuerdo con ella! Proporcionar una estructura basada en un pensamiento complejo sobre lo que podría suceder en el futuro es contraproducente. Factoriza el código cuando lo necesites. No abstraiga las cosas hasta que lo necesite. El código moderno está plagado de personas que intentan seguir servilmente las reglas en lugar de simplemente escribir código que funcione y adaptarlo de mala gana . Los buenos programadores son flojos .
Hasta el
Gracias por el comentario. Tenga en cuenta que no estoy abogando por la abstracción prematura, solo dividiendo las operaciones lógicas para que sea más fácil hacerlo más tarde.
Michael Brown
2

Personalmente, prefiero este último enfoque, ya que le ahorra trabajo en el futuro y obliga a la mentalidad de "cómo hacerlo de manera genérica". A pesar de eso, en su caso, la Versión 1 es mejor que la Versión 2, solo porque los problemas resueltos por la Versión 2 son demasiado triviales y específicos de fstream. Creo que debería hacerse de la siguiente manera (incluida la corrección de errores propuesta por Nawaz):

Funciones de utilidad genéricas:

void printLine(ostream& output, const string & line) { 
    output << line << endl; 
} 

void printLines(istream& input, ostream& output) { 
    string line; 
    while (getline(input, line)) {
        printLine(output, line); 
    } 
} 

Función específica del dominio:

void printFile(const string & filePath, ostream& output = std::cout) { 
    fstream file(filePath, ios::in); 
    printLines(file, output); 
} 

Ahora printLinesy printLinepuede trabajar no solo con fstream, sino con cualquier flujo.

gwiazdorrr
fuente
2
Estoy en desacuerdo. Esa printLine()función no tiene valor. Mira mi respuesta .
sbi
1
Bueno, si mantenemos printLine (), entonces podemos agregar un decorador que agrega números de línea o colores de sintaxis. Dicho esto, no extraería esos métodos hasta que encontrara una razón para hacerlo.
Roger CS Wernersson
2

Cada paradigma , (no necesariamente el que usted citó) a seguir requiere cierta disciplina y, por lo tanto, reducir la "libertad de expresión" - resulta en una sobrecarga inicial (¡al menos solo porque tiene que aprenderlo!). En este sentido, cada paradigma puede volverse dañino cuando el costo de esa sobrecarga no está sobrecompensado por la ventaja que el paradigma está diseñado para mantener consigo mismo.

La verdadera respuesta a la pregunta, por lo tanto, requiere una buena capacidad para "prever" el futuro, como:

  • Estoy ahora obligado a hacerlo AyB
  • ¿Cuál es la probabilidad de que en un futuro cercano deba hacer también A-y B+(es decir, algo que se parezca a A y B, pero un poco diferente)?
  • ¿Cuál es la probabilidad en un futuro más lejano de que A + se convierta A*o A*-?

Si esa probabilidad es relativamente alta, será una buena posibilidad si, mientras pienso en A y B, también pienso en sus posibles variantes, para así aislar las partes comunes para poder reutilizarlas.

Si esa probabilidad es muy baja (cualquier variante alrededor no Aes esencialmente nada más que Así misma), estudie cómo descomponer A más probablemente resulte en una pérdida de tiempo.

Solo como ejemplo, déjame contarte esta historia real:

Durante mi vida pasada como un maestro, descubrí que -en la mayoría de proyectos- del estudiante la práctica totalidad de ellos ofrecen su propia función para calcular la longitud de una cadena C .

Después de investigar un poco, descubrí que, al ser un problema frecuente, todos los estudiantes tienen la idea de utilizar una función para eso. Después de decirles que hay una función de biblioteca para eso ( strlen), muchos respondieron que, dado que el problema era tan simple y trivial, era más efectivo para ellos escribir su propia función (2 líneas de código) que buscar el manual de la biblioteca C (era 1984, olvidé la WEB y google!) en estricto orden alfabético para ver si había una función lista para eso.

Este es un ejemplo donde también el paradigma "no reinventar la rueda" puede volverse dañino, ¡sin un catálogo de ruedas efectivo!

Emilio Garavaglia
fuente
2

Su ejemplo está bien para ser utilizado en una herramienta desechable que se necesita ayer para realizar una tarea específica. O como una herramienta de administración controlada directamente por un administrador. Ahora hágalo robusto para que sea adecuado para sus clientes.

Agregue el manejo adecuado de errores / excepciones con mensajes significativos. Tal vez necesite verificación de parámetros, incluidas las decisiones que deben tomarse, por ejemplo, cómo manejar archivos no existentes. Agregue funcionalidad de registro, tal vez con diferentes niveles como información y depuración. Agregue comentarios para que los colegas de su equipo sepan lo que está sucediendo allí. Agregue todas las partes que generalmente se omiten por brevedad y se dejan como ejercicio para el lector al dar ejemplos de código. No olvides tus pruebas unitarias.

Su pequeña y agradable función lineal termina repentinamente en un desorden complejo que pide ser dividido en funciones separadas.

Seguro
fuente
2

En mi opinión, se vuelve dañino cuando llega tan lejos que una función casi no hace nada más que delegar el trabajo a otra función, porque eso es una señal de que ya no es una abstracción de nada y la mentalidad que conduce a tales funciones siempre está en peligro de haciendo cosas peores ...

De la publicación original

void printLine(const string & line) {
  cout << line << endl;
}

Si es lo suficientemente pedante, es posible que note que printLine sigue haciendo dos cosas: escribir la línea en cout y agregar un carácter de "línea final". Algunas personas pueden querer manejar eso creando nuevas funciones:

void printLine(const string & line) {
  reallyPrintLine(line);
  addEndLine();
}

void reallyPrintLine(const string & line) {
  cout << line;
}

void addEndLine() {
  cout << endl;
}

¡Oh no, ahora hemos empeorado el problema! ¡Ahora es incluso OBVIO que printLine haga DOS cosas! 1! No hace mucha estupidez crear las "soluciones" más absurdas que uno pueda imaginar para deshacerse de ese problema inevitable de que imprimir una línea consiste en imprimir la línea en sí misma y agregar un carácter de fin de línea.

void printLine(const string & line) {
  for (int i=0; i<2; i++)
    reallyPrintLine(line, i);
}

void reallyPrintLine(const string & line, int action) {
  cout << (action==0?line:endl);
}
usuario281377
fuente
1

Respuesta corta ... depende.

Piense en esto: qué pasa si, en el futuro, no desea imprimir solo en la salida estándar, sino en un archivo.

Sé lo que es YAGNI, pero solo digo que podría haber casos en los que se sabe que algunas implementaciones son necesarias, pero pospuestas. Entonces, tal vez el arquitecto o lo que sea que sepa que la función necesita poder imprimir también en un archivo, pero no quiere hacer la implementación en este momento. Entonces él crea esta función adicional, por lo que, en el futuro, solo necesita cambiar la salida en un lugar. ¿Tiene sentido?

Sin embargo, si está seguro de que solo necesita salida en la consola, realmente no tiene mucho sentido. Escribir un "envoltorio" cout <<parece inútil.

Luchian Grigore
fuente
1
Pero estrictamente hablando, ¿no es la función printLine un nivel diferente de abstracción de iterar sobre líneas?
@Petr Supongo que sí, por eso te sugieren que separes la funcionalidad. Creo que el concepto es correcto, pero debe aplicarlo caso por caso.
1

La razón por la que hay libros que dedican capítulos a las virtudes de "hacer una cosa" es que todavía hay desarrolladores que escriben funciones de 4 páginas y condicionan 6 niveles. Si su código es simple y claro, lo ha hecho bien.

Kevin
fuente
0

Como han comentado otros carteles, hacer una cosa es cuestión de escala.

También sugeriría que la idea de One Thing es evitar que las personas codifiquen por efecto secundario. Esto se ejemplifica mediante un acoplamiento secuencial donde los métodos deben llamarse en un orden particular para obtener el resultado 'correcto'.

NWS
fuente