Projetando uma classe para ter classes inteiras como parâmetros, em vez de propriedades individuais

30

Digamos, por exemplo, que você tenha um aplicativo com uma classe amplamente compartilhada chamada User. Esta classe expõe todas as informações sobre o usuário, seu ID, nome, níveis de acesso a cada módulo, fuso horário etc.

Os dados do usuário são obviamente amplamente referenciados em todo o sistema, mas por qualquer motivo, o sistema é configurado para que, em vez de passar esse objeto de usuário para classes que dependem dele, apenas passemos propriedades individuais dele.

Uma classe que requer o ID do usuário exigirá simplesmente o GUID userIdcomo parâmetro, às vezes também podemos precisar do nome de usuário, para que seja transmitido como um parâmetro separado. Em alguns casos, isso é passado para métodos individuais, portanto, os valores não são mantidos no nível da classe.

Toda vez que preciso acessar informações diferentes da classe User, preciso fazer alterações adicionando parâmetros e, onde adicionar uma nova sobrecarga não é apropriado, também preciso alterar todas as referências ao método ou construtor de classe.

O usuário é apenas um exemplo. Isso é amplamente praticado em nosso código.

Estou certo ao pensar que isso é uma violação do princípio Aberto / Fechado? Não apenas o ato de alterar as classes existentes, mas configurá-las em primeiro lugar, para que mudanças generalizadas provavelmente sejam necessárias no futuro?

Se apenas passássemos no Userobjeto, eu poderia fazer uma pequena alteração na classe com a qual estou trabalhando. Se eu precisar adicionar um parâmetro, talvez seja necessário fazer dezenas de alterações nas referências à classe.

Existem outros princípios quebrados por essa prática? Inversão de dependência, talvez? Embora não façamos referência a uma abstração, existe apenas um tipo de usuário; portanto, não há necessidade real de ter uma interface de usuário.

Existem outros princípios não SOLID sendo violados, como princípios básicos de programação defensiva?

Meu construtor deve ficar assim:

MyConstructor(GUID userid, String username)

Ou isto:

MyConstructor(User theUser)

Edição da postagem:

Foi sugerido que a pergunta seja respondida em "Pass ID or Object?". Isso não responde à questão de como a decisão de ir de qualquer maneira afeta uma tentativa de seguir os princípios do SOLID, que está no cerne desta questão.

Jimbo
fonte
11
@gnat: Este definitivamente não é um duplicado. A possível duplicata é sobre o encadeamento de métodos para atingir profundamente uma hierarquia de objetos. Esta pergunta não parece estar perguntando sobre isso.
Greg Burghardt
2
A segunda forma é frequentemente usada quando o número de parâmetros passados ​​se torna pesado.
Robert Harvey
12
A única coisa que eu não gosto na primeira assinatura é que não há garantia de que o userId e o nome de usuário sejam realmente originários do mesmo usuário. É um bug em potencial que é evitado ao passar pelo Usuário em qualquer lugar. Mas a decisão realmente depende do que os métodos chamados estão fazendo com os argumentos.
17 de 26
9
A palavra "analisar" não faz sentido no contexto em que você a está usando. Você quis dizer "aprovar"?
Konrad Rudolph
5
E sobre o Iem SOLID? MyConstructorbasicamente diz agora "eu preciso de um Guide um string". Então, por que não ter uma interface fornecendo a Guide a string, vamos Userimplementar essa interface e vamos MyConstructordepender de uma instância implementando essa interface? E se as necessidades de MyConstructormudança, altere a interface. - Isso me ajudou muito a pensar nas interfaces para "pertencer" ao consumidor e não ao provedor . Então pense "como consumidor eu preciso de algo que faça isso e aquilo" em vez de "como provedor, eu posso fazer isso e aquilo".
Corak

Respostas:

31

Não há absolutamente nada de errado em passar um Userobjeto inteiro como parâmetro. De fato, isso pode ajudar a esclarecer seu código e tornar mais óbvio para os programadores o que um método leva se a assinatura do método exigir a User.

Passar tipos de dados simples é bom, até que eles signifiquem algo diferente do que são. Considere este exemplo:

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

E um exemplo de uso:

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

foo.Bar(user.Id);

Você consegue identificar o defeito? O compilador não pode. O "ID do usuário" sendo passado é apenas um número inteiro. Nomeamos a variável, usermas inicializamos seu valor a partir do blogPostRepositoryobjeto, que provavelmente retorna BlogPostobjetos, não Userobjetos - ainda assim, o código é compilado e você acaba com um erro de execução instável.

Agora considere este exemplo alterado:

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

Talvez o Barmétodo use apenas o "ID do usuário", mas a assinatura do método exija um Userobjeto. Agora vamos voltar ao mesmo exemplo de uso de antes, mas altere-o para passar o "usuário" inteiro em:

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

foo.Bar(user);

Agora temos um erro do compilador. O blogPostRepository.Findmétodo retorna um BlogPostobjeto, que habilmente chamamos de "usuário". Em seguida, passamos esse "usuário" para o Barmétodo e obtemos imediatamente um erro do compilador, porque não podemos passar a BlogPostpara um método que aceita a User.

O sistema de tipos do idioma está sendo aproveitado para escrever o código correto mais rapidamente e identificar defeitos no tempo de compilação, em vez do tempo de execução.

Realmente, ter que refatorar muito código, pois as alterações nas informações do usuário são apenas um sintoma de outros problemas. Ao passar um Userobjeto inteiro, você obtém os benefícios acima, além dos benefícios de não precisar refatorar todas as assinaturas de métodos que aceitam informações do usuário quando algo sobre a Userclasse é alterado.

Greg Burghardt
fonte
6
Eu diria que o seu raciocínio por si só aponta para a passagem de campos, mas faz com que os campos sejam invólucros triviais em torno do valor real. Neste exemplo, um usuário possui um campo do tipo UserID e um UserID possui um único campo com valor inteiro. Agora, a declaração de Bar informa imediatamente que Bar não usa todas as informações sobre o Usuário, apenas seu ID, mas você ainda não pode cometer erros tolos como passar um número inteiro que não veio de um UserID para Bar.
26718 Ian
(Cont.) É claro que esse tipo de estilo de programação é bastante tedioso, especialmente em uma linguagem que não possui um bom suporte sintático (Haskell é bom para esse estilo, por exemplo, pois você pode apenas corresponder ao "ID do ID do usuário") .
26418 Ian
5
@Ian: Eu acho que envolver um ID em seu próprio tipo patina em torno da questão original levantada pelo OP, que é uma mudança estrutural na classe User, torna necessário refatorar muitas assinaturas de método. Passar o objeto Usuário inteiro resolve esse problema.
precisa
@Ian: Apesar de ser honesto, mesmo trabalhando em C #, fiquei muito tentado a agrupar IDs e o tipo em um Struct apenas para dar um pouco mais de clareza.
precisa
11
"não há nada errado em passar um ponteiro em seu lugar". Ou uma referência, para evitar todos os problemas com ponteiros que você pode encontrar.
Yay295
17

Estou certo ao pensar que isso é uma violação do princípio Aberto / Fechado?

Não, não é uma violação desse princípio. Esse princípio se preocupa em não mudar Userde maneira que afete outras partes do código que o utiliza. Suas alterações Userpodem ser uma violação, mas não estão relacionadas.

Existem outros princípios quebrados por essa prática? Talvez inversão de dependência?

Não. O que você descreve - injetando apenas as partes necessárias de um objeto de usuário em cada método - é o oposto: é pura inversão de dependência.

Existem outros princípios não SOLID sendo violados, como princípios básicos de programação defensiva?

Não. Essa abordagem é uma maneira perfeitamente válida de codificação. Não está violando esses princípios.

Mas a inversão de dependência é apenas um princípio; não é uma lei inquebrável. E o DI puro pode adicionar complexidade ao sistema. Se você achar que apenas a injeção dos valores necessários do usuário nos métodos, em vez de passar todo o objeto do usuário para o método ou construtor, cria problemas, não faça dessa maneira. Trata-se de obter um equilíbrio entre princípios e pragmatismo.

Para endereçar seu comentário:

Há problemas em ter que analisar desnecessariamente um novo valor em cinco níveis da cadeia e alterar todas as referências a todos os cinco desses métodos existentes ...

Parte da questão aqui é que você claramente não gosta dessa abordagem, conforme o comentário "desnecessariamente [passe] ...". E isso é justo o suficiente; não há resposta certa aqui. Se você acha pesado, não faça dessa maneira.

No entanto, com relação ao princípio de aberto / fechado, se você seguir estritamente isso, "... altere todas as referências a todos os cinco desses métodos existentes ..." é uma indicação de que esses métodos foram modificados, quando deveriam ser fechado para modificação. Na realidade, porém, o princípio aberto / fechado faz sentido para APIs públicas, mas não faz muito sentido para as partes internas de um aplicativo.

... mas certamente um plano para cumprir esse princípio, na medida do possível, incluiria estratégias para reduzir a necessidade de mudanças futuras?

Mas então você entra no território YAGNI e ainda seria ortogonal ao princípio. Se você possui um método Fooque usa um nome de usuário e também deseja Foouma data de nascimento, seguindo o princípio, você adiciona um novo método; Foopermanece inalterado. Novamente, é uma boa prática para APIs públicas, mas é um absurdo para código interno.

Como mencionado anteriormente, trata-se de equilíbrio e bom senso para qualquer situação. Se esses parâmetros frequentemente mudam, sim, use Userdiretamente. Isso o salvará das alterações em larga escala que você descreve. Mas se eles não mudam frequentemente, passar apenas o necessário também é uma boa abordagem.

David Arno
fonte
Há problemas em ter que analisar desnecessariamente um novo valor em cinco níveis da cadeia e alterar todas as referências para todos os cinco desses métodos existentes. Por que o princípio Aberto / Fechado se aplica apenas à classe Usuário e não também à classe que estou editando atualmente, que também é consumida por outras classes? Estou ciente de que o princípio é especificamente sobre evitar mudanças, mas certamente um plano para cumpri-lo na medida do possível é incluir estratégias para reduzir a necessidade de mudanças futuras?
Jimbo
@ Jimbo, atualizei minha resposta para tentar resolver seu comentário.
David Arno
Agradeço sua contribuição. Entre. Nem Robert C Martin aceita que o princípio Aberto / Fechado tenha uma regra rígida. É uma regra de ouro que inevitavelmente será quebrada. A aplicação do princípio é um exercício para tentar cumpri-lo o máximo possível. Por isso, usei a palavra "praticável" anteriormente.
Jimbo
Não é inversão de dependência passar os parâmetros do Usuário em vez do próprio Usuário.
James Ellis-Jones
@ JamesEllis-Jones, a inversão de dependência inverte as dependências de "ask", para "tell". Se você passar em uma Userinstância e depois consultar esse objeto para obter um parâmetro, estará invertendo apenas parcialmente as dependências; ainda há algumas perguntas acontecendo. A inversão de dependência verdadeira é 100% "diga, não pergunte". Mas tem um preço de complexidade.
David Arno
10

Sim, alterar uma função existente é uma violação do Princípio Aberto / Fechado. Você está modificando algo que deve ser fechado para modificação devido a alterações nos requisitos. Um design melhor (para não mudar quando os requisitos mudam) seria passar o Usuário para as coisas que deveriam funcionar nos usuários.

Mas isso pode estar em conflito com o Princípio de Segregação de Interface, já que você pode estar transmitindo muito mais informações do que a função precisa para fazer seu trabalho.

Então, como na maioria das coisas - depende .

Usando apenas um nome de usuário, a função é mais flexível, trabalhando com nomes de usuários, independentemente de onde eles vêm e sem a necessidade de criar um objeto Usuário totalmente funcional. Ele fornece resiliência à mudança se você acha que a fonte de dados mudará.

O uso de todo o usuário torna mais claro o uso e faz um contrato mais firme com os chamadores. Ele fornece resiliência à mudança se você achar que mais do usuário será necessário.

Telastyn
fonte
+1, mas não tenho certeza da sua frase "você pode transmitir mais informações". Quando você passa (Usuário, usuário), passa o mínimo de informações, uma referência a um objeto. É verdade que a referência pode ser usada para obter mais informações, mas significa que o código de chamada não precisa obtê-lo. Quando você passa (GUID userid, string username), o método chamado sempre pode chamar User.find (userid) para encontrar a interface pública do objeto, para que você realmente não oculte nada.
dcorking
5
@dcorking, " Ao passar (usuário do usuário), você passa o mínimo de informações, uma referência a um objeto ". Você passa o máximo de informações relacionadas a esse objeto: o objeto inteiro. " o método chamado sempre pode chamar User.find (userid) ...". Em um sistema bem projetado, isso não seria possível, pois o método em questão não teria acesso User.find(). De fato, nem deveria haver um User.find. Encontrar um usuário nunca deve ser de responsabilidade de User.
David Arno
2
@dcorking - enh. O fato de você estar passando uma referência pequena é uma coincidência técnica. Você está acoplando o todo Userà função. Talvez isso faça sentido. Mas talvez a função deva se importar apenas com o nome do usuário - e transmitir informações como a data de ingresso do usuário ou endereço incorreto.
Telastyn
@DavidArno talvez seja a chave para uma resposta clara ao OP. De quem é a responsabilidade de encontrar um usuário? Existe um nome para o princípio de design de separar o localizador / fábrica da classe?
dcorking
11
@dcorking Eu diria que essa é uma implicação do Princípio da Responsabilidade Única. Saber onde os usuários estão armazenados e como recuperá-los por ID são responsabilidades separadas que uma Userclasse-não deve ter. Pode haver um UserRepositoryou similar que lide com essas coisas.
Hulk #
3

Esse design segue o padrão de objeto de parâmetro . Resolve problemas que surgem com muitos parâmetros na assinatura do método.

Estou certo ao pensar que isso é uma violação do princípio Aberto / Fechado?

Não. A aplicação desse padrão habilita o princípio de abrir / fechar (OCP). Por exemplo, classes derivadas de Userpodem ser fornecidas como parâmetro que induzem um comportamento diferente na classe consumidora.

Existem outros princípios quebrados por essa prática?

Isso pode acontecer. Deixe-me explicar com base nos princípios do SOLID.

O princípio de responsabilidade única (SRP) pode ser violado se tiver o design conforme explicado:

Esta classe expõe todas as informações sobre o usuário, seu ID, nome, níveis de acesso a cada módulo, fuso horário etc.

O problema está com todas as informações . Se a Userclasse tiver muitas propriedades, ela se tornará um enorme objeto de transferência de dados que transporta informações não relacionadas da perspectiva das classes consumidoras. Exemplo: Do ponto de vista de uma classe consumir UserAuthenticationa propriedade User.Ide User.Namesão relevantes, mas não User.Timezone.

O princípio de segregação de interface (ISP) também é violado com um raciocínio semelhante, mas adiciona outra perspectiva. Exemplo: Suponha que uma classe consumidora UserManagementexija que a propriedade User.Nameseja dividida User.LastNamee User.FirstNamea classe UserAuthenticationtambém seja modificada para isso.

Felizmente, o ISP também oferece uma saída possível para o problema: geralmente esses objetos de parâmetro ou objetos de transporte de dados começam pequenos e crescem com o tempo. Se isso se tornar pesado, considere a seguinte abordagem: Introduza interfaces personalizadas para as necessidades das classes consumidoras. Exemplo: introduza interfaces e deixe a Userclasse derivar dela:

class User : IUserAuthenticationInfo, IUserLocationInfo { ... }

Cada interface deve expor um subconjunto de propriedades relacionadas da Userclasse necessárias para que uma classe consumidora cumpra sua operação. Procure clusters de propriedades. Tente reutilizar as interfaces. No caso da classe consumidora, UserAuthenticationuse em IUserAuthenticationInfovez de User. Se possível, divida a Userclasse em várias classes concretas usando as interfaces como "estêncil".

Theo Lenndorff
fonte
11
Depois que o Usuário fica complicado, há uma explosão combinatória de possíveis subinterfaces, por exemplo, se o Usuário tiver apenas 3 propriedades, haverá 7 combinações possíveis. Sua proposta parece legal, mas é impraticável.
user949300
1. Analiticamente, você está certo. No entanto, dependendo de como o domínio é modelado, bits de informações relacionadas tendem a se agrupar. Portanto, praticamente não é necessário lidar com todas as combinações possíveis de interfaces e propriedades. 2. A abordagem descrita não pretendia ser uma solução universal, mas talvez eu devesse adicionar um pouco mais de 'possível' e 'possível' à resposta.
Theo Lenndorff
2

Quando confrontado com esse problema no meu próprio código, concluí que as classes / objetos básicos do modelo são a resposta.

Um exemplo comum seria o padrão do repositório. Freqüentemente, ao consultar um banco de dados por meio de repositórios, muitos dos métodos no repositório usam muitos dos mesmos parâmetros.

Minhas regras práticas para repositórios são:

  • Onde mais de um método usa os mesmos 2 ou mais parâmetros, os parâmetros devem ser agrupados como um objeto de modelo.

  • Onde um método usa mais de 2 parâmetros, os parâmetros devem ser agrupados como um objeto de modelo.

  • Os modelos podem herdar de uma base comum, mas apenas quando realmente faz sentido (geralmente é melhor refatorar mais tarde do que começar com a herança em mente).


Os problemas com o uso de modelos de outras camadas / áreas não se tornam aparentes até que o projeto comece a se tornar um pouco complexo. É só então que você descobre que menos código cria mais trabalho ou mais complicações.

E sim, é totalmente bom ter 2 modelos diferentes com propriedades idênticas que atendem a diferentes camadas / finalidades (por exemplo, ViewModels vs POCOs).

Dom
fonte
2

Vamos apenas verificar os aspectos individuais do SOLID:

  • Responsabilidade única: possivelmente violada se as pessoas tenderem a passar apenas por seções da classe.
  • Aberto / fechado: Irrelevante para onde as seções da classe são passadas, somente onde o objeto completo é passado. (Acho que é aí que a dissonância cognitiva entra em ação: você precisa alterar um código distante, mas a classe em si parece bem.)
  • Substituição de Liskov: Sem problemas, não estamos fazendo subclasses.
  • Inversão de dependência (depende de abstrações, não de dados concretos). Sim, isso é violado: as pessoas não têm abstrações, elas retiram elementos concretos da classe e distribuem isso por aí. Eu acho que essa é a questão principal aqui.

Uma coisa que tende a confundir os instintos de design é que a classe é essencialmente para objetos globais e essencialmente somente leitura. Em tal situação, violar abstrações não prejudica muito: apenas ler dados que não são modificados cria um acoplamento bastante fraco; somente quando se torna uma pilha enorme, a dor se torna perceptível.
Para restabelecer os instintos de design, basta supor que o objeto não seja muito global. Que contexto uma função precisaria se o Userobjeto pudesse ser alterado a qualquer momento? Quais componentes do objeto provavelmente seriam mutados juntos? Eles podem ser separados User, seja como um subobjeto referenciado ou como uma interface que exponha apenas uma "fatia" de campos relacionados não seja tão importante.

Outro princípio: Userobserve as funções que usam partes de e veja quais campos (atributos) tendem a andar juntos. Essa é uma boa lista preliminar de subobjetos - você definitivamente precisa pensar se eles realmente pertencem um ao outro.

É muito trabalhoso, e é um pouco difícil de fazer, e seu código se tornará um pouco menos flexível, pois ficará um pouco mais difícil identificar o subobjeto (subinterface) que precisa ser passado para uma função, principalmente se os subobjetos tiverem sobreposições.

A divisão Userficará realmente feia se os subobjetos se sobreporem, então as pessoas ficarão confusas sobre qual escolher se todos os campos obrigatórios são da sobreposição. Se você se separar hierarquicamente (por exemplo, você tem UserMarketSegmentqual, entre outras coisas, possui UserLocation), as pessoas não têm certeza de qual nível a função que estão escrevendo: está lidando com dados do usuário no Location nível ou no MarketSegmentnível? Isso não ajuda exatamente o fato de que isso pode mudar com o tempo, ou seja, você está novamente alterando as assinaturas das funções, às vezes em toda uma cadeia de chamadas.

Em outras palavras: a menos que você realmente conheça seu domínio e tenha uma idéia bastante clara de qual módulo está lidando com quais aspectos User, não vale a pena melhorar a estrutura do programa.

Toolforger
fonte
1

Esta é uma pergunta realmente interessante. Depende.

Se você acha que seu método pode mudar internamente no futuro para exigir parâmetros diferentes do objeto Usuário, certamente deve passar a coisa toda. A vantagem é que o código externo ao método é protegido contra alterações no método em termos de quais parâmetros ele está usando, o que, como você diz, causaria uma cascata de alterações externamente. Assim, a passagem de todo o usuário aumenta o encapsulamento.

Se você tiver certeza de que nunca precisará usar nada além de dizer o e-mail do usuário, deve passar isso. A vantagem disso é que você pode usar o método em uma ampla variedade de contextos: você pode usá-lo por exemplo com o email de uma empresa ou com um email digitado por alguém. Isso aumenta a flexibilidade.

Isso faz parte de uma gama mais ampla de perguntas sobre a criação de classes para ter um escopo amplo ou estreito, incluindo injetar dependências ou não e ter objetos disponíveis globalmente. No momento, há uma tendência infeliz de pensar que um escopo mais restrito é sempre bom. No entanto, sempre há uma troca entre encapsulamento e flexibilidade, como neste caso.

James Ellis-Jones
fonte
1

Acho melhor passar o menor número possível de parâmetros e quantos forem necessários. Isso facilita o teste e não requer a criação de objetos inteiros.

No seu exemplo, se você usará apenas o ID do usuário ou o nome do usuário, é tudo o que deve passar. Se esse padrão se repetir várias vezes e o objeto do usuário real for muito maior, meu conselho é criar uma interface menor para isso. Poderia ser

interface IIdentifieable
{
    Guid ID { get; }
}

ou

interface INameable
{
    string Name { get; }
}

Isso facilita muito o teste com zombaria e você sabe instantaneamente quais valores são realmente usados. Caso contrário, você frequentemente precisará inicializar objetos complexos com muitas outras dependências, embora no final você precise apenas de uma ou duas propriedades.

t3chb0t
fonte
1

Aqui está algo que eu encontrei de tempos em tempos:

  • Um método usa um argumento do tipo User(ou Productqualquer outra coisa) que possui muitas propriedades, mesmo que o método use apenas algumas delas.
  • Por alguma razão, alguma parte do código precisa chamar esse método, mesmo que não tenha um Userobjeto totalmente preenchido . Ele cria uma instância e inicializa apenas as propriedades que o método realmente precisa.
  • Isso acontece várias vezes.
  • Depois de um tempo, quando você encontra um método que possui um Userargumento, encontra-se precisando encontrar chamadas para esse método para descobrir de onde Uservem a origem, para saber quais propriedades são preenchidas. É um usuário "real" com um endereço de email ou foi criado apenas para passar um ID de usuário e algumas permissões?

Se você criar Usere preencher apenas algumas propriedades porque essas são as que o método precisa, o chamador realmente saberá mais sobre o funcionamento interno do método do que deveria.

Pior ainda, quando você tem uma instância de User, precisa saber de onde veio, para saber quais propriedades são preenchidas. Você não quer saber disso.

Com o tempo, quando os desenvolvedores os vêem Usercomo um contêiner para argumentos de método, eles podem começar a adicionar propriedades a ele para cenários de uso único. Agora está ficando feio, porque a classe está ficando cheia de propriedades que quase sempre serão nulas ou padrão.

Essa corrupção não é inevitável, mas acontece repetidamente quando passamos um objeto apenas porque precisamos acessar algumas de suas propriedades. A zona de perigo é a primeira vez que você vê alguém criando uma instância Usere apenas preenchendo algumas propriedades para que elas possam passar para um método. Coloque o pé nele porque é um caminho escuro.

Sempre que possível, defina o exemplo certo para o próximo desenvolvedor passando apenas o que você precisa.

Scott Hannen
fonte