¿Es una mala práctica modificar un objeto pasado por referencia?

12

En el pasado, normalmente he realizado la mayor parte de mi manipulación de un objeto dentro del método principal que se está creando / actualizando, pero últimamente me he encontrado con un enfoque diferente, y tengo curiosidad si es una mala práctica.

Aquí hay un ejemplo. Digamos que tengo un repositorio que acepta una Userentidad, pero antes de insertar la entidad, llamamos a algunos métodos para asegurarnos de que todos sus campos estén configurados para lo que queremos. Ahora, en lugar de llamar a métodos y establecer los valores de campo desde el método Insertar, llamo a una serie de métodos de preparación que dan forma al objeto antes de su inserción.

Método antiguo

public void InsertUser(User user) {
    user.Username = GenerateUsername(user);
    user.Password = GeneratePassword(user);

    context.Users.Add(user);
}

Nuevos métodos

public void InsertUser(User user) {
    SetUsername(user);
    SetPassword(user);

    context.Users.Add(user);
}

private void SetUsername(User user) {
    var username = "random business logic";

    user.Username = username;
}

private void SetPassword(User user) {
    var password = "more business logic";

    user.Password = password;
}

Básicamente, ¿la práctica de establecer el valor de una propiedad desde otro método es una mala práctica?

JD Davis
fuente
66
Solo así se dice ... no has pasado nada por referencia aquí. Pasar referencias por valor no es para nada lo mismo.
cHao
44
@ cHao: Esa es una distinción sin diferencia. El comportamiento del código es el mismo independientemente.
Robert Harvey
2
@JDDavis: Fundamentalmente, la única diferencia real entre sus dos ejemplos es que le ha dado un nombre significativo al nombre de usuario establecido y las acciones de contraseña establecida.
Robert Harvey
1
Todas sus llamadas son por referencia aquí. Debe usar un tipo de valor en C # para pasar por valor.
Frank Hileman
2
@FrankHileman: Todas las llamadas son por valor aquí. Ese es el valor predeterminado en C #. Pasar una referencia y pasar por referencia son diferentes bestias, y la distinción sí importa. Si userse pasa por referencia, el código podría dar un tirón hacia fuera de las manos de la persona que llama y reemplazarlo con sólo decir, por ejemplo, user = null;.
cHao

Respuestas:

10

El problema aquí es que a Userpuede contener dos cosas diferentes:

  1. Una entidad de usuario completa, que puede pasar a su almacén de datos.

  2. El conjunto de elementos de datos requeridos por la persona que llama para comenzar el proceso de creación de una entidad de usuario. El sistema debe agregar un nombre de usuario y una contraseña antes de que sea realmente un usuario válido, como se indica en el punto 1 anterior.

Esto comprende un matiz no documentado para su modelo de objetos que no se expresa en su sistema de tipos en absoluto. Solo necesita "saberlo" como desarrollador. Eso no es genial, y conduce a patrones de código extraños como el que estás encontrando.

Te sugiero que necesites dos entidades, por ejemplo, una Userclase y una EnrollRequestclase. Este último puede contener todo lo que necesita saber para crear un usuario. Se vería como su clase de usuario, pero sin el nombre de usuario y la contraseña. Entonces podrías hacer esto:

public User InsertUser(EnrollRequest request) {
    var userName = GenerateUserName();
    var password = GeneratePassword();

    //You might want to replace this with a factory call, but "new" works here as an example
    var newUser = new User
    (
        request.Name, 
        request.Email, 
        userName, 
        password
    );
    context.Users.Add(user);
    return newUser;
}

La persona que llama comienza con solo información de inscripción y recupera al usuario completado después de que se inserta. De esta manera, evitas mutar cualquier clase y también tienes una distinción de tipo seguro entre un usuario que está insertado y uno que no lo está.

John Wu
fuente
2
InsertUserEs probable que un método con un nombre como tenga el efecto secundario de la inserción. No estoy seguro de lo que significa "repulsivo" en este contexto. En cuanto al uso de un "usuario" que no se ha agregado, parece que has perdido el punto. Si no se ha agregado, no es un usuario, solo una solicitud para crear un usuario. Eres libre de trabajar con la solicitud, por supuesto.
John Wu
@candiedorange Esos no son efectos secundarios, son los efectos deseados de llamar al método. Si no desea agregar de inmediato, cree otro método que satisfaga el caso de uso. Pero ese no es el caso de uso aprobado en la pregunta, por lo que se dice que la preocupación no es importante.
Andy
@candiedorange Si el acoplamiento facilita el código del cliente, diría que está bien. Lo que los desarrolladores o pos podrían pensar en el futuro es irrelevante. Si llega ese momento, cambia el código. Nada es más derrochador que intentar crear un código que pueda manejar cualquier posible caso de uso futuro.
Andy
55
@CandiedOrange Le sugiero que intente no acoplar estrechamente el sistema de tipos definido con precisión de la implementación con las entidades conceptuales y simples en inglés de la empresa. Un concepto de negocio de "usuario" ciertamente puede implementarse en dos clases, si no más, para representar variantes funcionalmente significativas. A un usuario que no persiste no se le garantiza un nombre de usuario único, no tiene una clave principal a la que puedan vincularse las transacciones y ni siquiera puede iniciar sesión, por lo que diría que comprende una variante significativa. Para un laico, por supuesto, tanto EnrollRequest como el Usuario son "usuarios", en su lenguaje vago.
John Wu
5

Los efectos secundarios están bien siempre que no sean inesperados. Por lo tanto, no hay nada malo en general cuando un repositorio tiene un método que acepta a un usuario y cambia su estado interno. Pero en mi humilde opinión, un nombre de método como InsertUser no comunica claramente esto , y eso es lo que lo hace propenso a errores. Cuando use su repositorio, esperaría una llamada como

 repo.InsertUser(user);

para cambiar el estado interno de los repositorios, no el estado del objeto del usuario. Este problema existe en ambas implementaciones, ¿cómo InsertUseres que esto es completamente irrelevante?

Para resolver esto, podrías

  • inicialización separada de la inserción (por lo que la persona que llama InsertUsernecesita proporcionar un Userobjeto completamente inicializado , o

  • construir la inicialización en el proceso de construcción del objeto de usuario (como lo sugieren algunas de las otras respuestas), o

  • simplemente trate de encontrar un mejor nombre para el método que exprese más claramente lo que hace.

Así que elija un nombre de método, como PrepareAndInsertUser, OrchestrateUserInsertion, InsertUserWithNewNameAndPasswordo lo que usted prefiere hacer el efecto secundario más clara.

Por supuesto, un nombre de método tan largo indica que el método puede estar haciendo "demasiado" (violación de SRP), pero a veces no quiere o no puede solucionarlo fácilmente, y esta es la solución menos intrusiva y pragmática.

Doc Brown
fuente
3

Estoy mirando las dos opciones que ha elegido, y tengo que decir que, con mucho, prefiero el método anterior en lugar del nuevo método propuesto. Hay algunas razones para ello, aunque fundamentalmente hacen lo mismo.

En cualquier caso, está configurando el user.UserNamey user.Password. Tengo reservas sobre el elemento de contraseña, pero esas reservas no están relacionadas con el tema en cuestión.

Implicaciones de modificar objetos de referencia

  • Hace que la programación concurrente sea más difícil, pero no todas las aplicaciones son multiproceso
  • Esas modificaciones pueden ser sorprendentes, particularmente si nada sobre el método sugiere que sucedería
  • Esas sorpresas pueden dificultar el mantenimiento

Método antiguo versus método nuevo

El viejo método hacía las cosas más fáciles de probar:

  • GenerateUserName()Es independientemente comprobable. Puede escribir pruebas contra ese método y asegurarse de que los nombres se generen correctamente
  • Si el nombre requiere información del objeto de usuario, puede cambiar la firma GenerateUserName(User user)y mantener esa capacidad de prueba

El nuevo método oculta las mutaciones:

  • No sabes que el Userobjeto está cambiando hasta que vas a 2 capas de profundidad
  • Los cambios en el Userobjeto son más sorprendentes en ese caso.
  • SetUserName()hace más que establecer un nombre de usuario. Eso no es verdad en la publicidad, lo que dificulta que los nuevos desarrolladores descubran cómo funcionan las cosas en su aplicación
Berin Loritsch
fuente
1

Estoy totalmente de acuerdo con la respuesta de John Wu. Su sugerencia es buena. Pero extraña un poco tu pregunta directa.

Básicamente, ¿la práctica de establecer el valor de una propiedad desde otro método es una mala práctica?

No inherentemente

No puede llevar esto demasiado lejos, ya que se encontrará con un comportamiento inesperado. Por ejemplo PrintName(myPerson), no debería estar cambiando el objeto persona, ya que el método implica que solo le interesa leer los valores existentes. Pero ese es un argumento diferente al de su caso, ya SetUsername(user)que implica fuertemente que va a establecer valores.


Este es en realidad un enfoque que uso a menudo para las pruebas de unidad / integración, donde creo un método específicamente para alterar un objeto con el fin de establecer sus valores en una situación particular que quiero probar.

Por ejemplo:

var myContract = CreateEmptyContract();

ArrrangeContractDeletedStatus(myContract);

Explícitamente espero que el ArrrangeContractDeletedStatusmétodo cambie el estado del myContractobjeto.

El principal beneficio es que el método me permite probar la eliminación del contrato con diferentes contratos iniciales; por ejemplo, un contrato que tiene un historial de estado largo, o uno que no tiene historial de estado previo, un contrato que tiene un historial de estado intencionalmente erróneo, un contrato que mi usuario de prueba no puede eliminar.

Si me hubiera fusionado CreateEmptyContracty ArrrangeContractDeletedStatusen un solo método; Tendría que crear múltiples variantes de este método para cada contrato diferente que quisiera probar en un estado eliminado.

Y aunque podría hacer algo como:

myContract = ArrrangeContractDeletedStatus(myContract);

Esto es redundante (ya que estoy cambiando el objeto de todos modos), o ahora me estoy obligando a hacer un clon profundo del myContractobjeto; lo cual es excesivamente difícil si quieres cubrir cada caso (imagina si quiero varios niveles de propiedades de navegación. ¿Necesito clonarlos todos? ¿Solo la entidad de nivel superior? ... Tantas preguntas, tantas expectativas implícitas )

Cambiar el objeto es la forma más fácil de obtener lo que quiero sin tener que hacer más trabajo solo para evitar no cambiar el objeto por principio.


Entonces, la respuesta directa a su pregunta es que no es inherentemente una mala práctica, siempre y cuando no ofusque que el método pueda cambiar el objeto pasado. Para su situación actual, eso queda muy claro a través del nombre del método.

Flater
fuente
0

Encuentro lo viejo y lo nuevo problemático.

Vieja forma:

1) InsertUser(User user)

Al leer el nombre de este método que aparece en mi IDE, lo primero que me viene a la mente es

¿Dónde se inserta el usuario?

El nombre del método debería leer AddUserToContext. Al menos, esto es lo que hace el método al final.

2) InsertUser(User user)

Esto viola claramente el principio de la menor sorpresa. Digamos que hice mi trabajo hasta ahora y creé una nueva instancia de a Usery le di una namey passwordpuse una , me sorprendería:

a) esto no solo inserta un usuario en algo

b) también subvierte mi intención de insertar al usuario tal como está; el nombre y la contraseña fueron anulados.

c) ¿indica esto que también es una violación del principio de responsabilidad única?

Nueva manera:

1) InsertUser(User user)

Todavía violación de SRP y principio de la menor sorpresa

2) SetUsername(User user)

Pregunta

Establecer nombre de usuario? ¿A qué?

Mejor: SetRandomNameo algo que refleje la intención.

3) SetPassword(User user)

Pregunta

¿Configurar la clave? ¿A qué?

Mejor: SetRandomPasswordo algo que refleje la intención.


Sugerencia:

Lo que preferiría leer sería algo como:

public User GenerateRandomUser(UserFactory Uf){
    User u = Uf.GetUser();
    u.Name = GenerateRandomUserName();
    u.Password = GenerateRandomUserPassword();
    return u;
}

...

public void AddUserToContext(User u){
    this.context.Users.Add(u);
}

Con respecto a su pregunta inicial:

¿Es una mala práctica modificar un objeto pasado por referencia?

No. Es más una cuestión de gustos.

Thomas Junk
fuente