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

42

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

Para explicar un poco más el problema, el artículo establece que no debería haber, por ejemplo, una FileWriterclase, pero dado que escribir es una acción , debería ser un método de la clase File. Te darás cuenta de que a menudo depende del idioma, ya que un programador de Ruby probablemente estaría en contra del uso de una FileWriterclase (Ruby usa el método File.openpara 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 hacerlo rompería el principio de Responsabilidad Única. Cuando programaba 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 llamarse Registro) no debería tener la responsabilidad de realizar consultas SQL y acceso a DataBase. Esto de alguna manera invalida el patrón ActionRecord utilizado, por ejemplo, por Ruby on Rails.

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

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

Y debo decir que no tiene mucho sentido para mí que una Encodingo Charsetclase realmente 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 Fileresponsabilidad de la clase abrir, leer o guardar archivos.
  • No es una Xmlresponsabilidad de clase serializarse.
  • No es una Userresponsabilidad de clase consultar una base de datos.
  • etc.

Sin embargo, si extrapolamos estas ideas, ¿por qué tendría Objectuna toStringclase? No es responsabilidad de un automóvil o de un perro convertirse en una cuerda, ¿verdad?

Entiendo que, desde un punto de vista pragmático, deshacerse del toStringmétodo por la belleza de seguir una forma SÓLIDA estricta, que hace que el código sea más fácil de mantener al hacer que sea inútil, no es una opción aceptable.

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

¿Cuál es la responsabilidad de una clase?

Pierre Arlaud
fuente
Los objetos toString son convenientes principalmente, y toString se usa generalmente para ver rápidamente el estado interno durante la depuración
freak de trinquete el
3
@ratchetfreak Estoy de acuerdo, pero fue un ejemplo (casi una broma) para mostrar que la responsabilidad individual se rompe con frecuencia en la forma que describí.
Pierre Arlaud
8
Si bien el principio de responsabilidad única tiene sus fervientes partidarios, en mi opinión, es una guía en el mejor de los casos, y muy flexible para la abrumadora mayoría de las opciones de diseño. El problema con SRP es que terminas con cientos de clases con nombres extraños, hace que sea realmente difícil encontrar lo que necesitas (o descubrir si lo que necesitas ya existe) y comprender el panorama general. Prefiero tener menos clases bien nombradas que me digan instantáneamente qué esperar de ellas, incluso si tienen bastantes responsabilidades. Si en el futuro las cosas cambian, dividiré la clase.
Dunk

Respuestas:

24

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

En primer lugar, el llamado "Principio de responsabilidad única" es un reflejo, declarado explícitamente, del concepto de cohesión . Leyendo la literatura de la época (alrededor del '70), las personas estaban (y todavía están) luchando por definir qué es un módulo y cómo construirlo de una manera que conserve buenas propiedades. Entonces, dirían "aquí hay un montón de estructuras y procedimientos, haré un módulo con ellos", pero sin ningún criterio de por qué este conjunto de cosas arbitrarias están agrupadas, la organización podría terminar teniendo poco sentido - poca "cohesión". Por lo tanto, surgieron discusiones sobre criterios.

Por lo tanto, lo primero que hay que tener en cuenta aquí es que, hasta ahora, el debate gira en torno a la organización y los efectos relacionados con el mantenimiento y la comprensibilidad (por poco para una computadora si un módulo "tiene sentido").

Luego, alguien más (Sr. Martin) entró y aplicó el mismo pensamiento a la unidad de una clase como criterio para usar cuando se piensa qué debería o no pertenecer a él, promoviendo este criterio a un principio, el que se está discutiendo aquí. El punto que hizo fue que "Una clase debería tener una sola 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 entender esto último es ver dónde está el Sr. Martin estaba apuntando cuando elaboró ​​sobre el tema.

Por supuesto, después de leer lo que el Sr. Martin escribió, debe quedar claro que estos son criterios de dirección y diseño para evitar escenarios problemáticos, no de ninguna manera para perseguir ningún tipo de cumplimiento, y mucho menos un cumplimiento fuerte, especialmente cuando la "responsabilidad" está mal definida (y preguntas como "¿esto es así? viola el principio? "son ejemplos perfectos de la confusión generalizada). Por lo tanto, me parece lamentable que se llame un principio, engañando a la gente para tratar de llevarlo a las últimas consecuencias, donde no serviría de nada. El propio Sr. 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, existen muchos desafíos conocidos con respecto a la modularidad (y este tema es un caso de ello), no estamos en el punto de tener buenas respuestas incluso para algunas preguntas simples al respecto.

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

Ahora, déjenme hacer una pausa para decir algo al respecto toString: hay una cosa fundamental que generalmente se descuida cuando uno hace esa transición de pensamiento de módulos a clases y reflexiona sobre qué métodos deberían pertenecer a una clase. Y el asunto es el despacho dinámico (también conocido como enlace tardío, "polimorfismo").

En un mundo sin "métodos primordiales", 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 / anulado, esta opción ya no es del gusto: convertir un procedimiento en un método también puede hacer que sea un candidato para anular, y lo mismo podría no ser cierto para los "procedimientos gratuitos" (los lenguajes que admiten múltiples métodos tienen una salida a esta dicotomía). En consecuencia, ya no se trata solo de una organización, sino también de 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 ahora, tenemos poco más que pautas para ayudarnos a decidir dónde pertenecen las cosas,

Finalmente, nos enfrentamos a lenguajes que conllevan terribles decisiones de diseño, por ejemplo, forzando a uno a crear una clase para cada pequeña cosa. Por lo tanto, lo que alguna vez fue la razón canónica y los criterios principales para tener objetos (y en la tierra de clases, por lo tanto, clases), es decir, tener estos "objetos" que son una especie de "comportamientos que también se comportan como datos". , pero proteja su representación concreta (si la hay) de la manipulación directa a toda costa (y esa es la pista 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.

Thiago Silva
fuente
31

[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 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 elegirá una u otra en función de cómo se utilizará el sistema.

Como todos sabemos, la metodología "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 una voz activa o pasiva. Hacer que su modelo de dominio dependa de eso es incorrecto, debería ser una elección de diseño consciente si algo es un objeto o un método, no solo una consecuencia accidental de cómo formula los requisitos.

Sin embargo, no demostrar que, al igual que usted tiene muchas posibilidades de expresar lo mismo en Inglés, tiene muchas posibilidades de expresar la misma cosa en su modelo de dominio ... y ninguna de esas posibilidades es inherentemente más correcto que los otros. Depende del contexto.

Aquí hay un ejemplo que también es muy popular en los cursos introductorios de OO: una cuenta bancaria. Que normalmente se modela como un objeto con un balancecampo y un transfermétodo. En otras palabras: el saldo de la cuenta son datos , y una transferencia es una operación . Cuál es una manera razonable de modelar una cuenta bancaria.

Excepto que no es así como 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 cambio, 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 son 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 sus objetos de cuenta como los objetos de transacción nunca necesitan cambiar, son inmutables).

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

Jörg W Mittag
fuente
Me gustan tus comparaciones de haskell, hace que la respuesta sea un poco más ... fresca. ¿Qué diría sobre los diseños que permiten que un ActionRecordsea ​​un modelo y un solicitante de base de datos? No importa el contexto, parece estar rompiendo el principio de responsabilidad única.
Pierre Arlaud
55
"... porque depende mucho de cómo se formule una oración". ¡Hurra! Ah, y me encanta tu ejemplo bancario. Nunca supe que en los años ochenta ya me habían enseñado programación funcional :) (diseño centrado en datos y almacenamiento independiente del tiempo donde los saldos, etc. son computables y no deben almacenarse, y la dirección de alguien no debe cambiarse, sino almacenarse como un hecho nuevo) ...
Marjan Venema
1
No diría que la metodología Verbo / Sustantivo es "ridícula". Diría que los diseños simplistas fallarán y que es muy difícil hacerlo bien. Porque, por ejemplo, ¿no es una API RESTful una metodología verbo / sustantivo? ¿Dónde, para un grado adicional de dificultad, los verbos son extremadamente limitados?
user949300
@ user949300: El hecho de que el conjunto de verbos sea fijo, evita con precisión el problema que mencioné, a saber, que puedes formular oraciones de múltiples maneras diferentes, haciendo que lo que es un sustantivo y lo que sea un verbo dependa del estilo de escritura y no del análisis de dominio .
Jörg W Mittag
8

Con demasiada frecuencia, el Principio de responsabilidad única se convierte en un Principio de responsabilidad cero, y terminas con Clases anémicas que no hacen nada (excepto los establecedores y captadores), lo que conduce a un desastre del Reino de los Nombres .

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

En su ejemplo con Encoding, IMO, definitivamente debería poder codificar. ¿Qué crees que debería hacer en su lugar? Solo tener un nombre "utf" es cero responsabilidad. Ahora, tal vez el nombre debería ser Encoder. Pero, como dijo Konrad, datos (que codifican) y comportamiento (haciéndolo) pertenecen juntos .

user949300
fuente
7

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

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 (leer, escribir, agregar), la cadena que desea escribir.

Entonces construyes un objeto File con el nombre del archivo (y el método de acceso). El objeto Archivo ahora está cerrado sobre el nombre de archivo (en este caso, probablemente lo habrá tomado como un valor de solo lectura / constante). La instancia de Archivo ahora está lista para atender llamadas al método de "escritura" definido en la clase. Esto solo toma 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).

Por lo tanto, una instancia de la clase File encajona algunos datos en algún tipo de blob compuesto para que el uso posterior de esa instancia sea más simple. Cuando tiene un objeto File, no necesita preocuparse por cuál es el nombre del archivo, solo le preocupa qué cadena pone como argumento en la llamada de escritura. Para reiterar: el objeto File 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 organizan de esta manera: cosas creadas por adelantado, cosas creadas al comienzo de cada iteración de algún tipo de bucle de proceso, luego diferentes cosas declaradas en las dos mitades de un if if, luego cosas creadas en el comienzo 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 un código cada vez más fino, pero cada vez que tenga encajonó los datos en las capas inferiores de la pila en una especie de bonita abstracción que facilita el acceso. Vea lo que está haciendo es usar "OO" como una especie de marco de gestión de cierre para un enfoque de programación funcional.

La solución de acceso directo a algunos problemas implica el estado mutable de los objetos, que no es tan funcional: está manipulando los cierres desde el exterior. Estás haciendo que algunas de las cosas de tu clase sean "globales", al menos según el alcance anterior. Cada vez que escriba un setter, trate 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 remover un poco de estado mutable para resolver ciertos tipos de problemas rápidamente, sin demasiada carga cognitiva, y realmente hacer algo.

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

A menudo, estas encapsulaciones se ven superficialmente como un objeto del mundo real / un objeto en el dominio del problema, pero no se deje engañar. Cuando crea una clase de automóvil, en realidad no es un automóvil, es una recopilación de datos que forma una plataforma en la que puede lograr ciertos tipos de cosas que podría 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 podría no ser la clase correcta, incluso si el dominio del problema se trata de automóviles. A diferencia del Reino de los sustantivos, son las operaciones, los verbos en los que se debe basar una clase.

Benedicto
fuente
4

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

Si bien lo hace, puede que no sea necesariamente un buen código. Más allá del simple hecho de que cualquier tipo de regla que se sigue 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 misma necesita diferencias.

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

Telastyn
fuente
4

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

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

Y debo decir que no tiene mucho sentido para mí que una Encodingo Charsetclase realmente codifique cadenas. Simplemente debería 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 Encodingsolo representara (o identificara) cierta codificación, por ejemplo, UTF-8, entonces obviamente también necesitaría una Encoderclase, para que pueda implementarla GetBytes, pero luego debe administrar la relación entre Encodersy Encodings, por lo que terminar con nuestro buen ol '

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

tan brillantemente ridiculizado en ese artículo al que se vinculó (gran lectura, por cierto).

Si te entiendo bien, me preguntas 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 las HelpfulStuffhojas y yo estoy sentado frente al monitor, no tengo forma de saber dónde GetUTF8Bytesse implementa.

Qué busco en HelpfulStuff, Utils, StringHelper?

Señor, ayúdame si el método se implementa de manera independiente en los tres ... lo que sucede mucho, todo lo que se necesita es que el tipo que estaba ante mí tampoco supiera dónde buscar esa función, y creía que no había ninguna sin embargo, 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

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

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

Así que iría con un enfoque moderado y sería liberal sobre la responsabilidad individual. Prefiero que la Encodingclase identifique un tipo de codificación y realice la codificación real: datos no separados del comportamiento.

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

Tenga en cuenta que esto podría ser cómo se implementan los bytes codificados internamente (en las bibliotecas .NET). Las clases simplemente no son visibles públicamente y solo esta API "fachada" está expuesta en el exterior. No es tan difícil verificar si eso es cierto, soy demasiado flojo para hacerlo ahora :) Probablemente no lo sea, pero eso no invalida mi punto.

Puede optar por simplificar la implementación públicamente visible en aras de la amabilidad mientras mantiene una implementación canónica más severa internamente.

Konrad Morawski
fuente
1

En Java, la abstracción utilizada para representar archivos es una secuencia, algunas secuencias son unidireccionales (solo lectura o escritura solamente) otras son bidireccionales. Para Ruby, la clase File es la abstracción que representa los archivos del sistema operativo. ¿Entonces la pregunta se convierte en cuál es su responsabilidad única? En Java, la responsabilidad de FileWriter es proporcionar un flujo unidireccional de bytes 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 con el SRP, solo tienen diferentes responsabilidades.

No es responsabilidad de un automóvil o de un perro convertirse en una cuerda, ¿verdad?

¿Seguro Por qué no? Espero que la clase Car represente completamente una abstracción Car. Si tiene sentido que la abstracción del automóvil se use donde se necesita una cadena, entonces espero que la clase Car admita la conversión a una cadena.

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

metal de piedra
fuente
0

La clase puede tener múltiples responsabilidades. Al menos en la clásica OOP centrada en datos.

Si está aplicando el principio de responsabilidad única en toda su extensión, obtendrá un diseño centrado en la responsabilidad donde básicamente todos los métodos no auxiliares pertenecen a su propia clase.

Creo que no hay nada de malo en extraer una pieza de 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 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 me parece una exageración. Simplemente obtiene muchas más clases para manejar y para cada operación necesita invocar su clase respectiva. Es la flexibilidad lo que tiene un costo.

Estas clases extraídos deben tener nombres muy descriptivos basados en la operación que contienen, por ejemplo <X>Writer, <X>Reader, <X>Builder. Nombres como <X>Manager, <X>Handlerno describe la operación en absoluto y si se llaman así porque contienen más de una operación, entonces no está claro qué 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 para 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, que es realmente algo 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 nombró a UserDataHandler de esa manera porque básicamente solo hay funcionalidad en esa clase y no hay datos reales. Sin embargo, también puede hacer un resumen de ese hecho y considerar que los datos de la base de datos pertenecen a la clase. Con esta abstracción puede nombrar la clase en función de lo que es y no de 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 .

clima
fuente