¿Cuál es la verdadera responsabilidad de una clase?

40

Me pregunto si es legítimo usar verbos basados en sustantivos en OOP.
Me topé con este artículo brillante , aunque todavía no estoy de acuerdo con el punto que hace.

Para explicar el problema un poco más, el artículo establece que no debería haber, por ejemplo, una clase FileWriter , pero como escribir es una acción debería ser una método de la clase File . Llegará a darse cuenta de que a menudo depende del lenguaje, ya que un programador de Ruby probablemente esté en contra del uso de una clase FileWriter (Ruby usa el método File.open para acceder a un archivo), mientras que un programador de Java no lo haría.

Mi punto de vista personal (y sí, muy humilde) es que al hacerlo se rompería el principio de responsabilidad única. Cuando programé en PHP (porque PHP es obviamente el mejor lenguaje para OOP, ¿verdad?), A menudo usaba este tipo de marco:

<?php

// This is just an example that I just made on the fly, may contain errors

class User extends Record {

    protected $name;

    public function __construct($name) {
        $this->name = $name;
    }

}

class UserDataHandler extends DataHandler /* knows the pdo object */ {

    public function find($id) {
         $query = $this->db->prepare('SELECT ' . $this->getFields . ' FROM users WHERE id = :id');
         $query->bindParam(':id', $id, PDO::PARAM_INT);
         $query->setFetchMode( PDO::FETCH_CLASS, 'user');
         $query->execute();
         return $query->fetch( PDO::FETCH_CLASS );
    }


}

?>

Tengo entendido que el sufijo DataHandler no agrega nada relevante ; pero el punto es que el principio de responsabilidad única nos dicta que un objeto utilizado como modelo que contiene datos (puede denominarse Registro) tampoco debería tener la responsabilidad de realizar consultas SQL y acceso a la Base de datos. De alguna manera, esto invalida el patrón ActionRecord utilizado, por ejemplo, por Ruby on Rails.

Encontré este código C # (sí, cuarto lenguaje de objeto utilizado en esta publicación) el otro día:

byte[] bytes = Encoding.Default.GetBytes(myString);
myString = Encoding.UTF8.GetString(bytes);

Y tengo que decir que no tiene mucho sentido para mí que una clase Encoding o Charset en realidad codifique cadenas. Simplemente debería ser una representación de lo que realmente es una codificación.

Por lo tanto, tendería a pensar que:

  • No es responsabilidad de la clase File abrir, leer o guardar archivos.
  • No es una responsabilidad de la clase Xml para serializarse.
  • No es responsabilidad de una clase User consultar una base de datos.
  • etc.

Sin embargo, si extrapolamos estas ideas, ¿por qué Object tiene una clase toString ? No es responsabilidad de un auto o de un perro convertirse en una cadena, ¿verdad?

Entiendo que, desde un punto de vista pragmático, deshacerse del método toString por la belleza de seguir un formulario SOLID estricto, que hace que el código sea más mantenible al hacer que sea inútil, no es una opción aceptable.

También entiendo que puede que no haya una respuesta exacta (lo que sería más un ensayo que una respuesta seria) a esto, o que puede estar basada en opiniones. Sin embargo, todavía me gustaría saber si mi enfoque realmente sigue lo que realmente es el principio de responsabilidad única.

¿Cuál es la responsabilidad de una clase?

    
pregunta Pierre Arlaud 04.12.2013 - 10:31

8 respuestas

22

Dadas algunas divergencias entre idiomas, este puede ser un tema difícil. Por lo tanto, estoy formulando los siguientes comentarios de una manera que intenta ser lo más completo posible dentro del ámbito de OO.

En primer lugar, el llamado "Principio de Responsabilidad Única" es un reflejo, explícitamente declarado, del concepto cohesión . Al leer la literatura de la época (alrededor del '70), la gente estaba (y aún está) luchando por definir qué es un módulo y cómo construirlos de una manera que preservara buenas propiedades. Por lo tanto, dirían que "aquí hay un montón de estructuras y procedimientos, haré un módulo con ellos", pero sin un criterio acerca de por qué este conjunto de cosas arbitrarias se empaquetan juntas, la organización podría terminar teniendo poco sentido. - pequeña "cohesión". Por lo tanto, surgieron discusiones sobre criterios.

Por lo tanto, lo primero que se debe tener en cuenta es que, hasta ahora, el debate se centra en la organización y los efectos relacionados con el mantenimiento y la capacidad de comprensión (desde el punto de vista de una computadora, si un módulo "tiene sentido").

Luego, alguien más (el señor Martin) entró y aplicó el mismo pensamiento a la unidad de una clase como un criterio para usar cuando piensa en lo que debería o no debería pertenecer a él, promoviendo este criterio a un principio, el uno discutido aquí El punto que señaló fue que "Una clase debe tener solo una razón para cambiar" .

Bueno, sabemos por experiencia que muchos objetos (y muchas clases) que parecen hacer "muchas cosas" tienen una muy buena razón para hacerlo. El caso indeseable serían las clases que están repletas de funcionalidad hasta el punto de ser impenetrables para el mantenimiento, etc. Y comprender esto último es ver dónde está el sr. Martin apuntaba a cuando él elaboró sobre el tema.

Por supuesto, después de leer lo que mr. Martin escribió, debería estar claro que estos son criterios de dirección y diseño para evitar escenarios problemáticos, no de ninguna manera para buscar ningún tipo de cumplimiento, y mucho menos un cumplimiento sólido, especialmente cuando la "responsabilidad" no está bien definida (y preguntas como "¿viola esto el principio?" son ejemplos perfectos de la confusión generalizada). Por lo tanto, me parece desafortunado que se llame un principio , engañando a la gente para que intente llevarlo a las últimas consecuencias, donde no haría ningún bien. El mismo Martin discutió los diseños que "hacen más de una cosa" que probablemente deberían mantenerse así, ya que la separación produciría peores resultados. Además, hay muchos desafíos conocidos con respecto a la modularidad (y este tema es un caso de ello), no estamos en un punto de tener buenas respuestas, incluso para algunas preguntas simples al respecto.

  

Sin embargo, si extrapolamos estas ideas, ¿por qué tendría Object una clase toString? No es responsabilidad de un auto o de un perro convertirse en una cadena, ¿verdad?

Ahora, permítame hacer una pausa para decir algo aquí acerca de toString : hay una cosa fundamental que se suele descuidar cuando se realiza la transición del pensamiento de los módulos a las clases y se reflexiona sobre qué métodos deben pertenecer a una clase. Y la cosa es el envío dinámico (también conocido como enlace tardío, "polimorfismo").

En un mundo sin "métodos de anulación", elegir entre "obj.toString ()" o "toString (obj)" es solo una cuestión de preferencia de sintaxis. Sin embargo, en un mundo donde los programadores pueden cambiar el comportamiento de un programa agregando una subclase con una implementación distinta de un método existente / invalidado, esta elección ya no es de buen gusto: hacer de un procedimiento un método también puede hacer que sea un candidato para el reemplazo, y lo mismo podría no ser cierto para los "procedimientos libres" (los idiomas que admiten métodos múltiples tienen una salida de esta dicotomía). En consecuencia, ya no es más una discusión sobre la organización, sino también sobre la semántica. Finalmente, a qué clase está vinculado el método, también se convierte en una decisión impactante (y en muchos casos, hasta el momento, tenemos poco más que pautas que nos ayuden a decidir a dónde pertenecen las cosas, ya que las compensaciones no obvias surgen de diferentes opciones) .

Finalmente, nos enfrentamos a lenguajes que llevan terribles decisiones de diseño, por ejemplo, forzando a uno a crear una clase para cada pequeña cosa. Por lo tanto, lo que una vez fue la razón canónica y los criterios principales para tener objetos (y en la clase-tierra, por lo tanto, clases), es decir, tener estos "objetos" que son una especie de "comportamientos que también se comportan como datos" , pero protege su representación concreta (si existe) de la manipulación directa a toda costa (y esa es la sugerencia principal de lo que debería ser la interfaz de un objeto, desde el punto de vista de sus clientes), se vuelve borrosa y confusa.

    
respondido por el Thiago Silva 04.12.2013 - 19:17
30

[Nota: voy a hablar de objetos aquí. Objetos es de lo que se trata la programación orientada a objetos, después de todo, no de las clases.]

La responsabilidad de un objeto depende principalmente de su modelo de dominio. Por lo general, hay muchas formas de modelar el mismo dominio, y usted elegirá una u otra forma según la forma en que se utilizará el sistema.

Como todos sabemos, la metodología del "verbo / sustantivo" que a menudo se enseña en los cursos introductorios es ridícula, porque depende mucho de cómo se formule una oración. Puede expresar casi cualquier cosa como un sustantivo o un verbo, ya sea en voz activa o pasiva. El hecho de que su modelo de dominio dependa de eso es incorrecto, debe ser una elección de diseño consciente, ya sea que algo sea un objeto o un método, no solo una consecuencia accidental de la forma en que formula los requisitos.

Sin embargo, muestra que, al igual que tiene muchas posibilidades de expresar lo mismo en inglés, tiene muchas posibilidades de expresar lo mismo en su modelo de dominio ... y ninguna de esas posibilidades Es inherentemente más correcto que los otros. Depende del contexto.

Este es un ejemplo que también es muy popular en los cursos introductorios OO: una cuenta bancaria. Que normalmente se modela como un objeto con un campo balance y un método transfer . En otras palabras: el saldo de la cuenta es datos y una transferencia es una operación . Que es una forma razonable de modelar una cuenta bancaria.

Excepto, no es no la forma en que se modelan las cuentas bancarias en el software bancario del mundo real (y, de hecho, no es cómo funcionan los bancos en el mundo real). En su lugar, tiene un comprobante de transacción y el saldo de la cuenta se calcula sumando (y restando) todos los comprobantes de transacción de una cuenta. En otras palabras: la transferencia es datos y el saldo es una operación . (Curiosamente, esto también hace que su sistema sea puramente funcional, ya que nunca necesita modificar el saldo, tanto los objetos de su cuenta como los objetos de la transacción nunca tienen que cambiar, son inmutables).

En cuanto a su pregunta específica sobre toString , estoy de acuerdo. Prefiero mucho la solución Haskell de una clase de tipo Showable . (Scala nos enseña que las clases de tipos encajan perfectamente con OO). Lo mismo con la igualdad. Con mucha frecuencia, la igualdad es no una propiedad de un objeto, sino del contexto en el que se utiliza el objeto. Solo piense en los números de punto flotante: ¿cuál debería ser el épsilon? De nuevo, Haskell tiene la clase de tipo Eq .

    
respondido por el Jörg W Mittag 04.12.2013 - 11:23
8

Con demasiada frecuencia, el principio de responsabilidad única se convierte en un principio de responsabilidad cero, y terminas con Clases de anemia que no hacen nada. (excepto setters y getters), que lleva a un El desastre del Reino de los Nombres .

Nunca lo conseguirás perfecto, pero es mejor que una clase haga un poco demasiado que demasiado poco.

En su ejemplo con Codificación, OMI, definitivamente debería ser capaz de codificar. ¿Qué crees que debería hacer en su lugar? El solo hecho de tener un nombre "utf" es responsabilidad cero. Ahora, tal vez el nombre debería ser Codificador. Pero, como dijo Konrad, los datos (qué codificación) y el comportamiento (al hacerlo) pertenecen juntos .

    
respondido por el user949300 07.12.2013 - 01:49
7

Una instancia de una clase es un cierre. Eso es. Si piensas así, todo el software bien diseñado que mires se verá bien, y todo el software mal pensado no lo hará. Déjame expandir.

Si desea escribir algo para escribir en un archivo, piense en todas las cosas que necesita especificar (para el sistema operativo): nombre de archivo, método de acceso (lectura, escritura, anexo), la cadena que desea escribir.

Entonces construyes un objeto File con el nombre de archivo (y el método de acceso). El objeto Archivo ahora está cerrado sobre el nombre del archivo (en este caso, probablemente lo habrá tomado como un valor de solo lectura / const). La instancia de Archivo ahora está lista para recibir llamadas al método de "escritura" definido en la clase. Esto solo requiere un argumento de cadena, pero en el cuerpo del método de escritura, la implementación, también hay acceso al nombre de archivo (o el identificador de archivo que se creó a partir de él).

Una instancia de clase de archivo, por lo tanto, encuadra algunos datos en algún tipo de blob compuesto para que el uso posterior de esa instancia sea más sencillo. Cuando tiene un objeto de archivo, no necesita preocuparse por cuál es el nombre del archivo, solo le preocupa qué cadena coloque como argumento en la llamada de escritura. Para reiterar: el objeto Archivo encapsula todo lo que no necesita saber sobre dónde va realmente la cadena que desea escribir.

La mayoría de los problemas que desea resolver se superponen de esta manera: las cosas se crean al principio, las cosas se crean al comienzo de cada iteración de algún tipo de bucle de proceso, luego se declaran diferentes cosas en las dos mitades de una otra cosa, entonces las cosas creadas al inicio de algún tipo de sub-bucle, luego las cosas declaradas como parte del algoritmo dentro de ese bucle, etc. A medida que agrega más y más llamadas de función a la pila, está haciendo código cada vez más fino, pero cada vez que Habré encuadrado los datos en las capas más bajas de la pila en algún tipo de abstracción agradable que facilite el acceso. Ver lo que está haciendo es usar "OO" como un tipo de marco de administración de cierre para un enfoque de programación funcional.

La solución de acceso directo a algunos problemas involucra el estado mutable de los objetos, que no es tan funcional: está manipulando los cierres desde el exterior. Estás haciendo algunas de las cosas de tu clase "global", al menos en el ámbito de arriba. Cada vez que escribes un setter, trata de evitarlos. Yo diría que aquí es donde tienes la responsabilidad de tu clase, o de las otras clases en las que opera, mal. Pero no se preocupe demasiado: a veces es pragmático agitarse en un poco de estado mutable para resolver ciertos tipos de problemas rápidamente, sin demasiada carga cognitiva, y en realidad hacer cualquier cosa.

En resumen, para responder a su pregunta, ¿cuál es la verdadera responsabilidad de una clase? Es para presentar un tipo de plataforma, basada en algunos datos subyacentes, para lograr un tipo de operación más detallado. Es para encapsular la mitad de los datos involucrados en una operación que cambia con menos frecuencia que la otra mitad de los datos (Think currying ...). P.ej. nombre de archivo vs cadena para escribir.

A menudo, estas encapsulaciones se parecen superficialmente a un objeto del mundo real / un objeto en el dominio del problema, pero no se deje engañar. Cuando creas una clase de Automóvil, no es realmente un automóvil, es una colección de datos que forma una plataforma en la que puedes lograr ciertos tipos de cosas que podrías considerar querer hacer en un automóvil. Formar una representación de cadena (toString) es una de esas cosas: escupir todos esos datos internos. Recuerde que a veces una clase de automóvil puede no ser la clase correcta, incluso si el dominio del problema tiene que ver con los automóviles. En contraste con el Reino de los sustantivos, son las operaciones, los verbos sobre los que se debe basar una clase.

    
respondido por el Benedict 06.12.2013 - 09:48
4
  

También entiendo que puede que no haya una respuesta exacta (lo que sería más un ensayo que una respuesta seria) para esto, o que puede estar basada en opiniones. Sin embargo, todavía me gustaría saber si mi enfoque realmente sigue lo que realmente es el principio de responsabilidad única.

Mientras lo hace, puede que no sea un buen código. Más allá del simple hecho de que cualquier regla seguida ciegamente conduce a un código incorrecto, está comenzando una premisa no válida: los objetos (de programación) no son objetos (físicos).

Los objetos son un conjunto de paquetes cohesivos de datos y operaciones (y, a veces, solo uno de los dos). Si bien estos a menudo modelan objetos del mundo real, la diferencia entre un modelo de computadora de algo y esa cosa en sí misma necesita diferencias.

Al tomar esa línea dura entre un "sustantivo" que representa una cosa y otras cosas que las consumen, se opondrá a un beneficio clave de la programación orientada a objetos (tener función y estado juntos para que la función pueda proteger invariantes de el estado). Peor aún, estás tratando de representar el mundo físico en código, que, como se ha demostrado en la historia, no funcionará bien.

    
respondido por el Telastyn 04.12.2013 - 18:29
4
  

Encontré este código C # (sí, cuarto lenguaje de objeto utilizado en este   post) sólo el otro día:

byte[] bytes = Encoding.Default.GetBytes(myString);
myString = Encoding.UTF8.GetString(bytes);
     

Y tengo que decir que no tiene mucho sentido para mí que un    Encoding o Charset class en realidad codifica cadenas. Debería   simplemente ser una representación de lo que realmente es una codificación.

En teoría, sí, pero creo que C # hace un compromiso justificado en aras de la simplicidad (a diferencia del enfoque más estricto de Java).

Si Encoding solo representara (o identificara) cierta codificación, por ejemplo, UTF-8, obviamente también necesitaría una clase Encoder para poder implementar GetBytes en ella, pero luego Necesitamos gestionar la relación entre Encoder s y Encoding s, por lo que terminamos con nuestros buenos amigos

EncodersFactory.getDefaultFactory().createEncoder(new UTF8Encoding()).getBytes(myString)

de manera tan brillante en ese artículo al que te vinculaste (gran lectura, por cierto).

Si te entiendo bien, estás preguntando dónde está la línea.

Bueno, en el lado opuesto del espectro está el enfoque de procedimiento, no es difícil de implementar en lenguajes OOP con el uso de clases estáticas:

HelpfulStuff.GetUTF8Bytes(myString)

El gran problema es que cuando el autor de HelpfulStuff se va y me siento frente al monitor, no tengo forma de saber dónde se implementa GetUTF8Bytes .

¿Lo busco en HelpfulStuff , Utils , StringHelper ?

Señor, ayúdeme si el método se implementa de manera independiente en los tres ... lo que sucede mucho, todo lo que hace falta es que el tipo que tenía delante de mí tampoco sabía dónde buscar esa función y creía que no existía. Todavía no hay ninguno, así que sacaron otro y ahora los tenemos en abundancia.

Para mí, el diseño adecuado no se trata de satisfacer criterios abstractos, sino de mi facilidad para continuar después de sentarme frente a su código. Ahora como hace

EncodersFactory.getDefaultFactory().createEncoder(new UTF8Encoding()).getBytes(myString)

calificar en este aspecto? Mal también. No es una respuesta que quiero escuchar cuando le grito a mi compañero de trabajo "¿cómo puedo codificar una cadena en una secuencia de bytes?" :)

Así que iría con un enfoque moderado y sería liberal con respecto a la responsabilidad única. Prefiero tener la clase Encoding tanto para identificar un tipo de codificación como para hacer la codificación real: datos no separados del comportamiento.

Creo que pasa como un patrón de fachada, lo que ayuda a engrasar los engranajes. ¿Este patrón legítimo viola SOLID? Una pregunta relacionada: hace ¿El patrón de fachada viola el SRP?

Tenga en cuenta que esta podría ser la forma en que los bytes codificados se implementan internamente (en las bibliotecas .NET). Las clases simplemente no son visibles públicamente y solo esta API "fachada" está expuesta afuera. No es tan difícil de verificar si eso es cierto, estoy un poco demasiado perezoso para hacerlo ahora mismo :) Probablemente no lo sea, pero eso no invalida mi punto.

Puede optar por simplificar la implementación públicamente visible con el fin de mantener la cordialidad mientras se mantiene internamente una implementación canónica más severa.

    
respondido por el Konrad Morawski 05.12.2013 - 11:59
1

En Java, la abstracción utilizada para representar archivos es una secuencia, algunas secuencias son unidireccionales (solo lectura o escritura) otras son bidireccionales. Para Ruby, la clase Archivo es la abstracción que representa los archivos del sistema operativo. Entonces la pregunta es ¿cuál es su única responsabilidad? En Java, la responsabilidad de FileWriter es proporcionar un flujo de bytes unidireccional que esté conectado a un archivo del sistema operativo. En Ruby, la responsabilidad de File es proporcionar acceso bidireccional a un archivo del sistema. Ambos cumplen el SRP, solo tienen diferentes responsabilidades.

  

No es responsabilidad de un Automóvil o de un Perro convertirse en una cadena, ¿verdad?

Claro, ¿por qué no? Espero que la clase de coches represente completamente una abstracción de coches. Si tiene sentido que la abstracción del auto se use donde se necesita una cadena, espero que la clase Car admita la conversión a una cadena.

Para responder directamente a la pregunta más grande, la responsabilidad de una clase es actuar como una abstracción. Ese trabajo puede ser complejo, pero ese es el punto, una abstracción debería ocultar cosas complejas detrás de una interfaz más fácil de usar y menos compleja. El SRP es una guía de diseño para que te concentres en la pregunta "¿qué representa esta abstracción?". Si se necesitan múltiples comportamientos diferentes para abstraer completamente el concepto, entonces así sea.

    
respondido por el stonemetal 04.12.2013 - 17:36
0

La clase puede tener múltiples responsabilidades. Al menos en el clásico OOP centrado en los datos.

Si está aplicando el principio de responsabilidad única en toda su extensión, obtiene responsabilidad -centro de diseño donde básicamente todos los métodos que no son de ayuda pertenecen a su propia clase.

Creo que no hay nada de malo en extraer una parte de la complejidad de una clase base como en el caso de File y FileWriter. La ventaja es que obtiene una separación clara del código que es responsable de una determinada operación y también que puede anular (subclase) solo ese fragmento de código en lugar de anular toda la clase base (por ejemplo, Archivo). Sin embargo, aplicar eso sistemáticamente parece ser una exageración para mí. Simplemente obtendrá muchas más clases para manejar y para cada operación que necesite para invocar su clase respectiva. Es la flexibilidad que tiene un costo.

Estas clases extraídas deben tener nombres muy descriptivos según la operación que contienen, por ejemplo. <X>Writer , <X>Reader , <X>Builder . Los nombres como <X>Manager , <X>Handler no describen la operación en absoluto y, si se les llama así porque contienen más de una operación, no está claro lo que ha logrado. Ha separado la funcionalidad de sus datos, aún rompe el principio de responsabilidad individual incluso en esa clase extraída, y si está buscando un método determinado, es posible que no sepa dónde buscarlo (si está en <X> o <X>Manager ) .

Ahora, su caso con UserDataHandler es diferente porque no hay una clase base (la clase que contiene datos reales). Los datos de esta clase se almacenan en un recurso externo (base de datos) y está utilizando una api para acceder a ellos y manipularlos. Su clase de usuario representa un usuario en tiempo de ejecución, lo que realmente es diferente a un usuario persistente y la separación de responsabilidades (preocupaciones) puede ser útil aquí, especialmente si tiene mucha lógica relacionada con el usuario en tiempo de ejecución.

Probablemente haya llamado UserDataHandler así porque básicamente hay una funcionalidad en esa clase y no hay datos reales. Sin embargo, también puede abstraerse de ese hecho y considerar que los datos en la base de datos pertenecen a la clase. Con esta abstracción puede nombrar la clase según lo que es y no por lo que hace. Puede darle un nombre como UserRepository, que es bastante hábil y también sugiere el uso del patrón de repositorio .

    
respondido por el clime 08.12.2013 - 19:30

Lea otras preguntas en las etiquetas