Diseñar una clase para tomar clases completas como parámetros en lugar de propiedades individuales

30

Digamos, por ejemplo, que tiene una aplicación con una clase ampliamente compartida llamada User. Esta clase expone toda la información sobre el usuario, su ID, nombre, niveles de acceso a cada módulo, zona horaria, etc.

Obviamente, los datos de usuario están ampliamente referenciados en todo el sistema, pero por cualquier razón, el sistema está configurado de modo que en lugar de pasar este objeto de usuario a clases que dependen de él, solo estamos pasando propiedades individuales de él.

Una clase que requiere la identificación del usuario, simplemente requerirá el GUID userIdcomo parámetro, a veces también podríamos necesitar el nombre de usuario, por lo que se pasa como un parámetro separado. En algunos casos, esto se pasa a métodos individuales, por lo que los valores no se mantienen a nivel de clase.

Cada vez que necesito acceso a una información diferente de la clase Usuario, tengo que hacer cambios agregando parámetros y cuando agregar una nueva sobrecarga no es apropiado, también tengo que cambiar cada referencia al método o al constructor de la clase.

El usuario es solo un ejemplo. Esto se practica ampliamente en nuestro código.

¿Estoy en lo cierto al pensar que esto es una violación del principio Abierto / Cerrado? ¿No solo el acto de cambiar las clases existentes, sino establecerlas en primer lugar para que sea muy probable que se requieran cambios generalizados en el futuro?

Si acabamos de pasar el Userobjeto, podría hacer un pequeño cambio en la clase con la que estoy trabajando. Si tengo que agregar un parámetro, podría tener que hacer docenas de cambios en las referencias a la clase.

¿Hay otros principios quebrantados por esta práctica? ¿Inversión de dependencia quizás? Aunque no estamos haciendo referencia a una abstracción, solo hay un tipo de usuario, por lo que no hay ninguna necesidad real de tener una interfaz de usuario.

¿Se están violando otros principios no SÓLIDOS, como los principios básicos de programación defensiva?

Mi constructor debería verse así:

MyConstructor(GUID userid, String username)

O esto:

MyConstructor(User theUser)

Publicado:

Se ha sugerido que la pregunta se responda en "¿Id. De paso u objeto?". Esto no responde a la pregunta de cómo la decisión de ir de cualquier manera afecta un intento de seguir los principios SÓLIDOS, que es el núcleo de esta pregunta.

Jimbo
fuente
11
@gnat: Esto definitivamente no es un duplicado. El posible duplicado es sobre el método de encadenamiento para llegar a una jerarquía de objetos profunda. Esta pregunta no parece estar preguntando sobre eso en absoluto.
Greg Burghardt
2
La segunda forma se usa a menudo cuando el número de parámetros que se pasan se ha vuelto difícil de manejar.
Robert Harvey
12
Lo único que no me gusta de la primera firma es que no hay garantía de que el ID de usuario y el nombre de usuario realmente se originen en el mismo usuario. Es un error potencial que se evita al pasar el Usuario a todas partes. Pero la decisión realmente depende de lo que estén haciendo los métodos llamados con los argumentos.
17 de 26
99
La palabra "analizar" no tiene sentido en el contexto en el que la está utilizando. ¿Quiso decir "pasar" en su lugar?
Konrad Rudolph
55
¿Qué pasa con el Ien SOLID? MyConstructorBásicamente dice ahora "Necesito una Guidy una string". Entonces, ¿por qué no tener una interfaz que proporcione ay Guida string, dejar Userque implemente esa interfaz y MyConstructordepender de una instancia que implemente esa interfaz? Y si las necesidades de MyConstructorcambio, cambiar la interfaz. - Me ayudó mucho pensar en interfaces para "pertenecer" al consumidor en lugar del proveedor . Así que piense "como consumidor , necesito algo que haga esto y aquello" en lugar de "como proveedor puedo hacer esto y aquello".
Corak

Respuestas:

31

No hay absolutamente nada de malo en pasar un Userobjeto completo como parámetro. De hecho, podría ayudar a aclarar su código y hacer que sea más obvio para los programadores qué toma un método si la firma del método requiere a User.

Pasar tipos de datos simples es bueno, hasta que significan algo diferente de lo que son. Considere este ejemplo:

public class Foo
{
    public void Bar(int userId)
    {
        // ...
    }
}

Y un ejemplo de uso:

var user = blogPostRepository.Find(32);
var foo = new Foo();

foo.Bar(user.Id);

¿Puedes detectar el defecto? El compilador no puede. El "ID de usuario" que se pasa es solo un número entero. Nombramos la variable userpero inicializamos su valor del blogPostRepositoryobjeto, que presumiblemente devuelve BlogPostobjetos, no Userobjetos; sin embargo, el código se compila y termina con un error de tiempo de ejecución inestable.

Ahora considere este ejemplo alterado:

public class Foo
{
    public void Bar(User user)
    {
        // ...
    }
}

Quizás el Barmétodo solo usa el "Id. De usuario" pero la firma del método requiere un Userobjeto. Ahora volvamos al mismo ejemplo de uso que antes, pero modifíquelo para pasar todo el "usuario" en:

var user = blogPostRepository.Find(32);
var foo = new Foo();

foo.Bar(user);

Ahora tenemos un error de compilación. El blogPostRepository.Findmétodo devuelve un BlogPostobjeto, que inteligentemente llamamos "usuario". Luego pasamos este "usuario" al Barmétodo y obtenemos rápidamente un error del compilador, porque no podemos pasar BlogPosta un método que acepte un User.

El sistema de tipos del lenguaje se está aprovechando para escribir el código correcto más rápido e identificar defectos en tiempo de compilación, en lugar de tiempo de ejecución.

Realmente, tener que refactorizar mucho código porque los cambios en la información del usuario son simplemente un síntoma de otros problemas. Al pasar un Userobjeto completo , obtiene los beneficios anteriores, además de los beneficios de no tener que refactorizar todas las firmas de métodos que aceptan información del usuario cuando Usercambia algo sobre la clase.

Greg Burghardt
fuente
66
Diría que su razonamiento en sí mismo realmente apunta a pasar campos, pero que los campos sean envoltorios triviales alrededor del valor real. En este ejemplo, un usuario tiene un campo de tipo ID de usuario y un ID de usuario tiene un solo campo con valor entero. Ahora, la declaración de Bar le dice de inmediato que Bar no usa toda la información sobre el Usuario, solo su ID, pero aún así no puede cometer errores tontos como pasar un número entero que no vino de un ID de usuario a Bar.
Ian
(Cont.) Por supuesto, este tipo de estilo de programación es bastante tedioso, especialmente en un lenguaje que no tiene un buen soporte sintáctico (Haskell es bueno para este estilo, por ejemplo, ya que puede coincidir con "ID de ID de usuario") .
Ian
55
@Ian: Creo que envolver un Id en su propio tipo patina alrededor del problema original presentado por el OP, que es cambios estructurales en la clase Usuario, hace que sea necesario refactorizar muchas firmas de métodos. Pasar todo el objeto Usuario resuelve este problema.
Greg Burghardt
@ Ian: Aunque para ser honesto, incluso trabajando en C #, he tenido la tentación de envolver Ids y cosas así en un Struct solo para dar un poco más de claridad.
Greg Burghardt
1
"No hay nada de malo en pasar un puntero en su lugar". O una referencia, para evitar todos los problemas con los punteros con los que podría toparse.
Yay295
17

¿Estoy en lo cierto al pensar que esto es una violación del principio Abierto / Cerrado?

No, no es una violación de ese principio. Ese principio tiene que ver con no cambiar las Userformas que afectan otras partes del código que lo usan. Sus cambios Userpodrían ser una violación, pero no están relacionados.

¿Hay otros principios quebrantados por esta práctica? ¿Perahaps inversión de dependencia?

No. Lo que describe, solo inyectando las partes requeridas de un objeto de usuario en cada método, es lo contrario: es pura inversión de dependencia.

¿Se están violando otros principios no SÓLIDOS, como los principios básicos de programación defensiva?

No. Este enfoque es una forma perfectamente válida de codificación. No está violando tales principios.

Pero la inversión de dependencia es solo un principio; No es una ley inquebrantable. Y la DI pura puede agregar complejidad al sistema. Si encuentra que solo inyectar los valores de usuario necesarios en los métodos, en lugar de pasar todo el objeto de usuario al método o al constructor, crea problemas, entonces no lo haga de esa manera. Se trata de lograr un equilibrio entre los principios y el pragmatismo.

Para abordar su comentario:

Hay problemas con tener que analizar innecesariamente un nuevo valor en cinco niveles de la cadena y luego cambiar todas las referencias a los cinco métodos existentes ...

Parte del problema aquí es que claramente no le gusta este enfoque, según el comentario "innecesariamente [pasar] ...". Y eso es justo; No hay una respuesta correcta aquí. Si lo encuentra pesado, no lo haga de esa manera.

Sin embargo, con respecto al principio abierto / cerrado, si sigue eso estrictamente, entonces "... cambie todas las referencias a los cinco métodos existentes ..." es una indicación de que esos métodos fueron modificados, cuando deberían ser cerrado a modificaciones. Sin embargo, en realidad, el principio de abrir / cerrar tiene sentido para las API públicas, pero no tiene mucho sentido para el funcionamiento interno de una aplicación.

... pero seguramente un plan para cumplir con ese principio en la medida de lo posible incluiría estrategias para reducir la necesidad de un cambio futuro?

Pero luego te paseas por el territorio YAGNI y aún sería ortogonal al principio. Si tiene un método Fooque toma un nombre de usuario y luego desea Footomar una fecha de nacimiento, siguiendo el principio, agregue un nuevo método; Foopermanece sin cambios. Una vez más, es una buena práctica para las API públicas, pero no tiene sentido para el código interno.

Como se mencionó anteriormente, se trata de equilibrio y sentido común para cualquier situación dada. Si esos parámetros a menudo cambian, entonces sí, úselo Userdirectamente. Te salvará de los cambios a gran escala que describas. Pero si a menudo no cambian, entonces pasar solo lo que se necesita también es un buen enfoque.

David Arno
fuente
Hay problemas con tener que analizar innecesariamente un nuevo valor en cinco niveles de la cadena y luego cambiar todas las referencias a los cinco métodos existentes. ¿Por qué el principio Abierto / Cerrado se aplicaría solo a la clase Usuario y no también a la clase que estoy editando actualmente, que también es consumida por otras clases? Soy consciente de que el principio trata específicamente de evitar el cambio, pero ¿seguramente un plan para cumplir ese principio en la medida de lo posible incluiría estrategias para reducir la necesidad de un cambio futuro?
Jimbo
@ Jimbo, he actualizado mi respuesta para tratar de abordar su comentario.
David Arno
Agradezco tu aporte. Por cierto. Ni siquiera Robert C Martin acepta que el principio Abierto / Cerrado tiene una regla difícil. Es una regla general que inevitablemente se romperá. Aplicar el principio es un ejercicio para tratar de cumplirlo tanto como sea posible. Por eso usé la palabra "practicable" anteriormente.
Jimbo
No es inversión de dependencia pasar los parámetros del Usuario en lugar del Usuario mismo.
James Ellis-Jones
@ JamesEllis-Jones, la inversión de dependencia invierte las dependencias de "preguntar" a "contar". Si pasa una Userinstancia y luego consulta ese objeto para obtener un parámetro, entonces solo está invirtiendo parcialmente las dependencias; Todavía hay algunas preguntas en curso. La verdadera inversión de dependencia es 100% "decir, no preguntar". Pero tiene un precio complejo.
David Arno
10

Sí, cambiar una función existente es una violación del Principio Abierto / Cerrado. Estás modificando algo que debería estar cerrado a modificaciones debido a cambios en los requisitos. Un mejor diseño (para no cambiar cuando cambian los requisitos) sería pasar al Usuario a cosas que deberían funcionar en los usuarios.

Pero que podría ir en contra de la interfaz de Segregación principio, ya que podría estar pasando a lo largo de manera más información que las necesidades de función para hacer su trabajo.

Entonces, como con la mayoría de las cosas, depende .

Usando solo un nombre de usuario, la función sea más flexible, trabajando con nombres de usuario independientemente de su procedencia y sin la necesidad de crear un objeto de Usuario que funcione completamente. Proporciona resistencia al cambio si cree que la fuente de datos cambiará.

El uso de todo el usuario lo hace más claro sobre el uso y hace un contrato más firme con sus llamantes. Proporciona resistencia al cambio si cree que se necesitará más usuario.

Telastyn
fuente
+1 pero no estoy seguro acerca de tu frase "podrías estar pasando más información". Cuando pasas (Usuario theuser) pasas el mínimo de información, una referencia a un objeto. Es cierto que la referencia se puede utilizar para obtener más información, pero significa que el código de llamada no tiene que obtenerla. Cuando pasa (GUID userid, string username), el método llamado siempre puede llamar a User.find (userid) para encontrar la interfaz pública del objeto, por lo que realmente no oculta nada.
dcorking
55
@dcorking, " Cuando pasas (Usuario theuser) pasas el mínimo de información, una referencia a un objeto ". Pasa la información máxima relacionada con ese objeto: el objeto completo. " el método llamado siempre puede llamar a User.find (userid) ...". En un sistema bien diseñado, eso no sería posible ya que el método en cuestión no tendría acceso User.find(). De hecho, ni siquiera debería haber un User.find. Encontrar un usuario nunca debe ser responsabilidad de User.
David Arno
2
@dcorking - enh. Que estés pasando una referencia que resulta ser pequeña es una coincidencia técnica. Estás acoplando todo Usera la función. Quizás eso tenga sentido. Pero tal vez la función solo debería preocuparse por el nombre del usuario, y transmitir cosas como la fecha de unión del usuario o la dirección es incorrecta.
Telastyn
@DavidArno quizás esa sea la clave para una respuesta clara a OP. ¿De quién es la responsabilidad de encontrar un usuario? ¿Existe un nombre para el principio de diseño de separar el buscador / fábrica de la clase?
dcorking
1
@dcorking Yo diría que es una implicación del Principio de Responsabilidad Única. Saber dónde están almacenados los Usuarios y cómo recuperarlos por ID son responsabilidades separadas que una Userclase no debería tener. Puede haber una UserRepositoryo similar que se ocupe de tales cosas.
Hulk
3

Este diseño sigue el patrón de objeto de parámetro . Resuelve problemas que surgen de tener muchos parámetros en la firma del método.

¿Estoy en lo cierto al pensar que esto es una violación del principio Abierto / Cerrado?

No. La aplicación de este patrón habilita el principio de Abrir / cerrar (OCP). Por ejemplo, Userse pueden proporcionar clases derivadas de como parámetro que inducen un comportamiento diferente en la clase consumidora.

¿Hay otros principios quebrantados por esta práctica?

Se puede suceder. Permítanme explicar sobre la base de los principios SÓLIDOS.

El principio de responsabilidad única (SRP) se puede violar si tiene el diseño que usted ha explicado:

Esta clase expone toda la información sobre el usuario, su ID, nombre, niveles de acceso a cada módulo, zona horaria, etc.

El problema es con toda la información . Si la Userclase tiene muchas propiedades, se convierte en un gran Objeto de transferencia de datos que transporta información no relacionada desde la perspectiva de las clases consumidoras. Ejemplo: desde la perspectiva de una clase consumidora, UserAuthenticationlas propiedades User.Idy User.Nameson relevantes, pero no lo son User.Timezone.

El principio de segregación de interfaz (ISP) también se viola con un razonamiento similar, pero agrega otra perspectiva. Ejemplo: supongamos que una clase consumidora UserManagementrequiere que la propiedad User.Namese divida User.LastNamey User.FirstNamela clase UserAuthenticationtambién se debe modificar para esto.

Afortunadamente, el ISP también le brinda una posible solución al problema: por lo general, estos Objetos de parámetro u Objetos de transporte de datos comienzan con poco y crecen con el tiempo. Si esto se vuelve difícil de manejar, considere el siguiente enfoque: Introducir interfaces adaptadas a las necesidades de las clases consumidoras. Ejemplo: Introducir interfaces y dejar que la Userclase se derive de ella:

class User : IUserAuthenticationInfo, IUserLocationInfo { ... }

Cada interfaz debe exponer un subconjunto de propiedades relacionadas de la Userclase necesarias para que una clase consumidora complete su operación. Busque grupos de propiedades. Intenta reutilizar las interfaces. En el caso de la clase consumidora, UserAuthenticationuse en IUserAuthenticationInfolugar de User. Luego, si es posible, divida la Userclase en varias clases concretas utilizando las interfaces como "plantilla".

Theo Lenndorff
fuente
1
Una vez que el usuario se complica, hay una explosión combinatoria de posibles subinterfaces, por ejemplo, si el usuario tiene solo 3 propiedades, hay 7 combinaciones posibles. Su propuesta suena bien pero es inviable.
user949300
1. Analíticamente tienes razón. Sin embargo, dependiendo de cómo se modele el dominio, los bits de información relacionada tienden a agruparse. Por lo tanto, prácticamente no es necesario tratar con todas las combinaciones posibles de interfaces y propiedades. 2. El enfoque esbozado no tenía la intención de ser una solución universal, pero tal vez debería agregar más "posible" y "poder" en la respuesta.
Theo Lenndorff
2

Cuando me enfrenté a este problema en mi propio código, llegué a la conclusión de que las clases / objetos modelo básicos son la respuesta.

Un ejemplo común sería el patrón de repositorio. A menudo, cuando se consulta una base de datos a través de repositorios, muchos de los métodos en el repositorio toman muchos de los mismos parámetros.

Mis reglas generales para los repositorios son:

  • Cuando más de un método toma los mismos 2 o más parámetros, los parámetros deben agruparse como un objeto modelo.

  • Cuando un método toma más de 2 parámetros, los parámetros deben agruparse como un objeto modelo.

  • Los modelos pueden heredar de una base común, pero solo cuando realmente tiene sentido (generalmente es mejor refactorizar más tarde que comenzar con la herencia en mente).


Los problemas con el uso de modelos de otras capas / áreas no se hacen evidentes hasta que el proyecto comienza a volverse un poco complejo. Es solo entonces cuando encuentra que menos código crea más trabajo o más complicaciones.

Y sí, está totalmente bien tener 2 modelos diferentes con propiedades idénticas que sirven para diferentes capas / propósitos (es decir, ViewModels vs POCO).

Dom
fuente
2

Vamos a ver los aspectos individuales de SOLID:

  • Responsabilidad individual: posiblemente fiolada si las personas tienden a pasar solo secciones de la clase.
  • Abierto / cerrado: irrelevante donde se pasan secciones de la clase, solo donde se pasa el objeto completo. (Creo que ahí es donde entra en juego la disonancia cognitiva: es necesario cambiar el código lejano, pero la clase en sí parece estar bien).
  • Sustitución de Liskov: no es un problema, no estamos haciendo subclases.
  • Inversión de dependencia (depende de abstracciones, no datos concretos). Sí, eso se viola: las personas no tienen abstracciones, sacan elementos concretos de la clase y los transmiten. Creo que ese es el problema principal aquí.

Una cosa que tiende a confundir los instintos de diseño es que la clase es esencialmente para objetos globales y esencialmente de solo lectura. En tal situación, violar las abstracciones no duele mucho: solo leer datos que no se modifican crea un acoplamiento bastante débil; solo cuando se convierte en una enorme pila, el dolor se vuelve notable.
Para restablecer los instintos de diseño, simplemente asuma que el objeto no es muy global. ¿Qué contexto necesitaría una función si el Userobjeto pudiera mutar en cualquier momento? ¿Qué componentes del objeto probablemente mutarían juntos? Estos pueden dividirse User, ya sea como un subobjeto referenciado o como una interfaz que expone solo una "porción" de campos relacionados, no es tan importante.

Otro principio: Userobserve las funciones que usan partes de y vea qué campos (atributos) tienden a ir juntos. Esa es una buena lista preliminar de subobjetos: definitivamente debes pensar si realmente pertenecen juntos.

Es mucho trabajo, y es algo difícil de hacer, y su código se volverá un poco menos flexible porque será un poco más difícil identificar el subobjeto (subinterfaz) que debe pasar a una función, particularmente si los subobjetos tienen superposiciones.

La división Useren realidad se volverá fea si los subobjetos se superponen, entonces las personas se confundirán acerca de cuál elegir si todos los campos requeridos son de la superposición. Si se divide jerárquicamente (por ejemplo, tiene UserMarketSegmentcuál, entre otras cosas, tiene UserLocation), las personas no estarán seguras en qué nivel se encuentra la función que están escribiendo: ¿se trata de datos de usuario en el Location nivel o en elMarketSegment nivel? No ayuda exactamente que esto pueda cambiar con el tiempo, es decir, volver a cambiar las firmas de funciones, a veces en toda una cadena de llamadas.

En otras palabras: a menos que realmente conozca su dominio y tenga una idea bastante clara de qué módulo se ocupa de qué aspectos User, realmente no vale la pena mejorar la estructura del programa.

Toolforger
fuente
1

Esta es una pregunta realmente interesante. Depende

Si cree que su método puede cambiar internamente en el futuro para requerir diferentes parámetros del objeto Usuario, ciertamente debe pasarlo todo. La ventaja es que el código externo al método está protegido de los cambios dentro del método en términos de los parámetros que está utilizando, lo que, como usted dice, causaría una cascada de cambios externos. Entonces, pasar todo el usuario aumenta la encapsulación.

Si está seguro de que nunca necesitará usar nada más que decir el correo electrónico del Usuario, debe pasarlo. La ventaja de esto es que puede usar el método en una gama más amplia de contextos: puede usarlo, por ejemplo. con el correo electrónico de una empresa o con un correo electrónico que alguien acaba de escribir. Esto aumenta la flexibilidad.

Esto es parte de una gama más amplia de preguntas sobre cómo construir clases para tener un alcance amplio o estrecho, incluyendo si se inyectan dependencias o no y si se tienen objetos disponibles globalmente. Hay una desafortunada tendencia en este momento a pensar que un alcance más estrecho siempre es bueno. Sin embargo, siempre hay una compensación entre encapsulación y flexibilidad como en este caso.

James Ellis-Jones
fuente
1

Creo que es mejor pasar la menor cantidad posible de parámetros y la cantidad necesaria. Esto facilita las pruebas y no requiere el encajonamiento de objetos completos.

En su ejemplo, si va a usar solo la identificación de usuario o el nombre de usuario, esto es todo lo que debe pasar. Si este patrón se repite varias veces y el objeto de usuario real es mucho más grande, entonces mi consejo es crear una interfaz más pequeña para eso. Podría ser

interface IIdentifieable
{
    Guid ID { get; }
}

o

interface INameable
{
    string Name { get; }
}

Esto hace que las pruebas con burla sean mucho más fáciles y usted sabe instantáneamente qué valores se usan realmente. De lo contrario, a menudo necesita inicializar objetos complejos con muchas otras dependencias, aunque al final solo necesita una o dos propiedades.

t3chb0t
fuente
1

Aquí hay algo que he encontrado de vez en cuando:

  • Un método toma un argumento de tipo User(o Productlo que sea) que tiene muchas propiedades, aunque el método solo usa algunas de ellas.
  • Por alguna razón, alguna parte del código necesita llamar a ese método a pesar de que no tiene un Userobjeto completamente poblado . Crea una instancia e inicializa solo las propiedades que el método realmente necesita.
  • Esto sucede muchas veces.
  • Después de un tiempo, cuando encuentra un método que tiene un Userargumento, tiene que buscar llamadas a ese método para saber de dónde Userproviene para que sepa qué propiedades están pobladas. ¿Es un usuario "real" con una dirección de correo electrónico, o simplemente se creó para pasar una identificación de usuario y algunos permisos?

Si crea una Usery solo completa algunas propiedades porque esas son las que necesita el método, entonces la persona que llama realmente sabe más sobre el funcionamiento interno del método de lo que debería.

Peor aún, cuando se tiene una instancia deUser , usted tiene que saber de dónde vino para que sepa qué propiedades están pobladas. No quieres tener que saber eso.

Con el tiempo, cuando los desarrolladores ven User como un contenedor para argumentos de métodos, podrían comenzar a agregarle propiedades para escenarios de un solo uso. Ahora se está poniendo feo, porque la clase se está abarrotando con propiedades que casi siempre serán nulas o predeterminadas.

Tal corrupción no es inevitable, pero ocurre una y otra vez cuando pasamos un objeto solo porque necesitamos acceso a algunas de sus propiedades. La zona de peligro es la primera vez que ve a alguien creando una instancia Usery rellenando algunas propiedades para que pueda pasarla a un método. Pon tu pie sobre él porque es un camino oscuro.

Siempre que sea posible, establezca el ejemplo correcto para el próximo desarrollador pasando solo lo que necesita pasar.

Scott Hannen
fuente