Modificar um objeto passado por referência é uma má prática?

12

No passado, eu normalmente fazia a maior parte da minha manipulação de um objeto no método principal que está sendo criado / atualizado, mas me peguei adotando uma abordagem diferente recentemente e estou curioso para saber se é uma prática ruim.

Aqui está um exemplo. Digamos que eu tenha um repositório que aceite uma Userentidade, mas antes de inseri-la, chamamos alguns métodos para garantir que todos os seus campos estejam configurados para o que queremos. Agora, em vez de chamar métodos e definir os valores dos campos no método Insert, chamo uma série de métodos de preparação que modelam o objeto antes de sua inserção.

Método antigo:

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

    context.Users.Add(user);
}

Novos 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;
}

Basicamente, a prática de definir o valor de uma propriedade de outro método é uma prática ruim?

JD Davis
fonte
6
Só assim é dito ... você não passou nada por referência aqui. Passar referências por valor não é a mesma coisa.
cHao
4
@cHao: Essa é uma distinção sem diferença. O comportamento do código é o mesmo, independentemente.
Robert Harvey
2
@JDDavis: Fundamentalmente, a única diferença real entre seus dois exemplos é que você deu um nome significativo ao nome de usuário definido e às ações de senha definidas.
Robert Harvey
1
Todas as suas chamadas são por referência aqui. Você precisa usar um tipo de valor em C # para passar por valor.
Frank Hileman
2
@FrankHileman: Todas as chamadas são por valor aqui. Esse é o padrão em c #. Passar uma referência e passar por referência são bestas diferentes, e a distinção importa. Se userpassado por referência, o código poderia arrancá-lo das mãos do chamador e substituí-lo simplesmente dizendo, digamos user = null;,.
cHao

Respostas:

10

O problema aqui é que um Userpode realmente conter duas coisas diferentes:

  1. Uma entidade de usuário completa, que pode ser transmitida ao seu armazenamento de dados.

  2. O conjunto de elementos de dados necessários ao chamador para iniciar o processo de criação de uma entidade Usuário. O sistema deve adicionar um nome de usuário e senha antes de ser realmente um usuário válido, como no item 1 acima.

Isso inclui uma nuance não documentada no seu modelo de objeto que não é expressa no seu sistema de tipos. Você só precisa "conhecê-lo" como desenvolvedor. Isso não é ótimo e leva a padrões de código estranhos como o que você está encontrando.

Eu sugiro que você precise de duas entidades, por exemplo, uma Userclasse e uma EnrollRequestclasse. O último pode conter tudo o que você precisa saber para criar um usuário. Seria parecido com sua classe de usuário, mas sem o nome de usuário e a senha. Então você pode fazer isso:

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;
}

O chamador começa apenas com as informações de inscrição e recupera o usuário completo depois que ele é inserido. Dessa forma, você evita a mutação de qualquer classe e também tem uma distinção de tipo seguro entre um usuário inserido e um que não é.

John Wu
fonte
2
Um método com um nome como InsertUserprovavelmente terá o efeito colateral da inserção. Não tenho certeza do que "nojento" significa neste contexto. Quanto ao uso de um "usuário" que não foi adicionado, parece que você perdeu o objetivo. Se não foi adicionado, não é um usuário, apenas uma solicitação para criar um usuário. Você é livre para trabalhar com a solicitação, é claro.
John Wu
@candiedorange Esses não são efeitos colaterais, são os efeitos desejados de chamar o método. Se você não deseja adicionar imediatamente, crie outro método que satisfaça o caso de uso. Mas esse não é o caso de uso passado na pergunta; portanto, digamos que a preocupação não é importante.
214 Andy Andy
@candiedorange Se o acoplamento facilitar o código do cliente, eu diria que está bem. O que os devs ou pos podem pensar no futuro é irrelevante. Se chegar essa hora, você altera o código. Nada é mais inútil do que tentar criar código que possa lidar com qualquer possível caso de uso futuro.
213 Andy
5
@CandiedOrange, sugiro que você tente não associar firmemente o sistema de tipos precisamente definido da implementação às entidades conceituais e em inglês puro do negócio. Um conceito comercial de "usuário" certamente pode ser implementado em duas classes, se não mais, para representar variantes funcionalmente significativas. Um usuário que não persiste não tem um nome de usuário exclusivo garantido, não possui uma chave primária à qual as transações possam ser vinculadas e nem pode se conectar, portanto, eu diria que ela compreende uma variante significativa. Para um leigo, é claro, tanto o EnrollRequest quanto o Usuário são "usuários", em sua linguagem vaga.
John Wu
5

Os efeitos colaterais são aceitáveis, desde que não sejam inesperados. Portanto, não há nada errado em geral quando um repositório possui um método para aceitar um usuário e altera o estado interno do usuário. Mas IMHO, como um nome de método InsertUser , não comunica isso claramente , e é isso que o torna propenso a erros. Ao usar seu repo, eu esperaria uma ligação como

 repo.InsertUser(user);

para alterar o estado interno dos repositórios, não o estado do objeto do usuário. Esse problema existe em ambas as implementações, como InsertUserisso internamente é completamente irrelevante.

Para resolver isso, você pode

  • separar a inicialização da inserção (para que o chamador InsertUserprecise fornecer um Userobjeto totalmente inicializado ou,

  • crie a inicialização no processo de construção do objeto de usuário (conforme sugerido por algumas das outras respostas) ou

  • tente simplesmente encontrar um nome melhor para o método que expressa mais claramente o que ele faz.

Portanto, escolha um nome de método como PrepareAndInsertUser, OrchestrateUserInsertion, InsertUserWithNewNameAndPasswordou o que você preferir fazer o efeito colateral mais clara.

Obviamente, um nome de método tão longo indica que o método talvez esteja fazendo "muito" (violação do SRP), mas às vezes você não quer ou não pode consertar isso facilmente, e essa é a solução menos intrusiva e pragmática.

Doc Brown
fonte
3

Estou analisando as duas opções que você escolheu e devo dizer que prefiro o método antigo em vez do novo método proposto. Existem algumas razões para isso, mesmo que eles façam a mesma coisa.

Nos dois casos, você está definindo o user.UserNamee user.Password. Tenho reservas sobre o item de senha, mas essas reservas não são pertinentes ao tópico em questão.

Implicações da modificação de objetos de referência

  • Isso dificulta a programação simultânea - mas nem todos os aplicativos são multithread
  • Essas modificações podem ser surpreendentes, principalmente se nada sobre o método sugerir que isso aconteceria
  • Essas surpresas podem dificultar a manutenção

Método Antigo vs. Novo Método

O método antigo tornou as coisas mais fáceis de testar:

  • GenerateUserName()é testável independentemente. Você pode escrever testes nesse método e garantir que os nomes sejam gerados corretamente
  • Se o nome exigir informações do objeto de usuário, você poderá alterar a assinatura GenerateUserName(User user)e manter essa testabilidade

O novo método oculta as mutações:

  • Você não sabe que o Userobjeto está mudando até que você tenha 2 camadas de profundidade
  • As mudanças no Userobjeto são mais surpreendentes nesse caso
  • SetUserName()faz mais do que definir um nome de usuário. Isso não é verdade na publicidade, o que torna mais difícil para os novos desenvolvedores descobrirem como as coisas funcionam no seu aplicativo
Berin Loritsch
fonte
1

Concordo plenamente com a resposta de John Wu. Sua sugestão é boa. Mas falta um pouco a sua pergunta direta.

Basicamente, a prática de definir o valor de uma propriedade de outro método é uma prática ruim?

Não inerentemente.

Você não pode levar isso muito longe, pois terá um comportamento inesperado. Por exemplo PrintName(myPerson), não deve alterar o objeto person, pois o método implica que ele esteja interessado apenas na leitura dos valores existentes. Mas esse é um argumento diferente do seu caso, pois SetUsername(user)implica fortemente que ele definirá valores.


Essa é realmente uma abordagem que costumo usar para testes de unidade / integração, onde crio um método especificamente para alterar um objeto, a fim de definir seus valores para uma situação específica que quero testar.

Por exemplo:

var myContract = CreateEmptyContract();

ArrrangeContractDeletedStatus(myContract);

Eu espero explicitamente que o ArrrangeContractDeletedStatusmétodo altere o estado do myContractobjeto.

O principal benefício é que o método permite testar a exclusão de contratos com diferentes contratos iniciais; por exemplo, um contrato que tenha um longo histórico de status ou um que não tenha um histórico anterior, um contrato que tenha um histórico de status intencionalmente errôneo, um contrato que meu usuário de teste não tem permissão para excluir.

Se eu tivesse mesclado CreateEmptyContracte ArrrangeContractDeletedStatusem um único método; Eu precisaria criar várias variantes desse método para cada contrato diferente que gostaria de testar em um estado excluído.

E enquanto eu poderia fazer algo como:

myContract = ArrrangeContractDeletedStatus(myContract);

Isso é redundante (já que estou alterando o objeto de qualquer maneira) ou agora estou me forçando a criar um clone profundo do myContractobjeto; o que é excessivamente difícil se você deseja cobrir todos os casos (imagine se eu quero propriedades de navegação em vários níveis. Preciso clonar todas elas? Somente a entidade de nível superior? ... Tantas perguntas, tantas expectativas implícitas )

Alterar o objeto é a maneira mais fácil de obter o que quero, sem ter que fazer mais trabalho apenas para evitar não alterar o objeto por uma questão de princípio.


Portanto, a resposta direta à sua pergunta é que não é uma prática inerentemente ruim, desde que você não ofusque que o método possa alterar o objeto passado. Para a sua situação atual, isso é esclarecido abundantemente pelo nome do método.

Flater
fonte
0

Acho o antigo e o novo problemático.

À moda antiga:

1) InsertUser(User user)

Ao ler esse nome de método aparecendo no meu IDE, a primeira coisa que me vem à cabeça é

Onde o usuário está inserido?

O nome do método deve ser lido AddUserToContext. Pelo menos, é o que o método faz no final.

2) InsertUser(User user)

Isso viola claramente o princípio da menor surpresa. Digamos, eu fiz meu trabalho até agora e criei uma instância recente de ae Userdei a ele namee defini um password, ficaria surpreso:

a) isso não apenas insere um usuário em algo

b) também subverte minha intenção de inserir o usuário como está; o nome e a senha foram substituídos.

c) isso indica que também é uma violação do princípio da responsabilidade única.

Nova maneira:

1) InsertUser(User user)

Ainda violação do SRP e princípio da menor surpresa

2) SetUsername(User user)

Questão

Definir nome de usuário? Para quê?

Melhor: SetRandomNameou algo que reflete a intenção.

3) SetPassword(User user)

Questão

Configurar senha? Para quê?

Melhor: SetRandomPasswordou algo que reflete a intenção.


Sugestão:

O que eu preferiria ler seria 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);
}

Em relação à sua pergunta inicial:

Modificar um objeto passado por referência é uma má prática?

Não. É mais uma questão de gosto.

Thomas Junk
fonte