Patrón para una clase que solo hace una cosa

24

Digamos que tengo un procedimiento que hace cosas :

void doStuff(initalParams) {
    ...
}

Ahora descubro que "hacer cosas" es una operación bastante complicada. El procedimiento se hace más grande, lo divido en varios procedimientos más pequeños y pronto me doy cuenta de que tener algún tipo de estado sería útil al hacer cosas, por lo que necesito pasar menos parámetros entre los pequeños procedimientos. Entonces, lo factorizo ​​en su propia clase:

class StuffDoer {
    private someInternalState;

    public Start(initalParams) {
        ...
    }

    // some private helper procedures here
    ...
}

Y luego lo llamo así:

new StuffDoer().Start(initialParams);

o así:

new StuffDoer(initialParams).Start();

Y esto es lo que se siente mal. Cuando uso la API .NET o Java, nunca llamo new SomeApiClass().Start(...);, lo que me hace sospechar que lo estoy haciendo mal. Claro, podría hacer que el constructor de StuffDoer sea privado y agregar un método auxiliar estático:

public static DoStuff(initalParams) {
    new StuffDoer().Start(initialParams);
}

Pero entonces tendría una clase cuya interfaz externa consta de un solo método estático, que también se siente raro.

De ahí mi pregunta: ¿Hay un patrón bien establecido para este tipo de clases que

  • tener un solo punto de entrada y
  • ¿no tiene un estado "reconocible externamente", es decir, el estado de instancia solo se requiere durante la ejecución de ese único punto de entrada?
Heinzi
fuente
Se sabe que hago cosas como bool arrayContainsSomestring = new List<string>(stringArray).Contains("somestring");cuando todo lo que me importaba era esa información en particular y los métodos de extensión LINQ no están disponibles. Funciona bien y se ajusta dentro de una if()condición sin necesidad de saltar a través de aros. Por supuesto, desea un lenguaje recolectado de basura si está escribiendo código como ese.
un CVn
Si es independiente del lenguaje , ¿por qué agregar también Java y C # ? :)
Steven Jeuris
Tengo curiosidad, ¿cuál es la funcionalidad de este 'método único'? Sería de gran ayuda para determinar cuál es el mejor enfoque en el escenario específico, ¡pero una pregunta interesante de todos modos!
Steven Jeuris el
@StevenJeuris: Me he topado con este problema en varias situaciones, pero en el caso actual es un método que importa datos a la base de datos local (los carga desde un servidor web, realiza algunas manipulaciones y los almacena en la base de datos).
Heinzi el
1
Si siempre llama al método cuando crea la instancia, ¿por qué no convertirlo en una lógica de inicialización dentro del constructor?
NoPuerto

Respuestas:

29

Hay un patrón llamado Object Object donde factoriza un método único y grande con muchas variables / argumentos temporales en una clase separada. Hace esto en lugar de simplemente extraer partes del método en métodos separados porque necesitan acceso al estado local (los parámetros y las variables temporales) y no puede compartir el estado local utilizando variables de instancia porque serían locales para ese método (y los métodos extraídos de él) solo y no serían utilizados por el resto del objeto.

Entonces, en cambio, el método se convierte en su propia clase, los parámetros y las variables temporales se convierten en variables de instancia de esta nueva clase, y luego el método se divide en métodos más pequeños de la nueva clase. La clase resultante generalmente tiene un solo método de instancia pública que realiza la tarea que encapsula la clase.

luego
fuente
1
Esto suena exactamente como lo que estoy haciendo. Gracias, al menos tengo un nombre para eso ahora. :-)
Heinzi
2
Enlace muy relevante! A mí me huele a antipatrón. Si su método es tan largo, ¿quizás partes de él podrían extraerse en clases reutilizables o, en general, refactorizarse de una mejor manera? Tampoco he escuchado sobre este patrón antes, tampoco puedo encontrar muchos otros recursos que me hacen creer que generalmente no se usa tanto.
Steven Jeuris
1
@ StevenJeuris No creo que sea un antipatrón. Un antipatrón sería agrupar los métodos refactorizados con parámetros de lote en lugar de almacenar este estado que es común entre estos métodos en una variable de instancia.
Alfredo Osorio el
@AlfredoOsorio: Bueno, abarrota los métodos refactorizados con muchos parámetros. Viven en el objeto, por lo que es mejor que pasarlos a todos, pero es peor que refactorizar a componentes reutilizables que solo necesitan un subconjunto limitado de los parámetros cada uno.
Jan Hudec
13

Diría que la clase en cuestión (usando un único punto de entrada) está bien diseñada. Es fácil de usar y extender. Muy solido.

Lo mismo para no "externally recognizable" state. Es buena encapsulación.

No veo ningún problema con código como:

var doer = new StuffDoer(initialParams);
var result = doer.Calculate(extraParams);
jgauffin
fuente
1
Y, por supuesto, si no necesita usar el mismo doernuevamente, eso se reduce a var result = new StuffDoer(initialParams).Calculate(extraParams);.
un CVn
¡Debe cambiar el nombre de calcular a doStuff!
shabunc
1
Agregaría que si no desea inicializar (nuevo) su clase donde la usa (probablemente quiera usar una Fábrica) tiene la opción de crear el objeto en otro lugar de su lógica comercial y luego pasar los parámetros directamente al método público único que tienes. La otra opción es usar una Fábrica y tener un método de creación que obtenga los parámetros y cree su objeto con todos los parámetros a través de su constructor. De lo que llamas tu método público sin parámetros desde donde quieras.
Patkos Csaba
2
Esta respuesta es demasiado simplista. ¿Es una StringComparerclase que utilizas new StringComparer().Compare( "bleh", "blah" )bien diseñada? ¿Qué pasa con la creación de estado cuando no hay necesidad de hacerlo? De acuerdo, el comportamiento está bien encapsulado (bueno, al menos para el usuario de la clase, más sobre eso en mi respuesta ), pero eso no lo convierte en un "buen diseño" por defecto. En cuanto a su ejemplo de "resultado real". Esto solo tendría sentido si alguna vez usaras doerseparado de result.
Steven Jeuris
1
No es una razón para existir, es one reason to change. La diferencia puede parecer sutil. Tienes que entender lo que eso significa. Nunca creará clases de un método
jgauffin
8

Desde una perspectiva de principios SÓLIDOS , la respuesta de jgauffin tiene sentido. Sin embargo, no debe olvidarse de los principios generales de diseño, por ejemplo, la ocultación de información .

Veo varios problemas con el enfoque dado:

  • Como usted mismo señaló, la gente no espera usar la palabra clave "nueva", cuando el objeto creado no administra ningún estado . Su diseño refleja su intención. Las personas que usan su clase pueden confundirse sobre el estado que administra y si las llamadas posteriores al método pueden dar lugar a un comportamiento diferente.
  • Desde la perspectiva de la persona que usa la clase, el estado interno está bien oculto, pero cuando quieres hacer modificaciones a la clase o simplemente entenderlo, estás haciendo las cosas más complejas. Ya he escrito mucho sobre los problemas que veo con los métodos de división solo para hacerlos más pequeños , especialmente cuando se mueve el estado al alcance de la clase. ¡Está modificando la forma en que se debe usar su API solo para tener funciones más pequeñas! Eso en mi opinión definitivamente lo lleva demasiado lejos.

Algunas referencias relacionadas

Posiblemente, un argumento principal radica en qué tan lejos se puede extender el Principio de Responsabilidad Única . "Si lo llevas a ese extremo y construyes clases que tienen una razón para existir, puedes terminar con un solo método por clase. Esto causaría una gran expansión de clases incluso para los procesos más simples, causando que el sistema sea difícil de entender y difícil de cambiar ".

Otra referencia relevante relacionada con este tema: "Divida sus programas en métodos que realicen una tarea identificable. Mantenga todas las operaciones en un método al mismo nivel de abstracción ". - Kent Beck Key aquí es "el mismo nivel de abstracción". Eso no significa "una cosa", ya que a menudo se interpreta. Este nivel de abstracción depende completamente del contexto para el que está diseñando.

Entonces, ¿cuál es el enfoque adecuado?

Sin conocer su caso de uso concreto, es difícil saberlo. Hay un escenario en el que a veces (no muy a menudo) uso un enfoque similar. Cuando quiero procesar un conjunto de datos, sin querer que esta funcionalidad esté disponible para todo el alcance de la clase. Escribí una publicación de blog sobre esto, cómo lambdas puede mejorar aún más la encapsulación . Yo también empecé a una pregunta sobre el tema aquí en Los programadores . El siguiente es un último ejemplo de dónde usé esta técnica.

new TupleList<Key, int>
{
    { Key.NumPad1, 1 },
            ...
    { Key.NumPad3, 16 },
    { Key.NumPad4, 17 },
}
    .ForEach( t =>
    {
        var trigger = new IC.Trigger.EventTrigger(
                        new KeyInputCondition( t.Item1, KeyInputCondition.KeyState.Down ) );
        trigger.ConditionsMet += () => AddMarker( t.Item2 );
        _inputController.AddTrigger( trigger );
    } );

Dado que el código muy 'local' dentro del ForEachno se reutiliza en ningún otro lugar, simplemente puedo mantenerlo en la ubicación exacta donde es relevante. Esbozar el código de tal manera que el código que se basa el uno en el otro está fuertemente agrupado lo hace más legible en mi opinión.

Posibles alternativas

  • En C #, podría usar métodos de extensión en su lugar. Así que opera el argumento directamente que pasas a este método de 'una cosa'.
  • Vea si esta función en realidad no pertenece a otra clase.
  • Conviértalo en una función estática en una clase estática . Este es probablemente el enfoque más apropiado, como también se refleja en las API generales a las que se refirió.
Steven Jeuris
fuente
Encontré un posible nombre para este antipatrón como se describe en otra respuesta, Poltergeists .
Steven Jeuris
4

Diría que es bastante bueno, pero que su API aún debería ser un método estático en una clase estática, ya que eso es lo que el usuario espera. El hecho de que el método estático lo use newpara crear su objeto auxiliar y hacer el trabajo es un detalle de implementación que debe ocultarse a quien lo llama.

Scott Whitlock
fuente
¡Guauu! Lograste llegar al gesto de manera mucho más concisa que yo. :) Gracias. (por supuesto, voy un poco más allá donde podríamos estar en desacuerdo, pero estoy de acuerdo con la premisa general)
Steven Jeuris
2
new StuffDoer().Start(initialParams);

Hay un problema con los desarrolladores despistados que podrían usar una instancia compartida y usarla

  1. varias veces (está bien si la ejecución anterior (tal vez parcial) no arruina la ejecución posterior)
  2. de varios subprocesos (no está bien, usted dijo explícitamente que tiene estado interno).

Por lo tanto, esto necesita documentación explícita de que no es seguro para subprocesos y si es liviano (crearlo es rápido, no ata recursos externos ni mucha memoria) que está bien instanciar sobre la marcha.

Esconderlo en un método estático ayuda con esto, porque luego reutilizar la instancia nunca sucede.

Si tiene una inicialización costosa, podría (no siempre) ser beneficioso preparar la inicialización una vez y usarla varias veces creando otra clase que contenga solo estado y la haga clonable. La inicialización crearía un estado en el que se almacenaría doer. start()lo clonaría y lo pasaría a métodos internos.

Esto también permitiría otras cosas como el estado persistente de ejecución parcial. (Si lleva mucho tiempo y factores externos, por ejemplo, fallas en el suministro de electricidad, podrían interrumpir la ejecución). Pero estas cosas extravagantes adicionales generalmente no son necesarias, por lo que no valen la pena.

user470365
fuente
Buenos puntos. Espero que no te importe la limpieza.
Steven Jeuris el
2

Además de mi otra respuesta más elaborada y personalizada , siento que la siguiente observación merece una respuesta por separado.

Hay indicios de que podrías estar siguiendo el antipatrón de Poltergeists .

Los poltergeists son clases con roles muy limitados y ciclos de vida efectivos. A menudo inician procesos para otros objetos. La solución refactorizada incluye una reasignación de responsabilidades a objetos de vida más larga que eliminan a los Poltergeists.

Síntomas y consecuencias

  • Rutas de navegación redundantes.
  • Asociaciones transitorias.
  • Clases sin estado.
  • Objetos y clases temporales de corta duración.
  • Clases de operación única que existen solo para "inicializar" o "invocar" otras clases a través de asociaciones temporales.
  • Clases con nombres de operación "tipo control" como start_process_alpha.

Solución refactorizada

Los Cazafantasmas resuelven Poltergeists al eliminarlos de la jerarquía de clases por completo. Después de su eliminación, sin embargo, la funcionalidad que fue "proporcionada" por el poltergeist debe ser reemplazada. Esto es fácil con un ajuste simple para corregir la arquitectura.

Steven Jeuris
fuente