¿Un constructor que valida sus argumentos viola el SRP?

66

Estoy tratando de adherirme al Principio Único de Responsabilidad (SRP) tanto como sea posible y me acostumbré a un determinado patrón (para el SRP en los métodos) que depende en gran medida de los delegados. Me gustaría saber si este enfoque es correcto o si existen problemas graves con él.

Por ejemplo, para verificar la entrada a un constructor, podría introducir el siguiente método (la entrada Stream es aleatoria, podría ser cualquier cosa)

private void CheckInput(Stream stream)
{
    if(stream == null)
    {
        throw new ArgumentNullException();
    }

    if(!stream.CanWrite)
    {
        throw new ArgumentException();
    }
}

Este método (posiblemente) hace más de una cosa

  • Comprueba las entradas
  • Lanzar diferentes excepciones

Para cumplir con el SRP, por lo tanto, cambié la lógica a

private void CheckInput(Stream stream, 
                        params (Predicate<Stream> predicate, Action action)[] inputCheckers)
{
    foreach(var inputChecker in inputCheckers)
    {
        if(inputChecker.predicate(stream))
        {
            inputChecker.action();
        }
    }
}

Que supuestamente solo hace una cosa (¿lo hace?): verifique la entrada. Para la comprobación real de las entradas y el lanzamiento de las excepciones, he introducido métodos como

bool StreamIsNull(Stream s)
{
    return s == null;
}

bool StreamIsReadonly(Stream s)
{
    return !s.CanWrite;
}

void Throw<TException>() where TException : Exception, new()
{
    throw new TException();
}

y puede llamar a CheckInput like

CheckInput(stream,
    (this.StreamIsNull, this.Throw<ArgumentNullException>),
    (this.StreamIsReadonly, this.Throw<ArgumentException>))

¿Es esto mejor que la primera opción o introduzco una complejidad innecesaria? ¿Hay alguna manera de que pueda mejorar este patrón, si es viable?

    
pregunta Paul Kertscher 18.10.2017 - 11:00

12 respuestas

149

SRP es quizás el principio de software más incomprendido.

Una aplicación de software se crea a partir de módulos, que se construyen a partir de módulos, que se construyen a partir de ...

En la parte inferior, una sola función como CheckInput solo contendrá un poco de lógica, pero a medida que avanza, cada módulo sucesivo encapsula más y más lógica y esto es normal .

SRP no se trata de hacer una sola acción atómica . Se trata de tener una sola responsabilidad, incluso si esa responsabilidad requiere múltiples acciones ... y, en última instancia, se trata de mantenimiento y comprobabilidad :

  • promueve la encapsulación (evitando los objetos de Dios),
  • promueve la separación de preocupaciones (evitando los cambios de ondulación en todo el código base),
  • ayuda a la capacidad de prueba al reducir el alcance de las responsabilidades.

El hecho de que CheckInput se implemente con dos controles y genere dos excepciones diferentes es irrelevante en cierta medida.

CheckInput tiene una responsabilidad limitada: garantizar que la entrada cumpla con los requisitos. Sí, hay varios requisitos, pero esto no significa que haya múltiples responsabilidades. Sí, podría dividir los cheques, pero ¿cómo ayudaría eso? En algún momento, las comprobaciones deben enumerarse de alguna manera.

Comparemos:

Constructor(Stream stream) {
    CheckInput(stream);
    // ...
}

versus:

Constructor(Stream stream) {
    CheckInput(stream,
        (this.StreamIsNull, this.Throw<ArgumentNullException>),
        (this.StreamIsReadonly, this.Throw<ArgumentException>));
    // ...
}

Ahora, CheckInput hace menos ... ¡pero su interlocutor hace más!

Ha cambiado la lista de requisitos de CheckInput , donde están encapsulados, a Constructor , donde están visibles.

¿Es un buen cambio? Depende:

  • Si CheckInput solo se llama allí: es discutible, por un lado hace visibles los requisitos, por otro lado, desordena el código;
  • Si CheckInput se llama varias veces con los mismos requisitos , violará DRY y tendrá un problema de encapsulación.

Es importante darse cuenta de que una sola responsabilidad puede implicar un lote de trabajo. El "cerebro" de un automóvil autónomo tiene una única responsabilidad:

  

Conduciendo el auto a su destino.

Es una responsabilidad única, pero requiere la coordinación de un montón de sensores y actores, toma muchas decisiones e incluso tiene requisitos conflictivos 1 ...

... sin embargo, todo está encapsulado. Así que al cliente no le importa.

1 seguridad de los pasajeros, seguridad de los demás, respeto de las regulaciones, ...

    
respondido por el Matthieu M. 18.10.2017 - 12:47
41

Citando al tío Bob sobre el SRP ( enlace ):

  

El principio de responsabilidad única (SRP) establece que cada módulo de software debe tener una y solo una razón para cambiar.

     

... Este principio es sobre las personas.

     

... Cuando escribe un módulo de software, desea asegurarse de que cuando se solicitan los cambios, dichos cambios solo pueden originarse en una sola persona, o más bien, en un solo grupo de personas estrechamente acopladas que representan un solo negocio estrechamente definido. función.

     

... Esta es la razón por la que no ponemos SQL en JSPs. Esta es la razón por la que no generamos HTML en los módulos que computan los resultados. Esta es la razón por la que las reglas comerciales no deben conocer el esquema de la base de datos. Esta es la razón por la que separamos las preocupaciones.

Explica que los módulos de software deben abordar las preocupaciones específicas de las partes interesadas. Por lo tanto, respondiendo a tu pregunta:

  

¿Es esto mejor que la primera opción o introduzco una complejidad innecesaria? ¿Hay alguna manera de que pueda mejorar este patrón, si es viable?

OMI, solo estás mirando un método, cuando deberías mirar a un nivel superior (nivel de clase en este caso). Quizás deberíamos echar un vistazo a lo que su clase está haciendo actualmente (y esto requiere más explicación sobre su escenario). Por ahora, tu clase sigue haciendo lo mismo. Por ejemplo, si mañana hay alguna solicitud de cambio sobre alguna validación (por ejemplo, "ahora la secuencia puede ser nula"), entonces aún debe ir a esta clase y cambiar las cosas dentro de ella.

    
respondido por el Emerson Cardoso 18.10.2017 - 11:30
36

No, este cambio no está informado por el SRP.

Pregúntese por qué no hay ninguna comprobación en su comprobador para "el objeto pasado es una secuencia" . La respuesta es obvia: el idioma impide a la persona que llama compilar un programa que pasa en una no transmisión.

El sistema de tipo de C # no es suficiente para satisfacer sus necesidades; sus cheques son implementando la aplicación de invariantes que no se pueden expresar en el sistema de tipos hoy . Si hubiera una manera de decir que el método toma un flujo grabable no anulable, lo habría escrito, pero no lo hay, por lo que hizo la siguiente mejor cosa: aplicó la restricción de tipo en tiempo de ejecución. Esperemos que también lo haya documentado, para que los desarrolladores que usan su método no tengan que violarlo, no pasen por sus casos de prueba y luego solucionen el problema.

Poner tipos en un método no es una violación del Principio de Responsabilidad Única; tampoco es el método el hacer cumplir sus condiciones previas o afirmar sus postcondiciones.

    
respondido por el Eric Lippert 18.10.2017 - 15:33
22

No todas las responsabilidades son iguales.

Aquí hay dos cajones. Ambos tienen una responsabilidad. Cada uno tiene nombres que te permiten saber lo que les pertenece. Uno es el cajón de los cubiertos. El otro es el cajón de chatarra.

Entonces, ¿cuál es la diferencia? El cajón de los cubiertos deja en claro lo que no le pertenece. El cajón de chatarra sin embargo acepta cualquier cosa que se ajuste. Sacar las cucharas del cajón de los cubiertos parece estar muy mal. Sin embargo, me cuesta mucho pensar en algo que se perdería si se sacara del cajón de chatarra. La verdad es que puede afirmar que cualquier cosa tiene una responsabilidad única, pero ¿cuál cree que tiene la responsabilidad individual más centrada?

Un objeto que tiene una sola responsabilidad no significa que solo una cosa pueda suceder aquí. Las responsabilidades pueden anidar. Pero esas responsabilidades de anidación deben tener sentido, no deberían sorprenderte cuando las encuentres aquí y deberías perderlas si se hubieran ido.

Entonces cuando ofreces

CheckInput(Stream stream);

No me preocupa que sea tanto verificar las entradas como lanzar excepciones. Me preocuparía si se tratara tanto de verificar la entrada como de guardarla también. Esa es una sorpresa desagradable. Una que no me perdería si se hubiera ido.

    
respondido por el candied_orange 20.10.2017 - 00:07
21

Cuando te atas en nudos y escribes un código extraño para ajustarte a un Principio importante de software, por lo general, has entendido mal el principio (aunque a veces el principio es incorrecto). Como señala la excelente respuesta de Matthieu, todo el significado de SRP depende de la definición de "responsabilidad".

Los programadores experimentados ven estos principios y los relacionan con las memorias de código que hemos fastidiado; Los programadores menos experimentados los ven y pueden no tener nada con qué relacionarlos. Es una abstracción que flota en el espacio, toda sonrisa y no gato. Así que lo adivinan, y por lo general va mal. Antes de que haya desarrollado el sentido de caballo de la programación, la diferencia entre un código excesivamente complicado y un código normal no es tan obvia.

Este no es un mandamiento religioso que debe obedecer independientemente de las consecuencias personales. Es más bien una regla general para formalizar un elemento de la programación del sentido del caballo y ayudarlo a mantener su código lo más simple y claro posible. Si tiene el efecto contrario, tienes derecho a buscar información externa.

En la programación, no puedes equivocarte más que tratar de deducir el significado de un identificador a partir de los primeros principios con solo mirarlo, y eso se aplica a los identificadores al escribir sobre la programación. como identificadores en el código actual.

    
respondido por el Ed Plunkett 18.10.2017 - 16:33
14

Función CheckInput

Primero, déjame poner lo obvio, CheckInput está haciendo una cosa, incluso si está verificando varios aspectos. En última instancia, comprueba la entrada . Se podría argumentar que no es una cosa si se trata de métodos llamados DoSomething , pero creo que es seguro suponer que verificar la entrada no es demasiado vago.

Agregar este patrón para predicados podría ser útil si no quieres que la lógica para verificar la entrada se coloque en tu clase, pero este patrón parece bastante detallado para lo que estás tratando de lograr. Podría ser mucho más directo simplemente pasar una interfaz IStreamValidator con un solo método isValid(Stream) si eso es lo que desea obtener. Cualquier clase que implemente IStreamValidator puede usar predicados como StreamIsNull o StreamIsReadonly si lo desean, pero volviendo al punto central, es un cambio bastante ridículo para mantener el interés en mantener el principio de responsabilidad única.

Verificación de cordura

Es mi idea que todos tengamos un "control de cordura" para asegurarnos de que al menos estás tratando con un Stream que no es nulo y se puede escribir, y este chequeo básico no hace que tu clase sea << em> validador de flujos. Ten en cuenta que sería mejor dejar los controles más sofisticados fuera de tu clase, pero ahí es donde se dibuja la línea. Una vez que necesite comenzar a cambiar el estado de su flujo al leer o dedicar recursos a la validación, ha comenzado a realizar una validación formal de su flujo y esto es lo que debería ser arrastrado a su propia clase.

Conclusión

Mi opinión es que si está aplicando un patrón para organizar mejor un aspecto de su clase, merece estar en su propia clase. Dado que un patrón no encaja, también debería preguntarse si realmente pertenece o no a su propia clase en primer lugar. Mi opinión es que, a menos que crea que la validación de la transmisión probablemente se modifique en el futuro, y especialmente si cree que esta validación puede ser de naturaleza dinámica, entonces el patrón que describió es una buena idea, incluso si es posible. Ser inicialmente trivial. De lo contrario, no hay necesidad de hacer arbitrariamente más complejo su programa. Llamemos a una pala una pala. La validación es una cosa, pero verificar la entrada nula no es una validación y, por lo tanto, creo que puede estar seguro de mantenerla en su clase sin violar el principio de responsabilidad única.

    
respondido por el Neil 18.10.2017 - 11:17
4

El principio enfáticamente no establece que un fragmento de código debe "hacer solo una cosa".

La "responsabilidad" en SRP debe entenderse a un nivel de requisitos. La responsabilidad del código es satisfacer los requisitos del negocio. SRP se viola si un objeto satisface más de un requisitos empresariales independientes . Por independiente, significa que un requisito podría cambiar mientras que el otro permanece en su lugar.

Es concebible que se introduzca un nuevo requisito comercial, lo que significa que este objeto en particular no debería verificar que sea legible, mientras que otro requisito comercial aún requiere que el objeto verifique que sea legible. No, porque los requisitos del negocio no especifican detalles de implementación a ese nivel.

Un ejemplo real de una violación de SRP sería un código como este:

var message = "Your package will arrive before " + DateTime.Now.AddDays(14);

Este código es muy simple, pero aún así es posible que el texto cambie independientemente de la fecha de entrega esperada, ya que son decididos por diferentes partes del negocio.

    
respondido por el JacquesB 20.10.2017 - 10:58
3

Me gusta el punto de @ la respuesta de EricLippert :

  

Pregúntese por qué no hay ninguna comprobación en su comprobador para el objeto pasado es una secuencia . La respuesta es obvia: el idioma impide a la persona que llama compilar un programa que pasa en una no transmisión.

     

El sistema de tipo de C # no es suficiente para satisfacer sus necesidades; sus cheques son implementando la aplicación de invariantes que no se pueden expresar en el sistema de tipos hoy . Si hubiera una manera de decir que el método toma un flujo grabable no anulable, lo habría escrito, pero no lo hay, por lo que hizo la siguiente mejor cosa: aplicó la restricción de tipo en tiempo de ejecución. Es de esperar que también lo hayas documentado, para que los desarrolladores que usan tu método no tengan que violarlo, fallar en sus casos de prueba y luego solucionar el problema.

EricLippert tiene razón en que este es un problema para el sistema de tipos. Y como desea utilizar el principio de responsabilidad única (SRP), básicamente necesita que el sistema de tipos sea responsable de este trabajo.

En realidad es posible hacer esto en C #. Podemos capturar los null 's en el momento de la compilación, y luego capturar los null ' no literales en el tiempo de ejecución. Eso no es tan bueno como una revisión completa en tiempo de compilación, pero es una mejora estricta en comparación con la captura nunca en tiempo de compilación.

Entonces, ya sabes cómo C # tiene Nullable<T> ? Vamos a revertir eso y hacer un NonNullable<T> :

public struct NonNullable<T> where T : class
{
    public T Value { get; private set; }
    public NonNullable(T value)
    {
        if (value == null) { throw new NullArgumentException(); }
        this.Value = value;
    }
    //  Ease-of-use:
    public static implicit operator T(NonNullable<T> value) { return value.Value; }
    public static implicit operator NonNullable<T>(T value) { return new NonNullable<T>(value); }

    //  Hack-ish overloads that prevent null-literals from being implicitly converted into NonNullable<T>'s.
    public static implicit operator NonNullable<T>(Tuple<T> value) { return new NonNullable<T>(value.Item1); }
    public static implicit operator NonNullable<T>(Tuple<T, T> value) { return new NonNullable<T>(value.Item1); }
}

Ahora, en lugar de escribir

public void Foo(Stream stream)
{
  if (stream == null) { throw new NullArgumentException(); }

  // ...method code...
}

, simplemente escribe:

public void Foo(NonNullable<Stream> stream)
{
  // ...method code...
}

Luego, hay tres casos de uso:

  1. El usuario llama a Foo() con un Stream no nulo:

    Stream stream = new Stream();
    Foo(stream);
    

    Este es el caso de uso deseado, y funciona con o sin NonNullable<> .

  2. El usuario llama a Foo() con un nulo Stream :

    Stream stream = null;
    Foo(stream);
    

    Este es un error de llamada. Aquí NonNullable<> ayuda a informar al usuario que no debería hacer esto, pero en realidad no lo detiene. De cualquier manera, esto resulta en un NullArgumentException en tiempo de ejecución.

  3. El usuario llama a Foo() con null :

    Foo(null);
    

    null no se convertirá implícitamente en NonNullable<> , por lo que el usuario recibe un error en el IDE, antes del tiempo de ejecución. Esto es delegar la comprobación de nulos al sistema de tipo, tal como lo recomendaría el SRP.

También puede extender este método para afirmar otras cosas sobre sus argumentos. Por ejemplo, dado que desea un flujo grabable, puede definir un struct WriteableStream<T> where T:Stream que verifique tanto null como stream.CanWrite en el constructor. Esto aún sería una verificación de tipo en tiempo de ejecución, pero:

  1. Decora el tipo con el calificador WriteableStream , lo que señala la necesidad de las personas que llaman.

  2. Realiza la comprobación en un solo lugar en el código, por lo que no tiene que repetir la comprobación y throw InvalidArgumentException cada vez.

  3. Se ajusta mejor al SRP al aplicar las tareas de verificación de tipos al sistema de tipos (según lo extienden los decoradores genéricos).

respondido por el Nat 19.10.2017 - 05:21
3

Su enfoque es actualmente de procedimiento. Estás separando el objeto Stream y validándolo desde el exterior. No hagas eso, se rompe la encapsulación. Deje que Stream sea responsable de su propia validación. No podemos intentar aplicar el SRP hasta que tengamos algunas clases para aplicarlo.

Aquí hay un Stream que realiza una acción solo si pasa la validación:

class Stream
{
    public void someAction()
    {
        if(!stream.canWrite)
        {
            throw new ArgumentException();
        }

        System.out.println("My action");
    }
}

Pero ahora estamos violando SRP! "Una clase debe tener una sola razón para cambiar". Tenemos una mezcla de 1) validación y 2) lógica real. Tenemos dos razones por las que podría necesitar cambiar.

Podemos resolver esto con validar decoradores . Primero, debemos convertir nuestro Stream a una interfaz e implementarlo como una clase concreta.

interface Stream
{
    void someAction();
}

class DefaultStream implements Stream
{
    @Override
    public void someAction()
    {
        System.out.println("My action");
    }
}

Ahora podemos escribir un decorador que ajusta un Stream , realiza la validación y difiere al Stream dado para la lógica real de la acción.

class WritableStream implements Stream
{
    private final Stream stream;

    public WritableStream(final Stream stream)
    {
        this.stream = stream;
    }

    @Override
    public void someAction()
    {
        if(!stream.canWrite)
        {
            throw new ArgumentException();
        }
        stream.someAction();
    }
}

Ahora podemos componer esto de la forma que queramos:

final Stream myStream = new WritableStream(
    new DefaultStream()
);

¿Quieres validación adicional? Añadir otro decorador.

    
respondido por el Michael 18.10.2017 - 18:19
1

El trabajo de una clase es proporcionar un servicio que cumpla con un contrato . Una clase siempre tiene un contrato: un conjunto de requisitos para usarlo, y promete que hará acerca de su estado y resultados siempre que se cumplan los requisitos. Este contrato puede ser explícito, a través de la documentación y / o aseveraciones, o implícito, pero siempre existe.

Parte del contrato de su clase es que la persona que llama le da al constructor algunos argumentos que no deben ser nulos. Implementar el contrato es la responsabilidad de la clase, por lo que verificar que la persona que llama ha cumplido con su parte del contrato puede considerarse fácilmente como parte del alcance de la responsabilidad de la clase.

La idea de que una clase implementa un contrato se debe a Bertrand Meyer , el diseñador del lenguaje de programación Eiffel y de la idea de diseño por contrato . El lenguaje Eiffel hace que la especificación y verificación del contrato sea parte del lenguaje.

    
respondido por el Wayne Conrad 22.10.2017 - 00:45
0

Como se ha señalado en otras respuestas, a menudo se entiende mal el SRP. No se trata de tener un código atómico que solo hace una función. Se trata de asegurarse de que sus objetos y métodos solo hagan una cosa, y que la única cosa se haga solo en un lugar.

Veamos un mal ejemplo en pseudo código.

class Math
    private int a;
    private int b;
    def constructor(int x, int y) 
        if(x != null)
          a = x
        else if(x < 0)
          a = abs(x)
        else if (x == -1)
          throw "Some Silly Error"
        else
          a = 0
        end
        if(y != null)
           b = y
        else if(y < 0)
           b = abs(y)
        else if(y == -1)
           throw "Some Silly Error"
        else
         b = 0
        end
    end
    def add()
        return a + b
    end
    def sub()
        return b - a
    end
end

En nuestro ejemplo bastante absurdo, la "responsabilidad" de Math # constructor es hacer que el objeto matemático sea utilizable. Lo hace primero limpiando la entrada y luego asegurándose de que los valores no sean -1.

Este es un SRP válido porque el constructor está haciendo solo una cosa. Está preparando el objeto matemático. Sin embargo, no es muy fácil de mantener. Viola lo seco.

Así que vamos a darle otro pase

class Math
    private int a;
    private int b;
    def constructor(int x, int y)
        cleanX(x)
        cleanY(y)
    end
    def cleanX(int x)
        if(x != null)
          a = x
        else if(x < 0)
          a = abs(x)
        else if (x == -1)
          throw "Some Silly Error"
        else
          a = 0
        end
   end
   def cleanY(int y)
        if(y != null)
           b = y
        else if(y < 0)
           b = abs(y)
        else if(y == -1)
           throw "Some Silly Error"
        else
         b = 0
        end
    end
    def add()
        return a + b
    end
    def sub()
        return b - a
    end
end

En este pase hemos mejorado un poco acerca de DRY, pero todavía tenemos maneras de ir con DRY. Por otro lado, SRP parece un poco apagado. Ahora tenemos dos funciones con el mismo trabajo. Tanto la entrada cleanX como cleanY sanitize input.

Vamos a darle otra oportunidad

class Math
    private int a;
    private int b;
    def constructor(int x, int y)
        a = clean(x)
        b = clean(y)
    end
    def clean(int i)
        if(i != null)
          return i
        else if(i < 0)
          return abs(i)
        else if (i == -1)
          throw "Some Silly Error"
        else
          return 0
        end
    end
    def add()
        return a + b
    end
    def sub()
        return b - a
    end
end

Ahora, finalmente, fueron mejores acerca de DRY, y SRP parece estar de acuerdo. Solo tenemos un lugar que realiza el trabajo de "desinfección".

En teoría, el código es más fácil de mantener y, mejor aún, cuando vamos a corregir el error y a reforzar el código, solo necesitamos hacerlo en un lugar.

class Math
    private int a;
    private int b;
    def constructor(int x, int y)
        a = clean(x)
        b = clean(y)
    end
    def clean(int i)
        if(i == null)
          return 0
        else if (i == -1)
          throw "Some Silly Error"
        else
          return abs(i)
        end
    end
    def add()
        return a + b
    end
    def sub()
        return b - a
    end
end

En la mayoría de los casos del mundo real, los objetos serían más complejos y el SRP se aplicaría a un montón de objetos. Por ejemplo, la edad puede pertenecer a Padre, Madre, Hijo, Hija, así que en lugar de tener 4 clases que calculan la edad desde la fecha de nacimiento, tiene una clase de Persona que hace eso y las 4 clases heredan de eso. Pero espero que este ejemplo ayude a explicar. El SRP no se trata de acciones atómicas, sino de un "trabajo" que se realiza.

    
respondido por el coteyr 07.02.2018 - 13:28
-3

Hablando de SRP, al tío Bob no le gustan los cheques nulos esparcidos por todas partes. En general, usted, como equipo, debe evitar el uso de parámetros nulos para los constructores siempre que sea posible. Cuando publicas tu código fuera de tu equipo, las cosas pueden cambiar.

Hacer cumplir la no nulabilidad de los parámetros del constructor sin asegurar primero la cohesividad de la clase en cuestión resulta en un aumento en el código de llamada, especialmente en las pruebas.

Si realmente desea hacer cumplir dichos contratos, considere usar Debug.Assert o algo similar para reducir el desorden:

public AClassThatDefinitelyNeedsAWritableStream(Stream stream)
{
   Assert.That(stream.CanWrite, "Put crucial information here, and not inane bloat.");

   // Go on normal operation.
}
    
respondido por el abuzittin gillifirca 23.10.2017 - 07:51

Lea otras preguntas en las etiquetas